diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 13fdf8f..0000000 --- a/LICENSE +++ /dev/null @@ -1,675 +0,0 @@ - - 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/README.md b/README.md deleted file mode 100644 index 3a9df24..0000000 --- a/README.md +++ /dev/null @@ -1,110 +0,0 @@ -

- Logo -

- -

- Sparkles - - A hackable shell for Hyprland - - - Powered by Fabric - Sparkles - -

- -
- -[![GitHub stars](https://img.shields.io/github/stars/S4NKALP/Modus?style=for-the-badge&logo=github&color=FFB686&logoColor=D9E0EE&labelColor=292324)](https://github.com/S4NKALP/Modus/stargazers) -[![Hyprland](https://img.shields.io/badge/Made%20for-Hyprland-pink?style=for-the-badge&logo=linux&logoColor=D9E0EE&labelColor=292324&color=C6A0F6)](https://hyprland.org/) -[![Maintained](https://img.shields.io/badge/Maintained-Yes-blue?style=for-the-badge&logo=linux&logoColor=D9E0EE&labelColor=292324&color=3362E1)]() -[![Discord](https://dcbadge.limes.pink/api/server/https://discord.gg/tRFxkbQ3Zq)](https://discord.gg/tRFxkbQ3Zq) - -
- -
- -
-

Home Screen:

- fabric -
-

Lock Screen:

- fabric -
-
- - -## Installation - -> [!CAUTION] -> - You need a working installation of hyprland and knowledge of how it works -> - There may not be all packages in your system install them accordingly - -```bash -git clone https://github.com/S4NKALP/Modus ~/.config/Modus -cd ~/.config/Modus -./install.sh -``` - -> [!TIP] -> ## Post Installation -> - Install recommended [Icon theme](https://github.com/vinceliuice/MacTahoe-icon-theme) , [GTK theme](https://github.com/vinceliuice/MacTahoe-gtk-theme) and [Cursor Theme](https://github.com/vinceliuice/MacTahoe-icon-theme/tree/main/cursors)
-> - Check `config/hypr/modus.conf` edit it according to your device and copy it to your hyprland config -> - For Lock Screen Bind keys to `python lock.py` - -

Rocket Todo

- -## Manual Installation (WIP) - -```bash -paru -S glace-git gtk-session-lock python-pyotp python-pillow python-ijson python-setproctitle apple-fonts cinnamon-desktop --needed -git clone https://github.com/S4NKALP/Modus ~/.config/Modus -cd ~/.config/Modus -python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -pip install --no-deps git+https://github.com/Fabric-Development/fabric.git -``` - -- [x] Launcher -- [x] Lock Screen -- [x] Dock -- [x] Notification -- [x] Control Center -- [x] Music Player -- [x] Desktop Widgets -- [x] New Launcher (like Spotlight) -- [ ] Settings -- [x] ~~Magnifier hover effect on Dock~~ -- [x] ~~New Application Switcher~~ -- [x] Panel Widget -- [x] MacOS like Widget -- [x] Expandable Notification Centre -- [ ] Installation Script -- [ ] Proper Documentation -- [ ] Pomodoro Timer Widget -- [x] To-do List Widget - -## Bug Fixes (the bug found till now) - -- [x] WiFi -- [x] wifi off button looks bigger -- [x] Metadata Changes delay in Media Player -- [x] Active Window Title showing `Unknown` when no active window -- [x] Notification Escape Char Issue - -## Team - -- [SANKALP](https://github.com/S4NKALP/) -- [tr1x_em](https://github.com/tr1xem) - -## Special Thanks - -A big thank you to the following people for their incredible help with code and creative ideas. Your help made a real difference! - -- [darsh](https://github.com/its-darsh): for creating Fabric, which made everything possible. -- [gummy bear album](https://github.com/muhchaudhary): for sharing fantastic code snippets that saved me time and effort. -- [axenide](https://github.com/Axenide): for the amazing config that not only inspired parts of mine but also provided some gems I couldnโ€™t resist borrowing. -- [E3nviction](https://github.com/E3nviction/): for code snippets and ideas that were incredibly helpful. - -I truly appreciate your support diff --git a/assets/screenshots/home.png b/assets/screenshots/home.png deleted file mode 100644 index a98ab7c..0000000 Binary files a/assets/screenshots/home.png and /dev/null differ diff --git a/assets/screenshots/lock.png b/assets/screenshots/lock.png deleted file mode 100644 index 123f456..0000000 Binary files a/assets/screenshots/lock.png and /dev/null differ diff --git a/config/assets/config.json b/config/assets/config.json deleted file mode 100644 index 3750756..0000000 --- a/config/assets/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "wallpapers_dir": "~/Pictures/Wallpapers/", - "dock_position": "Bottom", - "terminal_command": "kitty -e", - "dock_enabled": true, - "dock_auto_hide": true, - "dock_always_occluded": false, - "dock_icon_size": 52, - "window_switcher_items_per_row": 10, - "hide_special_workspace": true, - "dock_hide_special_workspace_apps": true, - "dock_preview_apps": false, - "notification_timeout": "5s", - "notification_ignored_apps": ["Hyprshot"], - "notification_limited_apps_history": ["Spotify"] -} diff --git a/config/assets/emoji.json b/config/assets/emoji.json deleted file mode 100644 index fa174ff..0000000 --- a/config/assets/emoji.json +++ /dev/null @@ -1,15509 +0,0 @@ -{ - "๐Ÿ˜€": { - "name": "grinning face", - "slug": "grinning_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ƒ": { - "name": "grinning face with big eyes", - "slug": "grinning_face_with_big_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜„": { - "name": "grinning face with smiling eyes", - "slug": "grinning_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "beaming face with smiling eyes", - "slug": "beaming_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜†": { - "name": "grinning squinting face", - "slug": "grinning_squinting_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜…": { - "name": "grinning face with sweat", - "slug": "grinning_face_with_sweat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคฃ": { - "name": "rolling on the floor laughing", - "slug": "rolling_on_the_floor_laughing", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ˜‚": { - "name": "face with tears of joy", - "slug": "face_with_tears_of_joy", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™‚": { - "name": "slightly smiling face", - "slug": "slightly_smiling_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ™ƒ": { - "name": "upside-down face", - "slug": "upside_down_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซ ": { - "name": "melting face", - "slug": "melting_face", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ˜‰": { - "name": "winking face", - "slug": "winking_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜Š": { - "name": "smiling face with smiling eyes", - "slug": "smiling_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜‡": { - "name": "smiling face with halo", - "slug": "smiling_face_with_halo", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅฐ": { - "name": "smiling face with hearts", - "slug": "smiling_face_with_hearts", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "smiling face with heart-eyes", - "slug": "smiling_face_with_heart_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคฉ": { - "name": "star-struck", - "slug": "star_struck", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜˜": { - "name": "face blowing a kiss", - "slug": "face_blowing_a_kiss", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜—": { - "name": "kissing face", - "slug": "kissing_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ˜บ๏ธ": { - "name": "smiling face", - "slug": "smiling_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜š": { - "name": "kissing face with closed eyes", - "slug": "kissing_face_with_closed_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜™": { - "name": "kissing face with smiling eyes", - "slug": "kissing_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅฒ": { - "name": "smiling face with tear", - "slug": "smiling_face_with_tear", - "group": "Smileys & Emotion", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ˜‹": { - "name": "face savoring food", - "slug": "face_savoring_food", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜›": { - "name": "face with tongue", - "slug": "face_with_tongue", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜œ": { - "name": "winking face with tongue", - "slug": "winking_face_with_tongue", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคช": { - "name": "zany face", - "slug": "zany_face", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "squinting face with tongue", - "slug": "squinting_face_with_tongue", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค‘": { - "name": "money-mouth face", - "slug": "money_mouth_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿค—": { - "name": "smiling face with open hands", - "slug": "smiling_face_with_open_hands", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿคญ": { - "name": "face with hand over mouth", - "slug": "face_with_hand_over_mouth", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿซข": { - "name": "face with open eyes and hand over mouth", - "slug": "face_with_open_eyes_and_hand_over_mouth", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿซฃ": { - "name": "face with peeking eye", - "slug": "face_with_peeking_eye", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿคซ": { - "name": "shushing face", - "slug": "shushing_face", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿค”": { - "name": "thinking face", - "slug": "thinking_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซก": { - "name": "saluting face", - "slug": "saluting_face", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "zipper-mouth face", - "slug": "zipper_mouth_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿคจ": { - "name": "face with raised eyebrow", - "slug": "face_with_raised_eyebrow", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "neutral face", - "slug": "neutral_face", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ˜‘": { - "name": "expressionless face", - "slug": "expressionless_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ถ": { - "name": "face without mouth", - "slug": "face_without_mouth", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซฅ": { - "name": "dotted line face", - "slug": "dotted_line_face", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ˜ถโ€๐ŸŒซ๏ธ": { - "name": "face in clouds", - "slug": "face_in_clouds", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "smirking face", - "slug": "smirking_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜’": { - "name": "unamused face", - "slug": "unamused_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™„": { - "name": "face with rolling eyes", - "slug": "face_with_rolling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฌ": { - "name": "grimacing face", - "slug": "grimacing_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฎโ€๐Ÿ’จ": { - "name": "face exhaling", - "slug": "face_exhaling", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "๐Ÿคฅ": { - "name": "lying face", - "slug": "lying_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿซจ": { - "name": "shaking face", - "slug": "shaking_face", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ™‚โ€โ†”๏ธ": { - "name": "head shaking horizontally", - "slug": "head_shaking_horizontally", - "group": "Smileys & Emotion", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿ™‚โ€โ†•๏ธ": { - "name": "head shaking vertically", - "slug": "head_shaking_vertically", - "group": "Smileys & Emotion", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿ˜Œ": { - "name": "relieved face", - "slug": "relieved_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜”": { - "name": "pensive face", - "slug": "pensive_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ช": { - "name": "sleepy face", - "slug": "sleepy_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคค": { - "name": "drooling face", - "slug": "drooling_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ˜ด": { - "name": "sleeping face", - "slug": "sleeping_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ท": { - "name": "face with medical mask", - "slug": "face_with_medical_mask", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค’": { - "name": "face with thermometer", - "slug": "face_with_thermometer", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿค•": { - "name": "face with head-bandage", - "slug": "face_with_head_bandage", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿคข": { - "name": "nauseated face", - "slug": "nauseated_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿคฎ": { - "name": "face vomiting", - "slug": "face_vomiting", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿคง": { - "name": "sneezing face", - "slug": "sneezing_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅต": { - "name": "hot face", - "slug": "hot_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅถ": { - "name": "cold face", - "slug": "cold_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅด": { - "name": "woozy face", - "slug": "woozy_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ˜ต": { - "name": "face with crossed-out eyes", - "slug": "face_with_crossed_out_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ตโ€๐Ÿ’ซ": { - "name": "face with spiral eyes", - "slug": "face_with_spiral_eyes", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "๐Ÿคฏ": { - "name": "exploding head", - "slug": "exploding_head", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿค ": { - "name": "cowboy hat face", - "slug": "cowboy_hat_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅณ": { - "name": "partying face", - "slug": "partying_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅธ": { - "name": "disguised face", - "slug": "disguised_face", - "group": "Smileys & Emotion", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ˜Ž": { - "name": "smiling face with sunglasses", - "slug": "smiling_face_with_sunglasses", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿค“": { - "name": "nerd face", - "slug": "nerd_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "face with monocle", - "slug": "face_with_monocle", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜•": { - "name": "confused face", - "slug": "confused_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซค": { - "name": "face with diagonal mouth", - "slug": "face_with_diagonal_mouth", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ˜Ÿ": { - "name": "worried face", - "slug": "worried_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ™": { - "name": "slightly frowning face", - "slug": "slightly_frowning_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ˜น๏ธ": { - "name": "frowning face", - "slug": "frowning_face", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ˜ฎ": { - "name": "face with open mouth", - "slug": "face_with_open_mouth", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฏ": { - "name": "hushed face", - "slug": "hushed_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฒ": { - "name": "astonished face", - "slug": "astonished_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ณ": { - "name": "flushed face", - "slug": "flushed_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅบ": { - "name": "pleading face", - "slug": "pleading_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅน": { - "name": "face holding back tears", - "slug": "face_holding_back_tears", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฆ": { - "name": "frowning face with open mouth", - "slug": "frowning_face_with_open_mouth", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ง": { - "name": "anguished face", - "slug": "anguished_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜จ": { - "name": "fearful face", - "slug": "fearful_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฐ": { - "name": "anxious face with sweat", - "slug": "anxious_face_with_sweat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฅ": { - "name": "sad but relieved face", - "slug": "sad_but_relieved_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ข": { - "name": "crying face", - "slug": "crying_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ญ": { - "name": "loudly crying face", - "slug": "loudly_crying_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฑ": { - "name": "face screaming in fear", - "slug": "face_screaming_in_fear", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜–": { - "name": "confounded face", - "slug": "confounded_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฃ": { - "name": "persevering face", - "slug": "persevering_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ž": { - "name": "disappointed face", - "slug": "disappointed_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜“": { - "name": "downcast face with sweat", - "slug": "downcast_face_with_sweat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฉ": { - "name": "weary face", - "slug": "weary_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ซ": { - "name": "tired face", - "slug": "tired_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅฑ": { - "name": "yawning face", - "slug": "yawning_face", - "group": "Smileys & Emotion", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ˜ค": { - "name": "face with steam from nose", - "slug": "face_with_steam_from_nose", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ก": { - "name": "enraged face", - "slug": "enraged_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ ": { - "name": "angry face", - "slug": "angry_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคฌ": { - "name": "face with symbols on mouth", - "slug": "face_with_symbols_on_mouth", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜ˆ": { - "name": "smiling face with horns", - "slug": "smiling_face_with_horns", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฟ": { - "name": "angry face with horns", - "slug": "angry_face_with_horns", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’€": { - "name": "skull", - "slug": "skull", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜ ๏ธ": { - "name": "skull and crossbones", - "slug": "skull_and_crossbones", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ฉ": { - "name": "pile of poo", - "slug": "pile_of_poo", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคก": { - "name": "clown face", - "slug": "clown_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ‘น": { - "name": "ogre", - "slug": "ogre", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘บ": { - "name": "goblin", - "slug": "goblin", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ป": { - "name": "ghost", - "slug": "ghost", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ฝ": { - "name": "alien", - "slug": "alien", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘พ": { - "name": "alien monster", - "slug": "alien_monster", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค–": { - "name": "robot", - "slug": "robot", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜บ": { - "name": "grinning cat", - "slug": "grinning_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ธ": { - "name": "grinning cat with smiling eyes", - "slug": "grinning_cat_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜น": { - "name": "cat with tears of joy", - "slug": "cat_with_tears_of_joy", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ป": { - "name": "smiling cat with heart-eyes", - "slug": "smiling_cat_with_heart_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ผ": { - "name": "cat with wry smile", - "slug": "cat_with_wry_smile", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฝ": { - "name": "kissing cat", - "slug": "kissing_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™€": { - "name": "weary cat", - "slug": "weary_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฟ": { - "name": "crying cat", - "slug": "crying_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜พ": { - "name": "pouting cat", - "slug": "pouting_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™ˆ": { - "name": "see-no-evil monkey", - "slug": "see_no_evil_monkey", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™‰": { - "name": "hear-no-evil monkey", - "slug": "hear_no_evil_monkey", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™Š": { - "name": "speak-no-evil monkey", - "slug": "speak_no_evil_monkey", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’Œ": { - "name": "love letter", - "slug": "love_letter", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’˜": { - "name": "heart with arrow", - "slug": "heart_with_arrow", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "heart with ribbon", - "slug": "heart_with_ribbon", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’–": { - "name": "sparkling heart", - "slug": "sparkling_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’—": { - "name": "growing heart", - "slug": "growing_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’“": { - "name": "beating heart", - "slug": "beating_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ž": { - "name": "revolving hearts", - "slug": "revolving_hearts", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’•": { - "name": "two hearts", - "slug": "two_hearts", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’Ÿ": { - "name": "heart decoration", - "slug": "heart_decoration", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฃ๏ธ": { - "name": "heart exclamation", - "slug": "heart_exclamation", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’”": { - "name": "broken heart", - "slug": "broken_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โค๏ธโ€๐Ÿ”ฅ": { - "name": "heart on fire", - "slug": "heart_on_fire", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "โค๏ธโ€๐Ÿฉน": { - "name": "mending heart", - "slug": "mending_heart", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "โค๏ธ": { - "name": "red heart", - "slug": "red_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉท": { - "name": "pink heart", - "slug": "pink_heart", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿงก": { - "name": "orange heart", - "slug": "orange_heart", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ’›": { - "name": "yellow heart", - "slug": "yellow_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’š": { - "name": "green heart", - "slug": "green_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’™": { - "name": "blue heart", - "slug": "blue_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉต": { - "name": "light blue heart", - "slug": "light_blue_heart", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ’œ": { - "name": "purple heart", - "slug": "purple_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸคŽ": { - "name": "brown heart", - "slug": "brown_heart", - "group": "Smileys & Emotion", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ–ค": { - "name": "black heart", - "slug": "black_heart", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฉถ": { - "name": "grey heart", - "slug": "grey_heart", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "white heart", - "slug": "white_heart", - "group": "Smileys & Emotion", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ’‹": { - "name": "kiss mark", - "slug": "kiss_mark", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฏ": { - "name": "hundred points", - "slug": "hundred_points", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ข": { - "name": "anger symbol", - "slug": "anger_symbol", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฅ": { - "name": "collision", - "slug": "collision", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ซ": { - "name": "dizzy", - "slug": "dizzy", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฆ": { - "name": "sweat droplets", - "slug": "sweat_droplets", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’จ": { - "name": "dashing away", - "slug": "dashing_away", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ณ๏ธ": { - "name": "hole", - "slug": "hole", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ฌ": { - "name": "speech balloon", - "slug": "speech_balloon", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ": { - "name": "eye in speech bubble", - "slug": "eye_in_speech_bubble", - "group": "Smileys & Emotion", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ—จ๏ธ": { - "name": "left speech bubble", - "slug": "left_speech_bubble", - "group": "Smileys & Emotion", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ—ฏ๏ธ": { - "name": "right anger bubble", - "slug": "right_anger_bubble", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ญ": { - "name": "thought balloon", - "slug": "thought_balloon", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ค": { - "name": "ZZZ", - "slug": "zzz", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘‹": { - "name": "waving hand", - "slug": "waving_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคš": { - "name": "raised back of hand", - "slug": "raised_back_of_hand", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ–๏ธ": { - "name": "hand with fingers splayed", - "slug": "hand_with_fingers_splayed", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โœ‹": { - "name": "raised hand", - "slug": "raised_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ––": { - "name": "vulcan salute", - "slug": "vulcan_salute", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿซฑ": { - "name": "rightwards hand", - "slug": "rightwards_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซฒ": { - "name": "leftwards hand", - "slug": "leftwards_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซณ": { - "name": "palm down hand", - "slug": "palm_down_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซด": { - "name": "palm up hand", - "slug": "palm_up_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซท": { - "name": "leftwards pushing hand", - "slug": "leftwards_pushing_hand", - "group": "People & Body", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.0" - }, - "๐Ÿซธ": { - "name": "rightwards pushing hand", - "slug": "rightwards_pushing_hand", - "group": "People & Body", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.0" - }, - "๐Ÿ‘Œ": { - "name": "OK hand", - "slug": "ok_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐ŸคŒ": { - "name": "pinched fingers", - "slug": "pinched_fingers", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿค": { - "name": "pinching hand", - "slug": "pinching_hand", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "โœŒ๏ธ": { - "name": "victory hand", - "slug": "victory_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคž": { - "name": "crossed fingers", - "slug": "crossed_fingers", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿซฐ": { - "name": "hand with index finger and thumb crossed", - "slug": "hand_with_index_finger_and_thumb_crossed", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐ŸคŸ": { - "name": "love-you gesture", - "slug": "love_you_gesture", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿค˜": { - "name": "sign of the horns", - "slug": "sign_of_the_horns", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿค™": { - "name": "call me hand", - "slug": "call_me_hand", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ‘ˆ": { - "name": "backhand index pointing left", - "slug": "backhand_index_pointing_left", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘‰": { - "name": "backhand index pointing right", - "slug": "backhand_index_pointing_right", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘†": { - "name": "backhand index pointing up", - "slug": "backhand_index_pointing_up", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ–•": { - "name": "middle finger", - "slug": "middle_finger", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘‡": { - "name": "backhand index pointing down", - "slug": "backhand_index_pointing_down", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โ˜๏ธ": { - "name": "index pointing up", - "slug": "index_pointing_up", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿซต": { - "name": "index pointing at the viewer", - "slug": "index_pointing_at_the_viewer", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿ‘": { - "name": "thumbs up", - "slug": "thumbs_up", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘Ž": { - "name": "thumbs down", - "slug": "thumbs_down", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โœŠ": { - "name": "raised fist", - "slug": "raised_fist", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘Š": { - "name": "oncoming fist", - "slug": "oncoming_fist", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿค›": { - "name": "left-facing fist", - "slug": "left_facing_fist", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคœ": { - "name": "right-facing fist", - "slug": "right_facing_fist", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ‘": { - "name": "clapping hands", - "slug": "clapping_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™Œ": { - "name": "raising hands", - "slug": "raising_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿซถ": { - "name": "heart hands", - "slug": "heart_hands", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿ‘": { - "name": "open hands", - "slug": "open_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคฒ": { - "name": "palms up together", - "slug": "palms_up_together", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿค": { - "name": "handshake", - "slug": "handshake", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿ™": { - "name": "folded hands", - "slug": "folded_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โœ๏ธ": { - "name": "writing hand", - "slug": "writing_hand", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’…": { - "name": "nail polish", - "slug": "nail_polish", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคณ": { - "name": "selfie", - "slug": "selfie", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ’ช": { - "name": "flexed biceps", - "slug": "flexed_biceps", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿฆพ": { - "name": "mechanical arm", - "slug": "mechanical_arm", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆฟ": { - "name": "mechanical leg", - "slug": "mechanical_leg", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆต": { - "name": "leg", - "slug": "leg", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆถ": { - "name": "foot", - "slug": "foot", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘‚": { - "name": "ear", - "slug": "ear", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿฆป": { - "name": "ear with hearing aid", - "slug": "ear_with_hearing_aid", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ƒ": { - "name": "nose", - "slug": "nose", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง ": { - "name": "brain", - "slug": "brain", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿซ€": { - "name": "anatomical heart", - "slug": "anatomical_heart", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "lungs", - "slug": "lungs", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆท": { - "name": "tooth", - "slug": "tooth", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆด": { - "name": "bone", - "slug": "bone", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ‘€": { - "name": "eyes", - "slug": "eyes", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘๏ธ": { - "name": "eye", - "slug": "eye", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ‘…": { - "name": "tongue", - "slug": "tongue", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘„": { - "name": "mouth", - "slug": "mouth", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซฆ": { - "name": "biting lip", - "slug": "biting_lip", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ‘ถ": { - "name": "baby", - "slug": "baby", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง’": { - "name": "child", - "slug": "child", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ‘ฆ": { - "name": "boy", - "slug": "boy", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ง": { - "name": "girl", - "slug": "girl", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง‘": { - "name": "person", - "slug": "person", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ‘ฑ": { - "name": "person blond hair", - "slug": "person_blond_hair", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘จ": { - "name": "man", - "slug": "man", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง”": { - "name": "person beard", - "slug": "person_beard", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง”โ€โ™‚๏ธ": { - "name": "man beard", - "slug": "man_beard", - "group": "People & Body", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿง”โ€โ™€๏ธ": { - "name": "woman beard", - "slug": "woman_beard", - "group": "People & Body", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘จโ€๐Ÿฆฐ": { - "name": "man red hair", - "slug": "man_red_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘จโ€๐Ÿฆฑ": { - "name": "man curly hair", - "slug": "man_curly_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘จโ€๐Ÿฆณ": { - "name": "man white hair", - "slug": "man_white_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘จโ€๐Ÿฆฒ": { - "name": "man bald", - "slug": "man_bald", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘ฉ": { - "name": "woman", - "slug": "woman", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฐ": { - "name": "woman red hair", - "slug": "woman_red_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง‘โ€๐Ÿฆฐ": { - "name": "person red hair", - "slug": "person_red_hair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฑ": { - "name": "woman curly hair", - "slug": "woman_curly_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง‘โ€๐Ÿฆฑ": { - "name": "person curly hair", - "slug": "person_curly_hair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆณ": { - "name": "woman white hair", - "slug": "woman_white_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง‘โ€๐Ÿฆณ": { - "name": "person white hair", - "slug": "person_white_hair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฒ": { - "name": "woman bald", - "slug": "woman_bald", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง‘โ€๐Ÿฆฒ": { - "name": "person bald", - "slug": "person_bald", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘ฑโ€โ™€๏ธ": { - "name": "woman blond hair", - "slug": "woman_blond_hair", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฑโ€โ™‚๏ธ": { - "name": "man blond hair", - "slug": "man_blond_hair", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง“": { - "name": "older person", - "slug": "older_person", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ‘ด": { - "name": "old man", - "slug": "old_man", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ต": { - "name": "old woman", - "slug": "old_woman", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™": { - "name": "person frowning", - "slug": "person_frowning", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™โ€โ™‚๏ธ": { - "name": "man frowning", - "slug": "man_frowning", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™โ€โ™€๏ธ": { - "name": "woman frowning", - "slug": "woman_frowning", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™Ž": { - "name": "person pouting", - "slug": "person_pouting", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™Žโ€โ™‚๏ธ": { - "name": "man pouting", - "slug": "man_pouting", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™Žโ€โ™€๏ธ": { - "name": "woman pouting", - "slug": "woman_pouting", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™…": { - "name": "person gesturing NO", - "slug": "person_gesturing_no", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™…โ€โ™‚๏ธ": { - "name": "man gesturing NO", - "slug": "man_gesturing_no", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™…โ€โ™€๏ธ": { - "name": "woman gesturing NO", - "slug": "woman_gesturing_no", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™†": { - "name": "person gesturing OK", - "slug": "person_gesturing_ok", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™†โ€โ™‚๏ธ": { - "name": "man gesturing OK", - "slug": "man_gesturing_ok", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™†โ€โ™€๏ธ": { - "name": "woman gesturing OK", - "slug": "woman_gesturing_ok", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’": { - "name": "person tipping hand", - "slug": "person_tipping_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’โ€โ™‚๏ธ": { - "name": "man tipping hand", - "slug": "man_tipping_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’โ€โ™€๏ธ": { - "name": "woman tipping hand", - "slug": "woman_tipping_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™‹": { - "name": "person raising hand", - "slug": "person_raising_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™‹โ€โ™‚๏ธ": { - "name": "man raising hand", - "slug": "man_raising_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™‹โ€โ™€๏ธ": { - "name": "woman raising hand", - "slug": "woman_raising_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง": { - "name": "deaf person", - "slug": "deaf_person", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿงโ€โ™‚๏ธ": { - "name": "deaf man", - "slug": "deaf_man", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿงโ€โ™€๏ธ": { - "name": "deaf woman", - "slug": "deaf_woman", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ™‡": { - "name": "person bowing", - "slug": "person_bowing", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™‡โ€โ™‚๏ธ": { - "name": "man bowing", - "slug": "man_bowing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™‡โ€โ™€๏ธ": { - "name": "woman bowing", - "slug": "woman_bowing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคฆ": { - "name": "person facepalming", - "slug": "person_facepalming", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคฆโ€โ™‚๏ธ": { - "name": "man facepalming", - "slug": "man_facepalming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคฆโ€โ™€๏ธ": { - "name": "woman facepalming", - "slug": "woman_facepalming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคท": { - "name": "person shrugging", - "slug": "person_shrugging", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคทโ€โ™‚๏ธ": { - "name": "man shrugging", - "slug": "man_shrugging", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคทโ€โ™€๏ธ": { - "name": "woman shrugging", - "slug": "woman_shrugging", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€โš•๏ธ": { - "name": "health worker", - "slug": "health_worker", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€โš•๏ธ": { - "name": "man health worker", - "slug": "man_health_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€โš•๏ธ": { - "name": "woman health worker", - "slug": "woman_health_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐ŸŽ“": { - "name": "student", - "slug": "student", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐ŸŽ“": { - "name": "man student", - "slug": "man_student", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐ŸŽ“": { - "name": "woman student", - "slug": "woman_student", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿซ": { - "name": "teacher", - "slug": "teacher", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿซ": { - "name": "man teacher", - "slug": "man_teacher", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿซ": { - "name": "woman teacher", - "slug": "woman_teacher", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€โš–๏ธ": { - "name": "judge", - "slug": "judge", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€โš–๏ธ": { - "name": "man judge", - "slug": "man_judge", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€โš–๏ธ": { - "name": "woman judge", - "slug": "woman_judge", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐ŸŒพ": { - "name": "farmer", - "slug": "farmer", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐ŸŒพ": { - "name": "man farmer", - "slug": "man_farmer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐ŸŒพ": { - "name": "woman farmer", - "slug": "woman_farmer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿณ": { - "name": "cook", - "slug": "cook", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿณ": { - "name": "man cook", - "slug": "man_cook", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿณ": { - "name": "woman cook", - "slug": "woman_cook", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿ”ง": { - "name": "mechanic", - "slug": "mechanic", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿ”ง": { - "name": "man mechanic", - "slug": "man_mechanic", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿ”ง": { - "name": "woman mechanic", - "slug": "woman_mechanic", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿญ": { - "name": "factory worker", - "slug": "factory_worker", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿญ": { - "name": "man factory worker", - "slug": "man_factory_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿญ": { - "name": "woman factory worker", - "slug": "woman_factory_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿ’ผ": { - "name": "office worker", - "slug": "office_worker", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿ’ผ": { - "name": "man office worker", - "slug": "man_office_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿ’ผ": { - "name": "woman office worker", - "slug": "woman_office_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿ”ฌ": { - "name": "scientist", - "slug": "scientist", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿ”ฌ": { - "name": "man scientist", - "slug": "man_scientist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿ”ฌ": { - "name": "woman scientist", - "slug": "woman_scientist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿ’ป": { - "name": "technologist", - "slug": "technologist", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿ’ป": { - "name": "man technologist", - "slug": "man_technologist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿ’ป": { - "name": "woman technologist", - "slug": "woman_technologist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐ŸŽค": { - "name": "singer", - "slug": "singer", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐ŸŽค": { - "name": "man singer", - "slug": "man_singer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐ŸŽค": { - "name": "woman singer", - "slug": "woman_singer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐ŸŽจ": { - "name": "artist", - "slug": "artist", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐ŸŽจ": { - "name": "man artist", - "slug": "man_artist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐ŸŽจ": { - "name": "woman artist", - "slug": "woman_artist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€โœˆ๏ธ": { - "name": "pilot", - "slug": "pilot", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€โœˆ๏ธ": { - "name": "man pilot", - "slug": "man_pilot", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€โœˆ๏ธ": { - "name": "woman pilot", - "slug": "woman_pilot", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿš€": { - "name": "astronaut", - "slug": "astronaut", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿš€": { - "name": "man astronaut", - "slug": "man_astronaut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿš€": { - "name": "woman astronaut", - "slug": "woman_astronaut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿš’": { - "name": "firefighter", - "slug": "firefighter", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿš’": { - "name": "man firefighter", - "slug": "man_firefighter", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿš’": { - "name": "woman firefighter", - "slug": "woman_firefighter", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฎ": { - "name": "police officer", - "slug": "police_officer", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ฎโ€โ™‚๏ธ": { - "name": "man police officer", - "slug": "man_police_officer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฎโ€โ™€๏ธ": { - "name": "woman police officer", - "slug": "woman_police_officer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ•ต๏ธ": { - "name": "detective", - "slug": "detective", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "2.0" - }, - "๐Ÿ•ต๏ธโ€โ™‚๏ธ": { - "name": "man detective", - "slug": "man_detective", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ•ต๏ธโ€โ™€๏ธ": { - "name": "woman detective", - "slug": "woman_detective", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’‚": { - "name": "guard", - "slug": "guard", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’‚โ€โ™‚๏ธ": { - "name": "man guard", - "slug": "man_guard", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’‚โ€โ™€๏ธ": { - "name": "woman guard", - "slug": "woman_guard", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿฅท": { - "name": "ninja", - "slug": "ninja", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘ท": { - "name": "construction worker", - "slug": "construction_worker", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ทโ€โ™‚๏ธ": { - "name": "man construction worker", - "slug": "man_construction_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ทโ€โ™€๏ธ": { - "name": "woman construction worker", - "slug": "woman_construction_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿซ…": { - "name": "person with crown", - "slug": "person_with_crown", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿคด": { - "name": "prince", - "slug": "prince", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ‘ธ": { - "name": "princess", - "slug": "princess", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ณ": { - "name": "person wearing turban", - "slug": "person_wearing_turban", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ณโ€โ™‚๏ธ": { - "name": "man wearing turban", - "slug": "man_wearing_turban", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ณโ€โ™€๏ธ": { - "name": "woman wearing turban", - "slug": "woman_wearing_turban", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฒ": { - "name": "person with skullcap", - "slug": "person_with_skullcap", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง•": { - "name": "woman with headscarf", - "slug": "woman_with_headscarf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿคต": { - "name": "person in tuxedo", - "slug": "person_in_tuxedo", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคตโ€โ™‚๏ธ": { - "name": "man in tuxedo", - "slug": "man_in_tuxedo", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿคตโ€โ™€๏ธ": { - "name": "woman in tuxedo", - "slug": "woman_in_tuxedo", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘ฐ": { - "name": "person with veil", - "slug": "person_with_veil", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ฐโ€โ™‚๏ธ": { - "name": "man with veil", - "slug": "man_with_veil", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘ฐโ€โ™€๏ธ": { - "name": "woman with veil", - "slug": "woman_with_veil", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿคฐ": { - "name": "pregnant woman", - "slug": "pregnant_woman", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿซƒ": { - "name": "pregnant man", - "slug": "pregnant_man", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซ„": { - "name": "pregnant person", - "slug": "pregnant_person", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿคฑ": { - "name": "breast-feeding", - "slug": "breast_feeding", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ‘ฉโ€๐Ÿผ": { - "name": "woman feeding baby", - "slug": "woman_feeding_baby", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘จโ€๐Ÿผ": { - "name": "man feeding baby", - "slug": "man_feeding_baby", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿง‘โ€๐Ÿผ": { - "name": "person feeding baby", - "slug": "person_feeding_baby", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘ผ": { - "name": "baby angel", - "slug": "baby_angel", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐ŸŽ…": { - "name": "Santa Claus", - "slug": "santa_claus", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคถ": { - "name": "Mrs. Claus", - "slug": "mrs_claus", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿง‘โ€๐ŸŽ„": { - "name": "mx claus", - "slug": "mx_claus", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿฆธ": { - "name": "superhero", - "slug": "superhero", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆธโ€โ™‚๏ธ": { - "name": "man superhero", - "slug": "man_superhero", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆธโ€โ™€๏ธ": { - "name": "woman superhero", - "slug": "woman_superhero", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆน": { - "name": "supervillain", - "slug": "supervillain", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆนโ€โ™‚๏ธ": { - "name": "man supervillain", - "slug": "man_supervillain", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆนโ€โ™€๏ธ": { - "name": "woman supervillain", - "slug": "woman_supervillain", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง™": { - "name": "mage", - "slug": "mage", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง™โ€โ™‚๏ธ": { - "name": "man mage", - "slug": "man_mage", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง™โ€โ™€๏ธ": { - "name": "woman mage", - "slug": "woman_mage", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงš": { - "name": "fairy", - "slug": "fairy", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงšโ€โ™‚๏ธ": { - "name": "man fairy", - "slug": "man_fairy", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงšโ€โ™€๏ธ": { - "name": "woman fairy", - "slug": "woman_fairy", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง›": { - "name": "vampire", - "slug": "vampire", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง›โ€โ™‚๏ธ": { - "name": "man vampire", - "slug": "man_vampire", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง›โ€โ™€๏ธ": { - "name": "woman vampire", - "slug": "woman_vampire", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงœ": { - "name": "merperson", - "slug": "merperson", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงœโ€โ™‚๏ธ": { - "name": "merman", - "slug": "merman", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงœโ€โ™€๏ธ": { - "name": "mermaid", - "slug": "mermaid", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง": { - "name": "elf", - "slug": "elf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงโ€โ™‚๏ธ": { - "name": "man elf", - "slug": "man_elf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงโ€โ™€๏ธ": { - "name": "woman elf", - "slug": "woman_elf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงž": { - "name": "genie", - "slug": "genie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงžโ€โ™‚๏ธ": { - "name": "man genie", - "slug": "man_genie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงžโ€โ™€๏ธ": { - "name": "woman genie", - "slug": "woman_genie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸงŸ": { - "name": "zombie", - "slug": "zombie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸงŸโ€โ™‚๏ธ": { - "name": "man zombie", - "slug": "man_zombie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸงŸโ€โ™€๏ธ": { - "name": "woman zombie", - "slug": "woman_zombie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸงŒ": { - "name": "troll", - "slug": "troll", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ’†": { - "name": "person getting massage", - "slug": "person_getting_massage", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’†โ€โ™‚๏ธ": { - "name": "man getting massage", - "slug": "man_getting_massage", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’†โ€โ™€๏ธ": { - "name": "woman getting massage", - "slug": "woman_getting_massage", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’‡": { - "name": "person getting haircut", - "slug": "person_getting_haircut", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’‡โ€โ™‚๏ธ": { - "name": "man getting haircut", - "slug": "man_getting_haircut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’‡โ€โ™€๏ธ": { - "name": "woman getting haircut", - "slug": "woman_getting_haircut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšถ": { - "name": "person walking", - "slug": "person_walking", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿšถโ€โ™‚๏ธ": { - "name": "man walking", - "slug": "man_walking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšถโ€โ™€๏ธ": { - "name": "woman walking", - "slug": "woman_walking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšถโ€โžก๏ธ": { - "name": "person walking facing right", - "slug": "person_walking_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿšถโ€โ™€๏ธโ€โžก๏ธ": { - "name": "woman walking facing right", - "slug": "woman_walking_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿšถโ€โ™‚๏ธโ€โžก๏ธ": { - "name": "man walking facing right", - "slug": "man_walking_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿง": { - "name": "person standing", - "slug": "person_standing", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿงโ€โ™‚๏ธ": { - "name": "man standing", - "slug": "man_standing", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿงโ€โ™€๏ธ": { - "name": "woman standing", - "slug": "woman_standing", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐ŸงŽ": { - "name": "person kneeling", - "slug": "person_kneeling", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐ŸงŽโ€โ™‚๏ธ": { - "name": "man kneeling", - "slug": "man_kneeling", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐ŸงŽโ€โ™€๏ธ": { - "name": "woman kneeling", - "slug": "woman_kneeling", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐ŸงŽโ€โžก๏ธ": { - "name": "person kneeling facing right", - "slug": "person_kneeling_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐ŸงŽโ€โ™€๏ธโ€โžก๏ธ": { - "name": "woman kneeling facing right", - "slug": "woman_kneeling_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐ŸงŽโ€โ™‚๏ธโ€โžก๏ธ": { - "name": "man kneeling facing right", - "slug": "man_kneeling_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿง‘โ€๐Ÿฆฏ": { - "name": "person with white cane", - "slug": "person_with_white_cane", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿง‘โ€๐Ÿฆฏโ€โžก๏ธ": { - "name": "person with white cane facing right", - "slug": "person_with_white_cane_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘จโ€๐Ÿฆฏ": { - "name": "man with white cane", - "slug": "man_with_white_cane", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘จโ€๐Ÿฆฏโ€โžก๏ธ": { - "name": "man with white cane facing right", - "slug": "man_with_white_cane_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฏ": { - "name": "woman with white cane", - "slug": "woman_with_white_cane", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฏโ€โžก๏ธ": { - "name": "woman with white cane facing right", - "slug": "woman_with_white_cane_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿง‘โ€๐Ÿฆผ": { - "name": "person in motorized wheelchair", - "slug": "person_in_motorized_wheelchair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿง‘โ€๐Ÿฆผโ€โžก๏ธ": { - "name": "person in motorized wheelchair facing right", - "slug": "person_in_motorized_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘จโ€๐Ÿฆผ": { - "name": "man in motorized wheelchair", - "slug": "man_in_motorized_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘จโ€๐Ÿฆผโ€โžก๏ธ": { - "name": "man in motorized wheelchair facing right", - "slug": "man_in_motorized_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆผ": { - "name": "woman in motorized wheelchair", - "slug": "woman_in_motorized_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ฉโ€๐Ÿฆผโ€โžก๏ธ": { - "name": "woman in motorized wheelchair facing right", - "slug": "woman_in_motorized_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿง‘โ€๐Ÿฆฝ": { - "name": "person in manual wheelchair", - "slug": "person_in_manual_wheelchair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿง‘โ€๐Ÿฆฝโ€โžก๏ธ": { - "name": "person in manual wheelchair facing right", - "slug": "person_in_manual_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘จโ€๐Ÿฆฝ": { - "name": "man in manual wheelchair", - "slug": "man_in_manual_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘จโ€๐Ÿฆฝโ€โžก๏ธ": { - "name": "man in manual wheelchair facing right", - "slug": "man_in_manual_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฝ": { - "name": "woman in manual wheelchair", - "slug": "woman_in_manual_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฝโ€โžก๏ธ": { - "name": "woman in manual wheelchair facing right", - "slug": "woman_in_manual_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿƒ": { - "name": "person running", - "slug": "person_running", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿƒโ€โ™‚๏ธ": { - "name": "man running", - "slug": "man_running", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿƒโ€โ™€๏ธ": { - "name": "woman running", - "slug": "woman_running", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿƒโ€โžก๏ธ": { - "name": "person running facing right", - "slug": "person_running_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿƒโ€โ™€๏ธโ€โžก๏ธ": { - "name": "woman running facing right", - "slug": "woman_running_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿƒโ€โ™‚๏ธโ€โžก๏ธ": { - "name": "man running facing right", - "slug": "man_running_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ’ƒ": { - "name": "woman dancing", - "slug": "woman_dancing", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ•บ": { - "name": "man dancing", - "slug": "man_dancing", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ•ด๏ธ": { - "name": "person in suit levitating", - "slug": "person_in_suit_levitating", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฏ": { - "name": "people with bunny ears", - "slug": "people_with_bunny_ears", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ฏโ€โ™‚๏ธ": { - "name": "men with bunny ears", - "slug": "men_with_bunny_ears", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฏโ€โ™€๏ธ": { - "name": "women with bunny ears", - "slug": "women_with_bunny_ears", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿง–": { - "name": "person in steamy room", - "slug": "person_in_steamy_room", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง–โ€โ™‚๏ธ": { - "name": "man in steamy room", - "slug": "man_in_steamy_room", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง–โ€โ™€๏ธ": { - "name": "woman in steamy room", - "slug": "woman_in_steamy_room", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง—": { - "name": "person climbing", - "slug": "person_climbing", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง—โ€โ™‚๏ธ": { - "name": "man climbing", - "slug": "man_climbing", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง—โ€โ™€๏ธ": { - "name": "woman climbing", - "slug": "woman_climbing", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿคบ": { - "name": "person fencing", - "slug": "person_fencing", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ‡": { - "name": "horse racing", - "slug": "horse_racing", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โ›ท๏ธ": { - "name": "skier", - "slug": "skier", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ‚": { - "name": "snowboarder", - "slug": "snowboarder", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐ŸŒ๏ธ": { - "name": "person golfing", - "slug": "person_golfing", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐ŸŒ๏ธโ€โ™‚๏ธ": { - "name": "man golfing", - "slug": "man_golfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐ŸŒ๏ธโ€โ™€๏ธ": { - "name": "woman golfing", - "slug": "woman_golfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ„": { - "name": "person surfing", - "slug": "person_surfing", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ„โ€โ™‚๏ธ": { - "name": "man surfing", - "slug": "man_surfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ„โ€โ™€๏ธ": { - "name": "woman surfing", - "slug": "woman_surfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšฃ": { - "name": "person rowing boat", - "slug": "person_rowing_boat", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿšฃโ€โ™‚๏ธ": { - "name": "man rowing boat", - "slug": "man_rowing_boat", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšฃโ€โ™€๏ธ": { - "name": "woman rowing boat", - "slug": "woman_rowing_boat", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐ŸŠ": { - "name": "person swimming", - "slug": "person_swimming", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐ŸŠโ€โ™‚๏ธ": { - "name": "man swimming", - "slug": "man_swimming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐ŸŠโ€โ™€๏ธ": { - "name": "woman swimming", - "slug": "woman_swimming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "โ›น๏ธ": { - "name": "person bouncing ball", - "slug": "person_bouncing_ball", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "2.0" - }, - "โ›น๏ธโ€โ™‚๏ธ": { - "name": "man bouncing ball", - "slug": "man_bouncing_ball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "โ›น๏ธโ€โ™€๏ธ": { - "name": "woman bouncing ball", - "slug": "woman_bouncing_ball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‹๏ธ": { - "name": "person lifting weights", - "slug": "person_lifting_weights", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "2.0" - }, - "๐Ÿ‹๏ธโ€โ™‚๏ธ": { - "name": "man lifting weights", - "slug": "man_lifting_weights", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‹๏ธโ€โ™€๏ธ": { - "name": "woman lifting weights", - "slug": "woman_lifting_weights", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšด": { - "name": "person biking", - "slug": "person_biking", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿšดโ€โ™‚๏ธ": { - "name": "man biking", - "slug": "man_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšดโ€โ™€๏ธ": { - "name": "woman biking", - "slug": "woman_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšต": { - "name": "person mountain biking", - "slug": "person_mountain_biking", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿšตโ€โ™‚๏ธ": { - "name": "man mountain biking", - "slug": "man_mountain_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšตโ€โ™€๏ธ": { - "name": "woman mountain biking", - "slug": "woman_mountain_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคธ": { - "name": "person cartwheeling", - "slug": "person_cartwheeling", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคธโ€โ™‚๏ธ": { - "name": "man cartwheeling", - "slug": "man_cartwheeling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคธโ€โ™€๏ธ": { - "name": "woman cartwheeling", - "slug": "woman_cartwheeling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคผ": { - "name": "people wrestling", - "slug": "people_wrestling", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿคผโ€โ™‚๏ธ": { - "name": "men wrestling", - "slug": "men_wrestling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿคผโ€โ™€๏ธ": { - "name": "women wrestling", - "slug": "women_wrestling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿคฝ": { - "name": "person playing water polo", - "slug": "person_playing_water_polo", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคฝโ€โ™‚๏ธ": { - "name": "man playing water polo", - "slug": "man_playing_water_polo", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคฝโ€โ™€๏ธ": { - "name": "woman playing water polo", - "slug": "woman_playing_water_polo", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคพ": { - "name": "person playing handball", - "slug": "person_playing_handball", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคพโ€โ™‚๏ธ": { - "name": "man playing handball", - "slug": "man_playing_handball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคพโ€โ™€๏ธ": { - "name": "woman playing handball", - "slug": "woman_playing_handball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคน": { - "name": "person juggling", - "slug": "person_juggling", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคนโ€โ™‚๏ธ": { - "name": "man juggling", - "slug": "man_juggling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคนโ€โ™€๏ธ": { - "name": "woman juggling", - "slug": "woman_juggling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง˜": { - "name": "person in lotus position", - "slug": "person_in_lotus_position", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง˜โ€โ™‚๏ธ": { - "name": "man in lotus position", - "slug": "man_in_lotus_position", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง˜โ€โ™€๏ธ": { - "name": "woman in lotus position", - "slug": "woman_in_lotus_position", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ›€": { - "name": "person taking bath", - "slug": "person_taking_bath", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ›Œ": { - "name": "person in bed", - "slug": "person_in_bed", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘": { - "name": "people holding hands", - "slug": "people_holding_hands", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ญ": { - "name": "women holding hands", - "slug": "women_holding_hands", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ซ": { - "name": "woman and man holding hands", - "slug": "woman_and_man_holding_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ฌ": { - "name": "men holding hands", - "slug": "men_holding_hands", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ’": { - "name": "kiss", - "slug": "kiss", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ": { - "name": "kiss woman, man", - "slug": "kiss_woman_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ": { - "name": "kiss man, man", - "slug": "kiss_man_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ": { - "name": "kiss woman, woman", - "slug": "kiss_woman_woman", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ’‘": { - "name": "couple with heart", - "slug": "couple_with_heart", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ": { - "name": "couple with heart woman, man", - "slug": "couple_with_heart_woman_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ": { - "name": "couple with heart man, man", - "slug": "couple_with_heart_man_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ": { - "name": "couple with heart woman, woman", - "slug": "couple_with_heart_woman_woman", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ": { - "name": "family man, woman, boy", - "slug": "family_man_woman_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง": { - "name": "family man, woman, girl", - "slug": "family_man_woman_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family man, woman, girl, boy", - "slug": "family_man_woman_girl_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family man, woman, boy, boy", - "slug": "family_man_woman_boy_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family man, woman, girl, girl", - "slug": "family_man_woman_girl_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ": { - "name": "family man, man, boy", - "slug": "family_man_man_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง": { - "name": "family man, man, girl", - "slug": "family_man_man_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family man, man, girl, boy", - "slug": "family_man_man_girl_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family man, man, boy, boy", - "slug": "family_man_man_boy_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family man, man, girl, girl", - "slug": "family_man_man_girl_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ": { - "name": "family woman, woman, boy", - "slug": "family_woman_woman_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง": { - "name": "family woman, woman, girl", - "slug": "family_woman_woman_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family woman, woman, girl, boy", - "slug": "family_woman_woman_girl_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family woman, woman, boy, boy", - "slug": "family_woman_woman_boy_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family woman, woman, girl, girl", - "slug": "family_woman_woman_girl_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฆ": { - "name": "family man, boy", - "slug": "family_man_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family man, boy, boy", - "slug": "family_man_boy_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ง": { - "name": "family man, girl", - "slug": "family_man_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family man, girl, boy", - "slug": "family_man_girl_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family man, girl, girl", - "slug": "family_man_girl_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฆ": { - "name": "family woman, boy", - "slug": "family_woman_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family woman, boy, boy", - "slug": "family_woman_boy_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ง": { - "name": "family woman, girl", - "slug": "family_woman_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family woman, girl, boy", - "slug": "family_woman_girl_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family woman, girl, girl", - "slug": "family_woman_girl_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ—ฃ๏ธ": { - "name": "speaking head", - "slug": "speaking_head", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ‘ค": { - "name": "bust in silhouette", - "slug": "bust_in_silhouette", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ฅ": { - "name": "busts in silhouette", - "slug": "busts_in_silhouette", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซ‚": { - "name": "people hugging", - "slug": "people_hugging", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ‘ช": { - "name": "family", - "slug": "family", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿง‘โ€๐Ÿง‘โ€๐Ÿง’": { - "name": "family adult, adult, child", - "slug": "family_adult_adult_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿง‘โ€๐Ÿง‘โ€๐Ÿง’โ€๐Ÿง’": { - "name": "family adult, adult, child, child", - "slug": "family_adult_adult_child_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿง‘โ€๐Ÿง’": { - "name": "family adult, child", - "slug": "family_adult_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿง‘โ€๐Ÿง’โ€๐Ÿง’": { - "name": "family adult, child, child", - "slug": "family_adult_child_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿ‘ฃ": { - "name": "footprints", - "slug": "footprints", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿต": { - "name": "monkey face", - "slug": "monkey_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "monkey", - "slug": "monkey", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "gorilla", - "slug": "gorilla", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆง": { - "name": "orangutan", - "slug": "orangutan", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿถ": { - "name": "dog face", - "slug": "dog_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "dog", - "slug": "dog", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฆฎ": { - "name": "guide dog", - "slug": "guide_dog", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ•โ€๐Ÿฆบ": { - "name": "service dog", - "slug": "service_dog", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉ": { - "name": "poodle", - "slug": "poodle", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿบ": { - "name": "wolf", - "slug": "wolf", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸฆŠ": { - "name": "fox", - "slug": "fox", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "raccoon", - "slug": "raccoon", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฑ": { - "name": "cat face", - "slug": "cat_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆ": { - "name": "cat", - "slug": "cat", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿˆโ€โฌ›": { - "name": "black cat", - "slug": "black_cat", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "lion", - "slug": "lion", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฏ": { - "name": "tiger face", - "slug": "tiger_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…": { - "name": "tiger", - "slug": "tiger", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ†": { - "name": "leopard", - "slug": "leopard", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿด": { - "name": "horse face", - "slug": "horse_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸซŽ": { - "name": "moose", - "slug": "moose", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "donkey", - "slug": "donkey", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "horse", - "slug": "horse", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ„": { - "name": "unicorn", - "slug": "unicorn", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฆ“": { - "name": "zebra", - "slug": "zebra", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸฆŒ": { - "name": "deer", - "slug": "deer", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆฌ": { - "name": "bison", - "slug": "bison", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฎ": { - "name": "cow face", - "slug": "cow_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‚": { - "name": "ox", - "slug": "ox", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿƒ": { - "name": "water buffalo", - "slug": "water_buffalo", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ„": { - "name": "cow", - "slug": "cow", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿท": { - "name": "pig face", - "slug": "pig_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–": { - "name": "pig", - "slug": "pig", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ—": { - "name": "boar", - "slug": "boar", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฝ": { - "name": "pig nose", - "slug": "pig_nose", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "ram", - "slug": "ram", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‘": { - "name": "ewe", - "slug": "ewe", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "goat", - "slug": "goat", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "camel", - "slug": "camel", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "two-hump camel", - "slug": "two_hump_camel", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ™": { - "name": "llama", - "slug": "llama", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆ’": { - "name": "giraffe", - "slug": "giraffe", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "elephant", - "slug": "elephant", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆฃ": { - "name": "mammoth", - "slug": "mammoth", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "rhinoceros", - "slug": "rhinoceros", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆ›": { - "name": "hippopotamus", - "slug": "hippopotamus", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿญ": { - "name": "mouse face", - "slug": "mouse_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "mouse", - "slug": "mouse", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ€": { - "name": "rat", - "slug": "rat", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿน": { - "name": "hamster", - "slug": "hamster", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฐ": { - "name": "rabbit face", - "slug": "rabbit_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡": { - "name": "rabbit", - "slug": "rabbit", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฟ๏ธ": { - "name": "chipmunk", - "slug": "chipmunk", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฆซ": { - "name": "beaver", - "slug": "beaver", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆ”": { - "name": "hedgehog", - "slug": "hedgehog", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฆ‡": { - "name": "bat", - "slug": "bat", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿป": { - "name": "bear", - "slug": "bear", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿปโ€โ„๏ธ": { - "name": "polar bear", - "slug": "polar_bear", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿจ": { - "name": "koala", - "slug": "koala", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿผ": { - "name": "panda", - "slug": "panda", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆฅ": { - "name": "sloth", - "slug": "sloth", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆฆ": { - "name": "otter", - "slug": "otter", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆจ": { - "name": "skunk", - "slug": "skunk", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆ˜": { - "name": "kangaroo", - "slug": "kangaroo", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆก": { - "name": "badger", - "slug": "badger", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿพ": { - "name": "paw prints", - "slug": "paw_prints", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆƒ": { - "name": "turkey", - "slug": "turkey", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "chicken", - "slug": "chicken", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "rooster", - "slug": "rooster", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฃ": { - "name": "hatching chick", - "slug": "hatching_chick", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "baby chick", - "slug": "baby_chick", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "front-facing baby chick", - "slug": "front_facing_baby_chick", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "bird", - "slug": "bird", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "penguin", - "slug": "penguin", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•Š๏ธ": { - "name": "dove", - "slug": "dove", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฆ…": { - "name": "eagle", - "slug": "eagle", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆ†": { - "name": "duck", - "slug": "duck", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆข": { - "name": "swan", - "slug": "swan", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆ‰": { - "name": "owl", - "slug": "owl", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆค": { - "name": "dodo", - "slug": "dodo", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชถ": { - "name": "feather", - "slug": "feather", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆฉ": { - "name": "flamingo", - "slug": "flamingo", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆš": { - "name": "peacock", - "slug": "peacock", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆœ": { - "name": "parrot", - "slug": "parrot", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชฝ": { - "name": "wing", - "slug": "wing", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿฆโ€โฌ›": { - "name": "black bird", - "slug": "black_bird", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿชฟ": { - "name": "goose", - "slug": "goose", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿฆโ€๐Ÿ”ฅ": { - "name": "phoenix", - "slug": "phoenix", - "group": "Animals & Nature", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿธ": { - "name": "frog", - "slug": "frog", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŠ": { - "name": "crocodile", - "slug": "crocodile", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿข": { - "name": "turtle", - "slug": "turtle", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸฆŽ": { - "name": "lizard", - "slug": "lizard", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "snake", - "slug": "snake", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฒ": { - "name": "dragon face", - "slug": "dragon_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰": { - "name": "dragon", - "slug": "dragon", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฆ•": { - "name": "sauropod", - "slug": "sauropod", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฆ–": { - "name": "T-Rex", - "slug": "t_rex", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿณ": { - "name": "spouting whale", - "slug": "spouting_whale", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‹": { - "name": "whale", - "slug": "whale", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฌ": { - "name": "dolphin", - "slug": "dolphin", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆญ": { - "name": "seal", - "slug": "seal", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸŸ": { - "name": "fish", - "slug": "fish", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ ": { - "name": "tropical fish", - "slug": "tropical_fish", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿก": { - "name": "blowfish", - "slug": "blowfish", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆˆ": { - "name": "shark", - "slug": "shark", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ™": { - "name": "octopus", - "slug": "octopus", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "spiral shell", - "slug": "spiral_shell", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชธ": { - "name": "coral", - "slug": "coral", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿชผ": { - "name": "jellyfish", - "slug": "jellyfish", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "snail", - "slug": "snail", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ‹": { - "name": "butterfly", - "slug": "butterfly", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "bug", - "slug": "bug", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿœ": { - "name": "ant", - "slug": "ant", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "honeybee", - "slug": "honeybee", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชฒ": { - "name": "beetle", - "slug": "beetle", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿž": { - "name": "lady beetle", - "slug": "lady_beetle", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ—": { - "name": "cricket", - "slug": "cricket", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿชณ": { - "name": "cockroach", - "slug": "cockroach", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ•ท๏ธ": { - "name": "spider", - "slug": "spider", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•ธ๏ธ": { - "name": "spider web", - "slug": "spider_web", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฆ‚": { - "name": "scorpion", - "slug": "scorpion", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸฆŸ": { - "name": "mosquito", - "slug": "mosquito", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชฐ": { - "name": "fly", - "slug": "fly", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชฑ": { - "name": "worm", - "slug": "worm", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆ ": { - "name": "microbe", - "slug": "microbe", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "bouquet", - "slug": "bouquet", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒธ": { - "name": "cherry blossom", - "slug": "cherry_blossom", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฎ": { - "name": "white flower", - "slug": "white_flower", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชท": { - "name": "lotus", - "slug": "lotus", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿต๏ธ": { - "name": "rosette", - "slug": "rosette", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒน": { - "name": "rose", - "slug": "rose", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ€": { - "name": "wilted flower", - "slug": "wilted_flower", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐ŸŒบ": { - "name": "hibiscus", - "slug": "hibiscus", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒป": { - "name": "sunflower", - "slug": "sunflower", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒผ": { - "name": "blossom", - "slug": "blossom", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒท": { - "name": "tulip", - "slug": "tulip", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชป": { - "name": "hyacinth", - "slug": "hyacinth", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐ŸŒฑ": { - "name": "seedling", - "slug": "seedling", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชด": { - "name": "potted plant", - "slug": "potted_plant", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸŒฒ": { - "name": "evergreen tree", - "slug": "evergreen_tree", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒณ": { - "name": "deciduous tree", - "slug": "deciduous_tree", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒด": { - "name": "palm tree", - "slug": "palm_tree", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒต": { - "name": "cactus", - "slug": "cactus", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒพ": { - "name": "sheaf of rice", - "slug": "sheaf_of_rice", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒฟ": { - "name": "herb", - "slug": "herb", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜˜๏ธ": { - "name": "shamrock", - "slug": "shamrock", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ€": { - "name": "four leaf clover", - "slug": "four_leaf_clover", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "maple leaf", - "slug": "maple_leaf", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‚": { - "name": "fallen leaf", - "slug": "fallen_leaf", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿƒ": { - "name": "leaf fluttering in wind", - "slug": "leaf_fluttering_in_wind", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชน": { - "name": "empty nest", - "slug": "empty_nest", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿชบ": { - "name": "nest with eggs", - "slug": "nest_with_eggs", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ„": { - "name": "mushroom", - "slug": "mushroom", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡": { - "name": "grapes", - "slug": "grapes", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆ": { - "name": "melon", - "slug": "melon", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰": { - "name": "watermelon", - "slug": "watermelon", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŠ": { - "name": "tangerine", - "slug": "tangerine", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‹": { - "name": "lemon", - "slug": "lemon", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‹โ€๐ŸŸฉ": { - "name": "lime", - "slug": "lime", - "group": "Food & Drink", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "banana", - "slug": "banana", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "pineapple", - "slug": "pineapple", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅญ": { - "name": "mango", - "slug": "mango", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "red apple", - "slug": "red_apple", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "green apple", - "slug": "green_apple", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "pear", - "slug": "pear", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‘": { - "name": "peach", - "slug": "peach", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "cherries", - "slug": "cherries", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "strawberry", - "slug": "strawberry", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "blueberries", - "slug": "blueberries", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "kiwi fruit", - "slug": "kiwi_fruit", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ…": { - "name": "tomato", - "slug": "tomato", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ’": { - "name": "olive", - "slug": "olive", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅฅ": { - "name": "coconut", - "slug": "coconut", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅ‘": { - "name": "avocado", - "slug": "avocado", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ†": { - "name": "eggplant", - "slug": "eggplant", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ”": { - "name": "potato", - "slug": "potato", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ•": { - "name": "carrot", - "slug": "carrot", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐ŸŒฝ": { - "name": "ear of corn", - "slug": "ear_of_corn", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒถ๏ธ": { - "name": "hot pepper", - "slug": "hot_pepper", - "group": "Food & Drink", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿซ‘": { - "name": "bell pepper", - "slug": "bell_pepper", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅ’": { - "name": "cucumber", - "slug": "cucumber", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅฌ": { - "name": "leafy green", - "slug": "leafy_green", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅฆ": { - "name": "broccoli", - "slug": "broccoli", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿง„": { - "name": "garlic", - "slug": "garlic", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿง…": { - "name": "onion", - "slug": "onion", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฅœ": { - "name": "peanuts", - "slug": "peanuts", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿซ˜": { - "name": "beans", - "slug": "beans", - "group": "Food & Drink", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐ŸŒฐ": { - "name": "chestnut", - "slug": "chestnut", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซš": { - "name": "ginger root", - "slug": "ginger_root", - "group": "Food & Drink", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿซ›": { - "name": "pea pod", - "slug": "pea_pod", - "group": "Food & Drink", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ„โ€๐ŸŸซ": { - "name": "brown mushroom", - "slug": "brown_mushroom", - "group": "Food & Drink", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿž": { - "name": "bread", - "slug": "bread", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "croissant", - "slug": "croissant", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ–": { - "name": "baguette bread", - "slug": "baguette_bread", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿซ“": { - "name": "flatbread", - "slug": "flatbread", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅจ": { - "name": "pretzel", - "slug": "pretzel", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅฏ": { - "name": "bagel", - "slug": "bagel", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅž": { - "name": "pancakes", - "slug": "pancakes", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿง‡": { - "name": "waffle", - "slug": "waffle", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿง€": { - "name": "cheese wedge", - "slug": "cheese_wedge", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ–": { - "name": "meat on bone", - "slug": "meat_on_bone", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—": { - "name": "poultry leg", - "slug": "poultry_leg", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅฉ": { - "name": "cut of meat", - "slug": "cut_of_meat", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅ“": { - "name": "bacon", - "slug": "bacon", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "hamburger", - "slug": "hamburger", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸ": { - "name": "french fries", - "slug": "french_fries", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "pizza", - "slug": "pizza", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒญ": { - "name": "hot dog", - "slug": "hot_dog", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅช": { - "name": "sandwich", - "slug": "sandwich", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸŒฎ": { - "name": "taco", - "slug": "taco", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒฏ": { - "name": "burrito", - "slug": "burrito", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซ”": { - "name": "tamale", - "slug": "tamale", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅ™": { - "name": "stuffed flatbread", - "slug": "stuffed_flatbread", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿง†": { - "name": "falafel", - "slug": "falafel", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฅš": { - "name": "egg", - "slug": "egg", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿณ": { - "name": "cooking", - "slug": "cooking", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ˜": { - "name": "shallow pan of food", - "slug": "shallow_pan_of_food", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฒ": { - "name": "pot of food", - "slug": "pot_of_food", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ•": { - "name": "fondue", - "slug": "fondue", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅฃ": { - "name": "bowl with spoon", - "slug": "bowl_with_spoon", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅ—": { - "name": "green salad", - "slug": "green_salad", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฟ": { - "name": "popcorn", - "slug": "popcorn", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿงˆ": { - "name": "butter", - "slug": "butter", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿง‚": { - "name": "salt", - "slug": "salt", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅซ": { - "name": "canned food", - "slug": "canned_food", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฑ": { - "name": "bento box", - "slug": "bento_box", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "rice cracker", - "slug": "rice_cracker", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™": { - "name": "rice ball", - "slug": "rice_ball", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "cooked rice", - "slug": "cooked_rice", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "curry rice", - "slug": "curry_rice", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿœ": { - "name": "steaming bowl", - "slug": "steaming_bowl", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "spaghetti", - "slug": "spaghetti", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ ": { - "name": "roasted sweet potato", - "slug": "roasted_sweet_potato", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿข": { - "name": "oden", - "slug": "oden", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฃ": { - "name": "sushi", - "slug": "sushi", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "fried shrimp", - "slug": "fried_shrimp", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "fish cake with swirl", - "slug": "fish_cake_with_swirl", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅฎ": { - "name": "moon cake", - "slug": "moon_cake", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿก": { - "name": "dango", - "slug": "dango", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸฅŸ": { - "name": "dumpling", - "slug": "dumpling", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅ ": { - "name": "fortune cookie", - "slug": "fortune_cookie", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅก": { - "name": "takeout box", - "slug": "takeout_box", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฆ€": { - "name": "crab", - "slug": "crab", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฆž": { - "name": "lobster", - "slug": "lobster", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "shrimp", - "slug": "shrimp", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆ‘": { - "name": "squid", - "slug": "squid", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆช": { - "name": "oyster", - "slug": "oyster", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "soft ice cream", - "slug": "soft_ice_cream", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "shaved ice", - "slug": "shaved_ice", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿจ": { - "name": "ice cream", - "slug": "ice_cream", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉ": { - "name": "doughnut", - "slug": "doughnut", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "cookie", - "slug": "cookie", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‚": { - "name": "birthday cake", - "slug": "birthday_cake", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฐ": { - "name": "shortcake", - "slug": "shortcake", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "cupcake", - "slug": "cupcake", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅง": { - "name": "pie", - "slug": "pie", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "chocolate bar", - "slug": "chocolate_bar", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฌ": { - "name": "candy", - "slug": "candy", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿญ": { - "name": "lollipop", - "slug": "lollipop", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฎ": { - "name": "custard", - "slug": "custard", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฏ": { - "name": "honey pot", - "slug": "honey_pot", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿผ": { - "name": "baby bottle", - "slug": "baby_bottle", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅ›": { - "name": "glass of milk", - "slug": "glass_of_milk", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "โ˜•": { - "name": "hot beverage", - "slug": "hot_beverage", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ–": { - "name": "teapot", - "slug": "teapot", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿต": { - "name": "teacup without handle", - "slug": "teacup_without_handle", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿถ": { - "name": "sake", - "slug": "sake", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿพ": { - "name": "bottle with popping cork", - "slug": "bottle_with_popping_cork", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿท": { - "name": "wine glass", - "slug": "wine_glass", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿธ": { - "name": "cocktail glass", - "slug": "cocktail_glass", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿน": { - "name": "tropical drink", - "slug": "tropical_drink", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿบ": { - "name": "beer mug", - "slug": "beer_mug", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿป": { - "name": "clinking beer mugs", - "slug": "clinking_beer_mugs", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ‚": { - "name": "clinking glasses", - "slug": "clinking_glasses", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅƒ": { - "name": "tumbler glass", - "slug": "tumbler_glass", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿซ—": { - "name": "pouring liquid", - "slug": "pouring_liquid", - "group": "Food & Drink", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿฅค": { - "name": "cup with straw", - "slug": "cup_with_straw", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿง‹": { - "name": "bubble tea", - "slug": "bubble_tea", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงƒ": { - "name": "beverage box", - "slug": "beverage_box", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿง‰": { - "name": "mate", - "slug": "mate", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸงŠ": { - "name": "ice", - "slug": "ice", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฅข": { - "name": "chopsticks", - "slug": "chopsticks", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฝ๏ธ": { - "name": "fork and knife with plate", - "slug": "fork_and_knife_with_plate", - "group": "Food & Drink", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿด": { - "name": "fork and knife", - "slug": "fork_and_knife", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ„": { - "name": "spoon", - "slug": "spoon", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ”ช": { - "name": "kitchen knife", - "slug": "kitchen_knife", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ™": { - "name": "jar", - "slug": "jar", - "group": "Food & Drink", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿบ": { - "name": "amphora", - "slug": "amphora", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "globe showing Europe-Africa", - "slug": "globe_showing_europe_africa", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒŽ": { - "name": "globe showing Americas", - "slug": "globe_showing_americas", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "globe showing Asia-Australia", - "slug": "globe_showing_asia_australia", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "globe with meridians", - "slug": "globe_with_meridians", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ—บ๏ธ": { - "name": "world map", - "slug": "world_map", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—พ": { - "name": "map of Japan", - "slug": "map_of_japan", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงญ": { - "name": "compass", - "slug": "compass", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ”๏ธ": { - "name": "snow-capped mountain", - "slug": "snow_capped_mountain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ›ฐ๏ธ": { - "name": "mountain", - "slug": "mountain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ‹": { - "name": "volcano", - "slug": "volcano", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ป": { - "name": "mount fuji", - "slug": "mount_fuji", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•๏ธ": { - "name": "camping", - "slug": "camping", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–๏ธ": { - "name": "beach with umbrella", - "slug": "beach_with_umbrella", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿœ๏ธ": { - "name": "desert", - "slug": "desert", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ๏ธ": { - "name": "desert island", - "slug": "desert_island", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿž๏ธ": { - "name": "national park", - "slug": "national_park", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŸ๏ธ": { - "name": "stadium", - "slug": "stadium", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›๏ธ": { - "name": "classical building", - "slug": "classical_building", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—๏ธ": { - "name": "building construction", - "slug": "building_construction", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿงฑ": { - "name": "brick", - "slug": "brick", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชจ": { - "name": "rock", - "slug": "rock", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชต": { - "name": "wood", - "slug": "wood", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ›–": { - "name": "hut", - "slug": "hut", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ˜๏ธ": { - "name": "houses", - "slug": "houses", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿš๏ธ": { - "name": "derelict house", - "slug": "derelict_house", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ ": { - "name": "house", - "slug": "house", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿก": { - "name": "house with garden", - "slug": "house_with_garden", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿข": { - "name": "office building", - "slug": "office_building", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฃ": { - "name": "Japanese post office", - "slug": "japanese_post_office", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "post office", - "slug": "post_office", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "hospital", - "slug": "hospital", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "bank", - "slug": "bank", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿจ": { - "name": "hotel", - "slug": "hotel", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉ": { - "name": "love hotel", - "slug": "love_hotel", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "convenience store", - "slug": "convenience_store", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "school", - "slug": "school", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฌ": { - "name": "department store", - "slug": "department_store", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿญ": { - "name": "factory", - "slug": "factory", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฏ": { - "name": "Japanese castle", - "slug": "japanese_castle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฐ": { - "name": "castle", - "slug": "castle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’’": { - "name": "wedding", - "slug": "wedding", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ผ": { - "name": "Tokyo tower", - "slug": "tokyo_tower", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ฝ": { - "name": "Statue of Liberty", - "slug": "statue_of_liberty", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›ช": { - "name": "church", - "slug": "church", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•Œ": { - "name": "mosque", - "slug": "mosque", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›•": { - "name": "hindu temple", - "slug": "hindu_temple", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "synagogue", - "slug": "synagogue", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ›ฉ๏ธ": { - "name": "shinto shrine", - "slug": "shinto_shrine", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•‹": { - "name": "kaaba", - "slug": "kaaba", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ›ฒ": { - "name": "fountain", - "slug": "fountain", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›บ": { - "name": "tent", - "slug": "tent", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "foggy", - "slug": "foggy", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒƒ": { - "name": "night with stars", - "slug": "night_with_stars", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™๏ธ": { - "name": "cityscape", - "slug": "cityscape", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ„": { - "name": "sunrise over mountains", - "slug": "sunrise_over_mountains", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ…": { - "name": "sunrise", - "slug": "sunrise", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ†": { - "name": "cityscape at dusk", - "slug": "cityscape_at_dusk", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ‡": { - "name": "sunset", - "slug": "sunset", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ‰": { - "name": "bridge at night", - "slug": "bridge_at_night", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™จ๏ธ": { - "name": "hot springs", - "slug": "hot_springs", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ ": { - "name": "carousel horse", - "slug": "carousel_horse", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "playground slide", - "slug": "playground_slide", - "group": "Travel & Places", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐ŸŽก": { - "name": "ferris wheel", - "slug": "ferris_wheel", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽข": { - "name": "roller coaster", - "slug": "roller_coaster", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ˆ": { - "name": "barber pole", - "slug": "barber_pole", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽช": { - "name": "circus tent", - "slug": "circus_tent", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš‚": { - "name": "locomotive", - "slug": "locomotive", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšƒ": { - "name": "railway car", - "slug": "railway_car", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš„": { - "name": "high-speed train", - "slug": "high_speed_train", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš…": { - "name": "bullet train", - "slug": "bullet_train", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš†": { - "name": "train", - "slug": "train", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš‡": { - "name": "metro", - "slug": "metro", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšˆ": { - "name": "light rail", - "slug": "light_rail", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš‰": { - "name": "station", - "slug": "station", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸšŠ": { - "name": "tram", - "slug": "tram", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "monorail", - "slug": "monorail", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšž": { - "name": "mountain railway", - "slug": "mountain_railway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš‹": { - "name": "tram car", - "slug": "tram_car", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸšŒ": { - "name": "bus", - "slug": "bus", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "oncoming bus", - "slug": "oncoming_bus", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸšŽ": { - "name": "trolleybus", - "slug": "trolleybus", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "minibus", - "slug": "minibus", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš‘": { - "name": "ambulance", - "slug": "ambulance", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš’": { - "name": "fire engine", - "slug": "fire_engine", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš“": { - "name": "police car", - "slug": "police_car", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš”": { - "name": "oncoming police car", - "slug": "oncoming_police_car", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿš•": { - "name": "taxi", - "slug": "taxi", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš–": { - "name": "oncoming taxi", - "slug": "oncoming_taxi", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš—": { - "name": "automobile", - "slug": "automobile", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš˜": { - "name": "oncoming automobile", - "slug": "oncoming_automobile", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿš™": { - "name": "sport utility vehicle", - "slug": "sport_utility_vehicle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ป": { - "name": "pickup truck", - "slug": "pickup_truck", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿšš": { - "name": "delivery truck", - "slug": "delivery_truck", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš›": { - "name": "articulated lorry", - "slug": "articulated_lorry", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšœ": { - "name": "tractor", - "slug": "tractor", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŽ๏ธ": { - "name": "racing car", - "slug": "racing_car", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ๏ธ": { - "name": "motorcycle", - "slug": "motorcycle", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ต": { - "name": "motor scooter", - "slug": "motor_scooter", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆฝ": { - "name": "manual wheelchair", - "slug": "manual_wheelchair", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆผ": { - "name": "motorized wheelchair", - "slug": "motorized_wheelchair", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ›บ": { - "name": "auto rickshaw", - "slug": "auto_rickshaw", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿšฒ": { - "name": "bicycle", - "slug": "bicycle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ด": { - "name": "kick scooter", - "slug": "kick_scooter", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ›น": { - "name": "skateboard", - "slug": "skateboard", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ›ผ": { - "name": "roller skate", - "slug": "roller_skate", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "bus stop", - "slug": "bus_stop", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ฃ๏ธ": { - "name": "motorway", - "slug": "motorway", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ค๏ธ": { - "name": "railway track", - "slug": "railway_track", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ข๏ธ": { - "name": "oil drum", - "slug": "oil_drum", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ›ฝ": { - "name": "fuel pump", - "slug": "fuel_pump", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ž": { - "name": "wheel", - "slug": "wheel", - "group": "Travel & Places", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿšจ": { - "name": "police car light", - "slug": "police_car_light", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฅ": { - "name": "horizontal traffic light", - "slug": "horizontal_traffic_light", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฆ": { - "name": "vertical traffic light", - "slug": "vertical_traffic_light", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›‘": { - "name": "stop sign", - "slug": "stop_sign", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿšง": { - "name": "construction", - "slug": "construction", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โš“": { - "name": "anchor", - "slug": "anchor", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›Ÿ": { - "name": "ring buoy", - "slug": "ring_buoy", - "group": "Travel & Places", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "โ›ต": { - "name": "sailboat", - "slug": "sailboat", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ถ": { - "name": "canoe", - "slug": "canoe", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿšค": { - "name": "speedboat", - "slug": "speedboat", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ณ๏ธ": { - "name": "passenger ship", - "slug": "passenger_ship", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ›ด๏ธ": { - "name": "ferry", - "slug": "ferry", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ฅ๏ธ": { - "name": "motor boat", - "slug": "motor_boat", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿšข": { - "name": "ship", - "slug": "ship", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœˆ๏ธ": { - "name": "airplane", - "slug": "airplane", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ฉ๏ธ": { - "name": "small airplane", - "slug": "small_airplane", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ซ": { - "name": "airplane departure", - "slug": "airplane_departure", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›ฌ": { - "name": "airplane arrival", - "slug": "airplane_arrival", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿช‚": { - "name": "parachute", - "slug": "parachute", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ’บ": { - "name": "seat", - "slug": "seat", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "helicopter", - "slug": "helicopter", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸšŸ": { - "name": "suspension railway", - "slug": "suspension_railway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš ": { - "name": "mountain cableway", - "slug": "mountain_cableway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšก": { - "name": "aerial tramway", - "slug": "aerial_tramway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›ฐ๏ธ": { - "name": "satellite", - "slug": "satellite", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿš€": { - "name": "rocket", - "slug": "rocket", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ธ": { - "name": "flying saucer", - "slug": "flying_saucer", - "group": "Travel & Places", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ›Ž๏ธ": { - "name": "bellhop bell", - "slug": "bellhop_bell", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿงณ": { - "name": "luggage", - "slug": "luggage", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "โŒ›": { - "name": "hourglass done", - "slug": "hourglass_done", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โณ": { - "name": "hourglass not done", - "slug": "hourglass_not_done", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โŒš": { - "name": "watch", - "slug": "watch", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฐ": { - "name": "alarm clock", - "slug": "alarm_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฑ๏ธ": { - "name": "stopwatch", - "slug": "stopwatch", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โฒ๏ธ": { - "name": "timer clock", - "slug": "timer_clock", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ•ฐ๏ธ": { - "name": "mantelpiece clock", - "slug": "mantelpiece_clock", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•›": { - "name": "twelve oโ€™clock", - "slug": "twelve_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ง": { - "name": "twelve-thirty", - "slug": "twelve_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "one oโ€™clock", - "slug": "one_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•œ": { - "name": "one-thirty", - "slug": "one_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•‘": { - "name": "two oโ€™clock", - "slug": "two_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "two-thirty", - "slug": "two_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•’": { - "name": "three oโ€™clock", - "slug": "three_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ž": { - "name": "three-thirty", - "slug": "three_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•“": { - "name": "four oโ€™clock", - "slug": "four_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•Ÿ": { - "name": "four-thirty", - "slug": "four_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•”": { - "name": "five oโ€™clock", - "slug": "five_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ• ": { - "name": "five-thirty", - "slug": "five_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ••": { - "name": "six oโ€™clock", - "slug": "six_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ก": { - "name": "six-thirty", - "slug": "six_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•–": { - "name": "seven oโ€™clock", - "slug": "seven_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ข": { - "name": "seven-thirty", - "slug": "seven_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•—": { - "name": "eight oโ€™clock", - "slug": "eight_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ฃ": { - "name": "eight-thirty", - "slug": "eight_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•˜": { - "name": "nine oโ€™clock", - "slug": "nine_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ค": { - "name": "nine-thirty", - "slug": "nine_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•™": { - "name": "ten oโ€™clock", - "slug": "ten_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ฅ": { - "name": "ten-thirty", - "slug": "ten_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•š": { - "name": "eleven oโ€™clock", - "slug": "eleven_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ฆ": { - "name": "eleven-thirty", - "slug": "eleven_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ‘": { - "name": "new moon", - "slug": "new_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ’": { - "name": "waxing crescent moon", - "slug": "waxing_crescent_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ“": { - "name": "first quarter moon", - "slug": "first_quarter_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ”": { - "name": "waxing gibbous moon", - "slug": "waxing_gibbous_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ•": { - "name": "full moon", - "slug": "full_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ–": { - "name": "waning gibbous moon", - "slug": "waning_gibbous_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ—": { - "name": "last quarter moon", - "slug": "last_quarter_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ˜": { - "name": "waning crescent moon", - "slug": "waning_crescent_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ™": { - "name": "crescent moon", - "slug": "crescent_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒš": { - "name": "new moon face", - "slug": "new_moon_face", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ›": { - "name": "first quarter moon face", - "slug": "first_quarter_moon_face", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒœ": { - "name": "last quarter moon face", - "slug": "last_quarter_moon_face", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒก๏ธ": { - "name": "thermometer", - "slug": "thermometer", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜€๏ธ": { - "name": "sun", - "slug": "sun", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "full moon face", - "slug": "full_moon_face", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒž": { - "name": "sun with face", - "slug": "sun_with_face", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "ringed planet", - "slug": "ringed_planet", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "โญ": { - "name": "star", - "slug": "star", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒŸ": { - "name": "glowing star", - "slug": "glowing_star", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ ": { - "name": "shooting star", - "slug": "shooting_star", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒŒ": { - "name": "milky way", - "slug": "milky_way", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜๏ธ": { - "name": "cloud", - "slug": "cloud", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›…": { - "name": "sun behind cloud", - "slug": "sun_behind_cloud", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›ˆ๏ธ": { - "name": "cloud with lightning and rain", - "slug": "cloud_with_lightning_and_rain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒค๏ธ": { - "name": "sun behind small cloud", - "slug": "sun_behind_small_cloud", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒฅ๏ธ": { - "name": "sun behind large cloud", - "slug": "sun_behind_large_cloud", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒฆ๏ธ": { - "name": "sun behind rain cloud", - "slug": "sun_behind_rain_cloud", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒง๏ธ": { - "name": "cloud with rain", - "slug": "cloud_with_rain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒจ๏ธ": { - "name": "cloud with snow", - "slug": "cloud_with_snow", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒฉ๏ธ": { - "name": "cloud with lightning", - "slug": "cloud_with_lightning", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒช๏ธ": { - "name": "tornado", - "slug": "tornado", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒซ๏ธ": { - "name": "fog", - "slug": "fog", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒฌ๏ธ": { - "name": "wind face", - "slug": "wind_face", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ€": { - "name": "cyclone", - "slug": "cyclone", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒˆ": { - "name": "rainbow", - "slug": "rainbow", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ‚": { - "name": "closed umbrella", - "slug": "closed_umbrella", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜‚๏ธ": { - "name": "umbrella", - "slug": "umbrella", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜”": { - "name": "umbrella with rain drops", - "slug": "umbrella_with_rain_drops", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›ฑ๏ธ": { - "name": "umbrella on ground", - "slug": "umbrella_on_ground", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โšก": { - "name": "high voltage", - "slug": "high_voltage", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ„๏ธ": { - "name": "snowflake", - "slug": "snowflake", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜ƒ๏ธ": { - "name": "snowman", - "slug": "snowman", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ›„": { - "name": "snowman without snow", - "slug": "snowman_without_snow", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜„๏ธ": { - "name": "comet", - "slug": "comet", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ฅ": { - "name": "fire", - "slug": "fire", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ง": { - "name": "droplet", - "slug": "droplet", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒŠ": { - "name": "water wave", - "slug": "water_wave", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽƒ": { - "name": "jack-o-lantern", - "slug": "jack_o_lantern", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ„": { - "name": "Christmas tree", - "slug": "christmas_tree", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ†": { - "name": "fireworks", - "slug": "fireworks", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‡": { - "name": "sparkler", - "slug": "sparkler", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงจ": { - "name": "firecracker", - "slug": "firecracker", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "โœจ": { - "name": "sparkles", - "slug": "sparkles", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽˆ": { - "name": "balloon", - "slug": "balloon", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‰": { - "name": "party popper", - "slug": "party_popper", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽŠ": { - "name": "confetti ball", - "slug": "confetti_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‹": { - "name": "tanabata tree", - "slug": "tanabata_tree", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "pine decoration", - "slug": "pine_decoration", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽŽ": { - "name": "Japanese dolls", - "slug": "japanese_dolls", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "carp streamer", - "slug": "carp_streamer", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "wind chime", - "slug": "wind_chime", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‘": { - "name": "moon viewing ceremony", - "slug": "moon_viewing_ceremony", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงง": { - "name": "red envelope", - "slug": "red_envelope", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐ŸŽ€": { - "name": "ribbon", - "slug": "ribbon", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "wrapped gift", - "slug": "wrapped_gift", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ—๏ธ": { - "name": "reminder ribbon", - "slug": "reminder_ribbon", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽŸ๏ธ": { - "name": "admission tickets", - "slug": "admission_tickets", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽซ": { - "name": "ticket", - "slug": "ticket", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ–๏ธ": { - "name": "military medal", - "slug": "military_medal", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ†": { - "name": "trophy", - "slug": "trophy", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…": { - "name": "sports medal", - "slug": "sports_medal", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅ‡": { - "name": "1st place medal", - "slug": "1st_place_medal", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅˆ": { - "name": "2nd place medal", - "slug": "2nd_place_medal", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ‰": { - "name": "3rd place medal", - "slug": "3rd_place_medal", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "โšฝ": { - "name": "soccer ball", - "slug": "soccer_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โšพ": { - "name": "baseball", - "slug": "baseball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸฅŽ": { - "name": "softball", - "slug": "softball", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ€": { - "name": "basketball", - "slug": "basketball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "volleyball", - "slug": "volleyball", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿˆ": { - "name": "american football", - "slug": "american_football", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰": { - "name": "rugby football", - "slug": "rugby_football", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŽพ": { - "name": "tennis", - "slug": "tennis", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "flying disc", - "slug": "flying_disc", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐ŸŽณ": { - "name": "bowling", - "slug": "bowling", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "cricket game", - "slug": "cricket_game", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‘": { - "name": "field hockey", - "slug": "field_hockey", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "ice hockey", - "slug": "ice_hockey", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "lacrosse", - "slug": "lacrosse", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "ping pong", - "slug": "ping_pong", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿธ": { - "name": "badminton", - "slug": "badminton", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸฅŠ": { - "name": "boxing glove", - "slug": "boxing_glove", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ‹": { - "name": "martial arts uniform", - "slug": "martial_arts_uniform", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ…": { - "name": "goal net", - "slug": "goal_net", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "โ›ณ": { - "name": "flag in hole", - "slug": "flag_in_hole", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›ธ๏ธ": { - "name": "ice skate", - "slug": "ice_skate", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽฃ": { - "name": "fishing pole", - "slug": "fishing_pole", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคฟ": { - "name": "diving mask", - "slug": "diving_mask", - "group": "Activities", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŽฝ": { - "name": "running shirt", - "slug": "running_shirt", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽฟ": { - "name": "skis", - "slug": "skis", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ท": { - "name": "sled", - "slug": "sled", - "group": "Activities", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸฅŒ": { - "name": "curling stone", - "slug": "curling_stone", - "group": "Activities", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸŽฏ": { - "name": "bullseye", - "slug": "bullseye", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช€": { - "name": "yo-yo", - "slug": "yo_yo", - "group": "Activities", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "kite", - "slug": "kite", - "group": "Activities", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ”ซ": { - "name": "water pistol", - "slug": "water_pistol", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽฑ": { - "name": "pool 8 ball", - "slug": "pool_8_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฎ": { - "name": "crystal ball", - "slug": "crystal_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช„": { - "name": "magic wand", - "slug": "magic_wand", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸŽฎ": { - "name": "video game", - "slug": "video_game", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•น๏ธ": { - "name": "joystick", - "slug": "joystick", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽฐ": { - "name": "slot machine", - "slug": "slot_machine", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽฒ": { - "name": "game die", - "slug": "game_die", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงฉ": { - "name": "puzzle piece", - "slug": "puzzle_piece", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงธ": { - "name": "teddy bear", - "slug": "teddy_bear", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿช…": { - "name": "piรฑata", - "slug": "pinata", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชฉ": { - "name": "mirror ball", - "slug": "mirror_ball", - "group": "Activities", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿช†": { - "name": "nesting dolls", - "slug": "nesting_dolls", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โ™ ๏ธ": { - "name": "spade suit", - "slug": "spade_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™ฅ๏ธ": { - "name": "heart suit", - "slug": "heart_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™ฆ๏ธ": { - "name": "diamond suit", - "slug": "diamond_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™ฃ๏ธ": { - "name": "club suit", - "slug": "club_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™Ÿ๏ธ": { - "name": "chess pawn", - "slug": "chess_pawn", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿƒ": { - "name": "joker", - "slug": "joker", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ€„": { - "name": "mahjong red dragon", - "slug": "mahjong_red_dragon", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽด": { - "name": "flower playing cards", - "slug": "flower_playing_cards", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽญ": { - "name": "performing arts", - "slug": "performing_arts", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–ผ๏ธ": { - "name": "framed picture", - "slug": "framed_picture", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽจ": { - "name": "artist palette", - "slug": "artist_palette", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงต": { - "name": "thread", - "slug": "thread", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชก": { - "name": "sewing needle", - "slug": "sewing_needle", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงถ": { - "name": "yarn", - "slug": "yarn", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชข": { - "name": "knot", - "slug": "knot", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ‘“": { - "name": "glasses", - "slug": "glasses", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ถ๏ธ": { - "name": "sunglasses", - "slug": "sunglasses", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฅฝ": { - "name": "goggles", - "slug": "goggles", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅผ": { - "name": "lab coat", - "slug": "lab_coat", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆบ": { - "name": "safety vest", - "slug": "safety_vest", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ‘”": { - "name": "necktie", - "slug": "necktie", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘•": { - "name": "t-shirt", - "slug": "t_shirt", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘–": { - "name": "jeans", - "slug": "jeans", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงฃ": { - "name": "scarf", - "slug": "scarf", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงค": { - "name": "gloves", - "slug": "gloves", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงฅ": { - "name": "coat", - "slug": "coat", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงฆ": { - "name": "socks", - "slug": "socks", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ‘—": { - "name": "dress", - "slug": "dress", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘˜": { - "name": "kimono", - "slug": "kimono", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅป": { - "name": "sari", - "slug": "sari", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉฑ": { - "name": "one-piece swimsuit", - "slug": "one_piece_swimsuit", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉฒ": { - "name": "briefs", - "slug": "briefs", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉณ": { - "name": "shorts", - "slug": "shorts", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ‘™": { - "name": "bikini", - "slug": "bikini", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘š": { - "name": "womanโ€™s clothes", - "slug": "woman_s_clothes", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชญ": { - "name": "folding hand fan", - "slug": "folding_hand_fan", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ‘›": { - "name": "purse", - "slug": "purse", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘œ": { - "name": "handbag", - "slug": "handbag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘": { - "name": "clutch bag", - "slug": "clutch_bag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›๏ธ": { - "name": "shopping bags", - "slug": "shopping_bags", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽ’": { - "name": "backpack", - "slug": "backpack", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉด": { - "name": "thong sandal", - "slug": "thong_sandal", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ‘ž": { - "name": "manโ€™s shoe", - "slug": "man_s_shoe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘Ÿ": { - "name": "running shoe", - "slug": "running_shoe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅพ": { - "name": "hiking boot", - "slug": "hiking_boot", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅฟ": { - "name": "flat shoe", - "slug": "flat_shoe", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ‘ ": { - "name": "high-heeled shoe", - "slug": "high_heeled_shoe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ก": { - "name": "womanโ€™s sandal", - "slug": "woman_s_sandal", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉฐ": { - "name": "ballet shoes", - "slug": "ballet_shoes", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ‘ข": { - "name": "womanโ€™s boot", - "slug": "woman_s_boot", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชฎ": { - "name": "hair pick", - "slug": "hair_pick", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ‘‘": { - "name": "crown", - "slug": "crown", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘’": { - "name": "womanโ€™s hat", - "slug": "woman_s_hat", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽฉ": { - "name": "top hat", - "slug": "top_hat", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ“": { - "name": "graduation cap", - "slug": "graduation_cap", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงข": { - "name": "billed cap", - "slug": "billed_cap", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿช–": { - "name": "military helmet", - "slug": "military_helmet", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โ›‘๏ธ": { - "name": "rescue workerโ€™s helmet", - "slug": "rescue_worker_s_helmet", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“ฟ": { - "name": "prayer beads", - "slug": "prayer_beads", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’„": { - "name": "lipstick", - "slug": "lipstick", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "ring", - "slug": "ring", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’Ž": { - "name": "gem stone", - "slug": "gem_stone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”‡": { - "name": "muted speaker", - "slug": "muted_speaker", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ˆ": { - "name": "speaker low volume", - "slug": "speaker_low_volume", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ”‰": { - "name": "speaker medium volume", - "slug": "speaker_medium_volume", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”Š": { - "name": "speaker high volume", - "slug": "speaker_high_volume", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ข": { - "name": "loudspeaker", - "slug": "loudspeaker", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฃ": { - "name": "megaphone", - "slug": "megaphone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฏ": { - "name": "postal horn", - "slug": "postal_horn", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ””": { - "name": "bell", - "slug": "bell", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”•": { - "name": "bell with slash", - "slug": "bell_with_slash", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŽผ": { - "name": "musical score", - "slug": "musical_score", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽต": { - "name": "musical note", - "slug": "musical_note", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽถ": { - "name": "musical notes", - "slug": "musical_notes", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ™๏ธ": { - "name": "studio microphone", - "slug": "studio_microphone", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽš๏ธ": { - "name": "level slider", - "slug": "level_slider", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽ›๏ธ": { - "name": "control knobs", - "slug": "control_knobs", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽค": { - "name": "microphone", - "slug": "microphone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽง": { - "name": "headphone", - "slug": "headphone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ป": { - "name": "radio", - "slug": "radio", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽท": { - "name": "saxophone", - "slug": "saxophone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช—": { - "name": "accordion", - "slug": "accordion", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸŽธ": { - "name": "guitar", - "slug": "guitar", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽน": { - "name": "musical keyboard", - "slug": "musical_keyboard", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽบ": { - "name": "trumpet", - "slug": "trumpet", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽป": { - "name": "violin", - "slug": "violin", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช•": { - "name": "banjo", - "slug": "banjo", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "drum", - "slug": "drum", - "group": "Objects", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿช˜": { - "name": "long drum", - "slug": "long_drum", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿช‡": { - "name": "maracas", - "slug": "maracas", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿชˆ": { - "name": "flute", - "slug": "flute", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ“ฑ": { - "name": "mobile phone", - "slug": "mobile_phone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฒ": { - "name": "mobile phone with arrow", - "slug": "mobile_phone_with_arrow", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜Ž๏ธ": { - "name": "telephone", - "slug": "telephone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ž": { - "name": "telephone receiver", - "slug": "telephone_receiver", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“Ÿ": { - "name": "pager", - "slug": "pager", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ ": { - "name": "fax machine", - "slug": "fax_machine", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”‹": { - "name": "battery", - "slug": "battery", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชซ": { - "name": "low battery", - "slug": "low_battery", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ”Œ": { - "name": "electric plug", - "slug": "electric_plug", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ป": { - "name": "laptop", - "slug": "laptop", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–ฅ๏ธ": { - "name": "desktop computer", - "slug": "desktop_computer", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–จ๏ธ": { - "name": "printer", - "slug": "printer", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โŒจ๏ธ": { - "name": "keyboard", - "slug": "keyboard", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ–ฑ๏ธ": { - "name": "computer mouse", - "slug": "computer_mouse", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–ฒ๏ธ": { - "name": "trackball", - "slug": "trackball", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ฝ": { - "name": "computer disk", - "slug": "computer_disk", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’พ": { - "name": "floppy disk", - "slug": "floppy_disk", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฟ": { - "name": "optical disk", - "slug": "optical_disk", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“€": { - "name": "dvd", - "slug": "dvd", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงฎ": { - "name": "abacus", - "slug": "abacus", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐ŸŽฅ": { - "name": "movie camera", - "slug": "movie_camera", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽž๏ธ": { - "name": "film frames", - "slug": "film_frames", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“ฝ๏ธ": { - "name": "film projector", - "slug": "film_projector", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽฌ": { - "name": "clapper board", - "slug": "clapper_board", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“บ": { - "name": "television", - "slug": "television", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ท": { - "name": "camera", - "slug": "camera", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ธ": { - "name": "camera with flash", - "slug": "camera_with_flash", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ“น": { - "name": "video camera", - "slug": "video_camera", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ผ": { - "name": "videocassette", - "slug": "videocassette", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "magnifying glass tilted left", - "slug": "magnifying_glass_tilted_left", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”Ž": { - "name": "magnifying glass tilted right", - "slug": "magnifying_glass_tilted_right", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ฏ๏ธ": { - "name": "candle", - "slug": "candle", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ก": { - "name": "light bulb", - "slug": "light_bulb", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฆ": { - "name": "flashlight", - "slug": "flashlight", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฎ": { - "name": "red paper lantern", - "slug": "red_paper_lantern", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช”": { - "name": "diya lamp", - "slug": "diya_lamp", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ“”": { - "name": "notebook with decorative cover", - "slug": "notebook_with_decorative_cover", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“•": { - "name": "closed book", - "slug": "closed_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“–": { - "name": "open book", - "slug": "open_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“—": { - "name": "green book", - "slug": "green_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“˜": { - "name": "blue book", - "slug": "blue_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“™": { - "name": "orange book", - "slug": "orange_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“š": { - "name": "books", - "slug": "books", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ““": { - "name": "notebook", - "slug": "notebook", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“’": { - "name": "ledger", - "slug": "ledger", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ƒ": { - "name": "page with curl", - "slug": "page_with_curl", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“œ": { - "name": "scroll", - "slug": "scroll", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“„": { - "name": "page facing up", - "slug": "page_facing_up", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฐ": { - "name": "newspaper", - "slug": "newspaper", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ž๏ธ": { - "name": "rolled-up newspaper", - "slug": "rolled_up_newspaper", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“‘": { - "name": "bookmark tabs", - "slug": "bookmark_tabs", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”–": { - "name": "bookmark", - "slug": "bookmark", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿท๏ธ": { - "name": "label", - "slug": "label", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ฐ": { - "name": "money bag", - "slug": "money_bag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช™": { - "name": "coin", - "slug": "coin", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ’ด": { - "name": "yen banknote", - "slug": "yen_banknote", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ต": { - "name": "dollar banknote", - "slug": "dollar_banknote", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ถ": { - "name": "euro banknote", - "slug": "euro_banknote", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ท": { - "name": "pound banknote", - "slug": "pound_banknote", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ธ": { - "name": "money with wings", - "slug": "money_with_wings", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ณ": { - "name": "credit card", - "slug": "credit_card", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงพ": { - "name": "receipt", - "slug": "receipt", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ’น": { - "name": "chart increasing with yen", - "slug": "chart_increasing_with_yen", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ‰๏ธ": { - "name": "envelope", - "slug": "envelope", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ง": { - "name": "e-mail", - "slug": "e_mail", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“จ": { - "name": "incoming envelope", - "slug": "incoming_envelope", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฉ": { - "name": "envelope with arrow", - "slug": "envelope_with_arrow", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ค": { - "name": "outbox tray", - "slug": "outbox_tray", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฅ": { - "name": "inbox tray", - "slug": "inbox_tray", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฆ": { - "name": "package", - "slug": "package", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ซ": { - "name": "closed mailbox with raised flag", - "slug": "closed_mailbox_with_raised_flag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ช": { - "name": "closed mailbox with lowered flag", - "slug": "closed_mailbox_with_lowered_flag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฌ": { - "name": "open mailbox with raised flag", - "slug": "open_mailbox_with_raised_flag", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“ญ": { - "name": "open mailbox with lowered flag", - "slug": "open_mailbox_with_lowered_flag", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“ฎ": { - "name": "postbox", - "slug": "postbox", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ณ๏ธ": { - "name": "ballot box with ballot", - "slug": "ballot_box_with_ballot", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โœ๏ธ": { - "name": "pencil", - "slug": "pencil", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ’๏ธ": { - "name": "black nib", - "slug": "black_nib", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–‹๏ธ": { - "name": "fountain pen", - "slug": "fountain_pen", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–Š๏ธ": { - "name": "pen", - "slug": "pen", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–Œ๏ธ": { - "name": "paintbrush", - "slug": "paintbrush", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–๏ธ": { - "name": "crayon", - "slug": "crayon", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "memo", - "slug": "memo", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ผ": { - "name": "briefcase", - "slug": "briefcase", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "file folder", - "slug": "file_folder", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“‚": { - "name": "open file folder", - "slug": "open_file_folder", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—‚๏ธ": { - "name": "card index dividers", - "slug": "card_index_dividers", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“…": { - "name": "calendar", - "slug": "calendar", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“†": { - "name": "tear-off calendar", - "slug": "tear_off_calendar", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—’๏ธ": { - "name": "spiral notepad", - "slug": "spiral_notepad", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—“๏ธ": { - "name": "spiral calendar", - "slug": "spiral_calendar", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“‡": { - "name": "card index", - "slug": "card_index", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ˆ": { - "name": "chart increasing", - "slug": "chart_increasing", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“‰": { - "name": "chart decreasing", - "slug": "chart_decreasing", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“Š": { - "name": "bar chart", - "slug": "bar_chart", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“‹": { - "name": "clipboard", - "slug": "clipboard", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“Œ": { - "name": "pushpin", - "slug": "pushpin", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "round pushpin", - "slug": "round_pushpin", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“Ž": { - "name": "paperclip", - "slug": "paperclip", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–‡๏ธ": { - "name": "linked paperclips", - "slug": "linked_paperclips", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "straight ruler", - "slug": "straight_ruler", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "triangular ruler", - "slug": "triangular_ruler", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ‚๏ธ": { - "name": "scissors", - "slug": "scissors", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ƒ๏ธ": { - "name": "card file box", - "slug": "card_file_box", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—„๏ธ": { - "name": "file cabinet", - "slug": "file_cabinet", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—‘๏ธ": { - "name": "wastebasket", - "slug": "wastebasket", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ”’": { - "name": "locked", - "slug": "locked", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”“": { - "name": "unlocked", - "slug": "unlocked", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "locked with pen", - "slug": "locked_with_pen", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "locked with key", - "slug": "locked_with_key", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”‘": { - "name": "key", - "slug": "key", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—๏ธ": { - "name": "old key", - "slug": "old_key", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ”จ": { - "name": "hammer", - "slug": "hammer", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช“": { - "name": "axe", - "slug": "axe", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "โ›๏ธ": { - "name": "pick", - "slug": "pick", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โš’๏ธ": { - "name": "hammer and pick", - "slug": "hammer_and_pick", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ› ๏ธ": { - "name": "hammer and wrench", - "slug": "hammer_and_wrench", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—ก๏ธ": { - "name": "dagger", - "slug": "dagger", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โš”๏ธ": { - "name": "crossed swords", - "slug": "crossed_swords", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ฃ": { - "name": "bomb", - "slug": "bomb", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชƒ": { - "name": "boomerang", - "slug": "boomerang", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿน": { - "name": "bow and arrow", - "slug": "bow_and_arrow", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›ก๏ธ": { - "name": "shield", - "slug": "shield", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿชš": { - "name": "carpentry saw", - "slug": "carpentry_saw", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ”ง": { - "name": "wrench", - "slug": "wrench", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช›": { - "name": "screwdriver", - "slug": "screwdriver", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ”ฉ": { - "name": "nut and bolt", - "slug": "nut_and_bolt", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โš™๏ธ": { - "name": "gear", - "slug": "gear", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ—œ๏ธ": { - "name": "clamp", - "slug": "clamp", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โš–๏ธ": { - "name": "balance scale", - "slug": "balance_scale", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฆฏ": { - "name": "white cane", - "slug": "white_cane", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ”—": { - "name": "link", - "slug": "link", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›“๏ธโ€๐Ÿ’ฅ": { - "name": "broken chain", - "slug": "broken_chain", - "group": "Objects", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "โ›“๏ธ": { - "name": "chains", - "slug": "chains", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "hook", - "slug": "hook", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงฐ": { - "name": "toolbox", - "slug": "toolbox", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงฒ": { - "name": "magnet", - "slug": "magnet", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชœ": { - "name": "ladder", - "slug": "ladder", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โš—๏ธ": { - "name": "alembic", - "slug": "alembic", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿงช": { - "name": "test tube", - "slug": "test_tube", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงซ": { - "name": "petri dish", - "slug": "petri_dish", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงฌ": { - "name": "dna", - "slug": "dna", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ”ฌ": { - "name": "microscope", - "slug": "microscope", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ญ": { - "name": "telescope", - "slug": "telescope", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ“ก": { - "name": "satellite antenna", - "slug": "satellite_antenna", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’‰": { - "name": "syringe", - "slug": "syringe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉธ": { - "name": "drop of blood", - "slug": "drop_of_blood", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ’Š": { - "name": "pill", - "slug": "pill", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉน": { - "name": "adhesive bandage", - "slug": "adhesive_bandage", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉผ": { - "name": "crutch", - "slug": "crutch", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿฉบ": { - "name": "stethoscope", - "slug": "stethoscope", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉป": { - "name": "x-ray", - "slug": "x_ray", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿšช": { - "name": "door", - "slug": "door", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›—": { - "name": "elevator", - "slug": "elevator", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชž": { - "name": "mirror", - "slug": "mirror", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸชŸ": { - "name": "window", - "slug": "window", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ›๏ธ": { - "name": "bed", - "slug": "bed", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›‹๏ธ": { - "name": "couch and lamp", - "slug": "couch_and_lamp", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿช‘": { - "name": "chair", - "slug": "chair", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿšฝ": { - "name": "toilet", - "slug": "toilet", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช ": { - "name": "plunger", - "slug": "plunger", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿšฟ": { - "name": "shower", - "slug": "shower", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "bathtub", - "slug": "bathtub", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿชค": { - "name": "mouse trap", - "slug": "mouse_trap", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿช’": { - "name": "razor", - "slug": "razor", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿงด": { - "name": "lotion bottle", - "slug": "lotion_bottle", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงท": { - "name": "safety pin", - "slug": "safety_pin", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงน": { - "name": "broom", - "slug": "broom", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงบ": { - "name": "basket", - "slug": "basket", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงป": { - "name": "roll of paper", - "slug": "roll_of_paper", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชฃ": { - "name": "bucket", - "slug": "bucket", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงผ": { - "name": "soap", - "slug": "soap", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿซง": { - "name": "bubbles", - "slug": "bubbles", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿชฅ": { - "name": "toothbrush", - "slug": "toothbrush", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงฝ": { - "name": "sponge", - "slug": "sponge", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงฏ": { - "name": "fire extinguisher", - "slug": "fire_extinguisher", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ›’": { - "name": "shopping cart", - "slug": "shopping_cart", - "group": "Objects", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿšฌ": { - "name": "cigarette", - "slug": "cigarette", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โšฐ๏ธ": { - "name": "coffin", - "slug": "coffin", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿชฆ": { - "name": "headstone", - "slug": "headstone", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โšฑ๏ธ": { - "name": "funeral urn", - "slug": "funeral_urn", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿงฟ": { - "name": "nazar amulet", - "slug": "nazar_amulet", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชฌ": { - "name": "hamsa", - "slug": "hamsa", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ—ฟ": { - "name": "moai", - "slug": "moai", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชง": { - "name": "placard", - "slug": "placard", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชช": { - "name": "identification card", - "slug": "identification_card", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "ATM sign", - "slug": "atm_sign", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฎ": { - "name": "litter in bin sign", - "slug": "litter_in_bin_sign", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšฐ": { - "name": "potable water", - "slug": "potable_water", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ™ฟ": { - "name": "wheelchair symbol", - "slug": "wheelchair_symbol", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšน": { - "name": "menโ€™s room", - "slug": "men_s_room", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšบ": { - "name": "womenโ€™s room", - "slug": "women_s_room", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšป": { - "name": "restroom", - "slug": "restroom", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšผ": { - "name": "baby symbol", - "slug": "baby_symbol", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšพ": { - "name": "water closet", - "slug": "water_closet", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›‚": { - "name": "passport control", - "slug": "passport_control", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›ƒ": { - "name": "customs", - "slug": "customs", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›„": { - "name": "baggage claim", - "slug": "baggage_claim", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›…": { - "name": "left luggage", - "slug": "left_luggage", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โš ๏ธ": { - "name": "warning", - "slug": "warning", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšธ": { - "name": "children crossing", - "slug": "children_crossing", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ›”": { - "name": "no entry", - "slug": "no_entry", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšซ": { - "name": "prohibited", - "slug": "prohibited", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšณ": { - "name": "no bicycles", - "slug": "no_bicycles", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšญ": { - "name": "no smoking", - "slug": "no_smoking", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฏ": { - "name": "no littering", - "slug": "no_littering", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšฑ": { - "name": "non-potable water", - "slug": "non_potable_water", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšท": { - "name": "no pedestrians", - "slug": "no_pedestrians", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ“ต": { - "name": "no mobile phones", - "slug": "no_mobile_phones", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ž": { - "name": "no one under eighteen", - "slug": "no_one_under_eighteen", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜ข๏ธ": { - "name": "radioactive", - "slug": "radioactive", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ˜ฃ๏ธ": { - "name": "biohazard", - "slug": "biohazard", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โฌ†๏ธ": { - "name": "up arrow", - "slug": "up_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†—๏ธ": { - "name": "up-right arrow", - "slug": "up_right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โžก๏ธ": { - "name": "right arrow", - "slug": "right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†˜๏ธ": { - "name": "down-right arrow", - "slug": "down_right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฌ‡๏ธ": { - "name": "down arrow", - "slug": "down_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†™๏ธ": { - "name": "down-left arrow", - "slug": "down_left_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฌ…๏ธ": { - "name": "left arrow", - "slug": "left_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†–๏ธ": { - "name": "up-left arrow", - "slug": "up_left_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†•๏ธ": { - "name": "up-down arrow", - "slug": "up_down_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†”๏ธ": { - "name": "left-right arrow", - "slug": "left_right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†ฉ๏ธ": { - "name": "right arrow curving left", - "slug": "right_arrow_curving_left", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†ช๏ธ": { - "name": "left arrow curving right", - "slug": "left_arrow_curving_right", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โคด๏ธ": { - "name": "right arrow curving up", - "slug": "right_arrow_curving_up", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โคต๏ธ": { - "name": "right arrow curving down", - "slug": "right_arrow_curving_down", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ƒ": { - "name": "clockwise vertical arrows", - "slug": "clockwise_vertical_arrows", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”„": { - "name": "counterclockwise arrows button", - "slug": "counterclockwise_arrows_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”™": { - "name": "BACK arrow", - "slug": "back_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”š": { - "name": "END arrow", - "slug": "end_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”›": { - "name": "ON! arrow", - "slug": "on_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”œ": { - "name": "SOON arrow", - "slug": "soon_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "TOP arrow", - "slug": "top_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "place of worship", - "slug": "place_of_worship", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โš›๏ธ": { - "name": "atom symbol", - "slug": "atom_symbol", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ•‰๏ธ": { - "name": "om", - "slug": "om", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โœก๏ธ": { - "name": "star of David", - "slug": "star_of_david", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜ธ๏ธ": { - "name": "wheel of dharma", - "slug": "wheel_of_dharma", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜ฏ๏ธ": { - "name": "yin yang", - "slug": "yin_yang", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โœ๏ธ": { - "name": "latin cross", - "slug": "latin_cross", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜ฆ๏ธ": { - "name": "orthodox cross", - "slug": "orthodox_cross", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ˜ช๏ธ": { - "name": "star and crescent", - "slug": "star_and_crescent", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜ฎ๏ธ": { - "name": "peace symbol", - "slug": "peace_symbol", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ•Ž": { - "name": "menorah", - "slug": "menorah", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ฏ": { - "name": "dotted six-pointed star", - "slug": "dotted_six_pointed_star", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชฏ": { - "name": "khanda", - "slug": "khanda", - "group": "Symbols", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "โ™ˆ": { - "name": "Aries", - "slug": "aries", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™‰": { - "name": "Taurus", - "slug": "taurus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™Š": { - "name": "Gemini", - "slug": "gemini", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™‹": { - "name": "Cancer", - "slug": "cancer", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™Œ": { - "name": "Leo", - "slug": "leo", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™": { - "name": "Virgo", - "slug": "virgo", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™Ž": { - "name": "Libra", - "slug": "libra", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™": { - "name": "Scorpio", - "slug": "scorpio", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™": { - "name": "Sagittarius", - "slug": "sagittarius", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™‘": { - "name": "Capricorn", - "slug": "capricorn", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™’": { - "name": "Aquarius", - "slug": "aquarius", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™“": { - "name": "Pisces", - "slug": "pisces", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›Ž": { - "name": "Ophiuchus", - "slug": "ophiuchus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”€": { - "name": "shuffle tracks button", - "slug": "shuffle_tracks_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "repeat button", - "slug": "repeat_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”‚": { - "name": "repeat single button", - "slug": "repeat_single_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ–ถ๏ธ": { - "name": "play button", - "slug": "play_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฉ": { - "name": "fast-forward button", - "slug": "fast_forward_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โญ๏ธ": { - "name": "next track button", - "slug": "next_track_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โฏ๏ธ": { - "name": "play or pause button", - "slug": "play_or_pause_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ—€๏ธ": { - "name": "reverse button", - "slug": "reverse_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โช": { - "name": "fast reverse button", - "slug": "fast_reverse_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฎ๏ธ": { - "name": "last track button", - "slug": "last_track_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ”ผ": { - "name": "upwards button", - "slug": "upwards_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โซ": { - "name": "fast up button", - "slug": "fast_up_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฝ": { - "name": "downwards button", - "slug": "downwards_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฌ": { - "name": "fast down button", - "slug": "fast_down_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โธ๏ธ": { - "name": "pause button", - "slug": "pause_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โน๏ธ": { - "name": "stop button", - "slug": "stop_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โบ๏ธ": { - "name": "record button", - "slug": "record_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ๏ธ": { - "name": "eject button", - "slug": "eject_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŽฆ": { - "name": "cinema", - "slug": "cinema", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”…": { - "name": "dim button", - "slug": "dim_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”†": { - "name": "bright button", - "slug": "bright_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ“ถ": { - "name": "antenna bars", - "slug": "antenna_bars", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›œ": { - "name": "wireless", - "slug": "wireless", - "group": "Symbols", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ“ณ": { - "name": "vibration mode", - "slug": "vibration_mode", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ด": { - "name": "mobile phone off", - "slug": "mobile_phone_off", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™€๏ธ": { - "name": "female sign", - "slug": "female_sign", - "group": "Symbols", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "โ™‚๏ธ": { - "name": "male sign", - "slug": "male_sign", - "group": "Symbols", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "โšง๏ธ": { - "name": "transgender symbol", - "slug": "transgender_symbol", - "group": "Symbols", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โœ–๏ธ": { - "name": "multiply", - "slug": "multiply", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โž•": { - "name": "plus", - "slug": "plus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โž–": { - "name": "minus", - "slug": "minus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โž—": { - "name": "divide", - "slug": "divide", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸฐ": { - "name": "heavy equals sign", - "slug": "heavy_equals_sign", - "group": "Symbols", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "โ™พ๏ธ": { - "name": "infinity", - "slug": "infinity", - "group": "Symbols", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "โ€ผ๏ธ": { - "name": "double exclamation mark", - "slug": "double_exclamation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ‰๏ธ": { - "name": "exclamation question mark", - "slug": "exclamation_question_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ“": { - "name": "red question mark", - "slug": "red_question_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ”": { - "name": "white question mark", - "slug": "white_question_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ•": { - "name": "white exclamation mark", - "slug": "white_exclamation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—": { - "name": "red exclamation mark", - "slug": "red_exclamation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ใ€ฐ๏ธ": { - "name": "wavy dash", - "slug": "wavy_dash", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฑ": { - "name": "currency exchange", - "slug": "currency_exchange", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฒ": { - "name": "heavy dollar sign", - "slug": "heavy_dollar_sign", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โš•๏ธ": { - "name": "medical symbol", - "slug": "medical_symbol", - "group": "Symbols", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "โ™ป๏ธ": { - "name": "recycling symbol", - "slug": "recycling_symbol", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โšœ๏ธ": { - "name": "fleur-de-lis", - "slug": "fleur_de_lis", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ฑ": { - "name": "trident emblem", - "slug": "trident_emblem", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“›": { - "name": "name badge", - "slug": "name_badge", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฐ": { - "name": "Japanese symbol for beginner", - "slug": "japanese_symbol_for_beginner", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โญ•": { - "name": "hollow red circle", - "slug": "hollow_red_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ…": { - "name": "check mark button", - "slug": "check_mark_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜‘๏ธ": { - "name": "check box with check", - "slug": "check_box_with_check", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ”๏ธ": { - "name": "check mark", - "slug": "check_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โŒ": { - "name": "cross mark", - "slug": "cross_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โŽ": { - "name": "cross mark button", - "slug": "cross_mark_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โžฐ": { - "name": "curly loop", - "slug": "curly_loop", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โžฟ": { - "name": "double curly loop", - "slug": "double_curly_loop", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "ใ€ฝ๏ธ": { - "name": "part alternation mark", - "slug": "part_alternation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœณ๏ธ": { - "name": "eight-spoked asterisk", - "slug": "eight_spoked_asterisk", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœด๏ธ": { - "name": "eight-pointed star", - "slug": "eight_pointed_star", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ‡๏ธ": { - "name": "sparkle", - "slug": "sparkle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ยฉ๏ธ": { - "name": "copyright", - "slug": "copyright", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ยฎ๏ธ": { - "name": "registered", - "slug": "registered", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ„ข๏ธ": { - "name": "trade mark", - "slug": "trade_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "#๏ธโƒฃ": { - "name": "keycap #", - "slug": "keycap_number_sign", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "*๏ธโƒฃ": { - "name": "keycap *", - "slug": "keycap_asterisk", - "group": "Symbols", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "0๏ธโƒฃ": { - "name": "keycap 0", - "slug": "keycap_0", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "1๏ธโƒฃ": { - "name": "keycap 1", - "slug": "keycap_1", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "2๏ธโƒฃ": { - "name": "keycap 2", - "slug": "keycap_2", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "3๏ธโƒฃ": { - "name": "keycap 3", - "slug": "keycap_3", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "4๏ธโƒฃ": { - "name": "keycap 4", - "slug": "keycap_4", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "5๏ธโƒฃ": { - "name": "keycap 5", - "slug": "keycap_5", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "6๏ธโƒฃ": { - "name": "keycap 6", - "slug": "keycap_6", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "7๏ธโƒฃ": { - "name": "keycap 7", - "slug": "keycap_7", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "8๏ธโƒฃ": { - "name": "keycap 8", - "slug": "keycap_8", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "9๏ธโƒฃ": { - "name": "keycap 9", - "slug": "keycap_9", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”Ÿ": { - "name": "keycap 10", - "slug": "keycap_10", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ” ": { - "name": "input latin uppercase", - "slug": "input_latin_uppercase", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ก": { - "name": "input latin lowercase", - "slug": "input_latin_lowercase", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ข": { - "name": "input numbers", - "slug": "input_numbers", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฃ": { - "name": "input symbols", - "slug": "input_symbols", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ค": { - "name": "input latin letters", - "slug": "input_latin_letters", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…ฐ๏ธ": { - "name": "A button (blood type)", - "slug": "a_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†Ž": { - "name": "AB button (blood type)", - "slug": "ab_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…ฑ๏ธ": { - "name": "B button (blood type)", - "slug": "b_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†‘": { - "name": "CL button", - "slug": "cl_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†’": { - "name": "COOL button", - "slug": "cool_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†“": { - "name": "FREE button", - "slug": "free_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ„น๏ธ": { - "name": "information", - "slug": "information", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†”": { - "name": "ID button", - "slug": "id_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ“‚๏ธ": { - "name": "circled M", - "slug": "circled_m", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†•": { - "name": "NEW button", - "slug": "new_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†–": { - "name": "NG button", - "slug": "ng_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…พ๏ธ": { - "name": "O button (blood type)", - "slug": "o_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†—": { - "name": "OK button", - "slug": "ok_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…ฟ๏ธ": { - "name": "P button", - "slug": "p_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†˜": { - "name": "SOS button", - "slug": "sos_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†™": { - "name": "UP! button", - "slug": "up_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†š": { - "name": "VS button", - "slug": "vs_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆ": { - "name": "Japanese โ€œhereโ€ button", - "slug": "japanese_here_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆ‚๏ธ": { - "name": "Japanese โ€œservice chargeโ€ button", - "slug": "japanese_service_charge_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆท๏ธ": { - "name": "Japanese โ€œmonthly amountโ€ button", - "slug": "japanese_monthly_amount_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆถ": { - "name": "Japanese โ€œnot free of chargeโ€ button", - "slug": "japanese_not_free_of_charge_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆฏ": { - "name": "Japanese โ€œreservedโ€ button", - "slug": "japanese_reserved_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰": { - "name": "Japanese โ€œbargainโ€ button", - "slug": "japanese_bargain_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆน": { - "name": "Japanese โ€œdiscountโ€ button", - "slug": "japanese_discount_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆš": { - "name": "Japanese โ€œfree of chargeโ€ button", - "slug": "japanese_free_of_charge_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆฒ": { - "name": "Japanese โ€œprohibitedโ€ button", - "slug": "japanese_prohibited_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰‘": { - "name": "Japanese โ€œacceptableโ€ button", - "slug": "japanese_acceptable_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆธ": { - "name": "Japanese โ€œapplicationโ€ button", - "slug": "japanese_application_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆด": { - "name": "Japanese โ€œpassing gradeโ€ button", - "slug": "japanese_passing_grade_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆณ": { - "name": "Japanese โ€œvacancyโ€ button", - "slug": "japanese_vacancy_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ใŠ—๏ธ": { - "name": "Japanese โ€œcongratulationsโ€ button", - "slug": "japanese_congratulations_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ใŠ™๏ธ": { - "name": "Japanese โ€œsecretโ€ button", - "slug": "japanese_secret_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆบ": { - "name": "Japanese โ€œopen for businessโ€ button", - "slug": "japanese_open_for_business_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆต": { - "name": "Japanese โ€œno vacancyโ€ button", - "slug": "japanese_no_vacancy_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ด": { - "name": "red circle", - "slug": "red_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸ ": { - "name": "orange circle", - "slug": "orange_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸก": { - "name": "yellow circle", - "slug": "yellow_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸข": { - "name": "green circle", - "slug": "green_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ”ต": { - "name": "blue circle", - "slug": "blue_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸฃ": { - "name": "purple circle", - "slug": "purple_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸค": { - "name": "brown circle", - "slug": "brown_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "โšซ": { - "name": "black circle", - "slug": "black_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โšช": { - "name": "white circle", - "slug": "white_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸฅ": { - "name": "red square", - "slug": "red_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸง": { - "name": "orange square", - "slug": "orange_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸจ": { - "name": "yellow square", - "slug": "yellow_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸฉ": { - "name": "green square", - "slug": "green_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸฆ": { - "name": "blue square", - "slug": "blue_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸช": { - "name": "purple square", - "slug": "purple_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸซ": { - "name": "brown square", - "slug": "brown_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "โฌ›": { - "name": "black large square", - "slug": "black_large_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฌœ": { - "name": "white large square", - "slug": "white_large_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—ผ๏ธ": { - "name": "black medium square", - "slug": "black_medium_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—ป๏ธ": { - "name": "white medium square", - "slug": "white_medium_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—พ": { - "name": "black medium-small square", - "slug": "black_medium_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—ฝ": { - "name": "white medium-small square", - "slug": "white_medium_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ–ช๏ธ": { - "name": "black small square", - "slug": "black_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ–ซ๏ธ": { - "name": "white small square", - "slug": "white_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ถ": { - "name": "large orange diamond", - "slug": "large_orange_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ท": { - "name": "large blue diamond", - "slug": "large_blue_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ธ": { - "name": "small orange diamond", - "slug": "small_orange_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”น": { - "name": "small blue diamond", - "slug": "small_blue_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”บ": { - "name": "red triangle pointed up", - "slug": "red_triangle_pointed_up", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ป": { - "name": "red triangle pointed down", - "slug": "red_triangle_pointed_down", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ ": { - "name": "diamond with a dot", - "slug": "diamond_with_a_dot", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”˜": { - "name": "radio button", - "slug": "radio_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ณ": { - "name": "white square button", - "slug": "white_square_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฒ": { - "name": "black square button", - "slug": "black_square_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "chequered flag", - "slug": "chequered_flag", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฉ": { - "name": "triangular flag", - "slug": "triangular_flag", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽŒ": { - "name": "crossed flags", - "slug": "crossed_flags", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿด": { - "name": "black flag", - "slug": "black_flag", - "group": "Flags", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿณ๏ธ": { - "name": "white flag", - "slug": "white_flag", - "group": "Flags", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿณ๏ธโ€๐ŸŒˆ": { - "name": "rainbow flag", - "slug": "rainbow_flag", - "group": "Flags", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿณ๏ธโ€โšง๏ธ": { - "name": "transgender flag", - "slug": "transgender_flag", - "group": "Flags", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿดโ€โ˜ ๏ธ": { - "name": "pirate flag", - "slug": "pirate_flag", - "group": "Flags", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡จ": { - "name": "flag Ascension Island", - "slug": "flag_ascension_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฉ": { - "name": "flag Andorra", - "slug": "flag_andorra", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ช": { - "name": "flag United Arab Emirates", - "slug": "flag_united_arab_emirates", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ซ": { - "name": "flag Afghanistan", - "slug": "flag_afghanistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฌ": { - "name": "flag Antigua & Barbuda", - "slug": "flag_antigua_barbuda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฎ": { - "name": "flag Anguilla", - "slug": "flag_anguilla", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฑ": { - "name": "flag Albania", - "slug": "flag_albania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฒ": { - "name": "flag Armenia", - "slug": "flag_armenia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ด": { - "name": "flag Angola", - "slug": "flag_angola", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ถ": { - "name": "flag Antarctica", - "slug": "flag_antarctica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ท": { - "name": "flag Argentina", - "slug": "flag_argentina", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ธ": { - "name": "flag American Samoa", - "slug": "flag_american_samoa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡น": { - "name": "flag Austria", - "slug": "flag_austria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡บ": { - "name": "flag Australia", - "slug": "flag_australia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ผ": { - "name": "flag Aruba", - "slug": "flag_aruba", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฝ": { - "name": "flag ร…land Islands", - "slug": "flag_aland_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฟ": { - "name": "flag Azerbaijan", - "slug": "flag_azerbaijan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฆ": { - "name": "flag Bosnia & Herzegovina", - "slug": "flag_bosnia_herzegovina", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ง": { - "name": "flag Barbados", - "slug": "flag_barbados", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฉ": { - "name": "flag Bangladesh", - "slug": "flag_bangladesh", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ช": { - "name": "flag Belgium", - "slug": "flag_belgium", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ซ": { - "name": "flag Burkina Faso", - "slug": "flag_burkina_faso", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฌ": { - "name": "flag Bulgaria", - "slug": "flag_bulgaria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ญ": { - "name": "flag Bahrain", - "slug": "flag_bahrain", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฎ": { - "name": "flag Burundi", - "slug": "flag_burundi", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฏ": { - "name": "flag Benin", - "slug": "flag_benin", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฑ": { - "name": "flag St. Barthรฉlemy", - "slug": "flag_st_barthelemy", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฒ": { - "name": "flag Bermuda", - "slug": "flag_bermuda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ณ": { - "name": "flag Brunei", - "slug": "flag_brunei", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ด": { - "name": "flag Bolivia", - "slug": "flag_bolivia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ถ": { - "name": "flag Caribbean Netherlands", - "slug": "flag_caribbean_netherlands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ท": { - "name": "flag Brazil", - "slug": "flag_brazil", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ธ": { - "name": "flag Bahamas", - "slug": "flag_bahamas", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡น": { - "name": "flag Bhutan", - "slug": "flag_bhutan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ป": { - "name": "flag Bouvet Island", - "slug": "flag_bouvet_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ผ": { - "name": "flag Botswana", - "slug": "flag_botswana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡พ": { - "name": "flag Belarus", - "slug": "flag_belarus", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฟ": { - "name": "flag Belize", - "slug": "flag_belize", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฆ": { - "name": "flag Canada", - "slug": "flag_canada", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡จ": { - "name": "flag Cocos (Keeling) Islands", - "slug": "flag_cocos_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฉ": { - "name": "flag Congo - Kinshasa", - "slug": "flag_congo_kinshasa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ซ": { - "name": "flag Central African Republic", - "slug": "flag_central_african_republic", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฌ": { - "name": "flag Congo - Brazzaville", - "slug": "flag_congo_brazzaville", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ญ": { - "name": "flag Switzerland", - "slug": "flag_switzerland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฎ": { - "name": "flag Cรดte dโ€™Ivoire", - "slug": "flag_cote_d_ivoire", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฐ": { - "name": "flag Cook Islands", - "slug": "flag_cook_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฑ": { - "name": "flag Chile", - "slug": "flag_chile", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฒ": { - "name": "flag Cameroon", - "slug": "flag_cameroon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ณ": { - "name": "flag China", - "slug": "flag_china", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ด": { - "name": "flag Colombia", - "slug": "flag_colombia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ต": { - "name": "flag Clipperton Island", - "slug": "flag_clipperton_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ท": { - "name": "flag Costa Rica", - "slug": "flag_costa_rica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡บ": { - "name": "flag Cuba", - "slug": "flag_cuba", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ป": { - "name": "flag Cape Verde", - "slug": "flag_cape_verde", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ผ": { - "name": "flag Curaรงao", - "slug": "flag_curacao", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฝ": { - "name": "flag Christmas Island", - "slug": "flag_christmas_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡พ": { - "name": "flag Cyprus", - "slug": "flag_cyprus", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฟ": { - "name": "flag Czechia", - "slug": "flag_czechia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ช": { - "name": "flag Germany", - "slug": "flag_germany", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฌ": { - "name": "flag Diego Garcia", - "slug": "flag_diego_garcia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฏ": { - "name": "flag Djibouti", - "slug": "flag_djibouti", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฐ": { - "name": "flag Denmark", - "slug": "flag_denmark", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฒ": { - "name": "flag Dominica", - "slug": "flag_dominica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ด": { - "name": "flag Dominican Republic", - "slug": "flag_dominican_republic", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฟ": { - "name": "flag Algeria", - "slug": "flag_algeria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ฆ": { - "name": "flag Ceuta & Melilla", - "slug": "flag_ceuta_melilla", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡จ": { - "name": "flag Ecuador", - "slug": "flag_ecuador", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ช": { - "name": "flag Estonia", - "slug": "flag_estonia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ฌ": { - "name": "flag Egypt", - "slug": "flag_egypt", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ญ": { - "name": "flag Western Sahara", - "slug": "flag_western_sahara", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ท": { - "name": "flag Eritrea", - "slug": "flag_eritrea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ธ": { - "name": "flag Spain", - "slug": "flag_spain", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡น": { - "name": "flag Ethiopia", - "slug": "flag_ethiopia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡บ": { - "name": "flag European Union", - "slug": "flag_european_union", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ฎ": { - "name": "flag Finland", - "slug": "flag_finland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ฏ": { - "name": "flag Fiji", - "slug": "flag_fiji", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ฐ": { - "name": "flag Falkland Islands", - "slug": "flag_falkland_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ฒ": { - "name": "flag Micronesia", - "slug": "flag_micronesia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ด": { - "name": "flag Faroe Islands", - "slug": "flag_faroe_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ท": { - "name": "flag France", - "slug": "flag_france", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฆ": { - "name": "flag Gabon", - "slug": "flag_gabon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ง": { - "name": "flag United Kingdom", - "slug": "flag_united_kingdom", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฉ": { - "name": "flag Grenada", - "slug": "flag_grenada", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ช": { - "name": "flag Georgia", - "slug": "flag_georgia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ซ": { - "name": "flag French Guiana", - "slug": "flag_french_guiana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฌ": { - "name": "flag Guernsey", - "slug": "flag_guernsey", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ญ": { - "name": "flag Ghana", - "slug": "flag_ghana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฎ": { - "name": "flag Gibraltar", - "slug": "flag_gibraltar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฑ": { - "name": "flag Greenland", - "slug": "flag_greenland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฒ": { - "name": "flag Gambia", - "slug": "flag_gambia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ณ": { - "name": "flag Guinea", - "slug": "flag_guinea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ต": { - "name": "flag Guadeloupe", - "slug": "flag_guadeloupe", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ถ": { - "name": "flag Equatorial Guinea", - "slug": "flag_equatorial_guinea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ท": { - "name": "flag Greece", - "slug": "flag_greece", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ธ": { - "name": "flag South Georgia & South Sandwich Islands", - "slug": "flag_south_georgia_south_sandwich_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡น": { - "name": "flag Guatemala", - "slug": "flag_guatemala", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡บ": { - "name": "flag Guam", - "slug": "flag_guam", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ผ": { - "name": "flag Guinea-Bissau", - "slug": "flag_guinea_bissau", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡พ": { - "name": "flag Guyana", - "slug": "flag_guyana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡ฐ": { - "name": "flag Hong Kong SAR China", - "slug": "flag_hong_kong_sar_china", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡ฒ": { - "name": "flag Heard & McDonald Islands", - "slug": "flag_heard_mcdonald_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡ณ": { - "name": "flag Honduras", - "slug": "flag_honduras", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡ท": { - "name": "flag Croatia", - "slug": "flag_croatia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡น": { - "name": "flag Haiti", - "slug": "flag_haiti", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡บ": { - "name": "flag Hungary", - "slug": "flag_hungary", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡จ": { - "name": "flag Canary Islands", - "slug": "flag_canary_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ฉ": { - "name": "flag Indonesia", - "slug": "flag_indonesia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ช": { - "name": "flag Ireland", - "slug": "flag_ireland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ฑ": { - "name": "flag Israel", - "slug": "flag_israel", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ฒ": { - "name": "flag Isle of Man", - "slug": "flag_isle_of_man", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ณ": { - "name": "flag India", - "slug": "flag_india", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ด": { - "name": "flag British Indian Ocean Territory", - "slug": "flag_british_indian_ocean_territory", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ถ": { - "name": "flag Iraq", - "slug": "flag_iraq", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ท": { - "name": "flag Iran", - "slug": "flag_iran", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ธ": { - "name": "flag Iceland", - "slug": "flag_iceland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡น": { - "name": "flag Italy", - "slug": "flag_italy", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฏ๐Ÿ‡ช": { - "name": "flag Jersey", - "slug": "flag_jersey", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฏ๐Ÿ‡ฒ": { - "name": "flag Jamaica", - "slug": "flag_jamaica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฏ๐Ÿ‡ด": { - "name": "flag Jordan", - "slug": "flag_jordan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฏ๐Ÿ‡ต": { - "name": "flag Japan", - "slug": "flag_japan", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ช": { - "name": "flag Kenya", - "slug": "flag_kenya", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ฌ": { - "name": "flag Kyrgyzstan", - "slug": "flag_kyrgyzstan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ญ": { - "name": "flag Cambodia", - "slug": "flag_cambodia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ฎ": { - "name": "flag Kiribati", - "slug": "flag_kiribati", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ฒ": { - "name": "flag Comoros", - "slug": "flag_comoros", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ณ": { - "name": "flag St. Kitts & Nevis", - "slug": "flag_st_kitts_nevis", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ต": { - "name": "flag North Korea", - "slug": "flag_north_korea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ท": { - "name": "flag South Korea", - "slug": "flag_south_korea", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ผ": { - "name": "flag Kuwait", - "slug": "flag_kuwait", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡พ": { - "name": "flag Cayman Islands", - "slug": "flag_cayman_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ฟ": { - "name": "flag Kazakhstan", - "slug": "flag_kazakhstan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ฆ": { - "name": "flag Laos", - "slug": "flag_laos", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ง": { - "name": "flag Lebanon", - "slug": "flag_lebanon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡จ": { - "name": "flag St. Lucia", - "slug": "flag_st_lucia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ฎ": { - "name": "flag Liechtenstein", - "slug": "flag_liechtenstein", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ฐ": { - "name": "flag Sri Lanka", - "slug": "flag_sri_lanka", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ท": { - "name": "flag Liberia", - "slug": "flag_liberia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ธ": { - "name": "flag Lesotho", - "slug": "flag_lesotho", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡น": { - "name": "flag Lithuania", - "slug": "flag_lithuania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡บ": { - "name": "flag Luxembourg", - "slug": "flag_luxembourg", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ป": { - "name": "flag Latvia", - "slug": "flag_latvia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡พ": { - "name": "flag Libya", - "slug": "flag_libya", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฆ": { - "name": "flag Morocco", - "slug": "flag_morocco", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡จ": { - "name": "flag Monaco", - "slug": "flag_monaco", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฉ": { - "name": "flag Moldova", - "slug": "flag_moldova", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ช": { - "name": "flag Montenegro", - "slug": "flag_montenegro", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ซ": { - "name": "flag St. Martin", - "slug": "flag_st_martin", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฌ": { - "name": "flag Madagascar", - "slug": "flag_madagascar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ญ": { - "name": "flag Marshall Islands", - "slug": "flag_marshall_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฐ": { - "name": "flag North Macedonia", - "slug": "flag_north_macedonia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฑ": { - "name": "flag Mali", - "slug": "flag_mali", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฒ": { - "name": "flag Myanmar (Burma)", - "slug": "flag_myanmar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ณ": { - "name": "flag Mongolia", - "slug": "flag_mongolia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ด": { - "name": "flag Macao SAR China", - "slug": "flag_macao_sar_china", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ต": { - "name": "flag Northern Mariana Islands", - "slug": "flag_northern_mariana_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ถ": { - "name": "flag Martinique", - "slug": "flag_martinique", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ท": { - "name": "flag Mauritania", - "slug": "flag_mauritania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ธ": { - "name": "flag Montserrat", - "slug": "flag_montserrat", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡น": { - "name": "flag Malta", - "slug": "flag_malta", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡บ": { - "name": "flag Mauritius", - "slug": "flag_mauritius", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ป": { - "name": "flag Maldives", - "slug": "flag_maldives", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ผ": { - "name": "flag Malawi", - "slug": "flag_malawi", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฝ": { - "name": "flag Mexico", - "slug": "flag_mexico", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡พ": { - "name": "flag Malaysia", - "slug": "flag_malaysia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฟ": { - "name": "flag Mozambique", - "slug": "flag_mozambique", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฆ": { - "name": "flag Namibia", - "slug": "flag_namibia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡จ": { - "name": "flag New Caledonia", - "slug": "flag_new_caledonia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ช": { - "name": "flag Niger", - "slug": "flag_niger", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ซ": { - "name": "flag Norfolk Island", - "slug": "flag_norfolk_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฌ": { - "name": "flag Nigeria", - "slug": "flag_nigeria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฎ": { - "name": "flag Nicaragua", - "slug": "flag_nicaragua", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฑ": { - "name": "flag Netherlands", - "slug": "flag_netherlands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ด": { - "name": "flag Norway", - "slug": "flag_norway", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ต": { - "name": "flag Nepal", - "slug": "flag_nepal", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ท": { - "name": "flag Nauru", - "slug": "flag_nauru", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡บ": { - "name": "flag Niue", - "slug": "flag_niue", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฟ": { - "name": "flag New Zealand", - "slug": "flag_new_zealand", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ด๐Ÿ‡ฒ": { - "name": "flag Oman", - "slug": "flag_oman", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฆ": { - "name": "flag Panama", - "slug": "flag_panama", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ช": { - "name": "flag Peru", - "slug": "flag_peru", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ซ": { - "name": "flag French Polynesia", - "slug": "flag_french_polynesia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฌ": { - "name": "flag Papua New Guinea", - "slug": "flag_papua_new_guinea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ญ": { - "name": "flag Philippines", - "slug": "flag_philippines", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฐ": { - "name": "flag Pakistan", - "slug": "flag_pakistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฑ": { - "name": "flag Poland", - "slug": "flag_poland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฒ": { - "name": "flag St. Pierre & Miquelon", - "slug": "flag_st_pierre_miquelon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ณ": { - "name": "flag Pitcairn Islands", - "slug": "flag_pitcairn_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ท": { - "name": "flag Puerto Rico", - "slug": "flag_puerto_rico", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ธ": { - "name": "flag Palestinian Territories", - "slug": "flag_palestinian_territories", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡น": { - "name": "flag Portugal", - "slug": "flag_portugal", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ผ": { - "name": "flag Palau", - "slug": "flag_palau", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡พ": { - "name": "flag Paraguay", - "slug": "flag_paraguay", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ถ๐Ÿ‡ฆ": { - "name": "flag Qatar", - "slug": "flag_qatar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡ช": { - "name": "flag Rรฉunion", - "slug": "flag_reunion", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡ด": { - "name": "flag Romania", - "slug": "flag_romania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡ธ": { - "name": "flag Serbia", - "slug": "flag_serbia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡บ": { - "name": "flag Russia", - "slug": "flag_russia", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡ผ": { - "name": "flag Rwanda", - "slug": "flag_rwanda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฆ": { - "name": "flag Saudi Arabia", - "slug": "flag_saudi_arabia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ง": { - "name": "flag Solomon Islands", - "slug": "flag_solomon_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡จ": { - "name": "flag Seychelles", - "slug": "flag_seychelles", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฉ": { - "name": "flag Sudan", - "slug": "flag_sudan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ช": { - "name": "flag Sweden", - "slug": "flag_sweden", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฌ": { - "name": "flag Singapore", - "slug": "flag_singapore", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ญ": { - "name": "flag St. Helena", - "slug": "flag_st_helena", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฎ": { - "name": "flag Slovenia", - "slug": "flag_slovenia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฏ": { - "name": "flag Svalbard & Jan Mayen", - "slug": "flag_svalbard_jan_mayen", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฐ": { - "name": "flag Slovakia", - "slug": "flag_slovakia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฑ": { - "name": "flag Sierra Leone", - "slug": "flag_sierra_leone", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฒ": { - "name": "flag San Marino", - "slug": "flag_san_marino", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ณ": { - "name": "flag Senegal", - "slug": "flag_senegal", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ด": { - "name": "flag Somalia", - "slug": "flag_somalia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ท": { - "name": "flag Suriname", - "slug": "flag_suriname", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ธ": { - "name": "flag South Sudan", - "slug": "flag_south_sudan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡น": { - "name": "flag Sรฃo Tomรฉ & Prรญncipe", - "slug": "flag_sao_tome_principe", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ป": { - "name": "flag El Salvador", - "slug": "flag_el_salvador", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฝ": { - "name": "flag Sint Maarten", - "slug": "flag_sint_maarten", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡พ": { - "name": "flag Syria", - "slug": "flag_syria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฟ": { - "name": "flag Eswatini", - "slug": "flag_eswatini", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฆ": { - "name": "flag Tristan da Cunha", - "slug": "flag_tristan_da_cunha", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡จ": { - "name": "flag Turks & Caicos Islands", - "slug": "flag_turks_caicos_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฉ": { - "name": "flag Chad", - "slug": "flag_chad", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ซ": { - "name": "flag French Southern Territories", - "slug": "flag_french_southern_territories", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฌ": { - "name": "flag Togo", - "slug": "flag_togo", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ญ": { - "name": "flag Thailand", - "slug": "flag_thailand", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฏ": { - "name": "flag Tajikistan", - "slug": "flag_tajikistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฐ": { - "name": "flag Tokelau", - "slug": "flag_tokelau", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฑ": { - "name": "flag Timor-Leste", - "slug": "flag_timor_leste", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฒ": { - "name": "flag Turkmenistan", - "slug": "flag_turkmenistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ณ": { - "name": "flag Tunisia", - "slug": "flag_tunisia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ด": { - "name": "flag Tonga", - "slug": "flag_tonga", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ท": { - "name": "flag Tรผrkiye", - "slug": "flag_turkiye", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡น": { - "name": "flag Trinidad & Tobago", - "slug": "flag_trinidad_tobago", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ป": { - "name": "flag Tuvalu", - "slug": "flag_tuvalu", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ผ": { - "name": "flag Taiwan", - "slug": "flag_taiwan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฟ": { - "name": "flag Tanzania", - "slug": "flag_tanzania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ฆ": { - "name": "flag Ukraine", - "slug": "flag_ukraine", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ฌ": { - "name": "flag Uganda", - "slug": "flag_uganda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ฒ": { - "name": "flag U.S. Outlying Islands", - "slug": "flag_u_s_outlying_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ณ": { - "name": "flag United Nations", - "slug": "flag_united_nations", - "group": "Flags", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ธ": { - "name": "flag United States", - "slug": "flag_united_states", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡พ": { - "name": "flag Uruguay", - "slug": "flag_uruguay", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ฟ": { - "name": "flag Uzbekistan", - "slug": "flag_uzbekistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ฆ": { - "name": "flag Vatican City", - "slug": "flag_vatican_city", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡จ": { - "name": "flag St. Vincent & Grenadines", - "slug": "flag_st_vincent_grenadines", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ช": { - "name": "flag Venezuela", - "slug": "flag_venezuela", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ฌ": { - "name": "flag British Virgin Islands", - "slug": "flag_british_virgin_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ฎ": { - "name": "flag U.S. Virgin Islands", - "slug": "flag_u_s_virgin_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ณ": { - "name": "flag Vietnam", - "slug": "flag_vietnam", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡บ": { - "name": "flag Vanuatu", - "slug": "flag_vanuatu", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ผ๐Ÿ‡ซ": { - "name": "flag Wallis & Futuna", - "slug": "flag_wallis_futuna", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ผ๐Ÿ‡ธ": { - "name": "flag Samoa", - "slug": "flag_samoa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฝ๐Ÿ‡ฐ": { - "name": "flag Kosovo", - "slug": "flag_kosovo", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡พ๐Ÿ‡ช": { - "name": "flag Yemen", - "slug": "flag_yemen", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡พ๐Ÿ‡น": { - "name": "flag Mayotte", - "slug": "flag_mayotte", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฟ๐Ÿ‡ฆ": { - "name": "flag South Africa", - "slug": "flag_south_africa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฟ๐Ÿ‡ฒ": { - "name": "flag Zambia", - "slug": "flag_zambia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฟ๐Ÿ‡ผ": { - "name": "flag Zimbabwe", - "slug": "flag_zimbabwe", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ": { - "name": "flag England", - "slug": "flag_england", - "group": "Flags", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ": { - "name": "flag Scotland", - "slug": "flag_scotland", - "group": "Flags", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ": { - "name": "flag Wales", - "slug": "flag_wales", - "group": "Flags", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - } -} diff --git a/config/assets/icons/applets/bluetooth-clear.svg b/config/assets/icons/applets/bluetooth-clear.svg deleted file mode 100644 index 6348bc5..0000000 --- a/config/assets/icons/applets/bluetooth-clear.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/config/assets/icons/applets/bluetooth-off-clear.svg b/config/assets/icons/applets/bluetooth-off-clear.svg deleted file mode 100644 index cea2aa5..0000000 --- a/config/assets/icons/applets/bluetooth-off-clear.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/config/assets/icons/applets/bluetooth-off.svg b/config/assets/icons/applets/bluetooth-off.svg deleted file mode 100644 index 308fba6..0000000 --- a/config/assets/icons/applets/bluetooth-off.svg +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - diff --git a/config/assets/icons/applets/bluetooth-paired.svg b/config/assets/icons/applets/bluetooth-paired.svg deleted file mode 100644 index c400923..0000000 --- a/config/assets/icons/applets/bluetooth-paired.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/applets/bluetooth.svg b/config/assets/icons/applets/bluetooth.svg deleted file mode 100644 index d3a0b61..0000000 --- a/config/assets/icons/applets/bluetooth.svg +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - diff --git a/config/assets/icons/applets/brightness.svg b/config/assets/icons/applets/brightness.svg deleted file mode 100755 index bcc61ef..0000000 --- a/config/assets/icons/applets/brightness.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/applets/caffeine-off.svg b/config/assets/icons/applets/caffeine-off.svg deleted file mode 100644 index b59dfe0..0000000 --- a/config/assets/icons/applets/caffeine-off.svg +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/config/assets/icons/applets/caffeine-on.svg b/config/assets/icons/applets/caffeine-on.svg deleted file mode 100644 index 2432b4a..0000000 --- a/config/assets/icons/applets/caffeine-on.svg +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/config/assets/icons/applets/dnd-clear.svg b/config/assets/icons/applets/dnd-clear.svg deleted file mode 100644 index 19b053f..0000000 --- a/config/assets/icons/applets/dnd-clear.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/config/assets/icons/applets/dnd-off.svg b/config/assets/icons/applets/dnd-off.svg deleted file mode 100644 index fe6a37c..0000000 --- a/config/assets/icons/applets/dnd-off.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/applets/dnd.svg b/config/assets/icons/applets/dnd.svg deleted file mode 100644 index 4ecdd99..0000000 --- a/config/assets/icons/applets/dnd.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/config/assets/icons/applets/flight-off.svg b/config/assets/icons/applets/flight-off.svg deleted file mode 100644 index d59785c..0000000 --- a/config/assets/icons/applets/flight-off.svg +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - diff --git a/config/assets/icons/applets/flight-on.svg b/config/assets/icons/applets/flight-on.svg deleted file mode 100644 index 5a492f1..0000000 --- a/config/assets/icons/applets/flight-on.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - diff --git a/config/assets/icons/applets/network-wired-offline.svg b/config/assets/icons/applets/network-wired-offline.svg deleted file mode 100644 index c1734ca..0000000 --- a/config/assets/icons/applets/network-wired-offline.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/applets/network-wired.svg b/config/assets/icons/applets/network-wired.svg deleted file mode 100644 index 7b6881e..0000000 --- a/config/assets/icons/applets/network-wired.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/applets/redshift-status-off.svg b/config/assets/icons/applets/redshift-status-off.svg deleted file mode 100644 index e81d366..0000000 --- a/config/assets/icons/applets/redshift-status-off.svg +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/config/assets/icons/applets/redshift-status-on.svg b/config/assets/icons/applets/redshift-status-on.svg deleted file mode 100644 index aaa2959..0000000 --- a/config/assets/icons/applets/redshift-status-on.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - diff --git a/config/assets/icons/applets/wifi-clear.svg b/config/assets/icons/applets/wifi-clear.svg deleted file mode 100644 index 9276ebe..0000000 --- a/config/assets/icons/applets/wifi-clear.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - diff --git a/config/assets/icons/applets/wifi-off-clear.svg b/config/assets/icons/applets/wifi-off-clear.svg deleted file mode 100644 index d19de29..0000000 --- a/config/assets/icons/applets/wifi-off-clear.svg +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - diff --git a/config/assets/icons/applets/wifi-off.svg b/config/assets/icons/applets/wifi-off.svg deleted file mode 100644 index ba5c100..0000000 --- a/config/assets/icons/applets/wifi-off.svg +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/config/assets/icons/applets/wifi.svg b/config/assets/icons/applets/wifi.svg deleted file mode 100644 index 6a0dd08..0000000 --- a/config/assets/icons/applets/wifi.svg +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/config/assets/icons/battery/battery-000-charging.svg b/config/assets/icons/battery/battery-000-charging.svg deleted file mode 100644 index ac8c28f..0000000 --- a/config/assets/icons/battery/battery-000-charging.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/config/assets/icons/battery/battery-000.svg b/config/assets/icons/battery/battery-000.svg deleted file mode 100644 index c49fb45..0000000 --- a/config/assets/icons/battery/battery-000.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/config/assets/icons/battery/battery-010-charging.svg b/config/assets/icons/battery/battery-010-charging.svg deleted file mode 100644 index c9ea308..0000000 --- a/config/assets/icons/battery/battery-010-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-010.svg b/config/assets/icons/battery/battery-010.svg deleted file mode 100644 index b88e8fb..0000000 --- a/config/assets/icons/battery/battery-010.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/battery/battery-020-charging.svg b/config/assets/icons/battery/battery-020-charging.svg deleted file mode 100644 index 5824ad4..0000000 --- a/config/assets/icons/battery/battery-020-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-020.svg b/config/assets/icons/battery/battery-020.svg deleted file mode 100644 index 87375ae..0000000 --- a/config/assets/icons/battery/battery-020.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/battery/battery-030-charging.svg b/config/assets/icons/battery/battery-030-charging.svg deleted file mode 100644 index c0088ce..0000000 --- a/config/assets/icons/battery/battery-030-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-030.svg b/config/assets/icons/battery/battery-030.svg deleted file mode 100644 index 312ab1b..0000000 --- a/config/assets/icons/battery/battery-030.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/battery/battery-040-charging.svg b/config/assets/icons/battery/battery-040-charging.svg deleted file mode 100644 index 1327b10..0000000 --- a/config/assets/icons/battery/battery-040-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-040.svg b/config/assets/icons/battery/battery-040.svg deleted file mode 100644 index ff8a28a..0000000 --- a/config/assets/icons/battery/battery-040.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/battery/battery-050-charging.svg b/config/assets/icons/battery/battery-050-charging.svg deleted file mode 100644 index 016985f..0000000 --- a/config/assets/icons/battery/battery-050-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-050.svg b/config/assets/icons/battery/battery-050.svg deleted file mode 100644 index 0e6af06..0000000 --- a/config/assets/icons/battery/battery-050.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/battery/battery-060-charging.svg b/config/assets/icons/battery/battery-060-charging.svg deleted file mode 100644 index e23c733..0000000 --- a/config/assets/icons/battery/battery-060-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-060.svg b/config/assets/icons/battery/battery-060.svg deleted file mode 100644 index f9c44b9..0000000 --- a/config/assets/icons/battery/battery-060.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-070-charging.svg b/config/assets/icons/battery/battery-070-charging.svg deleted file mode 100644 index cf18c55..0000000 --- a/config/assets/icons/battery/battery-070-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-070.svg b/config/assets/icons/battery/battery-070.svg deleted file mode 100644 index abd1a8e..0000000 --- a/config/assets/icons/battery/battery-070.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/battery/battery-080-charging.svg b/config/assets/icons/battery/battery-080-charging.svg deleted file mode 100644 index 672c08f..0000000 --- a/config/assets/icons/battery/battery-080-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-080.svg b/config/assets/icons/battery/battery-080.svg deleted file mode 100644 index 0a78582..0000000 --- a/config/assets/icons/battery/battery-080.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/battery/battery-090-charging.svg b/config/assets/icons/battery/battery-090-charging.svg deleted file mode 100644 index 2e81ab9..0000000 --- a/config/assets/icons/battery/battery-090-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-090.svg b/config/assets/icons/battery/battery-090.svg deleted file mode 100644 index 8cc1219..0000000 --- a/config/assets/icons/battery/battery-090.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/battery/battery-100-charging.svg b/config/assets/icons/battery/battery-100-charging.svg deleted file mode 100644 index 22471ae..0000000 --- a/config/assets/icons/battery/battery-100-charging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/config/assets/icons/battery/battery-100.svg b/config/assets/icons/battery/battery-100.svg deleted file mode 100644 index 9d9104b..0000000 --- a/config/assets/icons/battery/battery-100.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/brightness/brightness-0.svg b/config/assets/icons/brightness/brightness-0.svg deleted file mode 100755 index d6065c7..0000000 --- a/config/assets/icons/brightness/brightness-0.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/brightness/brightness-1.svg b/config/assets/icons/brightness/brightness-1.svg deleted file mode 100755 index e5b4101..0000000 --- a/config/assets/icons/brightness/brightness-1.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/brightness/brightness-2.svg b/config/assets/icons/brightness/brightness-2.svg deleted file mode 100755 index df7cfba..0000000 --- a/config/assets/icons/brightness/brightness-2.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/brightness/brightness-3.svg b/config/assets/icons/brightness/brightness-3.svg deleted file mode 100755 index c3b390b..0000000 --- a/config/assets/icons/brightness/brightness-3.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/brightness/brightness.svg b/config/assets/icons/brightness/brightness.svg deleted file mode 100755 index bcc61ef..0000000 --- a/config/assets/icons/brightness/brightness.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/mic/microphone-0.svg b/config/assets/icons/mic/microphone-0.svg deleted file mode 100755 index ef1f97a..0000000 --- a/config/assets/icons/mic/microphone-0.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/config/assets/icons/mic/microphone-1.svg b/config/assets/icons/mic/microphone-1.svg deleted file mode 100755 index dfa3008..0000000 --- a/config/assets/icons/mic/microphone-1.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/mic/microphone-2.svg b/config/assets/icons/mic/microphone-2.svg deleted file mode 100755 index 0b47426..0000000 --- a/config/assets/icons/mic/microphone-2.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - diff --git a/config/assets/icons/mic/microphone-3.svg b/config/assets/icons/mic/microphone-3.svg deleted file mode 100755 index f48bd31..0000000 --- a/config/assets/icons/mic/microphone-3.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/mic/microphone.svg b/config/assets/icons/mic/microphone.svg deleted file mode 100755 index dfa3008..0000000 --- a/config/assets/icons/mic/microphone.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/misc/chevron-left.svg b/config/assets/icons/misc/chevron-left.svg deleted file mode 100644 index 8cade29..0000000 --- a/config/assets/icons/misc/chevron-left.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/config/assets/icons/misc/chevron-right.svg b/config/assets/icons/misc/chevron-right.svg deleted file mode 100644 index f81b1e6..0000000 --- a/config/assets/icons/misc/chevron-right.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/config/assets/icons/misc/control-center.svg b/config/assets/icons/misc/control-center.svg deleted file mode 100644 index 24f2195..0000000 --- a/config/assets/icons/misc/control-center.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/config/assets/icons/misc/control.svg b/config/assets/icons/misc/control.svg deleted file mode 100644 index ed4e9d7..0000000 --- a/config/assets/icons/misc/control.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/config/assets/icons/misc/imac.svg b/config/assets/icons/misc/imac.svg deleted file mode 100644 index 47719db..0000000 --- a/config/assets/icons/misc/imac.svg +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/config/assets/icons/misc/logo.svg b/config/assets/icons/misc/logo.svg deleted file mode 100644 index 9fa578d..0000000 --- a/config/assets/icons/misc/logo.svg +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - diff --git a/config/assets/icons/misc/media-record.svg b/config/assets/icons/misc/media-record.svg deleted file mode 100644 index 8f71f09..0000000 --- a/config/assets/icons/misc/media-record.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/config/assets/icons/misc/search.svg b/config/assets/icons/misc/search.svg deleted file mode 100644 index ef84365..0000000 --- a/config/assets/icons/misc/search.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/notification.png b/config/assets/icons/notification.png deleted file mode 100644 index 41d66d6..0000000 Binary files a/config/assets/icons/notification.png and /dev/null differ diff --git a/config/assets/icons/notifications/notification-active.svg b/config/assets/icons/notifications/notification-active.svg deleted file mode 100644 index 63452f8..0000000 --- a/config/assets/icons/notifications/notification-active.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/config/assets/icons/notifications/notification-disabled.svg b/config/assets/icons/notifications/notification-disabled.svg deleted file mode 100644 index d4cb163..0000000 --- a/config/assets/icons/notifications/notification-disabled.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/config/assets/icons/notifications/notification-inactive.svg b/config/assets/icons/notifications/notification-inactive.svg deleted file mode 100644 index 108e755..0000000 --- a/config/assets/icons/notifications/notification-inactive.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/config/assets/icons/player/Pause.svg b/config/assets/icons/player/Pause.svg deleted file mode 100644 index f95a5b8..0000000 --- a/config/assets/icons/player/Pause.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - Layer 1 - - - \ No newline at end of file diff --git a/config/assets/icons/player/Rewind.svg b/config/assets/icons/player/Rewind.svg deleted file mode 100644 index e52bc1d..0000000 --- a/config/assets/icons/player/Rewind.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/config/assets/icons/player/audio-switcher.svg b/config/assets/icons/player/audio-switcher.svg deleted file mode 100644 index d50264f..0000000 --- a/config/assets/icons/player/audio-switcher.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/player/fwd.svg b/config/assets/icons/player/fwd.svg deleted file mode 100644 index 57f16c3..0000000 --- a/config/assets/icons/player/fwd.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/config/assets/icons/player/play.svg b/config/assets/icons/player/play.svg deleted file mode 100644 index d3356bf..0000000 --- a/config/assets/icons/player/play.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/power_modes/battery-balanced.svg b/config/assets/icons/power_modes/battery-balanced.svg deleted file mode 100644 index 9d9104b..0000000 --- a/config/assets/icons/power_modes/battery-balanced.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/power_modes/battery-performance.svg b/config/assets/icons/power_modes/battery-performance.svg deleted file mode 100644 index 3bd7b42..0000000 --- a/config/assets/icons/power_modes/battery-performance.svg +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - diff --git a/config/assets/icons/power_modes/battery-power.svg b/config/assets/icons/power_modes/battery-power.svg deleted file mode 100644 index 87375ae..0000000 --- a/config/assets/icons/power_modes/battery-power.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/config/assets/icons/todo/checkbox-check.svg b/config/assets/icons/todo/checkbox-check.svg deleted file mode 100644 index 198f1dd..0000000 --- a/config/assets/icons/todo/checkbox-check.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - diff --git a/config/assets/icons/todo/checkbox-uncheck.svg b/config/assets/icons/todo/checkbox-uncheck.svg deleted file mode 100644 index a0ee941..0000000 --- a/config/assets/icons/todo/checkbox-uncheck.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - diff --git a/config/assets/icons/todo/delete-symbolic.svg b/config/assets/icons/todo/delete-symbolic.svg deleted file mode 100644 index 180f034..0000000 --- a/config/assets/icons/todo/delete-symbolic.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - diff --git a/config/assets/icons/todo/edit.svg b/config/assets/icons/todo/edit.svg deleted file mode 100644 index 42b5282..0000000 --- a/config/assets/icons/todo/edit.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - diff --git a/config/assets/icons/todo/plus-symbolic.svg b/config/assets/icons/todo/plus-symbolic.svg deleted file mode 100644 index af065bd..0000000 --- a/config/assets/icons/todo/plus-symbolic.svg +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/todo/to-do-app-symbolic.svg b/config/assets/icons/todo/to-do-app-symbolic.svg deleted file mode 100644 index 17cbcdf..0000000 --- a/config/assets/icons/todo/to-do-app-symbolic.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/config/assets/icons/volume/audio-volume-0.svg b/config/assets/icons/volume/audio-volume-0.svg deleted file mode 100644 index 2728d98..0000000 --- a/config/assets/icons/volume/audio-volume-0.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/config/assets/icons/volume/audio-volume-1.svg b/config/assets/icons/volume/audio-volume-1.svg deleted file mode 100644 index 8378bed..0000000 --- a/config/assets/icons/volume/audio-volume-1.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/config/assets/icons/volume/audio-volume-2.svg b/config/assets/icons/volume/audio-volume-2.svg deleted file mode 100644 index 927462a..0000000 --- a/config/assets/icons/volume/audio-volume-2.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/config/assets/icons/volume/audio-volume-3.svg b/config/assets/icons/volume/audio-volume-3.svg deleted file mode 100644 index abd00cb..0000000 --- a/config/assets/icons/volume/audio-volume-3.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/config/assets/icons/volume/audio-volume.svg b/config/assets/icons/volume/audio-volume.svg deleted file mode 100644 index abd00cb..0000000 --- a/config/assets/icons/volume/audio-volume.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/config/assets/icons/wifi/network-wireless-0.svg b/config/assets/icons/wifi/network-wireless-0.svg deleted file mode 100644 index 79a7d67..0000000 --- a/config/assets/icons/wifi/network-wireless-0.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - diff --git a/config/assets/icons/wifi/network-wireless-100.svg b/config/assets/icons/wifi/network-wireless-100.svg deleted file mode 100644 index 9276ebe..0000000 --- a/config/assets/icons/wifi/network-wireless-100.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - diff --git a/config/assets/icons/wifi/network-wireless-20.svg b/config/assets/icons/wifi/network-wireless-20.svg deleted file mode 100644 index 5dd4259..0000000 --- a/config/assets/icons/wifi/network-wireless-20.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - diff --git a/config/assets/icons/wifi/network-wireless-40.svg b/config/assets/icons/wifi/network-wireless-40.svg deleted file mode 100644 index d323c5c..0000000 --- a/config/assets/icons/wifi/network-wireless-40.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - diff --git a/config/assets/icons/wifi/network-wireless-60.svg b/config/assets/icons/wifi/network-wireless-60.svg deleted file mode 100644 index 47c2a37..0000000 --- a/config/assets/icons/wifi/network-wireless-60.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - diff --git a/config/assets/icons/wifi/network-wireless-80.svg b/config/assets/icons/wifi/network-wireless-80.svg deleted file mode 100644 index 6e65071..0000000 --- a/config/assets/icons/wifi/network-wireless-80.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - diff --git a/config/assets/icons/wifi/wifi-connecting.svg b/config/assets/icons/wifi/wifi-connecting.svg deleted file mode 100644 index 6d95e92..0000000 --- a/config/assets/icons/wifi/wifi-connecting.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/config/assets/launcher.json b/config/assets/launcher.json deleted file mode 100644 index 9d411e0..0000000 --- a/config/assets/launcher.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "launcher_config": { - "em": { - "icon": "emoji-people-symbolic", - "description": "Emoji - Search and copy emojis" - }, - "clip": { - "icon": "app.getclipboard.Clipboard", - "description": "Clipboard - Search and manage clipboard history" - }, - "=": { - "examples": [ - "= 10*5", - "= 2^8", - "= sin(30)" - ], - "icon": "calculator", - "description": "Quick Math - Fast mathematical expressions" - }, - "app": { - "icon": "apps", - "description": "Applications - Launch installed applications" - }, - "bin": { - "icon": "terminal", - "description": "Bins - Search and run executable binaries" - }, - "power": { - "icon": "shutdown", - "description": "Power - System power management and session control" - }, - "caffeine": { - "examples": [ - "caffeine 30m", - "caffeine 1h", - "caffeine 2h", - "caffeine on" - ], - "icon": "caffeine", - "description": "Caffeine - Prevent system from going idle" - }, - "sc": { - "icon": "cs-screen", - "description": "Screencapture - Record screen and audio" - }, - "wall": { - "icon": "daily-wallpaper", - "description": "Wallpapers - Set wallpapers, and tons of features" - }, - "?": { - "icon": "search-symbolic", - "description": "Quick Web Search - Fast web search with question mark", - "examples": [ - "? cats", - "? google cats", - "? youtube music" - ] - }, - "remind": { - "icon": "alarm-timer", - "description": "Reminders - Set time-based reminders with notifications" - }, - "otp": { - "icon": "auth-otp-symbolic", - "description": "Manage TOTP codes and 2FA authentication" - }, - "pass": { - "icon": "nextcloud-password-client", - "description": "Password Manager - Search and manage passwords" - }, - "bm": { - "icon": "bookmark-add-symbolic", - "description": "Bookmarks - Search and manage bookmarks" - }, - "script": { - "icon": "terminal", - "description": "Bash Scripts - Manage and execute bash scripts" - }, - "bash": { - "icon": "terminal", - "description": "Bash Scripts - Manage and execute bash scripts" - }, - "sh": { - "icon": "terminal", - "description": "Bash Scripts - Manage and execute bash scripts" - }, - "tmux": { - "icon": "terminal", - "description": "Tmux Manager - Create, attach, rename, and kill tmux sessions" - } - }, - "settings": { - "max_examples_shown": 2, - "default_icon": "apps", - "fallback_example_template": "{trigger} ", - "config_version": "1.0" - } -} diff --git a/config/data.py b/config/data.py deleted file mode 100644 index e4aa977..0000000 --- a/config/data.py +++ /dev/null @@ -1,118 +0,0 @@ -import json -import os - -import gi -from fabric.utils.helpers import get_relative_path -from gi.repository import Gdk, GLib - -gi.require_version("Gtk", "3.0") - -APP_NAME = "modus" -APP_NAME_CAP = "Modus" - - -def parse_timeout_string(timeout_str): - """ - Parse timeout string in format like '5s', '10m', '30s' etc. - Returns timeout in milliseconds. - """ - if not timeout_str or not isinstance(timeout_str, str): - return 5000 - - timeout_str = timeout_str.strip().lower() - - if timeout_str.endswith("s"): - try: - seconds = int(timeout_str[:-1]) - return seconds * 1000 - except ValueError: - return 5000 - elif timeout_str.endswith("m"): - try: - minutes = int(timeout_str[:-1]) - return minutes * 60 * 1000 - except ValueError: - return 5000 - else: - try: - seconds = int(timeout_str) - return seconds * 1000 - except ValueError: - return 5000 - - -CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}" - -USERNAME = os.getlogin() -HOSTNAME = os.uname().nodename -HOME_DIR = os.path.expanduser("~") - -CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}") - -screen = Gdk.Screen.get_default() -CURRENT_WIDTH = screen.get_width() -CURRENT_HEIGHT = screen.get_height() - - -WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/") -CONFIG_FILE = get_relative_path("../config/assets/config.json") -MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") - - -def load_config(): - """Load the configuration from config.json""" - config = {} - - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, "r") as f: - config = json.load(f) - except Exception as e: - print(f"Error loading config: {e}") - - return config - - -if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, "r") as f: - config = json.load(f) - wallpapers_dir_from_config = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT) - WALLPAPERS_DIR = os.path.expanduser(wallpapers_dir_from_config) - DOCK_POSITION = config.get("dock_position", "Bottom") - TERMINAL_COMMAND = config.get("terminal_command", "kitty -e") - DOCK_ENABLED = config.get("dock_enabled", True) - DOCK_AUTO_HIDE = config.get("dock_auto_hide", True) - DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False) - DOCK_ICON_SIZE = config.get("dock_icon_size", 60) - WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 10) - HIDE_SPECIAL_WORKSPACE = config.get("hide_special_workspace", True) - DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get( - "dock_hide_special_workspace_apps", True - ) - - NOTIFICATION_TIMEOUT_STR = config.get("notification_timeout", "5s") - NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) - NOTIFICATION_IGNORED_APPS_HISTORY = config.get( - "notification_ignored_apps_history", ["Hyprshot"] - ) - NOTIFICATION_LIMITED_APPS_HISTORY = config.get( - "notification_limited_apps_history", ["Spotify"] - ) - -else: - WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT - DOCK_POSITION = "Bottom" - DOCK_ENABLED = True - DOCK_ALWAYS_OCCLUDED = False - DOCK_AUTO_HIDE = True - TERMINAL_COMMAND = "kitty -e" - DOCK_THEME = "Pills" - DOCK_ICON_SIZE = 60 - WINDOW_SWITCHER_ITEMS_PER_ROW = 10 - HIDE_SPECIAL_WORKSPACE = True - DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True - - NOTIFICATION_TIMEOUT_STR = "5s" - NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) - NOTIFICATION_IGNORED_APPS_HISTORY = ["Hyprshot"] - NOTIFICATION_LIMITED_APPS_HISTORY = ["Spotify"] diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf deleted file mode 100644 index cfa51cb..0000000 --- a/config/hypr/modus.conf +++ /dev/null @@ -1,53 +0,0 @@ -# LAYER RULES FOR BLURS - -layerrule = blur, ^modus-notifications$ -layerrule = xray 0, ^modus-notifications$ -layerrule = blurpopups, ^modus-notifications$ -layerrule = ignorezero, ^modus-notifications$ -layerrule = noanim , ^modus-notifications$ -layerrule = animation popin, ^lockscreen$ - -layerrule = blur, ^modus-launcher$ -layerrule = xray 0, ^modus-launcher$ -layerrule = blurpopups, ^modus-launcher$ -layerrule = ignorezero, ^modus-launcher$ -layerrule = animation popin, ^modus-launcher$ - -layerrule = blur, ^fabric$ -layerrule = ignorezero, ^fabric$ -layerrule = xray 0, ^fabric$ -layerrule = blurpopups, ^fabric$ -layerrule = blur, ^modus$ -layerrule = xray 0, ^modus$ -layerrule = blurpopups, ^modus$ -layerrule = ignorezero, ^modus$ -layerrule = animation slide right, ^notification-center$ - - -# KEYBINDS - -exec-once = uwsm app -- python ~/.config/Modus/main.py -$fabricSend = fabric-cli exec modus -# Reload Modus -bind = SUPER ALT, B, exec, killall modus; uwsm-app $(python $HOME/.config/Modus/main.py) -# Message -bind = SUPER SHIFT, y, exec, $fabricSend 'app.set_css()' # Reload CSS -# # Application Switcher -bind = ALT, TAB, exec, $fabricSend 'switcher.show_switcher()' -# Kanban -bind = SUPER, T, exec, $fabricSend "launcher.show_launcher('kanban')" # Kanban -# App Launcher -bind = SUPER, SPACE, exec, $fabricSend "launcher.show_launcher()" -# Clipboard History -bind = SUPER, V, exec, $fabricSend "launcher.show_launcher('clip')" -# Wallpapers -bind = SUPER, W, exec, $fabricSend "launcher.show_launcher('wall')" -# Random Wallpaper -bind = ALT SHIFT, W, exec, $fabricSend "launcher.show_launcher('wall random', external=True)" -# Emoji Picker -bind = SUPER, Period, exec, $fabricSend "launcher.show_launcher('em')" -# Power Menu -bind = SUPER, ESCAPE, exec, $fabricSend "launcher.show_launcher('power')" -# Toggle Caffeine -bind = SUPER SHIFT, M, exec, $fabricSend "launcher.show_launcher('caffeine on', external=True)" - diff --git a/config/matugen/templates/hyprland-colors.conf b/config/matugen/templates/hyprland-colors.conf deleted file mode 100644 index 9aeb090..0000000 --- a/config/matugen/templates/hyprland-colors.conf +++ /dev/null @@ -1,27 +0,0 @@ -$wallpaper = {{image}} -$background = {{colors.background.default.hex_stripped}} -$foreground = {{colors.on_background.default.hex_stripped}} - -$primary = {{colors.primary.default.hex_stripped}} -$secondary = {{colors.secondary.default.hex_stripped}} -$tertiary = {{colors.tertiary.default.hex_stripped}} -$surface = {{colors.surface.default.hex_stripped}} -$surface_bright = {{colors.surface_bright.default.hex_stripped}} -$outline = {{colors.outline.default.hex_stripped}} -$error = {{colors.error.default.hex_stripped | set_lightness: -20.0}} - -$shadow = {{colors.shadow.default.hex_stripped}} - -$red = {{colors.red.default.hex_stripped}} -$green = {{colors.green.default.hex_stripped}} -$yellow = {{colors.yellow.default.hex_stripped}} -$blue = {{colors.blue.default.hex_stripped}} -$magenta = {{colors.magenta.default.hex_stripped}} -$cyan = {{colors.cyan.default.hex_stripped}} -$white = {{colors.white.default.hex_stripped}} -$red_dim = {{colors.red.default.hex_stripped | set_lightness: -20.0}} -$green_dim = {{colors.green.default.hex_stripped | set_lightness: -20.0}} -$yellow_dim = {{colors.yellow.default.hex_stripped | set_lightness: -20.0}} -$blue_dim = {{colors.blue.default.hex_stripped | set_lightness: -20.0}} -$magenta_dim = {{colors.magenta.default.hex_stripped | set_lightness: -20.0}} -$cyan_dim = {{colors.cyan.default.hex_stripped | set_lightness: -20.0}} diff --git a/config/matugen/templates/macos.css b/config/matugen/templates/macos.css deleted file mode 100644 index 7d8c607..0000000 --- a/config/matugen/templates/macos.css +++ /dev/null @@ -1,32 +0,0 @@ -:vars { - --foreground: {{colors.on_background.default.hex}}; - --background: {{colors.background.default.hex}}; - --cursor: {{colors.on_background.default.hex}}; - --primary: {{colors.primary.default.hex}}; - --on-primary: {{colors.on_primary.default.hex}}; - --secondary: {{colors.secondary.default.hex}}; - --on-secondary: {{colors.on_secondary.default.hex}}; - --tertiary: {{colors.tertiary.default.hex}}; - --on-tertiary: {{colors.on_tertiary.default.hex}}; - --surface: {{colors.surface.default.hex}}; - --surface-bright: {{colors.surface_bright.default.hex}}; - --error: {{colors.error.default.hex}}; - --error-dim: {{colors.error.default.hex | set_lightness: -10.0}}; - --on-error: {{colors.on_error.default.hex}}; - --error-container: {{colors.error_container.default.hex}}; - --outline: {{colors.outline.default.hex}}; - --shadow: {{colors.shadow.default.hex}}; - --red: {{colors.red.default.hex}}; - --red-dim: {{colors.red.default.hex | set_lightness: -10.0}}; - --green: {{colors.green.default.hex}}; - --green-dim: {{colors.green.default.hex | set_lightness: -10.0}}; - --yellow: {{colors.yellow.default.hex}}; - --yellow-dim: {{colors.yellow.default.hex | set_lightness: -10.0}}; - --blue: {{colors.blue.default.hex}}; - --blue-dim: {{colors.blue.default.hex | set_lightness: -10.0}}; - --magenta: {{colors.magenta.default.hex}}; - --magenta-dim: {{colors.magenta.default.hex | set_lightness: -10.0}}; - --cyan: {{colors.cyan.default.hex}}; - --cyan-dim: {{colors.cyan.default.hex | set_lightness: -10.0}}; - --white: {{colors.white.default.hex}}; -} diff --git a/debug_memory.py b/debug_memory.py deleted file mode 100644 index 2e175e7..0000000 --- a/debug_memory.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -""" -Real-time memory monitor for debugging expanded player memory leaks. -This module provides functions to track memory usage in real-time. -""" - -import psutil -import os -import gc -import threading -import time -from loguru import logger - - -class MemoryMonitor: - """Real-time memory monitoring for debugging memory leaks.""" - - def __init__(self): - self.process = psutil.Process(os.getpid()) - self.baseline_memory = None - self.last_memory = None - self.monitoring = False - self.monitor_thread = None - - def get_memory_usage(self): - """Get current memory usage in MB.""" - return self.process.memory_info().rss / 1024 / 1024 - - def get_memory_details(self): - """Get detailed memory information.""" - memory_info = self.process.memory_info() - memory_percent = self.process.memory_percent() - - return { - "rss_mb": memory_info.rss / 1024 / 1024, - "vms_mb": memory_info.vms / 1024 / 1024, - "percent": memory_percent, - "num_threads": self.process.num_threads(), - } - - def set_baseline(self, label="Baseline"): - """Set the baseline memory usage.""" - self.baseline_memory = self.get_memory_usage() - logger.info(f"๐ŸŽฏ {label} memory: {self.baseline_memory:.1f} MB") - return self.baseline_memory - - def log_memory_change(self, label="Memory Check", force_gc=True): - """Log current memory usage and change from baseline.""" - if force_gc: - gc.collect() - - current_memory = self.get_memory_usage() - details = self.get_memory_details() - - if self.baseline_memory: - delta = current_memory - self.baseline_memory - logger.info( - f"๐Ÿ“Š {label}: {current_memory:.1f} MB (ฮ”: {delta:+.1f} MB) | Threads: { - details['num_threads'] - }" - ) - else: - logger.info( - f"๐Ÿ“Š {label}: {current_memory:.1f} MB | Threads: { - details['num_threads'] - }" - ) - - self.last_memory = current_memory - return current_memory - - def log_memory_spike(self, threshold_mb=10): - """Log if there's a significant memory increase.""" - if self.last_memory: - current = self.get_memory_usage() - increase = current - self.last_memory - if increase > threshold_mb: - logger.warning( - f"๐Ÿšจ MEMORY SPIKE: +{increase:.1f} MB (from {self.last_memory:.1f} to {current:.1f} MB)" - ) - return True - return False - - def start_continuous_monitoring(self, interval_seconds=2): - """Start continuous background monitoring.""" - if self.monitoring: - return - - self.monitoring = True - - def monitor_loop(): - while self.monitoring: - try: - self.log_memory_change("Continuous Monitor", force_gc=False) - time.sleep(interval_seconds) - except Exception as e: - logger.error(f"Memory monitor error: {e}") - break - - self.monitor_thread = threading.Thread(target=monitor_loop, daemon=True) - self.monitor_thread.start() - logger.info( - f"๐Ÿ” Started continuous memory monitoring (every {interval_seconds}s)" - ) - - def stop_continuous_monitoring(self): - """Stop continuous monitoring.""" - if self.monitoring: - self.monitoring = False - logger.info("๐Ÿ›‘ Stopped continuous memory monitoring") - - -# Global memory monitor instance -memory_monitor = MemoryMonitor() - -# Convenience functions for easy use - - -def set_memory_baseline(label="Baseline"): - """Set memory baseline.""" - return memory_monitor.set_baseline(label) - - -def log_memory(label="Memory Check"): - """Log current memory usage.""" - return memory_monitor.log_memory_change(label) - - -def start_memory_monitoring(): - """Start continuous memory monitoring.""" - memory_monitor.start_continuous_monitoring() - - -def stop_memory_monitoring(): - """Stop continuous memory monitoring.""" - memory_monitor.stop_continuous_monitoring() - - -def check_memory_spike(): - """Check for memory spike.""" - return memory_monitor.log_memory_spike() - - -# Test function - - -def test_memory_monitor(): - """Test the memory monitor.""" - print("Testing Memory Monitor...") - monitor = MemoryMonitor() - - monitor.set_baseline("Test Start") - - # Simulate some memory usage - big_list = [i for i in range(100000)] - monitor.log_memory_change("After creating big list") - - del big_list - monitor.log_memory_change("After deleting big list") - - print("Memory monitor test complete!") - - -if __name__ == "__main__": - test_memory_monitor() diff --git a/install.sh b/install.sh deleted file mode 100755 index e17294d..0000000 --- a/install.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/bin/bash - -# โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— -# โ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• -# โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— -# โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ•šโ•โ•โ•โ•โ–ˆโ–ˆโ•‘ -# โ–ˆโ–ˆโ•‘ โ•šโ•โ• โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘ -# โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ• -# -# A hackable shell for Hyprland -# Installation Script for Arch Linux -# -# Repository: https://github.com/S4NKALP/Modus -# License: GPLv3 - -set -e # Exit immediately if a command exits with a non-zero status -set -u # Treat unset variables as an error -set -o pipefail # Prevent errors in a pipeline from being masked - -REPO_URL="https://github.com/S4NKALP/Modus.git" -INSTALL_DIR="$HOME/.config/Modus1" - -PACKAGES=( - python-fabric-git - fabric-cli-git - glace-git - cliphist - gnome-bluetooth-3.0 - gobject-introspection - slurp - ffmpeg - hypridle - hyprsunset - hyprpicker - imagemagick - libnotify - matugen-bin - playerctl - python-fabric-git - python-gobject - python-pillow - python-setproctitle - python-toml - python-requests - python-numpy - python-pywayland - python-pyxdg - python-ijson - python-watchdog - python-pyotp - pyzbar - python-pywayland - python-psutil - python-pydbus - python-thefuzz - python-pam - gtk-session-lock - swww - apple-fonts - swappy - wl-clipboard - webp-pixbuf-loader - wf-recorder - acpi - brightnessctl - power-profiles-daemon - uwsm - cinnamon-desktop -) - -# Colors -GREEN=$(tput setaf 2) -YELLOW=$(tput setaf 3) -RED=$(tput setaf 1) -CYAN=$(tput setaf 6) -RESET=$(tput sgr0) - -# Function for status messages -step() { - echo -e "${CYAN}โ†’${RESET} $1" -} -success() { - echo -e "${GREEN}โœ”${RESET} $1" -} -warn() { - echo -e "${YELLOW}!${RESET} $1" -} -error() { - echo -e "${RED}โœ–${RESET} $1" -} - -echo -e "${GREEN}==================================" -echo " Modus Installer for Arch Linux" -echo -e "==================================${RESET}" - -# OS check -if ! grep -qi "arch" /etc/os-release; then - error "This script is designed for Arch Linux or Arch-based distro." - exit 1 -fi - -# Root check -if [ "$(id -u)" -eq 0 ]; then - error "Please do not run as root." - exit 1 -fi - -# Warn about sudo -echo -e "\n${YELLOW}Some packages require root privileges (e.g., glace-git).${RESET}" -echo "You will be prompted for your sudo password now so the installation runs smoothly." -sudo -v || { - error "Sudo authentication failed." - exit 1 -} - -# Keep sudo alive until script finishes -while true; do - sudo -n true - sleep 60 - kill -0 "$$" || exit -done 2>/dev/null & - -# Confirm installation -echo -e "\nThis will install Modus and the following packages:" -printf "%s\n" "${PACKAGES[@]}" -read -rp "$(echo -e "${YELLOW}""Proceed? (y/N): ""${RESET}")" confirm -if [[ ! "$confirm" =~ ^[Yy]$ ]]; then - warn "Installation cancelled." - exit 0 -fi - -aur_helper="yay" - -# Check if paru exists, otherwise use yay -if command -v paru &>/dev/null; then - aur_helper="paru" -elif ! command -v yay &>/dev/null; then - echo "Installing yay-bin..." - tmpdir=$(mktemp -d) - git clone --depth=1 https://aur.archlinux.org/yay-bin.git "$tmpdir/yay-bin" - (cd "$tmpdir/yay-bin" && makepkg -si --noconfirm) - rm -rf "$tmpdir" -fi - -# Clone or update repo -if [ -d "$INSTALL_DIR" ]; then - step "Updating Modus repository..." - git -C "$INSTALL_DIR" pull -else - step "Cloning Modus repository..." - git clone "$REPO_URL" "$INSTALL_DIR" -fi -success "Repository ready." - -# Install packages -step "Installing required packages..." -$aur_helper -Syy --needed --noconfirm "${PACKAGES[@]}" || warn "Some packages failed to install." - -# Update outdated packages -step "Checking for outdated packages..." -outdated=$($aur_helper -Qu | awk '{print $1}' || true) -to_update=() -for pkg in "${PACKAGES[@]}"; do - if echo "$outdated" | grep -q "^$pkg\$"; then - to_update+=("$pkg") - fi -done - -if [ ${#to_update[@]} -gt 0 ]; then - step "Updating outdated packages..." - $aur_helper -S --noconfirm "${to_update[@]}" -else - success "All packages are up-to-date." -fi - -# Run config -# step "Running configuration..." -# python "$INSTALL_DIR/config/config.py" - -# Start Modus -step "Starting Modus..." -killall modus 2>/dev/null || true -uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & -disown -success "Modus started successfully." - -echo -e "\n${GREEN}Installation complete!${RESET}" diff --git a/lock.py b/lock.py deleted file mode 100644 index 86a889c..0000000 --- a/lock.py +++ /dev/null @@ -1,234 +0,0 @@ -from widgets.circle_image import CircleImage as Image -from modules.panel.components.indicators import ( - BatteryIndicator, - BluetoothIndicator, - NetworkIndicator, -) -from gi.repository import ( - Gdk, # pyright: ignore[reportMissingModuleSource] - GLib, - GtkSessionLock, # pyright: ignore[reportAttributeAccessIssue] -) -from fabric.widgets.window import Window -from fabric.widgets.label import Label -from fabric.widgets.entry import Entry -from fabric.widgets.datetime import DateTime -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.box import Box -from fabric.utils import get_relative_path -from fabric import Application -import os -import getpass -import setproctitle -import gi -import pam - -gi.require_version("Gdk", "3.0") -gi.require_version("Gtk", "3.0") - - -gi.require_version("GtkSessionLock", "0.1") -# from fabric.widgets.image import Image - -# from widgets.wayland import WaylandWindow as Window - - -class IndicatorBox(Box): - def __init__(self, *args, **kwargs): - super().__init__( - h_align="end", - name="indicator-box", - spacing=5, - h_expand=True, - children=[ - BatteryIndicator(show_window=False), - BluetoothIndicator(show_window=False), - NetworkIndicator(show_window=False), - ], - ) - - -class ContentBox(CenterBox): - def __init__(self, on_activate, *args, **kwargs): - self.password_entry = Entry( - placeholder="Enter Password", - name="password-entry", - h_align="center", - v_align="center", - visible=False, - password=True, - on_activate=on_activate, - ) - - face_icon = os.path.expanduser("~/.face.icon") - if not os.path.exists(face_icon): - face_icon = get_relative_path("./assets/default.png") - self.password_entry.set_property("xalign", 0.5) - self.username_label = Label( - label=f"{getpass.getuser().title()}", - name="username", - visible=True, - h_align="center", - v_align="center", - ) - self.unlock_text = Label( - label="Touch ID or Enter Password", - name="unlock-text", - ) - super().__init__( - name="content-box", - h_expand=True, - orientation="vertical", - v_expand=True, - start_children=[ - IndicatorBox(), - DateTime( - formatters=["%A,%B %-d"], - interval=10000, - h_expand=False, - v_align="start", - v_expand=False, - name="lock-date", - ), - DateTime(formatters=["%I:%M"], name="lock-clock"), - ], - end_children=[ - Box( - name="profile-box", - h_align="center", - h_expand=True, - v_expand=True, - v_align="end", - children=[ - Image( - name="face-icon", - image_file=face_icon, - size=64, - ), - ], - ), - Box( - name="container-box", - orientation="v", - v_align="end", - h_align="center", - h_expand=True, - v_expand=True, - children=[ - self.username_label, - self.password_entry, - ], - ), - Box( - name="unlock-box", - v_align="end", - h_align="center", - h_expand=True, - v_expand=True, - children=[ - self.unlock_text, - ], - ), - ], - **kwargs, - ) - - -class LockScreen(Window): - def __init__(self, lock: GtkSessionLock.Lock): - self._hide_timeout_id = None # prevent AttributeError - self.lock = lock - self.content = ContentBox(self.on_activate) - super().__init__( - title="lock", - visible=False, - all_visible=False, - name="lockscreen-bg", - anchor="center", - child=self.content, - ) - - self.content.password_entry.set_visible(False) - self.connect("key-press-event", self._on_keypress) - - bg = os.path.expanduser("~/.current.wall") - if not os.path.exists(bg): - bg = get_relative_path("./assets/wallpapers_example/example-1.png") - self.set_style(f"background-image: url('{bg}');") - - def _on_keypress(self, widget, event): - keyval = event.keyval - - # ESC pressed โ†’ hide entry immediately - if keyval == Gdk.KEY_Escape and self.content.password_entry.get_visible(): - self._hide_entry() - return - - # Show entry if hidden - if not self.content.password_entry.get_visible(): - self.content.username_label.set_visible(False) - self.content.password_entry.set_visible(True) - self.content.password_entry.grab_focus() - self._start_hide_timer() - else: - # Reset timer if already visible - self._restart_hide_timer() - - def _start_hide_timer(self): - self._stop_hide_timer() # just in case - self._hide_timeout_id = GLib.timeout_add_seconds(5, self._hide_entry) - # 10 seconds of inactivity before hiding - - def _restart_hide_timer(self): - self._start_hide_timer() - - def _stop_hide_timer(self): - if self._hide_timeout_id: - GLib.source_remove(self._hide_timeout_id) - self._hide_timeout_id = None - - def _hide_entry(self): - self._stop_hide_timer() - self.content.password_entry.set_visible(False) - self.content.username_label.set_visible(True) - return False # stop timeout - - def on_activate(self, entry: Entry, *args): - if not pam.authenticate(getpass.getuser(), (entry.get_text() or "").strip()): - entry.set_text("") - entry.set_placeholder_text("Wrong Password") - return - self.lock.unlock_and_destroy() - self.destroy() - GLib.idle_add(app.quit) # schedules quit after unlock - - -def initialize(): - lock = GtkSessionLock.prepare_lock() - lock.lock_lock() - lockscreen = LockScreen(lock) - lock.new_surface( - lockscreen, - Gdk.Display.get_default().get_monitor( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] - 0 - ), - ) - lockscreen.show() - - -if __name__ == "__main__": - setproctitle.setproctitle("lockscreen") - initialize() - lockscreen = LockScreen(GtkSessionLock.Lock()) - - app = Application("lock", lockscreen) - - def set_css(): - app.set_stylesheet_from_file( - get_relative_path("main.css"), - ) - - app.set_css = set_css # pyright: ignore[reportAttributeAccessIssue] - - app.set_css() # pyright: ignore[reportAttributeAccessIssue] - app.run() diff --git a/main.css b/main.css deleted file mode 100644 index 738870f..0000000 --- a/main.css +++ /dev/null @@ -1,34 +0,0 @@ -@import url("./styles/colors.css"); -@import url("./styles/panel.css"); -@import url("./styles/dock.css"); -@import url("./styles/switcher.css"); -@import url("./styles/launcher.css"); -@import url("./styles/osd.css"); -@import url("./styles/controlcenter.css"); -@import url("./styles/notification.css"); -@import url("./styles/dropdown.css"); -@import url("./styles/about.css"); -@import url("./styles/notification-center.css"); -@import url("./styles/player.css"); -@import url("./styles/widgets.css"); -@import url("./styles/tray.css"); -@import url("./styles/battery-widget.css"); -@import url("./styles/lock.css"); -@import url("./styles/todo.css"); - -* { - all: unset; - color: var(--foreground); - font-size: unset; - font-family: "SF Pro Rounded"; -} - -#corner { - background-color: var(--shadow); - border-radius: 0; -} - -#corner-container { - min-width: 20px; - min-height: 20px; -} diff --git a/main.py b/main.py deleted file mode 100644 index 1ba20cf..0000000 --- a/main.py +++ /dev/null @@ -1,88 +0,0 @@ -import gi -import setproctitle -from fabric import Application -from fabric.core import widgets -from fabric.utils import get_relative_path, monitor_file -from loguru import logger - -from config.data import APP_NAME -from modules.dock import Dock -from modules.launcher.main import Launcher -from modules.notification.notification import ModusNoti -from modules.osd import OSD -from modules.panel.main import Panel -from modules.switcher import ApplicationSwitcher -from modules.widget import Deskwidgets - -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") - -# Initialize fontconfig early to prevent warnings -try: - import ctypes - - libfontconfig = ctypes.CDLL("libfontconfig.so.1") - libfontconfig.FcInit() -except: - pass - - -# from modules.corners import Corners - -for log in [ - "fabric.hyprland.widgets", - "fabric.audio.service", - "fabric.bluetooth.service", - "services.network", - "utils.wayland", - # "modules.notification.notification", - # "modules.notification.notification_center", - # "modules.controlcenter.player", - # "services.brightness", - # "modules.controlcenter.expanded_player", -]: - logger.disable(log) - - -if __name__ == "__main__": - setproctitle.setproctitle(APP_NAME) - - # Load configuration - from config.data import load_config - - # About().toggle(None) - config = load_config() - - panel = Panel() - # corners = Corners() - dock = Dock() - modusnoti = ModusNoti() - switcher = ApplicationSwitcher() - launcher = Launcher() - panel.launcher = launcher - osd = OSD() - - widgets = Deskwidgets() - # Set corners visibility based on config - # corners_visible = config.get("corners_visible", True) - # corners.set_visible(corners_visible) - - # Monitor CSS files for changes - css_file = monitor_file(get_relative_path("styles")) - _ = css_file.connect("changed", lambda *_: set_css()) - - # Make sure corners is added to the app - app = Application( - f"{APP_NAME}", panel, dock, switcher, launcher, modusnoti, osd, widgets - ) - - def set_css(): - app.set_stylesheet_from_file( - get_relative_path("main.css"), - ) - - app.set_css = set_css - - app.set_css() - - app.run() diff --git a/modules/about.py b/modules/about.py deleted file mode 100644 index 46c7f1c..0000000 --- a/modules/about.py +++ /dev/null @@ -1,537 +0,0 @@ -import re -import subprocess -import os - -import gi - -gi.require_version("GdkPixbuf", "2.0") -gi.require_version("Gtk", "3.0") -from gi.repository import GdkPixbuf, Gtk # type: ignore - -from fabric.utils.helpers import get_relative_path -from utils.roam import modus_service -from utils.icon_resolver import IconResolver - - -def read_dmi(field): - try: - with open(f"/sys/class/dmi/id/{field}") as f: - return f.read().strip() - except Exception: - return "Unknown" - - -def get_gpu_name(): - try: - output = ( - subprocess.check_output( - "lspci -nn | grep -i 'VGA compatible controller'", shell=True, text=True - ) - .strip() - .split("\n") - ) - - def clean(line): - matches = re.findall(r"\[(.*?)\]", line) - if len(matches) >= 2: - return matches[1].strip() - desc = line.split(":", 2)[-1] - return desc.replace("Corporation", "").strip() - - for line in output: - if any(vendor in line.lower() for vendor in ["nvidia", "amd", "radeon"]): - return clean(line) - - if output: - return clean(output[0]) - - return "Unknown GPU" - except Exception: - return "Unknown GPU" - - -def get_executable_path(exec_string): - """Extract and find the actual executable path from Exec field""" - if not exec_string: - return None - - # Remove common exec modifiers and arguments - exec_parts = exec_string.split() - if not exec_parts: - return None - - executable = exec_parts[0] - - # Remove common prefixes - prefixes_to_remove = ["env", "bash", "sh", "/usr/bin/env"] - while executable in prefixes_to_remove and len(exec_parts) > 1: - exec_parts.pop(0) - executable = exec_parts[0] - - # If it's already an absolute path, check if it exists - if executable.startswith("/"): - if os.path.exists(executable): - return executable - return None - - # Search in PATH - try: - result = subprocess.run(["which", executable], capture_output=True, text=True) - if result.returncode == 0: - return result.stdout.strip() - except Exception: - pass - - return None - - -def get_app_info(wmclass): - """Get comprehensive application information from .desktop file""" - if not wmclass: - return { - "name": "Desktop", - "version": "", - "comment": "Desktop Environment", - "icon": "desktop", - "exec": "", - "location": "", - "categories": "", - "desktop_file": "", - } - - desktop_paths = [ - "/usr/share/applications", - "/var/lib/flatpak/exports/share/applications", - os.path.expanduser("~/.local/share/applications"), - "/usr/local/share/applications", - ] - - for path in desktop_paths: - if not os.path.exists(path): - continue - - # Try exact match first - exact_matches = [ - f for f in os.listdir(path) if f.lower() == f"{wmclass.lower()}.desktop" - ] - - # Then try starts with - startswith_matches = [ - f - for f in os.listdir(path) - if f.startswith(wmclass.lower()) and f.endswith(".desktop") - ] - - # Finally try contains - contains_matches = [ - f - for f in os.listdir(path) - if wmclass.lower() in f.lower() and f.endswith(".desktop") - ] - - # Process matches in order of preference - for matches in [exact_matches, startswith_matches, contains_matches]: - for filename in matches: - desktop_file = os.path.join(path, filename) - try: - with open(desktop_file, "r", encoding="utf-8") as f: - content = f.read() - - name = wmclass.title() - version = "" - comment = "" - icon = wmclass.lower() - exec_cmd = "" - categories = "" - - # Parse desktop file - in_desktop_entry = False - for line in content.split("\n"): - line = line.strip() - if line == "[Desktop Entry]": - in_desktop_entry = True - continue - elif line.startswith("[") and line.endswith("]"): - in_desktop_entry = False - continue - - if not in_desktop_entry or "=" not in line: - continue - - key, value = line.split("=", 1) - if key == "Name": - name = value - elif key == "Version": - version = value - elif key == "Comment": - comment = value - elif key == "GenericName" and not comment: - # Use GenericName as fallback description - comment = value - elif key == "Icon": - icon = value - elif key == "Exec": - exec_cmd = value - elif key == "Categories": - categories = value - - # Get executable location - location = get_executable_path(exec_cmd) - - return { - "name": name, - "version": version, - "comment": comment, - "icon": icon, - "exec": exec_cmd, - "location": location or "", - "categories": categories, - "desktop_file": desktop_file, - } - except Exception: - continue - - # Fallback: try to find executable in PATH - location = "" - try: - result = subprocess.run( - ["which", wmclass.lower()], capture_output=True, text=True - ) - if result.returncode == 0: - location = result.stdout.strip() - except Exception: - pass - - return { - "name": wmclass.title() if wmclass else "Unknown Application", - "version": "", - "comment": "", - "icon": wmclass.lower() if wmclass else "application-x-executable", - "exec": "", - "location": location, - "categories": "", - "desktop_file": "", - } - - -class AboutApp(Gtk.Window): - def __init__(self, app_name="Unknown Application", wmclass=""): - super().__init__(title=f"About {app_name}") - self.app_name = app_name - self.wmclass = wmclass - self.icon_resolver = IconResolver() - - self.set_default_size(300, 500) - self.set_size_request(300, 480) - self.set_resizable(False) - self.set_title(f"About {app_name}") - self.set_name("about-app") - self.set_visible(False) - - self.setup_ui() - - def setup_ui(self): - app_info = get_app_info(self.wmclass) - - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - main_box.set_margin_top(20) - main_box.set_margin_bottom(20) - main_box.set_margin_start(15) - main_box.set_margin_end(15) - - # App Icon - logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) - try: - # Use IconResolver's get_icon_pixbuf method like other parts of the project - icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_info["icon"], 128) - if icon_pixbuf: - logo = Gtk.Image.new_from_pixbuf(icon_pixbuf) - else: - raise Exception("Icon pixbuf not found") - except Exception: - # Fallback: try direct file path if it's an absolute path - try: - if app_info["icon"].startswith("/") and os.path.exists( - app_info["icon"] - ): - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - app_info["icon"], 128, 128, preserve_aspect_ratio=True - ) - logo = Gtk.Image.new_from_pixbuf(pixbuf) - else: - raise Exception("Direct path failed") - except Exception: - # Final fallback: emoji - logo = Gtk.Label() - logo.set_markup("๐Ÿ“ฑ") - - logo_box.pack_start(logo, False, False, 0) - logo_box.set_margin_bottom(15) - - # App Name - app_name_label = Gtk.Label() - app_name_label.set_markup( - f"{app_info['name']}" - ) - app_name_label.set_halign(Gtk.Align.CENTER) - app_name_label.set_margin_bottom(5) - - # Version (if available) - if app_info["version"]: - version_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - version_label = Gtk.Label( - label=f"Version {app_info.get('version', 'Unknown')}" - ) - version_label.set_name("version-label") - version_label.set_halign(Gtk.Align.CENTER) - version_box.pack_start(version_label, False, False, 0) - version_box.set_margin_bottom(5) - - # Description/Comment - Make it more prominent - description_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - if app_info["comment"]: - # Create a frame for the description to make it stand out - desc_frame = Gtk.Frame() - desc_frame.set_shadow_type(Gtk.ShadowType.IN) - - description_label = Gtk.Label() - description_label.set_markup(f"{app_info['comment']}") - description_label.set_justify(Gtk.Justification.CENTER) - description_label.set_halign(Gtk.Align.CENTER) - description_label.set_line_wrap(True) - description_label.set_max_width_chars(45) - description_label.set_margin_top(8) - description_label.set_margin_bottom(8) - description_label.set_margin_start(12) - description_label.set_margin_end(12) - - desc_frame.add(description_label) - description_box.pack_start(desc_frame, False, False, 0) - else: - # Show a placeholder if no description is available - placeholder_label = Gtk.Label() - placeholder_label.set_markup( - "No description available" - ) - placeholder_label.set_halign(Gtk.Align.CENTER) - description_box.pack_start(placeholder_label, False, False, 0) - - description_box.set_margin_bottom(15) - - # Information Grid - # Create a label with markup - info_frame = Gtk.Frame() - label = Gtk.Label() - label.set_markup("Application Information") - label.set_margin_bottom(5) - info_frame.set_label_widget(label) - - info_frame.set_label_align(0.5, 0.5) - - info_grid = Gtk.Grid() - info_grid.set_row_spacing(8) - info_grid.set_column_spacing(15) - info_grid.set_margin_top(10) - info_grid.set_margin_bottom(10) - info_grid.set_margin_start(15) - info_grid.set_margin_end(15) - info_grid.set_valign(Gtk.Align.CENTER) - info_grid.set_halign(Gtk.Align.CENTER) - - def make_info_row(label_text, value_text, row): - """Create a row in the info grid""" - label = Gtk.Label(label=f"{label_text}:") - label.set_halign(Gtk.Align.END) - label.set_markup(f"{label_text}:") - - value = Gtk.Label() - value.set_markup(f'{value_text}') - value.set_halign(Gtk.Align.START) - value.set_line_wrap(True) - value.set_max_width_chars(30) - value.set_name("info-value-label") - - info_grid.attach(label, 0, row, 1, 1) - info_grid.attach(value, 1, row, 1, 1) - - row = 0 - - # Application Name - make_info_row("Name", app_info["name"], row) - row += 1 - - # Version - if app_info["version"]: - make_info_row("Version", app_info["version"], row) - row += 1 - - # Executable Location - if app_info["location"]: - make_info_row("Location", app_info["location"], row) - row += 1 - - # Window Class - if self.wmclass: - make_info_row("Window Class", self.wmclass, row) - row += 1 - - # Categories - if app_info["categories"]: - categories = app_info["categories"].replace(";", ", ").strip(", ") - make_info_row("Categories", categories, row) - row += 1 - - # Desktop File - if app_info["desktop_file"]: - desktop_file_name = os.path.basename(app_info["desktop_file"]) - make_info_row("Desktop File", desktop_file_name, row) - row += 1 - - info_frame.add(info_grid) - - # Layout - main_box.pack_start(logo_box, False, False, 0) - main_box.pack_start(app_name_label, False, False, 0) - if app_info["version"]: - main_box.pack_start(version_box, False, False, 0) - main_box.pack_start(description_box, False, False, 0) - main_box.pack_start(info_frame, False, False, 0) - - self.add(main_box) - - def toggle(self, b): - if self.get_visible(): - self.hide() - else: - self.show_all() - - -class About(Gtk.Window): - def __init__(self): - super().__init__(title="About Menu") - self.set_default_size(300, 550) - self.set_size_request(300, 500) - self.set_resizable(False) - self.set_title("About PC") - self.set_name("about-menu") - self.set_visible(False) - - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - main_box.set_margin_top(10) - main_box.set_margin_bottom(20) - main_box.set_margin_start(20) - main_box.set_margin_end(20) - - # Logo - logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - get_relative_path("../config/assets/icons/misc/imac.svg"), - 158, - 108, - preserve_aspect_ratio=True, - ) - logo = Gtk.Image.new_from_pixbuf(pixbuf) - logo_box.pack_start(logo, False, False, 0) - logo_box.set_margin_top(60) - - # Product Name & Vendor - name_label = Gtk.Label() - name_label.set_margin_top(30) - name_label.set_markup( - f"{read_dmi('product_name')}" - ) - - vendor_label = Gtk.Label(label=read_dmi("sys_vendor")) - vendor_label.set_name("vendor-label") - vendor_label.set_halign(Gtk.Align.CENTER) - - # Info Grid - info_grid = Gtk.Grid() - info_grid.set_row_spacing(6) - info_grid.set_column_spacing(10) - info_grid.set_valign(Gtk.Align.CENTER) - info_grid.set_halign(Gtk.Align.FILL) - - def make_label(text, align_end=False, name=None): - label = Gtk.Label(label=text) - label.set_halign(Gtk.Align.END if align_end else Gtk.Align.START) - if name: - label.set_name(name) - return label - - # Info values - labels = [ - ( - "Kernel", - subprocess.run( - "uname -r", shell=True, capture_output=True, text=True - ).stdout.strip(), - ), - ( - "CPU", - subprocess.run( - "lscpu | grep 'Model name:' | cut -d ':' -f2-", - shell=True, - capture_output=True, - text=True, - ).stdout.strip(), - ), - ( - "Memory", - subprocess.run( - "free -h --giga | grep Mem | tr -s ' ' | cut -d ' ' -f 2", - shell=True, - capture_output=True, - text=True, - ).stdout.strip(), - ), - ("GPU", get_gpu_name()), - ( - "Uptime", - subprocess.run( - "uptime -p", shell=True, capture_output=True, text=True - ).stdout.strip(), - ), - ] - - for i, (title, value) in enumerate(labels): - title_label = make_label(title, align_end=True) - value_label = make_label(value, align_end=False, name="info-label") - info_grid.attach(title_label, 0, i, 1, 1) - info_grid.attach(value_label, 1, i, 1, 1) - - # Button - button_box = Gtk.Box(halign=Gtk.Align.CENTER) - button_box.set_margin_top(20) - more_info_button = Gtk.Button(label="More Info...", name="more-info-button") - more_info_button.connect("clicked", self.open_more_info) - button_box.pack_start(more_info_button, False, False, 0) - - # Info Footer - info = Gtk.Label( - label="โ„ข and ยฉ 2025 Linux Inc.\nAll rights reserved.\n\n", - justify=Gtk.Justification.CENTER, - halign=Gtk.Align.CENTER, - ) - info.set_name("info-label") - info.set_margin_top(10) - - # Layout Order - main_box.pack_start(logo_box, False, False, 0) - main_box.pack_start(name_label, False, False, 0) - main_box.pack_start(vendor_label, False, False, 0) - main_box.pack_start(info_grid, False, False, 0) - main_box.pack_start(button_box, False, False, 0) - main_box.pack_start(info, False, False, 0) - - self.add(main_box) - - def open_more_info(self, button): - # TODO: Implement the logic to open more information - pass - - def toggle(self, b): - if self.get_visible(): - self.hide() - else: - self.show_all() diff --git a/modules/controlcenter/battery.py b/modules/controlcenter/battery.py deleted file mode 100644 index 068de6e..0000000 --- a/modules/controlcenter/battery.py +++ /dev/null @@ -1,393 +0,0 @@ -import subprocess - -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.image import Image -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.separator import Separator -from fabric.widgets.svg import Svg -from gi.repository import GLib - -from services.battery import Battery - - -class EnergyModeButton(Box): - def __init__( - self, - profile_name: str, - display_name: str, - icon_name: str, - battery_service: Battery, - parent, - **kwargs, - ): - super().__init__(name="energy-mode-button", **kwargs) - self.profile_name = profile_name - self.battery_service = battery_service - self.parent = parent - - self.mode_icon_svg = Svg( - # icon_name=f"battery-{icon_name}-symbolic", - svg_file=get_relative_path( - f"../../config/assets/icons/power_modes/battery-{icon_name}.svg" - ), - size=24, - ) - self.mode_icon = Box( - children=[self.mode_icon_svg], - name="energy-mode-icon", - style_classes="battery-profile-icon", - ) - - self.mode_label = Label( - label=display_name, - style_classes="battery-power-mode", - h_align="start", - h_expand=True, - ) - - start_box = Box( - orientation="horizontal", - spacing=4, - children=[self.mode_icon, self.mode_label], - ) - - self.button = Button( - child=start_box, - h_expand=True, - name="energy-mode-button-clickable", - on_clicked=self.on_clicked, - style_classes="battery-profile-button", - ) - - self.children = [self.button] - self.update_state() - - def on_clicked(self, *args): - success = self.battery_service.change_power_profile(self.profile_name) - if success: - # Update all profile buttons in parent - self.parent.update_energy_mode_buttons() - - # Reset icon state after short delay - GLib.timeout_add(300, lambda: self._reset_icon_state()) - - def _reset_icon_state(self): - return False # Remove timeout - - def update_state(self): - is_active = self.battery_service.power_profile == self.profile_name - if is_active: - self.mode_icon.add_style_class("connected") - else: - self.mode_icon.remove_style_class("connected") - - -class GameModeButton(Box): - def __init__(self, parent, **kwargs): - super().__init__(name="energy-mode-button", h_expand=True, **kwargs) - self.parent = parent - - self.game_icon = Image( - icon_name="applications-games-symbolic", - size=16, - name="game-mode-icon", - style_classes="battery-gamemode-icon", - ) - - self.game_label = Label( - label="Game Mode", - style_classes="gamemode-button", - h_align="start", - h_expand=True, - ) - - start_box = Box( - orientation="horizontal", - spacing=3, - children=[self.game_icon, self.game_label], - ) - - self.button = Button( - child=start_box, - name="game-mode-button-clickable", - on_clicked=self.on_clicked, - h_expand=True, - style_classes="battery-gamemode-button", - ) - - self.children = [self.button] - self.update_state() - - def on_clicked(self, *args): - try: - script_path = get_relative_path("../../scripts/gamemode.sh") - subprocess.run([script_path], check=False) - - GLib.timeout_add(500, lambda: self.update_state()) - except Exception as e: - print(f"Failed to toggle game mode: {e}") - - GLib.timeout_add(300, lambda: self._reset_icon_state()) - - def _reset_icon_state(self): - return False # Remove timeout - - def update_state(self): - try: - script_path = get_relative_path("../../scripts/gamemode.sh") - result = subprocess.run( - [script_path, "check"], capture_output=True, text=True, check=False - ) - is_active = result.stdout.strip() == "t" - - if is_active: - self.game_icon.add_style_class("connected") - else: - self.game_icon.remove_style_class("connected") - except Exception as e: - print(f"Failed to check game mode status: {e}") - # Default to inactive state on error - self.game_icon.remove_style_class("connected") - - return False # Remove timeout if called from GLib.timeout_add - - -class BatteryControl(Box): - def __init__(self, parent, **kwargs): - super().__init__( - spacing=12, - orientation="vertical", - name="control-center-widgets", - **kwargs, - ) - self.set_size_request(354, -1) - - self.parent = parent - self.battery_service = Battery() - self.energy_mode_buttons = [] - - self.battery_widget = Box( - name="battery-widget", - orientation="vertical", - style_classes="battery-status-section", - h_expand=True, - spacing=8, - ) - - self.battery_title = Label( - label="Battery", style_classes="battery-main-title", h_align="start" - ) - - self.battery_percentage_label = Label( - label="80%", style_classes="battery-percentage", h_align="end" - ) - - self.battery_header = CenterBox( - start_children=self.battery_title, - end_children=self.battery_percentage_label, - name="battery-header", - ) - - self.power_source_label = Label( - label="Power Source: Power Adapter", - style_classes="battery-power-source", - h_align="start", - ) - - self.charging_time_label = Label( - label="1h 4m until fully charged", - style_classes="battery-power-source", - h_align="start", - ) - - self.energy_mode_section = Box( - orientation="vertical", spacing=8, name="energy-mode-section" - ) - - self.energy_mode_title = Label( - label="Energy Mode", style_classes="battery-section-title", h_align="start" - ) - - self.energy_modes_container = Box( - orientation="vertical", spacing=4, name="energy-modes-container" - ) - - self.game_mode_section = Box( - orientation="vertical", spacing=8, name="game-mode-section" - ) - - self.game_mode_title = Label( - label="Game Mode", style_classes="battery-section-title", h_align="start" - ) - - self.game_mode_container = Box( - orientation="vertical", spacing=4, name="game-mode-container" - ) - - self.battery_settings_button = Button( - v_align="center", - child=Label( - label="Battery Settings", - h_align="start", - ), - style_classes="battery-settings-button", - on_clicked=self.open_battery_settings, - ) - - self.battery_widget.add(self.battery_header) - self.battery_widget.add(self.power_source_label) - self.battery_widget.add(self.charging_time_label) - - separator1 = Separator(orientation="h", name="separator") - self.battery_widget.add(separator1) - - self.energy_mode_section.add(self.energy_mode_title) - self.energy_mode_section.add(self.energy_modes_container) - self.battery_widget.add(self.energy_mode_section) - - separator2 = Separator(orientation="h", name="separator") - self.battery_widget.add(separator2) - - self.game_mode_section.add(self.game_mode_title) - self.game_mode_section.add(self.game_mode_container) - self.battery_widget.add(self.game_mode_section) - - separator3 = Separator(orientation="h", name="separator") - self.battery_widget.add(separator3) - - self.battery_widget.add(self.battery_settings_button) - - self.add(self.battery_widget) - - self.battery_service.connect("changed", self.on_battery_changed) - self.battery_service.connect("profile_changed", self.on_profile_changed) - - # Initialize display - self.update_battery_info() - self.create_energy_mode_buttons() - self.create_game_mode_button() - - def open_battery_settings(self, *args): - # TODO: Implement to open Battery Settings - pass - - def create_energy_mode_buttons(self): - # Clear existing buttons - for button in self.energy_mode_buttons: - button.destroy() - self.energy_mode_buttons.clear() - - # Get available profiles - available_profiles = self.battery_service.available_profiles - - if not available_profiles: - no_profiles_label = Label( - label="No energy modes available", - style_classes="battery-no-profiles", - h_align="start", - ) - self.energy_modes_container.add(no_profiles_label) - return - - # Define energy mode mappings with proper icon names - energy_mode_config = { - "balanced": {"display": "Automatic", "icon": "balanced"}, - "power-saver": {"display": "Low Power", "icon": "power"}, - "powersave": {"display": "Low Power", "icon": "power"}, - "performance": {"display": "High Power", "icon": "performance"}, - } - - # Define the desired order for energy modes - desired_order = ["balanced", "power-saver", "powersave", "performance"] - - # Create ordered list of available profiles - ordered_profiles = [] - for profile_name in desired_order: - if profile_name in available_profiles: - ordered_profiles.append(profile_name) - - # Add any remaining profiles not in the desired order - for profile in available_profiles: - if profile not in ordered_profiles: - ordered_profiles.append(profile) - - # Create button for each available profile in the specified order - for profile in ordered_profiles: - config = energy_mode_config.get( - profile, {"display": profile.title(), "icon": "good"} - ) - - button = EnergyModeButton( - profile_name=profile, - display_name=config["display"], - icon_name=config["icon"], - battery_service=self.battery_service, - parent=self, - ) - self.energy_mode_buttons.append(button) - self.energy_modes_container.add(button) - - def update_energy_mode_buttons(self): - for button in self.energy_mode_buttons: - button.update_state() - - def create_game_mode_button(self): - # Clear existing game mode button if any - for child in list(self.game_mode_container.get_children()): - child.destroy() - - # Create game mode button - self.game_mode_button = GameModeButton(parent=self) - self.game_mode_container.add(self.game_mode_button) - - def update_battery_info(self): - if not self.battery_service.is_present: - self.battery_percentage_label.set_label("No Battery") - self.power_source_label.set_label("Power Source: Not Present") - self.charging_time_label.set_label("") - return - - # Update percentage in header - percentage = self.battery_service.percentage - self.battery_percentage_label.set_label(f"{percentage}%") - - # Update power source and charging info - state = self.battery_service.state - - if state in ["CHARGING", "PENDING_CHARGE"]: - self.power_source_label.set_label("Power Source: Power Adapter") - time_to_full = self.battery_service.time_to_full - if time_to_full != "N/A" and time_to_full != "0m": - self.charging_time_label.set_label( - f"{time_to_full} until fully charged" - ) - else: - self.charging_time_label.set_label("Charging...") - elif state == "FULLY_CHARGED": - self.power_source_label.set_label("Power Source: Power Adapter") - self.charging_time_label.set_label("Fully Charged") - elif state in ["DISCHARGING", "PENDING_DISCHARGE"]: - self.power_source_label.set_label("Power Source: Battery") - time_to_empty = self.battery_service.time_to_empty - if time_to_empty != "N/A" and not time_to_empty.startswith( - "4553h" - ): # Filter out unrealistic times - self.charging_time_label.set_label(f"{time_to_empty} remaining") - else: - self.charging_time_label.set_label("On Battery Power") - elif state == "EMPTY": - self.power_source_label.set_label("Power Source: Battery") - self.charging_time_label.set_label("Battery Empty") - else: - self.power_source_label.set_label("Power Source: Unknown") - self.charging_time_label.set_label("") - - def on_battery_changed(self, *args): - self.update_battery_info() - - def on_profile_changed(self, service, new_profile): - self.update_energy_mode_buttons() diff --git a/modules/controlcenter/bluetooth.py b/modules/controlcenter/bluetooth.py deleted file mode 100644 index 08346aa..0000000 --- a/modules/controlcenter/bluetooth.py +++ /dev/null @@ -1,613 +0,0 @@ -import subprocess - -import gi -from fabric.bluetooth import BluetoothClient, BluetoothDevice -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from fabric.widgets.revealer import Revealer -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.separator import Separator -from fabric.widgets.svg import Svg -from gi.repository import Gdk, GLib, Gtk -from loguru import logger - -from services.battery import Battery - -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") - - -def set_bluetooth_enabled_with_fallback(client, enabled: bool): - try: - # Try fabric bluetooth first - client.set_enabled(enabled) - except Exception as e: - logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}") - # Fallback to rfkill to unblock/block bluetooth - if enabled: - command = ["rfkill", "unblock", "bluetooth"] - else: - command = ["rfkill", "block", "bluetooth"] - - try: - # Execute the rfkill command - subprocess.run( - command, capture_output=True, text=True, timeout=10, check=True - ) - except Exception as subprocess_error: - logger.error(f"rfkill fallback failed with exception: {subprocess_error}") - - -class BluetoothDeviceSlot(CenterBox): - def __init__(self, device: BluetoothDevice, **kwargs): - super().__init__(h_expand=True, name="device-button", **kwargs) - self.device = device - self.device.connect("changed", self.on_changed) - self.device.connect( - "notify::closed", lambda *_: self.device.closed and self.destroy() - ) - - self.styles = [ - "connected" if self.device.connected else "", - "paired" if self.device.paired else "", - ] - - self.dimage = Image( - icon_name=device.icon_name + "-symbolic", # type: ignore - size=5, - name="device-icon", - style_classes=" ".join(self.styles), - ) - - self.device_button = Button( - on_clicked=lambda *_: self.toggle_connecting(), - child=Box( - orientation="h", - h_expand=True, - children=[self.dimage, Label(label=device.name)], - ), # type: ignore - ) - self.start_children = [self.device_button] - - # Add battery info if available - if hasattr(device, "battery_percentage") and device.battery_percentage > 0: - battery_box = Box(orientation="h", spacing=4) - - # Create battery icon - battery_icon = Svg( - size=16, - svg_file=get_relative_path( - Battery.get_battery_icon_file( - device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ), - name="battery-icon", - ) - - # Create battery percentage label - battery_label = Label( - label=f"{device.battery_percentage:.0f}%", name="battery-label" - ) - - battery_box.children = [battery_icon, battery_label] - self.end_children = [battery_box] - - self.device_button.connect("enter-notify-event", self.on_button_enter) - self.device_button.connect("leave-notify-event", self.on_button_leave) - self.device.emit("changed") # to update display status - - def on_button_enter(self, widget, event): - self.add_style_class("button-hovered") - - def on_button_leave(self, widget, event): - self.remove_style_class("button-hovered") - - def toggle_connecting(self): - self.device.emit("changed") - self.device.set_connecting(not self.device.connected) - - def on_changed(self, *_): - try: - # Update connection and pairing status - new_styles = [ - "connected" if self.device.connected else "", - "paired" if self.device.paired else "", - ] - - self.styles = new_styles - self.dimage.set_property("style-classes", " ".join(self.styles)) - except Exception: - return - - # Update battery info if available - if ( - hasattr(self.device, "battery_percentage") - and self.device.battery_percentage > 0 - ): - if not self.end_children: # Add battery display if not already present - battery_box = Box(orientation="h", spacing=4) - - # Create battery icon - battery_icon = Svg( - size=16, - svg_file=get_relative_path( - Battery.get_battery_icon_file( - self.device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ), - name="battery-icon", - ) - - # Create battery percentage label - battery_label = Label( - label=f"{self.device.battery_percentage:.0f}%", name="battery-label" - ) - - battery_box.children = [battery_icon, battery_label] - self.end_children = [battery_box] - else: # Update existing battery display - battery_box = self.end_children[0] - if hasattr(battery_box, "children") and len(battery_box.children) >= 2: - battery_icon = battery_box.children[0] - battery_label = battery_box.children[1] - - # Update battery icon - battery_icon.set_from_file( - get_relative_path( - Battery.get_battery_icon_file( - self.device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ) - ) - - # Update battery percentage - battery_label.set_label(f"{self.device.battery_percentage:.0f}%") - elif self.end_children: # Remove battery display if no longer available - self.end_children = [] - - return - - -class BluetoothConnections(Box): - def __init__( - self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs - ): - super().__init__( - spacing=8, - orientation="vertical", - name="bluetooth-connections", - **kwargs, - ) - - self.parent = parent - self.show_hidden_devices = show_hidden_devices - self.is_scanning = False # Track scanning state - self.refresh_timer = None # Timer for periodic device refresh - self._update_in_progress = False # Prevent concurrent updates - self._destroyed = False # Track if widget is destroyed - - self.client = BluetoothClient(on_device_added=self.on_device_added) - - # Create pull-to-refresh indicator - self.refresh_indicator = Label( - name="bluetooth-refresh-indicator", - label="โ†“ Pull to scan for devices", - h_align="center", - visible=False, - style="color: #fff; font-size: 12px; padding: 5px;", - ) - - # Create title with optional back button - title_children = [] - if show_back_button: - title_children.append( - Button( - image=Image(icon_name="back", size=10), - on_clicked=lambda *_: self.parent.close_bluetooth(), - ) - ) - title_children.append(Label("Bluetooth", name="bluetooth-title")) - - self.title = Box( - orientation="h", - children=title_children, - ) - - self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") - - # Safely set initial state - self.toggle_button.set_active(self.client.enabled) - self.toggle_button.connect( - "notify::active", - lambda *_: set_bluetooth_enabled_with_fallback( - self.client, self.toggle_button.get_active() - ), - ) - - # Connect client signals - self.client.connect( - "notify::enabled", - lambda *_: self.toggle_button.set_active(self.client.enabled), - ) - self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) - - # Connect to device changes - self.client.connect("device-added", self.update_devices) - self.client.connect("device-removed", self.update_devices) - - # Connect to additional signals for better real-time monitoring - self.client.connect("changed", self.on_client_changed) - - # Create Devices section - self.paired_devices_label = Label( - label="Devices", h_align="start", name="networks-title" - ) - self.paired_devices = Box( - spacing=4, orientation="vertical", name="known-networks" - ) - - # Create "No devices available" message - self.no_devices_label = Label( - label="No devices available", - h_align="center", - name="no-networks-label", - visible=False, - ) - - # Create Other Devices section with clickable title - self.other_devices_button = Button( - child=Label("Other Devices", h_align="start"), - name="wifi-other-button", - on_clicked=self.toggle_other_devices, - ) - self.other_devices = Box(spacing=4, orientation="vertical") - - # Create scrolled window for other devices - self.other_devices_scrolled = ScrolledWindow( - min_content_size=(303, 150), - child=self.other_devices, - overlay_scroll=True, - ) - - # Add pull-to-refresh functionality to scrolled window - self.setup_pull_to_refresh() - - # Create revealer for Other Devices section - self.other_devices_revealer = Revealer( - child=self.other_devices_scrolled, - transition_type="slide-down", - transition_duration=100, - child_revealed=False, - ) - - # Create More Settings button (same style as Other Devices button) - self.more_settings_button = Button( - child=Label("More Settings", h_align="start"), - name="wifi-other-button", - on_clicked=self.open_bluetooth_settings, - ) - - self.children = [ - CenterBox( - start_children=self.title, - end_children=self.toggle_button, - name="bluetooth-widget-top", - ), - self.refresh_indicator, - Separator(orientation="h", name="separator"), - self.paired_devices_label, - self.paired_devices, - self.no_devices_label, - Separator(orientation="h", name="separator"), - self.other_devices_button, - self.other_devices_revealer, - Separator(orientation="h", name="separator"), - self.more_settings_button, - ] - - # Connect cleanup on destroy - self.connect("destroy", self.on_destroy) - - self.client.notify("scanning") - self.client.notify("enabled") - - # Initial device update - self.update_devices() - - # Start periodic device monitoring for real-time updates - self.start_device_monitoring() - - def toggle_other_devices(self, *_): - """Toggle the visibility of other devices section""" - current_state = self.other_devices_revealer.child_revealed - self.other_devices_revealer.child_revealed = not current_state - - # Handle scanning based on section visibility - if self.client: - if self.other_devices_revealer.child_revealed: - # Start scanning when revealing other devices section - if not self.client.scanning: - self.client.toggle_scan() - # Also force an immediate device refresh to catch any missed connections - self.force_device_refresh() - else: - # Stop scanning when hiding other devices section - if self.client.scanning: - self.client.toggle_scan() - - def open_bluetooth_settings(self, *_): - """Open Blueman bluetooth manager""" - try: - subprocess.Popen(["blueman-manager"], start_new_session=True) - if self.parent and hasattr(self.parent, "hide_controlcenter"): - self.parent.hide_controlcenter() - except FileNotFoundError: - pass - except Exception: - pass - - def update_scan_label(self): - """Update scanning state appearance""" - if self.client.scanning: - # Show scanning feedback in refresh indicator - self.refresh_indicator.set_label("Scanning for devices...") - self.refresh_indicator.set_visible(True) - self.refresh_indicator.add_style_class("scanning") - else: - # Hide scanning feedback - self.refresh_indicator.set_visible(False) - self.refresh_indicator.remove_style_class("scanning") - - def update_devices(self, *_): - """Update the list of available devices""" - # Prevent concurrent updates and check if destroyed - if self._update_in_progress or self._destroyed or not self.client: - return - - self._update_in_progress = True - - try: - # Store current device addresses to detect changes - current_paired_addresses = { - child.device.address - for child in self.paired_devices.get_children() - if hasattr(child, "device") - } - current_other_addresses = { - child.device.address - for child in self.other_devices.get_children() - if hasattr(child, "device") - } - - # Get current devices safely - devices = self.client.devices - paired_devices = [] - other_devices = [] - new_paired_addresses = set() - new_other_addresses = set() - - for device in devices: - try: - if device.name and device.name != "Unknown": - # Categorize devices: paired devices go to "Devices (Paired)" - # All others go to "Other Devices" - if device.paired: - paired_devices.append(device) - new_paired_addresses.add(device.address) - else: - other_devices.append(device) - new_other_addresses.add(device.address) - except Exception: - continue - - # Check if we need to update (devices added/removed) - paired_changed = current_paired_addresses != new_paired_addresses - other_changed = current_other_addresses != new_other_addresses - - # Only rebuild if something actually changed - if paired_changed or other_changed: - # Clear existing devices safely - for child in list(self.paired_devices.get_children()): - if not self._destroyed: - child.destroy() - for child in list(self.other_devices.get_children()): - if not self._destroyed: - child.destroy() - - # Add paired devices - for device in paired_devices: - if not self._destroyed: - device_slot = BluetoothDeviceSlot(device) - self.paired_devices.add(device_slot) - - # Add other devices - for device in other_devices: - if not self._destroyed: - device_slot = BluetoothDeviceSlot(device) - self.other_devices.add(device_slot) - - # Show/hide sections based on available devices - if not self._destroyed: - has_paired_devices = len(paired_devices) > 0 - has_other_devices = len(other_devices) > 0 - has_any_devices = has_paired_devices or has_other_devices - - # Show paired devices section only if there are paired devices - self.paired_devices_label.set_visible(has_paired_devices) - self.paired_devices.set_visible(has_paired_devices) - - # Show "No devices available" message if no devices at all - self.no_devices_label.set_visible(not has_any_devices) - - # Always show the other devices button, regardless of available devices - self.other_devices_button.set_visible(True) # Always visible - - except Exception: - pass - finally: - self._update_in_progress = False - - def start_device_monitoring(self): - """Start periodic monitoring for device changes""" - # Monitor for device changes every 5 seconds (less aggressive) - # This helps catch devices that connect from external sources - self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh) - - def stop_device_monitoring(self): - """Stop periodic monitoring""" - if self.refresh_timer: - GLib.source_remove(self.refresh_timer) - self.refresh_timer = None - - def periodic_device_refresh(self): - """Periodically refresh device list to catch external connections""" - # Skip if update in progress, destroyed, or client not available - if ( - self._update_in_progress - or self._destroyed - or not self.client - or not self.client.enabled - ): - return True # Continue monitoring - - try: - # Simple check - just trigger update_devices which has its own safety checks - # Don't force signal emissions as that can cause race conditions - self.update_devices() - - except Exception: - pass - - return True # Continue monitoring - - def force_device_refresh(self): - """Force an immediate refresh of the device list""" - if self._update_in_progress or self._destroyed: - return - - try: - # Simply trigger update_devices which has its own safety checks - # Avoid forcing signal emissions to prevent race conditions - self.update_devices() - except Exception: - pass - - def on_client_changed(self, *_): - """Handle when the bluetooth client state changes""" - # Update devices when client state changes - self.update_devices() - - def on_destroy(self, widget): - """Cleanup when widget is destroyed""" - # Mark as destroyed to prevent further updates - self._destroyed = True - # Stop monitoring - self.stop_device_monitoring() - # Make sure other devices revealer is collapsed when closing - try: - self.other_devices_revealer.child_revealed = False - except: - pass # Widget might already be destroyed - - def close_bluetooth(self): - """Called when Bluetooth panel is being closed""" - # Collapse the other devices section when closing - self.other_devices_revealer.child_revealed = False - - def setup_pull_to_refresh(self): - """Setup pull-to-refresh gesture for the scrolled window""" - # Get the scrolled window's vertical adjustment - self.vadjustment = self.other_devices_scrolled.get_vadjustment() - - # Track gesture state - self.pull_start_y = 0 - self.is_pulling = False - self.pull_threshold = 50 # pixels to trigger refresh - - # Connect to scroll events - self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event) - self.other_devices_scrolled.connect("button-press-event", self.on_button_press) - self.other_devices_scrolled.connect( - "button-release-event", self.on_button_release - ) - self.other_devices_scrolled.connect( - "motion-notify-event", self.on_motion_notify - ) - - # Enable events - self.other_devices_scrolled.set_events( - Gdk.EventMask.SCROLL_MASK - | Gdk.EventMask.BUTTON_PRESS_MASK - | Gdk.EventMask.BUTTON_RELEASE_MASK - | Gdk.EventMask.POINTER_MOTION_MASK - ) - - def on_scroll_event(self, widget, event): - """Handle scroll events for pull-to-refresh""" - # Only handle pull-to-refresh when at the top - if self.vadjustment.get_value() <= 0: - if event.direction == Gdk.ScrollDirection.UP: - # Scrolling up at the top - toggle scan and force refresh - self.client.toggle_scan() - self.force_device_refresh() - return True # Consume the event - return False # Let normal scrolling continue - - def on_button_press(self, widget, event): - """Handle button press for touch/drag gestures""" - if self.vadjustment.get_value() <= 0: - self.pull_start_y = event.y - self.is_pulling = True - return False - - def on_button_release(self, widget, event): - """Handle button release for touch/drag gestures""" - if self.is_pulling: - pull_distance = event.y - self.pull_start_y - if pull_distance > self.pull_threshold: - # Toggle scan and force refresh - self.client.toggle_scan() - self.force_device_refresh() - # Hide refresh indicator - self.refresh_indicator.set_visible(False) - self.refresh_indicator.remove_style_class("ready-to-refresh") - self.is_pulling = False - return False - - def on_motion_notify(self, widget, event): - """Handle motion events for visual feedback during pull""" - if self.is_pulling and self.vadjustment.get_value() <= 0: - pull_distance = event.y - self.pull_start_y - if pull_distance > 0: - # Show refresh indicator when pulling down - self.refresh_indicator.set_visible(True) - if pull_distance >= self.pull_threshold: - if self.client.scanning: - self.refresh_indicator.set_label("โ†‘ Release to stop scanning") - else: - self.refresh_indicator.set_label("โ†‘ Release to scan") - self.refresh_indicator.add_style_class("ready-to-refresh") - else: - if self.client.scanning: - self.refresh_indicator.set_label("โ†“ Pull to stop scanning") - else: - self.refresh_indicator.set_label("โ†“ Pull to scan for devices") - self.refresh_indicator.remove_style_class("ready-to-refresh") - else: - self.refresh_indicator.set_visible(False) - return False - - def on_device_added(self, client: BluetoothClient, address: str): - """Handle when a new device is added""" - # Update the device list when devices are added - self.update_devices() diff --git a/modules/controlcenter/expanded_player.py b/modules/controlcenter/expanded_player.py deleted file mode 100644 index 99d0ad4..0000000 --- a/modules/controlcenter/expanded_player.py +++ /dev/null @@ -1,1398 +0,0 @@ -# Standard library imports -import os -import re -import tempfile -import urllib.parse -import urllib.request -import threading -import weakref -from typing import List, Optional, Dict, Set -import gc - -# Fabric imports -from fabric.widgets.scale import Scale -from widgets.wayland import WaylandWindow as Window -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.box import Box -from fabric.utils import bulk_connect, invoke_repeater, cooldown -from fabric.utils.helpers import get_relative_path -from fabric.widgets.image import Image -from fabric.widgets.overlay import Overlay -from fabric.widgets.stack import Stack -from fabric.widgets.svg import Svg -from gi.repository import GLib, GObject -from loguru import logger - -# Local imports -from services.mpris import MprisPlayer, MprisPlayerManager -import config.data as data - -CACHE_DIR = f"{data.CACHE_DIR}/media" - -# Global memory management -_shared_mpris_manager = None -_widget_cache = weakref.WeakValueDictionary() -_artwork_cache = {} -_max_artwork_cache_size = 10 # Limit artwork cache to prevent memory bloat - - -def get_shared_mpris_manager(): - """Get shared MPRIS manager instance to reduce memory usage.""" - global _shared_mpris_manager - if _shared_mpris_manager is None: - _shared_mpris_manager = MprisPlayerManager() - return _shared_mpris_manager - - -def cleanup_artwork_cache(): - """Clean up artwork cache to prevent memory leaks.""" - global _artwork_cache - if len(_artwork_cache) > _max_artwork_cache_size: - # Keep only the most recent items - items = list(_artwork_cache.items()) - _artwork_cache = dict(items[-_max_artwork_cache_size:]) - # Force garbage collection - gc.collect() - - -def cleanup_old_cache_files(): - """Clean up old artwork cache files aggressively to save memory.""" - try: - if not os.path.exists(CACHE_DIR): - return - - import time - - current_time = time.time() - # Clean files older than 2 hours (more aggressive) - two_hours_ago = current_time - (2 * 60 * 60) - - for filename in os.listdir(CACHE_DIR): - filepath = os.path.join(CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mod_time = os.path.getmtime(filepath) - if file_mod_time < two_hours_ago: - os.remove(filepath) - logger.debug(f"Cleaned up old cache file: {filename}") - except Exception as e: - logger.warning(f"Failed to clean up cache file {filename}: {e}") - - except Exception as e: - logger.error(f"Error during cache cleanup: {e}") - - -def get_artwork_cached(url: str) -> Optional[str]: - """Get artwork with memory-efficient caching.""" - if not url: - return None - - # Check memory cache first - if url in _artwork_cache: - return _artwork_cache[url] - - # Clean cache if too large - cleanup_artwork_cache() - - try: - # Create cache directory if it doesn't exist - os.makedirs(CACHE_DIR, exist_ok=True) - - # Generate cache filename - safe_filename = re.sub( - r"[^\w\-_.]", "_", urllib.parse.urlparse(url).path.split("/")[-1] - ) - if not safe_filename: - safe_filename = f"artwork_{hash(url)}" - - cache_file = os.path.join(CACHE_DIR, safe_filename) - - # Check if cached file exists and is recent (within 2 hours) - if os.path.exists(cache_file): - import time - - if time.time() - os.path.getmtime(cache_file) < 7200: # 2 hours - _artwork_cache[url] = cache_file - return cache_file - - # Download and cache - urllib.request.urlretrieve(url, cache_file) - _artwork_cache[url] = cache_file - return cache_file - - except Exception as e: - logger.warning(f"Failed to cache artwork from {url}: {e}") - return None - - for filename in os.listdir(CACHE_DIR): - filepath = os.path.join(CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - if file_mtime < six_hours_ago: - os.unlink(filepath) - except Exception: - pass # Ignore individual file errors - except Exception: - pass # Ignore all errors in cleanup - - -class EmbeddedExpandedPlayer(Box): - """Embedded expanded player widget for use inside control center.""" - - def __init__(self, control_center, **kwargs): - super().__init__( - orientation="vertical", - h_expand=True, - name="embedded-expanded-player", - **kwargs, - ) - - self.control_center = control_center - self.mpris_manager = get_shared_mpris_manager() - - # Create back button (hidden in header) - self.back_button = Button( - name="back-button", - child=Label(label="โ† Back"), - on_clicked=self._on_back_clicked, - ) - - # Create expanded player content - self.player_content = PlayerBoxStack(self.mpris_manager) - - # Add escape key binding for navigation back - self._keybinding_added = False - try: - if hasattr(self.control_center, "add_keybinding"): - self.control_center.add_keybinding("Escape", self._on_back_clicked) - self._keybinding_added = True - except Exception: - pass # Ignore if keybinding fails - - self.children = [ - Box( - orientation="horizontal", - h_expand=True, - style_classes="menu", - visible=False, # Hide header to remove title and back button - children=[ - self.back_button, - Box(h_expand=True), # Spacer - Label(label="Now Playing", style_classes="title"), - Box(h_expand=True), # Spacer - ], - ), - self.player_content, - ] - - def _on_back_clicked(self, *_): - """Handle back button click""" - if self.control_center and hasattr( - self.control_center, "close_expanded_player" - ): - self.control_center.close_expanded_player() - - def refresh(self): - """Refresh the player content""" - # This will automatically update as MPRIS players change - pass - - def destroy(self): - """Clean up resources and prevent memory leaks""" - logger.debug("๐Ÿ—‘๏ธ EmbeddedExpandedPlayer cleanup starting") - - try: - # Destroy player content (PlayerBoxStack) - if hasattr(self, "player_content") and hasattr( - self.player_content, "destroy" - ): - self.player_content.destroy() - - # Clean up back button - if hasattr(self, "back_button") and hasattr(self.back_button, "destroy"): - self.back_button.destroy() - - # Clean up any other widgets we might have - for child in list(self.get_children()): - try: - child.destroy() - except Exception as e: - logger.warning(f"Failed to destroy child widget: {e}") - - # Clean up keybinding if it was added - if hasattr(self, "_keybinding_added") and self._keybinding_added: - try: - if hasattr(self.control_center, "remove_keybinding"): - self.control_center.remove_keybinding("Escape") - except Exception as e: - logger.warning(f"Failed to remove keybinding: {e}") - - # Aggressively clean global caches - global _widget_cache, _artwork_cache - _widget_cache.clear() - _artwork_cache.clear() - logger.debug("๐Ÿ—‘๏ธ Cleared global widget and artwork caches") - - # Clear references - if hasattr(self, "control_center"): - self.control_center = None - if hasattr(self, "mpris_manager"): - self.mpris_manager = None - - # Force garbage collection - gc.collect() - logger.debug("๐Ÿ—‘๏ธ EmbeddedExpandedPlayer cleanup completed") - - except Exception as e: - logger.error(f"Error during EmbeddedExpandedPlayer cleanup: {e}") - finally: - super().destroy() - - def _periodic_cleanup(self): - """Light cleanup for EmbeddedExpandedPlayer reuse""" - try: - logger.debug("๐Ÿงน EmbeddedExpandedPlayer light cleanup starting") - - # Only clean up the player content lightly - if hasattr(self, "player_content") and hasattr( - self.player_content, "_periodic_cleanup" - ): - self.player_content._periodic_cleanup() - - # Clean global caches but don't destroy core functionality - global _artwork_cache - _artwork_cache.clear() - - # Light garbage collection - gc.collect() - - logger.debug("๐Ÿงน EmbeddedExpandedPlayer light cleanup completed") - - except Exception as e: - logger.warning(f"Error during EmbeddedExpandedPlayer light cleanup: {e}") - - -class PlayerBoxStack(Box): - """Memory-optimized widget that displays current player information.""" - - def __init__(self, mpris_manager: MprisPlayerManager, **kwargs): - # Clean up old cache files on startup - cleanup_old_cache_files() - - # The player stack with memory-efficient settings - self.player_stack = Stack( - name="player-stack", - # Disable transitions to reduce memory usage - transition_type="none", - ) - self.current_stack_pos = 0 - - # Store player buttons - cleaned up via explicit removal - self.player_buttons: list[Button] = [] - self._player_widgets: Dict[str, Box] = weakref.WeakValueDictionary() - - # Track signal connections for cleanup using weak references - self._signal_connections = [] - - # Create a lightweight "No media playing" placeholder - self.no_media_box = self._create_no_media_box() - - super().__init__(orientation="v", name="media", children=[self.player_stack]) - - # Show the no media box initially - self.player_stack.children = [self.no_media_box] - self.set_visible(True) - - self.mpris_manager = mpris_manager - - # Track connections for cleanup - connections = bulk_connect( - self.mpris_manager, - { - "player-appeared": self.on_new_player, - "player-vanished": self.on_lost_player, - }, - ) - for handler_id in connections: - self._signal_connections.append((self.mpris_manager, handler_id)) - - # Process existing players - for player in self.mpris_manager.players: # type: ignore - logger.info( - f"[PLAYER MANAGER] player found: {player.get_property('player-name')}" - ) - self.on_new_player(self.mpris_manager, player) - - # Schedule periodic memory cleanup and store source ID - self._cleanup_source_id = GLib.timeout_add_seconds( - 300, self._periodic_cleanup - ) # Every 5 minutes - - def _periodic_cleanup(self): - """Light cleanup for widget reuse - preserve functionality.""" - try: - logger.debug("Starting light expanded player cleanup for reuse") - - # Clean artwork cache to reduce memory - cleanup_artwork_cache() - - # Reset visual state but preserve connections and core functionality - # Only clear the player widgets that can be recreated - for widget in list(self._player_widgets.values()): - try: - if widget and hasattr(widget, "get_parent") and widget.get_parent(): - # Only remove from parent, don't destroy (let GTK handle it) - widget.get_parent().remove(widget) - except Exception: - pass - # Don't clear the _player_widgets dict - just let them be recreated - - # Reset stack to show no_media_box without destroying children - try: - if hasattr(self, "player_stack") and hasattr(self, "no_media_box"): - self.player_stack.set_visible_child(self.no_media_box) - self.current_stack_pos = 0 - except Exception: - pass - - # Light garbage collection - gc.collect() - - logger.debug("Light expanded player cleanup completed") - - except Exception as e: - logger.warning(f"Error during light cleanup: {e}") - - return True # Continue timer - - def destroy(self): - """Clean up resources when the widget is destroyed.""" - try: - # Cancel any pending cleanup timer - if hasattr(self, "_cleanup_source_id") and self._cleanup_source_id: - try: - GLib.source_remove(self._cleanup_source_id) - except Exception: - pass # Timer may have already been removed - - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - if obj and hasattr(obj, "disconnect"): - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect signal: {e}") - self._signal_connections.clear() - - # Clean up player widgets - for widget in list(self._player_widgets.values()): - try: - if widget and hasattr(widget, "destroy"): - widget.destroy() - except Exception: - pass - self._player_widgets.clear() - - # Clean up player buttons explicitly - for button in list(self.player_buttons): - try: - if button and hasattr(button, "destroy"): - button.destroy() - except Exception: - pass - - # Clean up stack children - for child in self.player_stack.get_children(): - if hasattr(child, "destroy") and child != self.no_media_box: - try: - child.destroy() - except Exception: - pass - - # Force final garbage collection - gc.collect() - - except Exception as e: - logger.error(f"Error during PlayerBoxStack cleanup: {e}") - finally: - super().destroy() - - def _create_no_media_box(self): - """Create a placeholder box for when no media is playing.""" - fallback_cover_path = f"{data.HOME_DIR}/.current.wall" - - # Album cover with fallback image using Image widget - - album_cover = Box( - name="macos-album-image-no", - ) - album_cover.set_style(f"background-image:url('{fallback_cover_path}')") - - image_stack = Box(h_align="start", v_align="center", name="player-image-stack") - image_stack.children = [album_cover] - - # Track info showing "No media playing" - track_title = Label( - label="No media playing", - name="player-title-no", - justification="left", - max_chars_width=30, - ellipsization="end", - h_align="start", - ) - - track_artist = Label( - label="", - name="player-artist", - justification="left", - max_chars_width=12, - ellipsization="end", - h_align="start", - visible=False, # Hide artist and album when no media - ) - - track_album = Label( - label="", - name="player-album", - justification="left", - max_chars_width=12, - ellipsization="end", - h_align="start", - visible=False, # Hide artist and album when no media - ) - - track_info = Box( - name="track-info", - spacing=5, - orientation="v", - v_align="start", - h_align="start", - children=[track_title, track_artist, track_album], - ) - - # No control buttons for no media state - just an empty box - controls_box = Box( - name="player-controls", - visible=False, # Hide controls when no media - ) - - player_info_box = Box( - name="player-info-box", - v_align="center", - h_align="start", - orientation="v", - h_expand=True, - children=[track_info, controls_box], - ) - - inner_box = Box( - name="inner-player-box", - v_align="center", - h_align="start", - ) - - outer_box = Box( - name="outer-player-box", - h_align="start", - ) - - overlay_box = Overlay( - child=outer_box, - overlays=[ - inner_box, - player_info_box, - image_stack, - ], - ) - - no_media_box = Box( - h_align="center", - name="player-box", - h_expand=True, - children=[overlay_box], - ) - - return no_media_box - - def on_player_clicked(self, type): - # unset active from prev active button - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - - if type == "next": - self.current_stack_pos = ( - self.current_stack_pos + 1 - if self.current_stack_pos != len(self.player_stack.get_children()) - 1 - else 0 - ) - elif type == "prev": - self.current_stack_pos = ( - self.current_stack_pos - 1 - if self.current_stack_pos != 0 - else len(self.player_stack.get_children()) - 1 - ) - - # set new active button - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - - def on_player_clicked_by_index(self, index): - """Switch to player at given index""" - if 0 <= index < len(self.player_buttons): - # unset active from prev active button - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - # set new position - self.current_stack_pos = index - # set new active button - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - # Update all player boxes with new button state - self._update_all_player_buttons() - - def on_new_player(self, mpris_manager, player): - # if player_name in self.config.get("ignore", []): - # return - - # Remove the no media box if it's the only child - if ( - len(self.player_stack.get_children()) == 1 - and self.player_stack.get_children()[0] == self.no_media_box - ): - self.player_stack.children = [] - self.current_stack_pos = 0 - - self.set_visible(True) - - new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self) - self.player_stack.children = [ - *self.player_stack.children, - new_player_box, - ] - - self.make_new_player_button(self.player_stack.get_children()[-1]) - logger.info( - f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}", - ) - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - - # Update all player boxes with current button state - self._update_all_player_buttons() - - def on_lost_player(self, mpris_manager, player_name): - # the playerBox is automatically removed from mprisbox children on being removed - logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}") - players: List[PlayerBox] = self.player_stack.get_children() - - # Find and properly destroy the player box - player_box_to_remove = None - for player_box in players: - if ( - hasattr(player_box, "player") - and player_box.player.player_name == player_name - ): - player_box_to_remove = player_box - break - - if player_box_to_remove: - try: - player_box_to_remove.destroy() - except Exception as e: - logger.warning(f"Failed to destroy player box: {e}") - - # Check if this was the last player - remaining_players = [ - p for p in self.player_stack.get_children() if p != player_box_to_remove - ] - if len(remaining_players) == 0: - # Show the no media box instead of hiding - self.player_stack.children = [self.no_media_box] - self.current_stack_pos = 0 - self.player_buttons = [] # Clear player buttons - return - - # Adjust current position if needed - if self.current_stack_pos >= len(self.player_stack.get_children()): - self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) - - # Set active button if we have buttons and a valid position - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - if self.player_stack.get_children(): - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - - # Update all player boxes with current button state - self._update_all_player_buttons() - - def make_new_player_button(self, player_box): - new_button = Button(name="player-stack-button") - - def on_player_button_click(button: Button): - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - if button in self.player_buttons: - self.current_stack_pos = self.player_buttons.index(button) - button.add_style_class("active") - self.player_stack.set_visible_child(player_box) - - new_button.connect( - "clicked", - on_player_button_click, - ) - self.player_buttons.append(new_button) - - # This will automatically destroy our used button - def cleanup_button(*_): - try: - if new_button in self.player_buttons: - self.player_buttons.remove(new_button) - new_button.destroy() - except Exception as e: - logger.warning(f"Failed to cleanup button: {e}") - - player_box.connect("destroy", cleanup_button) - - def _update_all_player_buttons(self): - """Update all player boxes with the current button state""" - players: List[PlayerBox] = self.player_stack.get_children() - logger.info( - f"[PlayerBoxStack] Updating buttons for {len(players)} players, { - len(self.player_buttons) - } buttons" - ) - for player_box in players: - if hasattr(player_box, "update_buttons"): - player_box.update_buttons(self.player_buttons, len(players) > 1) - else: - logger.warning( - "[PlayerBoxStack] PlayerBox missing update_buttons method" - ) - - -class PlayerBox(Box): - """A widget that displays the current player information.""" - - def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): - super().__init__( - h_align="center", - name="player-box", - **kwargs, - h_expand=True, - ) - # Setup - self.player: MprisPlayer = player - self.player_stack = player_stack - self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall" - - self.icon_size = 15 - - # State - self.exit = False - self.skipped = False - self._user_seeking = False # Flag to prevent choppy seeking - self._seekbar_timer_id = None # Track timer ID for cleanup - - # Memory management - self.temp_artwork_files = [] # Track temp files for cleanup - self.current_download_thread = None # Track current download thread - self._download_cancelled = False # Flag to cancel downloads - self._signal_connections = [] # Track signal connections - - # Use same CSS background approach as small player for consistency - self.album_cover = Box( - name="macos-album-image", - ) - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - self.album_cover.set_size_request(70, 70) - - self.image_stack = Box( - h_align="start", v_align="center", name="player-image-stack" - ) - self.image_stack.children = [*self.image_stack.children, self.album_cover] - - # Track Info - self.track_title = Label( - label="No Title", - name="macos-player-title", - justification="left", - max_chars_width=30, - ellipsization="end", - h_align="start", - h_expand=True, - ) - - self.track_artist = Label( - label="No Artist", - name="macos-player-artist", - justification="left", - max_chars_width=25, - ellipsization="end", - h_align="start", - h_expand=True, - visible=True, - ) - self.track_album = Label( - label="No Album", - name="macos-player-album", - justification="left", - max_chars_width=25, - ellipsization="end", - h_align="start", - visible=True, # Hide artist and album when no media - ) - - self.app_icon = Box( - children=Image( - icon_name=self.player.player_name, name="player-app-icon", icon_size=20 - ), - h_align="end", - v_align="end", - tooltip_text=self.player.player_name, # type: ignore - ) - self.image = Overlay( - child=self.image_stack, - overlays=[ - self.app_icon, - ], - ) - # Seek bar should not update automatically during user interaction - self._user_seeking = False - - self.seek_bar = Scale( - value=0, - min_value=0, - max_value=100, - increments=(1, 1), - name="expanded-seek-bar", - size=1, - h_expand=True, - ) - self.seek_bar.connect("value-changed", self._on_scale_value_changed) - self.seek_bar.connect("button-press-event", self._on_seek_start) - self.seek_bar.connect("button-release-event", self._on_seek_end) - self.player.bind("can-seek", "sensitive", self.seek_bar) - - # Position and length labels for seek bar - self.position_label = Label( - label="0:00", - name="macos-position-label", - justification="left", - h_align="start", - ) - - self.length_label = Label( - label="0:00", - name="macos-length-label", - justification="right", - h_align="end", - ) - - # Labels box for position and length below seek bar - self.labels_box = Box( - name="macos-labels-box", - orientation="h", - children=[ - self.position_label, - Box(h_expand=True), # Spacer to push labels to ends - self.length_label, - ], - ) - - # Seek bar with position labels below - self.seek_box = Box( - name="macos-seek-box", - orientation="v", - spacing=2, - children=[ - self.seek_bar, - self.labels_box, - ], - ) - - # Define buttons first - self.button_box = Box( - name="macos-button-box", - h_align="center", - h_expand=True, - spacing=10, # Reduced spacing for macOS look - ) - - # Define controls_box BEFORE using it in track_info - self.controls_box = Box( - name="macos-player-controls", - orientation="v", - h_expand=True, - spacing=6, - h_align="center", - children=[self.button_box], - ) - - # Track info with inline controls - expands to fill available space - self.track_info = Box( - name="macos-track-info", - spacing=4, # Reduced spacing - orientation="v", - v_align="start", - h_align="fill", # Fill all available horizontal space - h_expand=True, # Expand horizontally to take maximum space - v_expand=True, # Also expand vertically - children=[ - self.track_title, - self.track_artist, - self.track_album, - ], - ) - - # Bind player properties - self.player.bind_property( - "title", - self.track_title, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" - ), # type: ignore - ) - self.player.bind_property( - "artist", - self.track_artist, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist" - ), # type: ignore - ) - self.player.bind_property( - "album", - self.track_album, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Album" - ), # type: ignore - ) - - # Player switcher buttons box (compact, minimal space) - self.stack_buttons_box = Box( - h_expand=False, # Fixed width, don't expand - v_expand=True, - name="macos-stack-buttons-box", - spacing=4, # Reduced spacing - orientation="h", # Vertical layout for compactness - h_align="center", - v_align="end", - ) - self.stack_buttons_box.hide() # Initially hidden - - # Create SVG icons from player directory - self.skip_next_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"), - ) - self.skip_prev_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/Rewind.svg"), - ) - self.play_pause_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"), - ) - - self.play_pause_button = Button( - style_classes=["control-buttons"], - name="macos-play-button", - child=self.play_pause_icon, - on_clicked=self.player.play_pause, - ) - - self.player.bind_property("can_pause", self.play_pause_button, "sensitive") - - self.next_button = Button( - style_classes=["control-buttons"], - name="macos-control-button", - child=self.skip_next_icon, - on_clicked=self._on_player_next, - ) - self.player.bind_property("can_go_next", self.next_button, "sensitive") - - self.prev_button = Button( - name="macos-control-button", - child=self.skip_prev_icon, - style_classes=["control-buttons"], - on_clicked=self._on_player_prev, - ) - self.button_box.children = ( - self.prev_button, - self.play_pause_button, - self.next_button, - ) - - self.box = Box( - orientation="horizontal", - children=[ - self.image, # Album art on left (fixed width) - # Contains title, artist, seek bar AND controls (expands) - self.track_info, - ], - ) - self.inner_box = Box( - orientation="v", - h_expand=True, - h_align="fill", # Fill available space - children=[ - self.box, # Track info and album art - self.seek_box, # Seek bar with position labels - self.controls_box, # Controls now inline with track info - ], - ) - # Compact macOS layout: album art on left, expanded track info+controls, minimal switcher - self.outer_box = Box( - name="macos-outer-player-box", - orientation="v", - spacing=10, # Reduced spacing between elements - h_expand=True, - v_expand=True, - v_align="center", - h_align="fill", # Fill available space - children=[ - self.inner_box, # Track info and controls - # Compact switcher in corner (fixed width) - self.stack_buttons_box, - ], - ) - - self.children = [*self.children, self.outer_box] - - # Track signal connections for cleanup - store (object, handler_id) tuples - connections = bulk_connect( - self.player, - { - "exit": self._on_player_exit, - "notify::playback-status": self._on_playback_change, - "notify::metadata": self._on_metadata, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self.player, handler_id)) - - def destroy(self): - """Clean up all resources when the widget is destroyed.""" - # Set exit flag FIRST to stop any running timers - self.exit = True - - # Cancel any ongoing downloads immediately - self._download_cancelled = True - - # Cancel seek bar timer - if self._seekbar_timer_id: - try: - from gi.repository import GLib - - GLib.source_remove(self._seekbar_timer_id) - except: - pass - self._seekbar_timer_id = None - - # Wait for download thread to finish (with timeout) - if self.current_download_thread and self.current_download_thread.is_alive(): - try: - self.current_download_thread.join(timeout=1.0) # 1 second timeout - except Exception: - pass - - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect signal: {e}") - self._signal_connections.clear() - - # Clean up temp files aggressively - self._cleanup_temp_files() - - # Clear image references - if hasattr(self, "album_cover_image"): - try: - self.album_cover_image.set_from_pixbuf(None) - except Exception: - pass - - super().destroy() - - def __del__(self): - """Ensure cleanup happens even if player exits unexpectedly.""" - try: - self._cleanup_temp_files() - except Exception: - pass # Ignore errors during cleanup in destructor - - def update_buttons(self, player_buttons, show_buttons): - """Update the stack switcher buttons in this player box""" - logger.info( - f"[PlayerBox] update_buttons called: show_buttons={ - show_buttons - }, num_buttons={len(player_buttons)}" - ) - - # Clear existing buttons - for child in self.stack_buttons_box.get_children(): - try: - child.destroy() - except Exception: - pass - - if show_buttons and len(player_buttons) > 1: - logger.info(f"[PlayerBox] Creating {len(player_buttons)} stack buttons") - # Create macOS-style dot indicators for each player - for i, button in enumerate(player_buttons): - # Create a macOS-style dot button - dot_button = Button( - name="macos-player-switcher-dot", - style_classes=["macos-switcher-dot"], - ) - - # Set active state based on original button - if button.get_style_context().has_class("active"): - dot_button.add_style_class("active") - - # Connect click handler to switch to this player - def make_click_handler(index): - return lambda *_: self.player_stack.on_player_clicked_by_index( - index - ) - - dot_button.connect("clicked", make_click_handler(i)) - self.stack_buttons_box.children = [ - *self.stack_buttons_box.children, - dot_button, - ] - logger.info(f"[PlayerBox] Added dot button {i}") - - self.stack_buttons_box.show_all() - logger.info("[PlayerBox] Stack buttons box shown") - else: - self.stack_buttons_box.hide() - logger.info("[PlayerBox] Stack buttons box hidden") - - def length_str(self, length): - """Convert length in microseconds to MM:SS or H:MM:SS format like real media players.""" - if length is None or length <= 0: - return "0:00" - - # Convert microseconds to seconds - length_seconds = length / 1000000 - - hours = int(length_seconds // 3600) - minutes = int((length_seconds % 3600) // 60) - seconds = int(length_seconds % 60) - - if hours > 0: - return f"{hours}:{minutes:02d}:{seconds:02d}" - else: - return f"{minutes}:{seconds:02d}" - - def _on_metadata(self, *_): - self._set_image() - duration = self.player.length - - if duration: - self.length_label.set_label(self.length_str(duration)) - # Clamp duration to avoid 32-bit integer overflow in the scale widget - max_int32 = 2147483647 # 2^31 - 1 - safe_duration = min(max_int32, duration) - self.seek_bar.set_range(0, safe_duration) - - # Cancel existing timer before starting a new one - if self._seekbar_timer_id: - try: - from gi.repository import GLib - - GLib.source_remove(self._seekbar_timer_id) - except: - pass - self._seekbar_timer_id = None - - # Start new timer and store its ID - self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) - - def _cleanup_temp_files(self): - """Clean up temporary artwork files.""" - for temp_file in self.temp_artwork_files: - try: - if os.path.exists(temp_file): - os.unlink(temp_file) - except Exception as e: - logger.warning(f"Failed to cleanup temp file {temp_file}: {e}") - self.temp_artwork_files.clear() - - def _on_player_exit(self, _, value): - self.exit = value - self._cleanup_temp_files() # Clean up temp files before destroying - self.destroy() - - def _on_player_next(self, *_): - self.player.next() - - def _on_player_prev(self, *_): - self.player.previous() - - def _on_playback_change(self, player, status): - status = player.get_property("playback-status") - - if status == "paused": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/play.svg") - ) - - if status == "playing": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/Pause.svg") - ) - - def _update_image(self, image_path): - if image_path and os.path.isfile(image_path): - self.album_cover.set_style(f"background-image:url('{image_path}')") - else: - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - - def _set_image(self, *_): - art_url = self.player.arturl - - # If no art URL or empty/None, use fallback - if not art_url: - self._update_image(None) - return - - parsed = urllib.parse.urlparse(art_url) - if parsed.scheme == "file": - local_arturl = urllib.parse.unquote(parsed.path) - self._update_image(local_arturl) - elif parsed.scheme in ("http", "https"): - # Cancel any existing download to prevent memory buildup - self._download_cancelled = True - - # Use threading.Thread instead of GLib.Thread for better control - if self.current_download_thread and self.current_download_thread.is_alive(): - # Thread will check _download_cancelled flag and exit early - pass - - self._download_cancelled = False - self.current_download_thread = threading.Thread( - target=self._download_and_set_artwork, - args=(art_url,), - daemon=True, # Dies with main thread - ) - self.current_download_thread.start() - else: - print(art_url) - self._update_image(art_url) - - def _download_and_set_artwork(self, arturl): - """ - Download the artwork from the given URL asynchronously and update the cover - using GLib.idle_add to ensure UI updates occur on the main thread. - """ - local_arturl = self.fallback_cover_path - temp_file_path = None - - try: - # Check if download was cancelled - if self._download_cancelled: - return - - # Clean up old temp files first (keep only last 1 to reduce memory) - if len(self.temp_artwork_files) > 1: - old_files = self.temp_artwork_files[:-1] - for old_file in old_files: - try: - if os.path.exists(old_file): - os.unlink(old_file) - except Exception: - pass - self.temp_artwork_files = self.temp_artwork_files[-1:] - - # Check again if cancelled - if self._download_cancelled: - return - - # Download artwork - parsed = urllib.parse.urlparse(arturl) - suffix = os.path.splitext(parsed.path)[1] or ".png" - - with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout - if self._download_cancelled: - return - data = response.read() - - # Check one more time if cancelled - if self._download_cancelled: - return - - # Create temp file in cache directory instead of system temp - os.makedirs(CACHE_DIR, exist_ok=True) - with tempfile.NamedTemporaryFile( - delete=False, suffix=suffix, dir=CACHE_DIR - ) as temp_file: - temp_file.write(data) - temp_file_path = temp_file.name - local_arturl = temp_file_path - - # Track temp file for cleanup - if temp_file_path and not self._download_cancelled: - self.temp_artwork_files.append(temp_file_path) - - except Exception as e: - if not self._download_cancelled: - logger.warning(f"Failed to download artwork from {arturl}: {e}") - # Clean up failed temp file - if temp_file_path and os.path.exists(temp_file_path): - try: - os.unlink(temp_file_path) - except Exception: - pass - return - - # Only update UI if not cancelled - if not self._download_cancelled: - GLib.idle_add(self._update_image, local_arturl) - return None - - def _move_seekbar(self, *_): - if self.player is None or self.exit or self._user_seeking: - return True # Continue the timer but don't update while user is seeking - - # Additional safety checks to prevent GTK errors - if not hasattr(self, "seek_bar") or self.seek_bar is None: - return False # Stop the timer - - try: - position = self.player.position - self.position_label.set_label(self.length_str(position)) - - # Only update seek bar if user is not currently seeking - if not self._user_seeking: - # Clamp position to avoid 32-bit integer overflow - max_int32 = 2147483647 # 2^31 - 1 - safe_position = min(max_int32, position) if position else 0 - self.seek_bar.set_value(safe_position) - - except Exception as e: - # If any error occurs (widget destroyed, etc), stop the timer - logger.warning(f"Seek bar update failed, stopping timer: {e}") - return False - - return True - - def _on_seek_start(self, widget, event): - """User started seeking - disable automatic updates""" - self._user_seeking = True - return False - - def _on_seek_end(self, widget, event): - """User finished seeking - re-enable automatic updates""" - self._user_seeking = False - return False - - def _on_scale_value_changed(self, scale: Scale): - """Handle seek bar value changes - only when user is seeking""" - if self.player and not self.exit and self._user_seeking: - try: - new_position = int(scale.get_value()) - # Clamp to 32-bit signed integer range to avoid overflow - max_int32 = 2147483647 # 2^31 - 1 - min_int32 = -2147483648 # -2^31 - new_position = max(min_int32, min(max_int32, new_position)) - self.player.position = new_position - self.position_label.set_label(self.length_str(new_position)) - except Exception as e: - # If setting position fails, just update the label - try: - self.position_label.set_label(self.length_str(new_position)) - except Exception: - logger.warning(f"Failed to update position label: {e}") - - @cooldown(0.1) - def _on_scale_move(self, scale: Scale, event, pos: int): - try: - if not self.exit and self.player: - self.player.position = pos - self.position_label.set_label(self.length_str(pos)) - self.seek_bar.set_value(pos) - except Exception as e: - logger.warning(f"Failed to update seek position: {e}") - - -class Thing(Box): - def __init__(self, **kwargs): - super().__init__( - name="thing", - size=(480, 160), - orientation="vertical", - spacing=0, - children=[ - Label( - name="thing-label", - label="This is a thing", - style="font-size: 16px; padding: 10px;", - ), - ], - **kwargs, - ) - - -class ExpandedPlayer(Window): - def __init__(self, **kwargs): - super().__init__( - name="expanded-player", - title="modus", - anchor="top right", - layer="top", - exclusivity="auto", - child=PlayerBoxStack(get_shared_mpris_manager()), - visible=False, - ) - self.add_keybinding("Escape", self.set_child_visible(False)) - - def destroy(self): - """Clean up resources when the window is destroyed.""" - # Clean up the child PlayerBoxStack - if hasattr(self, "child") and hasattr(self.child, "destroy"): - try: - self.child.destroy() - except Exception as e: - logger.warning(f"Failed to destroy child PlayerBoxStack: {e}") - - super().destroy() - - def _init_mousecapture(self, mousecapture): - self._mousecapture_parent = mousecapture - - def hide_controlcenter(self, *_): - # self._mousecapture_parent.toggle_mousecapture() - self.set_visible(False) diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py deleted file mode 100644 index cfa00bc..0000000 --- a/modules/controlcenter/main.py +++ /dev/null @@ -1,1078 +0,0 @@ -import subprocess - -from fabric.utils import idle_add -from fabric.utils.helpers import ( - get_relative_path, -) -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.label import Label -from fabric.widgets.scale import Scale -from fabric.widgets.svg import Svg -from gi.repository import Gdk, GLib -from loguru import logger - -from modules.controlcenter.bluetooth import ( - BluetoothConnections, - set_bluetooth_enabled_with_fallback, -) -from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer -from modules.controlcenter.nightlight import create_night_light_widget -from modules.controlcenter.per_app_volume import PerAppVolumeControl -from modules.controlcenter.player import PlayerBoxStack -from modules.controlcenter.wifi import WifiConnections -from services.brightness import Brightness -from services.mpris import MprisPlayerManager -from services.network import NetworkClient -from utils.roam import audio_service, modus_service -from widgets.wayland import WaylandWindow as Window - -brightness_service = Brightness.get_initial() - - -class ModusControlCenter(Window): - def __init__(self, **kwargs): - super().__init__( - layer="top", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="control-center-menu", - visible=False, - **kwargs, - ) - self.focus_mode = modus_service.dont_disturb - self._updating_brightness = False - self._updating_volume = False - - # Flight mode and caffeine states - self.flight_mode = False - self.caffeine_mode = False - self._caffeine_process = None - - # Lazy loading flags - self._music_initialized = False - self._per_app_volume_initialized = False - self._expanded_player_initialized = False - - # Store references for cleanup - initialize all as None - self._signal_connections = [] - self._music_widget_content = None - self._per_app_volume_widget = None - self._expanded_player_widget = None - self._mpris_manager = None # Shared MPRIS manager instance - - # Initialize network service for WiFi toggle - self.network_service = NetworkClient() - self.wifi_service = None - - # Initialize flight mode and caffeine states - self._check_initial_states() - - # Wait for network service to be ready - - self.add_keybinding("Escape", self.hide_controlcenter) - - volume = 100 - wlan = modus_service.sc("wlan-changed", self.wlan_changed) - bluetooth = modus_service.sc("bluetooth-changed", self.bluetooth_changed) - music = modus_service.sc("music-changed", self.audio_changed) - - self.network_service.connect("wifi-device-added", self.on_network_ready) - # Store signal connections for cleanup - self._signal_connections.extend( - [ - audio_service.connect("changed", self.audio_changed), - audio_service.connect("changed", self.volume_changed), - modus_service.connect("dont-disturb-changed", self.dnd_changed), - ] - ) - - print(wlan) - self.wlan_label = Label( - label=wlan, - name="wifi-widget-label", - max_chars_width=15, - h_align="start", - ellipsization="end", - ) - if bluetooth != "disabled": - if bluetooth.startswith("connected:"): - parts = bluetooth.split(":") - bluetooth_display = parts[1] if len(parts) >= 2 else "Connected" - else: - bluetooth_display = "On" - else: - bluetooth_display = "Off" - - self.bluetooth_label = Label( - label=bluetooth_display, - name="bluetooth-widget-label", - max_chars_width=15, - ellipsization="end", - h_align="start", - ) - self.volume_scale = Scale( - value=volume, - min_value=0, - max_value=100, - increments=(5, 5), - name="volume-widget-slider", - size=30, - h_expand=True, - ) - self.volume_scale.connect("change-value", self.set_volume) - self.volume_scale.connect("scroll-event", self.on_volume_scroll) - - current_brightness = brightness_service.screen_brightness - brightness_percentage = ( - int((current_brightness / brightness_service.max_screen) * 100) - if brightness_service.max_screen > 0 - else 50 - ) - - self.brightness_scale = Scale( - value=brightness_percentage, - min_value=0, - max_value=100, - increments=(5, 5), - name="brightness-widget-slider", - size=30, - h_expand=True, - ) - - # Only connect brightness controls if brightness service is available - if brightness_service.max_screen > 0: - self.brightness_scale.connect("change-value", self.set_brightness) - self.brightness_scale.connect("scroll-event", self.on_brightness_scroll) - self._signal_connections.append( - brightness_service.connect("screen", self.brightness_changed) - ) - else: - # Disable brightness scale if no backlight device available - self.brightness_scale.set_sensitive(False) - - # Create placeholder music widget - lazy load content when needed - self.music_widget = Box( - name="music-widget", - h_align="start", - children=[], # Empty initially - ) - - self.has_bluetooth_open = False - self.has_wifi_open = False - self.has_per_app_volume_open = False - self.has_expanded_player_open = False - - self.bluetooth_svg = Svg( - name="bluetooth-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/bluetooth.svg" - if bluetooth != "disabled" - else "../../config/assets/icons/applets/bluetooth-off.svg" - ), - size=42, - ) - self.wifi_svg = Svg( - name="wifi-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/wifi.svg" - if wlan != "No Connection" - else "../../config/assets/icons/applets/wifi-off.svg" - ), - size=42, - ) - - self.bluetooth_widget = Box( - name="bluetooth-widget", - orientation="h", - children=[ - Button( - name="bluetooth-icon-button", - child=self.bluetooth_svg, - on_clicked=self.toggle_bluetooth, - ), - Button( - name="bluetooth-info-button", - child=Box( - name="bluetooth-widget-info", - orientation="vertical", - children=[ - Label( - name="bluetooth-widget-name", - label="Bluetooth", - style_classes="ct", - h_align="start", - ), - self.bluetooth_label, - ], - ), - on_clicked=self.open_bluetooth, - ), - ], - ) - - self.wlan_widget = Box( - name="wifi-widget", - orientation="h", - children=[ - Button( - name="wifi-icon-button", - child=self.wifi_svg, - on_clicked=self.toggle_wifi, - ), - Button( - name="wifi-info-button", - child=Box( - name="wifi-widget-info", - orientation="vertical", - children=[ - Label( - name="wifi-widget-name", - label="Wi-Fi", - style_classes="ct", - h_align="start", - ), - self.wlan_label, - ], - ), - on_clicked=self.open_wifi, - ), - ], - ) - - self.focus_icon = Svg( - name="focus-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ), - size=42, - ) - - self.focus_status_label = Label( - label="On" if self.focus_mode else "Off", - style_classes="status-label", - h_align="start", - ) - - self.focus_widget = Button( - name="focus-widget", - child=Box( - spacing=4, - orientation="h", - children=[ - self.focus_icon, - Box( - v_expand=True, - h_expand=True, - v_align="center", - orientation="v", - h_align="start", - children=[ - Label( - label="Focus", - style_classes="title-widget", - h_align="start", - ), - self.focus_status_label, - ], - ), - ], - ), - on_clicked=self.set_dont_disturb, - ) - - self.flight_icon = Svg( - name="flight-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/flight-on.svg" - if self.flight_mode - else "../../config/assets/icons/applets/flight-off.svg" - ), - size=42, - ) - - self.flight_widget = Button( - name="flight-widget", - child=Box( - orientation="v", - h_expand=True, - v_expand=True, - spacing=8, - h_align="center", - v_align="center", - children=[ - self.flight_icon, - Label( - label="Flight", - style_classes="title-widget", - h_align="center", - ), - ], - ), - on_clicked=self.toggle_flight_mode, - ) - - self.caffeine_icon = Svg( - name="caffeine-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/caffeine-on.svg" - if self.caffeine_mode - else "../../config/assets/icons/applets/caffeine-off.svg" - ), - size=42, - ) - - self.caffeine_status_label = Label( - label="On" if self.caffeine_mode else "Off", - style_classes="status-label", - h_align="start", - ) - - self.caffeine_widget = Button( - name="caffeine-widget", - child=Box( - orientation="v", - spacing=8, - h_expand=True, - v_expand=True, - h_align="center", - v_align="center", - children=[ - self.caffeine_icon, - Label( - label="Caffeine", - style_classes="title-widget", - h_align="center", - ), - # Box( - # orientation="vertical", - # v_expand=True, - # v_align="center", - # h_align="center", - # h_expand=True, - # children=[ - # # self.caffeine_status_label, - # ], - # ), - ], - ), - on_clicked=self.toggle_caffeine, - ) - - # Create night light widget - self.night_light_widget = create_night_light_widget(self) - - # Create main widgets directly without XML - self.widgets = Box( - orientation="vertical", - h_expand=True, - name="control-center-widgets", - children=[ - # Top section: Wi-Fi + Bluetooth on left, DND + Flight Mode + Caffeine on right - Box( - orientation="horizontal", - h_expand=True, - name="top-widget", - children=[ - # Left side: Wi-Fi and Bluetooth - Box( - orientation="vertical", - name="wb-widget", - style_classes="menu", - spacing=5, - children=[ - self.wlan_widget, - self.bluetooth_widget, - self.night_light_widget, - ], - ), - # Right side: DND, Flight Mode, and Caffeine - Box( - orientation="vertical", - h_expand=True, - name="right-side-widget", - children=[ - # DND widget - Box( - orientation="vertical", - name="dnd-widget", - style_classes="menu", - children=[ - self.focus_widget, - ], - ), - # Flight Mode and Caffeine row - Box( - orientation="horizontal", - name="flight-caffeine-row", - children=[ - Box( - orientation="vertical", - name="flight-widget-container", - style_classes="menu", - h_expand=True, - v_expand=True, - children=[ - self.flight_widget, - ], - ), - Box( - orientation="vertical", - name="caffeine-widget-container", - h_expand=True, - v_expand=True, - style_classes="menu", - children=[ - self.caffeine_widget, - ], - ), - ], - ), - ], - ), - ], - ), - Box( - orientation="vertical", - name="brightness-widget", - style_classes="menu", - h_expand=True, - children=[ - Label(label="Display", style_classes="title", h_align="start"), - self.brightness_scale, - Label( - label="๓ฐ–จ ", name="brightness-widget-icon", h_align="start" - ), - ], - ), - Box( - orientation="vertical", - name="volume-widget", - style_classes="menu", - h_expand=True, - children=[ - Label(label="Sound", style_classes="title", h_align="start"), - Box( - name="vol-box", - orientation="horizontal", - spacing=12, - v_expand=True, - children=[ - Box( - name="vol-slider-box", - h_expand=True, - v_align="center", - v_expand=False, - children=[ - self.volume_scale, - ], - ), - Button( - name="per-app-volume-button", - size=(36, 36), - child=Svg( - svg_file=get_relative_path( - "../../config/assets/icons/player/audio-switcher.svg" - ), - name="per-app-volume-icon", - sidze=32, - ), - on_clicked=self.open_per_app_volume, - ), - ], - ), - Label(label="๏€จ ", name="volume-widget-icon", h_align="start"), - ], - ), - self.music_widget, - ], - ) - - # Initialize managers directly like working version - self.wifi_man = WifiConnections(self) - self.bluetooth_man = BluetoothConnections(self) - - self.has_bluetooth_open = False - self.has_wifi_open = False - - # Lazy-loaded widgets - create placeholders - self.bluetooth_widgets = None - self.wifi_widgets = None - self.per_app_volume_widgets = None - self.expanded_player_widgets = None - - # Create main content boxes - self.center_box = CenterBox(start_children=[self.widgets]) - self.bluetooth_center_box = None - self.wifi_center_box = None - self.per_app_volume_center_box = None - self.expanded_player_center_box = None - - # Create revealers for crossfade transitions - - self.widgets.set_size_request(300, -1) - - self.children = self.center_box - - # Track current state for smooth transitions - self.current_view = "main" # main, expanded_player - - # Connect to visibility changes for cleanup - self.connect("notify::visible", self._on_visibility_changed) - - def _on_visibility_changed(self, widget, param): - """Handle visibility changes for memory management""" - if not self.get_visible(): - self._cleanup_when_hidden() - - def _cleanup_when_hidden(self): - """Aggressively clean up resources when widget is hidden to reduce memory usage""" - try: - # Clean up music widget content if it exists - if self._music_widget_content: - # Remove from the parent container - current_children = list(self.music_widget.children) - if self._music_widget_content in current_children: - current_children.remove(self._music_widget_content) - self.music_widget.children = current_children - - # Trigger periodic cleanup before destroying - if hasattr(self._music_widget_content, "_periodic_cleanup"): - self._music_widget_content._periodic_cleanup() - - # Properly destroy the music widget content - try: - self._music_widget_content.destroy() - except Exception as e: - logger.warning(f"Failed to destroy music widget content: {e}") - self._music_widget_content = None - - # Clean up shared MPRIS manager when hidden to free memory - if self._mpris_manager: - try: - self._mpris_manager.destroy() - except Exception as e: - logger.warning( - f"Failed to destroy MPRIS manager during cleanup: {e}" - ) - self._mpris_manager = None - - # Reset initialization flags to force recreation next time - self._music_initialized = False - - # Force garbage collection - import gc - - gc.collect() - - logger.debug("Control center aggressive cleanup completed") - - except Exception as e: - logger.warning(f"Control center cleanup failed: {e}") - - def _ensure_music_widget(self): - """Lazy load music widget content - reuse MPRIS manager""" - if not self._music_initialized: - # Create shared MPRIS manager if it doesn't exist - if self._mpris_manager is None: - self._mpris_manager = MprisPlayerManager() - - self._music_widget_content = PlayerBoxStack( - self._mpris_manager, control_center=self - ) - # Add to the music widget's children list - current_children = list(self.music_widget.children) - current_children.append(self._music_widget_content) - self.music_widget.children = current_children - self._music_initialized = True - - def _ensure_bluetooth_widgets(self): - """Lazy load bluetooth widgets""" - if self.bluetooth_widgets is None: - self.bluetooth_widgets = Box( - orientation="vertical", - h_expand=True, - v_expand=True, - children=[ - self.bluetooth_man, - # Box( - # orientation="horizontal", - # name="top-widget", - # h_expand=True, - # children=[ - # Box( - # orientation="vertical", - # name="wb-widget", - # style_classes="menu", - # spacing=5, - # children=[ - # ], - # ), - # ], - # ), - ], - ) - self.bluetooth_center_box = Box( - h_expand=True, v_expand=True, children=[self.bluetooth_widgets] - ) - self.bluetooth_center_box.set_size_request(350, -1) - - def _ensure_wifi_widgets(self): - """Lazy load wifi widgets""" - if self.wifi_widgets is None: - self.wifi_widgets = Box( - orientation="vertical", - h_expand=True, - v_expand=True, - children=[ - self.wifi_man, - ], - ) - self.wifi_center_box = Box( - h_expand=True, v_expand=True, children=[self.wifi_widgets] - ) - self.wifi_center_box.set_size_request(350, -1) - - def _ensure_per_app_volume_widgets(self): - """Lazy load per-app volume widgets""" - if self.per_app_volume_widgets is None: - if self._per_app_volume_widget is None: - self._per_app_volume_widget = PerAppVolumeControl(self) - - self.per_app_volume_widgets = Box( - orientation="vertical", - h_expand=True, - name="control-center-widgets", - children=[ - self._per_app_volume_widget, - ], - ) - self.per_app_volume_center_box = CenterBox( - start_children=[self.per_app_volume_widgets] - ) - self.per_app_volume_center_box.set_size_request(300, -1) - - def _ensure_expanded_player_widgets(self): - """Lazy load expanded player widgets""" - if self.expanded_player_widgets is None: - if self._expanded_player_widget is None: - self._expanded_player_widget = EmbeddedExpandedPlayer(self) - - self.expanded_player_widgets = Box( - orientation="vertical", - h_expand=True, - name="control-center-widgets", - children=[ - self._expanded_player_widget, - ], - ) - self.expanded_player_center_box = CenterBox( - start_children=[self.expanded_player_widgets] - ) - self.expanded_player_center_box.set_size_request(300, -1) - - def _check_initial_states(self): - # Check if caffeine is already running - try: - result = subprocess.run( - ["pgrep", "-f", "modus-inhibit"], capture_output=True, text=True - ) - self.caffeine_mode = bool(result.stdout.strip()) - except Exception: - self.caffeine_mode = False - - # Flight mode starts as False (normal mode) - self.flight_mode = False - - # Update initial labels (will be set after widgets are created) - GLib.timeout_add(100, self._update_initial_labels) - - def _update_initial_labels(self): - return False - - def set_dont_disturb(self, *_): - self.focus_mode = not self.focus_mode - modus_service.dont_disturb = self.focus_mode - self.focus_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ) - ) - self.focus_status_label.set_label("On" if self.focus_mode else "Off") - - def toggle_flight_mode(self, *_): - try: - self.flight_mode = not self.flight_mode - - if self.flight_mode: - # Turn off WiFi and Bluetooth - if self.wifi_service: - self.wifi_service.wireless_enabled = False - if hasattr(self, "bluetooth_man") and hasattr( - self.bluetooth_man, "client" - ): - self.bluetooth_man.client.set_enabled(False) - else: - # Turn on WiFi and Bluetooth - if self.wifi_service: - self.wifi_service.wireless_enabled = True - if hasattr(self, "bluetooth_man") and hasattr( - self.bluetooth_man, "client" - ): - self.bluetooth_man.client.set_enabled(True) - - # Update icon - self.flight_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/flight-on.svg" - if self.flight_mode - else "../../config/assets/icons/applets/flight-off.svg" - ) - ) - - except Exception as e: - logger.warning(f"Failed to toggle flight mode: {e}") - - def toggle_caffeine(self, *_): - try: - if self.caffeine_mode: - # Turn off caffeine - inhibit_script = get_relative_path("../../utils/inhibit.py") - subprocess.run(["python3", inhibit_script, "off"], check=False) - self.caffeine_mode = False - if self._caffeine_process: - try: - self._caffeine_process.terminate() - except: - pass - self._caffeine_process = None - else: - # Turn on caffeine - inhibit_script = get_relative_path("../../utils/inhibit.py") - self._caffeine_process = subprocess.Popen( - ["python3", inhibit_script, "on"], start_new_session=True - ) - self.caffeine_mode = True - - self.caffeine_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/caffeine-on.svg" - if self.caffeine_mode - else "../../config/assets/icons/applets/caffeine-off.svg" - ) - ) - self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off") - - except Exception as e: - logger.warning(f"Failed to toggle caffeine: {e}") - - def set_volume(self, _, __, volume): - self._updating_volume = True - audio_service.speaker.volume = round(volume) - self._updating_volume = False - - def set_brightness(self, _, __, brightness): - self._updating_brightness = True - brightness_value = int((brightness / 100) * brightness_service.max_screen) - brightness_service.screen_brightness = brightness_value - self._updating_brightness = False - - def brightness_changed(self, _, brightness_value): - if self._updating_brightness: - return - - if brightness_service.max_screen > 0: - brightness_percentage = int( - (brightness_value / brightness_service.max_screen) * 100 - ) - - GLib.idle_add( - lambda: self.brightness_scale.set_value(brightness_percentage) - ) - - def on_volume_scroll(self, widget, event): - current_value = self.volume_scale.get_value() - scroll_step = 5 - if event.direction == Gdk.ScrollDirection.UP: - new_value = min(100, current_value + scroll_step) - elif event.direction == Gdk.ScrollDirection.DOWN: - new_value = max(0, current_value - scroll_step) - else: - return False - - self.volume_scale.set_value(new_value) - return True - - def on_brightness_scroll(self, widget, event): - current_value = self.brightness_scale.get_value() - scroll_step = 5 - if event.direction == Gdk.ScrollDirection.UP: - new_value = min(100, current_value + scroll_step) - elif event.direction == Gdk.ScrollDirection.DOWN: - new_value = max(0, current_value - scroll_step) - else: - return False - - self.brightness_scale.set_value(new_value) - return True - - def toggle_bluetooth(self, *_): - try: - # Access the bluetooth client from the bluetooth manager - if hasattr(self, "bluetooth_man") and hasattr(self.bluetooth_man, "client"): - current_state = self.bluetooth_man.client.enabled - set_bluetooth_enabled_with_fallback( - self.bluetooth_man.client, not current_state - ) - else: - logger.warning("Bluetooth client not available for toggling") - except Exception as e: - logger.warning(f"Failed to toggle bluetooth: {e}") - - def on_network_ready(self, *_): - """Called when network service is ready""" - self.wifi_service = self.network_service.wifi_device - if self.wifi_service: - # Connect to WiFi state changes to update icon - self.wifi_service.connect("notify::wireless-enabled", self.update_wifi_icon) - - def update_wifi_icon(self, *_): - """Update WiFi icon based on current state""" - try: - if self.wifi_service and hasattr(self, "wifi_svg"): - is_enabled = self.wifi_service.wireless_enabled - icon_file = ( - "../../config/assets/icons/applets/wifi.svg" - if is_enabled - else "../../config/assets/icons/applets/wifi-off.svg" - ) - self.wifi_svg.set_from_file(get_relative_path(icon_file)) - except Exception as e: - logger.warning(f"Failed to update WiFi icon: {e}") - - def toggle_wifi(self, *_): - """Toggle wifi on/off""" - try: - if self.wifi_service: - self.wifi_service.toggle_wifi() - # Update icon immediately after toggle - GLib.timeout_add(100, self.update_wifi_icon) - else: - logger.warning("WiFi device not available for toggling") - except Exception as e: - logger.warning(f"Failed to toggle wifi: {e}") - - def set_children(self, children): - self.children = children - - def open_bluetooth(self, *_): - self._ensure_bluetooth_widgets() - idle_add(lambda *_: self.set_children(self.bluetooth_center_box)) - self.has_bluetooth_open = True - - def open_wifi(self, *_): - self._ensure_wifi_widgets() - idle_add(lambda *_: self.set_children(self.wifi_center_box)) - self.has_wifi_open = True - - def close_bluetooth(self, *_): - if self.current_view == "expanded_player": - self._crossfade_to_view("main") - else: - idle_add(lambda *_: self.set_children(self.center_box)) - self.has_bluetooth_open = False - - def close_wifi(self, *_): - if self.current_view == "expanded_player": - self._crossfade_to_view("main") - else: - idle_add(lambda *_: self.set_children(self.center_box)) - self.has_wifi_open = False - - def open_per_app_volume(self, *_): - self._ensure_per_app_volume_widgets() - if self.current_view == "expanded_player": - # If coming from expanded player, use crossfade - self._crossfade_to_view("main") - GLib.timeout_add( - 250, - lambda: idle_add( - lambda *_: self.set_children(self.per_app_volume_center_box) - ), - ) - else: - idle_add(lambda *_: self.set_children(self.per_app_volume_center_box)) - self.has_per_app_volume_open = True - # Refresh the app list when opening - if self._per_app_volume_widget: - self._per_app_volume_widget.refresh() - - def close_per_app_volume(self, *_): - if self.current_view == "expanded_player": - self._crossfade_to_view("main") - else: - idle_add(lambda *_: self.set_children(self.center_box)) - self.has_per_app_volume_open = False - - def open_expanded_player(self, *_): - self._ensure_expanded_player_widgets() - self._crossfade_to_view("expanded_player") - self.has_expanded_player_open = True - # Refresh the player when opening - if self._expanded_player_widget: - self._expanded_player_widget.refresh() - - def close_expanded_player(self, *_): - self._crossfade_to_view("main") - self.has_expanded_player_open = False - - def _crossfade_to_view(self, view_name): - """Handle transitions between views""" - if view_name == "expanded_player": - # Show expanded player - self._ensure_expanded_player_widgets() - idle_add(lambda *_: self.set_children(self.expanded_player_center_box)) - self.current_view = "expanded_player" - elif view_name == "main": - # Show main view - idle_add(lambda *_: self.set_children(self.center_box)) - self.current_view = "main" - - def _set_mousecapture(self, visible: bool): - if visible: - # Lazy load music widget when becoming visible - self._ensure_music_widget() - - self.set_visible(visible) - if not visible: - self.close_bluetooth() - self.close_wifi() - self.close_per_app_volume() - self.close_expanded_player() - - def volume_changed( - self, - _, - ): - if self._updating_volume: - return - - GLib.idle_add( - lambda: self.volume_scale.set_value(int(audio_service.speaker.volume)) - ) - - def wlan_changed(self, _, wlan): - self.wifi_svg.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/wifi.svg" - if wlan != "No Connection" - else "../../config/assets/icons/applets/wifi-off.svg" - ) - ) - if wlan != "No Connection": - if wlan.startswith("connected:"): - parts = wlan.split(":") - if len(parts) >= 2: - wifi_name = parts[1] - GLib.idle_add( - lambda: self.wlan_label.set_property("label", wifi_name) - ) - else: - GLib.idle_add( - lambda: self.wlan_label.set_property("label", "Connected") - ) - else: - GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan)) - else: - GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan)) - - def bluetooth_changed(self, _, bluetooth): - self.bluetooth_svg.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/bluetooth.svg" - if bluetooth != "disabled" - else "../../config/assets/icons/applets/bluetooth-off.svg" - ) - ) - if bluetooth != "disabled": - if bluetooth.startswith("connected:"): - parts = bluetooth.split(":") - if len(parts) >= 2: - device_name = parts[1] - GLib.idle_add(lambda: self.bluetooth_label.set_label(device_name)) - else: - GLib.idle_add(lambda: self.bluetooth_label.set_label("Connected")) - elif bluetooth == "enabled": - GLib.idle_add(lambda: self.bluetooth_label.set_label("On")) - else: - GLib.idle_add(lambda: self.bluetooth_label.set_label("On")) - else: - GLib.idle_add(lambda: self.bluetooth_label.set_label("Off")) - - def audio_changed(self, *_): - pass - - def dnd_changed(self, _, dnd_state): - self.focus_mode = dnd_state - self.focus_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ) - ) - self.focus_status_label.set_label("On" if self.focus_mode else "Off") - - def _init_mousecapture(self, mousecapture): - self._mousecapture_parent = mousecapture - - def hide_controlcenter(self, *_): - self._mousecapture_parent.toggle_mousecapture() - self.set_visible(False) - - def destroy(self): - """Clean up resources when widget is destroyed""" - # Disconnect all signal connections - for connection in self._signal_connections: - try: - connection.disconnect() - except: - pass - - # Clean up caffeine process - if self._caffeine_process: - try: - self._caffeine_process.terminate() - except: - pass - self._caffeine_process = None - - # Clean up heavy components - if hasattr(self, "wifi_man") and self.wifi_man: - self.wifi_man.destroy() - if hasattr(self, "bluetooth_man") and self.bluetooth_man: - self.bluetooth_man.destroy() - if self._music_widget_content: - self._music_widget_content.destroy() - if self._per_app_volume_widget: - self._per_app_volume_widget.destroy() - if self._expanded_player_widget: - self._expanded_player_widget.destroy() - - # Clean up shared MPRIS manager - if self._mpris_manager: - try: - self._mpris_manager.destroy() - except Exception as e: - logger.warning(f"Failed to destroy MPRIS manager: {e}") - self._mpris_manager = None - - super().destroy() diff --git a/modules/controlcenter/nightlight.py b/modules/controlcenter/nightlight.py deleted file mode 100644 index c5414c2..0000000 --- a/modules/controlcenter/nightlight.py +++ /dev/null @@ -1,146 +0,0 @@ -# Standard library imports -import subprocess - -# Fabric imports -from fabric.utils.helpers import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.svg import Svg - -# Local imports -from loguru import logger - - -class NightLightControl: - """Control night light using hyprsunset""" - - def __init__(self): - self.is_active = False - self._check_initial_state() - - def _check_initial_state(self): - """Check if hyprsunset is currently running""" - try: - result = subprocess.run( - ["pgrep", "-f", "hyprsunset"], capture_output=True, text=True - ) - self.is_active = bool(result.stdout.strip()) - except Exception as e: - logger.warning(f"Failed to check hyprsunset status: {e}") - self.is_active = False - - def toggle(self): - """Toggle night light on/off""" - try: - if self.is_active: - # Turn off night light by killing hyprsunset - subprocess.run(["pkill", "hyprsunset"], check=False) - self.is_active = False - logger.debug("Night light turned off") - else: - # Turn on night light with default temperature (3000K) - subprocess.Popen(["hyprsunset", "-t", "4500"], start_new_session=True) - self.is_active = True - logger.debug("Night light turned on") - return True - except Exception as e: - logger.warning(f"Failed to toggle night light: {e}") - return False - - def set_temperature(self, temperature: int): - """Set night light temperature (1000-6500K)""" - try: - # Kill existing process - subprocess.run(["pkill", "hyprsunset"], check=False) - - # Start with new temperature - subprocess.Popen( - ["hyprsunset", "-t", str(temperature)], start_new_session=True - ) - self.is_active = True - logger.debug(f"Night light temperature set to {temperature}K") - return True - except Exception as e: - logger.warning(f"Failed to set night light temperature: {e}") - return False - - -def create_night_light_widget(control_center): - """Create night light widget for control center""" - - # Initialize night light control - night_light = NightLightControl() - - # Create icon - night_light_icon = Svg( - name="nightlight-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/redshift-status-on.svg" - if night_light.is_active - else "../../config/assets/icons/applets/redshift-status-off.svg" - ), - size=42, - ) - - # Create status label - night_light_status_label = Label( - label="On" if night_light.is_active else "Off", - name="nightlight-widget-label", - style_classes="status-label", - max_chars_width=15, - ellipsization="end", - h_align="start", - ) - - def toggle_night_light(*_): - """Toggle night light and update UI""" - if night_light.toggle(): - # Update icon - night_light_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/redshift-status-on.svg" - if night_light.is_active - else "../../config/assets/icons/applets/redshift-status-off.svg" - ) - ) - # Update status label - night_light_status_label.set_label("On" if night_light.is_active else "Off") - - # Create widget box (similar to bluetooth_widget structure) - night_light_widget = Box( - name="nightlight-widget", - orientation="h", - h_expand=True, - children=[ - Button( - name="nightlight-icon-button", - child=night_light_icon, - on_clicked=toggle_night_light, - ), - Button( - name="nightlight-info-button", - child=Box( - name="nightlight-widget-info", - h_expand=True, - v_expand=True, - v_align="center", - h_align="start", - orientation="vertical", - children=[ - Label( - name="nightlight-widget-name", - label="Night Light", - style_classes="ct", - h_align="start", - ), - night_light_status_label, - ], - ), - on_clicked=toggle_night_light, - ), - ], - ) - - return night_light_widget - diff --git a/modules/controlcenter/per_app_volume.py b/modules/controlcenter/per_app_volume.py deleted file mode 100644 index 08ba18a..0000000 --- a/modules/controlcenter/per_app_volume.py +++ /dev/null @@ -1,336 +0,0 @@ -# Standard library imports -from gi.repository import GLib - -# Fabric imports -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.scale import Scale -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.image import Image -from fabric.widgets.separator import Separator - -# Local imports -from utils.roam import audio_service - - -class PerAppVolumeControl(Box): - """Per-application volume control widget""" - - def __init__(self, control_center, **kwargs): - super().__init__( - orientation="vertical", - name="per-app-volume-control", - spacing=5, - **kwargs, - ) - - self.control_center = control_center - self._updating_volumes = set() - self._app_widgets = {} - self._signal_connections = [] - - # Header with back button - self.header = Box( - orientation="horizontal", - name="per-app-volume-header", - style_classes="menu-header", - children=[ - Button( - image=Image(icon_name="back", size=10), - on_clicked=lambda *_: self.control_center.close_per_app_volume(), - ), - Label("App Volume", name="app-volume-header"), - ], - ) - - # Scrollable container for app volume controls - self.apps_container = Box( - orientation="vertical", - name="apps-scrolled-container", - spacing=2, - ) - - self.scrolled_window = ScrolledWindow( - name="apps-scrolled-window", - child=self.apps_container, - size=(300, 500), - ) - - # Add escape key binding for navigation back - try: - if hasattr(self.control_center, "add_keybinding"): - self.control_center.add_keybinding("Escape", self._go_back) - except Exception: - pass # Ignore if keybinding fails - - self.children = [self.header, self.scrolled_window] - - # Connect to audio service changes - if audio_service: - self._signal_connections.append( - audio_service.connect("stream-added", self._on_stream_changed) - ) - self._signal_connections.append( - audio_service.connect("stream-removed", self._on_stream_changed) - ) - - # Initial population - self._populate_apps() - - # Set up auto-refresh timer for audio streams - self._refresh_timer = GLib.timeout_add_seconds(2, self._auto_refresh) - - def _auto_refresh(self): - """Auto-refresh the application list every 2 seconds""" - self._populate_apps() - return True # Continue the timer - - def _go_back(self, *_): - """Return to main control center view""" - self.control_center.close_per_app_volume() - - def _get_app_icon(self, app): - """Get icon for application""" - # Don't use fabric's generic audio icon, use description/name for better detection - fabric_icon = getattr(app, "icon_name", "") - if fabric_icon and fabric_icon != "audio": - return fabric_icon - - # Use description first (more accurate), then name - app_description = getattr(app, "description", "").lower() - app_name = app.name.lower() - - # Check description first as it's more reliable - search_text = app_description if app_description else app_name - - icon_mapping = { - "spotify": "spotify", - "firefox": "firefox", - "chromium": "chromium-browser", - "chrome": "google-chrome", - "vlc": "vlc", - "discord": "discord", - "steam": "steam", - "zen": "zen-browser", - "code": "vscode", - "visual studio code": "vscode", - "telegram": "telegram-desktop", - "pulse": "audio-card", - "zen": "zen-browser", - "pipewire": "audio-card", - "alsa": "audio-card", - "sink": "audio-speakers", - "source": "audio-input-microphone", - "youtube": "youtube", - "music": "rhythmbox", - "media": "multimedia-player", - } - - # Check if any key in mapping is contained in search text - for key, icon in icon_mapping.items(): - if key in search_text: - return icon - - # Also check app name as fallback - if search_text != app_name: - for key, icon in icon_mapping.items(): - if key in app_name: - return icon - - # Default audio icon for unknown apps - return "audio-volume-high" - - def _format_app_name(self, name): - """Format application name with proper capitalization""" - if not name: - return "Unknown" - - # Handle common app names specially - special_names = { - "spotify": "Spotify", - "firefox": "Firefox", - "chromium": "Chromium", - "chrome": "Chrome", - "vlc": "VLC", - "discord": "Discord", - "steam": "Steam", - "zen": "Zen Browser", - "code": "VS Code", - "telegram": "Telegram", - } - - name_lower = name.lower() - if name_lower in special_names: - return special_names[name_lower] - - # Default: capitalize first letter - return name.capitalize() - - def _populate_apps(self): - """Populate the widget with current audio applications""" - # Clear existing widgets - self.apps_container.children = [] - self._app_widgets.clear() - - # Use fabric audio service for applications - if not audio_service: - self._show_no_apps_message() - return - - applications = getattr(audio_service, "applications", []) - - if applications: - for i, app in enumerate(applications): - if hasattr(app, "name") and hasattr(app, "volume"): - app_widget = self._create_app_control(app) - self.apps_container.children = list( - self.apps_container.children - ) + [app_widget] - - # Add separator between apps (except for last one) - if i < len(applications) - 1: - separator = Separator( - orientation="horizontal", - style_classes="app-volume-separator", - ) - self.apps_container.children = list( - self.apps_container.children - ) + [separator] - - self._app_widgets[app.name] = (app_widget, app) - else: - self._show_no_apps_message() - - def _show_no_apps_message(self): - """Show message when no apps are playing audio""" - message = Label( - label="No applications currently using audio", - style_classes="subtitle", - h_align="center", - v_align="center", - ) - self.apps_container.children = [message] - - def _create_app_control(self, app): - """Create compact volume control for a single application""" - # Format app name (shorter for compact layout) - app_name = self._format_app_name(app.name) - # Get current volume from fabric audio service - current_volume = getattr(app, "volume", 0.0) - max_vol = getattr(audio_service, "max_volume", 100) if audio_service else 100 - - # Ensure volume is in valid range - volume_percent = max(0, min(current_volume, max_vol)) - - # App icon - use the same approach as expanded player - icon_name = self._get_app_icon(app) - app_icon = Image( - icon_name=icon_name, - name="app-icon", - icon_size=16, - style_classes="app-icon", - ) - - # App name label - name_label = Label( - label=app_name, - style_classes="app-name-compact", - justification="left", - h_align="start", - max_chars_width=10, - ellipsization="end", - ) - - # Volume scale (smaller and horizontal) - volume_scale = Scale( - value=volume_percent, - min_value=0, - max_value=max_vol, - increments=(1, 5), - name="compact-volume-slider", - size=20, - h_expand=True, - ) - - # Connect volume change handler - volume_scale.connect( - "change-value", - lambda scale, scroll_type, value, app=app: self._set_app_volume(app, value), - ) - - # Create horizontal compact layout - app_control = Box( - orientation="horizontal", - spacing=5, - h_expand=True, - style_classes="compact-app-volume-item", - children=[ - Box( - orientation="h", - spacing=3, - v_expand=True, - v_align="center", - name="app-control-box", - children=[ - app_icon, - name_label, - ], - ), - volume_scale, - ], - ) - # Add separator after the control - - return app_control - - def _set_app_volume(self, app, volume_value): - """Set volume for a specific application using fabric audio service""" - if app.name in self._updating_volumes: - return - - self._updating_volumes.add(app.name) - - try: - # Get max volume from audio service - max_vol = ( - getattr(audio_service, "max_volume", 100) if audio_service else 100 - ) - - # Ensure volume is within bounds - volume_value = max(0, min(volume_value, max_vol)) - - # Set volume directly - fabric expects the actual volume value, not percentage - app.volume = volume_value - - except Exception as e: - print(f"Error setting volume for {app.name}: {e}") - finally: - GLib.timeout_add(100, lambda: self._updating_volumes.discard(app.name)) - - def _on_stream_changed(self, *_): - """Handle when audio streams are added or removed""" - GLib.idle_add(self._populate_apps) - - def refresh(self): - """Manually refresh the application list""" - self._populate_apps() - - def destroy(self): - """Clean up resources""" - if hasattr(self, "_refresh_timer"): - GLib.source_remove(self._refresh_timer) - - # Disconnect fabric audio service signals - if audio_service: - for connection in self._signal_connections: - try: - audio_service.disconnect(connection) - except: - pass - - self._signal_connections.clear() - self._app_widgets.clear() - self._updating_volumes.clear() - - super().destroy() diff --git a/modules/controlcenter/player.py b/modules/controlcenter/player.py deleted file mode 100644 index d5a6fd4..0000000 --- a/modules/controlcenter/player.py +++ /dev/null @@ -1,955 +0,0 @@ -import os -import re -import tempfile -import urllib.parse -import urllib.request -from typing import List -import threading - -from fabric.utils import ( - bulk_connect, -) -from fabric.utils.helpers import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from fabric.widgets.overlay import Overlay -from fabric.widgets.stack import Stack -from fabric.widgets.svg import Svg -from gi.repository import GLib, GObject -from fabric.widgets.centerbox import CenterBox -from loguru import logger - -from services.mpris import MprisPlayer, MprisPlayerManager -import config.data as data - -CACHE_DIR = f"{data.CACHE_DIR}/media" - - -def cleanup_old_cache_files(): - """Clean up old artwork cache files (older than 1 day) and limit total cache size.""" - try: - if not os.path.exists(CACHE_DIR): - return - - import time - - current_time = time.time() - one_day_ago = current_time - (24 * 60 * 60) # 24 hours - cache_files = [] - - # Collect all cache files with their modification times - for filename in os.listdir(CACHE_DIR): - filepath = os.path.join(CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - file_size = os.path.getsize(filepath) - cache_files.append((filepath, file_mtime, file_size)) - except Exception: - pass # Ignore individual file errors - - # Remove files older than 1 day - total_size = 0 - recent_files = [] - for filepath, file_mtime, file_size in cache_files: - if file_mtime < one_day_ago: - try: - os.unlink(filepath) - except Exception: - pass - else: - recent_files.append((filepath, file_mtime, file_size)) - total_size += file_size - - # If cache is still too large (>50MB), remove oldest files - MAX_CACHE_SIZE = 50 * 1024 * 1024 # 50MB - if total_size > MAX_CACHE_SIZE: - # Sort by modification time (oldest first) - recent_files.sort(key=lambda x: x[1]) - for filepath, _, file_size in recent_files: - if total_size <= MAX_CACHE_SIZE: - break - try: - os.unlink(filepath) - total_size -= file_size - except Exception: - pass - - except Exception: - pass # Ignore all errors in cleanup - - -class PlayerBoxStack(Box): - """A widget that displays the current player information.""" - - def __init__( - self, mpris_manager: MprisPlayerManager, control_center=None, **kwargs - ): - # Clean up old cache files on startup - cleanup_old_cache_files() - - # The player stack - self.player_stack = Stack( - # transition_type="slide-left-right", - # transition_duration=500, - name="player-stack", - ) - self.current_stack_pos = 0 - self.control_center = control_center - - # List to store player buttons - self.player_buttons: list[Button] = [] - - # Track signal connections for cleanup - self._signal_connections = [] - - # Create a "No media playing" placeholder - self.no_media_box = self._create_no_media_box() - - super().__init__(orientation="v", name="media", children=[self.player_stack]) - - # Show the no media box initially - self.player_stack.children = [self.no_media_box] - self.set_visible(True) - - self.mpris_manager = mpris_manager - - # Track connections for cleanup - store (object, handler_id) tuples - connections = bulk_connect( - self.mpris_manager, - { - "player-appeared": self.on_new_player, - "player-vanished": self.on_lost_player, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self.mpris_manager, handler_id)) - - for player in self.mpris_manager.players: # type: ignore - logger.info( - f"[PLAYER MANAGER] player found: {player.get_property('player-name')}", - ) - self.on_new_player(self.mpris_manager, player) - - def destroy(self): - """Clean up resources when the widget is destroyed.""" - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect signal: {e}") - self._signal_connections.clear() - - # Clean up player buttons - for button in self.player_buttons: - try: - button.destroy() - except Exception: - pass - self.player_buttons.clear() - - # Clean up player boxes - for child in self.player_stack.get_children(): - if hasattr(child, "destroy") and child != self.no_media_box: - try: - child.destroy() - except Exception: - pass - - super().destroy() - - def _periodic_cleanup(self): - """Enhanced cleanup for reuse - clean internal state and free memory""" - try: - # Destroy all player boxes properly to free their resources - current_children = list(self.player_stack.get_children()) - for child in current_children: - if hasattr(child, "destroy") and child != self.no_media_box: - try: - child.destroy() - except Exception as e: - logger.warning(f"Failed to destroy player child: {e}") - - # Reset to no media state - self.player_stack.children = [self.no_media_box] - - # Clear player buttons - for button in self.player_buttons: - try: - button.destroy() - except Exception: - pass - self.player_buttons.clear() - - # Reset stack position - self.current_stack_pos = 0 - - # Clean up old cache files more aggressively - cleanup_old_cache_files() - - # Force garbage collection - import gc - - gc.collect() - - logger.debug("PlayerBoxStack enhanced cleanup completed") - except Exception as e: - logger.warning(f"PlayerBoxStack enhanced cleanup failed: {e}") - - def _create_no_media_box(self): - """Create a placeholder box for when no media is playing.""" - fallback_cover_path = f"{data.HOME_DIR}/.current.wall" - - # Album cover with fallback image - album_cover = Box(style_classes="album-image-c") - album_cover.set_style(f"background-image:url('{fallback_cover_path}')") - - image_stack = Box(h_align="start", v_align="center", name="player-image-stack") - image_stack.children = [album_cover] - - # Track info showing "No media playing" - track_title = Label( - label="No media playing", - name="player-title-c", - justification="left", - max_chars_width=25, - ellipsization="end", - h_align="start", - ) - - track_artist = Label( - label="", - name="player-artist-c", - justification="left", - max_chars_width=15, - ellipsization="end", - h_align="start", - visible=False, # Hide artist and album when no media - ) - - track_info = Box( - name="track-info", - # spacing=5, - h_expand=True, - orientation="v", - v_align="start", - h_align="start", - children=[track_title, track_artist], - ) - - # No control buttons for no media state - just an empty box - controls_box = Box( - name="player-controls", - visible=False, # Hide controls when no media - ) - - player_info_box = Box( - name="player-info-box-c", - h_expand=True, - v_align="center", - h_align="center", - orientation="v", - children=[track_info, controls_box], - ) - - inner_box = CenterBox( - name="inner-player-box", - start_children=[ - image_stack, - ], - center_children=[ - player_info_box, - ], - ) - # resize the inner box - outer_box = Box( - spacing=5, - name="outer-no-player-box-c", - h_expand=True, - h_align="fill", - # children=[ - v_expand=True, - children=inner_box, - # inner_box, - # player_info_box, - # image, - # ], - ) - - box = Box( - name="box-c", - orientation="h", - v_expand=True, - h_align="fill", - h_expand=True, - children=[ - outer_box, - ], - ) - no_media_box = Box( - h_align="center", - name="player-box", - h_expand=True, - children=[box], - ) - - return no_media_box - - def _find_playing_player_index(self): - """Find the index of the currently playing player.""" - players: List[PlayerBox] = self.player_stack.get_children() - for i, player_box in enumerate(players): - if ( - hasattr(player_box, "player") - and player_box.player.playback_status == "playing" - ): - return i - return None - - def _switch_to_playing_player(self): - """Switch to the currently playing player if one exists.""" - playing_index = self._find_playing_player_index() - if playing_index is not None and playing_index != self.current_stack_pos: - logger.info( - f"[PlayerBoxStack] Auto-switching to playing player at index { - playing_index - }" - ) - self.on_player_clicked_by_index(playing_index) - - def on_player_playback_changed(self, player_box, status): - """Called when a player's playback status changes.""" - if status == "playing": - # Find this player's index and switch to it - players: List[PlayerBox] = self.player_stack.get_children() - for i, pb in enumerate(players): - if pb == player_box: - if i != self.current_stack_pos: - logger.info( - f"[PlayerBoxStack] Switching to playing player: { - player_box.player.player_name - }" - ) - self.on_player_clicked_by_index(i) - break - - def on_player_clicked(self, type): - # unset active from prev active button - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - - if type == "next": - self.current_stack_pos = ( - self.current_stack_pos + 1 - if self.current_stack_pos != len(self.player_stack.get_children()) - 1 - else 0 - ) - elif type == "prev": - self.current_stack_pos = ( - self.current_stack_pos - 1 - if self.current_stack_pos != 0 - else len(self.player_stack.get_children()) - 1 - ) - - # set new active button - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - print( - f"[PlayerBoxStack] Switching to player at index { - self.current_stack_pos - }" - ) - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - - def on_player_clicked_by_index(self, index): - """Switch to player at given index""" - if 0 <= index < len(self.player_buttons): - # unset active from prev active button - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - # set new position - self.current_stack_pos = index - # set new active button - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - # Update all player boxes with new button state - self._update_all_player_buttons() - - def on_new_player(self, mpris_manager, player): - player_name = player.props.player_name - - # if player_name in self.config.get("ignore", []): - # return - - # Remove the no media box if it's the only child - if ( - len(self.player_stack.get_children()) == 1 - and self.player_stack.get_children()[0] == self.no_media_box - ): - self.player_stack.children = [] - self.current_stack_pos = 0 - - self.set_visible(True) - - new_player_box = PlayerBox( - player=MprisPlayer(player), - player_stack=self, - control_center=self.control_center, - ) - self.player_stack.children = [ - *self.player_stack.children, - new_player_box, - ] - - self.make_new_player_button(self.player_stack.get_children()[-1]) - logger.info( - f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}", - ) - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - - # Update all player boxes with current button state - self._update_all_player_buttons() - - # Check if this new player is playing and switch to it - self._switch_to_playing_player() - - def on_lost_player(self, mpris_manager, player_name): - # the playerBox is automatically removed from mprisbox children on being removed - logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}") - players: List[PlayerBox] = self.player_stack.get_children() - - # Find and properly destroy the player box - player_box_to_remove = None - for player_box in players: - if ( - hasattr(player_box, "player") - and player_box.player.player_name == player_name - ): - player_box_to_remove = player_box - break - - if player_box_to_remove: - try: - player_box_to_remove.destroy() - except Exception as e: - logger.warning(f"Failed to destroy player box: {e}") - - # Check if this was the last player - remaining_players = [ - p for p in self.player_stack.get_children() if p != player_box_to_remove - ] - if len(remaining_players) == 0: - # Show the no media box instead of hiding - self.player_stack.children = [self.no_media_box] - self.current_stack_pos = 0 - self.player_buttons = [] # Clear player buttons - return - - # Adjust current position if needed - if self.current_stack_pos >= len(self.player_stack.get_children()): - self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) - - # Set active button if we have buttons and a valid position - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - if self.player_stack.get_children(): - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - - # Update all player boxes with current button state - self._update_all_player_buttons() - - # After a player is removed, check if we should switch to a playing player - self._switch_to_playing_player() - - def make_new_player_button(self, player_box): - new_button = Button(name="player-stack-button") - - def on_player_button_click(button: Button): - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - if button in self.player_buttons: - self.current_stack_pos = self.player_buttons.index(button) - button.add_style_class("active") - self.player_stack.set_visible_child(player_box) - - new_button.connect( - "clicked", - on_player_button_click, - ) - self.player_buttons.append(new_button) - - # This will automatically destroy our used button - def cleanup_button(*_): - try: - if new_button in self.player_buttons: - self.player_buttons.remove(new_button) - new_button.destroy() - except Exception as e: - logger.warning(f"Failed to cleanup button: {e}") - - player_box.connect("destroy", cleanup_button) - - def _update_all_player_buttons(self): - """Update all player boxes with the current button state""" - players: List[PlayerBox] = self.player_stack.get_children() - logger.info( - f"[PlayerBoxStack] Updating buttons for {len(players)} players, { - len(self.player_buttons) - } buttons" - ) - for player_box in players: - if hasattr(player_box, "update_buttons"): - player_box.update_buttons(self.player_buttons, len(players) > 1) - else: - logger.warning( - "[PlayerBoxStack] PlayerBox missing update_buttons method" - ) - - -class PlayerBox(Box): - """A widget that displays the current player information.""" - - def __init__( - self, player: MprisPlayer, player_stack=None, control_center=None, **kwargs - ): - super().__init__( - h_align="center", - name="player-box", - **kwargs, - h_expand=True, - ) - # Setup - self.player: MprisPlayer = player - self.player_stack = player_stack - self.control_center = control_center - self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall" - - # Add controls_box attribute early for compatibility - # Temporary placeholder - self.controls_box = Box(name="temp-controls-box") - - self.image_size = 50 - self.icon_size = 15 - - # State - self.exit = False - self.skipped = False - - # Memory management - self.temp_artwork_files = [] # Track temp files for cleanup - self.current_download_thread = None # Track current download thread - self._download_cancelled = False # Flag to cancel downloads - self._signal_connections = [] # Track signal connections - - self.album_cover = Box(style_classes="album-image-c") - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - - self.image_stack = Box( - h_align="start", - v_align="center", - name="player-image-stack", - ) - self.image_stack.children = [*self.image_stack.children, self.album_cover] - - self.app_icon = Box( - children=Image( - icon_name=self.player.player_name, name="player-app-icon", icon_size=20 - ), - h_align="end", - v_align="end", - tooltip_text=self.player.player_name, # type: ignore - ) - self.image = Overlay( - child=self.image_stack, - overlays=[ - self.app_icon, - ], - ) - # Track Info - - self.track_title = Label( - label="No Title", - name="player-title-c", - justification="left", - max_chars_width=25, - ellipsization="end", - h_align="start", - ) - - self.track_artist = Label( - label="No Artist", - name="player-artist-c", - justification="left", - max_chars_width=23, - ellipsization="end", - h_align="start", - visible=True, - ) - - self.player.bind_property( - "title", - self.track_title, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" - ), # type: ignore - ) - self.player.bind_property( - "artist", - self.track_artist, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist" - ), # type: ignore - ) - - self.track_info = Box( - name="track-info", - spacing=5, - orientation="v", - v_align="start", - h_align="start", - children=[ - self.track_title, - self.track_artist, - ], - ) - - # Buttons with fixed sizing for layout stability - self.button_box = Box( - name="button-box-c", - h_expand=False, - spacing=2, - ) - - # Create SVG icons with consistent sizing - self.skip_next_icon = Svg( - name="control-buttons", - size=(22, 22), - svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"), - ) - self.play_pause_icon = Svg( - name="control-buttons", - size=(22, 22), - svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"), - ) - - # Fixed size buttons to prevent layout shifts - self.play_pause_button = Button( - name="player-button", - child=self.play_pause_icon, - on_clicked=self.player.play_pause, - ) - # Set consistent button size - - self.player.bind_property("can_pause", self.play_pause_button, "sensitive") - - self.next_button = Button( - name="player-button", - child=self.skip_next_icon, - on_clicked=self._on_player_next, - ) - # Set consistent button size - # self.next_button.set_size_request(32, 32) - self.player.bind_property("can_go_next", self.next_button, "sensitive") - - self.button_box.children = ( - self.play_pause_button, - self.next_button, - ) - - # Assign button_box to controls_box for compatibility - self.controls_box = self.button_box - - self.player_info_box = Box( - name="player-info-box-c", - v_align="center", - h_expand=True, - h_align="start", - orientation="v", - children=[ - self.track_info, - ], - ) - - self.inner_box = Box( - name="inner-player-box", - h_expand=True, - v_align="center", - h_align="start", - children=[ - self.image, - self.player_info_box, - ], - ) - # resize the inner box - self.outer_box = Button( - spacing=5, - name="outer-player-box-c", - h_expand=True, - # style="background-color:#fff", - on_clicked=self._on_outer_box_clicked, - h_align="start", - # children=[ - child=self.inner_box, - # self.inner_box, - # self.player_info_box, - # self.image, - # ], - ) - - self.box = CenterBox( - name="box-c", - orientation="h", - h_align="center", - start_children=[ - self.outer_box, - # self.stack_buttons_box, - ], - end_children=[ - self.button_box, - ], - ) - - self.children = [ - *self.children, - self.box, - ] - - # Track signal connections for cleanup - store (object, handler_id) tuples - connections = bulk_connect( - self.player, - { - "exit": self._on_player_exit, - "notify::playback-status": self._on_playback_change, - "notify::metadata": self._on_metadata, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self.player, handler_id)) - - def destroy(self): - """Clean up all resources when the widget is destroyed.""" - # Cancel any ongoing downloads - self._download_cancelled = True - - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect signal: {e}") - self._signal_connections.clear() - - # Clean up temp files - self._cleanup_temp_files() - - super().destroy() - - def __del__(self): - """Ensure cleanup happens even if player exits unexpectedly.""" - try: - self._cleanup_temp_files() - except Exception: - pass # Ignore errors during cleanup in destructor - - def _on_prev_button_click(self, *_): - """Handle prev button click: open expanded player in control center""" - try: - # Open expanded player in control center instead of new window - if self.control_center and hasattr( - self.control_center, "open_expanded_player" - ): - self.control_center.open_expanded_player() - except Exception as e: - logger.warning(f"Failed to handle prev button click: {e}") - - def _on_outer_box_clicked(self, *_): - """Handle outer box click with proper error handling.""" - try: - # Open expanded player in control center instead of new window - if self.control_center and hasattr( - self.control_center, "open_expanded_player" - ): - self.control_center.open_expanded_player() - except Exception as e: - logger.warning(f"Failed to handle outer box click: {e}") - import traceback - - logger.error(f"Full traceback: {traceback.format_exc()}") - - def update_buttons(self, player_buttons, show_buttons): - # """Update the stack switcher buttons in this player box""" - pass - - def _on_metadata(self, *_): - self._set_image() - - def _cleanup_temp_files(self): - """Clean up temporary artwork files.""" - for temp_file in self.temp_artwork_files: - try: - if os.path.exists(temp_file): - os.unlink(temp_file) - except Exception as e: - logger.warning(f"Failed to cleanup temp file {temp_file}: {e}") - self.temp_artwork_files.clear() - - def _on_player_exit(self, _, value): - self.exit = value - self._cleanup_temp_files() # Clean up temp files before destroying - self.destroy() - - def _on_player_next(self, *_): - self.player.next() - - def _on_player_prev(self, *_): - self.player.previous() - - def _on_playback_change(self, player, status): - status = player.get_property("playback-status") - - if status == "paused": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/play.svg") - ) - - if status == "playing": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/Pause.svg") - ) - # Notify the player stack that this player started playing - if self.player_stack and hasattr( - self.player_stack, "on_player_playback_changed" - ): - self.player_stack.on_player_playback_changed(self, status) - - def _update_image(self, image_path): - if image_path and os.path.isfile(image_path): - self.album_cover.set_style(f"background-image:url('{image_path}')") - else: - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - - def _set_image(self, *_): - art_url = self.player.arturl - - parsed = urllib.parse.urlparse(art_url) - if parsed.scheme == "file": - local_arturl = urllib.parse.unquote(parsed.path) - self._update_image(local_arturl) - elif parsed.scheme in ("http", "https"): - # Cancel any existing download to prevent memory buildup - self._download_cancelled = True - - # Use threading.Thread instead of GLib.Thread for better control - if self.current_download_thread and self.current_download_thread.is_alive(): - # Thread will check _download_cancelled flag and exit early - pass - - self._download_cancelled = False - self.current_download_thread = threading.Thread( - target=self._download_and_set_artwork, - args=(art_url,), - daemon=True, # Dies with main thread - ) - self.current_download_thread.start() - else: - self._update_image(art_url) - - def _download_and_set_artwork(self, arturl): - """ - Download the artwork from the given URL asynchronously and update the cover - using GLib.idle_add to ensure UI updates occur on the main thread. - """ - local_arturl = self.fallback_cover_path - temp_file_path = None - - try: - # Check if download was cancelled - if self._download_cancelled: - return - - # Clean up old temp files first (keep only last 1 to reduce memory) - if len(self.temp_artwork_files) > 1: - old_files = self.temp_artwork_files[:-1] - for old_file in old_files: - try: - if os.path.exists(old_file): - os.unlink(old_file) - except Exception: - pass - self.temp_artwork_files = self.temp_artwork_files[-1:] - - # Check again if cancelled - if self._download_cancelled: - return - - # Download artwork - parsed = urllib.parse.urlparse(arturl) - suffix = os.path.splitext(parsed.path)[1] or ".png" - - with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout - if self._download_cancelled: - return - data = response.read() - - # Check one more time if cancelled - if self._download_cancelled: - return - - # Create temp file in cache directory instead of system temp - os.makedirs(CACHE_DIR, exist_ok=True) - with tempfile.NamedTemporaryFile( - delete=False, suffix=suffix, dir=CACHE_DIR - ) as temp_file: - temp_file.write(data) - temp_file_path = temp_file.name - local_arturl = temp_file_path - - # Track temp file for cleanup - if temp_file_path and not self._download_cancelled: - self.temp_artwork_files.append(temp_file_path) - - except Exception as e: - if not self._download_cancelled: - logger.warning(f"Failed to download artwork from {arturl}: {e}") - # Clean up failed temp file - if temp_file_path and os.path.exists(temp_file_path): - try: - os.unlink(temp_file_path) - except Exception: - pass - return - - # Only update UI if not cancelled - if not self._download_cancelled: - GLib.idle_add(self._update_image, local_arturl) - - def close_bluetooth(self, *args): - """Placeholder method for compatibility""" - pass diff --git a/modules/controlcenter/wifi.py b/modules/controlcenter/wifi.py deleted file mode 100644 index 6cb612d..0000000 --- a/modules/controlcenter/wifi.py +++ /dev/null @@ -1,715 +0,0 @@ -from widgets.wifi_password_dialog import WiFiPasswordDialog -from services.network import NetworkClient -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.revealer import Revealer -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.button import Button -from fabric.widgets.box import Box -from fabric.widgets.svg import Svg -from fabric.utils import get_relative_path -from gi.repository import Gdk, GLib, Gtk -from fabric.widgets.separator import Separator -from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon -import gi -import subprocess - -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") - - -class WifiNetworkSlot(Box): - def __init__(self, access_point, wifi_service, parent=None, **kwargs): - super().__init__(name="wifi-network-slot", **kwargs) - self.access_point = access_point - self.wifi_service = wifi_service - self.parent = parent # Reference to control center - - # Get network info from AccessPoint object - self.ssid = access_point.ssid - self.bssid = access_point.bssid - self.strength = access_point.strength - self.icon_name = access_point.icon - - # Check if this network is currently connected - self.is_connected = access_point.is_active - - # Initialize styles based on connection state - self.styles = [ - "connected" if self.is_connected else "", - ] - - # Create connection status indicator using dynamic WiFi icon based on signal strength - wifi_icon_path = get_wifi_icon_for_strength(self.strength) - self.dimage = Svg( - svg_file=wifi_icon_path, - size=28, - name="device-icon", - ) - self.wifi_icon_box = Box( - children=[self.dimage], - style_classes=["wifi-icon-box"], - ) - # if self.is_connected: - # self.wifi_icon_box.remove_style_class("wifi-icon-box") - # self.wifi_icon_box.add_style_class("wifi-icon-box-connected") - # - # Set initial style classes - if self.is_connected: - self.dimage.add_style_class("connected") - - self.network_label = Label( - label=self.ssid, name="wifi-network-name", h_align="start", h_expand=True - ) - - # Create lock icon for secured networks - self.lock_icon = None - if self.access_point.requires_password: - self.lock_icon = Image( - icon_name="changes-prevent-symbolic", - size=12, - name="wifi-lock-icon", - ) - - # Initialize password dialog - self.password_dialog = None - - # Create the start section with WiFi icon and network name - start_box = Box( - orientation="h", - spacing=8, - ) - start_box.children = [self.wifi_icon_box, self.network_label] - if self.lock_icon: - start_box.children.append(self.lock_icon) - # Create end section with lock icon if needed - - self.children = [ - Button( - child=start_box, - h_expand=True, - name="wifi-network-button", - on_clicked=lambda *_: self.toggle_connecting(), - ) - ] - - # Emit initial change to update display - self.on_changed() - - def toggle_connecting(self): - # Check if this network is currently connected - is_currently_connected = self.access_point.is_active - - if is_currently_connected: - # Show disconnecting state using connecting icon - connecting_icon = get_wifi_connecting_icon() - self.dimage.set_from_file(connecting_icon) - self.dimage.add_style_class("disconnecting") - - # Disconnect from network - self.wifi_service.disconnect_wifi() - self.is_connected = False - # Remove disconnecting state after a short delay to show feedback - GLib.timeout_add(500, lambda: self._reset_disconnect_state()) - else: - # Try to connect - check if password is required - if self.access_point.requires_password: - # Show password dialog immediately - self._show_password_dialog() - else: - # Try to connect without password (for open networks) - connecting_icon = get_wifi_connecting_icon() - self.dimage.set_from_file(connecting_icon) - self.dimage.add_style_class("connecting") - - def on_open_connection_result(success, message): - """Handle the connection result for open networks""" - if success: - self.is_connected = True - # Remove connecting state after a short delay - GLib.timeout_add(500, lambda: self._reset_connect_state()) - else: - # Connection failed - self._reset_connect_state() - - # Update display after connection attempt - self.on_changed() - - try: - self.wifi_service.connect_to_wifi( - self.access_point, callback=on_open_connection_result - ) - except Exception: - # Handle any connection errors gracefully - self._reset_connect_state() - self.on_changed() - - # Update display after connection attempt - self.on_changed() - - def _reset_disconnect_state(self): - """Reset visual state after disconnect operation""" - self.dimage.remove_style_class("disconnecting") - wifi_icon_path = get_wifi_icon_for_strength(self.strength) - self.dimage.set_from_file(wifi_icon_path) - self.on_changed() - return False # Remove timeout - - def _reset_connect_state(self): - """Reset visual state after connect operation""" - self.dimage.remove_style_class("connecting") - wifi_icon_path = get_wifi_icon_for_strength(self.strength) - self.dimage.set_from_file(wifi_icon_path) - self.on_changed() - return False # Remove timeout - - def on_changed(self, *_): - # Check if this network is currently connected using the access point's is_active property - self.is_connected = self.access_point.is_active - self.styles = [ - "connected" if self.is_connected else "", - ] - # Update style classes for SVG widget - if self.is_connected: - self.wifi_icon_box.add_style_class("wifi-icon-box-connected") - else: - self.wifi_icon_box.remove_style_class("wifi-icon-box-connected") - return - - def _show_password_dialog(self): - """Show the WiFi password dialog""" - # Close the control center first - if self.parent and hasattr(self.parent, "hide_controlcenter"): - self.parent.hide_controlcenter() - - # Create a new dialog each time to ensure clean state - if self.password_dialog: - self.password_dialog.destroy_dialog() - - self.password_dialog = WiFiPasswordDialog( - ssid=self.ssid, - on_connect_callback=self._on_password_connect, - on_cancel_callback=self._on_password_cancel, - ) - - self.password_dialog.show_dialog() - - def _on_password_connect(self, ssid, password): - """Handle password dialog connect action""" - if password.strip(): - # Show connecting state using connecting icon - connecting_icon = get_wifi_connecting_icon() - self.dimage.set_from_file(connecting_icon) - self.dimage.add_style_class("connecting") - - # Try to connect with password using callback - def on_connection_result(success, message): - """Handle the connection result""" - if success: - self.is_connected = True - # Remove connecting state after a short delay - from gi.repository import GLib - - GLib.timeout_add(500, lambda: self._reset_connect_state()) - - # Clear any timeout in the password dialog - if ( - self.password_dialog - and self.password_dialog.connection_timeout_id - ): - GLib.source_remove(self.password_dialog.connection_timeout_id) - self.password_dialog.connection_timeout_id = None - self.password_dialog.is_connecting = False - else: - # Connection failed - show error in dialog - self._reset_connect_state() - if self.password_dialog: - self._show_connection_error(message) - - # Update display after connection attempt - self.on_changed() - - try: - self.wifi_service.connect_to_wifi( - self.access_point, password, callback=on_connection_result - ) - except Exception: - # Handle any connection errors gracefully - self._reset_connect_state() - if self.password_dialog: - self._show_connection_error("Connection failed. Please try again.") - self.on_changed() - - def _show_connection_error(self, message="Incorrect password. Please try again."): - """Show connection error in a separate thread to prevent UI blocking""" - if self.password_dialog: - self.password_dialog.show_error(message) - return False # Don't repeat if called from GLib.timeout_add - - def _on_password_cancel(self): - """Handle password dialog cancel action""" - # Reset any connecting state - self._reset_connect_state() - - -class WifiConnections(Box): - def __init__(self, parent, show_back_button=True, **kwargs): - super().__init__( - spacing=8, - orientation="vertical", - name="wifi-connections", - **kwargs, - ) - - self.parent = parent - self.network_service = NetworkClient() - self.wifi_service = None - self.is_scanning = False # Track scanning state - self.refresh_timer = None # Timer for periodic network refresh - self._update_in_progress = False # Prevent concurrent updates - self._destroyed = False # Track if widget is destroyed - - # Wait for network service to be ready - self.network_service.connect("wifi-device-added", self.on_network_ready) - - # Create pull-to-refresh indicator - self.refresh_indicator = Label( - name="wifi-refresh-indicator", - label="โ†“ Pull to scan for networks", - h_align="center", - visible=False, - style="color: #fff; font-size: 12px; padding: 5px;", - ) - - # Create title with optional back button - title_children = [] - if show_back_button: - title_children.append( - Button( - image=Image(icon_name="back", size=10), - on_clicked=lambda *_: self.parent.close_wifi(), - ) - ) - title_children.append(Label("Wi-Fi", name="wifi-title")) - - self.title = Box( - orientation="h", - children=title_children, - ) - - self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") - - # Create Known Network section - self.known_networks_label = Label( - label="Known Network", h_align="start", name="networks-title" - ) - self.known_networks = Box( - spacing=4, orientation="vertical", name="known-networks" - ) - - # Create "No networks available" message - self.no_networks_label = Label( - label="No networks available", - h_align="center", - name="no-networks-label", - visible=False, - ) - - # Create Other Networks section with clickable title - self.other_networks_button = Button( - child=Label("Other Networks", h_align="start"), - name="wifi-other-button", - on_clicked=self.toggle_other_networks, - ) - self.other_networks = Box(spacing=4, orientation="vertical") - - # Create scrolled window for other networks - self.other_networks_scrolled = ScrolledWindow( - min_content_size=(303, 150), - child=self.other_networks, - overlay_scroll=True, - ) - - # Add pull-to-refresh functionality to scrolled window - self.setup_pull_to_refresh() - - # Create revealer for Other Networks section - self.other_networks_revealer = Revealer( - child=self.other_networks_scrolled, - transition_type="slide-down", - transition_duration=100, - child_revealed=False, - ) - - # Create More Settings button (same style as Other Networks button) - self.more_settings_button = Button( - child=Label("More Settings", h_align="start"), - name="wifi-other-button", - on_clicked=self.open_network_settings, - ) - - self.children = [ - CenterBox( - start_children=self.title, - end_children=self.toggle_button, - name="wifi-widget-top", - ), - self.refresh_indicator, - Separator(orientation="h", name="separator"), - self.known_networks_label, - self.known_networks, - self.no_networks_label, - Separator(orientation="h", name="separator"), - self.other_networks_button, - self.other_networks_revealer, - Separator(orientation="h", name="separator"), - self.more_settings_button, - ] - - # Connect cleanup on destroy - self.connect("destroy", self.on_destroy) - - # Start periodic network monitoring for real-time updates - self.start_network_monitoring() - - def toggle_other_networks(self, *_): - """Toggle the visibility of other networks section""" - current_state = self.other_networks_revealer.child_revealed - self.other_networks_revealer.child_revealed = not current_state - - # Update button text based on state - if self.other_networks_revealer.child_revealed: - # Trigger a scan when revealing other networks and force refresh - if self.wifi_service: - self.wifi_service.scan() - # Also force an immediate network refresh to catch any missed connections - self.force_network_refresh() - - def on_network_ready(self, *_): - """Called when network service is ready""" - self.wifi_service = self.network_service.wifi_device - if self.wifi_service: - # Set up WiFi toggle - self.toggle_button.set_active(self.wifi_service.wireless_enabled) - self.toggle_button.connect("notify::active", self.on_toggle_changed) - - # Connect to WiFi service signals - self.wifi_service.connect( - "notify::wireless-enabled", self.on_wifi_enabled_changed - ) - self.wifi_service.connect("changed", self.update_networks) - self.wifi_service.connect("ap-added", self.update_networks) - self.wifi_service.connect("ap-removed", self.update_networks) - - # Initial network update - self.update_networks() - - def on_toggle_changed(self, toggle_button, *_): - """Handle WiFi toggle button changes""" - if self.wifi_service: - new_state = toggle_button.get_active() - self.wifi_service.wireless_enabled = new_state - - def on_wifi_enabled_changed(self, *_): - """Handle WiFi enabled state changes""" - if self.wifi_service: - self.toggle_button.set_active(self.wifi_service.wireless_enabled) - - def open_network_settings(self, *_): - """Open NetworkManager connection editor""" - try: - subprocess.Popen(["nm-connection-editor"], start_new_session=True) - if self.parent and hasattr(self.parent, "hide_controlcenter"): - self.parent.hide_controlcenter() - except FileNotFoundError: - pass - except Exception: - pass - - def update_networks(self, *_): - """Update the list of available networks""" - # Prevent concurrent updates and check if destroyed - if self._update_in_progress or self._destroyed or not self.wifi_service: - return - - self._update_in_progress = True - - try: - # Store current network SSIDs to detect changes - current_known_ssids = { - child.ssid - for child in self.known_networks.get_children() - if hasattr(child, "ssid") - } - current_other_ssids = { - child.ssid - for child in self.other_networks.get_children() - if hasattr(child, "ssid") - } - - # Get current networks - access_points = self.wifi_service.access_points - known_networks = [] - other_networks = [] - new_known_ssids = set() - new_other_ssids = set() - - for access_point in access_points: - try: - if access_point.ssid and access_point.ssid != "Unknown": - # Categorize networks: connected or saved networks go to "Known Network" - # All others go to "Other Networks" - if access_point.is_active or self._is_saved_network( - access_point - ): - known_networks.append(access_point) - new_known_ssids.add(access_point.ssid) - else: - other_networks.append(access_point) - new_other_ssids.add(access_point.ssid) - except Exception: - continue - - # Check if we need to update (networks added/removed) - known_changed = current_known_ssids != new_known_ssids - other_changed = current_other_ssids != new_other_ssids - - # Only rebuild if something actually changed - if known_changed or other_changed: - # Clear existing networks safely - for child in list(self.known_networks.get_children()): - if not self._destroyed: - child.destroy() - for child in list(self.other_networks.get_children()): - if not self._destroyed: - child.destroy() - - # Add known networks - for access_point in known_networks: - if not self._destroyed: - network_slot = WifiNetworkSlot( - access_point, self.wifi_service, parent=self.parent - ) - self.known_networks.add(network_slot) - - # Add other networks - for access_point in other_networks: - if not self._destroyed: - network_slot = WifiNetworkSlot( - access_point, self.wifi_service, parent=self.parent - ) - self.other_networks.add(network_slot) - - # Show/hide sections based on available networks - if not self._destroyed: - has_known_networks = len(known_networks) > 0 - has_other_networks = len(other_networks) > 0 - has_any_networks = has_known_networks or has_other_networks - - # Show known networks section only if there are known networks - self.known_networks_label.set_visible(has_known_networks) - self.known_networks.set_visible(has_known_networks) - - # Show "No networks available" message if no networks at all - self.no_networks_label.set_visible(not has_any_networks) - - # Always show the other networks button, regardless of available networks - self.other_networks_button.set_visible(True) # Always visible - - # Update all network connection states - self.refresh_network_states() - - except Exception: - pass - finally: - self._update_in_progress = False - - def _is_saved_network(self, access_point): - """Check if a network is saved/known using NetworkManager connections""" - if not self.network_service or not self.network_service._client: - return False - - try: - ssid = access_point.ssid - if not ssid or ssid == "Unknown": - return False - - # Get all saved connections from NetworkManager - connections = self.network_service._client.get_connections() - - for connection in connections: - # Check if this is a WiFi connection - if connection.get_connection_type() != "802-11-wireless": - continue - - # Get the wireless setting - wifi_setting = connection.get_setting_wireless() - if not wifi_setting: - continue - - # Compare SSIDs - connection_ssid_bytes = wifi_setting.get_ssid() - if connection_ssid_bytes: - from gi.repository import NM - - connection_ssid = NM.utils_ssid_to_utf8( - connection_ssid_bytes.get_data() - ) - if connection_ssid == ssid: - return True - - except Exception: - pass - - return False - - def refresh_network_states(self, *_): - """Refresh connection states for all network slots""" - # Refresh known networks - for child in self.known_networks.get_children(): - if hasattr(child, "on_changed"): - child.on_changed() - - # Refresh other networks - for child in self.other_networks.get_children(): - if hasattr(child, "on_changed"): - child.on_changed() - - def start_network_monitoring(self): - """Start periodic monitoring for network changes""" - # Monitor for network changes every 5 seconds - # This helps catch networks that connect from external sources - self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh) - - def stop_network_monitoring(self): - """Stop periodic monitoring""" - if self.refresh_timer: - GLib.source_remove(self.refresh_timer) - self.refresh_timer = None - - def periodic_network_refresh(self): - """Periodically refresh network list to catch external connections""" - # Skip if update in progress, destroyed, or wifi service not available - if ( - self._update_in_progress - or self._destroyed - or not self.wifi_service - or not self.wifi_service.wireless_enabled - ): - return True # Continue monitoring - - try: - # Simple check - just trigger update_networks which has its own safety checks - self.update_networks() - except Exception: - pass - - return True # Continue monitoring - - def force_network_refresh(self): - """Force an immediate refresh of the network list""" - if self._update_in_progress or self._destroyed: - return - - try: - # Simply trigger update_networks which has its own safety checks - self.update_networks() - except Exception: - pass - - def setup_pull_to_refresh(self): - """Setup pull-to-refresh gesture for the scrolled window""" - # Get the scrolled window's vertical adjustment - self.vadjustment = self.other_networks_scrolled.get_vadjustment() - - # Track gesture state - self.pull_start_y = 0 - self.is_pulling = False - self.pull_threshold = 50 # pixels to trigger refresh - - # Connect to scroll events - self.other_networks_scrolled.connect("scroll-event", self.on_scroll_event) - self.other_networks_scrolled.connect("button-press-event", self.on_button_press) - self.other_networks_scrolled.connect( - "button-release-event", self.on_button_release - ) - self.other_networks_scrolled.connect( - "motion-notify-event", self.on_motion_notify - ) - - # Enable events - self.other_networks_scrolled.set_events( - Gdk.EventMask.SCROLL_MASK - | Gdk.EventMask.BUTTON_PRESS_MASK - | Gdk.EventMask.BUTTON_RELEASE_MASK - | Gdk.EventMask.POINTER_MOTION_MASK - ) - - def on_scroll_event(self, widget, event): - """Handle scroll events for pull-to-refresh""" - # Only handle pull-to-refresh when at the top - if self.vadjustment.get_value() <= 0: - if event.direction == Gdk.ScrollDirection.UP: - # Scrolling up at the top - trigger scan and force refresh - if self.wifi_service: - self.wifi_service.scan() - self.force_network_refresh() - return True # Consume the event - return False # Let normal scrolling continue - - def on_button_press(self, widget, event): - """Handle button press for touch/drag gestures""" - if self.vadjustment.get_value() <= 0: - self.pull_start_y = event.y - self.is_pulling = True - return False - - def on_button_release(self, widget, event): - """Handle button release for touch/drag gestures""" - if self.is_pulling: - pull_distance = event.y - self.pull_start_y - if pull_distance > self.pull_threshold: - # Trigger scan and force refresh - if self.wifi_service: - self.wifi_service.scan() - self.force_network_refresh() - # Hide refresh indicator - self.refresh_indicator.set_visible(False) - self.refresh_indicator.remove_style_class("ready-to-refresh") - self.is_pulling = False - return False - - def on_motion_notify(self, widget, event): - """Handle motion events for visual feedback during pull""" - if self.is_pulling and self.vadjustment.get_value() <= 0: - pull_distance = event.y - self.pull_start_y - if pull_distance > 0: - # Show refresh indicator when pulling down - self.refresh_indicator.set_visible(True) - if pull_distance >= self.pull_threshold: - self.refresh_indicator.set_label("โ†‘ Release to scan") - self.refresh_indicator.add_style_class("ready-to-refresh") - else: - self.refresh_indicator.set_label("โ†“ Pull to scan for networks") - self.refresh_indicator.remove_style_class("ready-to-refresh") - else: - self.refresh_indicator.set_visible(False) - return False - - def on_destroy(self, widget): - """Cleanup when widget is destroyed""" - # Mark as destroyed to prevent further updates - self._destroyed = True - # Stop monitoring - self.stop_network_monitoring() - # Make sure other networks revealer is collapsed when closing - try: - self.other_networks_revealer.child_revealed = False - except: - pass # Widget might already be destroyed - - def close_wifi(self): - """Called when WiFi panel is being closed""" - # Collapse the other networks section when closing - self.other_networks_revealer.child_revealed = False diff --git a/modules/corners.py b/modules/corners.py deleted file mode 100644 index cf3c79e..0000000 --- a/modules/corners.py +++ /dev/null @@ -1,68 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.shapes import Corner -from widgets.wayland import WaylandWindow as Window - - -class MyCorner(Box): - def __init__(self, corner): - super().__init__( - name="corner-container", - children=Corner( - name="corner", - orientation=corner, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - size=20, - ), - ) - - -class Corners(Window): - def __init__(self): - super().__init__( - name="corners", - layer="bottom", - anchor="top bottom left right", - exclusivity="normal", - # pass_through=True, - visible=False, - all_visible=False, - ) - - self.all_corners = Box( - name="all-corners", - orientation="v", - h_expand=True, - v_expand=True, - h_align="fill", - v_align="fill", - children=[ - Box( - name="top-corners", - orientation="h", - h_align="fill", - children=[ - MyCorner("top-left"), - Box(h_expand=True), - MyCorner("top-right"), - ], - ), - Box(v_expand=True), - Box( - name="bottom-corners", - orientation="h", - h_align="fill", - children=[ - MyCorner("bottom-left"), - Box(h_expand=True), - MyCorner("bottom-right"), - ], - ), - ], - ) - - self.add(self.all_corners) - - self.show_all() diff --git a/modules/dock.py b/modules/dock.py deleted file mode 100644 index 0c836f4..0000000 --- a/modules/dock.py +++ /dev/null @@ -1,843 +0,0 @@ -import json -import os -import re -import subprocess - -from fabric.utils.helpers import get_desktop_applications, get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.eventbox import EventBox -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from fabric.widgets.overlay import Overlay -from fabric.widgets.revealer import Revealer -from gi.repository import GLib, Gtk -from loguru import logger - -import config.data as data -from services.modus import modus_service -from utils.functions import read_json_file, write_json_file, is_special_workspace_id -from utils.icon_resolver import IconResolver -from utils.occlusion import check_occlusion -from widgets.wayland import WaylandWindow as Window - -# Pinned apps file -PINNED_APPS_FILE = get_relative_path("../config/assets/dock.json") - - -class AppBar(Box): - def __init__(self, parent: Window): - self.client_buttons = {} # For running app instances - self.pinned_buttons = {} # For pinned apps - # Position tracking for hover effects - self.running_items_pos = [] - self.pinned_items_pos = [] - self._parent = parent - - # Set orientation based on dock position - orientation = ( - "vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal" - ) - - super().__init__( - spacing=0, - name="dock", - orientation=orientation, - children=[], - ) - self.icon_resolver = IconResolver() - self._hyprland_connection = modus_service._hyprland_connection - - # Initialize GTK menu - self.menu = Gtk.Menu() - - self.pinned_apps = read_json_file(PINNED_APPS_FILE) or [] - self.pinned_apps_container = Box() - self.add(self.pinned_apps_container) - - self.separator = Box( - v_align="center", style_classes=["hidden", "dock_separator"] - ) - self.add(self.separator) - - self.running_apps_container = Box(name="dock_container") - self.add(self.running_apps_container) - - self._populate_pinned_apps() - self.setup_app_monitoring() - - def setup_app_monitoring(self): - def update_running_apps(): - try: - self.update_dock_apps() - except Exception as e: - logger.error(f"[AppBar] Error updating apps: {e}") - return True - - GLib.timeout_add(250, update_running_apps) - GLib.idle_add(self.update_dock_apps) - - def _populate_pinned_apps(self): - for child in self.pinned_apps_container.get_children(): - self.pinned_apps_container.remove(child) - - self.pinned_buttons = {} - self.pinned_items_pos = [] - - try: - desktop_apps = get_desktop_applications(include_hidden=False) - except Exception: - desktop_apps = [] - - for app_data in self.pinned_apps: - self._create_pinned_button(app_data, desktop_apps) - - # Add trash icon at the end of pinned apps - self._create_trash_button() - - def _create_pinned_button(self, app_data, desktop_apps): - if isinstance(app_data, dict): - app_identifier = app_data.get("name", "") or app_data.get( - "window_class", "" - ) - display_name = app_data.get("display_name", app_identifier) - app = self._find_desktop_app_from_data(app_data, desktop_apps) - - if app: - icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE) - else: - icon_name = app_data.get("window_class", "") or app_data.get("name", "") - icon_pixbuf = self.icon_resolver.get_icon_pixbuf( - icon_name, data.DOCK_ICON_SIZE - ) - else: - app_identifier = app_data - app = self._find_desktop_app_by_id(app_data, desktop_apps) - if not app: - return - - display_name = app.display_name or app.name - icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE) - - pinned_image = Image(name="dock_item_icon") - pinned_image.set_from_pixbuf(icon_pixbuf) - - main_container = Box( - name="dock_item_main_container", - orientation="v", - children=[pinned_image], - ) - - pinned_button = Button( - name="dock_item", - child=main_container, - tooltip_text=display_name, - on_button_press_event=lambda _, event: self._handle_pinned_app_click( - event, app_data - ), - on_enter_notify_event=lambda *_: self._handle_item_hovered( - pinned_button, True - ), - on_leave_notify_event=lambda *_: self._handle_item_unhovered( - pinned_button, True - ), - ) - - pinned_button.add_style_class("shown") - - self.pinned_buttons[app_identifier] = pinned_button - self.pinned_apps_container.add(pinned_button) - self.pinned_items_pos.append(pinned_button) - - def _create_trash_button(self): - """Create a trash button that opens the trash in file manager""" - # Get trash icon - trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf( - "user-trash", data.DOCK_ICON_SIZE - ) - - trash_image = Image(name="dock_item_icon") - trash_image.set_from_pixbuf(trash_icon_pixbuf) - - main_container = Box( - name="dock_item_main_container", - orientation="v", - children=[trash_image], - ) - - trash_button = Button( - name="dock_item", - child=main_container, - tooltip_text="Trash", - on_button_press_event=lambda _, event: self._handle_trash_click(event), - on_enter_notify_event=lambda *_: self._handle_item_hovered( - trash_button, True - ), - on_leave_notify_event=lambda *_: self._handle_item_unhovered( - trash_button, True - ), - ) - - trash_button.add_style_class("shown") - trash_button.is_trash = True - - self.pinned_buttons["trash"] = trash_button - self.pinned_apps_container.add(trash_button) - self.pinned_items_pos.append(trash_button) - - def _find_desktop_app_from_data(self, app_data: dict, desktop_apps): - for app in desktop_apps: - if ( - ( - app_data.get("name") - and app.name - and app.name.lower() == app_data["name"].lower() - ) - or ( - app_data.get("window_class") - and hasattr(app, "window_class") - and app.window_class - and app.window_class.lower() == app_data["window_class"].lower() - ) - or ( - app_data.get("executable") - and app.executable - and ( - app.executable.lower() == app_data["executable"].lower() - or os.path.basename(app.executable).lower() - == os.path.basename(app_data["executable"]).lower() - ) - ) - ): - return app - return None - - def _find_desktop_app_by_id(self, app_id: str, desktop_apps): - for app in desktop_apps: - if ( - (app.name and app.name.lower() == app_id.lower()) - or (app.display_name and app.display_name.lower() == app_id.lower()) - or ( - hasattr(app, "window_class") - and app.window_class - and app.window_class.lower() == app_id.lower() - ) - or ( - app.executable - and ( - app.executable.lower() == app_id.lower() - or os.path.basename(app.executable).lower() == app_id.lower() - ) - ) - ): - return app - return None - - def show_menu(self, app_id: str, client=None, instance_address=None): - for item in self.menu.get_children(): - self.menu.remove(item) - item.destroy() - - if client or instance_address: - close_item = Gtk.MenuItem(label="Close") - if instance_address: - close_item.connect( - "activate", lambda *_: self._close_running_app(instance_address) - ) - self.menu.add(close_item) - - if app_id: - separator = Gtk.SeparatorMenuItem() - self.menu.add(separator) - - if app_id: - is_pinned = self._is_app_pinned(app_id) - pin_item = Gtk.MenuItem(label="Unpin" if is_pinned else "Pin") - - if is_pinned: - pin_item.connect("activate", lambda *_: self._unpin_app(app_id)) - else: - pin_item.connect("activate", lambda *_: self._pin_app(app_id)) - - self.menu.add(pin_item) - - self.menu.show_all() - - def _close_running_app(self, instance_address): - try: - self._hyprland_connection.send_command( - f"dispatch closewindow address:{instance_address}" - ) - except Exception as e: - logger.error(f"[AppBar] Error closing window: {e}") - - def _handle_pinned_app_click(self, event, app_data): - if event.button == 1: # Left click - launch app - self._launch_app_data(app_data) - elif event.button == 2: # Middle click - unpin app - app_identifier = self._get_app_identifier(app_data) - self._unpin_app(app_identifier) - elif event.button == 3: # Right click - show context menu - app_identifier = self._get_app_identifier(app_data) - self.show_menu(app_identifier) - self.menu.popup_at_pointer(event) - - def _handle_trash_click(self, event): - """Handle trash button click to open trash in file manager""" - if event.button == 1: # Left click - try: - trash_path = os.path.expanduser("~/.local/share/Trash/files") - file_managers = [ - "nautilus", - "dolphin", - "thunar", - "nemo", - "caja", - "pcmanfm", - ] - - for fm in file_managers: - try: - result = subprocess.run( - ["which", fm], capture_output=True, text=True - ) - if result.returncode == 0: - subprocess.Popen([fm, trash_path]) - return - except Exception: - continue - except Exception as e: - logger.error(f"[AppBar] Error opening trash: {e}") - - def _handle_item_hovered(self, item, pinned=False): - if pinned: - try: - index = self.pinned_items_pos.index(item) - if index > 0: - self.pinned_items_pos[index - 1].add_style_class("semi_hovered") - if index < len(self.pinned_items_pos) - 1: - self.pinned_items_pos[index + 1].add_style_class("semi_hovered") - except ValueError: - pass - else: - try: - index = self.running_items_pos.index(item) - if index > 0: - self.running_items_pos[index - 1].add_style_class("semi_hovered") - if index < len(self.running_items_pos) - 1: - self.running_items_pos[index + 1].add_style_class("semi_hovered") - except ValueError: - pass - - def _handle_item_unhovered(self, item, pinned=False): - if pinned: - try: - index = self.pinned_items_pos.index(item) - if index > 0: - self.pinned_items_pos[index - 1].remove_style_class("semi_hovered") - if index < len(self.pinned_items_pos) - 1: - self.pinned_items_pos[index + 1].remove_style_class("semi_hovered") - except ValueError: - pass - else: - try: - index = self.running_items_pos.index(item) - if index > 0: - self.running_items_pos[index - 1].remove_style_class("semi_hovered") - if index < len(self.running_items_pos) - 1: - self.running_items_pos[index + 1].remove_style_class("semi_hovered") - except ValueError: - pass - - def _get_app_identifier(self, app_data): - if isinstance(app_data, dict): - return app_data.get("name", "") or app_data.get("window_class", "") - return app_data - - def _launch_app_data(self, app_data): - try: - desktop_apps = get_desktop_applications(include_hidden=False) - - if isinstance(app_data, dict): - app = self._find_desktop_app_from_data(app_data, desktop_apps) - if app: - self._launch_app(app) - else: - self._launch_app_from_data(app_data) - else: - app = self._find_desktop_app_by_id(app_data, desktop_apps) - if app: - self._launch_app(app) - except Exception as e: - logger.error(f"[AppBar] Failed to launch app: {e}") - - def _launch_app(self, app): - try: - cleaned_command = re.sub(r"%\w+", "", app.command_line).strip() - final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" - subprocess.Popen(final_command, shell=True) - except Exception: - try: - app.launch() - except Exception as fallback_error: - logger.error(f"[AppBar] Failed to launch app: {fallback_error}") - - def _launch_app_from_data(self, app_data): - try: - command_line = app_data.get("command_line", "") - if command_line: - cleaned_command = re.sub(r"%\w+", "", command_line).strip() - final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" - subprocess.Popen(final_command, shell=True) - elif app_data.get("executable"): - final_command = ( - f"hyprctl dispatch exec 'uwsm app -- {app_data['executable']}'" - ) - subprocess.Popen(final_command, shell=True) - else: - logger.error( - f"[AppBar] No command or executable found for app: {app_data}" - ) - except Exception as e: - logger.error(f"[AppBar] Failed to launch app from data: {e}") - - def _pin_app(self, app_class: str): - if self._is_app_pinned(app_class): - return False - - try: - desktop_apps = get_desktop_applications(include_hidden=False) - app = self._find_desktop_app_by_id(app_class, desktop_apps) - - if app: - app_data = { - "name": app.name, - "display_name": app.display_name or app.name, - "window_class": getattr(app, "window_class", None) or app_class, - "executable": app.executable, - "command_line": app.command_line, - } - else: - app_data = { - "name": app_class, - "display_name": app_class, - "window_class": app_class, - "executable": app_class, - "command_line": app_class, - } - - self.pinned_apps.append(app_data) - except Exception: - self.pinned_apps.append(app_class) - - write_json_file(self.pinned_apps, PINNED_APPS_FILE) - self._populate_pinned_apps() - return True - - def _unpin_app(self, app_identifier: str): - apps_to_remove = [] - - for i, pinned_app in enumerate(self.pinned_apps): - if self._matches_app_identifier(pinned_app, app_identifier): - apps_to_remove.append(i) - - for i in reversed(apps_to_remove): - self.pinned_apps.pop(i) - - if apps_to_remove: - write_json_file(self.pinned_apps, PINNED_APPS_FILE) - self._populate_pinned_apps() - return True - return False - - def _matches_app_identifier(self, pinned_app, app_identifier): - if not app_identifier: - return False - - if isinstance(pinned_app, dict): - window_class = pinned_app.get("window_class") or "" - name = pinned_app.get("name") or "" - return ( - window_class.lower() == app_identifier.lower() - or name.lower() == app_identifier.lower() - ) - return ( - isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower() - ) - - def get_clients(self): - try: - clients_data = self._hyprland_connection.send_command("j/clients").reply - if not clients_data: - return [] - return json.loads(clients_data.decode("utf-8")) - except Exception as e: - logger.error(f"[AppBar] Error getting clients: {e}") - return [] - - def get_focused_window(self): - try: - active_data = self._hyprland_connection.send_command("j/activewindow").reply - if not active_data: - return None - return json.loads(active_data.decode("utf-8")) - except Exception as e: - logger.error(f"[AppBar] Error getting focused window: {e}") - return None - - def update_dock_apps(self): - try: - clients = self.get_clients() - focused_window = self.get_focused_window() - focused_address = focused_window.get("address", "") if focused_window else "" - - current_instance_ids = set() - - for client in clients: - if client.get("hidden", False) or not self._should_show_app_instance(client): - continue - - instance_address = client.get("address", "") - app_class = client.get("class", "") or client.get("title", "") - if not instance_address or not app_class: - continue - - current_instance_ids.add(instance_address) - - if instance_address not in self.client_buttons: - self.create_instance_button(instance_address, client, app_class) - else: - self.update_instance_button(instance_address, client, app_class) - - button = self.client_buttons[instance_address] - if instance_address == focused_address: - button.add_style_class("activated") - else: - button.remove_style_class("activated") - - self._update_pinned_apps_state(clients) - self._update_separator_visibility() - - self._cleanup_removed_instances(current_instance_ids) - - except Exception as e: - logger.error(f"[AppBar] Error in update_dock_apps: {e}") - - def _update_pinned_apps_state(self, clients): - running_app_classes = { - client.get("class", "").lower() or client.get("title", "").lower() - for client in clients - if not client.get("hidden", False) - and (client.get("class") or client.get("title")) - and self._should_show_app_instance(client) - } - - for app_identifier, button in self.pinned_buttons.items(): - # Skip trash button as it's not a regular app - if app_identifier == "trash" or hasattr(button, "is_trash"): - continue - - if app_identifier.lower() in running_app_classes: - button.add_style_class("instance") - else: - button.remove_style_class("instance") - - def _cleanup_removed_instances(self, current_instance_ids): - buttons_to_remove = [ - instance_id - for instance_id in self.client_buttons.keys() - if instance_id not in current_instance_ids - ] - - # Clean up removed and orphaned buttons - for instance_id in buttons_to_remove + [k for k, v in self.client_buttons.items() - if not hasattr(v, 'instance_address') or not v.get_parent()]: - if instance_id in self.client_buttons: - button = self.client_buttons.pop(instance_id) - try: - if button in self.running_items_pos: - self.running_items_pos.remove(button) - button.remove_style_class("shown") - button.remove_style_class("activated") - if button.get_parent(): - button.get_parent().remove(button) - button.destroy() - except Exception as e: - logger.warning(f"[AppBar] Error during cleanup: {e}") - - def create_instance_button(self, instance_address, client, app_class): - try: - client_image = Image(name="dock_item_icon") - - try: - desktop_apps = get_desktop_applications(include_hidden=False) - desktop_app = self._find_desktop_app_by_id(app_class, desktop_apps) - - if desktop_app: - pixbuf = desktop_app.get_icon_pixbuf(data.DOCK_ICON_SIZE) - else: - pixbuf = self.icon_resolver.get_icon_pixbuf( - app_class, data.DOCK_ICON_SIZE - ) - - client_image.set_from_pixbuf(pixbuf) - except Exception as e: - logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}") - - workspace_id = self._get_workspace_id(client) - workspace_label = None - if workspace_id is not None: - workspace_label = Label( - label=str(workspace_id), - name="workspace-indicator", - h_align="end", - v_align="end", - ) - - image_overlay = Overlay(name="dock-image-overlay", child=client_image) - if workspace_label: - image_overlay.add_overlay(workspace_label) - - indicator = Box(name="dock_item_indicator", h_align="center") - main_container = Box( - name="dock_item_main_container", - orientation="v", - children=[image_overlay, indicator], - ) - - tooltip_text = client.get("title", app_class) - if tooltip_text != app_class: - tooltip_text = f"{app_class}: {tooltip_text}" - - client_button = Button( - name="dock_item", - child=main_container, - tooltip_text=tooltip_text, - on_button_press_event=lambda widget, event: self.handle_instance_click( - widget, event - ), - on_enter_notify_event=lambda *_: self._handle_item_hovered( - client_button, False - ), - on_leave_notify_event=lambda *_: self._handle_item_unhovered( - client_button, False - ), - ) - - client_button.instance_address = instance_address - client_button.client_data = client - client_button.app_class = app_class - client_button.workspace_label = workspace_label - client_button.add_style_class("shown") - - self.client_buttons[instance_address] = client_button - self.running_apps_container.add(client_button) - self.running_items_pos.append(client_button) - - except Exception as e: - logger.error(f"[AppBar] Error creating instance button for {app_class}: {e}") - - def _get_workspace_id(self, client): - workspace_data = client.get("workspace", {}) - if isinstance(workspace_data, dict): - return workspace_data.get("id") - elif isinstance(workspace_data, (int, str)): - return workspace_data - return None - - def _is_special_workspace_id(self, ws_id): - return is_special_workspace_id(ws_id) - - def _should_show_app_instance(self, client): - if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS: - return True - - workspace_id = self._get_workspace_id(client) - if workspace_id is None: - return True - - return not self._is_special_workspace_id(workspace_id) - - def update_instance_button(self, instance_address, client, app_class): - if instance_address not in self.client_buttons: - return - - button = self.client_buttons[instance_address] - button.client_data = client - button.app_class = app_class - - tooltip_text = client.get("title", app_class) - if tooltip_text != app_class: - tooltip_text = f"{app_class}: {tooltip_text}" - button.set_tooltip_text(tooltip_text) - - workspace_id = self._get_workspace_id(client) - existing_label = getattr(button, "workspace_label", None) - - container = button.get_child() - if hasattr(container, "get_children"): - children = container.get_children() - if children: - image_overlay = children[0] - if isinstance(image_overlay, Overlay): - # Remove existing workspace label - if existing_label and existing_label.get_parent(): - image_overlay.remove_overlay(existing_label) - - # Add new workspace label if needed - if workspace_id is not None: - new_label = Label( - label=str(workspace_id), - name="workspace-indicator", - h_align="end", - v_align="end", - ) - image_overlay.add_overlay(new_label) - button.workspace_label = new_label - else: - button.workspace_label = None - - def handle_instance_click(self, button_widget, event): - instance_address = getattr(button_widget, "instance_address", None) - app_class = getattr(button_widget, "app_class", None) - - if event.button == 1: # Left click - focus window - if instance_address: - try: - self._hyprland_connection.send_command( - f"dispatch focuswindow address:{instance_address}" - ) - except Exception as e: - logger.error(f"[AppBar] Error focusing window: {e}") - - elif event.button == 2: # Middle click - pin/unpin app - if app_class and not self._is_app_pinned(app_class): - self._pin_app(app_class) - - elif event.button == 3: # Right click - context menu - if app_class: - self.show_menu(app_class, instance_address=instance_address) - self.menu.popup_at_pointer(event) - - def _is_app_pinned(self, app_class: str) -> bool: - return any( - self._matches_app_identifier(pinned_app, app_class) - for pinned_app in self.pinned_apps - ) - - def _update_separator_visibility(self): - has_pinned_apps = len(self.pinned_items_pos) > 0 - has_running_apps = len(self.running_items_pos) > 0 - if has_pinned_apps and has_running_apps: - self.separator.remove_style_class("hidden") - else: - self.separator.add_style_class("hidden") - - -class Dock(Window): - def __init__(self): - if not data.DOCK_ENABLED: - anchor = self._get_anchor_from_position() - super().__init__(layer="top", title="dock", anchor=anchor) - self.children = Box() # Empty dock if disabled - return - - anchor = self._get_anchor_from_position() - super().__init__(layer="top", anchor=anchor) - - self.app_bar = AppBar(self) - - transition_type = self._get_transition_type() - - self.revealer = Revealer( - child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"), - transition_duration=200, - transition_type=transition_type, - ) - - self.children = EventBox( - events=["enter-notify", "leave-notify"], - child=Box(style="min-height: 1px", children=self.revealer), - on_enter_notify_event=lambda *_: self.on_hover_enter(), - on_leave_notify_event=lambda *_: self.on_hover_leave(), - ) - - self.revealer.set_reveal_child(True) - self.app_bar.add_style_class("shown") - - self.dock_height = 100 - self.is_hovered = False - self.hide_timeout_id = None - - # Only setup occlusion monitoring if auto-hide is enabled - if data.DOCK_AUTO_HIDE: - self.setup_occlusion_monitoring() - - def on_hover_enter(self): - self.is_hovered = True - if self.hide_timeout_id: - GLib.source_remove(self.hide_timeout_id) - self.hide_timeout_id = None - self.revealer.set_reveal_child(True) - self.app_bar.add_style_class("shown") - - def on_hover_leave(self): - self.is_hovered = False - # Add small delay before potential hiding to prevent rapid show/hide cycles - if self.hide_timeout_id: - GLib.source_remove(self.hide_timeout_id) - self.hide_timeout_id = GLib.timeout_add(100, lambda: None) - - def _get_anchor_from_position(self): - if data.DOCK_POSITION == "Left": - return "left center" - elif data.DOCK_POSITION == "Right": - return "right center" - else: # Bottom (default) - return "bottom center" - - def _get_transition_type(self): - if data.DOCK_POSITION == "Left": - return "slide-right" - elif data.DOCK_POSITION == "Right": - return "slide-left" - else: # Bottom (default) - return "slide-up" - - def _get_occlusion_position(self): - if data.DOCK_POSITION == "Left": - return ("left", self.dock_height) - elif data.DOCK_POSITION == "Right": - return ("right", self.dock_height) - else: # Bottom (default) - return ("bottom", self.dock_height) - - def setup_occlusion_monitoring(self): - def check_dock_occlusion(): - try: - if data.DOCK_ALWAYS_OCCLUDED: - is_occluded = True - else: - occlusion_position = self._get_occlusion_position() - is_occluded = check_occlusion(occlusion_position) - - if ( - is_occluded - and not self.is_hovered - and self.revealer.get_reveal_child() - ): - self.revealer.set_reveal_child(False) - self.app_bar.remove_style_class("shown") - elif not is_occluded and not self.revealer.get_reveal_child(): - self.revealer.set_reveal_child(True) - self.app_bar.add_style_class("shown") - elif is_occluded and self.is_hovered: - if not self.revealer.get_reveal_child(): - self.revealer.set_reveal_child(True) - self.app_bar.add_style_class("shown") - except Exception as e: - logger.error(f"[Dock] Occlusion check error: {e}") - - return True - - GLib.timeout_add(300, check_dock_occlusion) diff --git a/modules/launcher/__init__.py b/modules/launcher/__init__.py deleted file mode 100644 index 547fa73..0000000 --- a/modules/launcher/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Plugin-based launcher module for Fabric. -Similar to Albert Launcher with extensible plugin system. -""" - -from .main import Launcher - -__all__ = ["Launcher"] diff --git a/modules/launcher/main.py b/modules/launcher/main.py deleted file mode 100644 index 30ada34..0000000 --- a/modules/launcher/main.py +++ /dev/null @@ -1,1380 +0,0 @@ -from typing import List, Optional, Tuple - -from gi.repository import Gdk, GLib - -from fabric.core.service import Property -from fabric.widgets.box import Box -from fabric.widgets.entry import Entry -from modules.launcher.plugin_manager import PluginManager -from modules.launcher.result import Result -from modules.launcher.result_item import ResultItem -from modules.launcher.trigger_config import TriggerConfig -from fabric.widgets.scrolledwindow import ScrolledWindow -from widgets.wayland import WaylandWindow as Window - -# Constants -SEARCH_DEBOUNCE_MS = 50 -TRIGGER_SEARCH_DEBOUNCE_MS = 50 -CURSOR_POSITION_DELAY_MS = 10 -SCROLL_PADDING = 10 -DEFAULT_ITEM_HEIGHT = 68 -PAGE_NAVIGATION_STEP = 5 -LAUNCHER_WIDTH = 640 -LAUNCHER_HEIGHT = 400 - - -class Launcher(Window): - """ - Main launcher window with search functionality and plugin system. - Spotlight-style interface. - """ - - # Properties - query = Property(str, flags="read-write", default_value="") - visible = Property(bool, flags="read-write", default_value=False) - active_trigger = Property(str, flags="read-write", default_value="") - - def __init__(self, **kwargs): - super().__init__( - name="launcher-window", - title="modus-launcher", - layer="top", - anchor="center", - exclusivity="none", - keyboard_mode="exclusive", - visible=False, # Start hidden until explicitly shown - **kwargs, - ) - self._initializing = True - self._auto_adding_space = False - self._processing_backspace = False - self.plugin_manager = PluginManager() - self.trigger_config = TriggerConfig() - - self.results: List[Result] = [] - self.selected_index = 0 - self.max_results = 5 # Show 4 applications by default instead of list - - # Trigger system - self.triggered_plugin = None # Currently active triggered plugin - self.active_trigger = "" # Currently active trigger keyword - self.query = "" # Current search query - self.visible = False # Launcher visibility state - self.opened_with_trigger = False # Whether launcher was opened with a trigger - - # Focus management - self.focus_mode = "search" # "search", "results" - - # Mouse activity tracking - self._mouse_active = False - self._last_mouse_activity = 0 - self._mouse_activity_timeout = 1000 # 1 second timeout - self._keyboard_used_recently = False - self._last_keyboard_activity = 0 - self._keyboard_activity_timeout = 2000 # 2 second timeout - self._launcher_just_opened = False - self._mouse_interaction_delay = 500 # 500ms delay after opening - - # Setup UI - main_box = Box( - name="launcher", - orientation="v", - spacing=0, - h_align="center", - v_align="center", - ) - self.add(main_box) - - # Search entry with Spotlight-style large text - self.search_entry = Entry( - name="launcher-search", - placeholder="Spotlight Search", - h_expand=True, - h_align="fill", - notify_text=lambda entry, *_: self._on_search_changed(entry), - ) - self.search_entry.connect("changed", self._on_search_changed) - self.search_entry.connect("activate", self._on_entry_activate) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - self.search_entry, - ], - ) - - main_box.add(self.header_box) - - self.results_scroll = ScrolledWindow( - name="launcher-results-scroll", - h_scrollbar_policy="never", - min_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT), - max_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT), - propagate_width=False, - propagate_height=False, - ) - - self.results_box = Box( - name="launcher-results", - orientation="v", - spacing=0, - ) - self.results_scroll.add(self.results_box) - main_box.add(self.results_scroll) - - # Keep the results container visible initially - - self.connect("key-press-event", self._on_key_press) - - # Connect mouse events to track activity - self.results_scroll.connect("button-press-event", self._on_mouse_activity) - self.results_scroll.connect("motion-notify-event", self._on_mouse_activity) - self.results_scroll.connect("scroll-event", self._on_mouse_activity) - - # Also track mouse activity on the main window for better coverage - self.connect("button-press-event", self._on_mouse_activity) - self.connect("motion-notify-event", self._on_mouse_activity) - - self.hide() - - # Mark initialization as complete - self._initializing = False - - # Hide trigger suggestions at startup - self._clear_results() - - def show_launcher(self, trigger_keyword: str = None, external: bool = False): - """Show the launcher and focus the search entry, or execute command externally. - - Args: - trigger_keyword: Optional trigger keyword to activate immediately (e.g., "google", "calc", "app") - external: If True, execute the command without showing the launcher UI - """ - # Reset mouse activity tracking when launcher is shown - self._mouse_active = False - self._last_mouse_activity = 0 - self._keyboard_used_recently = False - self._last_keyboard_activity = 0 - self._launcher_just_opened = True - - # Schedule to allow mouse interactions after a delay - def enable_mouse_interactions(): - self._launcher_just_opened = False - return False - - GLib.timeout_add(self._mouse_interaction_delay, enable_mouse_interactions) - - if external and trigger_keyword: - # Execute command externally without showing launcher - return self._execute_external_command(trigger_keyword) - - self.show_all() - - if trigger_keyword: - # Set flag to track that launcher was opened with a trigger keyword - self.opened_with_trigger = True - - # Set the trigger keyword with a space and activate trigger mode - trigger_text = f"{trigger_keyword} " - self.search_entry.set_text(trigger_text) - - # Detect and activate the trigger - triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) - if triggered_plugin: - self.triggered_plugin = triggered_plugin - self.active_trigger = detected_trigger - - # Query the plugin with empty string to show default options - try: - results = triggered_plugin.query("") - self.results = results - self.selected_index = 0 - self._update_results_display() - except Exception as e: - print( - f"Error querying triggered plugin {triggered_plugin.name}: {e}" - ) - self._clear_results() - else: - # Trigger not found, clear and show error or fallback - self.search_entry.set_text("") - self._clear_results() - else: - # Normal launcher opening - show applications - self.opened_with_trigger = False - self.search_entry.set_text("") - # Trigger initial search to show applications immediately - self._perform_search("") - - # Reset focus mode to search - self.focus_mode = "search" - - # Focus search entry without selecting text - if trigger_keyword: - # For trigger keywords, we want the cursor at the end - self.search_entry.grab_focus() - - def position_cursor(): - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(-1) # Move caret to end - return False # Only run once - - GLib.idle_add(position_cursor) - else: - # For normal opening, use our method that prevents text selection - self._focus_search_entry_without_selection() - - self.visible = True - - def _position_cursor_at_end(self, text_length: Optional[int] = None) -> None: - """Position cursor at the end of search entry text.""" - if text_length is None: - text_length = len(self.search_entry.get_text()) - - def position_cursor(): - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(-1) # Move caret to end - if hasattr(self.search_entry, "select_region"): - self.search_entry.select_region( - text_length, text_length - ) # No selection - return False # Only run once - - GLib.idle_add(position_cursor) - - def _add_space_to_trigger(self, trigger_word: str) -> None: - """Add space after trigger keyword and position cursor.""" - trigger_text_with_space = f"{trigger_word} " - - # Temporarily disable search change handling to prevent recursion - self._auto_adding_space = True - self.search_entry.set_text(trigger_text_with_space) - - # Position cursor at the end - def position_cursor(): - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(-1) # Move caret to end - if hasattr(self.search_entry, "select_region"): - self.search_entry.select_region( - len(trigger_text_with_space), len(trigger_text_with_space) - ) # No selection - self._auto_adding_space = False - return False # Only run once - - GLib.idle_add(position_cursor) - - # Update query - self.query = trigger_text_with_space - return trigger_text_with_space - - def close_launcher(self): - """Hide the launcher and clear search.""" - self.hide() - self.search_entry.set_text("") - self._clear_results() - self.triggered_plugin = None - self.active_trigger = "" - self.visible = False - self.opened_with_trigger = False - - def _on_search_changed(self, entry): - """Handle search text changes.""" - # Skip if still initializing - if getattr(self, "_initializing", True): - return - - # Skip if we're automatically adding a space to prevent recursion - if getattr(self, "_auto_adding_space", False): - return - - # Skip if we're processing a backspace to prevent interference - if getattr(self, "_processing_backspace", False): - return - - # Reset focus to search when user types - if self.focus_mode != "search": - self.focus_mode = "search" - - query = entry.get_text().strip() - self.query = query - - # If query is exactly ':', show all triggers - if query == ":": - self._show_available_triggers() - # If query matches a trigger exactly (with or without space), handle trigger activation - elif any( - query == trig or query == f"{trig} " - for trig in [ - t.strip() - for p in self.plugin_manager.get_active_plugins() - for t in p.get_triggers() - ] - ): - # Check if we need to add space immediately for exact trigger matches - if not query.endswith(" "): - # This is an exact trigger match without space - add space immediately - trigger_text_with_space = self._add_space_to_trigger(query) - GLib.timeout_add( - TRIGGER_SEARCH_DEBOUNCE_MS, - self._perform_search, - trigger_text_with_space, - ) - else: - # Already has space, proceed with normal search - GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query) - elif query: - # Debounce search to avoid too many queries - GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query) - else: - # Show applications when query is empty - self._perform_search("") - - def _perform_search(self, query: str) -> bool: - """Perform search across all plugins.""" - # Only search if query hasn't changed - if query != self.query: - return False - - if not query: - # Empty query - show popular applications - self.triggered_plugin = None - self.active_trigger = "" - - # Get applications plugin and show popular apps - applications_plugin = self._get_applications_plugin() - if applications_plugin: - try: - # Query with empty string to get popular/frequently used applications - all_results = applications_plugin.query("") - # Limit to max_results for empty query - self.results = all_results[: self.max_results] - self.selected_index = 0 - self._update_results_display() - except Exception as e: - print(f"Error getting applications: {e}") - self._clear_results() - else: - self._clear_results() - return False - - # Check if we're already in trigger mode - if self.triggered_plugin and self.active_trigger: - # We're in trigger mode - search within the triggered plugin - try: - # Extract the search query after the trigger - remaining_query = self._extract_query_after_trigger( - query, self.active_trigger - ) - all_results = self.triggered_plugin.query(remaining_query) - except Exception as e: - print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}") - all_results = [] - else: - # Check for trigger activation - triggered_plugin, trigger = self._detect_trigger(query) - - if triggered_plugin: - # New trigger detected - check if we need to add space automatically - trigger_word = trigger.strip() - current_text = self.search_entry.get_text() - - # If the current text is exactly the trigger word (no space), add space automatically - if ( - current_text.strip() == trigger_word - and not current_text.endswith(" ") - and not getattr(self, "_auto_adding_space", False) - ): - query = self._add_space_to_trigger(trigger_word) - - # Enter trigger mode - self.triggered_plugin = triggered_plugin - self.active_trigger = trigger - - # Extract search query after trigger - remaining_query = self._extract_query_after_trigger(query, trigger) - - # Always call the plugin's query method, even with empty remaining query - # This allows plugins to show default options when just the trigger is typed - try: - all_results = triggered_plugin.query(remaining_query) - except Exception as e: - print(f"Error in triggered plugin {triggered_plugin.name}: {e}") - all_results = [] - else: - # No trigger detected - search applications and show trigger suggestions - self.triggered_plugin = None - self.active_trigger = "" - - all_results = [] - - # Search applications directly without trigger - applications_plugin = self._get_applications_plugin() - if applications_plugin: - try: - app_results = applications_plugin.query(query) - all_results.extend(app_results) - except Exception as e: - print(f"Error searching applications: {e}") - - # Also show trigger suggestions if query matches trigger prefixes - trigger_suggestions = self._get_trigger_suggestions(query) - all_results.extend(trigger_suggestions) - - # Sort results by relevance score - all_results.sort(key=lambda r: r.relevance, reverse=True) - - # Check if any results have bypass_max_results flag - has_bypass = any( - hasattr(r, "data") and r.data and r.data.get("bypass_max_results") - for r in all_results - ) - - # Don't limit results for triggered plugin queries, only for global searches and trigger suggestions - if self.triggered_plugin and self.active_trigger: - # In trigger mode - show all results from the triggered plugin - self.results = all_results - elif not has_bypass: - # Global search - check if it's an application search - applications_plugin = self._get_applications_plugin() - is_application_search = applications_plugin and any( - hasattr(r, "plugin_name") and r.plugin_name == "Applications" - for r in all_results - ) - - if is_application_search: - # Don't limit application search results - self.results = all_results - else: - # Apply max_results limit for other global searches and trigger suggestions - self.results = all_results[: self.max_results] - else: - # Has bypass flag - show all results - self.results = all_results - self.selected_index = 0 - - # Update UI - self._update_results_display() - - return False # Don't repeat timeout - - def _extract_query_after_trigger(self, query: str, trigger: str) -> str: - """ - Extract the search query after removing the trigger. - - Args: - query: The full query string - trigger: The trigger keyword - - Returns: - The remaining query after the trigger - """ - if not query or not trigger: - return "" - - query_lower = query.lower() - trigger_lower = trigger.lower() - - # Handle trigger with space (e.g., "app ") - if trigger_lower.endswith(" ") and query_lower.startswith(trigger_lower): - return query[len(trigger) :].strip() - - # Handle trigger without space (e.g., "app") - trigger_word = trigger.strip().lower() - if query_lower.startswith(trigger_word): - # Check if it's followed by space or end of string - if len(query) == len(trigger_word): - return "" - elif len(query) > len(trigger_word) and query[len(trigger_word)] == " ": - return query[len(trigger_word) + 1 :].strip() - elif len(query) > len(trigger_word): - # No space after trigger word, extract rest - return query[len(trigger_word) :].strip() - - return "" - - def _show_available_triggers(self): - """Show available triggers when launcher is first opened.""" - trigger_suggestions = self._get_trigger_suggestions("") - self.results = trigger_suggestions # Show all available triggers without limit - self.selected_index = 0 - self._update_results_display() - - def _detect_trigger(self, query: str) -> Tuple[Optional[object], str]: - """ - Detect if query starts with a trigger keyword. - - Args: - query: The search query - - Returns: - Tuple of (plugin, trigger) if triggered, (None, "") otherwise - """ - if not query.strip(): - return None, "" - - # Check all plugins for triggers - for plugin in self.plugin_manager.get_active_plugins(): - trigger = plugin.get_active_trigger(query) - if trigger: - return plugin, trigger - - return None, "" - - def _get_applications_plugin(self): - """Get the applications plugin instance.""" - for plugin in self.plugin_manager.get_active_plugins(): - if ( - hasattr(plugin, "display_name") - and plugin.display_name == "Applications" - ): - return plugin - return None - - def _get_trigger_suggestions(self, query: str) -> List[Result]: - """ - Get trigger suggestions based on the current query. - - Args: - query: The search query - - Returns: - List of Result objects showing available triggers - """ - suggestions = [] - query_lower = query.lower().strip() - - # Get all available triggers from plugins - all_triggers = {} - for plugin in self.plugin_manager.get_active_plugins(): - triggers = plugin.get_triggers() - for trigger in triggers: - trigger_clean = trigger.strip() - if trigger_clean not in all_triggers: - all_triggers[trigger_clean] = { - "plugin": plugin, - "trigger": trigger, - "examples": [], - } - - # Get max examples to show from configuration - max_examples = self.trigger_config.settings.get("max_examples_shown", 2) - - # Show trigger suggestions based on query - if query_lower: - # Show triggers that match the query - for trigger_clean, _ in all_triggers.items(): - if trigger_clean.lower().startswith(query_lower): - result = self._create_trigger_result(trigger_clean, max_examples) - suggestions.append(result) - else: - # Empty query - show all available triggers - for trigger_clean, _ in all_triggers.items(): - result = self._create_trigger_result(trigger_clean, max_examples) - suggestions.append(result) - - return suggestions # Return all trigger suggestions without limit - - def _create_trigger_result(self, trigger_clean: str, max_examples: int) -> Result: - """Create a Result object for a trigger suggestion.""" - examples = self.trigger_config.get_trigger_examples(trigger_clean) - icon_name = self.trigger_config.get_trigger_icon(trigger_clean) - description = self.trigger_config.get_trigger_description(trigger_clean) - - return Result( - title=f"{trigger_clean}", - subtitle=f"{description} - {', '.join(examples[:max_examples])}", - icon_name=icon_name, - action=lambda t=trigger_clean: self._activate_trigger(t), - # Shorter triggers get higher relevance - relevance=100 - len(trigger_clean), - data={"type": "trigger_suggestion", "trigger": trigger_clean}, - ) - - def _activate_trigger(self, trigger: str): - """ - Activate a trigger by setting it in the search entry. - - Args: - trigger: The trigger keyword to activate - """ - # Set the trigger text in the search entry - trigger_text = f"{trigger} " - self.search_entry.set_text(trigger_text) - self.search_entry.grab_focus() - - def clear_selection(): - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(-1) # Move caret to end - if hasattr(self.search_entry, "select_region"): - self.search_entry.select_region( - len(trigger_text), len(trigger_text) - ) # No selection - return False # Only run once - - GLib.idle_add(clear_selection) - - # Manually set the trigger mode to avoid search processing issues - triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) - if triggered_plugin: - self.triggered_plugin = triggered_plugin - self.active_trigger = detected_trigger - - # Clear results and show trigger ready state - self.results = [] - self.selected_index = 0 - self._update_results_display() - - # Focus back to the search entry for immediate typing - self.search_entry.grab_focus() - - # Don't hide the launcher - user should be able to continue typing - - def _execute_external_command(self, command_string: str): - """Execute a command externally without showing the launcher UI. - - Args: - command_string: Full command string (e.g., "wall random", "calc 2+2") - - Returns: - Result of the command execution or None if failed - """ - try: - # Parse the command to extract trigger and query - parts = command_string.strip().split(" ", 1) - if not parts: - return None - - trigger_part = parts[0] - query_part = parts[1] if len(parts) > 1 else "" - - # Find the plugin that handles this trigger - triggered_plugin = None - - for plugin in self.plugin_manager.get_active_plugins(): - trigger = plugin.get_active_trigger(f"{trigger_part} ") - if trigger: - triggered_plugin = plugin - break - - if not triggered_plugin: - print(f"No plugin found for trigger: {trigger_part}") - return None - - # Query the plugin with the remaining query - try: - results = triggered_plugin.query(query_part) - if not results: - print(f"No results found for query: {query_part}") - return None - - # Find the first result that matches the query exactly or has highest relevance - best_result = None - for result in results: - # For exact matches like "random", execute immediately - if ( - hasattr(result, "data") - and result.data - and result.data.get("action") == query_part.strip() - ): - best_result = result - break - # For partial matches, take the first high-relevance result - elif not best_result and result.relevance >= 0.9: - best_result = result - - # If no exact match, take the first result - if not best_result and results: - best_result = results[0] - - if best_result: - # Execute the result action - try: - result_value = best_result.activate() - print(f"External command executed: {command_string}") - return result_value - except Exception as e: - print(f"Error executing result action: {e}") - return None - else: - print(f"No suitable result found for: {command_string}") - return None - - except Exception as e: - print(f"Error querying plugin {triggered_plugin.name}: {e}") - return None - - except Exception as e: - print(f"Error executing external command '{command_string}': {e}") - return None - - def _update_results_display(self): - """Update the results display.""" - # Skip if still initializing or results_box not ready - if getattr(self, "_initializing", True) or not hasattr(self, "results_box"): - return - - # Update input field with trigger indication (Spotlight-style) - self._update_input_action_text() - - # Clear existing results - for child in self.results_box.get_children(): - self.results_box.remove(child) - - # Add new results - for i, result in enumerate(self.results): - # Check if this result has a custom widget - if result.custom_widget: - # Ensure the widget is not already parented - parent = result.custom_widget.get_parent() - if parent: - parent.remove(result.custom_widget) - - result.custom_widget.show_all() # Ensure widget is visible - self.results_box.add(result.custom_widget) - else: - # Create normal result item - result_item = ResultItem( - result=result, selected=(i == self.selected_index), index=i - ) - result_item.clicked.connect( - lambda _, idx=i: self._on_result_clicked(result_item, idx) - ) - result_item.hovered.connect( - lambda _, idx=i: self._on_result_hovered(idx) - ) - self.results_box.add(result_item) - - self.results_box.show_all() - - self.results_scroll.show() - - def _update_input_action_text(self): - """Update the input field with action text (Spotlight-style).""" - # Check if search_entry is initialized - if not hasattr(self, "search_entry") or self.search_entry is None: - return - - # Get the current input text - current_text = self.search_entry.get_text() - - # Spotlight-style placeholder text - if self.triggered_plugin: - if self.results: - # Show more minimal placeholder for triggered mode - self.search_entry.set_placeholder_text( - f"Search {self.active_trigger.strip()}..." - ) - else: - # In trigger mode but no results yet - if current_text == self.active_trigger.strip(): - # Just the trigger keyword - self.search_entry.set_placeholder_text( - f"Search {self.active_trigger.strip()}..." - ) - else: - # Searching within trigger - self.search_entry.set_placeholder_text( - f"Searching {self.active_trigger.strip()}..." - ) - else: - # Not in trigger mode - show Spotlight-style help - if current_text == ":": - self.search_entry.set_placeholder_text("Available search triggers") - else: - self.search_entry.set_placeholder_text("Spotlight Search") - - def _clear_results(self): - """Clear all results.""" - self.results = [] - self.selected_index = 0 - for child in self.results_box.get_children(): - self.results_box.remove(child) - # Keep the results scroll visible even when empty - - def _handle_escape_key(self) -> bool: - """Handle escape key press.""" - # First check if there's a password entry widget that should handle Escape - password_entry_widget = self._find_password_entry_widget() - if password_entry_widget: - # Cancel the password entry - password_entry_widget.cancel_password_entry() - return True - elif self.opened_with_trigger: - # If launcher was opened with a trigger keyword, close directly - self.close_launcher() - return True - else: - # Hide launcher - self.close_launcher() - return True - - def _handle_backspace_key(self) -> bool: - """Handle backspace key press in trigger mode.""" - if self.triggered_plugin: - trigger_text = self.active_trigger.strip() - current_text = self.search_entry.get_text() - - # Set flag to prevent search change handler from interfering - self._processing_backspace = True - - # Allow normal backspace behavior first, then check if we need to exit trigger mode - # Don't intercept the backspace - let GTK handle it normally - - # Schedule a check after the backspace is processed - def check_trigger_after_backspace(): - try: - # Get the text after backspace has been processed - new_text = self.search_entry.get_text() - - # Check if we should exit trigger mode - # We need to check if the text still matches the trigger pattern - should_exit_trigger = False - - # If the active trigger ends with a space (like "calc "), - # we should exit if the text doesn't contain that space anymore - if self.active_trigger.endswith(" "): - # For triggers like "calc ", exit if text is just "calc" or doesn't start with "calc " - trigger_with_space = self.active_trigger.lower() - if not new_text.lower().startswith(trigger_with_space): - should_exit_trigger = True - else: - # For triggers without space, exit if text doesn't start with trigger - if not new_text.lower().startswith(trigger_text.lower()): - should_exit_trigger = True - - if should_exit_trigger: - self.triggered_plugin = None - self.active_trigger = "" - self._clear_results() - # Don't clear the text - let the user's edit stand - - # If we're still in trigger mode but the text changed, update the search - elif self.triggered_plugin and new_text != current_text: - # Trigger a search with the new text - self.query = new_text - GLib.timeout_add(50, self._perform_search, new_text) - - finally: - # Clear the backspace processing flag - self._processing_backspace = False - - return False # Don't repeat - - # Use idle_add to check after the backspace is processed - GLib.idle_add(check_trigger_after_backspace) - - # Allow the normal backspace to proceed - return False - - # Let normal backspace behavior continue for other cases - return False - - def _on_key_press(self, _widget, event): - """Handle key press events.""" - # Track keyboard activity - import time - - current_time = int(time.time() * 1000) - self._last_keyboard_activity = current_time - self._keyboard_used_recently = True - - keyval = event.keyval - - # Escape - handle password entry, exit trigger mode, or hide launcher - if keyval == Gdk.KEY_Escape: - return self._handle_escape_key() - - # Backspace - handle trigger mode backspace behavior - if keyval == Gdk.KEY_BackSpace: - return self._handle_backspace_key() - - # Up/Down - navigate results (alternative to Tab) - if keyval == Gdk.KEY_Up: - if self.focus_mode == "results" and self.results: - if self.selected_index > 0: - # Move to previous result - self.selected_index -= 1 - self._update_selection() - else: - # At first result, go back to search entry - self.focus_mode = "search" - self._focus_search_entry_without_selection() - elif self.results: - # If not in results mode but have results, enter results mode at last item - self.focus_mode = "results" - self.selected_index = len(self.results) - 1 - self._update_selection() - return True - - if keyval == Gdk.KEY_Down: - if self.focus_mode == "results" and self.results: - if self.selected_index < len(self.results) - 1: - # Move to next result - self.selected_index += 1 - self._update_selection() - else: - # At last result, wrap around to first result - self.selected_index = 0 - self._update_selection() - elif self.results: - # If not in results mode but have results, enter results mode at first item - self.focus_mode = "results" - self.selected_index = 0 - self._update_selection() - return True - - # Enter - activate selected result - if keyval == Gdk.KEY_Return: - # Check for Shift+Enter for alternative actions - if event.state & Gdk.ModifierType.SHIFT_MASK: - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.data: - # Check for generic alternative action first - if result.data.get("alt_action"): - result.data["alt_action"]() - return True - # Fallback to pin_action for backward compatibility - elif result.data.get("pin_action"): - result.data["pin_action"]() - return True - - # Check if the selected result has a custom widget with Entry fields - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.custom_widget: - # Check if the custom widget contains Entry widgets that should handle Enter - if self._custom_widget_has_entry(result.custom_widget): - # Let the custom widget handle the Enter key - # Find the focused Entry widget and trigger its activate signal - focused_entry = self._find_focused_entry_in_widget( - result.custom_widget - ) - if focused_entry: - focused_entry.emit("activate") - return True - - # Normal Enter behavior - self._activate_selected() - return True - - # Tab - cycle through focus areas and results - if keyval == Gdk.KEY_Tab: - if event.state & Gdk.ModifierType.SHIFT_MASK: - # Shift+Tab - reverse direction - if self.focus_mode == "results": - # Navigate through results in reverse - self._navigate_results_backward() - else: - self._cycle_focus_backward() - else: - # Tab - forward direction - if self.focus_mode == "results": - # Navigate through results forward - self._navigate_results_forward() - else: - self._cycle_focus_forward() - return True - - # Page Up/Page Down - navigate results faster - if keyval == Gdk.KEY_Page_Up: - if self.results: - self.selected_index = max(0, self.selected_index - PAGE_NAVIGATION_STEP) - self._update_selection() - return True - - if keyval == Gdk.KEY_Page_Down: - if self.results: - self.selected_index = min( - len(self.results) - 1, self.selected_index + PAGE_NAVIGATION_STEP - ) - self._update_selection() - return True - - # Home/End - go to first/last result - if keyval == Gdk.KEY_Home: - if self.results: - self.selected_index = 0 - self._update_selection() - return True - - if keyval == Gdk.KEY_End: - if self.results: - self.selected_index = len(self.results) - 1 - self._update_selection() - return True - - # Forward other keys to custom widgets if they can handle them - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.custom_widget and hasattr(result.custom_widget, "on_key_press"): - # Try to forward the key event to the custom widget - if result.custom_widget.on_key_press(result.custom_widget, event): - return True - - return False - - def _custom_widget_has_entry(self, widget): - """Check if a custom widget contains Entry widgets.""" - - if isinstance(widget, Entry): - return True - - # Check children recursively - if hasattr(widget, "get_children"): - for child in widget.get_children(): - if self._custom_widget_has_entry(child): - return True - - return False - - def _find_focused_entry_in_widget(self, widget): - """Find the focused Entry widget within a custom widget.""" - - if isinstance(widget, Entry): - # Try multiple ways to check if this Entry is focused - try: - if widget.has_focus() or widget.is_focus(): - return widget - # Also check if this is the only Entry in the widget (likely to be the target) - return widget - except: - # If focus checking fails, assume this Entry should handle the event - return widget - - # Check children recursively - if hasattr(widget, "get_children"): - for child in widget.get_children(): - focused_entry = self._find_focused_entry_in_widget(child) - if focused_entry: - return focused_entry - - return None - - def _find_password_entry_widget(self): - """Find a NetworkPasswordEntry widget in the current results.""" - for result in self.results: - if result.custom_widget: - # Check if this is a NetworkPasswordEntry widget - if ( - hasattr(result.custom_widget, "__class__") - and result.custom_widget.__class__.__name__ - == "NetworkPasswordEntry" - ): - return result.custom_widget - # Also check if it has the cancel_password_entry method (duck typing) - elif hasattr(result.custom_widget, "cancel_password_entry"): - return result.custom_widget - return None - - def _on_entry_activate(self, _entry): - """Handle entry activation (Enter key).""" - self._activate_selected() - - def _on_result_clicked(self, _result_item, index): - """Handle result item click.""" - # Only allow clicks when mouse is active or keyboard hasn't been used recently - if self._should_allow_mouse_interaction(): - self.selected_index = index - self._activate_selected() - - def _on_result_hovered(self, index): - """Handle result item hover.""" - # Only allow hover selection when mouse is active and keyboard hasn't been used recently - if self._should_allow_mouse_interaction(): - if 0 <= index < len(self.results): - self.selected_index = index - self.focus_mode = "results" # Switch to results focus mode - self._update_selection_visual_only() - - def _on_mouse_activity(self, widget, event): - """Track mouse activity to determine when mouse interactions should be enabled.""" - import time - - current_time = int(time.time() * 1000) - self._last_mouse_activity = current_time - self._mouse_active = True - - # Schedule a check to disable mouse activity after timeout - def check_mouse_timeout(): - if current_time - self._last_mouse_activity >= self._mouse_activity_timeout: - self._mouse_active = False - return False - - GLib.timeout_add(self._mouse_activity_timeout, check_mouse_timeout) - return False - - def _should_allow_mouse_interaction(self): - """Determine if mouse interactions should be allowed.""" - import time - - current_time = int(time.time() * 1000) - - # Don't allow mouse interactions immediately after launcher opens - if self._launcher_just_opened: - return False - - # Allow mouse interaction if: - # 1. Mouse is currently active (recent activity) - # 2. OR keyboard hasn't been used recently (2 seconds) - mouse_recently_active = ( - current_time - self._last_mouse_activity - ) < self._mouse_activity_timeout - keyboard_not_recently_used = ( - current_time - self._last_keyboard_activity - ) > self._keyboard_activity_timeout - - return mouse_recently_active or keyboard_not_recently_used - - def _is_mouse_over_results(self): - """Check if mouse is over the results area.""" - # Simple check - if we have results visible, assume user might be interacting - return len(self.results) > 0 and self.results_scroll.get_visible() - - def _update_selection(self): - """Update the visual selection of results with scrolling (for keyboard navigation).""" - children = self.results_box.get_children() - selected_widget = None - - for i, child in enumerate(children): - if isinstance(child, ResultItem): - is_selected = i == self.selected_index - child.set_selected(is_selected) - if is_selected: - selected_widget = child - # For custom widgets, we don't need to handle selection visually - # since they manage their own interaction - - # Focus custom widgets when selected for keyboard interaction - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.custom_widget and result.custom_widget.get_can_focus(): - # Give focus to the custom widget for keyboard interaction - result.custom_widget.grab_focus() - - # Scroll to make the selected item visible - if selected_widget and self.results_scroll.get_visible(): - # Use immediate scrolling for better responsiveness - self._scroll_to_widget(selected_widget) - # Also schedule a more accurate scroll after layout is complete - GLib.idle_add(self._ensure_selected_visible) - - def _update_selection_visual_only(self): - """Update the visual selection of results without scrolling (for mouse hover).""" - children = self.results_box.get_children() - - for i, child in enumerate(children): - if isinstance(child, ResultItem): - is_selected = i == self.selected_index - child.set_selected(is_selected) - # For custom widgets, we don't need to handle selection visually - # since they manage their own interaction - - # Focus custom widgets when selected for keyboard interaction - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.custom_widget and result.custom_widget.get_can_focus(): - # Give focus to the custom widget for keyboard interaction - result.custom_widget.grab_focus() - - def _scroll_to_widget(self, widget): - """Scroll the results container to make the widget visible.""" - if not widget or not self.results_scroll.get_visible(): - return - - # Debug output (can be removed in production) - # print(f"Scrolling to selected index: {self.selected_index}") - - # Get the scrolled window's vertical adjustment - vadjustment = self.results_scroll.get_vadjustment() - if not vadjustment: - return - - # Use a simpler approach: scroll to the selected item index - if self.results and 0 <= self.selected_index < len(self.results): - # Get all children to work with actual widgets - children = self.results_box.get_children() - if not children or self.selected_index >= len(children): - return - - # Get the selected child widget - selected_child = children[self.selected_index] - - # Try to get actual allocation, fallback to estimation - try: - allocation = selected_child.get_allocation() - item_height = allocation.height if allocation.height > 0 else 68 - item_y = allocation.y - except: - # Fallback to estimation - item_height = DEFAULT_ITEM_HEIGHT - item_y = self.selected_index * item_height - - # Get current scroll info - current_scroll = vadjustment.get_value() - page_size = vadjustment.get_page_size() - max_scroll = vadjustment.get_upper() - page_size - - # Calculate visible area - visible_top = current_scroll - visible_bottom = current_scroll + page_size - - # Check if selected item is visible - item_top = item_y - item_bottom = item_y + item_height - - # Add some padding for better visibility - # Scroll if needed - if item_top < visible_top + SCROLL_PADDING: - # Item is above visible area - scroll up - new_scroll = max(0, item_top - SCROLL_PADDING) - vadjustment.set_value(new_scroll) - elif item_bottom > visible_bottom - SCROLL_PADDING: - # Item is below visible area - scroll down - new_scroll = min(max_scroll, item_bottom - page_size + SCROLL_PADDING) - vadjustment.set_value(new_scroll) - - def _ensure_selected_visible(self): - """Alternative method to ensure selected item is visible using GTK methods.""" - if ( - not self.results - or self.selected_index < 0 - or self.selected_index >= len(self.results) - ): - return False - - children = self.results_box.get_children() - if self.selected_index < len(children): - selected_child = children[self.selected_index] - - # Try to use widget's allocation for more accurate scrolling - try: - allocation = selected_child.get_allocation() - if allocation.height > 0: - vadjustment = self.results_scroll.get_vadjustment() - if vadjustment: - # Calculate the position to center the selected item - page_size = vadjustment.get_page_size() - target_pos = ( - allocation.y - (page_size / 2) + (allocation.height / 2) - ) - target_pos = max( - 0, min(target_pos, vadjustment.get_upper() - page_size) - ) - vadjustment.set_value(target_pos) - except Exception as e: - print(f"Error in _ensure_selected_visible: {e}") - - return False # Don't repeat the idle callback - - def _cycle_focus_forward(self): - """Cycle focus forward: search -> results""" - if self.focus_mode == "search": - if self.results: - self.focus_mode = "results" - self._update_selection() - - def _cycle_focus_backward(self): - """Cycle focus backward: results -> search""" - if self.focus_mode == "results": - self.focus_mode = "search" - self._focus_search_entry_without_selection() - - def _focus_search_entry_without_selection(self): - """Focus search entry and position cursor at end without selecting text.""" - # First grab focus - self.search_entry.grab_focus() - - # Use multiple approaches to prevent text selection - def clear_selection(): - try: - text_length = len(self.search_entry.get_text()) - - # Method 1: Set position and clear selection - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(text_length) - if hasattr(self.search_entry, "select_region"): - self.search_entry.select_region(text_length, text_length) - - # Method 2: Try to access underlying GTK widget - try: - # For fabric Entry widgets, try to get the actual GTK Entry - if hasattr(self.search_entry, "_entry"): - gtk_entry = self.search_entry._entry - elif ( - hasattr(self.search_entry, "get_children") - and self.search_entry.get_children() - ): - gtk_entry = self.search_entry.get_children()[0] - else: - gtk_entry = self.search_entry - - if hasattr(gtk_entry, "set_position"): - gtk_entry.set_position(text_length) - if hasattr(gtk_entry, "select_region"): - gtk_entry.select_region(text_length, text_length) - - except Exception: - pass - - except Exception as e: - print(f"Could not clear selection: {e}") - return False - - # Schedule clearing selection after focus is established - GLib.idle_add(clear_selection) - # Also try with a small delay as backup - GLib.timeout_add(CURSOR_POSITION_DELAY_MS, clear_selection) - - def _navigate_results_forward(self): - """Navigate to next result or wrap around to first result.""" - if self.results and self.selected_index < len(self.results) - 1: - # Move to next result - self.selected_index += 1 - self._update_selection() - else: - # At last result, wrap around to first result - self.selected_index = 0 - self._update_selection() - - def _navigate_results_backward(self): - """Navigate to previous result or exit results mode.""" - if self.results and self.selected_index > 0: - # Move to previous result - self.selected_index -= 1 - self._update_selection() - else: - # Exit results mode and go to search - self.focus_mode = "search" - self._focus_search_entry_without_selection() - - def _activate_selected(self): - """Activate the currently selected result.""" - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - try: - # Check if this result has a custom widget - if result.custom_widget: - # For custom widgets, we don't activate them since they're already displayed - # The widget handles its own interactions - return - - # Check if this is a trigger suggestion or should keep launcher open - is_trigger_suggestion = ( - result.data and result.data.get("type") == "trigger_suggestion" - ) - keep_launcher_open = result.data and result.data.get( - "keep_launcher_open", False - ) - - # Activate the result - result.activate() - - # Only hide launcher if it's not a trigger suggestion and doesn't have keep_launcher_open flag - if not is_trigger_suggestion and not keep_launcher_open: - self.close_launcher() - # For trigger suggestions and keep_launcher_open actions, the launcher stays open - except Exception as e: - print(f"Error activating result: {e}") diff --git a/modules/launcher/plugin_base.py b/modules/launcher/plugin_base.py deleted file mode 100644 index 13bd1fc..0000000 --- a/modules/launcher/plugin_base.py +++ /dev/null @@ -1,172 +0,0 @@ -from abc import ABC, abstractmethod -from typing import List - -from modules.launcher.result import Result - - -class PluginBase(ABC): - """ - Abstract base class for launcher plugins. - All plugins must inherit from this class. - """ - - def __init__(self): - self.name = self.__class__.__name__.lower() - self.display_name = self.__class__.__name__ - self.description = "A launcher plugin" - self.version = "1.0.0" - self.enabled = True - self._triggers = [] # List of trigger keywords - - @abstractmethod - def initialize(self): - """ - Initialize the plugin. - Called when the plugin is activated. - """ - pass - - @abstractmethod - def cleanup(self): - """ - Cleanup the plugin. - Called when the plugin is deactivated. - """ - pass - - @abstractmethod - def query(self, query_string: str) -> List[Result]: - """ - Process a search query and return results. - - Args: - query_string: The search query from the user - - Returns: - List of Result objects - """ - pass - - def get_triggers(self) -> List[str]: - """ - Get list of trigger keywords for this plugin. - If the query starts with any of these, this plugin gets priority. - - Returns: - List of trigger strings (e.g., ["calc", "=", "math"]) - """ - return self._triggers - - def set_triggers(self, triggers: List[str]): - """ - Set the trigger keywords for this plugin. - - Args: - triggers: List of trigger keywords - """ - self._triggers = triggers - - def handles_query(self, query_string: str) -> bool: - """ - Check if this plugin should handle the given query. - - Args: - query_string: The search query - - Returns: - True if this plugin should process the query - """ - if not self.enabled: - return False - - # Check triggers - triggers = self.get_triggers() - if triggers: - query_lower = query_string.lower().strip() - return any(query_lower.startswith(trigger.lower()) for trigger in triggers) - - # Default: handle all queries - return True - - def get_active_trigger(self, query_string: str) -> str: - """ - Get the active trigger for the given query. - - Args: - query_string: The search query - - Returns: - The trigger keyword if found, empty string otherwise - """ - if not self.enabled: - return "" - - triggers = self.get_triggers() - if triggers: - query_lower = query_string.lower().strip() - - # Sort triggers by length (longest first) to match more specific triggers first - sorted_triggers = sorted(triggers, key=len, reverse=True) - - for trigger in sorted_triggers: - trigger_lower = trigger.lower() - - # Exact match with trigger (including space if present) - if query_lower.startswith(trigger_lower): - return trigger - - # Match trigger word followed by space - trigger_word = trigger.strip().lower() - if ( - query_lower.startswith(trigger_word + " ") - or query_lower == trigger_word - ): - return trigger - - return "" - - def query_triggered(self, query_string: str, trigger: str) -> List[Result]: - """ - Process a triggered query (when plugin is in sticky mode). - Default implementation removes trigger and calls query(). - - Args: - query_string: The full query string including trigger - trigger: The trigger that activated this plugin - - Returns: - List of Result objects - """ - # Remove trigger from query and process remaining text - remaining_query = query_string[len(trigger) :].strip() - return self.query(remaining_query) - - def get_config(self) -> dict: - """ - Get plugin configuration. - - Returns: - Dictionary of configuration options - """ - return { - "name": self.name, - "display_name": self.display_name, - "description": self.description, - "version": self.version, - "enabled": self.enabled, - } - - def set_config(self, config: dict): - """ - Set plugin configuration. - - Args: - config: Dictionary of configuration options - """ - self.enabled = config.get("enabled", self.enabled) - - def __str__(self): - return f"Plugin({self.name})" - - def __repr__(self): - return self.__str__() diff --git a/modules/launcher/plugin_manager.py b/modules/launcher/plugin_manager.py deleted file mode 100644 index caf002e..0000000 --- a/modules/launcher/plugin_manager.py +++ /dev/null @@ -1,183 +0,0 @@ -import importlib -import importlib.util -import os -from typing import Dict, List, Type - -from modules.launcher.plugin_base import PluginBase - - -class PluginManager: - """ - Manages launcher plugins. - """ - - def __init__(self): - self.plugins: Dict[str, PluginBase] = {} - self.plugin_classes: Dict[str, Type[PluginBase]] = {} - self.active_plugins: List[str] = [] - - # Load built-in plugins - self._load_builtin_plugins() - - # Load external plugins - self._load_external_plugins() - - # Activate default plugins - self._activate_default_plugins() - - def _load_builtin_plugins(self): - """Load built-in plugins from the plugins directory.""" - plugins_dir = os.path.join(os.path.dirname(__file__), "plugins") - - if not os.path.exists(plugins_dir): - return - - for filename in os.listdir(plugins_dir): - if filename.endswith(".py") and not filename.startswith("_"): - plugin_name = filename[:-3] # Remove .py extension - self._load_plugin_from_file(plugins_dir, plugin_name) - - def _load_external_plugins(self): - """Load external plugins from user directory.""" - # Could be implemented to load from ~/.config/launcher/plugins/ - pass - - def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str): - """Load a plugin from a Python file.""" - try: - plugin_path = os.path.join(plugins_dir, f"{plugin_name}.py") - spec = importlib.util.spec_from_file_location( - f"modules.launcher.plugins.{plugin_name}", plugin_path - ) - - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - - # Add the module to sys.modules to support relative imports - import sys - - sys.modules[f"modules.launcher.plugins.{plugin_name}"] = module - - spec.loader.exec_module(module) - - # Look for plugin class - for attr_name in dir(module): - attr = getattr(module, attr_name) - if ( - isinstance(attr, type) - and issubclass(attr, PluginBase) - and attr != PluginBase - ): - self.plugin_classes[plugin_name] = attr - break - - except Exception as e: - print(f"Failed to load plugin {plugin_name}: {e}") - - def _activate_default_plugins(self): - """Activate default plugins.""" - default_plugins = [ - "applications", - "calculator", - "system", - "clipboard", - "power", - "caffeine", - "screencapture", - "emoji", - "wallpaper", - "websearch", - "reminders", - "otp", - "password", - "bookmarks", - "bash_scripts", - "tmux", - ] - - for plugin_name in default_plugins: - self.activate_plugin(plugin_name) - - def activate_plugin(self, plugin_name: str) -> bool: - """Activate a plugin by name.""" - if plugin_name in self.plugins: - # Already activated - return True - - if plugin_name not in self.plugin_classes: - return False - - try: - # Instantiate plugin - plugin_class = self.plugin_classes[plugin_name] - plugin_instance = plugin_class() - - # Initialize plugin - plugin_instance.initialize() - - # Store plugin - self.plugins[plugin_name] = plugin_instance - self.active_plugins.append(plugin_name) - - return True - - except Exception as e: - print(f"Failed to activate plugin {plugin_name}: {e}") - return False - - def deactivate_plugin(self, plugin_name: str) -> bool: - """Deactivate a plugin by name.""" - if plugin_name not in self.plugins: - return False - - try: - # Cleanup plugin - plugin = self.plugins[plugin_name] - plugin.cleanup() - - # Remove from active plugins - del self.plugins[plugin_name] - if plugin_name in self.active_plugins: - self.active_plugins.remove(plugin_name) - - return True - - except Exception as e: - print(f"Failed to deactivate plugin {plugin_name}: {e}") - return False - - def get_active_plugins(self) -> List[PluginBase]: - """Get list of active plugin instances.""" - return [ - self.plugins[name] for name in self.active_plugins if name in self.plugins - ] - - def get_plugin_names(self) -> List[str]: - """Get list of available plugin names.""" - return list(self.plugin_classes.keys()) - - def get_active_plugin_names(self) -> List[str]: - """Get list of active plugin names.""" - return self.active_plugins.copy() - - def reload_plugin(self, plugin_name: str) -> bool: - """Reload a plugin.""" - was_active = plugin_name in self.active_plugins - - # Deactivate if active - if was_active: - self.deactivate_plugin(plugin_name) - - # Remove from classes - if plugin_name in self.plugin_classes: - del self.plugin_classes[plugin_name] - - # Reload from file - plugins_dir = os.path.join(os.path.dirname(__file__), "plugins") - self._load_plugin_from_file(plugins_dir, plugin_name) - - # Reactivate if it was active - if was_active and plugin_name in self.plugin_classes: - return self.activate_plugin(plugin_name) - - return plugin_name in self.plugin_classes diff --git a/modules/launcher/plugins/__init__.py b/modules/launcher/plugins/__init__.py deleted file mode 100644 index 666274b..0000000 --- a/modules/launcher/plugins/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Built-in plugins for the launcher. -""" diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py deleted file mode 100644 index 832d75b..0000000 --- a/modules/launcher/plugins/applications.py +++ /dev/null @@ -1,185 +0,0 @@ -import json -import re -from typing import List -import subprocess - -from fabric.utils import DesktopApp -from fabric.utils.helpers import get_desktop_applications, get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result -from utils.roam import modus_service - - -class ApplicationsPlugin(PluginBase): - def __init__(self): - super().__init__() - self.display_name = "Applications" - self.description = "Search and launch desktop applications" - - def initialize(self): - pass - - def cleanup(self): - pass - - def _pin_application(self, app): - """Pin an application to the dock.""" - config_path = get_relative_path("../../../config/assets/dock.json") - try: - with open(config_path, "r") as file: - pinned_apps = json.load(file) - except (FileNotFoundError, json.JSONDecodeError): - pinned_apps = [] - - # Handle legacy format (dict with "pinned_apps" key) and convert to new format (simple list) - if isinstance(pinned_apps, dict): - pinned_apps = pinned_apps.get("pinned_apps", []) - - # Check if app is already pinned (by name/app_id) - app_id = app.name # Use app.name as the identifier - if app_id not in pinned_apps: - pinned_apps.append(app_id) - - # Save the updated list - with open(config_path, "w") as file: - json.dump(pinned_apps, file, indent=4) - - # Notify dock about the change via modus_service - if modus_service: - try: - dock_apps_json = json.dumps(pinned_apps) - modus_service.dock_apps = dock_apps_json - except Exception as e: - print(f"Failed to notify dock about pinned app change: {e}") - - def query(self, query_string: str) -> List[Result]: - """Search applications based on query.""" - if not query_string.strip(): - return self._get_all_applications() - - try: - applications = get_desktop_applications(include_hidden=False) - except Exception as e: - print(f"Failed to load applications: {e}") - applications = [] - - query = query_string.lower().strip() - results = [] - - for app in applications: - relevance = self._calculate_relevance(app, query) - if relevance > 0: - description = app.description or app.generic_name or "" - if len(description) > 80: - description = description[:70] + "..." - - result = Result( - title=app.display_name or app.name, - subtitle=description, - icon=app.get_icon_pixbuf(size=48), - action=lambda a=app: self._launch_application(a), - relevance=relevance, - plugin_name=self.display_name, - data={ - "app": app, - "pin_action": lambda a=app: self._pin_application(a), - }, - ) - results.append(result) - - return results - - def _calculate_relevance(self, app, query: str) -> float: - """Calculate relevance score for an application.""" - if not query: - return 0.0 - - # Get searchable text - name = (app.name or "").lower() - display_name = (app.display_name or "").lower() - description = (app.description or "").lower() - generic_name = (app.generic_name or "").lower() - executable = (app.executable or "").lower() - - # Exact matches get highest score - if query == name or query == display_name: - return 1.0 - - # Starts with matches get high score - if ( - name.startswith(query) - or display_name.startswith(query) - or generic_name.startswith(query) - ): - return 0.9 - - # Contains matches get medium score - if ( - query in name - or query in display_name - or query in description - or query in generic_name - ): - return 0.7 - - # Fuzzy matching for partial matches - if self._fuzzy_match(query, name) or self._fuzzy_match(query, display_name): - return 0.5 - - # Executable name matching - if executable and query in executable: - return 0.4 - - return 0.0 - - def _fuzzy_match(self, query: str, text: str) -> bool: - """Simple fuzzy matching algorithm.""" - if not query or not text: - return False - - # Create regex pattern for fuzzy matching - pattern = ".*".join(re.escape(char) for char in query) - return bool(re.search(pattern, text, re.IGNORECASE)) - - def _launch_application(self, app: DesktopApp): - - # Remove ALL % codes (e.g., %u, %U, %f, %F, %i, %c, etc.) - cleaned_command = re.sub(r"%\w+", "", app.command_line).strip() - - # Final command with hyprctl dispatch - final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" - subprocess.Popen(final_command, shell=True) - - # app.launch() - - def _get_all_applications(self) -> List[Result]: - """Get a list of all available applications.""" - try: - applications = get_desktop_applications(include_hidden=False) - except Exception as e: - print(f"Failed to load applications: {e}") - return [] - - results = [] - - for app in applications: - # Truncate description - description = app.description or app.generic_name or "" - if len(description) > 80: - description = description[:70] + "..." - - result = Result( - title=app.display_name or app.name, - subtitle=description, - icon=app.get_icon_pixbuf(size=48), - action=lambda a=app: self._launch_application(a), - relevance=0.5, # Default relevance for all apps - plugin_name=self.display_name, - data={ - "app": app, - "pin_action": lambda a=app: self._pin_application(a), - }, - ) - results.append(result) - - return results diff --git a/modules/launcher/plugins/bash_scripts.py b/modules/launcher/plugins/bash_scripts.py deleted file mode 100644 index 44bb2e8..0000000 --- a/modules/launcher/plugins/bash_scripts.py +++ /dev/null @@ -1,431 +0,0 @@ -import json -import os -import threading -import time -from typing import Dict, List - -import config.data as data -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class BashScriptsPlugin(PluginBase): - """ - Plugin for managing and executing bash scripts. - """ - - def __init__(self): - super().__init__() - self.display_name = "Bash Scripts" - self.description = "Manage and execute bash scripts" - - # Configuration - self.scripts_cache_file = os.path.join(data.CACHE_DIR, "bash_scripts.json") - - # Default script directory to scan (only Modus scripts) - self.modus_scripts_dir = os.path.expanduser("~/.config/Modus/scripts") - - # Scripts to exclude from discovery - self.excluded_scripts = { - "screen-capture.sh" # Exclude screen-capture.sh as it's handled by screencapture plugin - } - - # In-memory cache - self._scripts_cache: Dict[str, Dict] = {} - self._last_cache_update = 0 - self._cache_update_interval = 300 # 5 minutes - - # Background cache building - self._cache_building = False - self._cache_thread = None - - def initialize(self): - """Initialize the bash scripts plugin.""" - self.set_triggers(["sh"]) - self._load_scripts_cache() - self._start_background_cache_update() - - def cleanup(self): - """Cleanup the bash scripts plugin.""" - self._scripts_cache.clear() - if self._cache_thread and self._cache_thread.is_alive(): - # Note: We don't join the thread to avoid blocking cleanup - pass - - def _load_scripts_cache(self): - """Load scripts cache from JSON file.""" - try: - if os.path.exists(self.scripts_cache_file): - with open(self.scripts_cache_file, "r", encoding="utf-8") as f: - cache_data = json.load(f) - self._scripts_cache = cache_data.get("scripts", {}) - self._last_cache_update = cache_data.get("last_update", 0) - else: - print( - "BashScriptsPlugin: No cache file found, will build cache in background" - ) - except Exception as e: - print(f"BashScriptsPlugin: Error loading scripts cache: {e}") - self._scripts_cache = {} - self._last_cache_update = 0 - - def _save_scripts_cache(self): - """Save scripts cache to JSON file.""" - try: - os.makedirs(data.CACHE_DIR, exist_ok=True) - cache_data = { - "scripts": self._scripts_cache, - "last_update": self._last_cache_update, - } - with open(self.scripts_cache_file, "w", encoding="utf-8") as f: - json.dump(cache_data, f, indent=2) - except Exception as e: - print(f"BashScriptsPlugin: Error saving scripts cache: {e}") - - def _start_background_cache_update(self): - """Start background thread to update scripts cache.""" - current_time = time.time() - - # Check if cache needs updating - if ( - current_time - self._last_cache_update > self._cache_update_interval - or not self._scripts_cache - ): - if not self._cache_building: - self._cache_building = True - self._cache_thread = threading.Thread( - target=self._build_scripts_cache_background, daemon=True - ) - self._cache_thread.start() - - def _build_scripts_cache_background(self): - """Build scripts cache in background thread.""" - try: - new_cache = {} - - # Scan Modus scripts directory for discovered scripts - if os.path.exists(self.modus_scripts_dir) and os.path.isdir( - self.modus_scripts_dir - ): - try: - self._scan_directory_for_scripts(self.modus_scripts_dir, new_cache) - except (PermissionError, FileNotFoundError, OSError) as e: - print( - f"BashScriptsPlugin: Error scanning Modus scripts directory: { - e - }" - ) - - # Update cache atomically - self._scripts_cache = new_cache - self._last_cache_update = time.time() - - # Save to disk - self._save_scripts_cache() - - except Exception as e: - print(f"BashScriptsPlugin: Error building scripts cache: {e}") - finally: - self._cache_building = False - - def _scan_directory_for_scripts(self, directory: str, cache: Dict): - """Scan a directory for bash scripts and add them to cache.""" - try: - with os.scandir(directory) as entries: - for entry in entries: - if entry.is_file(follow_symlinks=False): - script_path = entry.path - script_name = entry.name - - # Skip excluded scripts - if script_name in self.excluded_scripts: - continue - - # Check if it's a script file - if self._is_script_file(script_path): - cache[script_name] = { - "path": script_path, - "name": script_name, - "description": self._get_script_description( - script_path - ), - "type": "discovered", - "executable": os.access(script_path, os.X_OK), - "args": [], - "category": os.path.basename(directory), - } - - except (PermissionError, FileNotFoundError, OSError) as e: - print(f"BashScriptsPlugin: Error scanning directory {directory}: {e}") - except Exception as e: - print(f"BashScriptsPlugin: Unexpected error scanning {directory}: {e}") - - def _is_script_file(self, file_path: str) -> bool: - """Check if a file is a bash script.""" - try: - # Check file extension first (most common case) - if file_path.endswith((".sh", ".bash")): - return True - - # For files without extension, check shebang - try: - with open(file_path, "rb") as f: - first_line = f.readline(100).decode("utf-8", errors="ignore") - if first_line.startswith("#!") and ( - "bash" in first_line or "sh" in first_line - ): - return True - except (PermissionError, FileNotFoundError, UnicodeDecodeError): - pass - - return False - except Exception: - return False - - def _get_script_description(self, script_path: str) -> str: - """Extract description from script comments.""" - try: - with open(script_path, "r", encoding="utf-8", errors="ignore") as f: - lines = f.readlines() - - # Look for description in first few comment lines - for line in lines[:10]: - line = line.strip() - if line.startswith("#") and not line.startswith("#!"): - # Remove leading # and whitespace - desc = line[1:].strip() - if desc and len(desc) > 5: # Meaningful description - return desc - - return f"Script: {os.path.basename(script_path)}" - except (PermissionError, FileNotFoundError, UnicodeDecodeError): - return f"Script: {os.path.basename(script_path)}" - - def query(self, query_string: str) -> List[Result]: - """Search for bash scripts matching the query.""" - query = query_string.strip() - - # Start background update if needed (non-blocking) - if not self._scripts_cache or ( - time.time() - self._last_cache_update > self._cache_update_interval - ): - self._start_background_cache_update() - - results = [] - - # Handle special commands - if not query: - # Show all scripts when no query - results.extend(self._list_all_scripts()) - else: - # Search for scripts - results.extend(self._search_scripts(query)) - - return results - - def _list_all_scripts(self) -> List[Result]: - """List all available scripts.""" - results = [] - max_results = 20 - - # Sort scripts by name for consistent ordering - sorted_scripts = sorted( - self._scripts_cache.items(), key=lambda x: x[1].get("name", "") - ) - - # Add scripts (limit to max_results) - for script_name, script_info in sorted_scripts: - script_results = self._create_script_results_with_args( - script_name, script_info, 0.8 - ) - for script_result in script_results: - if len(results) < max_results: - results.append(script_result) - else: - break - if len(results) >= max_results: - break - - return results - - def _search_scripts(self, query: str) -> List[Result]: - """Search for scripts matching the query.""" - results = [] - query_lower = query.lower() - max_results = 15 - - # Categorize matches for better sorting - exact_matches = [] - prefix_matches = [] - partial_matches = [] - description_matches = [] - - for script_name, script_info in self._scripts_cache.items(): - script_name_lower = script_name.lower() - description_lower = script_info.get("description", "").lower() - - # Skip if no match at all - if ( - query_lower not in script_name_lower - and query_lower not in description_lower - ): - continue - - # Categorize matches - if script_name_lower == query_lower: - exact_matches.append((script_name, script_info, 1.0)) - elif script_name_lower.startswith(query_lower): - prefix_matches.append((script_name, script_info, 0.9)) - elif query_lower in script_name_lower: - partial_matches.append((script_name, script_info, 0.7)) - elif query_lower in description_lower: - description_matches.append((script_name, script_info, 0.5)) - - # Combine results in priority order - all_matches = ( - exact_matches + prefix_matches + partial_matches + description_matches - ) - - # Convert to Result objects - for script_name, script_info, relevance in all_matches: - script_results = self._create_script_results_with_args( - script_name, script_info, relevance - ) - for script_result in script_results: - if len(results) < max_results: - results.append(script_result) - else: - break - if len(results) >= max_results: - break - - return results - - def _create_script_result( - self, script_name: str, script_info: Dict, relevance: float - ) -> Result: - """Create a Result object for a script.""" - script_path = script_info.get("path", "") - description = script_info.get("description", "") - script_type = script_info.get("type", "discovered") - executable = script_info.get("executable", False) - category = script_info.get("category", "") - - # Create subtitle with additional info - subtitle_parts = [] - if description: - subtitle_parts.append(description) - if category: - subtitle_parts.append(f"[{category}]") - if not executable: - subtitle_parts.append("(not executable)") - - subtitle = ( - " | ".join(subtitle_parts) if subtitle_parts else f"Execute: {script_name}" - ) - - # Choose icon based on script type and status - if not executable: - icon_name = "gtk-file" - elif script_type == "custom": - icon_name = "folder-script-symbolic" - else: - icon_name = "terminalc" - - return Result( - title=script_name, - subtitle=subtitle, - icon_name=icon_name, - action=self._create_script_action(script_name, script_info), - relevance=relevance, - plugin_name=self.display_name, - data={ - "script_name": script_name, - "script_path": script_path, - "type": script_type, - }, - ) - - def _create_script_results_with_args( - self, script_name: str, script_info: Dict, relevance: float - ) -> List[Result]: - """Create multiple Result objects for scripts that support arguments.""" - results = [] - - # Check for special scripts that need argument variants - if script_name == "hyprpicker.sh": - # For hyprpicker, only show the argument variants (skip basic version) - variants = [ - ("-rgb", "Pick RGB color"), - ("-hex", "Pick HEX color"), - ("-hsv", "Pick HSV color"), - ] - - for arg, desc in variants: - variant_result = Result( - title=f"{script_name} {arg}", - subtitle=f"{desc} | [scripts]", - icon_name="terminal-symbolic", - action=self._create_script_action_with_args( - script_name, script_info, [arg] - ), - relevance=relevance - + 0.1, # Slightly higher relevance for specific variants - plugin_name=self.display_name, - data={ - "script_name": script_name, - "script_path": script_info.get("path", ""), - "type": script_info.get("type", "discovered"), - "args": [arg], - }, - ) - results.append(variant_result) - else: - # For other scripts, create the basic result - basic_result = self._create_script_result( - script_name, script_info, relevance - ) - results.append(basic_result) - - return results - - def _create_script_action(self, script_name: str, script_info: Dict): - """Create an action function for executing a script.""" - - def action(): - self._execute_script(script_name, script_info) - - return action - - def _create_script_action_with_args( - self, script_name: str, script_info: Dict, args: List[str] - ): - """Create an action function for executing a script with specific arguments.""" - - def action(): - # Create a modified script_info with the specific arguments - modified_script_info = script_info.copy() - modified_script_info["args"] = args - self._execute_script(script_name, modified_script_info) - - return action - - def _execute_script(self, script_name: str, script_info: Dict): - """Execute a bash script.""" - try: - script_path = script_info.get("path", "") - script_args = script_info.get("args", []) - - if not os.path.exists(script_path): - return - - if not script_info.get("executable", False): - return - - # Build command - command = [script_path] + script_args - exec_shell_command_async(command) - - except Exception as e: - print(f"BashScriptsPlugin: Error executing script '{script_name}': {e}") diff --git a/modules/launcher/plugins/bookmarks.py b/modules/launcher/plugins/bookmarks.py deleted file mode 100644 index ee55139..0000000 --- a/modules/launcher/plugins/bookmarks.py +++ /dev/null @@ -1,786 +0,0 @@ -import json -import subprocess -import threading -import time -from pathlib import Path -from typing import Dict, List, Optional -from urllib.parse import urlparse - -from thefuzz import fuzz - -from fabric.utils.helpers import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class BookmarkManager: - """Manages user's custom bookmarks.""" - - def __init__(self, storage_file: Path): - self.storage_file = storage_file - self.bookmarks: List[Dict] = [] - self.cache_lock = threading.Lock() - self.last_loaded = 0 - self.cache_ttl = 30 # Cache for 30 seconds - self._load_bookmarks() - - def _get_favicon_url(self, url: str) -> str: - """Generate favicon URL for a given website URL.""" - try: - parsed = urlparse(url) - return f"{parsed.scheme}://{parsed.netloc}/favicon.ico" - except: - return "" - - def _extract_domain(self, url: str) -> str: - """Extract domain from URL.""" - try: - parsed = urlparse(url) - domain = parsed.netloc - # Remove www. prefix - if domain.startswith("www."): - domain = domain[4:] - return domain - except: - return url - - def _normalize_url(self, url: str) -> str: - """Normalize URL by adding protocol if missing.""" - url = url.strip() - if not url.startswith(("http://", "https://")): - if url.startswith("www."): - url = "https://" + url - else: - url = "https://" + url - return url - - def _load_bookmarks(self): - """Load bookmarks from JSON file with caching.""" - with self.cache_lock: - current_time = time.time() - - # Check if cache is still valid - if ( - current_time - self.last_loaded - ) < self.cache_ttl and self.last_loaded > 0: - return - - try: - if self.storage_file.exists(): - with open(self.storage_file, "r", encoding="utf-8") as f: - data = json.load(f) - self.bookmarks = data.get("bookmarks", []) - else: - # File doesn't exist, start with empty list but don't save yet - self.bookmarks = [] - - self.last_loaded = current_time - except Exception as e: - print(f"Error loading bookmarks: {e}") - self.bookmarks = [] - - def get_bookmarks(self) -> List[Dict]: - """Get bookmarks, loading from file if needed.""" - self._load_bookmarks() - return self.bookmarks - - def _save_bookmarks_unlocked(self): - """Save bookmarks to JSON file without acquiring lock.""" - try: - self.storage_file.parent.mkdir(parents=True, exist_ok=True) - data = { - "bookmarks": self.bookmarks, - "last_updated": time.time(), - } - with open(self.storage_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - # Update cache timestamp - self.last_loaded = time.time() - except Exception as e: - print(f"Error saving bookmarks: {e}") - - def _save_bookmarks(self): - """Save bookmarks to JSON file.""" - with self.cache_lock: - self._save_bookmarks_unlocked() - - def add_bookmark( - self, title: str, url: str, description: str = "", tags: List[str] = None - ) -> bool: - """Add a new bookmark.""" - try: - url = self._normalize_url(url) - - # Check if bookmark already exists - for bookmark in self.bookmarks: - if bookmark["url"] == url: - return False # Already exists - - new_bookmark = { - "title": title.strip(), - "url": url, - "description": description.strip(), - "tags": tags or [], - "created": time.time(), - "accessed": 0, - } - - self.bookmarks.append(new_bookmark) - self._save_bookmarks() - - # Clear cache to force reload - self.last_loaded = 0 - - return True - - except Exception as e: - print(f"Error adding bookmark: {e}") - return False - - def remove_bookmark(self, identifier: str) -> bool: - """Remove a bookmark by title or URL.""" - try: - identifier = identifier.lower().strip() - - for i, bookmark in enumerate(self.bookmarks): - if ( - bookmark["title"].lower() == identifier - or bookmark["url"].lower() == identifier - or self._extract_domain(bookmark["url"]).lower() == identifier - ): - self.bookmarks.pop(i) - self._save_bookmarks() - - # Clear cache to force reload - self.last_loaded = 0 - - return True - - return False - - except Exception as e: - print(f"Error removing bookmark: {e}") - return False - - def update_access_time(self, url: str): - """Update the last accessed time for a bookmark.""" - try: - for bookmark in self.bookmarks: - if bookmark["url"] == url: - bookmark["accessed"] = time.time() - self._save_bookmarks() - break - except Exception as e: - print(f"Error updating access time: {e}") - - def get_bookmark_count(self) -> int: - """Get total number of bookmarks.""" - return len(self.get_bookmarks()) - - -class BookmarksPlugin(PluginBase): - """ - User bookmarks plugin for the launcher. - Allows users to add, remove, and search their own bookmarks. - """ - - def __init__(self): - super().__init__() - self.display_name = "Bookmarks" - self.description = "Manage and search your personal bookmarks" - - # Initialize bookmark manager with storage file - self.bookmark_file = Path( - get_relative_path("../../../config/assets/bookmarks.json") - ) - self.bookmark_manager = BookmarkManager(self.bookmark_file) - self.max_results = 15 - - # Cache for results - self._results_cache = {} - self._cache_timestamps = {} - self._cache_ttl = 30 # 30 seconds - - # Launcher instance for refreshing - self._launcher_instance = None - self._original_close_launcher = None - - def initialize(self): - """Initialize the bookmarks plugin.""" - self.set_triggers(["bm"]) - self._setup_launcher_hooks() - - def cleanup(self): - """Cleanup the bookmarks plugin.""" - self._results_cache.clear() - self._cache_timestamps.clear() - self._cleanup_launcher_hooks() - - def query(self, query_string: str) -> List[Result]: - """Process bookmark queries with caching.""" - query_key = query_string.strip() - current_time = time.time() - - # Check cache first (except for add/remove commands which should always execute) - if ( - not query_key.startswith(("add ", "remove ", "delete ", "rm ")) - and query_key in self._results_cache - and (current_time - self._cache_timestamps.get(query_key, 0)) - < self._cache_ttl - ): - return self._results_cache[query_key] - - query = query_key.lower() - results = [] - - if not query: - # Show recent/popular bookmarks when no query - results = self._get_recent_bookmarks() - elif query.startswith("add "): - # Add new bookmark (don't cache) - results = self._handle_add_command(query[4:].strip()) - elif query.startswith(("remove ", "delete ", "rm ")): - # Remove bookmark (don't cache) - command_parts = query_key.split(" ", 1) - if len(command_parts) > 1: - results = self._handle_remove_command(command_parts[1].strip()) - else: - results = self._show_remove_help() - else: - # Search bookmarks - results = self._search_bookmarks(query) - - # Cache results (except for add/remove commands) - if not query.startswith(("add ", "remove ", "delete ", "rm ")): - self._results_cache[query_key] = results - self._cache_timestamps[query_key] = current_time - - return results - - def _search_bookmarks(self, query: str) -> List[Result]: - """Search through bookmarks.""" - bookmarks = self.bookmark_manager.get_bookmarks() - - if not bookmarks: - return [ - Result( - title="No Bookmarks Found", - subtitle="Use 'add <url>' to add first bookmark", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "info", "keep_launcher_open": True}, - ) - ] - - # Search bookmarks - results = [] - for bookmark in bookmarks: - relevance = self._calculate_relevance(bookmark, query) - if relevance > 0.3: # Only show relevant results - result = self._create_bookmark_result(bookmark, relevance) - if result: - results.append(result) - - # Sort by relevance and limit results - results.sort(key=lambda r: r.relevance, reverse=True) - return results[: self.max_results] - - def _get_recent_bookmarks(self) -> List[Result]: - """Get recent/popular bookmarks when no query is provided.""" - bookmarks = self.bookmark_manager.get_bookmarks() - - if not bookmarks: - return [ - Result( - title="No Bookmarks Yet", - subtitle="Use 'add <title> <url>' to add first bookmark", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="Example: Add Google", - subtitle="add Google https://google.com", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "example", "keep_launcher_open": True}, - ), - ] - - # Sort by access time (most recent first) and show top 10 - sorted_bookmarks = sorted( - bookmarks, key=lambda b: b.get("accessed", 0), reverse=True - ) - results = [] - for bookmark in sorted_bookmarks[:10]: - result = self._create_bookmark_result(bookmark, 0.8) - if result: - results.append(result) - - return results - - def _handle_add_command(self, args: str) -> List[Result]: - """Handle add bookmark command.""" - if not args: - return [ - Result( - title="Add Bookmark", - subtitle="Usage: add <title> <url> [description]", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="Example", - subtitle="add Google https://google.com Search engine", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "example", "keep_launcher_open": True}, - ), - ] - - # Parse arguments: title url [description] - parts = args.split() - if len(parts) < 2: - return [ - Result( - title="Invalid Format", - subtitle="Usage: add <title> <url> [description]", - icon_name="alert", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - title = parts[0] - url = parts[1] - description = " ".join(parts[2:]) if len(parts) > 2 else "" - - # Check if bookmark already exists - normalized_url = self.bookmark_manager._normalize_url(url) - existing_bookmarks = self.bookmark_manager.get_bookmarks() - already_exists = any( - bookmark["url"] == normalized_url for bookmark in existing_bookmarks - ) - - if already_exists: - # Truncate URL for display to prevent launcher resize - display_url = normalized_url - if len(display_url) > 35: - display_url = display_url[:32] + "..." - - return [ - Result( - title="Bookmark Already Exists", - subtitle=f"URL '{display_url}' already exists", - icon_name="alert", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - # Show add action - will execute on Enter - domain = self.bookmark_manager._extract_domain(normalized_url) - - # Truncate domain if too long - if len(domain) > 25: - domain = domain[:22] + "..." - - subtitle = f"Click to add: {domain}" - if description: - # Truncate description to prevent launcher resize - max_desc_len = 35 - len(domain) # Account for domain + separator - if len(description) > max_desc_len: - description = description[: max_desc_len - 3] + "..." - subtitle += f" โ€ข {description}" - - # Truncate title for display - display_title = title - if len(display_title) > 25: - display_title = display_title[:22] + "..." - - return [ - Result( - title=f"Add bookmark '{display_title}'", - subtitle=subtitle, - icon_name="plus", - action=lambda: self._add_bookmark_action(title, url, description), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "add", "name": title, "keep_launcher_open": True}, - ) - ] - - def _handle_remove_command(self, identifier: str) -> List[Result]: - """Handle remove bookmark command.""" - if not identifier: - return self._show_remove_help() - - # Find matching bookmarks - bookmarks = self.bookmark_manager.get_bookmarks() - identifier_lower = identifier.lower().strip() - - matching_bookmarks = [] - for bookmark in bookmarks: - if ( - bookmark["title"].lower() == identifier_lower - or bookmark["url"].lower() == identifier_lower - or self.bookmark_manager._extract_domain(bookmark["url"]).lower() - == identifier_lower - ): - matching_bookmarks.append(bookmark) - - if not matching_bookmarks: - return [ - Result( - title="Bookmark Not Found", - subtitle=f"No bookmark found matching '{identifier}'", - icon_name="alert", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - # Show remove action - will execute on Enter - bookmark = matching_bookmarks[0] # Take first match - title = bookmark.get("title", "Untitled") - domain = self.bookmark_manager._extract_domain(bookmark.get("url", "")) - - # Truncate title and domain for display - display_title = title - if len(display_title) > 25: - display_title = display_title[:22] + "..." - - display_domain = domain - if len(display_domain) > 30: - display_domain = display_domain[:27] + "..." - - return [ - Result( - title=f"Remove '{display_title}'?", - subtitle=f"Click to confirm: {display_domain}", - icon_name="trash", - action=lambda: self._remove_bookmark_action(identifier), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "remove", "name": title, "keep_launcher_open": True}, - ) - ] - - def _show_remove_help(self) -> List[Result]: - """Show help for remove command.""" - bookmarks = self.bookmark_manager.get_bookmarks() - results = [ - Result( - title="Remove Bookmark", - subtitle="Usage: remove <title|url|domain>", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ] - - # Show available bookmarks to remove - if bookmarks: - results.append( - Result( - title="Available Bookmarks:", - subtitle=f"{len(bookmarks)} bookmarks available to remove", - icon_name="bookmarks-organize", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "info", "keep_launcher_open": True}, - ) - ) - - # Show first few bookmarks as examples - for bookmark in bookmarks[:3]: - title = bookmark.get("title", "Untitled") - domain = self._extract_domain(bookmark.get("url", "")) - - # Truncate title and domain for consistent display - display_title = title - if len(display_title) > 20: - display_title = display_title[:17] + "..." - - display_domain = domain - if len(display_domain) > 20: - display_domain = display_domain[:17] + "..." - - results.append( - Result( - title=f"remove {display_title}", - subtitle=f"Click to remove: {display_title} ({display_domain})", - icon_name="trash", - action=lambda t=title: self._remove_bookmark_action(t), - relevance=0.8, - plugin_name=self.display_name, - data={ - "type": "remove_option", - "bookmark": bookmark, - "keep_launcher_open": True, - }, - ) - ) - - return results - - def _add_bookmark_action(self, title: str, url: str, description: str = ""): - """Execute the add bookmark action.""" - success = self.bookmark_manager.add_bookmark(title, url, description) - if success: - print(f"โœ“ Added bookmark '{title}' - {url}") - # Clear cache to force refresh - self._results_cache.clear() - self._cache_timestamps.clear() - # Reset to trigger word and refresh - self._reset_to_trigger() - else: - print(f"โœ— Failed to add bookmark '{title}' - already exists") - - def _remove_bookmark_action(self, identifier: str): - """Execute the remove bookmark action.""" - success = self.bookmark_manager.remove_bookmark(identifier) - if success: - print(f"โœ“ Removed bookmark '{identifier}'") - # Clear cache to force refresh - self._results_cache.clear() - self._cache_timestamps.clear() - # Reset to trigger word and refresh - self._reset_to_trigger() - else: - print(f"โœ— Failed to remove bookmark '{identifier}' - not found") - - def _remove_bookmark_with_reset(self, identifier: str): - """Execute the remove bookmark action via alt_action (Shift+Enter) and reset to trigger.""" - success = self.bookmark_manager.remove_bookmark(identifier) - if success: - print(f"โœ“ Removed bookmark '{identifier}'") - # Clear cache to force refresh - self._results_cache.clear() - self._cache_timestamps.clear() - # Reset to trigger word and refresh - self._reset_to_trigger() - else: - print(f"โœ— Failed to remove bookmark '{identifier}' - not found") - - def _calculate_relevance(self, bookmark: Dict, query: str) -> float: - """Calculate relevance score for a bookmark.""" - title = bookmark.get("title", "").lower() - url = bookmark.get("url", "").lower() - description = bookmark.get("description", "").lower() - - # Exact title match - if query == title: - return 1.0 - - # Title starts with query - if title.startswith(query): - return 0.95 - - # Query in title - if query in title: - position = title.index(query) - position_score = 1.0 - (position / len(title)) - return 0.8 + (position_score * 0.1) - - # Query in URL - if query in url: - return 0.7 - - # Query in description - if query in description: - return 0.6 - - # Fuzzy matching for title - if len(query) >= 3: - fuzzy_score = fuzz.partial_ratio(query, title) / 100.0 - if fuzzy_score >= 0.7: - return fuzzy_score * 0.6 - - return 0.0 - - def _create_bookmark_result( - self, bookmark: Dict, relevance: float - ) -> Optional[Result]: - """Create a Result object for a bookmark.""" - try: - title = bookmark.get("title", "Untitled") - url = bookmark.get("url", "") - description = bookmark.get("description", "") - - # Truncate long titles to prevent launcher resize - if len(title) > 45: - title = title[:42] + "..." - - # Create subtitle with domain and description - domain = self._extract_domain(url) - - # Truncate domain if too long - if len(domain) > 30: - domain = domain[:27] + "..." - - if description: - # Truncate description to prevent launcher resize - # Account for domain + separator - max_desc_len = 50 - len(domain) - if len(description) > max_desc_len: - description = description[: max_desc_len - 3] + "..." - subtitle = f"{domain} โ€ข {description}" - else: - subtitle = domain - - # Final subtitle length check to ensure consistent launcher size - if len(subtitle) > 60: - subtitle = subtitle[:57] + "..." - - return Result( - title=title, - subtitle=subtitle, - icon_name="bookmark-organize", - action=lambda u=url: self._open_bookmark(u), - relevance=relevance, - plugin_name=self.display_name, - data={ - "type": "bookmark", - "url": url, - "domain": domain, - "description": description, - "keep_launcher_open": False, - "alt_action": lambda t=title: self._remove_bookmark_with_reset(t), - }, - ) - except Exception as e: - print(f"Error creating bookmark result: {e}") - return None - - def _extract_domain(self, url: str) -> str: - """Extract domain from URL.""" - try: - parsed = urlparse(url) - domain = parsed.netloc - # Remove www. prefix - if domain.startswith("www."): - domain = domain[4:] - return domain - except: - return url - - def _open_bookmark(self, url: str): - """Open bookmark URL in default browser and update access time.""" - try: - # Update access time - self.bookmark_manager.update_access_time(url) - - # Open URL - subprocess.Popen( - ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - except Exception as e: - print(f"Failed to open bookmark: {e}") - - def _setup_launcher_hooks(self): - """Setup hooks to monitor launcher state.""" - try: - # Try to find the launcher instance - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "close_launcher") - ): - self._launcher_instance = obj - break - except Exception as e: - print(f"Warning: Could not setup launcher hooks: {e}") - - def _cleanup_launcher_hooks(self): - """Cleanup launcher hooks.""" - try: - self._launcher_instance = None - except Exception as e: - print(f"Warning: Could not cleanup launcher hooks: {e}") - - def _reset_to_trigger(self): - """Reset launcher to trigger word and refresh.""" - try: - if self._launcher_instance and hasattr( - self._launcher_instance, "search_entry" - ): - # Get the current trigger (bookmark or bm) - current_text = self._launcher_instance.search_entry.get_text() - trigger = "bookmark " - - # Determine which trigger was used - if current_text.lower().startswith("bm "): - trigger = "bm " - - # Reset to trigger word with space - try: - from gi.repository import GLib - - def reset_and_refresh(): - # Set text to trigger word - self._launcher_instance.search_entry.set_text(trigger) - # Position cursor at end - self._launcher_instance.search_entry.set_position(-1) - # Trigger search to show default bookmarks - self._launcher_instance._perform_search(trigger) - return False - - GLib.timeout_add(50, reset_and_refresh) - except ImportError: - # Fallback: direct call if GLib not available - self._launcher_instance.search_entry.set_text(trigger) - self._launcher_instance.search_entry.set_position(-1) - self._launcher_instance._perform_search(trigger) - except Exception as e: - print(f"Could not reset to trigger: {e}") - - def _force_launcher_refresh(self): - """Force the launcher to refresh and show updated results.""" - try: - if self._launcher_instance and hasattr( - self._launcher_instance, "_perform_search" - ): - # Get current search text - current_text = "" - if hasattr(self._launcher_instance, "search_entry"): - current_text = self._launcher_instance.search_entry.get_text() - - # Trigger a search to refresh results - try: - from gi.repository import GLib - - def refresh(): - self._launcher_instance._perform_search(current_text) - return False - - GLib.timeout_add(50, refresh) - except ImportError: - # Fallback: direct call if GLib not available - self._launcher_instance._perform_search(current_text) - except Exception as e: - print(f"Could not force launcher refresh: {e}") diff --git a/modules/launcher/plugins/caffeine.py b/modules/launcher/plugins/caffeine.py deleted file mode 100644 index decde97..0000000 --- a/modules/launcher/plugins/caffeine.py +++ /dev/null @@ -1,208 +0,0 @@ -import subprocess -from threading import Timer -from typing import List - -import gi - -import config.data as data -from fabric.utils import get_relative_path -from fabric.utils.helpers import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - -gi.require_version("Gtk", "3.0") - - -class CaffeinePlugin(PluginBase): - """ - Plugin for preventing system idle using the inhibit script. - """ - - def __init__(self): - super().__init__() - self.display_name = "Caffeine" - self.description = "Prevent system from going idle" - self.inhibit_script = get_relative_path("../../../utils/inhibit.py") - - # Predefined durations - self.durations = { - "30m": "30 minutes", - "1h": "1 hour", - "2h": "2 hours", - "4h": "4 hours", - "8h": "8 hours", - "on": "On", - "off": "Off", - } - - def initialize(self): - """Initialize the caffeine plugin.""" - self.set_triggers(["caffeine"]) - - def cleanup(self): - """Cleanup the caffeine plugin.""" - pass - - def _create_inhibit_action(self, duration: str): - """Create an inhibit action that properly captures the duration.""" - - def action(): - try: - # Run the script in the background without waiting - subprocess.Popen( - ["python3", self.inhibit_script, duration], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, # Run in a new session to prevent hanging - ) - - # Send notification based on duration - if duration.lower() == "off": - # Deactivation notification - exec_shell_command_async( - f"notify-send 'โ˜• Caffeine' 'Deactivated' -a '{ - data.APP_NAME_CAP - }' -e" - ) - else: - # Activation notification with duration - duration_text = self.durations.get(duration, duration) - exec_shell_command_async( - f"notify-send 'โ˜• Caffeine' 'Activated for { - duration_text - }' -a '{data.APP_NAME_CAP}' -e" - ) - - # Schedule expiration notification for timed durations - if duration.lower() not in ["on", "off"]: - self._schedule_expiration_notification(duration, duration_text) - - except Exception as e: - print(f"Error starting inhibit script: {e}") - - return action - - def _parse_duration_to_seconds(self, duration_str: str) -> int: - """Parse duration string into seconds. Same logic as inhibit.py""" - try: - if duration_str.lower() in ["on", "off"]: - return 0 - elif duration_str.endswith("h"): - return int(float(duration_str[:-1]) * 3600) - elif duration_str.endswith("m"): - return int(float(duration_str[:-1]) * 60) - elif duration_str.endswith("s"): - return int(float(duration_str[:-1])) - else: - return int(duration_str) - except ValueError: - return 0 - - def _schedule_expiration_notification(self, duration: str, duration_text: str): - """Schedule a notification for when the caffeine effect expires.""" - seconds = self._parse_duration_to_seconds(duration) - if seconds > 0: - - def send_expiration_notification(): - exec_shell_command_async( - f"notify-send 'โ˜• Caffeine' 'Expired after {duration_text}' -a '{ - data.APP_NAME_CAP - }' -e" - ) - - # Schedule the notification - timer = Timer(seconds, send_expiration_notification) - timer.daemon = True # Don't prevent program exit - timer.start() - - def _is_valid_duration(self, query: str) -> bool: - """Check if the query is a valid duration format.""" - if query in self.durations: - return True - if query.isdigit(): - return True - if query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit(): - return True - return False - - def _get_default_action(self, query: str): - """Get the default action for a direct duration input.""" - if self._is_valid_duration(query): - return self._create_inhibit_action(query) - return None - - def query(self, query_string: str) -> List[Result]: - """Search caffeine durations based on query.""" - # For empty queries, show all available durations - if not query_string.strip(): - query_string = "" # Will match all durations in the loop below - - query = query_string.lower().strip() - results = [] - - # Handle direct search entry (e.g., "caffeine 30m" or just "30m") - if query.startswith("caffeine "): - query = query[9:].strip() # Remove "caffeine " prefix - # If query becomes empty after removing prefix, show all durations - if not query: - query = "" # Will match all durations in the loop below - elif query == "caffeine": - # Handle just "caffeine" without space - show all durations - query = "" - elif not query: - # Handle empty query - show all durations - pass - elif ( - query in self.durations - or query.isdigit() - or ( - query.endswith(("h", "m", "s")) - and query[:-1].replace(".", "").isdigit() - ) - ): - # If query is a valid duration without prefix, use it directly - pass - else: - return [] - - # Add custom duration option - if query.isdigit() or ( - query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit() - ): - result = Result( - title=f"Custom: {query}", - subtitle="Set custom duration", - icon_name="caffeine", - action=self._create_inhibit_action(query), - relevance=1.0, - plugin_name=self.display_name, - data={"duration": query}, - ) - results.append(result) - - # Add predefined durations - for duration, description in self.durations.items(): - # If query is empty, show all durations; otherwise filter by query - if not query or query in duration or query in description.lower(): - # Special handling for "off" - it should stop inhibition - if duration.lower() == "off": - subtitle = "Stop idle inhibition" - else: - subtitle = f"Prevent idle for {description.lower()}" - - result = Result( - title=description, - subtitle=subtitle, - icon_name="caffeine", - action=self._create_inhibit_action(duration), - relevance=0.9 if query == duration else 0.7, - plugin_name=self.display_name, - data={"duration": duration}, - ) - results.append(result) - - # Set default action for direct duration input - if results: - results[0].default_action = self._get_default_action(query) - - return results diff --git a/modules/launcher/plugins/calculator.py b/modules/launcher/plugins/calculator.py deleted file mode 100644 index f97dddb..0000000 --- a/modules/launcher/plugins/calculator.py +++ /dev/null @@ -1,163 +0,0 @@ -import math -import re -import subprocess -import time -from typing import List - -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result -from utils.conversion import Conversion - - -class CalculatorPlugin(PluginBase): - """ - Plugin for calculating mathematical expressions and converting units. - """ - - def __init__(self): - super().__init__() - self.display_name = "Calculator" - self.description = "Evaluate mathematical expressions and convert units" - - # Safe functions for evaluation - self.safe_functions = { - "abs": abs, - "round": round, - "min": min, - "max": max, - "sum": sum, - "pow": pow, - "sqrt": math.sqrt, - "sin": math.sin, - "cos": math.cos, - "tan": math.tan, - "asin": math.asin, - "acos": math.acos, - "atan": math.atan, - "log": math.log, - "log10": math.log10, - "exp": math.exp, - "pi": math.pi, - "e": math.e, - } - - # Initialize conversion utility - self.converter = Conversion() - - # Pre-compiled regex patterns - self.expression_pattern = re.compile(r"[\d+\-*/^()=]") - self.number_pattern = re.compile(r"\d") - self.conversion_pattern = re.compile( - r"(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(?:to|in|=)\s*([a-zA-Z]+)" - ) - - # Cache for conversion results - self._conversion_cache = {} - self._last_cache_cleanup = time.time() - self._cache_cleanup_interval = 300 # 5 minutes - - def initialize(self): - """Initialize the files plugin.""" - self.set_triggers(["="]) - - def _cleanup_cache(self): - """Clean up old cache entries.""" - current_time = time.time() - if current_time - self._last_cache_cleanup > self._cache_cleanup_interval: - self._conversion_cache.clear() - self._last_cache_cleanup = current_time - - def query(self, query: str) -> List[Result]: - """Process a query and return results.""" - if not query: - return [] - - # Clean up cache periodically - self._cleanup_cache() - - # Check if it's a conversion query - conversion_match = self.conversion_pattern.match(query) - if conversion_match: - try: - value, from_unit, to_unit = conversion_match.groups() - value = float(value) - - # Check cache first - cache_key = f"{value}_{from_unit}_{to_unit}" - if cache_key in self._conversion_cache: - result = self._conversion_cache[cache_key] - subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" - else: - # Use the conversion utility - result = self.converter.convert(value, from_unit, to_unit) - # Cache the result - self._conversion_cache[cache_key] = result - subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" - - return [ - Result( - title=f"{result:.6g} {to_unit}", - subtitle=subtitle, - icon_name="calculator-symbolic", - action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), - relevance=1.0, - plugin_name=self.display_name, - data={"from": (value, from_unit), "to": (result, to_unit)}, - ) - ] - except ValueError as e: - return [ - Result( - title="Invalid conversion", - subtitle=str(e), - icon_name="calculator-symbolic", - relevance=0.0, - plugin_name=self.display_name, - ) - ] - - # Check if it's a math expression - if self.expression_pattern.search(query): - try: - # Evaluate the expression - result = eval(query, {"__builtins__": {}}, self.safe_functions) - if isinstance(result, (int, float)): - return [ - Result( - title=f"{result:.6g}", - subtitle=f"{query} = {result:.6g}", - icon_name="calculator-symbolic", - action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), - relevance=1.0, - plugin_name=self.display_name, - ) - ] - except Exception: - pass - - return [] - - def _format_cache_age(self, age_seconds: float) -> str: - """Format cache age for display.""" - if age_seconds < 60: - return f"{int(age_seconds)}s ago" - elif age_seconds < 3600: - return f"{int(age_seconds // 60)}m ago" - else: - return f"{int(age_seconds // 3600)}h ago" - - def _copy_to_clipboard(self, text: str): - """Copy text to clipboard using cliphist.""" - try: - # First copy to clipboard - subprocess.run(["wl-copy"], input=text.encode(), check=True) - # Then store in cliphist - subprocess.run(["cliphist", "store"], input=text.encode(), check=True) - except subprocess.CalledProcessError: - # If cliphist fails, at least we have the text in clipboard - pass - - def cleanup(self): - """Clean up resources.""" - self._conversion_cache.clear() - self.converter.cleanup() diff --git a/modules/launcher/plugins/clipboard.py b/modules/launcher/plugins/clipboard.py deleted file mode 100644 index e332d19..0000000 --- a/modules/launcher/plugins/clipboard.py +++ /dev/null @@ -1,438 +0,0 @@ -import os -import subprocess -import sys -import tempfile -import threading -import time -from concurrent.futures import ThreadPoolExecutor -from typing import Dict, List, Optional - -from gi.repository import GdkPixbuf, GLib - -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class ClipboardPlugin(PluginBase): - def __init__(self): - super().__init__() - self.name = "clipboard" - self.display_name = "Clipboard History" - self.description = "Search and manage clipboard history using cliphist" - - # Performance settings - show more history like example_cliphist.py - self.max_results = 50 - # Cache clipboard items for 5 seconds (more responsive) - self.cache_ttl = 5 - - # Initialize cache and temp directory - self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-") - # Cache images forever like example_cliphist.py - self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {} - self.clipboard_items_cache: List[str] = [] - self.cache_timestamp = 0 - - # Threading - self.executor = ThreadPoolExecutor( - max_workers=2, thread_name_prefix="clipboard" - ) - self.cache_lock = threading.Lock() - - # State tracking - self._loading = False - self._pending_updates = False - - def initialize(self): - """Initialize the plugin.""" - self.set_triggers(["clip"]) - try: - subprocess.run( - ["cliphist", "list"], capture_output=True, check=True, timeout=5 - ) - except (subprocess.SubprocessError, FileNotFoundError): - raise RuntimeError("cliphist is not installed or not working properly") - - # Pre-warm cache in background - self.executor.submit(self._load_clipboard_items_cached) - - def cleanup(self): - """Cleanup the plugin.""" - try: - # Shutdown executor - if hasattr(self, "executor"): - self.executor.shutdown(wait=False) - - # Clean up temp files - if os.path.exists(self.tmp_dir): - import shutil - - shutil.rmtree(self.tmp_dir) - - # Clear caches - with self.cache_lock: - self.image_cache.clear() - self.clipboard_items_cache.clear() - except Exception as e: - print(f"Error cleaning up temporary files: {e}", file=sys.stderr) - - def invalidate_cache(self): - """Force invalidation of the clipboard cache.""" - with self.cache_lock: - self.clipboard_items_cache.clear() - self.cache_timestamp = 0 - # Keep image cache forever like example_cliphist.py - - def _force_launcher_refresh(self): - """Force the launcher to refresh its results.""" - try: - from gi.repository import GLib - - def trigger_refresh(): - try: - # Try to access the launcher through the fabric Application - from fabric import Application - - app = Application.get_default() - - if app and hasattr(app, "launcher"): - launcher = app.launcher - if ( - launcher - and hasattr(launcher, "search_entry") - and hasattr(launcher, "_perform_search") - ): - # Get current search text to preserve the query - current_text = launcher.search_entry.get_text() - # Trigger the search to refresh results - launcher._perform_search(current_text) - return False - - # Fallback: try to find launcher instance through other means - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - ): - if hasattr(obj, "search_entry") and hasattr( - obj, "_perform_search" - ): - current_text = obj.search_entry.get_text() - obj._perform_search(current_text) - return False - - except Exception as e: - print(f"Error forcing launcher refresh: {e}") - - return False # Don't repeat - - # Use immediate refresh - GLib.timeout_add(10, trigger_refresh) - - except Exception as e: - print(f"Could not trigger refresh: {e}") - - def _load_clipboard_items_cached(self) -> List[str]: - """Load clipboard items from cliphist with caching and change detection.""" - current_time = time.time() - - # Always load fresh data to check for changes - try: - result = subprocess.run( - ["cliphist", "list"], capture_output=True, check=True, timeout=5 - ) - stdout_str = result.stdout.decode("utf-8", errors="replace") - if stdout_str.strip(): - lines = stdout_str.strip().split("\n") - items = [ - line for line in lines if line and "<meta http-equiv" not in line - ] - else: - items = [] - - # Check if data has changed - with self.cache_lock: - data_changed = ( - not self.clipboard_items_cache - or len(items) != len(self.clipboard_items_cache) - or items != self.clipboard_items_cache - ) - - # If cache is still valid and data hasn't changed, return cached data - if ( - not data_changed - and self.clipboard_items_cache - and current_time - self.cache_timestamp < self.cache_ttl - ): - return self.clipboard_items_cache.copy() - - # Update cache with fresh data - self.clipboard_items_cache = items - self.cache_timestamp = current_time - - # Keep image cache forever like example_cliphist.py - don't clear on data changes - - return items - except subprocess.CalledProcessError as e: - print(f"Error loading clipboard history: {e}", file=sys.stderr) - return [] - except Exception as e: - print(f"Unexpected error: {e}", file=sys.stderr) - return [] - - def _load_clipboard_items(self) -> List[str]: - """Load clipboard items (legacy method for compatibility).""" - return self._load_clipboard_items_cached() - - def _create_pixbuf_from_bytes( - self, image_data: bytes, max_size: int = 100 - ) -> GdkPixbuf.Pixbuf: - """Create a GdkPixbuf from image bytes with size limit.""" - try: - loader = GdkPixbuf.PixbufLoader() - loader.write(image_data) - loader.close() - pixbuf = loader.get_pixbuf() - - # Scale image if needed - width, height = pixbuf.get_width(), pixbuf.get_height() - if width > height: - new_width = max_size - new_height = int(height * (max_size / width)) - else: - new_height = max_size - new_width = int(width * (max_size / height)) - - return pixbuf.scale_simple( - new_width, new_height, GdkPixbuf.InterpType.BILINEAR - ) - except GLib.Error: - return None - - def _is_image_data(self, content: str) -> bool: - """Determine if clipboard content is likely an image (like example_cliphist.py).""" - return ( - content.startswith("data:image/") - or content.startswith("\x89PNG") - or content.startswith("GIF8") - or content.startswith("\xff\xd8\xff") - or "binary" in content.lower() - and any( - ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"] - ) - ) - - def _get_text_preview(self, content: str) -> str: - """Get a text preview of the content.""" - if len(content) > 50: - return content[:37] + "..." - return content - - def _load_image_preview_cached(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]: - """Load image preview with forever caching (like example_cliphist.py).""" - # Check cache first - cache forever like example_cliphist.py - with self.cache_lock: - if item_id in self.image_cache: - return self.image_cache[item_id] - - try: - result = subprocess.run( - ["cliphist", "decode", item_id], - capture_output=True, - check=True, - timeout=3, - ) - pixbuf = self._create_pixbuf_from_bytes(result.stdout) - if pixbuf: - with self.cache_lock: - self.image_cache[item_id] = pixbuf - - return pixbuf - except Exception as e: - print(f"Error loading image preview: {e}", file=sys.stderr) - return None - - def _load_image_preview_async(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]: - """Load image preview (legacy method for compatibility).""" - return self._load_image_preview_cached(item_id) - - def query(self, query_string: str) -> List[Result]: - """Search clipboard history using cliphist with optimized performance.""" - results = [] - - # Handle query string - if query_string.lower() == "clip": - query_string = "" # Show all items - - try: - # Load clipboard items from cache - clipboard_items = self._load_clipboard_items_cached() - - # Early exit if no items - if not clipboard_items: - return results - - # Filter items based on query - search through ALL items first - filtered_items = [] - query_lower = query_string.lower() if query_string else "" - - for item in clipboard_items: - parts = item.split("\t", 1) - content = parts[1] if len(parts) > 1 else item - content_lower = content.lower() - - # Fast filtering - search through all items, don't limit here - if not query_lower or query_lower in content_lower: - # Calculate relevance score for better sorting - relevance = 1.0 - if query_lower: - if content_lower.startswith(query_lower): - relevance = 1.0 # Exact start match - elif query_lower in content_lower: - # Position-based scoring: earlier matches get higher scores - position = content_lower.find(query_lower) - relevance = max( - 0.5, 1.0 - (position / len(content_lower)) * 0.4 - ) - - filtered_items.append((item, relevance)) - - # Sort by relevance (highest first) and then limit results - filtered_items.sort(key=lambda x: x[1], reverse=True) - filtered_items = filtered_items[: self.max_results] - - # Process items with lazy image loading - for i, (item, relevance) in enumerate(filtered_items): - parts = item.split("\t", 1) - item_id = parts[0] if len(parts) > 1 else str(i) - content = parts[1] if len(parts) > 1 else item - - # Handle image content like example_cliphist.py - if self._is_image_data(content): - # Check if image is already cached (forever cache like example_cliphist.py) - cached_pixbuf = None - with self.cache_lock: - cached_pixbuf = self.image_cache.get(item_id) - - if cached_pixbuf: - # Use cached image immediately - result = Result( - title="Image from clipboard", - subtitle="Click to copy image to clipboard", - description="Image content", - icon=cached_pixbuf, - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard(id), - data={"bypass_max_results": True}, - ) - else: - # Try to load image immediately like example_cliphist.py - try: - immediate_pixbuf = self._load_image_preview_cached(item_id) - if immediate_pixbuf: - # Successfully loaded immediately - result = Result( - title="Image from clipboard", - subtitle="Click to copy image to clipboard", - description="Image content", - icon=immediate_pixbuf, - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard( - id - ), - data={"bypass_max_results": True}, - ) - else: - # Show placeholder if loading failed - result = Result( - title="Image from clipboard", - subtitle="Click to copy image to clipboard", - description="Image content", - icon_name="image-x-generic", - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard( - id - ), - data={"bypass_max_results": True}, - ) - except Exception: - # Fallback to placeholder - result = Result( - title="Image from clipboard", - subtitle="Click to copy image to clipboard", - description="Image content", - icon_name="image-x-generic", - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard(id), - data={"bypass_max_results": True}, - ) - - results.append(result) - continue - - # Handle text content - display_text = self._get_text_preview(content) - result = Result( - title=display_text, - subtitle="Text from clipboard", - description=( - content if len(content) <= 100 else content[:97] + "..." - ), - icon_name="edit-paste", - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard(id), - data={"bypass_max_results": True}, - ) - results.append(result) - - except Exception as e: - # Handle errors gracefully - results.append( - Result( - title="Error accessing clipboard history", - subtitle=str(e), - icon_name="dialog-error", - relevance=0.0, - plugin_name=self.name, - data={"bypass_max_results": True}, - ) - ) - - return results - - def _copy_to_clipboard(self, entry_id: str): - """Copy entry to clipboard using cliphist with timeout.""" - try: - result = subprocess.run( - ["cliphist", "decode", entry_id], - capture_output=True, - check=True, - timeout=5, - ) - # Use wl-copy for Wayland or xclip for X11 - try: - subprocess.run(["wl-copy"], input=result.stdout, check=True, timeout=3) - except subprocess.SubprocessError: - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=result.stdout, - check=True, - timeout=3, - ) - - # Don't invalidate image cache - keep images cached forever like example_cliphist.py - # Only invalidate clipboard items cache - with self.cache_lock: - self.clipboard_items_cache.clear() - self.cache_timestamp = 0 - - except subprocess.SubprocessError as e: - print(f"Error copying to clipboard: {e}", file=sys.stderr) - except subprocess.TimeoutExpired as e: - print(f"Timeout copying to clipboard: {e}", file=sys.stderr) diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py deleted file mode 100644 index 8842e80..0000000 --- a/modules/launcher/plugins/emoji.py +++ /dev/null @@ -1,187 +0,0 @@ -import json -import os -import subprocess -import time -from collections import OrderedDict -from typing import Dict, List - -import config.data as data -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class EmojiPlugin(PluginBase): - """ - Plugin for searching and copying emojis. - """ - - def __init__(self): - super().__init__() - self.name = "emoji" - self.display_name = "Emoji" - self.description = "Search and copy emojis" - self.emoji_data = {} - self.emoji_path = get_relative_path("../../../config/assets/emoji.json") - - # Use cache directory for recent emojis (save directly in cache dir) - self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json") - self.recent_emojis = OrderedDict() - self.max_recent_emojis = 20 # Maximum number of recent emojis to track - - def initialize(self): - """Initialize the emoji plugin.""" - self.set_triggers(["em"]) - self._load_emoji_data() - self._load_recent_emojis() - - def cleanup(self): - """Cleanup the emoji plugin.""" - pass - - def _load_emoji_data(self): - """Load emoji data from JSON file.""" - try: - if os.path.exists(self.emoji_path): - with open(self.emoji_path, "r", encoding="utf-8") as f: - self.emoji_data = json.load(f) - else: - print(f"Emoji file not found: {self.emoji_path}") - except Exception as e: - print(f"Error loading emoji data: {e}") - - def _load_recent_emojis(self): - """Load recently used emojis from JSON file.""" - try: - if os.path.exists(self.recent_emoji_path): - with open(self.recent_emoji_path, "r", encoding="utf-8") as f: - recent_data = json.load(f) - # Convert to OrderedDict to maintain order - self.recent_emojis = OrderedDict(recent_data) - else: - # Create empty recent emojis file - self.recent_emojis = OrderedDict() - self._save_recent_emojis() - except Exception as e: - print(f"Error loading recent emoji data: {e}") - self.recent_emojis = OrderedDict() - - def _save_recent_emojis(self): - """Save recently used emojis to JSON file.""" - try: - # Ensure the cache directory exists - os.makedirs(data.CACHE_DIR, exist_ok=True) - - with open(self.recent_emoji_path, "w", encoding="utf-8") as f: - json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2) - except Exception as e: - print(f"Error saving recent emoji data: {e}") - - def _add_to_recent(self, emoji: str): - """Add an emoji to the recent list.""" - # Remove if already exists (to move it to front) - if emoji in self.recent_emojis: - del self.recent_emojis[emoji] - - # Add to front with current timestamp - self.recent_emojis[emoji] = time.time() - - # Keep only the most recent emojis - while len(self.recent_emojis) > self.max_recent_emojis: - # Remove the oldest item - self.recent_emojis.popitem(last=False) - - # Save to file - self._save_recent_emojis() - - def _copy_to_clipboard(self, emoji: str): - """Copy emoji to clipboard and track usage.""" - try: - # Try Wayland first - try: - subprocess.run(["wl-copy"], input=emoji.encode(), check=True) - except subprocess.SubprocessError: - # Fall back to X11 - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=emoji.encode(), - check=True, - ) - - # Track this emoji as recently used - self._add_to_recent(emoji) - - except Exception as e: - print(f"Failed to copy to clipboard: {e}") - - def query(self, query_string: str) -> List[Result]: - """Search emojis based on query.""" - results = [] - query = query_string.lower().strip() - - # If no query, show recently used emojis - if not query: - if self.recent_emojis: - # Show recent emojis in reverse order (most recent first) - for emoji in reversed(list(self.recent_emojis.keys())): - if emoji in self.emoji_data: - emoji_info = self.emoji_data[emoji] - results.append( - self._create_emoji_result(emoji, emoji_info, 1.0) - ) - else: - # If no recent emojis, show some popular ones as fallback - popular_emojis = ["๐Ÿ˜€", "๐Ÿ‘", "โค๏ธ", "๐ŸŽ‰", "๐Ÿ”ฅ", "โœจ", "๐Ÿš€", "๐ŸŒˆ"] - for emoji in popular_emojis: - if emoji in self.emoji_data: - emoji_info = self.emoji_data[emoji] - results.append( - self._create_emoji_result(emoji, emoji_info, 1.0) - ) - return results - - # Search by name, group, or the emoji itself - for emoji, info in self.emoji_data.items(): - relevance = 0 - name = info.get("name", "").lower() - group = info.get("group", "").lower() - slug = info.get("slug", "").lower() - - # Exact match with emoji - if query == emoji: - relevance = 1.0 - # Name contains query - elif query in name: - relevance = 0.9 - # Slug contains query - elif query in slug: - relevance = 0.8 - # Group contains query - elif query in group: - relevance = 0.7 - - if relevance > 0: - results.append(self._create_emoji_result(emoji, info, relevance)) - - # Sort by relevance - results.sort(key=lambda x: x.relevance, reverse=True) - return results[:20] # Limit to 20 results - - def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result: - """Create a Result object for an emoji.""" - name = info.get("name", "") - group = info.get("group", "") - - # Check if this is a recently used emoji - is_recent = emoji in self.recent_emojis - subtitle = f"{group}" + (" โ€ข Recently used" if is_recent else "") - - return Result( - title=name, # Show only the name, not the emoji - subtitle=subtitle, - icon_markup=emoji, # Use the emoji itself as the icon - action=lambda e=emoji: self._copy_to_clipboard(e), - relevance=relevance, - plugin_name=self.display_name, - data={"emoji": emoji, "name": name, "group": group, "recent": is_recent}, - ) diff --git a/modules/launcher/plugins/otp.py b/modules/launcher/plugins/otp.py deleted file mode 100644 index 7ab0b6d..0000000 --- a/modules/launcher/plugins/otp.py +++ /dev/null @@ -1,850 +0,0 @@ -import json -import subprocess -import threading -import time -from pathlib import Path -from typing import Dict, List, Optional - -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result -from services.auth import ( - generate_totp, - get_time_remaining_with_blink, - parse_otpauth_uri, - scan_qr_and_add_account, - validate_base32_secret, -) - - -class OTPPlugin(PluginBase): - """Plugin for managing TOTP (Time-based One-Time Password) codes.""" - - def __init__(self): - super().__init__() - self.display_name = "OTP Manager" - self.description = "Manage TOTP codes and 2FA authentication" - - self.secrets_file = Path( - get_relative_path("../../../config/assets/accounts.json") - ) - self.secrets: Dict[str, Dict] = {} - self.last_update = 0 - - # Threading for auto-refresh - self.refresh_thread = None - self.stop_refresh = threading.Event() - - def initialize(self): - """Initialize the OTP plugin.""" - self.set_triggers(["otp"]) - self._load_secrets() - self._ensure_config_file() - self._start_refresh_thread() - - def cleanup(self): - """Cleanup the OTP plugin.""" - if self.refresh_thread and self.refresh_thread.is_alive(): - self.stop_refresh.set() - self.refresh_thread.join(timeout=1) - - def _load_secrets(self): - """Load secrets from JSON file.""" - try: - if self.secrets_file.exists(): - with open(self.secrets_file, "r", encoding="utf-8") as f: - self.secrets = json.load(f) - else: - self.secrets = {} - except Exception as e: - print(f"Error loading OTP secrets: {e}") - self.secrets = {} - - def _save_secrets(self): - """Save secrets to JSON file.""" - try: - self.secrets_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.secrets_file, "w", encoding="utf-8") as f: - json.dump(self.secrets, f, indent=2) - except Exception as e: - print(f"Error saving OTP secrets: {e}") - - def _ensure_config_file(self): - """Ensure the config file exists.""" - if not self.secrets_file.exists(): - self.secrets_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.secrets_file, "w", encoding="utf-8") as f: - json.dump({}, f, indent=2) - - def _start_refresh_thread(self): - """Start background thread for auto-refreshing tokens.""" - - def refresh_loop(): - while not self.stop_refresh.wait(5): - current_time = time.time() - if current_time - self.last_update >= 5: - self.last_update = current_time - # Only refresh if we have secrets and launcher is likely active - if self.secrets: - try: - self._selective_force_refresh() - except Exception: - pass - - self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True) - self.refresh_thread.start() - - def _selective_force_refresh(self): - """Update time display in existing OTP result items.""" - try: - import gc - - from gi.repository import GLib - - def do_update(): - try: - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "results_box") - and hasattr(obj, "visible") - and obj.visible - and hasattr(obj, "results") - and obj.results - ): - has_otp_results = any( - result.data and result.data.get("type") == "totp" - for result in obj.results - if hasattr(result, "data") and result.data - ) - - if has_otp_results: - self._update_existing_result_labels(obj.results_box) - return False - except Exception: - pass - return False - - GLib.idle_add(do_update) - except Exception: - pass - - def _update_existing_result_labels(self, results_box): - """Update subtitle labels in existing ResultItem widgets.""" - try: - time_display = self._get_time_remaining_with_blink() - for child in results_box.get_children(): - if ( - hasattr(child, "__class__") - and child.__class__.__name__ == "ResultItem" - and hasattr(child, "result") - and hasattr(child.result, "data") - and child.result.data - and child.result.data.get("type") == "totp" - ): - self._update_result_item_content(child, time_display) - except Exception as e: - print(f"Error updating result labels: {e}") - - def _update_result_item_content(self, result_item, time_display): - """Update both the title (OTP code) and subtitle (time display) of a specific ResultItem.""" - try: - account_name = result_item.result.data.get("account", "") - if not account_name or account_name not in self.secrets: - return - - account_data = self.secrets[account_name] - secret = account_data.get("secret", "") - issuer = account_data.get("issuer", "") - display_name = f"{issuer} - {account_name}" if issuer else account_name - - current_totp_code = self._generate_totp(secret) - if not current_totp_code: - return - - old_code = result_item.result.data.get("code", "") - if current_totp_code != old_code: - result_item.result.data["code"] = current_totp_code - self._find_and_update_title_label(result_item, current_totp_code) - result_item.result.action = ( - lambda code=current_totp_code: self._copy_to_clipboard(code) - ) - - new_subtitle_markup = f"{display_name} โ€ข {time_display} remaining" - self._find_and_update_subtitle_label(result_item, new_subtitle_markup) - except Exception as e: - print(f"Error updating result item: {e}") - - def _find_and_update_title_label(self, result_item, new_title): - """Find the title label widget and update its text.""" - - def find_title_label(widget): - if hasattr(widget, "get_name") and widget.get_name() == "result-item-title": - return widget - if hasattr(widget, "get_children"): - for child in widget.get_children(): - found = find_title_label(child) - if found: - return found - return None - - title_label = find_title_label(result_item) - if title_label and hasattr(title_label, "set_label"): - title_label.set_label(new_title) - - def _find_and_update_subtitle_label(self, result_item, new_markup): - """Find the subtitle label widget and update its markup.""" - - def find_subtitle_label(widget): - if ( - hasattr(widget, "get_name") - and widget.get_name() == "result-item-subtitle" - ): - return widget - if hasattr(widget, "get_children"): - for child in widget.get_children(): - found = find_subtitle_label(child) - if found: - return found - return None - - subtitle_label = find_subtitle_label(result_item) - if subtitle_label and hasattr(subtitle_label, "set_markup"): - subtitle_label.set_markup(new_markup) - - def _copy_to_clipboard(self, text: str): - """Copy text to clipboard.""" - try: - try: - subprocess.run(["wl-copy"], input=text.encode(), check=True) - except subprocess.SubprocessError: - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=text.encode(), - check=True, - ) - except Exception as e: - print(f"Failed to copy to clipboard: {e}") - - def _trigger_refresh(self): - """Trigger launcher refresh to return to default OTP view.""" - try: - from gi.repository import GLib - - # Use a small delay to ensure the action completes first - def trigger_refresh(): - try: - # Try to access the launcher through the fabric Application - from fabric import Application - - app = Application.get_default() - - if app and hasattr(app, "launcher"): - launcher = app.launcher - if launcher and hasattr(launcher, "search_entry"): - # Clear the search entry and set it to just "otp " - launcher.search_entry.set_text("otp ") - # Position cursor at the end - launcher.search_entry.set_position(-1) - # Trigger the search to show default OTP view - if hasattr(launcher, "_perform_search"): - launcher._perform_search("otp ") - return False - - # Fallback: try to find launcher instance through other means - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - ): - if hasattr(obj, "search_entry") and hasattr( - obj, "_perform_search" - ): - obj.search_entry.set_text("otp ") - obj.search_entry.set_position(-1) - obj._perform_search("otp ") - return False - - except Exception as e: - print(f"Error forcing launcher refresh: {e}") - - return False # Don't repeat - - # Use a small delay to ensure the action completes first - GLib.timeout_add(50, trigger_refresh) - - except Exception as e: - print(f"Could not trigger refresh: {e}") - - def _remove_account_and_refresh(self, account_name: str): - """Remove an account and trigger refresh to return to default OTP view.""" - try: - if account_name in self.secrets: - # Remove the account - del self.secrets[account_name] - self._save_secrets() - - # Trigger refresh to return to default OTP view - self._trigger_refresh() - except Exception as e: - print(f"Error removing account {account_name}: {e}") - - def _generate_totp(self, secret: str) -> Optional[str]: - """Generate TOTP code from secret.""" - return generate_totp(secret) - - def _get_time_remaining_with_blink(self) -> str: - """Get time remaining with blinking effect.""" - return get_time_remaining_with_blink() - - def query(self, query_string: str) -> List[Result]: - """Process OTP queries.""" - query = query_string.strip() - - if not query: - return self._list_otp_codes() - - query_lower = query.lower() - if query_lower.startswith("add "): - add_content = query[4:].strip() - - # Handle both formats: with ``` and without ``` - if "```" in add_content: - # Old format: add account```secret``` - parts = add_content.split("```", 1) - if len(parts) == 2: - account_name = parts[0].strip() - secret_or_uri = parts[1].strip() - return self._handle_direct_add(account_name, secret_or_uri) - elif " " in add_content: - # New format: add account secret - parts = add_content.split(" ", 1) - if len(parts) == 2: - account_name = parts[0].strip() - secret_or_uri = parts[1].strip() - return self._handle_direct_add(account_name, secret_or_uri) - - return self._handle_add_command(add_content) - elif query_lower == "remove" or query_lower.startswith("remove "): - # Handle both "remove" and "remove accountname" - if query_lower == "remove": - remove_content = "" - else: - remove_content = query[7:].strip() - return self._handle_remove_command(remove_content) - elif query_lower == "qr" or query_lower.startswith("qr "): - # Handle QR scanning command - if query_lower == "qr": - qr_content = "" - else: - qr_content = query[3:].strip() - return self._handle_qr_command(qr_content) - else: - return self._search_accounts(query) - - def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]: - """Handle direct addition of OTP account.""" - if not account_name or not secret_or_uri: - return [ - Result( - title="Invalid format", - subtitle="Usage: add <account_name> <secret> or add <account_name>```<secret>```", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ] - - try: - if secret_or_uri.startswith("otpauth://"): - return self._handle_otpauth_uri(account_name, secret_or_uri) - else: - return self._handle_base32_secret(account_name, secret_or_uri) - except Exception as e: - print(f"OTP Debug: Error in _handle_direct_add: {e}") - return [ - Result( - title="Error adding account", - subtitle=f"Debug: {str(e)}", - icon_name="cancel", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - def _list_otp_codes(self) -> List[Result]: - """List all OTP codes with current tokens.""" - results = [] - - if not self.secrets: - results.append( - Result( - title="No OTP accounts configured", - subtitle="Use 'add <account> <secret>' to add your first account", - icon_name="gtk-authentication-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty", "keep_launcher_open": True}, - ) - ) - results.append( - Result( - title="Available commands:", - subtitle="add <account> <secret> | remove <account> | qr <account>", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - return results - - time_display = self._get_time_remaining_with_blink() - - for account_name, account_data in self.secrets.items(): - secret = account_data.get("secret", "") - issuer = account_data.get("issuer", "") - totp_code = self._generate_totp(secret) - - if totp_code: - display_name = f"{issuer} - {account_name}" if issuer else account_name - results.append( - Result( - title=f"{totp_code}", - subtitle_markup=f"{display_name} โ€ข { - time_display - } remaining โ€ข Shift+Enter: remove", - icon_name="gtk-authentication-symbolic", - action=lambda code=totp_code: self._copy_to_clipboard(code), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "totp", - "account": account_name, - "code": totp_code, - "alt_action": lambda acc=account_name: self._remove_account_and_refresh( - acc - ), - }, - ) - ) - else: - results.append( - Result( - title=f"Error: {account_name}", - subtitle="Invalid secret or configuration", - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={ - "type": "error", - "account": account_name, - "keep_launcher_open": True, - }, - ) - ) - - return results - - def _search_accounts(self, query: str) -> List[Result]: - """Search accounts by name or issuer.""" - results = [] - query_lower = query.lower() - - for account_name, account_data in self.secrets.items(): - issuer = account_data.get("issuer", "").lower() - account_lower = account_name.lower() - - if query_lower in account_lower or query_lower in issuer: - secret = account_data.get("secret", "") - totp_code = self._generate_totp(secret) - - if totp_code: - display_name = ( - f"{account_data.get('issuer', '')} - {account_name}" - if account_data.get("issuer") - else account_name - ) - time_display = self._get_time_remaining_with_blink() - - results.append( - Result( - title=f"{totp_code}", - subtitle_markup=f"{display_name} โ€ข { - time_display - } remaining โ€ข Shift+Enter: remove", - icon_name="gtk-authentication-symbolic", - action=lambda code=totp_code: self._copy_to_clipboard(code), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "totp", - "account": account_name, - "code": totp_code, - "alt_action": lambda acc=account_name: self._remove_account_and_refresh( - acc - ), - }, - ) - ) - - if not results: - results.append( - Result( - title=f"No accounts found for '{query}'", - subtitle="Use 'add <account> <secret>' to create new account", - icon_name="edit-find-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "no_results", "keep_launcher_open": True}, - ) - ) - results.append( - Result( - title="Available commands:", - subtitle="add <account> <secret> | remove <account> | qr <account>", - icon_name="info", - action=lambda: None, - relevance=0.4, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - - return results - - def _handle_add_command(self, account_name: str) -> List[Result]: - """Handle manual addition of OTP secret.""" - if not account_name: - return [ - Result( - title="Enter account name", - subtitle="Usage: add <account_name> <secret>", - icon_name="dialog-question-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ] - - return [ - Result( - title=f"To add '{account_name}':", - subtitle=f"Type: add {account_name} <secret>", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "instruction", - "account": account_name, - "keep_launcher_open": True, - }, - ), - Result( - title="Base32 Secret Format:", - subtitle="Example: add gmail JBSWY3DPEHPK3PXP", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="otpauth URI Format:", - subtitle="Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="Remove Account:", - subtitle="Example: remove gmail", - icon_name="trash", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - ] - - def _handle_qr_command(self, account_name: str) -> List[Result]: - """Handle QR scanning command.""" - if not account_name: - return [ - Result( - title="Scan QR Code", - subtitle="Click to scan QR code from screen", - icon_name="view-barcode-qr-symbolic", - action=lambda: self._scan_qr_and_add_account(""), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "qr_scan"}, - ), - Result( - title="QR Scan Instructions:", - subtitle="Use 'qr <account_name>' to specify account name", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - ] - else: - return [ - Result( - title=f"Scan QR Code for '{account_name}'", - subtitle="Click to scan QR code from screen", - icon_name="view-barcode-qr-symbolic", - action=lambda name=account_name: self._scan_qr_and_add_account( - name - ), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "qr_scan"}, - ), - ] - - def _scan_qr_and_add_account(self, account_name: str): - """Scan QR code and add OTP account.""" - print(f"QR scan action called for account: '{account_name}'") - print("Starting QR scan process asynchronously...") - - # Run QR scanning asynchronously so launcher closes immediately - import threading - - thread = threading.Thread(target=self._scan_qr_async, args=(account_name,)) - thread.daemon = True - thread.start() - - def _scan_qr_async(self, account_name: str): - """Async QR scanning process.""" - result = scan_qr_and_add_account(account_name, str(self.secrets_file)) - - if result["success"]: - print(result["message"]) - # Reload secrets from file - self._load_secrets() - # Trigger refresh to show the new account - self._trigger_refresh() - else: - print(f"QR scan failed: {result['error']}") - - def _handle_remove_command(self, account_name: str) -> List[Result]: - """Handle removal of OTP account.""" - if not account_name: - # Show all available accounts for removal - results = [] - - if not self.secrets: - results.append( - Result( - title="No OTP accounts to remove", - subtitle="Use 'add <account> <secret>' to add accounts first", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty", "keep_launcher_open": True}, - ) - ) - else: - results.append( - Result( - title="Select account to remove:", - subtitle="Type: remove <account_name> to remove an account", - icon_name="user-trash-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - - # Get time display for consistency with main OTP view - time_display = self._get_time_remaining_with_blink() - - # Show all accounts with their current OTP codes and remove actions - for acc_name, account_data in self.secrets.items(): - secret = account_data.get("secret", "") - issuer = account_data.get("issuer", "") - display_name = f"{issuer} - {acc_name}" if issuer else acc_name - - # Generate current TOTP code - totp_code = self._generate_totp(secret) - - if totp_code: - results.append( - Result( - title=f"{totp_code}", - subtitle_markup=f"Press Enter to remove โ€ข { - time_display - } remaining", - icon_name="user-trash-symbolic", - action=lambda acc=acc_name: self._remove_account_and_refresh( - acc - ), - relevance=0.9, - plugin_name=self.display_name, - data={ - "type": "remove_instruction", - "account": acc_name, - "code": totp_code, - "keep_launcher_open": True, - }, - ) - ) - else: - results.append( - Result( - title=f"Error: {acc_name}", - subtitle="Press Enter to remove (Invalid secret)", - icon_name="user-trash-symbolic", - action=lambda acc=acc_name: self._remove_account_and_refresh( - acc - ), - relevance=0.8, - plugin_name=self.display_name, - data={ - "type": "remove_instruction", - "account": acc_name, - "keep_launcher_open": True, - }, - ) - ) - - return results - - # Check if account exists - if account_name not in self.secrets: - return [ - Result( - title=f"Account '{account_name}' not found", - subtitle="Use 'remove' to see all available accounts", - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - # Get account info for confirmation - account_data = self.secrets[account_name] - issuer = account_data.get("issuer", "") - display_name = f"{issuer} - {account_name}" if issuer else account_name - - # Show confirmation for removal - return [ - Result( - title=f"Remove '{display_name}'?", - subtitle="Press Enter to confirm removal", - icon_name="user-trash-symbolic", - action=lambda acc=account_name: self._remove_account_and_refresh(acc), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "remove_confirm", - "account": account_name, - "keep_launcher_open": True, - }, - ) - ] - - def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]: - """Handle raw Base32 secret.""" - result = validate_base32_secret(secret) - - if not result["success"]: - return [ - Result( - title="Invalid Base32 secret", - subtitle=result["error"], - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - self.secrets[account_name] = { - "secret": result["secret"], - "issuer": "", - "algorithm": "SHA1", - "digits": 6, - "period": 30, - } - self._save_secrets() - - return [ - Result( - title=f"โœ“ Added '{account_name}'", - subtitle=f"OTP account added successfully (secret: { - result['secret'][:4] - }...)", - icon_name="emblem-ok-symbolic", - action=lambda: self._trigger_refresh(), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "success", "keep_launcher_open": True}, - ) - ] - - def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]: - """Handle otpauth:// URI.""" - result = parse_otpauth_uri(uri, account_name) - - if not result["success"]: - return [ - Result( - title="Error parsing otpauth URI", - subtitle=result["error"], - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - self.secrets[result["account_name"]] = { - "secret": result["secret"], - "issuer": result["issuer"], - "algorithm": result["algorithm"], - "digits": result["digits"], - "period": result["period"], - } - self._save_secrets() - - display_name = ( - f"{result['issuer']} - {result['account_name']}" - if result["issuer"] - else result["account_name"] - ) - return [ - Result( - title=f"โœ“ Added '{display_name}'", - subtitle="OTP account added from URI", - icon_name="emblem-ok-symbolic", - action=lambda: self._trigger_refresh(), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "success", "keep_launcher_open": True}, - ) - ] diff --git a/modules/launcher/plugins/password.py b/modules/launcher/plugins/password.py deleted file mode 100644 index 1551b20..0000000 --- a/modules/launcher/plugins/password.py +++ /dev/null @@ -1,690 +0,0 @@ -import base64 -import json -import subprocess -import threading -import time -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional - -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class PasswordManager: - """Simple password manager with basic encryption and caching.""" - - def __init__(self, storage_file: Path): - self.storage_file = storage_file - self.passwords: Dict[str, Dict] = {} - self._cache_lock = threading.Lock() - self._last_loaded = 0 - self._cache_ttl = 30 # Cache for 30 seconds - self._load_passwords() - - def _simple_encrypt(self, text: str, key: str = "modus_pass") -> str: - """Simple encryption using XOR with base64 encoding.""" - key_bytes = key.encode("utf-8") - text_bytes = text.encode("utf-8") - - # XOR encryption - encrypted = bytearray() - for i, byte in enumerate(text_bytes): - encrypted.append(byte ^ key_bytes[i % len(key_bytes)]) - - # Base64 encode - return base64.b64encode(encrypted).decode("utf-8") - - def _simple_decrypt(self, encrypted_text: str, key: str = "modus_pass") -> str: - """Simple decryption using XOR with base64 decoding.""" - try: - key_bytes = key.encode("utf-8") - - # Base64 decode - encrypted_bytes = base64.b64decode(encrypted_text.encode("utf-8")) - - # XOR decryption - decrypted = bytearray() - for i, byte in enumerate(encrypted_bytes): - decrypted.append(byte ^ key_bytes[i % len(key_bytes)]) - - return decrypted.decode("utf-8") - except Exception: - return encrypted_text # Return as-is if decryption fails - - def _load_passwords(self): - """Load passwords from JSON file with caching.""" - with self._cache_lock: - current_time = time.time() - - # Check if cache is still valid - if (current_time - self._last_loaded) < self._cache_ttl and self.passwords: - return - - try: - if self.storage_file.exists(): - with open(self.storage_file, "r", encoding="utf-8") as f: - data = json.load(f) - self.passwords = data.get("passwords", {}) - else: - self.passwords = {} - - self._last_loaded = current_time - except Exception as e: - print(f"Error loading passwords: {e}") - self.passwords = {} - - def _save_passwords(self): - """Save passwords to JSON file.""" - with self._cache_lock: - try: - self.storage_file.parent.mkdir(parents=True, exist_ok=True) - data = { - "passwords": self.passwords, - "last_modified": datetime.now().isoformat(), - } - with open(self.storage_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - - # Update cache timestamp - self._last_loaded = time.time() - except Exception as e: - print(f"Error saving passwords: {e}") - - def add_password(self, name: str, password: str, description: str = "") -> bool: - """Add a new password entry.""" - try: - encrypted_password = self._simple_encrypt(password) - self.passwords[name] = { - "password": encrypted_password, - "description": description, - "created": datetime.now().isoformat(), - "last_accessed": None, - } - self._save_passwords() - return True - except Exception as e: - print(f"Error adding password: {e}") - return False - - def get_password(self, name: str, update_access_time: bool = True) -> Optional[str]: - """Get decrypted password by name.""" - # Ensure we have fresh data - self._load_passwords() - - if name in self.passwords: - try: - encrypted = self.passwords[name]["password"] - decrypted = self._simple_decrypt(encrypted) - - # Update last accessed time only if requested (to avoid frequent saves) - if update_access_time: - self.passwords[name]["last_accessed"] = datetime.now().isoformat() - # Don't save immediately - batch saves for better performance - - return decrypted - except Exception as e: - print(f"Error decrypting password: {e}") - return None - return None - - def remove_password(self, name: str) -> bool: - """Remove a password entry.""" - if name in self.passwords: - del self.passwords[name] - self._save_passwords() - return True - return False - - def list_passwords(self) -> List[str]: - """Get list of all password names.""" - # Ensure we have fresh data - self._load_passwords() - return list(self.passwords.keys()) - - def get_password_info(self, name: str) -> Optional[Dict]: - """Get password metadata without decrypting.""" - if name in self.passwords: - info = self.passwords[name].copy() - info.pop("password", None) # Remove encrypted password - return info - return None - - -class PasswordPlugin(PluginBase): - """ - Password manager plugin for the launcher. - Stores passwords securely and allows easy access. - """ - - def __init__(self): - super().__init__() - self.display_name = "Password Manager" - self.description = "Secure password storage and management" - - # Initialize password manager - self.password_file = Path( - get_relative_path("../../../config/assets/passwords.json") - ) - self.password_manager = PasswordManager(self.password_file) - - # State for password visibility - self.revealed_passwords: Dict[str, str] = {} - - # Cache for results to avoid repeated queries - self._results_cache: Dict[str, List[Result]] = {} - self._cache_timestamps: Dict[str, float] = {} - self._cache_ttl = 5 # Cache results for 5 seconds - - # Track launcher state for auto-hiding passwords - self._launcher_instance = None - - def initialize(self): - """Initialize the password plugin.""" - self.set_triggers(["pass"]) - self._setup_launcher_hooks() - - def cleanup(self): - """Cleanup the password plugin.""" - self.revealed_passwords.clear() - self._results_cache.clear() - self._cache_timestamps.clear() - self._cleanup_launcher_hooks() - - def query(self, query_string: str) -> List[Result]: - """Process password manager queries with caching.""" - query_key = query_string.strip() - current_time = time.time() - - # Check cache first (except for add/remove commands which should always execute) - if ( - not query_key.startswith(("add ", "remove ", "delete ")) - and query_key in self._results_cache - and (current_time - self._cache_timestamps.get(query_key, 0)) - < self._cache_ttl - ): - return self._results_cache[query_key] - - results = [] - query = query_key.lower() - - # Handle different commands - if not query: - # Show all passwords - results.extend(self._list_all_passwords()) - elif query.startswith("add "): - # Add new password (don't cache) - results.extend(self._handle_add_command(query_string)) - elif query.startswith("remove ") or query.startswith("delete "): - # Remove password (don't cache) - results.extend(self._handle_remove_command(query_string)) - else: - # Search for specific password - results.extend(self._search_passwords(query)) - - # Cache results (except for add/remove commands) - if not query.startswith(("add ", "remove ", "delete ")): - self._results_cache[query_key] = results - self._cache_timestamps[query_key] = current_time - - return results - - def _list_all_passwords(self) -> List[Result]: - """List all stored passwords.""" - results = [] - password_names = self.password_manager.list_passwords() - - if not password_names: - results.append( - Result( - title="No passwords stored", - subtitle="Use 'pass add <name> <password>' to add your first password", - icon_name="password-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty", "keep_launcher_open": True}, - ) - ) - results.append( - Result( - title="Available commands:", - subtitle="add <name> <password> | remove <name> | <name> (to search)", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - return results - - # Sort passwords alphabetically - password_names.sort() - - for name in password_names: - info = self.password_manager.get_password_info(name) - description = info.get("description", "") if info else "" - - # Check if password is revealed - if name in self.revealed_passwords: - title = f"{name}: {self.revealed_passwords[name]}" - subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" - else: - title = f"{name}: {'*' * 8}" - subtitle = "Enter: copy | Shift+Enter: reveal password" - - if description: - subtitle += f" | {description}" - - results.append( - Result( - title=title, - subtitle=subtitle, - icon_name="key", - action=lambda n=name: self._copy_password_to_clipboard(n), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "password", - "name": name, - "keep_launcher_open": False, - "alt_action": lambda n=name: self._toggle_password_visibility( - n - ), - }, - ) - ) - - return results - - def _handle_add_command(self, query_string: str) -> List[Result]: - """Handle add password command.""" - results = [] - parts = query_string.strip().split(" ", 3) - - if len(parts) < 3: - results.append( - Result( - title="Add Password - Invalid format", - subtitle="Usage: add <name> <password> [description]", - icon_name="cancel", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ) - return results - - name = parts[1] - password = parts[2] - description = parts[3] if len(parts) > 3 else "" - - # Check if password already exists - if name in self.password_manager.list_passwords(): - results.append( - Result( - title=f"Update password for '{name}'?", - subtitle="Password already exists. Click to update it.", - icon_name="key", - action=lambda: self._add_password_action( - name, password, description, update=True - ), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "update", "name": name, "keep_launcher_open": False}, - ) - ) - else: - results.append( - Result( - title=f"Add password for '{name}'", - subtitle="Click to save password" - + (f" | {description}" if description else ""), - icon_name="plus", - action=lambda: self._add_password_action( - name, password, description - ), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "add", "name": name, "keep_launcher_open": False}, - ) - ) - - return results - - def _handle_remove_command(self, query_string: str) -> List[Result]: - """Handle remove password command.""" - results = [] - parts = query_string.strip().split(" ", 1) - - if len(parts) < 2: - results.append( - Result( - title="Remove Password - Invalid format", - subtitle="Usage: remove <name>", - icon_name="cancel", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ) - return results - - name = parts[1] - - if name not in self.password_manager.list_passwords(): - results.append( - Result( - title=f"Password '{name}' not found", - subtitle="Check the name and try again", - icon_name="cancel", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ) - else: - results.append( - Result( - title=f"Remove password '{name}'?", - subtitle="Click to confirm deletion (this cannot be undone)", - icon_name="trash", - action=lambda: self._remove_password_action(name), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "remove", "name": name, "keep_launcher_open": False}, - ) - ) - - return results - - def _search_passwords(self, query: str) -> List[Result]: - """Search for passwords by name.""" - results = [] - password_names = self.password_manager.list_passwords() - - # Filter passwords that match the query - matching_passwords = [ - name for name in password_names if query.lower() in name.lower() - ] - - if not matching_passwords: - results.append( - Result( - title=f"No passwords found matching '{query}'", - subtitle="Try a different search term or use 'pass' to see all passwords", - icon_name="magnifier", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "no_results", "keep_launcher_open": True}, - ) - ) - return results - - # Sort by relevance (exact match first, then starts with, then contains) - def get_relevance(name: str) -> float: - name_lower = name.lower() - query_lower = query.lower() - - if name_lower == query_lower: - return 1.0 - elif name_lower.startswith(query_lower): - return 0.9 - else: - return 0.7 - - matching_passwords.sort(key=get_relevance, reverse=True) - - for name in matching_passwords: - info = self.password_manager.get_password_info(name) - description = info.get("description", "") if info else "" - - # Check if password is revealed - if name in self.revealed_passwords: - title = f"{name}: {self.revealed_passwords[name]}" - subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" - else: - title = f"{name}: {'*' * 8}" - subtitle = "Enter: copy | Shift+Enter: reveal password" - - if description: - subtitle += f" | {description}" - - results.append( - Result( - title=title, - subtitle=subtitle, - icon_name="key", - action=lambda n=name: self._copy_password_to_clipboard(n), - relevance=get_relevance(name), - plugin_name=self.display_name, - data={ - "type": "password", - "name": name, - "keep_launcher_open": False, - "alt_action": lambda n=name: self._toggle_password_visibility( - n - ), - }, - ) - ) - - return results - - def _add_password_action( - self, name: str, password: str, description: str = "", update: bool = False - ): - """Action to add/update a password.""" - try: - success = self.password_manager.add_password(name, password, description) - if success: - action_word = "updated" if update else "added" - # Clear cache to force refresh - self._results_cache.clear() - - # Send notification if available (non-blocking) - try: - subprocess.Popen( - [ - "notify-send", - "Password Manager", - f"Password '{name}' {action_word} successfully", - ] - ) - except: - pass - else: - print(f"Failed to add password '{name}'") - except Exception as e: - print(f"Error adding password: {e}") - - def _remove_password_action(self, name: str): - """Action to remove a password.""" - try: - success = self.password_manager.remove_password(name) - if success: - # Remove from revealed passwords if present - self.revealed_passwords.pop(name, None) - - # Clear cache to force refresh - self._results_cache.clear() - - # Send notification if available (non-blocking) - try: - subprocess.Popen( - [ - "notify-send", - "Password Manager", - f"Password '{name}' removed successfully", - ] - ) - except: - pass - else: - print(f"Failed to remove password '{name}'") - except Exception as e: - print(f"Error removing password: {e}") - - def _copy_password_to_clipboard(self, name: str): - """Copy password to clipboard and reveal it temporarily.""" - try: - password = self.password_manager.get_password( - name, update_access_time=False - ) - if password: - # Copy to clipboard (use timeout to avoid hanging) - try: - subprocess.run( - ["wl-copy"], input=password.encode(), check=True, timeout=2 - ) - except subprocess.SubprocessError: - # Fall back to X11 - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=password.encode(), - check=True, - timeout=2, - ) - - # Reveal password temporarily - self.revealed_passwords[name] = password - - # Send notification if available (non-blocking) - try: - subprocess.Popen( - [ - "notify-send", - "Password Manager", - f"Password for '{name}' copied to clipboard", - ] - ) - except: - pass - - # Clear cache to force refresh - self._results_cache.clear() - - else: - print(f"Failed to retrieve password for '{name}'") - except Exception as e: - print(f"Error copying password: {e}") - - def _reveal_password(self, name: str): - """Reveal password without copying to clipboard.""" - try: - password = self.password_manager.get_password( - name, update_access_time=False - ) - if password: - self.revealed_passwords[name] = password - # Clear cache to force refresh with revealed password - self._results_cache.clear() - else: - print(f"Failed to retrieve password for '{name}'") - except Exception as e: - print(f"Error revealing password: {e}") - - def _hide_password(self, name: str): - """Hide revealed password.""" - self.revealed_passwords.pop(name, None) - - def _hide_all_passwords(self): - """Hide all revealed passwords.""" - if self.revealed_passwords: - self.revealed_passwords.clear() - # Clear cache to force refresh with hidden passwords - self._results_cache.clear() - - def _setup_launcher_hooks(self): - """Setup hooks to monitor launcher state.""" - try: - # Try to find the launcher instance - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "close_launcher") - ): - self._launcher_instance = obj - # Store original close_launcher method - self._original_close_launcher = obj.close_launcher - # Replace with our wrapper - obj.close_launcher = self._wrapped_close_launcher - break - except Exception as e: - print(f"Warning: Could not setup launcher hooks: {e}") - - def _cleanup_launcher_hooks(self): - """Cleanup launcher hooks.""" - try: - if self._launcher_instance and hasattr(self, "_original_close_launcher"): - # Restore original close_launcher method - self._launcher_instance.close_launcher = self._original_close_launcher - self._launcher_instance = None - except Exception as e: - print(f"Warning: Could not cleanup launcher hooks: {e}") - - def _wrapped_close_launcher(self): - """Wrapper for launcher close that hides passwords.""" - # Hide all passwords when launcher closes - self._hide_all_passwords() - # Call original close_launcher method - if hasattr(self, "_original_close_launcher"): - self._original_close_launcher() - - def _toggle_password_visibility(self, name: str): - """Toggle password visibility when Shift+Enter is pressed.""" - if name in self.revealed_passwords: - # Hide password - self.revealed_passwords.pop(name, None) - self._results_cache.clear() - else: - # Reveal password - try: - password = self.password_manager.get_password( - name, update_access_time=False - ) - if password: - self.revealed_passwords[name] = password - self._results_cache.clear() - else: - print(f"Failed to retrieve password for '{name}'") - except Exception as e: - print(f"Error revealing password: {e}") - - # Force refresh of the launcher to show updated state - self._force_launcher_refresh() - - def _force_launcher_refresh(self): - """Force the launcher to refresh and show updated results.""" - try: - if self._launcher_instance and hasattr( - self._launcher_instance, "_perform_search" - ): - # Get current search text - current_text = "" - if hasattr(self._launcher_instance, "search_entry"): - current_text = self._launcher_instance.search_entry.get_text() - - # Trigger a search to refresh results - try: - from gi.repository import GLib - - def refresh(): - self._launcher_instance._perform_search(current_text) - return False - - GLib.timeout_add(50, refresh) - except ImportError: - # Fallback: direct call if GLib not available - self._launcher_instance._perform_search(current_text) - except Exception as e: - print(f"Could not force launcher refresh: {e}") diff --git a/modules/launcher/plugins/power.py b/modules/launcher/plugins/power.py deleted file mode 100644 index 38b9cd8..0000000 --- a/modules/launcher/plugins/power.py +++ /dev/null @@ -1,101 +0,0 @@ -from typing import List - -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class PowerPlugin(PluginBase): - """ - Plugin for system power management operations. - """ - - def __init__(self): - super().__init__() - self.display_name = "Power" - self.description = "System power management and control" - self.commands = { - "shutdown": { - "description": "Shutdown the system", - "icon": "system-shutdown-symbolic", - "action": self.shutdown, - }, - "restart": { - "description": "Restart the system", - "icon": "system-reboot-symbolic", - "action": self.restart, - }, - "lock": { - "description": "Lock the screen", - "icon": "system-lock-screen-symbolic", - "action": self.lock, - }, - "suspend": { - "description": "Suspend the system", - "icon": "system-suspend-symbolic", - "action": self.suspend, - }, - "logout": { - "description": "Logout from current session", - "icon": "system-log-out-symbolic", - "action": self.logout, - }, - } - - def initialize(self): - """Initialize the power plugin.""" - self.set_triggers(["power"]) - - def cleanup(self): - """Cleanup the power plugin.""" - pass - - def query(self, query_string: str) -> List[Result]: - """Search power commands based on query.""" - query = query_string.lower().strip() - results = [] - - # If no query, show all power commands - if not query: - for cmd, info in self.commands.items(): - result = Result( - title=cmd.capitalize(), - subtitle=info["description"], - icon_name=info["icon"], - action=info["action"], - relevance=1.0, - plugin_name=self.display_name, - data={"command": cmd}, - ) - results.append(result) - else: - # Filter commands based on query - for cmd, info in self.commands.items(): - if query in cmd.lower() or query in info["description"].lower(): - result = Result( - title=cmd.capitalize(), - subtitle=info["description"], - icon_name=info["icon"], - action=info["action"], - relevance=1.0 if query == cmd else 0.7, - plugin_name=self.display_name, - data={"command": cmd}, - ) - results.append(result) - - return results - - def shutdown(self, *args) -> None: - exec_shell_command_async("systemctl poweroff") - - def restart(self, *args) -> None: - exec_shell_command_async("systemctl reboot") - - def lock(self, *args) -> None: - exec_shell_command_async("loginctl lock-session") - - def suspend(self, *args) -> None: - exec_shell_command_async("systemctl suspend") - - def logout(self, *args) -> None: - exec_shell_command_async("hyprctl dispatch exit") diff --git a/modules/launcher/plugins/reminders.py b/modules/launcher/plugins/reminders.py deleted file mode 100644 index ded8c20..0000000 --- a/modules/launcher/plugins/reminders.py +++ /dev/null @@ -1,448 +0,0 @@ -import re -import subprocess -import threading -from datetime import datetime, timedelta -from typing import Dict, List, Optional - -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class Reminder: - """ - Represents a single reminder with its timer and metadata. - """ - - def __init__( - self, - reminder_id: int, - message: str, - target_time: datetime, - timer: threading.Timer, - ): - self.id = reminder_id - self.message = message - self.target_time = target_time - self.timer = timer - self.created_time = datetime.now() - - def cancel(self): - """Cancel this reminder.""" - if self.timer: - self.timer.cancel() - - def get_time_remaining(self) -> str: - """Get formatted time remaining until reminder.""" - now = datetime.now() - if self.target_time <= now: - return "Overdue" - - delta = self.target_time - now - total_seconds = int(delta.total_seconds()) - - if total_seconds < 60: - return f"{total_seconds}s" - elif total_seconds < 3600: - minutes = total_seconds // 60 - seconds = total_seconds % 60 - return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" - else: - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" - - def get_target_time_str(self) -> str: - """Get formatted target time.""" - return self.target_time.strftime("%H:%M") - - -class RemindersPlugin(PluginBase): - """ - Time-based reminders plugin for the launcher. - """ - - def __init__(self): - super().__init__() - self.display_name = "Reminders" - self.description = "Set time-based reminders with notifications" - self.reminders: Dict[int, Reminder] = {} - self.next_id = 1 - - # Regex patterns for time parsing - self.time_patterns = { - "relative_time": re.compile(r"^(\d+)([smhd])$"), # 5m, 30s, 2h, 1d - "absolute_time": re.compile(r"^(\d{1,2}):(\d{2})$"), # 14:30, 9:15 - "relative_with_unit": re.compile( - r"^(\d+)\s*(min|mins|minute|minutes|hour|hours|sec|seconds|day|days)$", - re.IGNORECASE, - ), - } - - def initialize(self): - """Initialize the reminders plugin.""" - self.set_triggers(["remind"]) - - def cleanup(self): - """Cleanup the reminders plugin.""" - # Cancel all active reminders - for reminder in self.reminders.values(): - reminder.cancel() - self.reminders.clear() - - def _send_notification(self, title: str, message: str): - """Send a desktop notification using notify-send.""" - try: - subprocess.run( - ["notify-send", "-a", "Reminders", "-i", "alarm-clock", title, message], - check=False, - ) - except Exception as e: - print(f"Failed to send notification: {e}") - - def _parse_time_input(self, time_str: str) -> Optional[datetime]: - """ - Parse various time input formats and return target datetime. - - Supported formats: - - 5m, 30s, 2h, 1d (relative time) - - 14:30, 9:15 (absolute time today) - - 5 minutes, 2 hours (relative with full unit names) - """ - time_str = time_str.strip().lower() - - # Try relative time format (5m, 30s, 2h, 1d) - match = self.time_patterns["relative_time"].match(time_str) - if match: - value, unit = match.groups() - value = int(value) - - if unit == "s": - delta = timedelta(seconds=value) - elif unit == "m": - delta = timedelta(minutes=value) - elif unit == "h": - delta = timedelta(hours=value) - elif unit == "d": - delta = timedelta(days=value) - else: - return None - - return datetime.now() + delta - - # Try absolute time format (14:30, 9:15) - match = self.time_patterns["absolute_time"].match(time_str) - if match: - hour, minute = map(int, match.groups()) - if 0 <= hour <= 23 and 0 <= minute <= 59: - target = datetime.now().replace( - hour=hour, minute=minute, second=0, microsecond=0 - ) - # If the time has already passed today, schedule for tomorrow - if target <= datetime.now(): - target += timedelta(days=1) - return target - - # Try relative time with full unit names - match = self.time_patterns["relative_with_unit"].match(time_str) - if match: - value, unit = match.groups() - value = int(value) - unit = unit.lower() - - if unit in ["sec", "seconds"]: - delta = timedelta(seconds=value) - elif unit in ["min", "mins", "minute", "minutes"]: - delta = timedelta(minutes=value) - elif unit in ["hour", "hours"]: - delta = timedelta(hours=value) - elif unit in ["day", "days"]: - delta = timedelta(days=value) - else: - return None - - return datetime.now() + delta - - return None - - def _create_reminder(self, time_str: str, message: str) -> Optional[Reminder]: - """Create a new reminder with the given time and message.""" - target_time = self._parse_time_input(time_str) - if not target_time: - return None - - # Calculate delay in seconds - delay = (target_time - datetime.now()).total_seconds() - if delay <= 0: - return None - - # Create timer that will trigger the notification - timer = threading.Timer(delay, self._trigger_reminder, [self.next_id, message]) - - # Create reminder object - reminder = Reminder(self.next_id, message, target_time, timer) - - # Store reminder and start timer - self.reminders[self.next_id] = reminder - timer.start() - - # Increment ID for next reminder - self.next_id += 1 - - return reminder - - def _trigger_reminder(self, reminder_id: int, message: str): - """Trigger a reminder notification and remove it from active reminders.""" - # Send notification - self._send_notification("โฐ Reminder", message) - - # Remove from active reminders - if reminder_id in self.reminders: - del self.reminders[reminder_id] - - def _cancel_reminder(self, reminder_id: Optional[int] = None) -> int: - """Cancel a specific reminder or all reminders. Returns number of cancelled reminders.""" - if reminder_id is not None: - if reminder_id in self.reminders: - self.reminders[reminder_id].cancel() - del self.reminders[reminder_id] - return 1 - return 0 - else: - # Cancel all reminders - count = len(self.reminders) - for reminder in self.reminders.values(): - reminder.cancel() - self.reminders.clear() - return count - - def _format_time_remaining(self, total_seconds: float) -> str: - """Format time remaining in a human-readable way.""" - total_seconds = int(total_seconds) - - if total_seconds < 60: - return f"{total_seconds}s" - elif total_seconds < 3600: - minutes = total_seconds // 60 - seconds = total_seconds % 60 - return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" - else: - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" - - def _create_and_confirm_reminder(self, time_str: str, message: str): - """Actually create the reminder when the user presses Enter.""" - reminder = self._create_reminder(time_str, message) - if reminder: - time_remaining = reminder.get_time_remaining() - self._send_notification( - "โœ… Reminder Created", f"Reminder set for {time_remaining}: {message}" - ) - else: - self._send_notification( - "โŒ Failed to Create Reminder", "The specified time may be in the past" - ) - - def query(self, query_string: str) -> List[Result]: - """Process reminder queries.""" - results = [] - query = query_string.strip() - - if not query: - # Show help and active reminders count - active_count = len(self.reminders) - results.append( - Result( - title="Reminders Help", - subtitle=f"Active reminders: { - active_count - } | Usage: remind 5m Take a break", - icon_name="alarm-timer", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help"}, - ) - ) - - # Show quick examples - examples = [ - ("remind 5m Take a break", "Set 5 minute reminder"), - ("remind 14:30 Meeting", "Set reminder for 2:30 PM"), - ("remind list", "List active reminders"), - ("remind cancel", "Cancel all reminders"), - ] - - for example, desc in examples: - results.append( - Result( - title=example, - subtitle=desc, - icon_name="alarm-timer", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"type": "example"}, - ) - ) - - return results - - # Handle list command - if query.lower() in ["list", "ls", "show"]: - if not self.reminders: - results.append( - Result( - title="No Active Reminders", - subtitle="Use 'remind 5m message' to set a reminder", - icon_name="timer-off", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty_list"}, - ) - ) - else: - for reminder in sorted( - self.reminders.values(), key=lambda r: r.target_time - ): - time_remaining = reminder.get_time_remaining() - target_time = reminder.get_target_time_str() - - results.append( - Result( - title=f"#{reminder.id}: {reminder.message}", - subtitle=f"In {time_remaining} (at {target_time})", - icon_name="alarm-timer", - action=lambda rid=reminder.id: self._cancel_reminder(rid), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "active_reminder", "id": reminder.id}, - ) - ) - - return results - - # Handle cancel command - if query.lower().startswith("cancel") or query.lower().startswith("stop"): - parts = query.split() - if len(parts) == 1: - # Cancel all reminders - count = self._cancel_reminder() - results.append( - Result( - title=f"Cancelled {count} Reminders", - subtitle="All active reminders have been cancelled", - icon_name="timer-off", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "cancel_all"}, - ) - ) - else: - # Try to cancel specific reminder by ID - try: - reminder_id = int(parts[1]) - count = self._cancel_reminder(reminder_id) - if count > 0: - results.append( - Result( - title=f"Cancelled Reminder #{reminder_id}", - subtitle="Reminder has been cancelled", - icon_name="timer-off", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "cancel_specific"}, - ) - ) - else: - results.append( - Result( - title="Reminder Not Found", - subtitle=f"No reminder with ID #{reminder_id}", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - except ValueError: - results.append( - Result( - title="Invalid Reminder ID", - subtitle="Please provide a valid reminder ID number", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - - return results - - # Handle setting new reminders - parts = query.split(None, 1) - if len(parts) >= 1: - time_str = parts[0] - message = parts[1] if len(parts) > 1 else "Reminder" - - # Try to parse the time (but don't create the reminder yet!) - target_time = self._parse_time_input(time_str) - if target_time: - # Calculate delay and check if it's valid - delay = (target_time - datetime.now()).total_seconds() - if delay > 0: - # Show what would happen, but don't create the reminder yet - time_remaining = self._format_time_remaining(delay) - target_time_str = target_time.strftime("%H:%M") - - results.append( - Result( - title=f"Set Reminder: {message}", - subtitle=f"Will remind in {time_remaining} (at { - target_time_str - })", - icon_name="alarm-timer", - action=lambda ts=time_str, msg=message: self._create_and_confirm_reminder( - ts, msg - ), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "preview", - "time_str": time_str, - "message": message, - }, - ) - ) - else: - results.append( - Result( - title="Time is in the Past", - subtitle="Please specify a future time", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - else: - # Invalid time format - results.append( - Result( - title="Invalid Time Format", - subtitle="Use formats like: 5m, 30s, 2h, 14:30, or '5 minutes'", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - - return results diff --git a/modules/launcher/plugins/screencapture.py b/modules/launcher/plugins/screencapture.py deleted file mode 100644 index 05253b6..0000000 --- a/modules/launcher/plugins/screencapture.py +++ /dev/null @@ -1,687 +0,0 @@ -import subprocess -from typing import List - -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class ScreencapturePlugin(PluginBase): - """ - Plugin for taking screenshots and screen recordings using screen-capture.sh script. - """ - - def __init__(self): - super().__init__() - self.display_name = "Screencapture" - self.description = "Take screenshots and screen recordings" - self.script_path = get_relative_path("../../../scripts/screen-capture.sh") - - def initialize(self): - """Initialize the screencapture plugin.""" - self.set_triggers(["sc"]) - - def cleanup(self): - """Cleanup the screencapture plugin.""" - pass - - def get_commands(self): - """Return available commands for this plugin.""" - return { - # Screenshot commands - "screenshot": "Take a screenshot of the main display", - "ss": "Take a screenshot of the main display", - "screenshot-region": "Take a screenshot of selected region", - "ss-region": "Take a screenshot of selected region", - "screenshot-both": "Take a screenshot of both displays", - "ss-both": "Take a screenshot of both displays", - "screenshot-hdmi": "Take a screenshot of HDMI display", - "ss-hdmi": "Take a screenshot of HDMI display", - # Recording commands (with audio) - "record": "Start recording main display with audio", - "rec": "Start recording main display with audio", - "record-region": "Start recording selected region with audio", - "rec-region": "Start recording selected region with audio", - "record-hdmi": "Start recording HDMI display with audio", - "rec-hdmi": "Start recording HDMI display with audio", - # Recording commands (no audio) - "record-noaudio": "Start recording main display without audio", - "rec-noaudio": "Start recording main display without audio", - "record-noaudio-region": "Start recording selected region without audio", - "rec-noaudio-region": "Start recording selected region without audio", - "record-noaudio-hdmi": "Start recording HDMI display without audio", - "rec-noaudio-hdmi": "Start recording HDMI display without audio", - # High-quality recording commands - "record-hq": "Start high-quality recording of main display", - "rec-hq": "Start high-quality recording of main display", - "record-hq-region": "Start high-quality recording of selected region", - "rec-hq-region": "Start high-quality recording of selected region", - "record-hq-hdmi": "Start high-quality recording of HDMI display", - "rec-hq-hdmi": "Start high-quality recording of HDMI display", - # GIF recording commands - "record-gif": "Start GIF recording of main display", - "rec-gif": "Start GIF recording of main display", - "record-gif-region": "Start GIF recording of selected region", - "rec-gif-region": "Start GIF recording of selected region", - # Control commands - "stop": "Stop current recording", - # Conversion commands - "convert-webm": "Convert latest MKV recording to WebM format", - "conv-webm": "Convert latest MKV recording to WebM format", - "convert-iphone": "Convert latest MKV recording for iPhone compatibility", - "conv-iphone": "Convert latest MKV recording for iPhone compatibility", - "convert-youtube": "Convert latest recording for YouTube upload", - "conv-youtube": "Convert latest recording for YouTube upload", - "convert-gif": "Convert latest recording to GIF format", - "conv-gif": "Convert latest recording to GIF format", - # Conversion commands with file input - "convert-webm-file": "Convert specific MKV file to WebM format", - "conv-webm-file": "Convert specific MKV file to WebM format", - "convert-iphone-file": "Convert specific MKV file for iPhone compatibility", - "conv-iphone-file": "Convert specific MKV file for iPhone compatibility", - "convert-youtube-file": "Convert specific video file for YouTube upload", - "conv-youtube-file": "Convert specific video file for YouTube upload", - "convert-gif-file": "Convert specific video file to GIF format", - "conv-gif-file": "Convert specific video file to GIF format", - } - - def _run_script(self, *args): - """Execute the screen-capture script with given arguments.""" - try: - subprocess.Popen([self.script_path] + list(args)) - except Exception as e: - print(f"Error running screen-capture script: {e}") - - def _run_script_with_file(self, format_type: str, file_path: str): - """Execute the screen-capture script with file parameter.""" - try: - subprocess.Popen([self.script_path, "convert", format_type, file_path]) - except Exception as e: - print(f"Error running screen-capture script with file: {e}") - - def _is_recording(self): - """Check if recording is currently active.""" - try: - result = subprocess.run( - [self.script_path, "status"], capture_output=True, text=True - ) - return result.stdout.strip() == "true" - except Exception: - return False - - def _get_command_result(self, command: str) -> Result: - """Get a Result object for a specific command.""" - # Import here to avoid circular imports - - command_info = { - # Screenshot commands - "screenshot": ( - "Take Screenshot (eDP-1)", - "Capture the main display", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "eDP-1"), - ), - "ss": ( - "Take Screenshot (eDP-1)", - "Capture the main display", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "eDP-1"), - ), - "screenshot-region": ( - "Take Region Screenshot", - "Capture a selected region", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "selection"), - ), - "ss-region": ( - "Take Region Screenshot", - "Capture a selected region", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "selection"), - ), - "screenshot-both": ( - "Take Screenshot (Both Displays)", - "Capture both displays combined", - "video-joined-displays-symbolic", - lambda: self._run_script("screenshot", "both"), - ), - "ss-both": ( - "Take Screenshot (Both Displays)", - "Capture both displays combined", - "video-joined-displays-symbolic", - lambda: self._run_script("screenshot", "both"), - ), - "screenshot-hdmi": ( - "Take Screenshot (HDMI-A-1)", - "Capture HDMI display", - "video-display-symbolic", - lambda: self._run_script("screenshot", "HDMI-A-1"), - ), - "ss-hdmi": ( - "Take Screenshot (HDMI-A-1)", - "Capture HDMI display", - "video-display-symbolic", - lambda: self._run_script("screenshot", "HDMI-A-1"), - ), - # Recording commands (with audio) - "record": ( - "Start Recording (eDP-1)", - "Record the main display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "eDP-1"), - ), - "rec": ( - "Start Recording (eDP-1)", - "Record the main display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "eDP-1"), - ), - "record-region": ( - "Start Region Recording", - "Record a selected region with audio", - "media-record-symbolic", - lambda: self._run_script("record", "selection"), - ), - "rec-region": ( - "Start Region Recording", - "Record a selected region with audio", - "media-record-symbolic", - lambda: self._run_script("record", "selection"), - ), - "record-hdmi": ( - "Start Recording (HDMI-A-1)", - "Record HDMI display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "HDMI-A-1"), - ), - "rec-hdmi": ( - "Start Recording (HDMI-A-1)", - "Record HDMI display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "HDMI-A-1"), - ), - # Recording commands (no audio) - "record-noaudio": ( - "Start Recording No Audio (eDP-1)", - "Record the main display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "eDP-1"), - ), - "rec-noaudio": ( - "Start Recording No Audio (eDP-1)", - "Record the main display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "eDP-1"), - ), - "record-noaudio-region": ( - "Start Region Recording No Audio", - "Record a selected region without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "selection"), - ), - "rec-noaudio-region": ( - "Start Region Recording No Audio", - "Record a selected region without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "selection"), - ), - "record-noaudio-hdmi": ( - "Start Recording No Audio (HDMI-A-1)", - "Record HDMI display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "HDMI-A-1"), - ), - "rec-noaudio-hdmi": ( - "Start Recording No Audio (HDMI-A-1)", - "Record HDMI display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "HDMI-A-1"), - ), - # High-quality recording commands - "record-hq": ( - "Start HQ Recording (eDP-1)", - "High-quality recording for YouTube", - "media-record-symbolic", - lambda: self._run_script("record-hq", "eDP-1"), - ), - "rec-hq": ( - "Start HQ Recording (eDP-1)", - "High-quality recording for YouTube", - "media-record-symbolic", - lambda: self._run_script("record-hq", "eDP-1"), - ), - "record-hq-region": ( - "Start HQ Region Recording", - "High-quality region recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "selection"), - ), - "rec-hq-region": ( - "Start HQ Region Recording", - "High-quality region recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "selection"), - ), - "record-hq-hdmi": ( - "Start HQ Recording (HDMI-A-1)", - "High-quality HDMI recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "HDMI-A-1"), - ), - "rec-hq-hdmi": ( - "Start HQ Recording (HDMI-A-1)", - "High-quality HDMI recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "HDMI-A-1"), - ), - # GIF recording commands - "record-gif": ( - "Start GIF Recording (eDP-1)", - "Record as optimized GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "eDP-1"), - ), - "rec-gif": ( - "Start GIF Recording (eDP-1)", - "Record as optimized GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "eDP-1"), - ), - "record-gif-region": ( - "Start GIF Region Recording", - "Record selected region as GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "selection"), - ), - "rec-gif-region": ( - "Start GIF Region Recording", - "Record selected region as GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "selection"), - ), - # Control commands - "stop": ( - "Stop Recording", - "Stop the current screen recording", - "media-playback-stop-symbolic", - lambda: self._run_script("record", "stop"), - ), - # Conversion commands - "convert-webm": ( - "Convert Latest to WebM", - "Convert latest MKV recording to WebM format", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "webm"), - ), - "conv-webm": ( - "Convert Latest to WebM", - "Convert latest MKV recording to WebM format", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "webm"), - ), - "convert-iphone": ( - "Convert Latest for iPhone", - "Convert latest MKV recording for iPhone compatibility", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "iphone"), - ), - "conv-iphone": ( - "Convert Latest for iPhone", - "Convert latest MKV recording for iPhone compatibility", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "iphone"), - ), - "convert-youtube": ( - "Convert Latest for YouTube", - "Convert latest recording for YouTube upload", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "youtube"), - ), - "conv-youtube": ( - "Convert Latest for YouTube", - "Convert latest recording for YouTube upload", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "youtube"), - ), - "convert-gif": ( - "Convert Latest to GIF", - "Convert latest recording to GIF format", - "image-x-generic-symbolic", - lambda: self._run_script("convert", "gif"), - ), - "conv-gif": ( - "Convert Latest to GIF", - "Convert latest recording to GIF format", - "image-x-generic-symbolic", - lambda: self._run_script("convert", "gif"), - ), - # File-based conversion commands (these will be handled specially) - "convert-webm-file": ( - "Convert File to WebM", - "Type filename after command (e.g., convert-webm-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-webm-file": ( - "Convert File to WebM", - "Type filename after command (e.g., conv-webm-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "convert-iphone-file": ( - "Convert File for iPhone", - "Type filename after command (e.g., convert-iphone-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-iphone-file": ( - "Convert File for iPhone", - "Type filename after command (e.g., conv-iphone-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "convert-youtube-file": ( - "Convert File for YouTube", - "Type filename after command (e.g., convert-youtube-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-youtube-file": ( - "Convert File for YouTube", - "Type filename after command (e.g., conv-youtube-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "convert-gif-file": ( - "Convert File to GIF", - "Type filename after command (e.g., convert-gif-file video.mkv)", - "image-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-gif-file": ( - "Convert File to GIF", - "Type filename after command (e.g., conv-gif-file video.mkv)", - "image-x-generic-symbolic", - None, # Will be handled in query method - ), - } - - if command in command_info: - title, subtitle, icon, action = command_info[command] - if action is not None: # Regular command - return Result( - title=title, - subtitle=subtitle, - icon_name=icon, - action=action, - relevance=1.0, - plugin_name=self.display_name, - ) - else: # File-based command, show instruction - return Result( - title=title, - subtitle=subtitle, - icon_name=icon, - action=lambda: None, # No action for instruction - relevance=1.0, - plugin_name=self.display_name, - ) - - return None - - def query(self, query_string: str) -> List[Result]: - """Search for screencapture actions based on query.""" - # Import here to avoid circular imports - - # Clean the query string - query = query_string.strip().lower() - - results = [] - - # Check for file-based conversion commands with parameters - file_conversion_commands = { - "convert-webm-file": "webm", - "conv-webm-file": "webm", - "convert-iphone-file": "iphone", - "conv-iphone-file": "iphone", - "convert-youtube-file": "youtube", - "conv-youtube-file": "youtube", - "convert-gif-file": "gif", - "conv-gif-file": "gif", - } - - # Parse query for file-based commands - query_parts = query.split() - if len(query_parts) >= 2: - command = query_parts[0] - file_param = " ".join(query_parts[1:]) - - if command in file_conversion_commands: - format_type = file_conversion_commands[command] - return [ - Result( - title=f"Convert {file_param} to {format_type.upper()}", - subtitle=f"Convert specified file to {format_type} format", - icon_name=( - "video-x-generic-symbolic" - if format_type != "gif" - else "image-x-generic-symbolic" - ), - action=lambda fp=file_param, ft=format_type: self._run_script_with_file( - ft, fp - ), - relevance=1.0, - plugin_name=self.display_name, - ) - ] - - # Check if query matches a command and return it as a result - command_result = self._get_command_result(query) - if command_result: - return [command_result] - - # Check recording status - is_recording = self._is_recording() - - # If recording is active, show stop button first with highest relevance - if is_recording: - results.append( - Result( - title="Stop Recording", - subtitle="Stop the current screen recording", - icon_name="media-playback-stop-symbolic", - action=lambda: self._run_script("record", "stop"), - relevance=2.0, # Highest relevance to appear at top - plugin_name=self.display_name, - ) - ) - - # Screenshot actions - results.extend( - [ - Result( - title="Take Screenshot", - subtitle="Capture the entire screen (eDP-1)", - icon_name="camera-photo-symbolic", - action=lambda: self._run_script("screenshot", "eDP-1"), - relevance=1.0, - plugin_name=self.display_name, - ), - Result( - title="Take Region Screenshot", - subtitle="Capture a selected region", - icon_name="camera-photo-symbolic", - action=lambda: self._run_script("screenshot", "selection"), - relevance=0.9, - plugin_name=self.display_name, - ), - Result( - title="Take Screenshot (Both Displays)", - subtitle="Capture both displays combined", - icon_name="video-joined-displays-symbolic", - action=lambda: self._run_script("screenshot", "both"), - relevance=0.8, - plugin_name=self.display_name, - ), - Result( - title="Take Screenshot (HDMI-A-1)", - subtitle="Capture HDMI display", - icon_name="video-display-symbolic", - action=lambda: self._run_script("screenshot", "HDMI-A-1"), - relevance=0.7, - plugin_name=self.display_name, - ), - ] - ) - - # Standard recording actions - results.extend( - [ - Result( - title="Start Recording (eDP-1)", - subtitle="Record the main display with audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record", "eDP-1"), - relevance=0.7, - plugin_name=self.display_name, - ), - Result( - title="Start Region Recording", - subtitle="Record a selected region", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record", "selection"), - relevance=0.6, - plugin_name=self.display_name, - ), - Result( - title="Start Recording (HDMI-A-1)", - subtitle="Record HDMI display with audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record", "HDMI-A-1"), - relevance=0.5, - plugin_name=self.display_name, - ), - ] - ) - - # No-audio recording actions - results.extend( - [ - Result( - title="Start Recording No Audio (eDP-1)", - subtitle="Record the main display without audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-noaudio", "eDP-1"), - relevance=0.65, - plugin_name=self.display_name, - ), - Result( - title="Start Region Recording No Audio", - subtitle="Record a selected region without audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-noaudio", "selection"), - relevance=0.55, - plugin_name=self.display_name, - ), - Result( - title="Start Recording No Audio (HDMI-A-1)", - subtitle="Record HDMI display without audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-noaudio", "HDMI-A-1"), - relevance=0.45, - plugin_name=self.display_name, - ), - ] - ) - - # High-quality recording actions - results.extend( - [ - Result( - title="Start HQ Recording (eDP-1)", - subtitle="High-quality recording for YouTube", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-hq", "eDP-1"), - relevance=0.4, - plugin_name=self.display_name, - ), - Result( - title="Start HQ Region Recording", - subtitle="High-quality region recording", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-hq", "selection"), - relevance=0.3, - plugin_name=self.display_name, - ), - Result( - title="Start HQ Recording (HDMI-A-1)", - subtitle="High-quality HDMI recording", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-hq", "HDMI-A-1"), - relevance=0.2, - plugin_name=self.display_name, - ), - ] - ) - - # GIF recording actions - results.extend( - [ - Result( - title="Start GIF Recording (eDP-1)", - subtitle="Record as optimized GIF", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-gif", "eDP-1"), - relevance=0.1, - plugin_name=self.display_name, - ), - Result( - title="Start GIF Region Recording", - subtitle="Record selected region as GIF", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-gif", "selection"), - relevance=0.05, - plugin_name=self.display_name, - ), - ] - ) - - # Conversion actions - results.extend( - [ - Result( - title="Convert Latest to WebM", - subtitle="Convert latest MKV recording to WebM format", - icon_name="video-x-generic-symbolic", - action=lambda: self._run_script("convert", "webm"), - relevance=0.01, - plugin_name=self.display_name, - ), - Result( - title="Convert Latest for iPhone", - subtitle="Convert latest MKV recording for iPhone compatibility", - icon_name="video-x-generic-symbolic", - action=lambda: self._run_script("convert", "iphone"), - relevance=0.01, - plugin_name=self.display_name, - ), - Result( - title="Convert Latest for YouTube", - subtitle="Convert latest recording for YouTube upload", - icon_name="video-x-generic-symbolic", - action=lambda: self._run_script("convert", "youtube"), - relevance=0.01, - plugin_name=self.display_name, - ), - Result( - title="Convert Latest to GIF", - subtitle="Convert latest recording to GIF format", - icon_name="image-x-generic-symbolic", - action=lambda: self._run_script("convert", "gif"), - relevance=0.01, - plugin_name=self.display_name, - ), - ] - ) - - return results diff --git a/modules/launcher/plugins/system.py b/modules/launcher/plugins/system.py deleted file mode 100644 index bc7dba0..0000000 --- a/modules/launcher/plugins/system.py +++ /dev/null @@ -1,242 +0,0 @@ -import json -import os -import shlex -import threading -import time -from typing import List, Set, Union - -import config.data as data -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class SystemPlugin(PluginBase): - """ - Plugin for system commands and actions. - """ - - def __init__(self): - super().__init__() - self.display_name = "System" - self.description = "System commands and actions" - - # JSON cache file for system binaries - self.bin_cache_file = os.path.join(data.CACHE_DIR, "system_binaries.json") - - # In-memory cache for system binaries - self._bin_cache: Set[str] = set() - self._last_bin_update = 0 - self._bin_update_interval = 300 # 5 minutes - - # Background cache building - self._cache_building = False - self._cache_thread = None - - def initialize(self): - """Initialize the system plugin.""" - self.set_triggers(["bin"]) - self._load_bin_cache() - self._start_background_cache_update() - - def cleanup(self): - """Cleanup the system plugin.""" - self._bin_cache.clear() - if self._cache_thread and self._cache_thread.is_alive(): - # Note: We don't join the thread to avoid blocking cleanup - pass - - def _load_bin_cache(self): - """Load binary cache from JSON file.""" - try: - if os.path.exists(self.bin_cache_file): - with open(self.bin_cache_file, "r", encoding="utf-8") as f: - cache_data = json.load(f) - self._bin_cache = set(cache_data.get("binaries", [])) - self._last_bin_update = cache_data.get("last_update", 0) - else: - print( - "SystemPlugin: No cache file found, will build cache in background" - ) - except Exception as e: - print(f"SystemPlugin: Error loading binary cache: {e}") - self._bin_cache = set() - self._last_bin_update = 0 - - def _save_bin_cache(self): - """Save binary cache to JSON file.""" - try: - # Ensure the cache directory exists - os.makedirs(data.CACHE_DIR, exist_ok=True) - - cache_data = { - "binaries": sorted(list(self._bin_cache)), - "last_update": self._last_bin_update, - "cache_version": "1.0", - } - - with open(self.bin_cache_file, "w", encoding="utf-8") as f: - json.dump(cache_data, f, indent=2) - except Exception as e: - print(f"SystemPlugin: Error saving binary cache: {e}") - - def _start_background_cache_update(self): - """Start background thread to update binary cache.""" - current_time = time.time() - - # Check if cache needs updating - if ( - current_time - self._last_bin_update > self._bin_update_interval - or not self._bin_cache - ): - if not self._cache_building: - self._cache_building = True - self._cache_thread = threading.Thread( - target=self._build_bin_cache_background, daemon=True - ) - self._cache_thread.start() - - def _build_bin_cache_background(self): - """Build binary cache in background thread.""" - try: - new_cache = set() - processed_paths = set() # Avoid duplicate paths - - for path in os.environ["PATH"].split(":"): - # Skip empty paths and duplicates - if not path or path in processed_paths: - continue - processed_paths.add(path) - - if os.path.exists(path) and os.path.isdir(path): - try: - # Use os.scandir for better performance than os.listdir - with os.scandir(path) as entries: - for entry in entries: - if entry.is_file(follow_symlinks=False) and os.access( - entry.path, os.X_OK - ): - new_cache.add(entry.name) - except (PermissionError, FileNotFoundError, OSError): - continue - - # Update cache atomically - self._bin_cache = new_cache - self._last_bin_update = time.time() - - # Save to disk - self._save_bin_cache() - - except Exception as e: - print(f"SystemPlugin: Error building binary cache: {e}") - finally: - self._cache_building = False - - def query(self, query_string: str) -> List[Result]: - """Search for system commands matching the query.""" - query = query_string.strip() - - if not query: - return [] - - results = [] - - # Parse the query to extract binary name and arguments - query_parts = query.split() - if not query_parts: - return [] - - binary_query = query_parts[0].lower() - full_command = query # Keep the original case and spacing - - # Check system binaries - # Start background update if needed (non-blocking) - but only if cache is empty or very old - if not self._bin_cache or ( - time.time() - self._last_bin_update > self._bin_update_interval - ): - self._start_background_cache_update() - - # Optimize search with early termination and result limiting - exact_matches = [] - prefix_matches = [] - partial_matches = [] - max_results = 20 # Limit total results for performance - - for binary in self._bin_cache: - # Pre-compute lowercase once - binary_lower = binary.lower() - - # Skip if no match at all - if binary_query not in binary_lower: - continue - - # Categorize matches for better sorting - if binary_lower == binary_query: - # Exact match - highest priority - display_command = full_command - command_to_execute = full_command - relevance = 1.0 - exact_matches.append( - (binary, display_command, command_to_execute, relevance) - ) - elif binary_lower.startswith(binary_query): - # Prefix match - high priority - display_command = binary - command_to_execute = binary - relevance = 0.9 - prefix_matches.append( - (binary, display_command, command_to_execute, relevance) - ) - else: - # Partial match - lower priority - display_command = binary - command_to_execute = binary - relevance = 0.7 - partial_matches.append( - (binary, display_command, command_to_execute, relevance) - ) - - # Early termination if we have enough good matches - if len(exact_matches) + len(prefix_matches) >= max_results: - break - - # Combine results in priority order - all_matches = exact_matches + prefix_matches + partial_matches - - # Convert to Result objects (limit to max_results) - for binary, display_command, command_to_execute, relevance in all_matches[ - :max_results - ]: - result = Result( - title=display_command, - subtitle=f"Execute: {display_command}", - icon_name="terminal", - action=self._create_action(command_to_execute), - relevance=relevance, - plugin_name=self.display_name, - data={"command": command_to_execute, "id": binary}, - ) - results.append(result) - - return results # Already sorted by priority - - def _create_action(self, command: Union[str, List[str]]): - """Create an action function for the given command.""" - - def action(): - self._execute_command(command) - - return action - - def _execute_command(self, command: Union[str, List[str]]): - """Execute a system command.""" - try: - if isinstance(command, str): - # Handle string commands with arguments - split into list for proper execution - command_list = shlex.split(command) - exec_shell_command_async(command_list) - else: - # Handle list commands (backward compatibility) - exec_shell_command_async(command) - except Exception as e: - print(f"SystemPlugin: Error executing command '{command}': {e}") diff --git a/modules/launcher/plugins/tmux.py b/modules/launcher/plugins/tmux.py deleted file mode 100644 index 5c93fad..0000000 --- a/modules/launcher/plugins/tmux.py +++ /dev/null @@ -1,334 +0,0 @@ -import subprocess -import threading -import time -from typing import List - -import config.data as data -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class TmuxPlugin(PluginBase): - """ - Plugin for managing tmux sessions through the launcher. - """ - - def __init__(self): - super().__init__() - self.display_name = "Tmux Manager" - self.description = "Manage tmux sessions - create, attach, rename, and kill" - - # Cache for sessions to avoid repeated subprocess calls - self._sessions_cache = [] - self._last_cache_update = 0 - # Cache sessions for 10 seconds (increased from 2) - self._cache_ttl = 10 - - # Threading for auto-refresh - only when actively used - self.refresh_thread = None - self.stop_refresh = threading.Event() - self._last_query_time = 0 - # Stop refreshing after 30 seconds of inactivity - self._active_refresh_timeout = 30 - - def initialize(self): - """Initialize the tmux plugin.""" - self.set_triggers(["tmux"]) - # Don't start refresh thread immediately - start it when first used - - def cleanup(self): - """Cleanup the tmux plugin.""" - self.stop_refresh.set() - if self.refresh_thread: - self.refresh_thread.join(timeout=1) - self._sessions_cache.clear() - - def _start_refresh_thread(self): - """Start background thread to refresh session cache.""" - if not self.refresh_thread or not self.refresh_thread.is_alive(): - self.refresh_thread = threading.Thread( - target=self._refresh_sessions_background, daemon=True - ) - self.refresh_thread.start() - - def _refresh_sessions_background(self): - """Background thread to refresh sessions cache only when actively used.""" - while not self.stop_refresh.is_set(): - try: - current_time = time.time() - - # Stop refreshing if plugin hasn't been used recently - if current_time - self._last_query_time > self._active_refresh_timeout: - print("TmuxPlugin: Stopping background refresh due to inactivity") - break - - if current_time - self._last_cache_update > self._cache_ttl: - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = current_time - - self.stop_refresh.wait(5) - except Exception as e: - print(f"TmuxPlugin: Error in refresh thread: {e}") - self.stop_refresh.wait(10) # Wait longer on error - - def _get_tmux_sessions(self): - """Get list of tmux sessions.""" - try: - result = subprocess.run( - ["tmux", "list-sessions", "-F", "#{session_name}"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - return [ - s.strip() for s in result.stdout.strip().split("\n") if s.strip() - ] - return [] - except ( - subprocess.TimeoutExpired, - subprocess.CalledProcessError, - FileNotFoundError, - ) as e: - print(f"TmuxPlugin: Error getting tmux sessions: {e}") - return [] - - def query(self, query_string: str) -> List[Result]: - """Process tmux queries.""" - query = query_string.strip().lower() - results = [] - - # Track usage and start refresh thread if needed - current_time = time.time() - self._last_query_time = current_time - - # Start refresh thread if not running and plugin is being used - if not self.refresh_thread or not self.refresh_thread.is_alive(): - self._start_refresh_thread() - - # Get current sessions (use cache if recent) - if current_time - self._last_cache_update > self._cache_ttl: - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = current_time - - sessions = self._sessions_cache - - # Handle specific commands - if query.startswith("new ") or query.startswith("create "): - session_name = query.split(" ", 1)[1].strip() if " " in query else "" - results.append(self._create_new_session_result(session_name)) - - elif query.startswith("kill ") or query.startswith("delete "): - session_name = query.split(" ", 1)[1].strip() if " " in query else "" - if session_name: - matching_sessions = [ - s for s in sessions if session_name.lower() in s.lower() - ] - for session in matching_sessions: - results.append(self._create_kill_session_result(session)) - - elif query.startswith("rename "): - parts = query.split(" ", 2) - if len(parts) >= 3: - old_name, new_name = parts[1], parts[2] - if old_name in sessions: - results.append( - self._create_rename_session_result(old_name, new_name) - ) - - else: - # Show existing sessions for attachment - if sessions: - # Filter sessions based on query - if query: - filtered_sessions = [s for s in sessions if query in s.lower()] - else: - filtered_sessions = sessions - - for session in filtered_sessions: - results.append(self._create_attach_session_result(session)) - - # Always show option to create new session - if not query or "new" in query or "create" in query: - results.append( - self._create_new_session_result( - query - if query and not any(cmd in query for cmd in ["new", "create"]) - else "" - ) - ) - - return results - - def _create_attach_session_result(self, session_name: str) -> Result: - """Create result for attaching to a session.""" - return Result( - title=f"Attach to '{session_name}'", - subtitle=f"Connect to tmux session: {session_name}", - icon_name="terminal", - action=lambda: self._attach_to_session(session_name), - relevance=0.9, - data={"type": "attach", "session": session_name}, - ) - - def _create_new_session_result(self, session_name: str = "") -> Result: - """Create result for creating a new session.""" - display_name = session_name if session_name else "new session" - return Result( - title=f"Create '{display_name}'", - subtitle=f"Create new tmux session{ - f': {session_name}' if session_name else '' - }", - icon_name="plus", - action=lambda: self._create_session(session_name), - relevance=0.8, - data={"type": "create", "session": session_name}, - ) - - def _create_kill_session_result(self, session_name: str) -> Result: - """Create result for killing a session.""" - return Result( - title=f"Kill '{session_name}'", - subtitle=f"Terminate tmux session: {session_name}", - icon_name="trash", - action=lambda: self._kill_session(session_name), - relevance=0.7, - data={"type": "kill", "session": session_name}, - ) - - def _create_rename_session_result(self, old_name: str, new_name: str) -> Result: - """Create result for renaming a session.""" - return Result( - title=f"Rename '{old_name}' to '{new_name}'", - subtitle=f"Rename tmux session from {old_name} to {new_name}", - icon_name="config", - action=lambda: self._rename_session(old_name, new_name), - relevance=0.6, - data={"type": "rename", "old_session": old_name, "new_session": new_name}, - ) - - def _attach_to_session(self, session_name: str): - """Attach to an existing tmux session.""" - try: - terminal_cmd = self._get_terminal_command( - f"tmux attach-session -t '{session_name}'" - ) - exec_shell_command_async(terminal_cmd) - print(f"TmuxPlugin: Attaching to session '{session_name}'") - except Exception as e: - print(f"TmuxPlugin: Error attaching to session '{session_name}': {e}") - - def _create_session(self, session_name: str = ""): - """Create a new tmux session.""" - try: - if not session_name: - # Generate a default name - existing_sessions = self._get_tmux_sessions() - counter = 0 - while str(counter) in existing_sessions: - counter += 1 - session_name = str(counter) - - # Clean the session name - clean_name = session_name.strip().replace(" ", "_") - - # Create session - subprocess.run( - ["tmux", "new-session", "-d", "-s", clean_name], check=True, timeout=10 - ) - - # Launch terminal and attach - terminal_cmd = self._get_terminal_command( - f"tmux attach-session -t '{clean_name}'" - ) - exec_shell_command_async(terminal_cmd) - - # Refresh cache - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = time.time() - - print(f"TmuxPlugin: Created and attached to session '{clean_name}'") - except Exception as e: - print(f"TmuxPlugin: Error creating session '{session_name}': {e}") - - def _kill_session(self, session_name: str): - """Kill a tmux session.""" - try: - subprocess.run( - ["tmux", "kill-session", "-t", session_name], check=True, timeout=10 - ) - - # Refresh cache - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = time.time() - - print(f"TmuxPlugin: Killed session '{session_name}'") - except Exception as e: - print(f"TmuxPlugin: Error killing session '{session_name}': {e}") - - def _rename_session(self, old_name: str, new_name: str): - """Rename a tmux session.""" - try: - clean_name = new_name.strip().replace(" ", "_") - subprocess.run( - ["tmux", "rename-session", "-t", old_name, clean_name], - check=True, - timeout=10, - ) - - # Refresh cache - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = time.time() - - print(f"TmuxPlugin: Renamed session '{old_name}' to '{clean_name}'") - except Exception as e: - print( - f"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}" - ) - - def _get_terminal_command(self, cmd: str) -> str: - """Get terminal command based on configured terminal or available terminals.""" - # First try to use the configured terminal command - if hasattr(data, "TERMINAL_COMMAND") and data.TERMINAL_COMMAND: - parts = data.TERMINAL_COMMAND.split() - terminal = parts[0] - - try: - # Check if the configured terminal is available - subprocess.run( - ["which", terminal], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return f"{data.TERMINAL_COMMAND} {cmd}" - except subprocess.CalledProcessError: - # If configured terminal is not available, fall back to defaults - pass - - # Fallback to checking available terminals - terminals = [ - ("kitty", f"kitty -e {cmd}"), - ("alacritty", f"alacritty -e {cmd}"), - ("foot", f"foot {cmd}"), - ("gnome-terminal", f"gnome-terminal -- {cmd}"), - ("konsole", f"konsole -e {cmd}"), - ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"), - ] - - for term, term_cmd in terminals: - try: - # Check if terminal is available - subprocess.run( - ["which", term], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return term_cmd - except subprocess.CalledProcessError: - continue - - # Default fallback - return f"kitty -e {cmd}" diff --git a/modules/launcher/plugins/wallpaper.py b/modules/launcher/plugins/wallpaper.py deleted file mode 100644 index 3d4aa28..0000000 --- a/modules/launcher/plugins/wallpaper.py +++ /dev/null @@ -1,976 +0,0 @@ -import colorsys -import hashlib -import json -import os -import random -import re -import threading -import time -from typing import Dict, List, Optional -from loguru import logger - -from gi.repository import GdkPixbuf -from PIL import Image - -import config.data as data -from fabric.utils.helpers import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class WallpaperPlugin(PluginBase): - """ - Plugin for wallpaper management with search, random selection, and matugen integration. - """ - - def __init__(self): - super().__init__() - self.display_name = "Wallpaper" - self.wallpapers = [] - self.cache_dir = f"{data.CACHE_DIR}/thumbs" - self.thumbnail_cache: Dict[str, Optional[GdkPixbuf.Pixbuf]] = {} - self.thumbnail_loading = set() # Track which thumbnails are being loaded - self.schemes = { - "scheme-tonal-spot": "Tonal Spot", - "scheme-content": "Content", - "scheme-expressive": "Expressive", - "scheme-fidelity": "Fidelity", - "scheme-fruit-salad": "Fruit Salad", - "scheme-monochrome": "Monochrome", - "scheme-neutral": "Neutral", - "scheme-rainbow": "Rainbow", - } - - def initialize(self): - """Initialize the wallpaper plugin.""" - self.set_triggers(["wall"]) - self._load_wallpapers() - os.makedirs(self.cache_dir, exist_ok=True) - # Start background thumbnail creation - self._start_background_thumbnail_creation() - - def cleanup(self): - """Cleanup the wallpaper plugin.""" - pass - - def _load_wallpapers(self): - """Load available wallpapers from the wallpapers directory.""" - try: - if os.path.exists(data.WALLPAPERS_DIR): - self.wallpapers = sorted( - [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)] - ) - else: - logger.error(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}") - except Exception as e: - logger.error(f"Error loading wallpapers: {e}") - - def _is_image(self, filename: str) -> bool: - """Check if file is a supported image format.""" - return filename.lower().endswith( - (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") - ) - - def _get_matugen_state(self) -> bool: - """Get current matugen state from config.json.""" - self.matugen_enabled = True # Default to True - try: - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - self.matugen_enabled = config.get("matugen_enabled", True) - except FileNotFoundError: - # File doesn't exist, keep default True - pass - except Exception as e: - logger.error(f"Error reading config file: {e}") - # Keep default True on error - - return self.matugen_enabled - - def _set_matugen_state(self, enabled: bool): - """Set matugen state and save to config.json.""" - self.matugen_enabled = enabled - - # Save the state to config.json - try: - # Read current config - config = {} - if os.path.exists(data.CONFIG_FILE): - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - - # Update matugen state - config["matugen_enabled"] = enabled - - # Write back to config file - with open(data.CONFIG_FILE, "w") as f: - json.dump(config, f, indent=4) - - except Exception as e: - logger.error(f"Error writing matugen state to config: {e}") - - # Clear the search query after toggling matugen - self._clear_launcher_query() - - # # Send notification - # status = "enabled" if enabled else "disabled" - # exec_shell_command_async( - # f"notify-send '๐ŸŽจ Matugen' 'Dynamic colors {status}' -a '{ - # data.APP_NAME_CAP - # }' -e" - # ) - - def _clear_launcher_query(self): - """Clear the launcher search query and reset to trigger.""" - try: - # Try to access the launcher through the fabric Application - from gi.repository import GLib - - from fabric import Application - - app = Application.get_default() - - if app and hasattr(app, "launcher"): - launcher = app.launcher - if launcher and hasattr(launcher, "search_entry"): - - def clear_and_refresh(): - # Clear the search entry to just the trigger - launcher.search_entry.set_text("wall ") - # Position cursor at the end - launcher.search_entry.set_position(-1) - # Trigger the search to show default wallpaper view - if hasattr(launcher, "_perform_search"): - launcher._perform_search("wall ") - return False - - # Use a small delay to ensure the action completes first - GLib.timeout_add(50, clear_and_refresh) - return - - # Fallback: try to find launcher instance through other means - import gc - - for obj in gc.get_objects(): - if hasattr(obj, "__class__") and obj.__class__.__name__ == "Launcher": - if hasattr(obj, "search_entry") and hasattr(obj, "_perform_search"): - - def clear_and_refresh(): - obj.search_entry.set_text("wall ") - obj.search_entry.set_position(-1) - obj._perform_search("wall ") - return False - - GLib.timeout_add(50, clear_and_refresh) - return - - except Exception as e: - logger.error(f"Could not clear launcher query: {e}") - - def _get_cache_path(self, filename: str) -> str: - """Get cache path for wallpaper thumbnail.""" - file_hash = hashlib.md5(filename.encode("utf-8")).hexdigest() - return os.path.join(self.cache_dir, f"{file_hash}.png") - - def _create_thumbnail(self, filename: str) -> str: - """Create thumbnail for wallpaper if it doesn't exist.""" - full_path = os.path.join(data.WALLPAPERS_DIR, filename) - cache_path = self._get_cache_path(filename) - - if not os.path.exists(cache_path): - try: - with Image.open(full_path) as img: - # Use faster thumbnail creation with smaller size for better performance - img.thumbnail((32, 32), Image.Resampling.LANCZOS) - img.save(cache_path, "PNG", optimize=True) - except Exception as e: - logger.error(f"Error creating thumbnail for {filename}: {e}") - return None - - return cache_path - - def _start_background_thumbnail_creation(self): - """Start background thread to create thumbnails for all wallpapers.""" - - def create_thumbnails(): - for wallpaper in self.wallpapers: - if wallpaper not in self.thumbnail_loading: - self.thumbnail_loading.add(wallpaper) - try: - cache_path = self._create_thumbnail(wallpaper) - if cache_path and os.path.exists(cache_path): - # Load thumbnail into memory cache - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, 32, 32, True - ) - self.thumbnail_cache[wallpaper] = pixbuf - except Exception as e: - logger.error( - f"Error loading thumbnail for {wallpaper}: {e}" - ) - self.thumbnail_cache[wallpaper] = None - else: - self.thumbnail_cache[wallpaper] = None - except Exception as e: - logger.error(f"Error processing thumbnail for {wallpaper}: {e}") - self.thumbnail_cache[wallpaper] = None - finally: - self.thumbnail_loading.discard(wallpaper) - - # Small delay to prevent overwhelming the system - time.sleep(0.01) - - # Start background thread - thread = threading.Thread(target=create_thumbnails, daemon=True) - thread.start() - - def _get_thumbnail_fast(self, filename: str) -> Optional[GdkPixbuf.Pixbuf]: - """Get thumbnail quickly from cache or return None if not ready.""" - # Return cached thumbnail if available - if filename in self.thumbnail_cache: - return self.thumbnail_cache[filename] - - # Check if thumbnail file exists and load it immediately - cache_path = self._get_cache_path(filename) - if os.path.exists(cache_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, 32, 32, True - ) - self.thumbnail_cache[filename] = pixbuf - return pixbuf - except Exception as e: - logger.error(f"Error loading thumbnail for {filename}: {e}") - self.thumbnail_cache[filename] = None - return None - - # If not in cache and file doesn't exist, trigger background creation - if filename not in self.thumbnail_loading: - self.thumbnail_loading.add(filename) - - def create_async(): - try: - cache_path = self._create_thumbnail(filename) - if cache_path and os.path.exists(cache_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, 32, 32, True - ) - self.thumbnail_cache[filename] = pixbuf - except Exception as e: - print(f"Error loading thumbnail for {filename}: {e}") - self.thumbnail_cache[filename] = None - else: - self.thumbnail_cache[filename] = None - except Exception as e: - print(f"Error creating thumbnail for {filename}: {e}") - self.thumbnail_cache[filename] = None - finally: - self.thumbnail_loading.discard(filename) - - thread = threading.Thread(target=create_async, daemon=True) - thread.start() - - return None - - def _set_wallpaper(self, filename: str, scheme: str = None): - """Set wallpaper and apply matugen color scheme if enabled.""" - full_path = os.path.join(data.WALLPAPERS_DIR, filename) - current_wall = os.path.expanduser("~/.current.wall") - - if scheme is None: - scheme = self._get_current_scheme() - - # Update current wallpaper symlink - if os.path.isfile(current_wall) or os.path.islink(current_wall): - os.remove(current_wall) - os.symlink(full_path, current_wall) - - # Always set the wallpaper image - exec_shell_command_async( - f'swww img "{ - full_path - }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' - ) - - # If Matugen is enabled, also apply the color scheme - matugen_enabled = self._get_matugen_state() - if matugen_enabled: - exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}') - - def _set_random_wallpaper(self): - """Set a random wallpaper.""" - if not self.wallpapers: - return - - filename = random.choice(self.wallpapers) - self._set_wallpaper(filename) - # Show notification for immediate feedback - exec_shell_command_async( - f"notify-send '๐ŸŽฒ Random Wallpaper' 'Applied: {filename}' -a '{ - data.APP_NAME_CAP - }' -e" - ) - return filename - - def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: - """Convert HSL color value to RGB HEX string.""" - # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0 - hue = h / 360.0 - r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S - r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255) - return f"#{r_int:02X}{g_int:02X}{b_int:02X}" - - def _is_valid_hex_color(self, hex_color: str) -> bool: - """Check if string is a valid hex color.""" - if not hex_color.startswith("#"): - hex_color = "#" + hex_color - return bool(re.match(r"^#[0-9A-Fa-f]{6}$", hex_color)) - - def _get_current_scheme(self) -> str: - """Get current color scheme from config (default to tonal-spot).""" - try: - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - return config.get("current_scheme", "scheme-tonal-spot") - except FileNotFoundError: - # File doesn't exist, return default - return "scheme-tonal-spot" - except Exception as e: - print(f"Error reading current scheme from config: {e}") - return "scheme-tonal-spot" - - def _set_current_scheme(self, scheme: str): - """Set current color scheme and apply it.""" - scheme_name = self.schemes.get(scheme, scheme) - matugen_enabled = self._get_matugen_state() - - # Save the scheme to config - try: - # Read current config - config = {} - if os.path.exists(data.CONFIG_FILE): - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - - # Update current scheme - config["current_scheme"] = scheme - - # Write back to config file - with open(data.CONFIG_FILE, "w") as f: - json.dump(config, f, indent=4) - - except Exception as e: - print(f"Error saving current scheme to config: {e}") - return - - # Apply the scheme to current wallpaper if matugen is enabled - if matugen_enabled: - current_wall = os.path.expanduser("~/.current.wall") - if os.path.exists(current_wall) and os.path.islink(current_wall): - # Get the current wallpaper path - wallpaper_path = os.readlink(current_wall) - if os.path.exists(wallpaper_path): - # Apply the new scheme to current wallpaper - exec_shell_command_async( - f'matugen image "{wallpaper_path}" -t {scheme}' - ) - - # Send notification - exec_shell_command_async( - f"notify-send '๐ŸŽจ Color Scheme' 'Applied { - scheme_name - } scheme' -a '{data.APP_NAME_CAP}' -e" - ) - else: - # No current wallpaper, just show scheme change notification - exec_shell_command_async( - f"notify-send '๐ŸŽจ Color Scheme' 'Set to { - scheme_name - } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e" - ) - else: - # No current wallpaper, just show scheme change notification - exec_shell_command_async( - f"notify-send '๐ŸŽจ Color Scheme' 'Set to { - scheme_name - } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e" - ) - else: - # Matugen is disabled, just save the setting - exec_shell_command_async( - f"notify-send '๐ŸŽจ Color Scheme' 'Set to { - scheme_name - } (matugen disabled)' -a '{data.APP_NAME_CAP}' -e" - ) - - def _apply_hex_color(self, hex_color: str, scheme: str = None): - """Apply hex color using matugen. Assumes matugen is enabled.""" - if not hex_color.startswith("#"): - hex_color = "#" + hex_color - - if scheme is None: - scheme = self._get_current_scheme() - - exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') - - def _apply_hex_color_direct(self, hex_color: str, scheme: str = None): - """Apply hex color using matugen (following example_wallpapers.py pattern).""" - if not hex_color.startswith("#"): - hex_color = "#" + hex_color - - if scheme is None: - scheme = self._get_current_scheme() - - exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') - - # Send notification - exec_shell_command_async( - f"notify-send '๐ŸŽจ Hex Color Applied' 'Applied color: {hex_color}' -a '{ - data.APP_NAME_CAP - }' -e" - ) - - def _generate_random_hex_color(self) -> str: - """Generate a random hex color.""" - hue = random.randint(0, 360) - return self._hsl_to_rgb_hex(hue) - - def _get_status_indicators(self) -> tuple: - """Get current status indicators for display.""" - current_scheme = self._get_current_scheme() - matugen_enabled = self._get_matugen_state() - scheme_name = self.schemes.get(current_scheme, current_scheme) - - indicators = [] - if not matugen_enabled: - indicators.append("Matugen Off") - - indicator_text = " โ€ข " + " โ€ข ".join(indicators) if indicators else "" - status_text = f"Matugen: { - 'Enabled' if matugen_enabled else 'Disabled' - } โ€ข Scheme: {scheme_name}" - - return indicator_text, status_text, current_scheme, matugen_enabled - - def query(self, query_string: str) -> List[Result]: - """Search wallpapers and provide management options.""" - results = [] - query = query_string.lower().strip() - - # Get status indicators for consistent display - indicator_text, status_text, current_scheme, matugen_enabled = ( - self._get_status_indicators() - ) - - # Special commands - if query.strip() == "random": - # Show result for random wallpaper (execute on Enter) - results.append( - Result( - title=f"Random Wallpaper{indicator_text}", - subtitle=f"Set a random wallpaper โ€ข {status_text}", - icon_name="media-playlist-shuffle-symbolic", - action=lambda: self._set_random_wallpaper(), - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "random", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif query.startswith("random") and query.strip() != "random": - # Show suggestion for random wallpaper (partial match) - results.append( - Result( - title=f"Random Wallpaper{indicator_text}", - subtitle=f"Set a random wallpaper โ€ข {status_text}", - icon_name="media-playlist-shuffle-symbolic", - action=lambda: self._set_random_wallpaper(), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "random_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Hex color commands - if "color" in query or "hex" in query or query.startswith("#"): - # Check for scheme specification in the query - scheme = self._get_current_scheme() - for scheme_id, scheme_name in self.schemes.items(): - if ( - scheme_name.lower() in query.lower() - or scheme_id.lower() in query.lower() - ): - scheme = scheme_id - break - - # Check for hex color patterns - hex_match = re.search(r"#?([0-9A-Fa-f]{6})", query) - if hex_match: - hex_color = "#" + hex_match.group(1) - scheme_name = self.schemes.get(scheme, scheme) - - # Check if this is a complete hex color input (6 digits) - # Execute immediately when we have a complete 6-digit hex color - if len(hex_match.group(1)) == 6: - # Check if there's additional text after the hex color - hex_end_pos = hex_match.end() - remaining_text = query[hex_end_pos:].strip() - - # Only show result if no additional text after hex color (exact match) - if not remaining_text: - # Hex colors work when matugen is OFF (following example_wallpapers.py pattern) - if not matugen_enabled: - # Show result for hex color (execute on Enter) - results.append( - Result( - title=f"Apply Hex Color: {hex_color}{ - indicator_text - }", - subtitle=f"Apply with {scheme_name} scheme โ€ข { - status_text - }", - icon_name="color-picker-symbolic", - action=lambda c=hex_color, s=scheme: self._apply_hex_color_direct( - c, s - ), - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "hex_color", - "color": hex_color, - "scheme": scheme, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Show error result when matugen is enabled - results.append( - Result( - title=f"Cannot Apply Hex Color: {hex_color}{ - indicator_text - }", - subtitle="Matugen is enabled โ€ข Disable matugen to use hex colors", - icon_name="color-picker-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "hex_color_failed", - "color": hex_color, - "bypass_max_results": True, - }, - ) - ) - else: - # Show suggestion for hex color with additional text (partial match) - results.append( - Result( - title=f"Apply Hex Color: {hex_color}{indicator_text}", - subtitle=f"Apply with {scheme_name} scheme โ€ข { - status_text - }", - icon_name="color-picker-symbolic", - action=lambda c=hex_color, s=scheme: ( - self._apply_hex_color_direct(c, s) - if not matugen_enabled - else None - ), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "hex_color_suggestion", - "color": hex_color, - "scheme": scheme, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Incomplete hex color, show as suggestion - results.append( - Result( - title=f"Hex Color (incomplete): {hex_color}", - subtitle="Complete the 6-digit hex color to apply", - icon_name="color-picker-symbolic", - action=lambda: None, - relevance=0.7, - plugin_name=self.display_name, - data={ - "action": "hex_color_incomplete", - "color": hex_color, - "bypass_max_results": True, - }, - ) - ) - elif "random" in query and ("color" in query or "hex" in query): - # Check for exact matches for random color commands - if ( - query.strip() == "color random" - or query.strip() == "hex random" - or query.strip() == "random color" - or query.strip() == "random hex" - ): - # Random hex color - show result (execute on Enter) - scheme_name = self.schemes.get(scheme, scheme) - - # Check if matugen is disabled (hex colors work when matugen is OFF) - if not matugen_enabled: - results.append( - Result( - title=f"Random Hex Color{indicator_text}", - subtitle=f"Generate and apply random color with { - scheme_name - } scheme โ€ข {status_text}", - icon_name="color-picker-symbolic", - action=lambda s=scheme: self._apply_hex_color_direct( - self._generate_random_hex_color(), s - ), - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "random_hex", - "scheme": scheme, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Show error result when matugen is enabled - results.append( - Result( - title=f"Cannot Apply Random Color{indicator_text}", - subtitle="Matugen is enabled โ€ข Disable matugen to use hex colors", - icon_name="color-picker-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "random_hex_failed", - "bypass_max_results": True, - }, - ) - ) - else: - # Show suggestion for random hex color (partial match) - results.append( - Result( - title=f"Random Hex Color{indicator_text}", - subtitle=f"Generate and apply random color โ€ข {status_text}", - icon_name="color-picker-symbolic", - action=lambda s=scheme: ( - self._apply_hex_color_direct( - self._generate_random_hex_color(), s - ) - if not matugen_enabled - else None - ), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "random_hex_suggestion", - "scheme": scheme, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Show hex color help - results.append( - Result( - title="Hex Color Commands", - subtitle="Use: color #FF5733, hex #00FF00, color random", - icon_name="color-picker-symbolic", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"action": "hex_help", "bypass_max_results": True}, - ) - ) - - # Color scheme commands - if "scheme" in query: - current_scheme = self._get_current_scheme() - matugen_enabled = self._get_matugen_state() - - # Show all available schemes - for scheme_id, scheme_name in self.schemes.items(): - # Check if this scheme matches the query (for filtering) - if query.strip() == "scheme": - # Show all schemes when just "scheme" is typed - scheme_matches = True - else: - # Extract search terms from query (remove "scheme" keyword) - search_terms = query.replace("scheme", "").strip().split() - scheme_matches = False - - # Check if any search term matches the scheme name or ID - for term in search_terms: - if ( - term.lower() in scheme_name.lower() - or term.lower() in scheme_id.lower() - ): - scheme_matches = True - break - - if scheme_matches: - # Create indicators - indicators = [] - if scheme_id == current_scheme: - indicators.append("Current") - if not matugen_enabled: - indicators.append("Matugen Off") - - indicator_text = ( - " โ€ข " + " โ€ข ".join(indicators) if indicators else "" - ) - - results.append( - Result( - title=f"{scheme_name}{indicator_text}", - subtitle=f"Set color scheme to {scheme_name}" - + ( - " (requires matugen enabled)" - if not matugen_enabled - else "" - ), - icon_name="color-management-symbolic", - action=lambda s=scheme_id: self._set_current_scheme(s), - relevance=1.0 if scheme_id == current_scheme else 0.8, - plugin_name=self.display_name, - data={ - "action": "scheme_select", - "scheme": scheme_id, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Matugen controls - if "matugen" in query: - current_state = self._get_matugen_state() - - # Check for exact command matches - if query.strip() == "matugen on" or query.strip() == "matugen enable": - # Show result for enabling matugen (execute on Enter) - results.append( - Result( - title=f"Enable Matugen{indicator_text}", - subtitle=f"Turn on dynamic color generation โ€ข {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(True), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "matugen_on", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif query.strip() == "matugen off" or query.strip() == "matugen disable": - # Show result for disabling matugen (execute on Enter) - results.append( - Result( - title=f"Disable Matugen{indicator_text}", - subtitle=f"Turn off dynamic color generation โ€ข {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(False), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "matugen_off", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif query.strip() == "matugen toggle": - # Show result for toggling matugen (execute on Enter) - new_state = not current_state - results.append( - Result( - title=f"Toggle Matugen to { - 'Enabled' if new_state else 'Disabled' - }{indicator_text}", - subtitle=f"Switch matugen to { - 'enabled' if new_state else 'disabled' - } โ€ข {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(new_state), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "matugen_toggle", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif ("on" in query or "enable" in query) and not query.strip().endswith( - ("on", "enable") - ): - # Show suggestion for enabling (partial match) - results.append( - Result( - title=f"Enable Matugen{indicator_text}", - subtitle=f"Turn on dynamic color generation โ€ข {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(True), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "matugen_on_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif ("off" in query or "disable" in query) and not query.strip().endswith( - ("off", "disable") - ): - # Show suggestion for disabling (partial match) - results.append( - Result( - title=f"Disable Matugen{indicator_text}", - subtitle=f"Turn off dynamic color generation โ€ข {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(False), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "matugen_off_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif "toggle" in query and not query.strip().endswith("toggle"): - # Show suggestion for toggling (partial match) - results.append( - Result( - title=f"Toggle Matugen{indicator_text}", - subtitle=f"Switch matugen state โ€ข {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(not current_state), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "matugen_toggle_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Show current state with scheme info - results.append( - Result( - title=f"Matugen: {'Enabled' if current_state else 'Disabled'}{ - indicator_text - }", - subtitle=f"Dynamic colors โ€ข {status_text}", - icon_name="color-management-symbolic", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"action": "matugen_status", "bypass_max_results": True}, - ) - ) - - # Status command - if query == "status" or query == "info": - results.append( - Result( - title=f"Wallpaper System Status{indicator_text}", - subtitle=status_text, - icon_name="color-management-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"action": "status", "bypass_max_results": True}, - ) - ) - - # Show quick actions - results.append( - Result( - title=f"Random Wallpaper{indicator_text}", - subtitle=f"Set a random wallpaper โ€ข {status_text}", - icon_name="media-playlist-shuffle-symbolic", - action=lambda: self._set_random_wallpaper(), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "random_quick", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Search wallpapers by filename - Show ALL wallpapers like example_wallpapers.py - if not query or ( - query - and "matugen" not in query - and "random" not in query - and "scheme" not in query - and "status" not in query - and "info" not in query - and "color" not in query - and "hex" not in query - ): - matching_wallpapers = [] - for wallpaper in self.wallpapers: - if not query or query in wallpaper.lower(): - # Calculate relevance - relevance = 1.0 if query == wallpaper.lower() else 0.7 - if query and query in wallpaper.lower(): - relevance = 0.8 - matching_wallpapers.append((wallpaper, relevance)) - - # Sort by relevance and show ALL wallpapers (like example_wallpapers.py) - matching_wallpapers.sort(key=lambda x: x[1], reverse=True) - - # Show ALL wallpapers instead of limiting (following example_wallpapers.py pattern) - for wallpaper, relevance in matching_wallpapers: - # Use fast thumbnail loading - icon = self._get_thumbnail_fast(wallpaper) - - results.append( - Result( - title=f"{wallpaper}{indicator_text if not query else ''}", - subtitle=f"Set as wallpaper{ - ' โ€ข ' + status_text if not query else '' - }", - icon=icon, - icon_name="image-x-generic-symbolic" if not icon else None, - action=lambda w=wallpaper: self._set_wallpaper(w), - relevance=relevance, - plugin_name=self.display_name, - data={ - "wallpaper": wallpaper, - "action": "set", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Sort by relevance - no limit since we use bypass_max_results - results.sort(key=lambda x: x.relevance, reverse=True) - return results diff --git a/modules/launcher/plugins/websearch.py b/modules/launcher/plugins/websearch.py deleted file mode 100644 index 80f992b..0000000 --- a/modules/launcher/plugins/websearch.py +++ /dev/null @@ -1,259 +0,0 @@ -import subprocess -import urllib.parse -from typing import List - -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class WebSearchPlugin(PluginBase): - """ - Web search plugin that supports multiple search engines. - """ - - def __init__(self): - super().__init__() - self.display_name = "Web Search" - self.description = "Search the web using various search engines" - self.current_trigger = "" # Track the current active trigger - - # Define search engines with their URLs and icons - self.search_engines = { - "google": { - "name": "Google", - "url": "https://www.google.com/search?q={}", - "icon": "google-symbolic", - "description": "Search with Google", - }, - "duckduckgo": { - "name": "DuckDuckGo", - "url": "https://duckduckgo.com/?q={}", - "icon": "preferences-desktop-search", - "description": "Search with DuckDuckGo (privacy-focused)", - }, - "youtube": { - "name": "YouTube", - "url": "https://www.youtube.com/results?search_query={}", - "icon": "youtube-symbolic", - "description": "Search videos on YouTube", - }, - "github": { - "name": "GitHub", - "url": "https://github.com/search?q={}", - "icon": "github-desktop-symbolic", - "description": "Search repositories on GitHub", - }, - "stackoverflow": { - "name": "Stack Overflow", - "url": "https://stackoverflow.com/search?q={}", - "icon": "stack", - "description": "Search programming questions on Stack Overflow", - }, - "wikipedia": { - "name": "Wikipedia", - "url": "https://en.wikipedia.org/wiki/Special:Search?search={}", - "icon": "search-menus-symbolic", - "description": "Search articles on Wikipedia", - }, - "reddit": { - "name": "Reddit", - "url": "https://www.reddit.com/search/?q={}", - "icon": "reddit-symbolic", - "description": "Search discussions on Reddit", - }, - "linkedin": { - "name": "LinkedIn", - "url": "https://www.linkedin.com/search/results/all/?keywords={}", - "icon": "link", - "description": "Search professionals and jobs on LinkedIn", - }, - } - - # Default search engine - self.default_engine = "google" - - def initialize(self): - """Initialize the web search plugin.""" - # Set up triggers for web search - only main triggers, not individual search engines - triggers = ["?"] - - # Don't add individual search engine triggers to avoid cluttering trigger keywords - # Search engines can still be used within web search context (e.g., "web google cats") - - self.set_triggers(triggers) - - def get_active_trigger(self, query_string: str) -> str: - """Override to track which trigger was activated.""" - trigger = super().get_active_trigger(query_string) - if trigger: - self.current_trigger = trigger.strip() - return trigger - - def cleanup(self): - """Cleanup the web search plugin.""" - pass - - def query(self, query_string: str) -> List[Result]: - """Process web search queries.""" - results = [] - - if not query_string.strip(): - # Show available search engines when no query - return self._get_search_engine_list() - - # Check if the query is a URL - if self._is_url(query_string): - results.append(self._create_url_result(query_string)) - return results - - # Parse query to check if it starts with a search engine name - engine_name, search_query = self._parse_engine_query(query_string) - - if engine_name and engine_name in self.search_engines: - # Specific search engine specified in query (e.g., "google cats") - if search_query: - # Search with specific engine - results.append(self._create_search_result(engine_name, search_query)) - else: - # Show specific engine info - results.append(self._create_engine_info_result(engine_name)) - else: - # General search - offer multiple engines - search_query = query_string.strip() - - # Add default search engine first - results.append( - self._create_search_result(self.default_engine, search_query) - ) - - # Add other popular search engines - popular_engines = ["duckduckgo", "youtube", "github"] - for engine in popular_engines: - if engine != self.default_engine: - results.append(self._create_search_result(engine, search_query)) - - return results - - def _parse_engine_query(self, query: str) -> tuple[str, str]: - """Parse query to extract search engine and search terms.""" - query = query.strip().lower() - - for engine in self.search_engines.keys(): - if query.startswith(f"{engine} "): - return engine, query[len(engine) :].strip() - elif query == engine: - return engine, "" - - return "", query - - def _is_url(self, text: str) -> bool: - """Check if the text is a URL.""" - text = text.strip().lower() - return text.startswith(("http://", "https://", "www.")) or ( - "." in text and " " not in text and len(text) > 3 - ) - - def _create_url_result(self, url: str) -> Result: - """Create a result for opening a URL directly.""" - # Add protocol if missing - if not url.startswith(("http://", "https://")): - if url.startswith("www."): - url = "https://" + url - else: - url = "https://" + url - - return Result( - title=f"Open {url}", - subtitle="Open this URL in your default browser", - icon_name="link", - action=lambda u=url: self._open_url(u), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "url", "url": url}, - ) - - def _open_url(self, url: str): - """Open a URL directly in the default browser.""" - try: - subprocess.Popen( - ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - except Exception as e: - print(f"Failed to open URL: {e}") - - def _get_search_engine_list(self) -> List[Result]: - """Get list of available search engines.""" - results = [] - - for engine_id, engine_info in self.search_engines.items(): - result = Result( - title=engine_info["name"], - subtitle=engine_info["description"], - icon_name=engine_info["icon"], - action=lambda e=engine_id: self._show_engine_help(e), - relevance=1.0 if engine_id == self.default_engine else 0.8, - plugin_name=self.display_name, - data={"type": "engine_info", "engine": engine_id}, - ) - results.append(result) - - return results - - def _create_search_result(self, engine_id: str, query: str) -> Result: - """Create a search result for a specific engine and query.""" - engine_info = self.search_engines[engine_id] - - return Result( - title=f"Search '{query}' on {engine_info['name']}", - subtitle=f"{engine_info['description']} - {query}", - icon_name=engine_info["icon"], - action=lambda e=engine_id, q=query: self._perform_search(e, q), - relevance=1.0 if engine_id == self.default_engine else 0.9, - plugin_name=self.display_name, - data={"type": "search", "engine": engine_id, "query": query}, - ) - - def _create_engine_info_result(self, engine_id: str) -> Result: - """Create an info result for a specific search engine.""" - engine_info = self.search_engines[engine_id] - - return Result( - title=f"{engine_info['name']} Search", - subtitle=f"{engine_info['description']} - Type your search query", - icon_name=engine_info["icon"], - action=lambda: None, # No action for info result - relevance=1.0, - plugin_name=self.display_name, - data={"type": "engine_ready", "engine": engine_id}, - ) - - def _perform_search(self, engine_id: str, query: str): - """Perform a web search using the specified engine.""" - if not query.strip(): - return - - engine_info = self.search_engines.get(engine_id) - if not engine_info: - return - - # URL encode the search query - encoded_query = urllib.parse.quote_plus(query) - search_url = engine_info["url"].format(encoded_query) - - try: - # Open the search URL in the default browser - subprocess.Popen( - ["xdg-open", search_url], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - print(f"Failed to open search URL: {e}") - - def _show_engine_help(self, engine_id: str): - """Show help for a specific search engine.""" - engine_info = self.search_engines.get(engine_id) - if engine_info: - print( - f"Search engine: {engine_info['name']} - {engine_info['description']}" - ) diff --git a/modules/launcher/result.py b/modules/launcher/result.py deleted file mode 100644 index fce3550..0000000 --- a/modules/launcher/result.py +++ /dev/null @@ -1,52 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Callable, Optional - -from gi.repository import GdkPixbuf, Gtk - - -@dataclass -class Result: - """ - Represents a search result that can be displayed and activated. - """ - - # Display information - title: str - subtitle: str = "" - subtitle_markup: Optional[str] = None # Pango markup for subtitle - description: str = "" - icon: Optional[GdkPixbuf.Pixbuf] = None - icon_name: Optional[str] = None - icon_markup: Optional[str] = None - # Behavior - action: Optional[Callable[[], Any]] = None - relevance: float = 0.0 - - # Custom widget support - custom_widget: Optional[Gtk.Widget] = None - - # Metadata - plugin_name: str = "" - data: Optional[dict] = None - - def activate(self): - """Activate this result (execute its action).""" - if self.action: - return self.action() - else: - raise NotImplementedError("No action defined for this result") - - def __post_init__(self): - """Post-initialization processing.""" - # Ensure relevance is within valid range - self.relevance = max(0.0, min(1.0, self.relevance)) - - # Set default data if None - if self.data is None: - self.data = {} - - def __str__(self): - return f"Result(title='{self.title}', relevance={self.relevance})" - - def __repr__(self): - return self.__str__() diff --git a/modules/launcher/result_item.py b/modules/launcher/result_item.py deleted file mode 100644 index baaaa43..0000000 --- a/modules/launcher/result_item.py +++ /dev/null @@ -1,163 +0,0 @@ -import gi -from fabric.core.service import Signal -from fabric.widgets.box import Box -from fabric.widgets.eventbox import EventBox -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from modules.launcher.result import Result - -gi.require_version("Gtk", "3.0") - - -class ResultItem(EventBox): - """ - Widget for displaying a single search result. - """ - - # Signals - @Signal - def clicked(self, index: int) -> None: - """Emitted when result is clicked.""" - pass - - @Signal - def hovered(self, index: int) -> None: - """Emitted when result is hovered.""" - pass - - def __init__( - self, result: Result, selected: bool = False, index: int = 0, **kwargs - ): - super().__init__(name="launcher-result-item", **kwargs) - - self.result = result - self._selected = selected - self.index = index - - # Setup UI - self._setup_ui() - - # Connect signals - self.connect("button-press-event", self._on_button_press) - self.connect("enter-notify-event", self._on_enter) - self.connect("leave-notify-event", self._on_leave) - - # Set initial selection state - self.set_selected(selected) - - def _setup_ui(self): - """Setup the result item UI.""" - # Main container - main_box = Box( - name="result-item-main", - orientation="h", - spacing=12, - h_align="fill", - v_align="center", - ) - self.add(main_box) - - # Icon - if self.result.icon: - icon_widget = Image(pixbuf=self.result.icon, name="result-item-icon") - elif self.result.icon_name: - icon_widget = Image( - icon_name=self.result.icon_name, icon_size=48, name="result-item-icon" - ) - elif self.result.icon_markup: - icon_widget = Label( - markup=self.result.icon_markup, name="launcher-icon-label" - ) - else: - # Default icon - icon_widget = Image( - icon_name="application-default-icon", - icon_size=48, - name="result-item-icon", - ) - - main_box.add(icon_widget) - - # Text container - text_box = Box( - name="result-item-text", - orientation="v", - spacing=2, - h_expand=True, - v_align="center", - ) - main_box.add(text_box) - - # Title - title_label = Label( - label=self.result.title, - name="result-item-title", - h_align="start", - v_align="center", - ellipsize="end", - ) - text_box.add(title_label) - - # Subtitle (if present) - if self.result.subtitle or self.result.subtitle_markup: - if self.result.subtitle_markup: - # Use markup for subtitle (supports Pango markup) - subtitle_label = Label( - markup=self.result.subtitle_markup, - name="result-item-subtitle", - h_align="start", - v_align="center", - ellipsize="end", - ) - else: - # Use plain text for subtitle - subtitle_label = Label( - label=self.result.subtitle, - name="result-item-subtitle", - h_align="start", - v_align="center", - ellipsize="end", - ) - text_box.add(subtitle_label) - - # Plugin name (small text) - if self.result.plugin_name: - plugin_label = Label( - label=f"via {self.result.plugin_name}", - name="result-item-plugin", - h_align="start", - v_align="center", - ellipsize="end", - ) - text_box.add(plugin_label) - - def set_selected(self, selected: bool): - """Set the selection state of this result item.""" - self._selected = selected - - if selected: - self.add_style_class("selected") - else: - self.remove_style_class("selected") - - def get_selected(self) -> bool: - """Get the selection state of this result item.""" - return self._selected - - def _on_button_press(self, widget, event): - """Handle button press events.""" - if event.button == 1: # Left click - self.clicked.emit(self.index) - return True - return False - - def _on_enter(self, widget, event): - """Handle mouse enter events.""" - # Emit hover signal to update selection - self.hovered.emit(self.index) - return False - - def _on_leave(self, widget, event): - """Handle mouse leave events.""" - # Could be used for hover effects cleanup - return False diff --git a/modules/launcher/trigger_config.py b/modules/launcher/trigger_config.py deleted file mode 100644 index 3a568c4..0000000 --- a/modules/launcher/trigger_config.py +++ /dev/null @@ -1,57 +0,0 @@ -import json -import os -from typing import Any, Dict, List - -from fabric.utils import get_relative_path - - -class TriggerConfig: - def __init__(self, config_path: str = None): - if config_path is None: - config_path = get_relative_path("../../config/assets/launcher.json") - - self.config_path = config_path - - # Load configuration from JSON file - config = {"launcher_config": {}, "settings": {}} - if os.path.exists(config_path): - try: - with open(config_path, "r", encoding="utf-8") as f: - config = json.load(f) - except Exception as e: - print(f"Error loading trigger config: {e}") - - self.config = config - self.launcher_config = self.config.get("launcher_config", {}) - - # Initialize settings with defaults - default_settings = { - "max_examples_shown": 2, - "default_icon": "application-default-icon", - "fallback_example_template": "{trigger} <search>", - "config_version": "1.0", - } - self.settings = {**default_settings, **self.config.get("settings", {})} - - def get_trigger_examples(self, trigger: str) -> List[str]: - examples = self.launcher_config.get(trigger, {}).get("examples", []) - if not examples: - template = self.settings.get( - "fallback_example_template", "{trigger} <search>" - ) - examples = [template.format(trigger=trigger)] - return examples - - def get_trigger_icon(self, trigger: str) -> str: - icon = self.launcher_config.get(trigger, {}).get( - "icon", self.settings.get("default_icon", "application-default-icon") - ) - return icon - - def get_trigger_description(self, trigger: str) -> str: - return self.launcher_config.get(trigger, {}).get( - "description", f"{trigger} - No description available" - ) - - def get_all_triggers(self) -> Dict[str, Dict[str, Any]]: - return self.launcher_config.copy() diff --git a/modules/notification/notification.py b/modules/notification/notification.py deleted file mode 100644 index 77be746..0000000 --- a/modules/notification/notification.py +++ /dev/null @@ -1,1281 +0,0 @@ -import os -import hashlib -import time -import uuid - -from fabric.utils import get_relative_path -from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # type: ignore -from loguru import logger - -import config.data as data -from .unified_cache import ( - get_unified_cache_key, - save_to_cache, - get_from_cache, - cleanup_cache, - get_fallback_icon, - ensure_cache_dir -) -from fabric.notifications import ( - Notification, - NotificationAction, - NotificationCloseReason, -) -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.eventbox import EventBox -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from utils.roam import modus_service -from utils.functions import escape_markup_text -from widgets.custom_image import CustomImage -from widgets.customrevealer import SlideRevealer -from widgets.wayland import WaylandWindow as Window -from services.modus import notification_service - -NOTIFICATION_WIDTH = 360 -NOTIFICATION_IMAGE_SIZE = 48 - -NOTIFICATION_WIDTH = 360 -NOTIFICATION_IMAGE_SIZE = 48 - -# Unified notification cache directory (for both app icons and notification images) -UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications") - -# Backward compatibility constants -NOTIFICATION_ICON_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR -NOTIFICATION_IMAGE_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR - - -def ensure_notification_cache_dirs(): - """Ensure unified notification cache directory exists""" - os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True) - - -def cleanup_old_cache_files(): - """Clean up old notification cache files (older than 7 days)""" - try: - if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR): - return - - current_time = time.time() - week_ago = current_time - (7 * 24 * 60 * 60) # 7 days - - for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): - filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - if file_mtime < week_ago: - os.unlink(filepath) - logger.debug(f"Cleaned up old notification cache: {filename}") - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") - except Exception as e: - logger.warning(f"Failed to cleanup notification cache: {e}") - - -def get_unified_cache_key(source_data, size=None, app_name=None): - """Generate a unified cache key that works for both app icons and notification images""" - try: - if hasattr(source_data, "get_pixels"): - # For pixbuf data - use hash of pixel data for deterministic caching - try: - pixel_data = source_data.get_pixels() - image_hash = hashlib.md5(pixel_data).hexdigest()[:8] - return image_hash - except Exception: - # Fallback to timestamp if pixel data fails - return str(int(time.time()))[:8] - elif isinstance(source_data, str): - # For file paths - create hash-based name - if source_data.startswith("file://"): - source_data = source_data[7:] - - # Create hash from file path and size - hash_input = source_data - if size: - hash_input += f"_{size[0]}x{size[1]}" - - return hashlib.md5(hash_input.encode()).hexdigest()[:8] - else: - # Fallback to timestamp - return str(int(time.time()))[:8] - except Exception: - # Ultimate fallback - return str(int(time.time()))[:8] - - -# Backward compatibility -get_cache_key = get_unified_cache_key - - -def save_pixbuf_to_cache(pixbuf, cache_key, cache_dir): - """Save a pixbuf to the specified cache directory""" - try: - ensure_notification_cache_dirs() - cache_path = os.path.join(cache_dir, f"{cache_key}.png") - - # Don't overwrite existing cache - if os.path.exists(cache_path): - return cache_path - - pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Cached notification icon: {cache_key}") - return cache_path - except Exception as e: - logger.warning(f"Failed to cache notification icon: {e}") - return None - - -def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None): - """Get a cached pixbuf or return None if not found""" - if cache_dir is None: - cache_dir = NOTIFICATION_ICON_CACHE_DIR - - try: - cache_path = os.path.join(cache_dir, f"{cache_key}.png") - if os.path.exists(cache_path): - logger.debug(f"Using cached notification icon: {cache_key}") - logger.debug(f"Using cached notification icon: {cache_key}") - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, fallback_size[0], fallback_size[1], True - ) - except Exception as e: - logger.warning(f"Failed to load cached notification icon: {e}") - return None - - -def cache_notification_icon(source, size=(48, 48), app_name=None): - """Optimized notification icon caching with immediate pixbuf generation and caching""" - try: - ensure_notification_cache_dirs() - - # Handle different source types with optimized caching - if isinstance(source, str): - cache_key = get_unified_cache_key(source, size, app_name) - - # Check cache first for immediate return - cached_pixbuf = get_cached_pixbuf( - cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR - ) - if cached_pixbuf: - return cached_pixbuf - - # Load, cache, and return icon in one optimized flow - if source.startswith("file://"): - # Local file URL - process and cache immediately - file_path = source[7:] - pixbuf = load_and_cache_local_icon(file_path, cache_key, size) - elif os.path.exists(source): - # Direct file path - process and cache immediately - pixbuf = load_and_cache_local_icon(source, cache_key, size) - else: - # Icon name - resolve from theme and cache immediately - pixbuf = load_and_cache_theme_icon(source, cache_key, size) - - return pixbuf - - elif hasattr(source, "scale_simple"): - # Already a pixbuf - cache it directly with optimized flow - cache_key = get_unified_cache_key(source, size, app_name) - - # Check cache first - cached_pixbuf = get_cached_pixbuf( - cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR - ) - if cached_pixbuf: - return cached_pixbuf - - # Scale once and cache immediately - scaled_pixbuf = source.scale_simple( - size[0], size[1], GdkPixbuf.InterpType.BILINEAR - ) - save_pixbuf_to_cache(scaled_pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR) - return scaled_pixbuf - - except Exception as e: - logger.warning(f"Failed to cache notification icon: {e}") - - # Return fallback with caching - return get_fallback_notification_icon(size) - - -def load_and_cache_local_icon(file_path, cache_key, size): - """Load a local icon file and cache it""" - try: - if os.path.exists(file_path): - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - file_path, size[0], size[1], True - ) - save_pixbuf_to_cache(pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR) - return pixbuf - except Exception as e: - logger.warning(f"Failed to load local notification icon {file_path}: {e}") - - return get_fallback_notification_icon(size) - - -def load_and_cache_theme_icon(icon_name, cache_key, size): - """Load an icon from the current theme and cache it""" - # For simplicity, just use fallback since theme icon loading is complex in GTK4 - logger.debug(f"Using fallback for theme icon {icon_name}") - return get_fallback_notification_icon(size) - - -def get_fallback_notification_icon(size=(48, 48)): - """Get the fallback notification icon""" - try: - fallback_path = get_relative_path("../../config/assets/icons/notification.png") - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - fallback_path, size[0], size[1], True - ) - except Exception as e: - logger.warning(f"Failed to load fallback notification icon: {e}") - # Create a simple colored rectangle as ultimate fallback - try: - return GdkPixbuf.Pixbuf.new( - GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1] - ) - except: - return None - - -def get_notification_image_cache_key(notification_id, image_pixbuf): - """Generate a deterministic cache key based on image content to prevent duplicate caching""" - try: - # Use image content hash for deterministic caching - if image_pixbuf and hasattr(image_pixbuf, "get_pixels"): - try: - pixel_data = image_pixbuf.get_pixels() - image_hash = hashlib.md5(pixel_data).hexdigest()[:8] - return image_hash - except Exception: - # If pixel data fails, use image dimensions + timestamp - try: - width = image_pixbuf.get_width() - height = image_pixbuf.get_height() - dimension_hash = hashlib.md5(f"{width}x{height}".encode()).hexdigest()[:8] - return dimension_hash - except Exception: - pass - - # Fallback to timestamp for invalid pixbufs - return str(int(time.time()))[:8] - except Exception: - # Ultimate fallback - return str(int(time.time()))[:8] - - -def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)): - """Smart notification image caching that avoids duplicate caching""" - try: - ensure_notification_cache_dirs() - - # Generate deterministic cache key based on image content - cache_key = get_notification_image_cache_key(notification_id, image_pixbuf) - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - - # Check if already cached to avoid redundant work - if os.path.exists(cache_path): - logger.debug(f"Image cache hit - already exists: {cache_key}") - return cache_path, cache_key - - # Try to scale and save the image - try: - scaled_pixbuf = image_pixbuf.scale_simple( - size[0], size[1], GdkPixbuf.InterpType.BILINEAR - ) - scaled_pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Generated and cached notification image: {cache_key}") - return cache_path, cache_key - except Exception as scale_error: - logger.debug(f"Failed to cache image (temp file likely gone): {scale_error}") - return None, None - - except Exception as e: - logger.warning(f"Failed to cache notification image: {e}") - return None, None - - -def get_cached_notification_image(cache_key): - """Get a cached notification image or return None if not found""" - try: - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - if os.path.exists(cache_path): - return GdkPixbuf.Pixbuf.new_from_file(cache_path) - except Exception as e: - logger.warning(f"Failed to load cached notification image: {e}") - return None - - -def cleanup_notification_image_cache(cache_key=None): - """Clean up notification image cache - specific key or all""" - try: - ensure_notification_cache_dirs() - - if cache_key: - # Remove specific cached image - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - if os.path.exists(cache_path): - os.unlink(cache_path) - logger.debug(f"Cleaned up cached notification image: {cache_key}") - else: - # Remove all cached images - for filename in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR): - if filename.endswith(".png"): - filepath = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, filename) - try: - os.unlink(filepath) - logger.debug(f"Cleaned up cached notification image: {filename}") - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") - except Exception as e: - logger.warning(f"Failed to cleanup notification image cache: {e}") - - -def cleanup_notification_specific_caches( - app_icon_source=None, notification_image_cache_key=None -): - """Clean up caches specific to a notification (both app icon and notification image) - SINGLE ICON SIZE""" - try: - # Clean up notification image cache - if notification_image_cache_key: - cleanup_notification_image_cache(notification_image_cache_key) - - # Clean up app icon cache for this specific source (only 35x35 version) - if app_icon_source: - # Only clean 35x35 version since we only cache this size now - cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35)) - cache_path_35 = os.path.join( - NOTIFICATION_ICON_CACHE_DIR, f"{cache_key_35}.png" - ) - if os.path.exists(cache_path_35): - os.unlink(cache_path_35) - logger.debug(f"Cleaned up cached app icon (35x35): {cache_key_35}") - - except Exception as e: - logger.warning(f"Failed to cleanup notification specific caches: {e}") - - -def cleanup_all_notification_caches(): - """Clean up ALL notification caches (icons and images)""" - try: - # Clean icon cache - if os.path.exists(NOTIFICATION_ICON_CACHE_DIR): - for filename in os.listdir(NOTIFICATION_ICON_CACHE_DIR): - if filename.endswith(".png"): - filepath = os.path.join(NOTIFICATION_ICON_CACHE_DIR, filename) - try: - os.unlink(filepath) - logger.debug(f"Cleaned up cached notification icon: {filename}") - except Exception as e: - logger.warning( - f"Failed to cleanup icon cache file {filename}: {e}" - ) - - # Clean image cache - cleanup_notification_image_cache() - logger.info("Cleaned up all notification caches") - except Exception as e: - logger.warning(f"Failed to cleanup all notification caches: {e}") - - -def verify_cache_persistence(): - """Verify that cached icons persist and can be loaded after restart""" - try: - icon_cache_files = [] - image_cache_files = [] - - if os.path.exists(NOTIFICATION_ICON_CACHE_DIR): - icon_cache_files = [ - f for f in os.listdir(NOTIFICATION_ICON_CACHE_DIR) if f.endswith(".png") - ] - - if os.path.exists(NOTIFICATION_IMAGE_CACHE_DIR): - image_cache_files = [ - f - for f in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR) - if f.endswith(".png") - ] - - logger.info( - f"Cache persistence check: {len(icon_cache_files)} icons, { - len(image_cache_files) - } images cached" - ) - - # Test loading a few cached items to verify they work - for cache_file in icon_cache_files[:2]: # Test first 2 icon files - try: - cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file) - test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached icon: {cache_file}") - except Exception as e: - logger.warning(f"Failed to load cached icon {cache_file}: {e}") - - for cache_file in image_cache_files[:2]: # Test first 2 image files - try: - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file) - test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached image: {cache_file}") - except Exception as e: - logger.warning(f"Failed to load cached image {cache_file}: {e}") - - return len(icon_cache_files) + len(image_cache_files) > 0 - - except Exception as e: - logger.error(f"Failed to verify cache persistence: {e}") - return False - - -def migrate_persistent_notifications(): - """Migrate persistent notifications to use cached assets when temp files are gone""" - try: - from services.modus import notification_service - - migrated_count = 0 - for cached_notification in notification_service.cached_notifications: - notification = cached_notification._notification - - # Check if notification has image_pixbuf but temp file might be gone - if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: - try: - # Try to access pixel data to test if temp file still exists - notification.image_pixbuf.get_pixels() - except Exception: - # Temp file is gone, ensure app icon is cached as fallback - try: - if hasattr(notification, "app_icon") and notification.app_icon: - cache_notification_icon(notification.app_icon, (35, 35)) - migrated_count += 1 - logger.debug( - f"Migrated notification for { - notification.app_name - } to use cached app icon" - ) - except Exception as cache_error: - logger.debug( - f"Failed to cache app icon for {notification.app_name}: { - cache_error - }" - ) - - if migrated_count > 0: - logger.info( - f"Migrated {migrated_count} persistent notifications to use cached assets" - ) - - except Exception as e: - logger.warning(f"Failed to migrate persistent notifications: {e}") - - -# Initialize cache and verify persistence on module load -ensure_notification_cache_dirs() -cleanup_old_cache_files() -verify_cache_persistence() - -# Run migration for persistent notifications on startup -try: - migrate_persistent_notifications() -except Exception as e: - logger.debug(f"Migration skipped (service not ready): {e}") - - -def preload_notification_assets(notification): - """Preload and cache notification assets with robust error handling for persistent notifications - SINGLE ICON SIZE""" - try: - # Cache app icon only at content size (35x35) - scale down for headers at runtime - if hasattr(notification, "app_icon") and notification.app_icon: - try: - # Only cache at 35x35 to reduce disk usage - headers will scale this down - cache_notification_icon(notification.app_icon, (35, 35)) - except Exception as icon_error: - logger.debug( - f"Failed to preload app icon for {notification.app_name}: { - icon_error - }" - ) - - # Cache notification image if available - if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: - cache_notification_image(notification.id, notification.image_pixbuf, (35, 35)) - - except Exception as e: - logger.warning(f"Failed to preload notification assets: {e}") - - -def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 280): - """Configure revealer for ultra-smooth animation""" - revealer.duration = duration - - -class ActionButton(Button): - def __init__( - self, action: NotificationAction, index: int, total: int, notification_box - ): - super().__init__( - name="action-button", - h_expand=True, - on_clicked=self.on_clicked, - child=Label(name="button-label", label=action.label), - ) - self.action = action - self.notification_box = notification_box - style_class = ( - "start-action" - if index == 0 - else "end-action" - if index == total - 1 - else "middle-action" - ) - self.add_style_class(style_class) - self.connect( - "enter-notify-event", lambda *_: notification_box.hover_button(self) - ) - self.connect( - "leave-notify-event", lambda *_: notification_box.unhover_button(self) - ) - - def on_clicked(self, *_): - # Mark for cache cleanup when action button is clicked - self.notification_box._should_cleanup_cache = True - self.action.invoke() - self.action.parent.close("dismissed-by-user") - - -class NotificationWidget(Box): - def __init__( - self, - notification: Notification, - timeout_ms=data.NOTIFICATION_TIMEOUT, - show_close_button=True, - name="notification", - **kwargs, - ): - self.show_close_button = show_close_button - self.close_button = None - self._is_hovered = False - self.notification_image_cache_key = None # Track cached image for cleanup - self.app_icon_source = ( - notification.app_icon - ) # Track app icon source for cleanup - self._should_cleanup_cache = False # Only cleanup cache on manual dismissal - - super().__init__( - size=(NOTIFICATION_WIDTH, -1), - name=name, - orientation="v", - h_align="fill", - h_expand=True, - children=[ - self.create_content(notification), - self.create_action_buttons(notification), - ], - ) - - self.notification = notification - self.timeout_ms = timeout_ms - self._timeout_id = None - - # Add hover events to the main notification widget - self.connect("enter-notify-event", self._on_enter_notify) - self.connect("leave-notify-event", self._on_leave_notify) - - self.start_timeout() - - def create_header(self, notification): - """Create notification header with optimized cached app icon - SINGLE CACHE SIZE""" - try: - # Get 35x35 cached icon and scale down to 24x24 for header - cached_app_icon_pixbuf = cache_notification_icon( - notification.app_icon, (35, 35) - ) - - if cached_app_icon_pixbuf: - # Scale down the 35x35 cached icon to 24x24 for header display - header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple( - 24, 24, GdkPixbuf.InterpType.BILINEAR - ) - app_icon = CustomImage(pixbuf=header_icon_pixbuf) - app_icon.set_name("notification-icon") - else: - # Fallback to theme icon if caching fails completely - app_icon = Image( - name="notification-icon", - icon_name="notifications", - icon_size=24, - ) - except Exception as e: - logger.warning(f"Failed to load cached header icon: {e}") - # Ultimate fallback - app_icon = Image( - name="notification-icon", - icon_name="notifications", - icon_size=24, - ) - - return CenterBox( - name="notification-title", - start_children=[ - Box( - spacing=4, - children=[ - app_icon, - Label( - notification.app_name, - name="notification-app-name", - h_align="start", - ), - ], - ) - ], - end_children=[ - self.create_close_button() if self.show_close_button else Box() - ], - ) - - def create_content(self, notification): - return Box( - name="notification-content", - spacing=8, - children=[ - Box( - name="notification-image", - children=CustomImage( - pixbuf=self._get_notification_pixbuf(notification) - ), - ), - Box( - name="notification-text", - orientation="v", - v_align="center", - children=[ - Box( - name="notification-summary-box", - orientation="h", - children=[ - Label( - name="notification-summary", - markup=escape_markup_text(notification.summary.replace("\n", " ")), - h_align="start", - max_chars_width=40, - ellipsization="end", - ), - # Label( - # name="notification-app-name", - # markup=" | " + notification.app_name, - # h_align="start", - # ellipsization="end", - # ), - ], - ), - ( - Label( - markup=escape_markup_text(notification.body.replace("\n", " ")), - h_align="start", - max_chars_width=45, - ellipsization="end", - ) - if notification.body - else Label( - markup="", - h_align="start", - ellipsization="end", - ) - ), - ], - ), - Box(h_expand=True), - Box( - orientation="v", - children=[ - Button( - name="notification-close-button", - image=CustomImage(icon_name="close-symbolic", icon_size=18), - visible=True, # Initially hidden - on_clicked=lambda *_: self._manual_close(), - ), - Box(v_expand=True), - ], - ), - ], - ) - - def _on_enter_notify(self, widget, event): - self._is_hovered = True - if self.close_button: - self.close_button.set_visible(True) - self.pause_timeout() - return False - - def _on_leave_notify(self, widget, event): - self._is_hovered = False - if self.close_button: - self.close_button.set_visible(False) - self.resume_timeout() - return False - - def get_pixbuf(self, icon_path, width, height): - """Get pixbuf with caching support""" - try: - # Use the icon caching system - cached_pixbuf = cache_notification_icon(icon_path, (width, height)) - if cached_pixbuf: - return cached_pixbuf - except Exception as e: - logger.warning(f"Failed to get cached pixbuf for {icon_path}: {e}") - - # Fallback to original method if caching fails - if icon_path.startswith("file://"): - icon_path = icon_path[7:] - - if not os.path.exists(icon_path): - logger.warning(f"Icon path does not exist: {icon_path}") - return get_fallback_notification_icon((width, height)) - - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) - if pixbuf: - return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) - else: - return get_fallback_notification_icon((width, height)) - except Exception as e: - logger.error(f"Failed to load or scale icon: {e}") - return get_fallback_notification_icon((width, height)) - - def _get_notification_pixbuf(self, notification): - """Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat""" - notification_id = getattr(notification, "id", int(time.time())) - - try: - # Try to get cached notification image first - if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: - try: - cache_key = get_notification_image_cache_key(notification_id, notification.image_pixbuf) - cached_image = get_cached_notification_image(cache_key) - if cached_image: - return cached_image - except Exception as image_error: - logger.debug(f"Notification image processing failed: {image_error}") - # Continue to app icon fallback - - except Exception as e: - logger.debug(f"Failed to process notification image: {e}") - - # Use cached app icon as fallback - try: - app_icon_source = getattr(notification, "app_icon", None) - if app_icon_source: - cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) - if cached_app_icon: - return cached_app_icon - except Exception as e: - logger.debug(f"Failed to get cached app icon: {e}") - - # Ultimate fallback - return get_fallback_notification_icon((35, 35)) - - def create_action_buttons(self, notification): - return Box( - name="notification-action-buttons", - spacing=4, - h_expand=True, - children=[ - ActionButton(action, i, len(notification.actions), self) - for i, action in enumerate(notification.actions) - ], - ) - - def start_timeout(self): - self.stop_timeout() - self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification) - - def stop_timeout(self): - if self._timeout_id is not None: - GLib.source_remove(self._timeout_id) - self._timeout_id = None - - def close_notification(self): - self.notification.close("expired") - self.stop_timeout() - return False - - def pause_timeout(self): - self.stop_timeout() - - def resume_timeout(self): - if not self._is_hovered: # Only resume if not hovered - self.start_timeout() - - def _manual_close(self): - """Handle manual close button click - just close notification""" - self.notification.close("dismissed-by-user") - - def destroy(self): - self.stop_timeout() - # Only clean up caches if this was a manual dismissal - if self._should_cleanup_cache: - cleanup_notification_specific_caches( - app_icon_source=getattr(self, "app_icon_source", None), - notification_image_cache_key=getattr( - self, "notification_image_cache_key", None - ), - ) - logger.debug(f"Cleaned up caches for manually dismissed notification") - else: - logger.debug(f"Preserved caches for timeout/auto-dismissed notification") - super().destroy() - - # @staticmethod - def set_pointer_cursor(self, widget, cursor_name): - window = widget.get_window() - if window: - cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name) - window.set_cursor(cursor) - - def hover_button(self, button): - self.pause_timeout() - self.set_pointer_cursor(button, "hand2") - - def unhover_button(self, button): - # Don't resume timeout here since the notification itself might still be hovered - self.set_pointer_cursor(button, "arrow") - - -class NotificationRevealer(SlideRevealer): - def __init__( - self, - notification: Notification, - on_transition_end=None, - parent_window=None, - **kwargs, - ): - self.notif_box = NotificationWidget(notification, show_close_button=False) - self.notification = notification - self.on_transition_end = on_transition_end - # Reference to NotificationCenter window for queue clearing - self.parent_window = parent_window - self._is_closing = False - - # Enhanced swipe detection variables for Android-style animation - self._drag_start_y = 0 - self._drag_start_x = 0 - self._is_dragging = False - self._swipe_threshold = 80 # Distance to trigger auto-dismiss - self._swipe_velocity_threshold = ( - 150 # Velocity to trigger dismiss even on shorter swipes - ) - self._swipe_in_progress = False - self._current_offset = 0 - self._last_drag_time = 0 - self._drag_velocity = 0 - self._spring_back_duration = 200 # Duration for spring-back animation - self._dismiss_threshold = 0.3 # Dismiss if swiped 30% of width - - # Animation state - self._animation_in_progress = False - self._spring_timer_id = None - self._css_provider = None - - # Wrap notification in EventBox for swipe detection - self.event_box = EventBox( - events=[ - "button-press-event", - "button-release-event", - "motion-notify-event", - ], - child=self.notif_box, - ) - - super().__init__( - child=self.event_box, - direction="right", - duration=280, # Faster, smoother duration - ) - - smooth_revealer_animation(self) - - # Connect our own handler that manages the slide animation - self.notification.connect("closed", self.on_resolved) - - self._animation_in_progress = True - - def _ease_out_cubic(self, t): - """Smoother easing function for better animation quality""" - return 1 - pow(1 - t, 3) - - def _ease_out_quart(self, t): - """Even smoother easing for ultra-smooth animations""" - return 1 - pow(1 - t, 4) - - def _apply_transform(self, offset_x, opacity, scale): - """Apply smooth CSS transforms for animation""" - try: - # Create CSS transformation - transform_css = f""" - opacity: {opacity}; - transform: translateX({offset_x}px) scale({scale}); - transition: none; - """ - - # Apply to the notification box - if hasattr(self.notif_box, "get_style_context"): - style_context = self.notif_box.get_style_context() - if style_context: - # Use CSS provider for smooth transforms - if not hasattr(self, "_css_provider") or not self._css_provider: - from gi.repository import Gtk - - self._css_provider = Gtk.CssProvider() - style_context.add_provider( - self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - css_data = f"* {{ {transform_css} }}" - self._css_provider.load_from_data(css_data.encode()) - - except Exception as e: - logger.debug(f"Transform apply failed (non-critical): {e}") - - def _animate_dismiss(self, start_offset): - """Animate the notification sliding out with smooth 60fps animation""" - target_offset = NOTIFICATION_WIDTH + 50 - duration = 200 # Slightly longer for smoother feel - - if self._spring_timer_id: - GLib.source_remove(self._spring_timer_id) - - start_time = GLib.get_monotonic_time() / 1000 - offset_diff = target_offset - start_offset - - def animate_step(): - current_time = GLib.get_monotonic_time() / 1000 - elapsed = current_time - start_time - progress = min(1.0, elapsed / duration) - - # Use smoother easing for premium feel - eased_progress = self._ease_out_quart(progress) - current_offset = start_offset + (offset_diff * eased_progress) - - # Smoother fade and scale transitions - opacity = max(0.0, 1.0 - (progress * 0.9)) # Gentler fade - scale = max(0.9, 1.0 - (progress * 0.1)) # Subtle scale - - self._apply_transform(current_offset, opacity, scale) - - if progress >= 1.0: - # Mark notification for cache cleanup on swipe dismissal - self.notif_box._should_cleanup_cache = True - try: - self.notification.close("dismissed-by-user") - except: - pass - return False - - return True - - # Use consistent 60fps timing - self._animation_in_progress = True - self._spring_timer_id = GLib.timeout_add(16, animate_step) # ~60 FPS - - def _calculate_drag_velocity(self, current_x): - """Calculate the velocity of the drag gesture""" - current_time = GLib.get_monotonic_time() / 1000 - - if self._last_drag_time > 0: - time_diff = current_time - self._last_drag_time - if time_diff > 0: - distance_diff = current_x - self._drag_start_x - self._current_offset - self._drag_velocity = abs(distance_diff / time_diff) - - self._last_drag_time = current_time - - def _on_animation_complete(self, is_hiding=False): - if is_hiding: - # Manually destroy the notification widget since we disconnected its handler - self.notif_box.destroy() - - if self.on_transition_end: - self.on_transition_end() - self.destroy() - - def on_resolved( - self, - _notification: Notification, - reason: NotificationCloseReason, - ): - if self._is_closing: - return - - self._is_closing = True - - # Clean up any ongoing animations - if self._spring_timer_id: - GLib.source_remove(self._spring_timer_id) - - # Use different slide directions based on dismiss reason - if reason == "expired": - # Gentle fade-out for auto-dismiss - self.set_slide_direction("left") - self.duration = 250 # Slightly slower for natural feel - elif self._swipe_in_progress: - # Quick slide for swipe dismissals - self.duration = 150 - self.set_slide_direction("right") - else: - # Smooth slide for manual close - self.set_slide_direction("right") - self.duration = 200 - - self.hide() - # Consistent timing for smooth transitions - timeout_duration = self.duration + 50 - GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True)) - - def destroy(self): - # Clean up CSS provider and timers - if self._spring_timer_id: - GLib.source_remove(self._spring_timer_id) - super().destroy() - - -class NotificationState: - IDLE = 0 - SHOWING = 1 - HIDING = 2 - TRANSITIONING = 3 # New state for smooth transitions - - -class ModusNoti(Window): - def __init__(self): - self._server = notification_service - - self.notifications = Box( - v_expand=True, - h_expand=True, - style="margin: 1px 0px 1px 1px;", - orientation="v", - spacing=5, - ) - - # Enhanced queue system for ultra-smooth transitions - self.notification_queue = [] - self.current_notification = None - self.notification_state = NotificationState.IDLE - self._transition_timer_id = None - self._debounce_timer_id = None - self._last_notification_time = 0 - - # Queue management settings for smooth behavior - self.MAX_QUEUE_SIZE = 3 # Limit queue to prevent overwhelming - self.TRANSITION_DELAY = 100 # Smoother transition timing - self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications - - self._server.connect("notification-added", self.on_new_notification) - super().__init__( - anchor="top right", - child=self.notifications, - layer="overlay", - title="modus-notifications", # More specific title for debugging - all_visible=True, - visible=False, # Start hidden, show only when we have content - exclusive=False, - ) - - def on_new_notification(self, fabric_notif, id): - notification: Notification = fabric_notif.get_notification_from_id(id) - - # Check if notification still exists (might have been removed already) - if not notification: - return - - if self._server.dont_disturb or modus_service.dont_disturb: - # Notification is already cached by the service, just don't show popup - return - - # Preload assets immediately for optimal caching and display performance - preload_notification_assets(notification) - - # Implement smart queue management for smooth transitions - current_time = GLib.get_monotonic_time() / 1000 - - # If queue is getting full, remove oldest notifications smoothly - if len(self.notification_queue) >= self.MAX_QUEUE_SIZE: - # Remove oldest notification from queue (not current showing one) - if self.notification_queue: - oldest = self.notification_queue.pop(0) - try: - oldest.close("dismissed-by-user") - except: - pass - - # Add new notification to queue - self.notification_queue.append(notification) - - # Debounce rapid notifications for smoother experience - if self._debounce_timer_id: - GLib.source_remove(self._debounce_timer_id) - - self._debounce_timer_id = GLib.timeout_add( - self.DEBOUNCE_DELAY, - lambda: self._process_notification_queue_debounced() or False, - ) - - def _process_notification_queue_debounced(self): - """Process queue after debounce delay for smooth transitions""" - self._debounce_timer_id = None - self._process_notification_queue() - return False - - def _process_notification_queue(self): - # If we're currently showing a notification and there's a new one in queue - if ( - self.notification_state == NotificationState.SHOWING - and self.current_notification - and self.notification_queue - ): - # Smooth transition: start hiding current notification - self.notification_state = NotificationState.TRANSITIONING - self._start_smooth_transition() - - elif ( - self.notification_state == NotificationState.IDLE - and self.notification_queue - ): - # If we're idle and have notifications in queue, show the next one - self._show_next_notification() - - def _start_smooth_transition(self): - """Start smooth transition between notifications""" - if self.current_notification and not self.current_notification._is_closing: - # Don't mark for cache cleanup during smooth transitions - # to maintain performance - - # Use shorter timeout for smooth transitions - self.current_notification.notif_box.timeout_ms = 100 - - # Force close current notification with smooth animation - try: - self.current_notification.notification.close("expired") - except: - pass - - def _show_next_notification(self): - if ( - not self.notification_queue - or self.notification_state != NotificationState.IDLE - ): - return - - notification = self.notification_queue.pop(0) - - # Check if notification is still valid (might have been removed) - if not notification or not hasattr(notification, 'app_icon'): - # Skip invalid notifications and try next one - if self.notification_queue: - self._show_next_notification() - return - - self.notification_state = NotificationState.SHOWING - - new_box = NotificationRevealer( - notification, - on_transition_end=lambda: self._on_notification_finished(new_box), - parent_window=self, - ) - - self.current_notification = new_box - - # Clear any existing children - for child in list(self.notifications.children): - try: - self.notifications.remove(child) - except: - pass - - self.notifications.children = [new_box] - - # Show the window now that we have content to display - self.set_visible(True) - - new_box.show() - self.notifications.queue_resize() - - def start_animation(): - if new_box.get_parent() and new_box.get_realized(): - new_box.reveal() - return False - return True - - GLib.idle_add(start_animation) - - def _on_notification_finished(self, notification_box): - if notification_box != self.current_notification: - return - - # Cancel any pending transition timer - if self._transition_timer_id: - GLib.source_remove(self._transition_timer_id) - self._transition_timer_id = None - - # Safely remove notification box - try: - if notification_box in self.notifications.children: - self.notifications.remove(notification_box) - except: - pass - - # Reset state - self.current_notification = None - self.notification_state = NotificationState.IDLE - - # Process next notification with optimized delay for ultra-smooth transitions - if self.notification_queue: - self._transition_timer_id = GLib.timeout_add( - self.TRANSITION_DELAY, # Consistent smooth timing - lambda: self._show_next_notification() or False, - ) - else: - # Hide window when no more notifications to show - self.set_visible(False) - - def show_next_notification(self): - # Legacy method for compatibility - redirect to new implementation - self._show_next_notification() - - def on_notification_finished(self, notification_box): - # Legacy method for compatibility - redirect to new implementation - self._on_notification_finished(notification_box) - - def clear_notification_queue(self): - """Clear queue with smooth cleanup""" - queue_length = len(self.notification_queue) - if queue_length > 0: - # Smooth dismissal of queued notifications - for notification in list(self.notification_queue): - try: - notification.close("dismissed-by-user") - except: - pass - self.notification_queue.clear() - - # Also clean current notification if showing - if self.current_notification: - # Mark for cache cleanup when clearing queue - self.current_notification.notif_box._should_cleanup_cache = True - - # Clear animation timers - if self._transition_timer_id: - GLib.source_remove(self._transition_timer_id) - self._transition_timer_id = None - - if self._debounce_timer_id: - GLib.source_remove(self._debounce_timer_id) - self._debounce_timer_id = None - - def get_queue_length(self): - return len(self.notification_queue) diff --git a/modules/notification/notification_center.py b/modules/notification/notification_center.py deleted file mode 100644 index 3aa77b0..0000000 --- a/modules/notification/notification_center.py +++ /dev/null @@ -1,923 +0,0 @@ -from collections import defaultdict -import time - -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.eventbox import EventBox -from fabric.widgets.label import Label -from fabric.widgets.revealer import Revealer -from fabric.widgets.scrolledwindow import ScrolledWindow -from gi.repository import GLib, GdkPixbuf -from loguru import logger - -from modules.notification.notification import ( - NotificationWidget, - cache_notification_icon, - cache_notification_image, - get_cached_notification_image, - get_notification_image_cache_key, - preload_notification_assets, - cleanup_all_notification_caches, - cleanup_notification_specific_caches, - get_fallback_notification_icon, -) -from services.modus import notification_service -from utils.functions import escape_markup_text -from widgets.custom_image import CustomImage -from widgets.wayland import WaylandWindow as Window -from config import data - - -class ExpandableNotificationGroup(Box): - def __init__(self, app_name, notifications, **kwargs): - super().__init__( - name="notification-group", orientation="v", spacing=0, **kwargs - ) - - self.app_name = app_name - self.notifications = notifications - self.is_expanded = False # Always start collapsed - - # Create collapsed state first (shows only latest notification) - self.create_collapsed_state() - - # Create expanded state (hidden initially) - self.create_expanded_state() - - # Ensure we start in collapsed state - self.collapsed_eventbox.set_visible(True) - self.expanded_container.set_visible(False) - - def create_collapsed_state(self): - latest_notification = self.notifications[0] # Most recent notification - - # Create clickable event box - self.collapsed_eventbox = EventBox( - events=["button-press-event"], - ) - self.collapsed_eventbox.connect("button-press-event", self.on_clicked) - - # Only create stacked effect if we have multiple notifications - num_notifications = len(self.notifications) - - if num_notifications == 1: - # Single notification - no stacking needed - notification_widget = NotificationCenterWidget( - notification=latest_notification - ) - self.collapsed_eventbox.add(notification_widget) - else: - # Multiple notifications - create stacked effect - # Create container for the entire stack - stack_container = Box( - name="notification-stack-container", - orientation="v", - spacing=0, - ) - - # Add bottom shadow layer first (deepest) - if num_notifications >= 3: - bottom_shadow = Box( - name="stack-shadow-bottom", - ) - stack_container.add(bottom_shadow) - - # Add middle shadow layer - if num_notifications >= 2: - middle_shadow = Box( - name="stack-shadow-middle", - ) - stack_container.add(middle_shadow) - - # Add the main notification content on top - main_notification = Box( - name="stack-main-notification", - spacing=8, - children=[ - Box( - name="notification-image", - children=CustomImage( - pixbuf=self._get_notification_pixbuf_for_group( - latest_notification - ) - ), - ), - Box( - name="notification-text", - orientation="v", - v_align="center", - h_expand=True, - children=[ - Box( - name="notification-summary-box", - orientation="h", - children=[ - Label( - name="notification-summary", - markup=f"<b>{self.app_name}</b>", - h_align="start", - ellipsization="end", - ), - ], - ), - Label( - name="notification-body", - markup=escape_markup_text(latest_notification._notification.summary.replace( - "\n", " " - )), - max_chars_width=25, - h_align="start", - ellipsization="end", - ), - ], - ), - Box( - name="notification-count", - orientation="v", - children=[ - Button( - name="notification-close", - image=CustomImage( - icon_name="close-symbolic", icon_size=18 - ), - visible=True, - on_clicked=lambda *_: self._close_single_notification_and_stop_propagation( - latest_notification - ), - ), - Label( - name="notification-count-label", - label=f"{len(self.notifications)}", - h_align="end", - ), - ], - ), - ], - ) - stack_container.add(main_notification) - self.collapsed_eventbox.add(stack_container) - - self.add(self.collapsed_eventbox) - - # Create expanded state (hidden initially) - self.create_expanded_state() - - def _get_notification_pixbuf_for_group(self, cached_notification): - """Get notification pixbuf using cached image key - fallback to app icon""" - notification = cached_notification._notification - notification_id = getattr(notification, "id", None) - - # First try to get cached notification image using stored cache key - if ( - hasattr(cached_notification, "cache_metadata") - and cached_notification.cache_metadata - ): - notification_image_cache_key = cached_notification.cache_metadata.get( - "notification_image_cache_key" - ) - if notification_image_cache_key: - try: - cached_image = get_cached_notification_image( - notification_image_cache_key - ) - if cached_image: - logger.debug( - f"Using cached notification image: {notification_image_cache_key}" - ) - return cached_image - except Exception as e: - logger.debug(f"Failed to load cached notification image: {e}") - - # Fallback to app icon using cached key - if ( - hasattr(cached_notification, "cache_metadata") - and cached_notification.cache_metadata - ): - app_icon_cache_key = cached_notification.cache_metadata.get( - "app_icon_cache_key" - ) - if app_icon_cache_key: - try: - from modules.notification.unified_cache import get_from_cache - - cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) - if cached_app_icon: - # logger.debug(f"Using cached app icon: {app_icon_cache_key}") - return cached_app_icon - except Exception as e: - logger.debug(f"Failed to load cached app icon: {e}") - - # Final fallback - try to cache app icon directly if available - try: - app_icon_source = getattr(notification, "app_icon", None) - if app_icon_source: - cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) - if cached_app_icon: - logger.debug( - f"Using directly cached app icon for: {app_icon_source}" - ) - return cached_app_icon - except Exception as e: - logger.debug(f"Failed to get directly cached app icon: {e}") - - # Ultimate fallback - logger.debug("Using fallback notification icon") - return get_fallback_notification_icon((35, 35)) - - def create_expanded_state(self): - # Create main expanded container - self.expanded_container = Box( - name="notification-group-expanded-container", - orientation="v", - spacing=0, - ) - - # Header with app name and controls - self.header_content = Box( - orientation="h", - h_expand=True, - children=[ - Label( - name="notification-group-title", - markup=f"<b>{self.app_name}</b>", - h_align="start", - h_expand=True, - ), - Button( - name="notification-show-less", - label="Show less", - on_clicked=self.collapse, - h_align="end", - ), - Button( - name="notification-close-summery", - h_expand=False, - v_expand=False, - on_clicked=self.close_all, - image=CustomImage( - icon_name="close-symbolic", - name="notification-close-header", - icon_size=18, - h_align="end", - ), - visible=True, - ), - ], - ) - - # Wrap header in revealer for slide-up animation during collapse - self.header_revealer = Revealer( - child=self.header_content, - transition_type="slide-up", - transition_duration=300, - child_revealed=False, - ) - - # Box for individual notifications - self.notifications_list = Box( - name="notification-group-notifications", - orientation="v", - spacing=5, - ) - - # Add individual notifications to the list - for notification in self.notifications: - notification_widget = NotificationCenterWidget(notification=notification) - self.notifications_list.add(notification_widget) - - # Wrap notifications list in revealer for slide-down animation - self.notifications_revealer = Revealer( - child=self.notifications_list, - transition_type="slide-down", - transition_duration=300, - child_revealed=False, - ) - - # Wrap notifications revealer in crossfade revealer for closing animation - self.notifications_crossfade = Revealer( - child=self.notifications_revealer, - transition_type="crossfade", - transition_duration=250, - child_revealed=True, # Start revealed so crossfade works on close - ) - - # Add header revealer and notifications crossfade to container - self.expanded_container.add(self.header_revealer) - self.expanded_container.add(self.notifications_crossfade) - - # Add the container to the main group - self.add(self.expanded_container) - - # Hide the entire expanded container initially - self.expanded_container.set_visible(False) - - def on_clicked(self, widget, event): - if event.button == 1: # Left click - # Always allow expansion, even for single notifications - # This ensures single notifications can show their expanded entry view - self.expand() - return True - - def expand(self, *args): - """Expand to show all notifications in this group with slide-down animation""" - self.is_expanded = True - self.collapsed_eventbox.set_visible(False) - self.expanded_container.set_visible(True) - - # Show header immediately (no animation on expand) - self.header_revealer.set_reveal_child(True) - - # Ensure crossfade is revealed for expand - self.notifications_crossfade.set_reveal_child(True) - - # Small delay then animate notifications sliding down - GLib.timeout_add(50, lambda: self.notifications_revealer.set_reveal_child(True)) - logger.debug(f"Expanded notification group: {self.app_name}") - - def collapse(self, *args): - """Collapse with header sliding up, notifications crossfading, then sliding up""" - self.is_expanded = False - - # Start header slide-up and notifications crossfade simultaneously - self.header_revealer.set_reveal_child(False) - self.notifications_crossfade.set_reveal_child(False) - - # Show collapsed state and hide expanded container halfway through crossfade - GLib.timeout_add(125, self._show_collapsed_midway) - - # After crossfade completes, start slide-up animation (just for cleanup) - GLib.timeout_add( - 260, lambda: self.notifications_revealer.set_reveal_child(False) - ) - - logger.debug(f"Collapsed notification group: {self.app_name}") - - def _show_collapsed_midway(self): - """Show collapsed state and hide expanded container to prevent deformation""" - self.collapsed_eventbox.set_visible(True) - self.expanded_container.set_visible(False) - return False # Don't repeat timeout - - def _complete_collapse(self): - """Complete the collapse animation - no longer needed but kept for compatibility""" - return False # Don't repeat timeout - - def close_all(self, *args): - """Close all notifications in this group with proper cache cleanup""" - # Close all notifications in this group - for notification in self.notifications: - try: - # Get notification cache metadata for cleanup - cache_metadata = getattr(notification, "cache_metadata", {}) - - # Clean up caches using stored metadata - from modules.notification.notification import ( - cleanup_notification_specific_caches, - ) - - cleanup_notification_specific_caches( - app_icon_source=notification._notification.app_icon, - notification_image_cache_key=cache_metadata.get( - "notification_image_cache_key" - ), - ) - - logger.debug( - f"Cleaned up caches for notification ID: {notification._notification.id}" - ) - - notification_service.remove_cached_notification(notification.cache_id) - except Exception as e: - logger.error( - f"Error removing notification {notification.cache_id}: {e}" - ) - - def _close_single_notification(self, notification): - """Close a single notification from this group with proper cache cleanup""" - try: - # Get notification cache metadata for cleanup - cache_metadata = getattr(notification, "cache_metadata", {}) - - # Clean up caches using stored metadata - from modules.notification.notification import ( - cleanup_notification_specific_caches, - ) - - cleanup_notification_specific_caches( - app_icon_source=notification._notification.app_icon, - notification_image_cache_key=cache_metadata.get( - "notification_image_cache_key" - ), - ) - - logger.debug( - f"Cleaned up caches for notification ID: {notification._notification.id}" - ) - - notification_service.remove_cached_notification(notification.cache_id) - logger.debug(f"Closed single notification: {notification.cache_id}") - except Exception as e: - logger.error( - f"Error removing single notification {notification.cache_id}: {e}" - ) - - def _close_single_notification_and_stop_propagation(self, notification): - """Close notification and prevent click from expanding the group""" - self._close_single_notification(notification) - - # If this was the last notification in the group, the group will be removed - # by the notification_removed signal handler. If there are still notifications, - # we need to check if this group should be removed from view. - remaining_notifications = [ - n for n in self.notifications if n.cache_id != notification.cache_id - ] - - if not remaining_notifications: - # This was the last notification, the group will be destroyed by signal handler - pass - else: - # Update the notifications list and refresh the view immediately - self.notifications = remaining_notifications - # Force immediate UI update by destroying and recreating collapsed state - if hasattr(self, "collapsed_eventbox"): - self.collapsed_eventbox.destroy() - self.create_collapsed_state() - self.show_all() - - return True # Stop event propagation - - -class NotificationCenterWidget(NotificationWidget): - def __init__(self, notification, **kwargs): - self.notification_id = notification.cache_id - self.cache_metadata = getattr(notification, "cache_metadata", {}) - - super().__init__( - notification._notification, - timeout_ms=0, - show_close_button=True, - name="notification-centre-notifs", - **kwargs, - ) - - def _get_notification_pixbuf(self, notification): - """Get notification pixbuf using cached image key - fallback to app icon""" - notification_id = getattr(notification, "id", None) - - # First try to get cached notification image using stored cache key - if self.cache_metadata: - notification_image_cache_key = self.cache_metadata.get( - "notification_image_cache_key" - ) - if notification_image_cache_key: - try: - cached_image = get_cached_notification_image( - notification_image_cache_key - ) - if cached_image: - logger.debug( - f"Using cached notification image: {notification_image_cache_key}" - ) - return cached_image - except Exception as e: - logger.debug(f"Failed to load cached notification image: {e}") - - # Fallback to app icon using cached key - if self.cache_metadata: - app_icon_cache_key = self.cache_metadata.get("app_icon_cache_key") - if app_icon_cache_key: - try: - from modules.notification.unified_cache import get_from_cache - - cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) - if cached_app_icon: - # logger.debug(f"Using cached app icon: {app_icon_cache_key}") - return cached_app_icon - except Exception as e: - logger.debug(f"Failed to load cached app icon: {e}") - - # Final fallback - try to cache app icon directly if available - try: - app_icon_source = getattr(notification, "app_icon", None) - if app_icon_source: - cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) - if cached_app_icon: - logger.debug( - f"Using directly cached app icon for: {app_icon_source}" - ) - return cached_app_icon - except Exception as e: - logger.debug(f"Failed to get directly cached app icon: {e}") - - # Ultimate fallback - logger.debug("Using fallback notification icon") - return get_fallback_notification_icon((35, 35)) - - def create_content(self, notification): - # Create our custom close button for notification center - - self.close_button = Button( - name="notif-close-button", - image=CustomImage( - icon_name="close-symbolic", name="notification-close", icon_size=18 - ), - visible=True, # Always visible in notification center - on_clicked=self._on_close_clicked, - ) - self.close_button.connect( - "enter-notify-event", lambda *_: self.hover_button(self.close_button) - ) - self.close_button.connect( - "leave-notify-event", lambda *_: self.unhover_button(self.close_button) - ) - - # Create the content box manually with our custom close button - return Box( - name="notification-content", - spacing=8, - children=[ - Box( - name="notification-image", - children=CustomImage( - pixbuf=self._get_notification_pixbuf(notification) - ), - ), - Box( - name="notification-text", - orientation="v", - v_align="center", - children=[ - Box( - name="notification-summary-box", - orientation="h", - children=[ - Label( - name="notification-summary", - markup=escape_markup_text(notification.summary.replace("\n", " ")), - h_align="start", - max_chars_width=25, - ellipsization="end", - ), - ], - ), - ( - Label( - markup=escape_markup_text(notification.body.replace("\n", " ")), - h_align="start", - max_chars_width=35, - ellipsization="end", - ) - if notification.body - else Label( - markup="", - h_align="start", - ellipsization="end", - ) - ), - ], - ), - Box(h_expand=True), - Box( - orientation="v", - children=[ - self.close_button, # Use our custom close button - Box(v_expand=True), - ], - ), - ], - ) - - # Override to disable the action buttons - def create_action_buttons(self, notification): - return Box(name="notification-action-buttons") - - def _on_close_clicked(self, *args): - try: - # Use instance cache metadata instead of notification attribute - cache_metadata = self.cache_metadata - - # Clean up caches using stored metadata - from modules.notification.notification import ( - cleanup_notification_specific_caches, - ) - - cleanup_notification_specific_caches( - app_icon_source=self.notification.app_icon, - notification_image_cache_key=cache_metadata.get( - "notification_image_cache_key" - ), - ) - - logger.debug( - f"Cleaned up caches for notification center ID: {self.notification.id}" - ) - - notification_service.remove_cached_notification(self.notification_id) - except Exception as e: - logger.error(f"Error removing notification {self.notification_id}: {e}") - - # Override to disable timeout functionality - def start_timeout(self): - pass - - # Override to disable timeout functionality - def stop_timeout(self): - pass - - # Override to disable auto-close functionality - def close_notification(self): - return False - - -class NotificationCenter(Window): - def __init__(self): - super().__init__( - layer="overlay", - anchor="top right", - visible=False, - keyboard_mode="on-demand", - title="modus", - ) - - NOTIFICATION_CENTER_WIDTH = 410 - self.set_size_request(NOTIFICATION_CENTER_WIDTH, 600) - - # Group notifications by app name - self.notification_groups = defaultdict(list) - self.group_widgets = {} - - notification_service.connect( - "cached-notification-added", self.on_notification_added - ) - notification_service.connect( - "cached-notification-removed", self.on_notification_removed - ) - notification_service.connect("clear-all", self.on_clear_all) - notification_service.connect("notify::count", self.on_count_changed) - - main_box = Box( - orientation="v", - spacing=5, - name="noti-center-box", - ) - - self.scrolled = ScrolledWindow(h_expand=False, v_expand=False) - self.notifications_box = Box( - v_expand=False, - h_expand=False, - style="margin: 1px 0px 1px 1px;", - orientation="v", - spacing=5, - ) - self.scrolled.add(self.notifications_box) - main_box.add(self.scrolled) - - # No notifications label - REMOVED - - self.clear_all_button = Button( - name="noti-clear-button", - label="Clear", - on_clicked=self.clear_all_notifications, - visible=(notification_service.count > 0), - ) - self.button_centre_box = CenterBox( - center_children=[self.clear_all_button], - ) - main_box.add(self.button_centre_box) - - # Wrap main content in revealer for slide-left animation - self.main_revealer = Revealer( - child=main_box, - transition_type="slide-left", - transition_duration=400, - child_revealed=False, - ) - - self.children = self.main_revealer - - # Load existing notifications and group them - self._rebuild_notification_groups() - - self.add_keybinding("Escape", self._on_escape_pressed) - self.connect("destroy", self._on_destroy) - - def _rebuild_notification_groups(self): - """Rebuild notification groups from scratch with enhanced asset preloading and debugging""" - # Clear existing groups - self.notification_groups.clear() - self.group_widgets.clear() - - # Clear notifications box - for child in self.notifications_box.get_children(): - child.destroy() - - # Group notifications by app name and preload assets with debugging - rebuild_count = 0 - for cached_notification in notification_service.cached_notifications: - app_name = cached_notification._notification.app_name - notification_id = getattr(cached_notification._notification, "id", None) - - # Skip ignored apps during rebuild - if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: - continue - - # Preload assets for each cached notification to ensure display consistency - preload_notification_assets(cached_notification._notification) - - self.notification_groups[app_name].append(cached_notification) - rebuild_count += 1 - - logger.info( - f"Rebuilt {rebuild_count} notifications into { - len(self.notification_groups) - } groups" - ) - - # Create group widgets and handle limited apps - for app_name, notifications in self.notification_groups.items(): - # Sort notifications by ID (highest ID first - newest notifications) - # This ensures the latest notifications appear at the top of each group - notifications.sort( - key=lambda n: getattr(n._notification, "id", 0), reverse=True - ) - - # Handle limited apps history - only keep 5 notifications during rebuild - if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY: - if len(notifications) > 5: - # Keep only the 5 most recent notifications - self.notification_groups[app_name] = notifications[:5] - - group_widget = ExpandableNotificationGroup( - app_name, self.notification_groups[app_name] - ) - self.group_widgets[app_name] = group_widget - self.notifications_box.add(group_widget) - - def on_notification_added(self, service, cached_notification): - try: - app_name = cached_notification._notification.app_name - - # Check if this app should be ignored for history (don't add to notification center) - if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: - return - - # Preload assets for notification center display (ensure caching consistency) - preload_notification_assets(cached_notification._notification) - - # Add to groups in sorted order (highest ID first - maintains newest-first ordering) - notifications_list = self.notification_groups[app_name] - new_notification_id = getattr(cached_notification._notification, "id", 0) - - # Find the correct position to insert based on ID (highest first) - insert_position = 0 - for i, existing_notification in enumerate(notifications_list): - existing_id = getattr(existing_notification._notification, "id", 0) - if new_notification_id > existing_id: - insert_position = i - break - insert_position = i + 1 - - notifications_list.insert(insert_position, cached_notification) - - # Handle limited apps history - only keep 5 notifications - if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY: - notifications_for_app = self.notification_groups[app_name] - if len(notifications_for_app) > 5: - # Remove oldest notifications beyond the limit - excess_notifications = notifications_for_app[5:] - self.notification_groups[app_name] = notifications_for_app[:5] - - # Remove excess notifications from the service cache - for excess_notification in excess_notifications: - try: - notification_service.remove_cached_notification( - excess_notification.cache_id - ) - except Exception as e: - logger.error(f"Error removing excess notification: {e}") - - # Update or create group widget - if app_name in self.group_widgets: - # Update existing group - group_widget = self.group_widgets[app_name] - group_widget.notifications = self.notification_groups[app_name] - # Refresh the group widget - self._refresh_group_widget(group_widget) - else: - # Create new group widget - group_widget = ExpandableNotificationGroup( - app_name, self.notification_groups[app_name] - ) - self.group_widgets[app_name] = group_widget - self.notifications_box.pack_start(group_widget, False, False, 0) - group_widget.show_all() - - logger.debug(f"Added notification to group {app_name}") - except Exception as e: - logger.error(f"Error adding notification to group: {e}") - - def _refresh_group_widget(self, group_widget): - """Refresh a group widget's content""" - try: - # Remove existing children - for child in group_widget.get_children(): - group_widget.remove(child) - - # Recreate content - group_widget.create_collapsed_state() - group_widget.show_all() - - except Exception as e: - logger.error(f"Error refreshing group widget: {e}") - - def on_notification_removed(self, service, cached_notification): - try: - app_name = cached_notification._notification.app_name - - # Remove from groups - if app_name in self.notification_groups: - self.notification_groups[app_name] = [ - n - for n in self.notification_groups[app_name] - if n.cache_id != cached_notification.cache_id - ] - - # If no more notifications for this app, remove group widget - if not self.notification_groups[app_name]: - if app_name in self.group_widgets: - group_widget = self.group_widgets[app_name] - group_widget.destroy() - del self.group_widgets[app_name] - del self.notification_groups[app_name] - else: - # Update existing group widget - group_widget = self.group_widgets[app_name] - group_widget.notifications = self.notification_groups[app_name] - self._refresh_group_widget(group_widget) - - # Clean up caches - cleanup_notification_specific_caches( - app_icon_source=getattr(cached_notification, "app_icon_source", None), - notification_image_cache_key=getattr( - cached_notification, "notification_image_cache_key", None - ), - ) - - logger.debug(f"Removed notification from group {app_name}") - except Exception as e: - logger.error(f"Error removing notification from group: {e}") - - def on_clear_all(self, service): - try: - # Clear all groups - self.notification_groups.clear() - self.group_widgets.clear() - - # Clear all remaining cached notification images AND icons - cleanup_all_notification_caches() - for child in self.notifications_box.get_children(): - child.destroy() - logger.debug("Cleared all notification groups and remaining cached images") - except Exception as e: - logger.error(f"Error clearing notification groups: {e}") - - def on_count_changed(self, service, count=None): - current_count = notification_service.count - # No notifications label removed - only update clear button and scrolled visibility - self.clear_all_button.set_visible(current_count > 0) - self.scrolled.set_visible(current_count > 0) - - # Auto-close notification center when no notifications remain - if current_count == 0 and hasattr(self, "mousecapture"): - self.mousecapture.hide_child_window() - - def clear_all_notifications(self, *_): - # Clear all groups - self.notification_groups.clear() - self.group_widgets.clear() - - # Clear all remaining cached notification images AND icons when clear all is clicked - cleanup_all_notification_caches() # Clear ALL caches (icons + images) - notification_service.clear_all_cached_notifications() - if hasattr(self, "mousecapture"): - self.mousecapture.hide_child_window() - - def _on_escape_pressed(self, *_): - if hasattr(self, "mousecapture"): - self.mousecapture.hide_child_window() - - def _init_mousecapture(self, mousecapture): - self.mousecapture = mousecapture - - def _set_mousecapture(self, visible): - """Control notification center visibility with slide-left animation""" - if visible: - self.main_revealer.set_reveal_child(True) - else: - self.main_revealer.set_reveal_child(False) - logger.debug(f"Notification center visibility set to: {visible}") - - def _on_destroy(self, *_): - # Signals will be automatically disconnected when the object is destroyed - pass diff --git a/modules/notification/unified_cache.py b/modules/notification/unified_cache.py deleted file mode 100644 index 0bd673b..0000000 --- a/modules/notification/unified_cache.py +++ /dev/null @@ -1,197 +0,0 @@ -import os -import hashlib -import time -import uuid - -from fabric.utils import get_relative_path -from gi.repository import GdkPixbuf -from loguru import logger - -import config.data as data - -# Unified notification cache directory (for both app icons and notification images) -UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications") - - -def ensure_cache_dir(): - """Ensure unified notification cache directory exists""" - os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True) - - -def get_unified_cache_key(source_data, size=None, app_name=None): - """Generate a unified cache key that works for both app icons and notification images""" - try: - if hasattr(source_data, "get_pixels"): - # For pixbuf data - use hash of pixel data for deterministic caching - try: - pixel_data = source_data.get_pixels() - image_hash = hashlib.md5(pixel_data).hexdigest()[:8] - return image_hash - except Exception: - # Fallback to random UUID if pixel data fails - return str(uuid.uuid4())[:8] - elif isinstance(source_data, str): - # For file paths - create hash-based name - if source_data.startswith("file://"): - source_data = source_data[7:] - - # Create hash from file path and size - hash_input = source_data - if size: - hash_input += f"_{size[0]}x{size[1]}" - - return hashlib.md5(hash_input.encode()).hexdigest()[:8] - else: - # Fallback to random UUID - return str(uuid.uuid4())[:8] - except Exception: - # Ultimate fallback - return str(uuid.uuid4())[:8] - - -def save_to_cache(pixbuf, cache_key, size=None): - """Save a pixbuf to the unified cache directory""" - try: - ensure_cache_dir() - cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png") - - # Don't overwrite existing cache - if os.path.exists(cache_path): - logger.debug(f"Cache hit - already exists: {cache_key}") - return cache_path, cache_key - - # Scale if size is specified - if size and (pixbuf.get_width() != size[0] or pixbuf.get_height() != size[1]): - pixbuf = pixbuf.scale_simple( - size[0], size[1], GdkPixbuf.InterpType.BILINEAR - ) - - pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Cached notification asset: {cache_key}") - return cache_path, cache_key - except Exception as e: - logger.warning(f"Failed to cache notification asset: {e}") - return None, None - - -def get_from_cache(cache_key, size=None): - """Get a cached asset or return None if not found""" - try: - cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png") - if os.path.exists(cache_path): - # logger.debug(f"Using cached asset: {cache_key}") - if size: - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, size[0], size[1], True - ) - else: - return GdkPixbuf.Pixbuf.new_from_file(cache_path) - except Exception as e: - logger.warning(f"Failed to load cached asset: {e}") - return None - - -def cleanup_cache(cache_key=None): - """Clean up unified cache - specific key or all""" - try: - ensure_cache_dir() - - if cache_key: - # Remove specific cached asset - cache_path = os.path.join( - UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png" - ) - if os.path.exists(cache_path): - os.unlink(cache_path) - logger.debug(f"Cleaned up cached asset: {cache_key}") - else: - # Remove all cached assets - for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): - if filename.endswith(".png"): - filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) - try: - os.unlink(filepath) - logger.debug(f"Cleaned up cached asset: {filename}") - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") - except Exception as e: - logger.warning(f"Failed to cleanup cache: {e}") - - -def cleanup_old_cache_files(): - """Clean up old cache files (older than 7 days)""" - try: - if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR): - return - - current_time = time.time() - week_ago = current_time - (7 * 24 * 60 * 60) # 7 days - - for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): - filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - if file_mtime < week_ago: - os.unlink(filepath) - logger.debug(f"Cleaned up old cache: {filename}") - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") - except Exception as e: - logger.warning(f"Failed to cleanup cache: {e}") - - -def verify_cache_persistence(): - """Verify that cached assets persist and can be loaded after restart""" - try: - cache_files = [] - - if os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR): - cache_files = [ - f - for f in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR) - if f.endswith(".png") - ] - - logger.info(f"Cache persistence check: {len(cache_files)} assets cached") - - # Test loading a few cached items to verify they work - for cache_file in cache_files[:2]: # Test first 2 files - try: - cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file) - test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached asset: {cache_file}") - except Exception as e: - logger.warning(f"Failed to load cached asset {cache_file}: {e}") - - return len(cache_files) > 0 - - except Exception as e: - logger.error(f"Failed to verify cache persistence: {e}") - return False - - -def get_fallback_icon(size=(48, 48)): - """Get the fallback notification icon""" - try: - fallback_path = get_relative_path("../../config/assets/icons/notification.png") - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - fallback_path, size[0], size[1], True - ) - except Exception as e: - logger.warning(f"Failed to load fallback icon: {e}") - # Create a simple colored rectangle as ultimate fallback - try: - return GdkPixbuf.Pixbuf.new( - GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1] - ) - except: - return None - - -# Initialize cache on module load -ensure_cache_dir() -cleanup_old_cache_files() -verify_cache_persistence() - diff --git a/modules/osd.py b/modules/osd.py deleted file mode 100644 index 6b27aaa..0000000 --- a/modules/osd.py +++ /dev/null @@ -1,467 +0,0 @@ -import math -import time -from typing import ClassVar, Literal - -from gi.repository import GLib, GObject - -from fabric.audio import Audio -from fabric.utils.helpers import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.revealer import Revealer -from fabric.widgets.scale import Scale, ScaleMark -from fabric.widgets.svg import Svg -from services.brightness import Brightness -from utils.animator import Animator -from widgets.wayland import WaylandWindow as Window - - -class AnimatedScale(Scale): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.animator = None - - def animate_value(self, value: float): - if not self.animator: - self.animator = Animator( - bezier_curve=(0.34, 1.56, 0.64, 1.0), - duration=0.8, - min_value=self.min_value, - max_value=self.value, - tick_widget=self, - notify_value=lambda p, *_: self.set_value(p.value), - ) - self.animator.pause() - self.animator.min_value = self.value - self.animator.max_value = value - self.animator.play() - - -class BrightnessOSDContainer(Box): - def __init__(self, **kwargs): - super().__init__(**kwargs, orientation="v", spacing=3, name="osd") - self.brightness_service = Brightness.get_initial() - self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(0, 101, 10)), - value=70, - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - self.osd_window_image = Svg( - get_relative_path("../config/assets/icons/brightness/brightness.svg"), - size=(100, 150), - name="osd-image", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - self.add(self.osd_window_image) - self.add(self.scale) - self.update_brightness() - - self.scale.connect("value-changed", lambda *_: self.update_brightness()) - self.brightness_service.connect("screen", self.on_brightness_changed) - - def update_brightness(self) -> None: - current_brightness = self.brightness_service.screen_brightness - normalized_brightness = self._normalize_brightness(current_brightness) - if current_brightness != 0: - self.scale.animate_value(normalized_brightness) - - def get_svg(self, value): - b_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3) - return b_level - - def on_brightness_changed(self, _sender: any, value: float, *_args) -> None: - normalized_brightness = self._normalize_brightness(value) - self.osd_window_image.set_from_file( - get_relative_path( - f"../config/assets/icons/brightness/brightness-{ - self.get_svg(normalized_brightness) - }.svg" - ) - ) - self.scale.animate_value(normalized_brightness) - - def _normalize_brightness(self, brightness: float) -> float: - return (brightness / self.brightness_service.max_screen) * 100 - - -class AudioOSDContainer(Box): - __gsignals__: ClassVar[dict] = { - "volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), - } - - def __init__(self, **kwargs): - super().__init__( - **kwargs, - orientation="v", - name="osd", - ) - self.audio = Audio() - self.scale = AnimatedScale( - value=70, - marks=(ScaleMark(value=i) for i in range(1, 100, 10)), - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - self.osd_window_image = Svg( - get_relative_path("../config/assets/icons/volume/audio-volume.svg"), - size=(150, 150), - name="osd-image", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - self.previous_volume = None - self.previous_muted = None - - self.add(self.osd_window_image) - self.add(self.scale) - self.sync_with_audio() - - self.scale.connect("value-changed", self.on_volume_changed) - self.audio.connect("notify::speaker", self.on_audio_speaker_changed) - self.audio.connect("speaker-changed", self.on_speaker_changed) - - # Connect to current speaker if available - self._connect_speaker_signals() - - def _connect_speaker_signals(self): - """Connect to speaker's changed signal which should fire on both volume and mute changes""" - if self.audio.speaker: - # Connect to the main 'changed' signal from AudioStream - self.audio.speaker.connect("changed", self.on_speaker_stream_changed) - - def on_speaker_stream_changed(self, *_): - """This should be called whenever the speaker stream changes (volume OR mute)""" - if self.audio.speaker: - current_volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - current_muted = ( - self.audio.speaker.muted - if hasattr(self.audio.speaker, "muted") - else False - ) - - # Check if either volume or mute state changed - if ( - self.previous_volume != current_volume - or self.previous_muted != current_muted - ): - self.previous_volume = current_volume - self.previous_muted = current_muted - self.update_volume() - self.emit("volume-changed") - - def get_svg(self, value): - audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3) - return audio_level - - def sync_with_audio(self): - if self.audio.speaker: - volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - self.scale.set_value(volume) - self.previous_volume = volume - self.previous_muted = ( - self.audio.speaker.muted - if hasattr(self.audio.speaker, "muted") - else False - ) - - def on_volume_changed(self, *_): - if self.audio.speaker: - volume = self.scale.value - if 0 <= volume <= 100: - self.audio.speaker.set_volume(volume) - self.update_volume_display(volume) - self.emit("volume-changed") - - def update_volume_display(self, volume=None): - """Update the visual display based on current volume/mute state""" - if not self.audio.speaker: - return - - if volume is None: - volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - - is_muted = ( - self.audio.speaker.muted if hasattr(self.audio.speaker, "muted") else False - ) - - if volume == 0 or is_muted: - self.scale.add_style_class("muted") - display_volume = 0 - else: - self.scale.remove_style_class("muted") - display_volume = volume - - self.osd_window_image.set_from_file( - get_relative_path( - f"../config/assets/icons/volume/audio-volume-{ - self.get_svg(display_volume) - }.svg" - ) - ) - - def on_audio_speaker_changed(self, *_): - self._connect_speaker_signals() - self.update_volume() - - def on_speaker_changed(self, *_): - self._connect_speaker_signals() - self.update_volume() - - def update_volume(self, *_): - if self.audio.speaker and not self.is_hovered(): - volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - self.scale.set_value(volume) - self.update_volume_display(volume) - - -class MicrophoneOSDContainer(Box): - __gsignals__: ClassVar[dict] = { - "mic-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), - } - - def __init__(self, **kwargs): - super().__init__(**kwargs, orientation="v", spacing=13, name="osd") - self.audio = Audio() - self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(1, 100, 10)), - value=70, - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - - self.osd_window_image = Svg( - get_relative_path("../config/assets/icons/mic/microphone.svg"), - name="osd-image", - size=(100, 150), - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - self.previous_volume = None - self.previous_muted = None - - self.add(self.osd_window_image) - self.add(self.scale) - self.sync_with_audio() - - self.scale.connect("value-changed", self.on_volume_changed) - self.audio.connect("notify::microphone", self.on_audio_microphone_changed) - self.audio.connect("microphone-changed", self.on_microphone_changed) - - # Connect to current microphone if available - self._connect_microphone_signals() - - def _connect_microphone_signals(self): - """Connect to microphone's changed signal which should fire on both volume and mute changes""" - if self.audio.microphone: - # Connect to the main 'changed' signal from AudioStream - self.audio.microphone.connect("changed", self.on_microphone_stream_changed) - - def on_microphone_stream_changed(self, *_): - """This should be called whenever the microphone stream changes (volume OR mute)""" - if self.audio.microphone: - current_volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - current_muted = ( - self.audio.microphone.muted - if hasattr(self.audio.microphone, "muted") - else False - ) - - # Check if either volume or mute state changed - if ( - self.previous_volume != current_volume - or self.previous_muted != current_muted - ): - self.previous_volume = current_volume - self.previous_muted = current_muted - self.update_volume() - self.emit("mic-changed") - - def get_svg(self, value): - audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3) - return audio_level - - def sync_with_audio(self): - if self.audio.microphone: - volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - self.scale.set_value(volume) - self.previous_volume = volume - self.previous_muted = ( - self.audio.microphone.muted - if hasattr(self.audio.microphone, "muted") - else False - ) - - def on_volume_changed(self, *_): - if self.audio.microphone: - volume = self.scale.value - if 0 <= volume <= 100: - self.audio.microphone.set_volume(volume) - self.update_volume_display(volume) - self.emit("mic-changed") - - def update_volume_display(self, volume=None): - """Update the visual display based on current volume/mute state""" - if not self.audio.microphone: - return - - if volume is None: - volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - - is_muted = ( - self.audio.microphone.muted - if hasattr(self.audio.microphone, "muted") - else False - ) - - if volume == 0 or is_muted: - self.scale.add_style_class("muted") - display_volume = 0 - else: - self.scale.remove_style_class("muted") - display_volume = volume - - self.osd_window_image.set_from_file( - get_relative_path( - f"../config/assets/icons/mic/microphone-{ - self.get_svg(display_volume) - }.svg" - ) - ) - - def on_audio_microphone_changed(self, *_): - self._connect_microphone_signals() - self.update_volume() - - def on_microphone_changed(self, *_): - self._connect_microphone_signals() - self.update_volume() - - def update_volume(self, *_): - if self.audio.microphone and not self.is_hovered(): - volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - self.scale.set_value(volume) - self.update_volume_display(volume) - - -class OSD(Window): - def __init__(self, **kwargs): - self.audio_container = AudioOSDContainer() - self.brightness_container = BrightnessOSDContainer() - self.microphone_container = MicrophoneOSDContainer() - - self.timeout = 1000 - - self.revealer = Revealer( - transition_type="slide-up", - transition_duration=100, - child_revealed=False, - ) - - self.main_box = Box( - orientation="v", - h_expand=True, - children=[self.revealer], - ) - - super().__init__( - layer="overlay", - anchor="bottom", - title="modus", - child=self.main_box, - visible=False, - pass_through=True, - keyboard_mode="on-demand", - **kwargs, - ) - - self.last_activity_time = time.time() - - # Connect to the containers' signals - self.audio_container.connect("volume-changed", self.show_audio) - self.brightness_container.brightness_service.connect( - "screen", self.show_brightness - ) - self.microphone_container.connect("mic-changed", self.show_microphone) - - GLib.timeout_add(100, self.check_inactivity) - - def show_audio(self, *_): - self.show_box(box_to_show="audio") - self.reset_inactivity_timer() - - def show_brightness(self, *_): - self.show_box(box_to_show="brightness") - self.reset_inactivity_timer() - - def show_microphone(self, *_): - self.show_box(box_to_show="microphone") - self.reset_inactivity_timer() - - def show_box(self, box_to_show: Literal["audio", "brightness", "microphone"]): - self.set_visible(True) - if box_to_show == "audio": - self.revealer.children = self.audio_container - elif box_to_show == "brightness": - self.revealer.children = self.brightness_container - elif box_to_show == "microphone": - self.revealer.children = self.microphone_container - self.revealer.set_reveal_child(True) - self.reset_inactivity_timer() - - def start_hide_timer(self): - self.set_visible(False) - - def reset_inactivity_timer(self): - self.last_activity_time = time.time() - - def check_inactivity(self): - if time.time() - self.last_activity_time >= (self.timeout / 1000): - self.start_hide_timer() - return True diff --git a/modules/panel/components/enhanced_system_tray.py b/modules/panel/components/enhanced_system_tray.py deleted file mode 100644 index ac7e63b..0000000 --- a/modules/panel/components/enhanced_system_tray.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Enhanced System Tray Icon Handling - -This module provides enhanced icon loading capabilities for system tray items, -including fallback mechanisms for file paths and common icon locations. -""" - -import os - -from gi.repository import GdkPixbuf, Gtk - -from fabric.system_tray.widgets import SystemTrayItem - -# FIX: the tooltip should show application names instead of unknown - - -def patched_do_update_properties(self, *_): - # Try default GTK theme first - icon_name = self._item.icon_name - attention_icon_name = self._item.attention_icon_name - - if self._item.status == "NeedsAttention" and attention_icon_name: - preferred_icon_name = attention_icon_name - else: - preferred_icon_name = icon_name - - # Try to load from default GTK theme - if preferred_icon_name: - try: - default_theme = Gtk.IconTheme.get_default() - if default_theme.has_icon(preferred_icon_name): - pixbuf = default_theme.load_icon( - preferred_icon_name, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - # Set tooltip - tooltip = self._item.tooltip - self.set_tooltip_markup( - tooltip.description or tooltip.title or self._item.title.title() - if self._item.title - else "Unknown" - ) - return - except: - pass - - # Enhanced fallback handling for file paths - if preferred_icon_name and self._try_load_icon_from_path(preferred_icon_name): - return - - # Fallback to original implementation - original_do_update_properties(self, *_) - - -def _try_load_icon_from_path(self, icon_path): - try: - # Check if it's a file path and handle it directly - if os.path.isabs(icon_path) or "/" in icon_path: - # Try to load as SVG from the original path if it exists - if os.path.exists(icon_path): - if icon_path.lower().endswith(".svg"): - # Load SVG directly - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - icon_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - else: - # Load other image formats - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - icon_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # If it's a file path, try to extract just the filename for theme lookup - filename = os.path.basename(icon_path) - if filename: - # Remove extension for theme lookup - name_without_ext = os.path.splitext(filename)[0] - default_theme = Gtk.IconTheme.get_default() - - # Try filename without extension - if default_theme.has_icon(name_without_ext): - pixbuf = default_theme.load_icon( - name_without_ext, - self._icon_size, - Gtk.IconLookupFlags.FORCE_SIZE, - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # Try full filename - if default_theme.has_icon(filename): - pixbuf = default_theme.load_icon( - filename, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # If it looks like a file path but doesn't exist, try common icon locations - if os.path.isabs(icon_path): - common_icon_dirs = [ - "/usr/share/icons", - "/usr/share/pixmaps", - "/usr/local/share/icons", - "/usr/local/share/pixmaps", - os.path.expanduser("~/.local/share/icons"), - os.path.expanduser("~/.icons"), - ] - - filename = os.path.basename(icon_path) - for icon_dir in common_icon_dirs: - potential_path = os.path.join(icon_dir, filename) - if os.path.exists(potential_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - potential_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - except: - continue - - except Exception: - pass - - return False - - -def _set_tooltip(self): - tooltip = self._item.tooltip - self.set_tooltip_markup( - tooltip.description or tooltip.title or self._item.title.title() - if self._item.title - else "Unknown" - ) - - -def apply_enhanced_system_tray(): - # Store original method - global original_do_update_properties - original_do_update_properties = SystemTrayItem.do_update_properties - - # Attach helper methods to SystemTrayItem class - SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path - SystemTrayItem._set_tooltip = _set_tooltip - - # Replace the do_update_properties method - SystemTrayItem.do_update_properties = patched_do_update_properties - - -# Store reference to original method -original_do_update_properties = None diff --git a/modules/panel/components/indicators.py b/modules/panel/components/indicators.py deleted file mode 100644 index 7e8e277..0000000 --- a/modules/panel/components/indicators.py +++ /dev/null @@ -1,477 +0,0 @@ -from fabric.bluetooth import BluetoothClient -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.svg import Svg - -from modules.controlcenter.battery import BatteryControl -from modules.controlcenter.bluetooth import BluetoothConnections -from modules.controlcenter.wifi import WifiConnections -from services.battery import Battery -from services.network import NetworkClient -from utils.roam import modus_service -from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon -from widgets.mousecapture import DropDownMouseCapture -from widgets.wayland import WaylandWindow as Window - - -class BluetoothIndicator(Box): - def __init__(self, show_window=True, **kwargs): - super().__init__(name="bluetooth-indicator", orientation="h", **kwargs) - self.show_window = show_window - - self.bluetooth = BluetoothClient() - self.bt_icon = Svg( - name="bt-icon", - size=22, - svg_file=get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ), - ) - - self.bt_button = Button( - name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked - ) - - self.add(self.bt_button) - - # Create Bluetooth control center widget only if show_window is True - if self.show_window: - self.bluetooth_window = Window( - layer="overlay", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="bluetooth-control-window", - visible=False, - ) - - self.bluetooth_widget = BluetoothConnections(self, show_back_button=False) - self.bluetooth_window.children = [self.bluetooth_widget] - - # Create mouse capture for Bluetooth widget - self.bluetooth_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.bluetooth_window - ) - else: - self.bluetooth_window = None - self.bluetooth_widget = None - self.bluetooth_mousecapture = None - - modus_service.connect("bluetooth-changed", self.on_bluetooth_changed) - self.bluetooth.connect("changed", self.on_bluetooth_direct_changed) - self.bluetooth.connect("device-added", self.on_device_added) - self.bluetooth.connect("device-removed", self.on_device_removed) - - self.update_modus_service_bluetooth_state() - self.update_state() - - def update_state(self): - if not self.bluetooth.enabled: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-off-clear.svg" - ) - ) - tooltip = "Bluetooth disabled" - else: - connected_devices = self.bluetooth.connected_devices - if connected_devices: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ) - ) - if len(connected_devices) >= 1: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-paired.svg" - ) - ) - device = connected_devices[0] - tooltip = f"Connected to {device.alias}" - if device.battery_percentage > 0: - tooltip += f" ({device.battery_percentage:.0f}%)" - else: - tooltip = f"Connected to {len(connected_devices)} devices" - else: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ) - ) - tooltip = "No devices connected" - - self.bt_button.set_tooltip_text(tooltip) - - def on_bluetooth_changed(self, service, new_bluetooth_state): - self.update_state() - - def on_bluetooth_direct_changed(self, *args): - self.update_modus_service_bluetooth_state() - self.update_state() - - def on_device_added(self, _, address): - self.update_modus_service_bluetooth_state() - self.update_state() - - def on_device_removed(self, _, address): - self.update_modus_service_bluetooth_state() - self.update_state() - - def update_modus_service_bluetooth_state(self): - if not self.bluetooth.enabled: - bluetooth_state = "disabled" - else: - connected_devices = self.bluetooth.connected_devices - if connected_devices: - if len(connected_devices) == 1: - device = connected_devices[0] - bluetooth_state = f"connected:{device.alias}" - if ( - hasattr(device, "battery_percentage") - and device.battery_percentage > 0 - ): - bluetooth_state += f":{device.battery_percentage:.0f}%" - else: - bluetooth_state = f"connected:{len(connected_devices)}_devices" - else: - bluetooth_state = "enabled" - - modus_service.bluetooth = bluetooth_state - - def on_bluetooth_clicked(self, *args): - """Handle Bluetooth indicator click""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.toggle_mousecapture() - - def close_bluetooth(self, *args): - """Close Bluetooth control center""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.hide_child_window() - - def hide_controlcenter(self, *args): - """Hide Bluetooth control center""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.hide_child_window() - - -class NetworkIndicator(Box): - def __init__(self, show_window=True, **kwargs): - super().__init__(name="network-indicator", orientation="h", **kwargs) - self.show_window = show_window - - self.network_service = NetworkClient() - - self.network_icon = Svg( - name="network-icon", - size=22, - svg_file=get_relative_path( - "../../../config/assets/icons/applets/wifi-clear.svg" - ), - ) - - self.network_button = Button( - name="network-button", - child=self.network_icon, - on_clicked=self.on_wifi_clicked, - ) - - self.add(self.network_button) - - # Create WiFi control center widget only if show_window is True - if self.show_window: - self.wifi_window = Window( - layer="overlay", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="wifi-control-window", - visible=False, - ) - - self.wifi_widget = WifiConnections(self, show_back_button=False) - self.wifi_window.children = [self.wifi_widget] - - # Create mouse capture for WiFi widget - self.wifi_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.wifi_window - ) - else: - self.wifi_window = None - self.wifi_widget = None - self.wifi_mousecapture = None - - modus_service.connect("wlan-changed", self.on_wlan_changed) - self.network_service.connect("wifi-device-added", self.on_wifi_device_added) - self.network_service.connect( - "ethernet-device-added", self.on_ethernet_device_added - ) - self.network_service.connect("changed", self.on_network_changed) - - self.update_modus_service_wlan_state() - self.update_state() - - def on_wlan_changed(self, service, new_wlan_state): - self.update_state() - - def on_wifi_device_added(self, *args): - """Called when WiFi device is added""" - if self.network_service.wifi_device: - self.network_service.wifi_device.connect( - "changed", self.on_network_direct_changed - ) - self.update_modus_service_wlan_state() - self.update_state() - - def on_ethernet_device_added(self, *args): - """Called when Ethernet device is added""" - if self.network_service.ethernet_device: - self.network_service.ethernet_device.connect( - "changed", self.on_network_direct_changed - ) - self.update_modus_service_wlan_state() - self.update_state() - - def on_network_direct_changed(self, *args): - self.update_modus_service_wlan_state() - self.update_state() - - def on_network_changed(self, *args): - self.update_modus_service_wlan_state() - self.update_state() - - def update_modus_service_wlan_state(self): - wlan_state = "disconnected" - - # Check WiFi first (prioritize WiFi over Ethernet) - if self.network_service.wifi_device: - wifi = self.network_service.wifi_device - if not wifi.wireless_enabled: - wlan_state = "disabled" - elif wifi.active_access_point: - ap = wifi.active_access_point - wlan_state = f"connected:{ap.ssid}" - if ap.strength >= 0: - wlan_state += f":{ap.strength}%" - else: - wlan_state = "enabled" - - # Check Ethernet if WiFi is not connected - elif self.network_service.ethernet_device: - ethernet = self.network_service.ethernet_device - if ethernet.internet == "activated": - wlan_state = "ethernet:connected" - if hasattr(ethernet, "speed") and ethernet.speed: - wlan_state += f":{ethernet.speed}" - elif ethernet.internet == "activating": - wlan_state = "ethernet:connecting" - else: - wlan_state = "ethernet:disconnected" - - modus_service.wlan = wlan_state - - def update_state(self): - tooltip = "No network connection" - icon_file = "wifi-off-clear.svg" - - # Check WiFi first (prioritize WiFi over Ethernet) - if self.network_service.wifi_device: - wifi = self.network_service.wifi_device - if not wifi.wireless_enabled: - icon_file = "wifi-off-clear.svg" - tooltip = "WiFi disabled" - elif wifi.active_access_point: - ap = wifi.active_access_point - # Use dynamic WiFi icon based on signal strength - wifi_icon_path = get_wifi_icon_for_strength(ap.strength) - self.network_icon.set_from_file(wifi_icon_path) - tooltip = f"Connected to {ap.ssid}" - if ap.strength >= 0: - tooltip += f" ({ap.strength}%)" - self.network_button.set_tooltip_text(tooltip) - return # Early return to avoid setting icon again - else: - icon_file = "wifi-off-clear.svg" - tooltip = "WiFi disconnected" - - # Check Ethernet if WiFi is not connected - elif self.network_service.ethernet_device: - ethernet = self.network_service.ethernet_device - if ethernet.internet == "activated": - icon_file = "network-wired.svg" - tooltip = "Ethernet connected" - if hasattr(ethernet, "speed") and ethernet.speed: - tooltip += f" ({ethernet.speed})" - elif ethernet.internet == "activating": - icon_file = "network-wired.svg" - tooltip = "Ethernet connecting..." - else: - icon_file = "network-wired-offline.svg" - tooltip = "Ethernet disconnected" - - self.network_icon.set_from_file( - get_relative_path(f"../../../config/assets/icons/applets/{icon_file}") - ) - self.network_button.set_tooltip_text(tooltip) - - def on_wifi_clicked(self, *args): - """Handle WiFi indicator click""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.toggle_mousecapture() - - def close_wifi(self, *args): - """Close WiFi control center""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.hide_child_window() - - def hide_controlcenter(self, *args): - """Hide WiFi control center""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.hide_child_window() - - -class BatteryIndicator(Box): - def __init__(self, show_window=True, **kwargs): - super().__init__(name="battery-indicator", orientation="h", **kwargs) - self.show_window = show_window - - self.battery_service = Battery() - - self.battery_icon = Svg( - name="battery-icon", - size=23, - svg_file=get_relative_path( - "../../../config/assets/icons/battery/battery-100.svg" - ), - ) - - self.battery_button = Button( - name="battery-button", - child=self.battery_icon, - on_clicked=self.on_battery_clicked, - ) - - self.battery_label = Label(name="battery-label", label="--- %") - - self.add(self.battery_label) - self.add(self.battery_button) - - # Create Battery control center widget only if show_window is True - if self.show_window: - self.battery_window = Window( - layer="top", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="battery-control-window", - visible=False, - ) - - self.battery_widget = BatteryControl(self, show_back_button=False) - self.battery_window.children = [self.battery_widget] - - # Create mouse capture for Battery widget - self.battery_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.battery_window - ) - else: - self.battery_window = None - self.battery_widget = None - self.battery_mousecapture = None - - modus_service.connect("battery-changed", self.on_battery_changed) - self.battery_service.connect("changed", self.on_battery_direct_changed) - - self.update_modus_service_battery_state() - self.update_state() - - def on_battery_changed(self, service, new_battery_state): - self.update_state() - - def on_battery_direct_changed(self, *args): - self.update_modus_service_battery_state() - self.update_state() - - def update_modus_service_battery_state(self): - if not self.battery_service.is_present: - battery_state = "not_present" - else: - percentage = self.battery_service.percentage - state = self.battery_service.state.lower() - - battery_state = f"{state}:{percentage}%" - - if state == "discharging": - time_to_empty = self.battery_service.time_to_empty - if time_to_empty != "N/A": - battery_state += f":{time_to_empty}" - elif state == "charging": - time_to_full = self.battery_service.time_to_full - if time_to_full != "N/A": - battery_state += f":{time_to_full}" - - modus_service.battery = battery_state - - def get_battery_tooltip(self, percentage, state): - tooltip = f"Battery: {percentage}%" - - if state == "CHARGING": - tooltip += " (Charging)" - time_to_full = self.battery_service.time_to_full - if time_to_full != "N/A": - tooltip += f" - {time_to_full} until full" - elif state == "DISCHARGING": - time_to_empty = self.battery_service.time_to_empty - if time_to_empty != "N/A": - tooltip += f" - {time_to_empty} remaining" - elif state == "FULLY_CHARGED": - tooltip += " (Fully charged)" - - return tooltip - - def update_state(self): - if not self.battery_service.is_present: - # Hide the entire battery component when no battery is present - self.set_visible(False) - return - else: - # Show the battery component when battery is present - self.set_visible(True) - - percentage = self.battery_service.percentage - state = self.battery_service.state - is_charging = state in ["CHARGING", "FULLY_CHARGED"] - - icon_file = Battery.get_battery_icon_file( - percentage, is_charging, base_path="../../../config/assets/icons/" - ) - tooltip = self.get_battery_tooltip(percentage, state) - percentage_text = f"{percentage}%" - - # Update icon, tooltip, and percentage label - self.battery_icon.set_from_file(get_relative_path(icon_file)) - self.battery_button.set_tooltip_text(tooltip) - self.battery_label.set_label(percentage_text) - - def on_battery_clicked(self, *args): - """Handle Battery indicator click""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.toggle_mousecapture() - - def close_battery(self, *args): - """Close Battery control center""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.hide_child_window() - - def hide_controlcenter(self, *args): - """Hide Battery control center""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.hide_child_window() diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py deleted file mode 100644 index 61e7735..0000000 --- a/modules/panel/components/menubar.py +++ /dev/null @@ -1,411 +0,0 @@ -import json -import os -import subprocess - -from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow -from fabric.utils import FormattedString -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.label import Label - -from modules.about import About, AboutApp -from utils.roam import modus_service -from widgets.dropdown import ModusDropdown, dropdown_divider -from widgets.mousecapture import DropDownMouseCapture -from utils.app_name_resolver import format_window - - -def show_about_app(): - """Show about dialog for current active application""" - try: - # Use modus_service's Hyprland connection to get current window info - wmclass = "" - title = "" - - if ( - hasattr(modus_service, "_hyprland_connection") - and modus_service._hyprland_connection - ): - window_data = modus_service._hyprland_connection.send_command( - "j/activewindow" - ).reply - if window_data: - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Don't show about dialog if there's no active window (Finder state) - if not wmclass and not title: - return - - app_name = modus_service.current_active_app_name or "Finder" - # Don't show about dialog for Finder - if app_name == "Finder": - return - - about_window = AboutApp(app_name=app_name, wmclass=wmclass) - about_window.toggle(None) - except Exception: - # Fallback: only show if we have a real app name - app_name = modus_service.current_active_app_name or "" - if app_name and app_name != "Finder": - about_window = AboutApp(app_name=app_name, wmclass="") - about_window.toggle(None) - - -def dropdown_option( - label: str = "", - keybind: str = "", - on_click='echo "ModusPanelDropdown Action"', - on_clicked=None, -): - def on_click_subthread(button): - # Execute the action first - if on_clicked: - on_clicked(button) - else: - subprocess.Popen( - f"nohup {on_click} &", - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - # Hide dropdown by finding the current visible dropdown and calling its hide method - from widgets.dropdown import dropdowns - - for dropdown in dropdowns: - if dropdown.is_visible() and hasattr(dropdown, "hide_via_mousecapture"): - dropdown.hide_via_mousecapture() - break - - return Button( - child=CenterBox( - start_children=[ - Label(label=label, h_align="start", name="dropdown-option-label"), - ], - end_children=[ - Label(label=keybind, h_align="end", name="dropdown-option-keybind") - ], - orientation="horizontal", - h_align="fill", - h_expand=True, - v_expand=True, - ), - name="dropdown-option", - h_align="fill", - on_clicked=on_click_subthread, - h_expand=True, - v_expand=True, - ) - - -class SystemDropdown(ModusDropdown): - def __init__(self, parent, **kwargs): - super().__init__( - dropdown_id="os-menu", - parent=parent, - dropdown_children=[ - dropdown_option( - "About this PC", on_clicked=lambda _: About().toggle(_) - ), - dropdown_divider("---------------------"), - dropdown_option( - "System Settings...", - # TODO: Open Modus own setting - # on_click="xdg-open settings", - ), - dropdown_divider("---------------------"), - dropdown_option("Force Quit", "", "hyprctl kill"), - dropdown_divider("---------------------"), - dropdown_option("Sleep", "", "systemctl suspend"), - dropdown_option("Restart...", "", "systemctl reboot"), - dropdown_option("Shut Down...", "", "shutdown now"), - dropdown_divider("---------------------"), - dropdown_option("Lock Screen", "๓ฐ˜ณ L", "hyprlock"), - ], - **kwargs, - ) - - -class MenuBarDropdowns: - def __init__(self, parent): - self.parent = parent - - # System dropdown - self.system_dropdown = SystemDropdown(parent=parent) - self.menu_button_dropdown = DropDownMouseCapture( - layer="bottom", child_window=self.system_dropdown - ) - self.menu_button = Button( - label="Modus", - name="menu-button", - style_classes="button", - on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(), - ) - self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button) - - # Global menu dropdowns - self.global_title_menu_about = dropdown_option( - f"About {modus_service.current_active_app_name}", - on_clicked=lambda _: show_about_app(), - ) - self.global_menu_title = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-title", - parent=parent, - dropdown_children=[self.global_title_menu_about], - ), - ) - - self.global_menu_file = None - self.global_menu_edit = None - self.global_menu_view = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-view", - parent=parent, - dropdown_children=[ - dropdown_option( - "Enter Full Screen", - on_click="hyprctl dispatch fullscreen", - ), - ], - ), - ) - self.global_menu_go = None - self.global_menu_window = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-window", - parent=parent, - dropdown_children=[ - dropdown_option( - "Zoom In", - "๓ฐ‰ +", - on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')", - ), - dropdown_option( - "Zoom Out", - "๓ฐ‰ -", - on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Move Window to Left", - on_click="hyprctl dispatch movewindow l", - ), - dropdown_option( - "Move Window to Right", - on_click="hyprctl dispatch movewindow r", - ), - dropdown_option( - "Cycle Through Windows", - on_click="hyprctl dispatch cyclenext", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Float", on_click="hyprctl dispatch togglefloating" - ), - dropdown_option("Quit", on_click="hyprctl dispatch killactive"), - dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"), - dropdown_option( - "Toggle Split", on_click="hyprctl dispatch togglesplit" - ), - dropdown_option("Center", on_click="hyprctl dispatch centerwindow"), - dropdown_option("Group", on_click="hyprctl dispatch togglegroup"), - dropdown_option( - "Pin", - on_clicked=lambda _: subprocess.run( - "bash ~/.config/scripts/winpin.sh", shell=True - ), - ), - ], - ), - ) - - self.global_menu_help = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-help", - parent=parent, - dropdown_children=[ - dropdown_option( - "Modus", - on_click="xdg-open https://github.com/S4NKALP/Modus/issues", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/" - ), - ], - ), - ) - - # Create menu buttons - modus_service.connect( - "current-active-app-name-changed", - lambda _, value: self.global_title_menu_about.set_property( - "label", f"About {value}" - ), - ) - - # Connect to active app name changes to update the title button - modus_service.connect("current-active-app-name-changed", self._on_active_app_changed) - - self.global_menu_button_title = Button( - child=ActiveWindow( - formatter=FormattedString( - "{ format_window(win_title, win_class) }", - format_window=format_window, - ) - ), - name="global-title-button", - style_classes="button", - on_clicked=self._on_title_button_clicked, - ) - - self.global_menu_title.child_window.set_pointing_to( - self.global_menu_button_title - ) - - self.global_menu_button_file = Button( - label="File", name="global-menu-button-file", style_classes="button" - ) - self.global_menu_button_edit = Button( - label="Edit", name="global-menu-button-edit", style_classes="button" - ) - self.global_menu_button_view = Button( - label="View", - name="global-menu-button-view", - style_classes="button", - on_clicked=lambda _: self.global_menu_view.toggle_mousecapture(), - ) - self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view) - self.global_menu_button_go = Button( - label="Go", name="global-menu-button-go", style_classes="button" - ) - self.global_menu_button_window = Button( - label="Window", - name="global-menu-button-window", - style_classes="button", - on_clicked=lambda _: self.global_menu_window.toggle_mousecapture(), - ) - self.global_menu_window.child_window.set_pointing_to( - self.global_menu_button_window - ) - self.global_menu_button_help = Button( - label="Help", - name="global-menu-button-help", - style_classes="button", - on_clicked=lambda _: self.global_menu_help.toggle_mousecapture(), - ) - self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help) - - modus_service.connect("current-dropdown-changed", self.changed_dropdown) - modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) - - def _on_title_button_clicked(self, _): - """Handle title button click - only show dropdown if there's an active window""" - try: - # Use modus_service's Hyprland connection to get current window info - if ( - hasattr(modus_service, "_hyprland_connection") - and modus_service._hyprland_connection - ): - window_data = modus_service._hyprland_connection.send_command( - "j/activewindow" - ).reply - if window_data: - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Only show dropdown if there's an active window (not Finder) - if wmclass or title: - self.global_menu_title.toggle_mousecapture() - return - - # Fallback: check if current_active_app_name is not "Finder" - if ( - modus_service.current_active_app_name - and modus_service.current_active_app_name != "Finder" - ): - self.global_menu_title.toggle_mousecapture() - except Exception: - # If we can't get window info, don't show dropdown - pass - - def _on_active_app_changed(self, _, value): - """Handle active app name changes""" - # Update the "About" menu item label - self.global_title_menu_about.set_property("label", f"About {value}") - - def hide_dropdowns(self, *_): - self.menu_button.remove_style_class("active") - self.global_menu_button_edit.remove_style_class("active") - self.global_menu_button_file.remove_style_class("active") - self.global_menu_button_go.remove_style_class("active") - self.global_menu_button_help.remove_style_class("active") - self.global_menu_button_title.remove_style_class("active") - self.global_menu_button_view.remove_style_class("active") - self.global_menu_button_window.remove_style_class("active") - - def changed_dropdown(self, _, dropdown_id): - self.hide_dropdowns(_, True) - match dropdown_id: - case "os-menu": - self.menu_button.add_style_class("active") - case "global-menu-edit": - self.global_menu_button_edit.add_style_class("active") - case "global-menu-file": - self.global_menu_button_file.add_style_class("active") - case "global-menu-go": - self.global_menu_button_go.add_style_class("active") - case "global-menu-help": - self.global_menu_button_help.add_style_class("active") - case "global-menu-title": - self.global_menu_button_title.add_style_class("active") - case "global-menu-view": - self.global_menu_button_view.add_style_class("active") - case "global-menu-window": - self.global_menu_button_window.add_style_class("active") - case _: - pass - - -class MenuBar(Box): - """Main MenuBar widget that contains all menu buttons""" - - def __init__(self, parent_window=None, **kwargs): - # Extract parent_window from kwargs if not provided as parameter - if parent_window is None: - parent_window = kwargs.pop("parent_window", None) - - super().__init__(name="menubar", orientation="horizontal", spacing=0, **kwargs) - - # Create the dropdown system - self.dropdown_system = MenuBarDropdowns(parent=parent_window) - - # Add all the menu buttons to the menubar - self.children = [ - self.dropdown_system.global_menu_button_title, - self.dropdown_system.global_menu_button_file, - self.dropdown_system.global_menu_button_edit, - self.dropdown_system.global_menu_button_view, - self.dropdown_system.global_menu_button_go, - self.dropdown_system.global_menu_button_window, - self.dropdown_system.global_menu_button_help, - ] - - def show_system_dropdown(self, imac_button): - self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to( - imac_button - ) - mouse_capture = self.dropdown_system.menu_button_dropdown - if mouse_capture.is_visible(): - mouse_capture.set_child_window_visible(False) - else: - mouse_capture.set_child_window_visible(True) diff --git a/modules/panel/components/recording_indicator.py b/modules/panel/components/recording_indicator.py deleted file mode 100644 index ab318e3..0000000 --- a/modules/panel/components/recording_indicator.py +++ /dev/null @@ -1,236 +0,0 @@ -import os -import subprocess -import time - -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.svg import Svg -from gi.repository import GLib - - -class RecordingIndicator(Button): - def __init__(self, **kwargs): - super().__init__(name="panel-button", visible=True, **kwargs) - - self.script_path = get_relative_path("../../../scripts/screen-capture.sh") - self.recording_start_time = None - self.last_process_check = 0 - self.process_check_interval = 1.0 - self.timer_update_interval = 1000 - self.status_check_interval = 2000 - - self.timer_timeout_id = None - self.status_timeout_id = None - - self.recording_icon = Svg( - name="indicators-icon", - size=24, - svg_file=get_relative_path( - "../../../config/assets/icons/misc/media-record.svg" - ), - ) - self.time_label = Label( - name="recording-time-label", - markup="00:00", - max_width_chars=5, - ellipsize="none", - ) - - self.recording_box = Box( - orientation="h", - spacing=2, - children=[self.recording_icon, self.time_label], - size=(80, -1), - ) - - self.add(self.recording_box) - - self.connect("clicked", self.on_stop_recording) - self.connect("button-press-event", self.on_button_press) - self.hide() - - GLib.timeout_add(100, self._delayed_init) - - def on_button_press(self, *args): - GLib.timeout_add(100, lambda: self.remove_style_class("pressed") or False) - return False - - def is_recorder_running(self): - # add more process names if needed - recorder_processes = ["wf-recorder", "gpu-screen-recorder"] - - try: - for proc in recorder_processes: - result = subprocess.run( - ["pgrep", "-x", proc], - capture_output=True, - text=True, - timeout=1, - ) - if result.returncode == 0: - return True # Found a running recorder process - return False # None found running - except Exception: - return False - - def check_recording_status(self): - current_time = time.time() - self.last_process_check = current_time - - try: - is_recording = self.is_recorder_running() - - if is_recording: - if not self.get_visible(): - self.set_visible(True) - if self.timer_timeout_id is None: - self.timer_timeout_id = GLib.timeout_add( - self.timer_update_interval, self.update_timer_display - ) - - if self.recording_start_time is None: - self.recording_start_time = self.get_recording_start_time() - - self.update_timer_display() - else: - if self.get_visible(): - self.set_visible(False) - self.cleanup_recording_state() - - except Exception as e: - print(f"[DEBUG] Error checking recording status: {e}") - if self.get_visible(): - self.set_visible(False) - self.cleanup_recording_state() - - return True - - def update_timer_display(self): - if not self.get_visible() or self.recording_start_time is None: - return False - - try: - elapsed_seconds = int(time.time() - self.recording_start_time) - minutes = elapsed_seconds // 60 - seconds = elapsed_seconds % 60 - time_text = f"{minutes:02d}:{seconds:02d}" - - self.time_label.set_markup(time_text) - self.set_tooltip_text( - f"Recording in progress ({time_text}) - Click to stop" - ) - - return True - except Exception as e: - print(f"[DEBUG] Error updating timer display: {e}") - return False - - def cleanup_recording_state(self): - self.recording_start_time = None - - if self.timer_timeout_id: - GLib.source_remove(self.timer_timeout_id) - self.timer_timeout_id = None - - def get_recording_start_time(self): - wf_file = "/tmp/recording_start_time.txt" - gpu_file = "/tmp/gpu_recording_start_time.txt" - - def read_timestamp(path): - try: - with open(path, "r") as f: - content = f.read().strip() - if content: - t = float(content) - if abs(t - time.time()) <= 3600: - return t - return os.path.getmtime(path) - except (OSError, ValueError): - return None - - if os.path.exists(wf_file): - t = read_timestamp(wf_file) - if t: - print("[DEBUG] Using wf-recorder start time") - return t - - if os.path.exists(gpu_file): - t = read_timestamp(gpu_file) - if t: - print("[DEBUG] Using gpu-screen-recorder start time") - return t - - print("[DEBUG] No start time file found, using current time") - return time.time() - - def on_stop_recording(self, *args): - try: - self.set_visible(False) - self.cleanup_recording_state() - - def send_stop_command(): - try: - subprocess.Popen( - [self.script_path, "record", "stop"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - print(f"[DEBUG] Error sending stop command: {e}") - - GLib.idle_add(send_stop_command) - GLib.timeout_add(500, self._verify_recording_stopped) - GLib.timeout_add(1500, self._verify_recording_stopped) - GLib.timeout_add(3000, self._verify_recording_stopped) - - except Exception: - self.set_visible(False) - self.cleanup_recording_state() - - def _verify_recording_stopped(self): - try: - if self.is_recorder_running(): - if self.recording_start_time is None: - self.recording_start_time = self.get_recording_start_time() - - if self.recording_start_time: - self.set_visible(True) - self.update_timer_display() - - if self.timer_timeout_id is None: - self.timer_timeout_id = GLib.timeout_add( - self.timer_update_interval, self.update_timer_display - ) - else: - self.set_visible(False) - self.cleanup_recording_state() - - except Exception: - self.set_visible(False) - self.cleanup_recording_state() - - return False - - def _delayed_init(self): - try: - self.check_recording_status() - self.status_timeout_id = GLib.timeout_add( - self.status_check_interval, self.check_recording_status - ) - - except Exception as e: - print(f"[DEBUG] Error in delayed recording indicator init: {e}") - return False - - def destroy(self): - if self.timer_timeout_id: - GLib.source_remove(self.timer_timeout_id) - self.timer_timeout_id = None - - if self.status_timeout_id: - GLib.source_remove(self.status_timeout_id) - self.status_timeout_id = None - - super().destroy() diff --git a/modules/panel/components/workspace.py b/modules/panel/components/workspace.py deleted file mode 100644 index c05dab0..0000000 --- a/modules/panel/components/workspace.py +++ /dev/null @@ -1,44 +0,0 @@ -from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton -from fabric.widgets.box import Box -import config.data as data -from utils.functions import is_special_workspace_id - -# TODO: Support Multi Monitor - - -class WorkspaceIndicator(Box): - def __init__(self, **kwargs): - super().__init__( - name="workspace-indicator", orientation="h", spacing=4, **kwargs - ) - - # Create workspace widget with custom button factory if filtering is enabled - if data.HIDE_SPECIAL_WORKSPACE: - self.workspaces = HyprlandWorkspaces( - name="workspaces", - spacing=4, - buttons_factory=self._create_filtered_button, - ) - else: - self.workspaces = HyprlandWorkspaces( - name="workspaces", - spacing=4, - buttons_factory=lambda ws_id: WorkspaceButton( - id=ws_id, label=str(ws_id) - ), - ) - - self.add(self.workspaces) - self.show_all() - - def _is_special_workspace_id(self, ws_id): - return is_special_workspace_id(ws_id) - - def _create_filtered_button(self, ws_id): - if data.HIDE_SPECIAL_WORKSPACE and self._is_special_workspace_id(ws_id): - # Return None or an empty widget to hide special workspaces - return None - - return WorkspaceButton(id=ws_id, label=str(ws_id)) - - diff --git a/modules/panel/main.py b/modules/panel/main.py deleted file mode 100644 index 5228e7c..0000000 --- a/modules/panel/main.py +++ /dev/null @@ -1,243 +0,0 @@ -from fabric.system_tray.widgets import SystemTray -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.datetime import DateTime -from fabric.widgets.revealer import Revealer -from fabric.widgets.svg import Svg -from modules.controlcenter.main import ModusControlCenter -from modules.notification.notification_center import NotificationCenter -from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray -from modules.panel.components.indicators import ( - BatteryIndicator, - BluetoothIndicator, - NetworkIndicator, -) -from modules.panel.components.menubar import MenuBar -from modules.panel.components.recording_indicator import RecordingIndicator -from modules.panel.components.workspace import WorkspaceIndicator -from modules.todo.todo_widget import TodoListCapture -from utils.roam import modus_service -from widgets.mousecapture import MouseCapture -from widgets.wayland import WaylandWindow as Window -from services.modus import notification_service - -# Apply enhanced system tray icon handling -apply_enhanced_system_tray() - - -class Panel(Window): - def __init__(self, **kwargs): - super().__init__( - name="bar", - title="modus", - layer="top", - anchor="left top right", - exclusivity="auto", - visible=False, - ) - - self.launcher = kwargs.get("launcher", None) - self.menubar = MenuBar(parent_window=self) - - self.imac = Button( - name="panel-button", - child=Svg( - size=16, - svg_file=get_relative_path("../../config/assets/icons/misc/logo.svg"), - ), - on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac), - ) - - # DND indicator - REMOVED - - self.tray = SystemTray(name="system-tray", spacing=4, icon_size=20) - - self.tray_revealer = Revealer( - name="tray-revealer", - child=self.tray, - child_revealed=False, - transition_type="slide-left", - transition_duration=300, - ) - - self.chevron_button = Button( - name="panel-button", - child=Svg( - size=16, - svg_file=get_relative_path( - "../../config/assets/icons/misc/chevron-right.svg" - ), - ), - on_clicked=self.toggle_tray, - ) - - self.indicators = Box( - name="indicators", - orientation="h", - spacing=4, - children=[ - BatteryIndicator(), - NetworkIndicator(), - BluetoothIndicator(), - ], - ) - - self.search = Button( - name="panel-button", - on_clicked=lambda *_: self.search_apps(), - child=Svg( - size=22, - svg_file=get_relative_path("../../config/assets/icons/misc/search.svg"), - ), - ) - - self.control_center = MouseCapture( - layer="top", child_window=ModusControlCenter() - ) - - self.control_center_button = Button( - name="control-center-button", - style_classes="button", - on_clicked=self.control_center.toggle_mousecapture, - child=Svg( - size=22, - svg_file=get_relative_path( - "../../config/assets/icons/misc/control-center.svg" - ), - ), - ) - - # Notification Center with MouseCapture - self.notification_center = MouseCapture( - layer="overlay", child_window=NotificationCenter() - ) - - # Todo List with MouseCapture - self.todo_list = TodoListCapture() - - # Notification Center Icon - self.notification_icon = Svg( - size=22, - svg_file=get_relative_path( - "../../config/assets/icons/notifications/notification-inactive.svg" - ), - ) - - self.notification_center_icon_button = Button( - name="notification-center-icon-button", - child=self.notification_icon, - on_clicked=self.on_notification_icon_clicked, - ) - - # Clickable DateTime for todo list - self.datetime_button = Button( - name="datetime-button", - child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]), - on_clicked=self.on_datetime_clicked, - ) - - self.recording_indicator = RecordingIndicator() - - # Workspace indicator - self.workspace_indicator = WorkspaceIndicator() - - self.children = CenterBox( - name="panel", - start_children=Box( - name="modules-left", - children=[ - self.imac, - self.menubar, - ], - ), - center_children=Box( - name="modules-center", - children=self.recording_indicator, - ), - end_children=Box( - name="modules-right", - spacing=4, - orientation="h", - children=[ - self.workspace_indicator, - self.tray_revealer, - self.chevron_button, - self.indicators, - self.search, - self.control_center_button, - self.datetime_button, - self.notification_center_icon_button, - ], - ), - ) - - # Connect to DND state changes for notification icon - modus_service.connect("dont-disturb-changed", self.on_dnd_changed) - - # Connect to notification service for icon state updates - notification_service.connect( - "notify::count", self.on_notification_count_changed - ) - - # Set initial notification icon state - self.update_notification_icon() - - return self.show_all() - - def search_apps(self): - self.launcher.show_launcher() - - def toggle_tray(self, *_): - current_state = self.tray_revealer.child_revealed - self.tray_revealer.child_revealed = not current_state - - if self.tray_revealer.child_revealed: - self.chevron_button.get_child().set_from_file( - get_relative_path("../../config/assets/icons/misc/chevron-left.svg") - ) - else: - self.chevron_button.get_child().set_from_file( - get_relative_path("../../config/assets/icons/misc/chevron-right.svg") - ) - - def on_dnd_changed(self, _, dnd_state): - """Handle DND state changes from the service.""" - self.update_notification_icon() # Update notification icon when DND changes - - def on_notification_count_changed(self, service, *args): - """Handle notification count changes from the service.""" - self.update_notification_icon() - - def on_notification_icon_clicked(self, *args): - """Handle notification icon clicks - only open center if there are notifications.""" - count = notification_service.count - if count > 0: - # Only open notification center if there are notifications - self.notification_center.toggle_mousecapture() - # Do nothing if no notifications - - def on_datetime_clicked(self, *args): - """Handle datetime button clicks - open todo list.""" - self.todo_list.toggle_mousecapture() - - def update_notification_icon(self): - """Update the notification icon based on count and DND state.""" - count = notification_service.count - dnd_enabled = modus_service.dont_disturb - - if dnd_enabled: - # DND is enabled - show disabled icon - icon_file = "notification-disabled.svg" - elif count > 0: - # Has notifications - show active icon - icon_file = "notification-active.svg" - else: - # No notifications - show inactive icon - icon_file = "notification-inactive.svg" - - icon_path = get_relative_path( - f"../../config/assets/icons/notifications/{icon_file}" - ) - self.notification_icon.set_from_file(icon_path) diff --git a/modules/switcher.py b/modules/switcher.py deleted file mode 100644 index 564c54a..0000000 --- a/modules/switcher.py +++ /dev/null @@ -1,405 +0,0 @@ -import json - -import gi -from gi.repository import Gdk, Glace - -import config.data as data -from fabric.hyprland.widgets import get_hyprland_connection -from fabric.widgets.box import Box -from fabric.widgets.eventbox import EventBox -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from utils.icon_resolver import IconResolver -from utils.occlusion import get_screen_dimensions -from utils.functions import is_special_workspace -from widgets.wayland import WaylandWindow as Window - -gi.require_version("Glace", "0.1") - - -class ApplicationSwitcher(Window): - def __init__(self, **kwargs): - super().__init__( - name="application-switcher", - title="modus-switcher", - layer="top", - anchor="center", - exclusivity="auto", - keyboard_mode="exclusive", - visible=False, # Start hidden until explicitly shown - **kwargs, - ) - - self.conn = get_hyprland_connection() - self.icon_resolver = IconResolver() - self.windows = [] - self.current_index = 0 - self.tab_pressed = False - self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW - self.icon_size = 64 - - # Initialize Glace manager for window previews - self._manager = Glace.Manager() - - # Calculate preview size based on screen ratio - # Formula: screen_ratio = a:b, width = x, height = (x*b)/a - screen_width, screen_height = get_screen_dimensions() - preview_width = 150 # Base width - preview_height = int((preview_width * screen_height) / screen_width) - self.preview_size = [preview_width, preview_height] - - self.glace_clients = {} # Map window addresses to Glace clients - self.window_previews = {} # Map window addresses to preview images - - container = Box( - name="application-switcher-container", - orientation="v", - h_align="center", - v_align="center", - expand=True, - ) - self.add(container) - - self.view = Box( - name="application-switcher-view", - orientation="v", - spacing=12, - h_align="center", - v_align="center", - ) - container.add(self.view) - self.connect("key-press-event", self.on_key_press) - self.connect("key-release-event", self.on_key_release) - - # Connect to Glace manager signals to track clients - self._manager.connect("client-added", self._on_glace_client_added) - self._manager.connect("client-removed", self._on_glace_client_removed) - - self.show_all() - self.hide() - - def show_switcher(self) -> None: - self.update_windows() - if not self.windows: - return - - self.show() - self.grab_keyboard() - self.tab_pressed = False - - def hide_switcher(self) -> None: - self.hide() - self.ungrab_keyboard() - - def _on_glace_client_added(self, _, client): - """Handle when a Glace client is added""" - try: - # Map the client by its window address for later lookup - # We'll need to match this with Hyprland window data - client_id = client.get_id() - self.glace_clients[client_id] = client - except Exception as e: - print(f"Error adding Glace client: {e}") - - def _on_glace_client_removed(self, _, client): - """Handle when a Glace client is removed""" - try: - client_id = client.get_id() - if client_id in self.glace_clients: - del self.glace_clients[client_id] - except Exception as e: - print(f"Error removing Glace client: {e}") - - def _find_glace_client_for_window(self, window): - """Find the corresponding Glace client for a Hyprland window""" - try: - window_class = window.get("class", "").lower() - window_title = window.get("title", "") - - # Try to match by app_id/class and title - for _, client in self.glace_clients.items(): - try: - client_app_id = client.get_app_id() - client_title = client.get_title() - - if ( - client_app_id - and client_app_id.lower() == window_class - and client_title - and client_title == window_title - ): - return client - except Exception: - continue - - # Fallback: try to match by class only - for _, client in self.glace_clients.items(): - try: - client_app_id = client.get_app_id() - if client_app_id and client_app_id.lower() == window_class: - return client - except Exception: - continue - - except Exception as e: - print(f"Error finding Glace client: {e}") - - return None - - def create_preview_for_window(self, window): - """Create a preview image for a specific window""" - glace_client = self._find_glace_client_for_window(window) - - # Create a placeholder image first - preview_image = Image() - - if glace_client: - - def capture_callback(pbuf, _): - try: - scaled_pixbuf = pbuf.scale_simple( - self.preview_size[0], - self.preview_size[1], - 2, # GdkPixbuf.InterpType.BILINEAR - ) - preview_image.set_from_pixbuf(scaled_pixbuf) - except Exception as e: - print(f"Error setting preview image: {e}") - - try: - self._manager.capture_client( - client=glace_client, - overlay_cursor=False, - callback=capture_callback, - user_data=None, - ) - except Exception as e: - print(f"Error capturing client preview: {e}") - # Fallback to icon if preview fails - self._set_fallback_icon(preview_image, window) - else: - # Use icon as fallback if no Glace client found - self._set_fallback_icon(preview_image, window) - - return preview_image - - def _set_fallback_icon(self, image_widget, window): - """Set a fallback icon when preview is not available""" - class_name = window.get("class", "").lower() - icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size) - if not icon_img: - icon_img = self.icon_resolver.get_icon_pixbuf( - "application-x-executable-symbolic", self.icon_size - ) - image_widget.set_from_pixbuf(icon_img) - - def _is_special_workspace(self, client): - return is_special_workspace(client) - - def update_windows(self) -> None: - for child in self.view.get_children(): - self.view.remove(child) - - try: - clients_data = self.conn.send_command("j/clients").reply - if not clients_data: - return - clients = json.loads(clients_data.decode("utf-8")) - - # Filter out hidden windows and optionally special workspace windows - filtered_windows = [] - for c in clients: - if c.get("hidden", False): - continue - # Skip clients in special workspaces if the setting is enabled - if ( - data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS - and self._is_special_workspace(c) - ): - continue - filtered_windows.append(c) - - self.windows = filtered_windows - - active_data = self.conn.send_command("j/activewindow").reply - active_window = ( - json.loads(active_data.decode("utf-8")) if active_data else None - ) - - self.current_index = 0 - if active_window: - for i, window in enumerate(self.windows): - if window.get("address") == active_window.get("address"): - self.current_index = i - break - - current_row = Box( - name="window-row", - orientation="h", - spacing=12, - h_align="center", - v_align="center", - ) - self.view.add(current_row) - - for i, window in enumerate(self.windows): - title = window.get("title", "") - - # Create preview image for this window - preview_image = self.create_preview_for_window(window) - - button_content = Box( - name="switcher-button", - orientation="v", - spacing=4, - h_align="center", - v_align="center", - children=[ - Box( - name="switcher-preview-box", - style_classes=["window-basic", "sleek-border"], - children=[preview_image], - h_align="center", - v_align="center", - ), - Label( - label=title[:15] + "..." if len(title) > 15 else title, - h_align="center", - v_align="center", - max_width_chars=15, - ellipsize="end", - ), - ], - ) - - event_box = EventBox( - name="window-button", - style_classes=["active"] if i == self.current_index else None, - child=button_content, - ) - current_row.add(event_box) - - if (i + 1) % self.items_per_row == 0 and i + 1 < len(self.windows): - current_row = Box( - name="window-row", - orientation="h", - spacing=12, - h_align="center", - v_align="center", - ) - self.view.add(current_row) - - self.view.show_all() - self.update_selection() - except Exception as e: - print(f"Failed to update windows: {e}") - - def on_key_press(self, _, event): - keyval = event.keyval - state = event.state - alt_pressed = bool(state & Gdk.ModifierType.MOD1_MASK) - - if not self.windows: - return False - - if keyval == Gdk.KEY_Escape: - self.hide_switcher() - return True - - if keyval == Gdk.KEY_Tab: - if not self.tab_pressed or alt_pressed: - self.current_index = (self.current_index + 1) % len(self.windows) - self.update_selection() - self.tab_pressed = True - return True - - if keyval == Gdk.KEY_ISO_Left_Tab or ( - keyval == Gdk.KEY_Tab and (state & Gdk.ModifierType.SHIFT_MASK) - ): - self.current_index = (self.current_index - 1) % len(self.windows) - self.update_selection() - return True - - if keyval == Gdk.KEY_Return: - self.activate_selected() - self.hide_switcher() - return True - - if keyval == Gdk.KEY_Right or keyval == Gdk.KEY_l: - self.current_index = (self.current_index + 1) % len(self.windows) - self.update_selection() - return True - - if keyval == Gdk.KEY_Left or keyval == Gdk.KEY_h: - self.current_index = (self.current_index - 1) % len(self.windows) - self.update_selection() - return True - - if keyval == Gdk.KEY_Down: - next_index = self.current_index + self.items_per_row - if next_index < len(self.windows): - self.current_index = next_index - self.update_selection() - return True - - if keyval == Gdk.KEY_Up: - next_index = self.current_index - self.items_per_row - if next_index >= 0: - self.current_index = next_index - self.update_selection() - return True - - return False - - def on_key_release(self, _, event): - keyval = event.keyval - - if keyval in (Gdk.KEY_Alt_L, Gdk.KEY_Alt_R): - self.activate_selected() - self.hide_switcher() - return True - - if keyval == Gdk.KEY_Tab: - self.tab_pressed = False - return True - - return False - - def update_selection(self): - for row in self.view.get_children(): - for i, child in enumerate(row.get_children()): - index = self.view.get_children().index(row) * self.items_per_row + i - if index == self.current_index: - child.add_style_class("active") - else: - child.remove_style_class("active") - - def activate_selected(self): - if not self.windows or self.current_index >= len(self.windows): - return - - window = self.windows[self.current_index] - address = window.get("address") - if address: - try: - command = f"/dispatch focuswindow address:{address}" - self.conn.send_command(command) - except Exception as e: - print(f"Failed to focus window: {e}") - - def grab_keyboard(self): - try: - display = Gdk.Display.get_default() - seat = display.get_default_seat() - window = self.get_window() - seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None) - except Exception as e: - print(f"Failed to grab keyboard: {e}") - - def ungrab_keyboard(self): - try: - display = Gdk.Display.get_default() - seat = display.get_default_seat() - seat.ungrab() - except Exception as e: - print(f"Failed to ungrab keyboard: {e}") diff --git a/modules/todo/__init__.py b/modules/todo/__init__.py deleted file mode 100644 index 30afe86..0000000 --- a/modules/todo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Todo module \ No newline at end of file diff --git a/modules/todo/todo_widget.py b/modules/todo/todo_widget.py deleted file mode 100644 index a19cb96..0000000 --- a/modules/todo/todo_widget.py +++ /dev/null @@ -1,456 +0,0 @@ -# Standard library imports -from datetime import datetime - -# Fabric imports -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.entry import Entry -from fabric.widgets.label import Label -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.svg import Svg -from gi.repository import GLib - -# Local imports -from services.todo import todo_service -from widgets.mousecapture import MouseCapture -from widgets.wayland import WaylandWindow as Window - - -class TodoItem(Box): - """Individual todo item widget""" - - def __init__(self, todo_data, todo_list_widget, **kwargs): - self.todo_data = todo_data - self.todo_list_widget = todo_list_widget - self.editing = False - - super().__init__( - name="todo-item", - orientation="h", - spacing=8, - style_classes=["menu"], - **kwargs, - ) - - self._build_ui() - - def _build_ui(self): - """Build the todo item UI""" - # Checkbox for completion - using SVG icons - checkbox_icon = ( - "checkbox-check.svg" - if self.todo_data["completed"] - else "checkbox-uncheck.svg" - ) - self.checkbox_icon = Svg( - name="todo-checkbox-icon", - size=24, - svg_file=get_relative_path( - "../../config/assets/icons/todo/" + checkbox_icon - ), - ) - self.checkbox = Button( - name="todo-checkbox", - child=self.checkbox_icon, - on_clicked=self._toggle_completion, - ) - - # Todo text (can be converted to entry for editing) - text_content = self.todo_data["text"] - if self.todo_data["completed"]: - text_content = f"<s>{text_content}</s>" - - self.text_label = Label( - markup=text_content, - name="todo-text", - h_align="start", - h_expand=True, - line_wrap="word-char", - style_classes=( - ["title-widget"] - if not self.todo_data["completed"] - else ["status-label"] - ), - ) - - # Date/time label - created_at = datetime.fromisoformat(self.todo_data["created_at"]) - date_text = created_at.strftime("%b %d, %Y at %I:%M %p") - - self.date_label = Label( - label=date_text, - name="todo-date", - h_align="start", - style_classes=["todo-date-text"], - ) - - self.text_entry = Entry( - name="todo-text-entry", - text=self.todo_data["text"], - h_expand=True, - visible=False, - ) - self.text_entry.connect("activate", self._save_edit) - - # Priority indicator - # priority_symbols = {"high": "๐Ÿ”ด", "medium": "๐ŸŸก", "low": "๐ŸŸข"} - # - # self.priority_label = Label( - # label=priority_symbols.get(self.todo_data["priority"], "๐ŸŸก"), - # name="todo-priority-label", - # ) - # self.priority_button = Button( - # name="todo-priority", - # size=(20, 20), - # child=self.priority_label, - # on_clicked=self._cycle_priority, - # ) - # - # Edit button - using SVG icon - self.edit_icon = Svg( - name="todo-edit-icon", - size=12, - svg_file=get_relative_path("../../config/assets/icons/todo/edit.svg"), - ) - self.edit_button = Button( - name="todo-edit", - child=self.edit_icon, - on_clicked=self._start_edit, - ) - - # Delete button - using SVG icon - self.delete_icon = Svg( - name="todo-delete-icon", - size=12, - svg_file=get_relative_path( - "../../config/assets/icons/todo/delete-symbolic.svg" - ), - ) - self.delete_button = Button( - name="todo-delete", - child=self.delete_icon, - on_clicked=self._delete_todo, - ) - - # Text container that switches between label and entry - self.text_container = Box( - orientation="v", - h_expand=True, - children=[ - Box(orientation="h", children=[self.text_label]), - self.date_label, - ], - ) - - self.children = [ - self.checkbox, - self.text_container, - # self.priority_button, - self.edit_button, - self.delete_button, - ] - - def _toggle_completion(self, *_): - """Toggle todo completion status""" - todo_service.toggle_todo(self.todo_data["id"]) - - def _cycle_priority(self, *_): - """Cycle through priority levels""" - priorities = ["low", "medium", "high"] - current_index = priorities.index(self.todo_data["priority"]) - new_priority = priorities[(current_index + 1) % len(priorities)] - todo_service.set_priority(self.todo_data["id"], new_priority) - - def _start_edit(self, *_): - """Start editing the todo text""" - if self.editing: - return - - self.editing = True - self.text_container.children = [ - Box(orientation="h", children=[self.text_entry]), - self.date_label, - ] - self.text_entry.set_visible(True) - self.text_entry.grab_focus() - self.text_entry.set_position(-1) # Move cursor to end - - def _save_edit(self, *_): - """Save the edited todo text""" - if not self.editing: - return - - new_text = self.text_entry.get_text().strip() - if new_text: - todo_service.edit_todo(self.todo_data["id"], new_text) - - self._cancel_edit() - - def _cancel_edit(self): - """Cancel editing and revert to label""" - self.editing = False - self.text_container.children = [ - Box(orientation="h", children=[self.text_label]), - self.date_label, - ] - self.text_entry.set_visible(False) - - def _delete_todo(self, *_): - """Delete this todo""" - todo_service.delete_todo(self.todo_data["id"]) - - def update_from_data(self, todo_data): - """Update the widget from new todo data""" - self.todo_data = todo_data - - # Update checkbox icon by recreating it - checkbox_icon = ( - "checkbox-check.svg" if todo_data["completed"] else "checkbox-uncheck.svg" - ) - new_checkbox_icon = Svg( - name="todo-checkbox-icon", - size=20, - svg_file=get_relative_path( - "../../config/assets/icons/todo/" + checkbox_icon - ), - ) - self.checkbox.set_child(new_checkbox_icon) - self.checkbox_icon = new_checkbox_icon - - # Update text and styling with markup - text_content = todo_data["text"] - if todo_data["completed"]: - text_content = f"<s>{text_content}</s>" - - self.text_label.set_markup(text_content) - self.text_label.style_classes = ( - ["title-widget"] if not todo_data["completed"] else ["status-label"] - ) - - # Update date/time - created_at = datetime.fromisoformat(todo_data["created_at"]) - date_text = created_at.strftime("%b %d, %Y at %I:%M %p") - self.date_label.set_label(date_text) - - -class TodoListWidget(Window): - """Main todo list widget window""" - - def __init__(self, **kwargs): - super().__init__( - title="modus-todo", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="todo-list-window", - visible=False, # Back to hidden by default - **kwargs, - ) - - self.todo_items = {} # Maps todo IDs to TodoItem widgets - - # Register callback with todo service - todo_service.add_callback(self._on_todo_event) - - self._build_ui() - self._refresh_todos() - - # Add keybinding for escape - self.add_keybinding("Escape", self.hide_todo_list) - - def _build_ui(self): - """Build the main UI""" - # Header with title and stats - self.stats_label = Label( - label="", - name="todo-stats", - style_classes=["status-label"], - h_align="start", - ) - - self.header = Box( - name="todo-header", - orientation="v", - children=[ - Label( - label="Todo List", - name="todo-title", - style_classes=["title"], - h_align="start", - ), - self.stats_label, - ], - ) - - # Add new todo section - self.new_todo_entry = Entry( - name="new-todo-entry", - placeholder_text="Add a new task...", - h_expand=True, - ) - self.new_todo_entry.connect("activate", self._add_todo) - - # Add button - using SVG icon - self.add_icon = Svg( - name="add-todo-icon", - size=12, - svg_file=get_relative_path( - "../../config/assets/icons/todo/plus-symbolic.svg" - ), - ) - self.add_button = Button( - name="add-todo-button", - child=self.add_icon, - on_clicked=self._add_todo, - ) - - self.add_section = Box( - name="todo-add-section", - orientation="h", - spacing=8, - style_classes=["menu"], - children=[ - self.new_todo_entry, - self.add_button, - ], - ) - - # Todo items container - self.todos_container = Box( - name="todos-container", - orientation="v", - spacing=4, - ) - - # Scrolled window for todos - self.scrolled = ScrolledWindow( - name="todos-scrolled", - min_content_height=300, - max_content_height=500, - min_content_width=400, - child=self.todos_container, - policy="automatic", - v_expand=True, # Allow vertical expansion - ) - - # Clear completed button - self.clear_button = Button( - name="clear-completed-button", - label="Clear Completed", - style_classes=["status-label"], - on_clicked=self._clear_completed, - ) - - # Main container - self.main_container = Box( - name="todo-main-container", - orientation="v", - spacing=8, - style_classes=["menu"], - children=[ - self.header, - self.add_section, - self.scrolled, - self.clear_button, - ], - ) - - self.children = [self.main_container] - - def _add_todo(self, *_): - """Add a new todo""" - text = self.new_todo_entry.get_text().strip() - if text: - todo_service.add_todo(text) - self.new_todo_entry.set_text("") - - def _clear_completed(self, *_): - """Clear all completed todos""" - todo_service.clear_completed() - - def _on_todo_event(self, event_type, data=None): - """Handle todo service events via callback""" - if event_type == "todos-changed": - GLib.idle_add(self._refresh_todos) - elif event_type == "todo-added": - GLib.idle_add(self._refresh_todos) - elif event_type == "todo-deleted": - GLib.idle_add(self._refresh_todos) - elif event_type in ["todo-toggled", "todo-edited", "todo-priority-changed"]: - if data and data["id"] in self.todo_items: - GLib.idle_add( - lambda: self.todo_items[data["id"]].update_from_data(data) - ) - GLib.idle_add(self._update_stats) - - def _refresh_todos(self, *_): - """Refresh the entire todo list""" - # Clear existing items - self.todo_items.clear() - self.todos_container.children = [] - - # Get all todos - todos = todo_service.todos - - # Sort todos: incomplete first, then by priority, then by creation date - def sort_key(todo): - priority_order = {"high": 0, "medium": 1, "low": 2} - return ( - todo["completed"], # False (incomplete) comes before True (completed) - priority_order.get(todo["priority"], 1), - todo["created_at"], - ) - - sorted_todos = sorted(todos, key=sort_key) - - # Create todo item widgets - for todo in sorted_todos: - todo_item = TodoItem(todo, self) - self.todo_items[todo["id"]] = todo_item - - # Update container children - self.todos_container.children = list(self.todo_items.values()) - - # Update stats - self._update_stats() - - def _update_stats(self): - """Update the statistics display""" - stats = todo_service.get_stats() - stats_text = f"{stats['pending']} pending, {stats['completed']} completed" - self.stats_label.set_label(stats_text) - - def set_visible(self, visible): - """Override set_visible for debugging""" - super().set_visible(visible) - - def hide_todo_list(self, *_): - """Hide the todo list""" - if hasattr(self, "_mousecapture_parent"): - self._mousecapture_parent.toggle_mousecapture() - self.set_visible(False) - - def _init_mousecapture(self, mousecapture): - """Initialize mousecapture parent reference""" - self._mousecapture_parent = mousecapture - - def destroy(self): - """Clean up when destroyed""" - # Remove callback from todo service - todo_service.remove_callback(self._on_todo_event) - - super().destroy() - - -class TodoListCapture(MouseCapture): - """MouseCapture wrapper for the todo list""" - - def __init__(self, **kwargs): - super().__init__( - layer="top", - child_window=TodoListWidget(), - **kwargs, - ) diff --git a/modules/widget.py b/modules/widget.py deleted file mode 100644 index 21ce287..0000000 --- a/modules/widget.py +++ /dev/null @@ -1,887 +0,0 @@ -# Standard library imports -import psutil -import requests -import urllib.parse -import datetime -import time -import subprocess -import calendar -from concurrent.futures import ThreadPoolExecutor -from typing import Optional, Tuple, List, Dict, Any - -# Fabric imports -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.overlay import Overlay -from fabric.widgets.datetime import DateTime -from fabric.widgets.circularprogressbar import CircularProgressBar -from widgets.wayland import WaylandWindow as Window -from fabric.utils import invoke_repeater -from gi.repository import GLib - -# Local imports -from config.data import load_config - -# Module-level constants -WEATHER_UPDATE_INTERVAL = 600 # 10 minutes -WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes -SYSTEM_UPDATE_INTERVAL = 1000 # 1 second -CALENDAR_UPDATE_INTERVAL = int( - ( - ( - datetime.datetime.combine( - datetime.date.today() + datetime.timedelta(days=1), datetime.time.min - ) - - datetime.datetime.now() - ).total_seconds() - ) - * 1000 -) # Calculate time till midnight -LOCATION_CACHE_TIMEOUT = 604800 # 7 days (extended from 24h) - -# Thread pool for async operations -executor = ThreadPoolExecutor(max_workers=4) - -# Weather condition to CSS class mapping (iOS-style gradients) -WEATHER_GRADIENT_MAP = { - # Clear/Sunny conditions - bright blue to lighter blue - 0: "weather-clear", # Clear sky - 1: "weather-mostly-clear", # Mainly clear - # Cloudy conditions - grey gradients - 2: "weather-partly-cloudy", # Partly cloudy - 3: "weather-overcast", # Overcast - # Fog conditions - muted grey/blue - 45: "weather-fog", # Fog - 48: "weather-fog", # Depositing rime fog - # Light rain/drizzle - blue-grey gradients - 51: "weather-light-rain", # Light drizzle - 53: "weather-rain", # Moderate drizzle - 55: "weather-rain", # Dense drizzle - 61: "weather-light-rain", # Slight rain - 80: "weather-light-rain", # Slight rain showers - # Heavy rain - darker blue-grey - 63: "weather-heavy-rain", # Moderate rain - 65: "weather-heavy-rain", # Heavy rain - 81: "weather-heavy-rain", # Moderate rain showers - 82: "weather-storm", # Violent rain showers - # Snow conditions - blue-white gradients - 56: "weather-snow", # Light freezing drizzle - 57: "weather-snow", # Dense freezing drizzle - 66: "weather-snow", # Light freezing rain - 67: "weather-snow", # Heavy freezing rain - 71: "weather-snow", # Slight snow fall - 73: "weather-heavy-snow", # Moderate snow fall - 75: "weather-heavy-snow", # Heavy snow fall - 77: "weather-snow", # Snow grains - 85: "weather-snow", # Slight snow showers - 86: "weather-heavy-snow", # Heavy snow showers - # Storm conditions - dark dramatic gradients - 95: "weather-storm", # Thunderstorm - 96: "weather-storm", # Thunderstorm with slight hail - 99: "weather-storm", # Thunderstorm with heavy hail -} - -# Weather condition to emoji mapping -WEATHER_EMOJI_MAP = { - 0: "โ˜€๏ธ", # Clear sky - 1: "๐ŸŒค๏ธ", # Mainly clear - 2: "โ›…", # Partly cloudy - 3: "โ˜๏ธ", # Overcast - 45: "๐ŸŒซ๏ธ", # Fog - 48: "๐ŸŒซ๏ธ", # Depositing rime fog - 51: "๐ŸŒฆ๏ธ", # Light drizzle - 53: "๐ŸŒง๏ธ", # Moderate drizzle - 55: "๐ŸŒง๏ธ", # Dense drizzle - 56: "๐ŸŒจ๏ธ", # Light freezing drizzle - 57: "๐ŸŒจ๏ธ", # Dense freezing drizzle - 61: "๐ŸŒฆ๏ธ", # Slight rain - 63: "๐ŸŒง๏ธ", # Moderate rain - 65: "๐ŸŒง๏ธ", # Heavy rain - 66: "๐ŸŒจ๏ธ", # Light freezing rain - 67: "๐ŸŒจ๏ธ", # Heavy freezing rain - 71: "๐ŸŒจ๏ธ", # Slight snow fall - 73: "โ„๏ธ", # Moderate snow fall - 75: "โ„๏ธ", # Heavy snow fall - 77: "๐ŸŒจ๏ธ", # Snow grains - 80: "๐ŸŒฆ๏ธ", # Slight rain showers - 81: "๐ŸŒง๏ธ", # Moderate rain showers - 82: "โ›ˆ๏ธ", # Violent rain showers - 85: "๐ŸŒจ๏ธ", # Slight snow showers - 86: "โ„๏ธ", # Heavy snow showers - 95: "โ›ˆ๏ธ", # Thunderstorm - 96: "โ›ˆ๏ธ", # Thunderstorm with slight hail - 99: "โ›ˆ๏ธ", # Thunderstorm with heavy hail -} - -# Weather condition descriptions -WEATHER_DESC_MAP = { - 0: "Clear sky", - 1: "Mainly clear", - 2: "Partly cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Depositing rime fog", - 51: "Light drizzle", - 53: "Moderate drizzle", - 55: "Dense drizzle", - 56: "Light freezing drizzle", - 57: "Dense freezing drizzle", - 61: "Slight rain", - 63: "Moderate rain", - 65: "Heavy rain", - 66: "Light freezing rain", - 67: "Heavy freezing rain", - 71: "Slight snow", - 73: "Moderate snow", - 75: "Heavy snow", - 77: "Snow grains", - 80: "Light rain showers", - 81: "Moderate rain showers", - 82: "Violent rain showers", - 85: "Slight snow showers", - 86: "Heavy snow showers", - 95: "Thunderstorm", - 96: "Thunderstorm with hail", - 99: "Thunderstorm with heavy hail", -} - -# Location APIs in order of preference (fastest first) -LOCATION_APIS = [ - "https://ipapi.co/json/", # Fastest, 200ms average - "http://ip-api.com/json/", # Fast fallback, 150ms average - "https://ipinfo.io/json", # Original fallback -] - -# Global cache for weather data -_weather_cache: Dict[str, Tuple[Any, float]] = {} -_location_cache: Dict[str, Tuple[float, float, float]] = {} - - -def get_location() -> str: - """Get current location using multiple IP geolocation APIs with fallback.""" - for api_url in LOCATION_APIS: - try: - response = requests.get(api_url, timeout=2) - if response.status_code == 200: - data = response.json() - # Handle different API response formats - city = data.get("city", "") - if city: - return city.replace(" ", "") - except requests.RequestException as e: - print(f"Location API {api_url} failed: {e}") - continue - - print("All location APIs failed") - return "" - - -def get_coordinates(city: str) -> Optional[Tuple[float, float]]: - """Get coordinates for a city using Nominatim geocoding API.""" - cache_key = city.lower() - current_time = time.time() - - # Check cache first (cache for 7 days) - if cache_key in _location_cache: - lat, lon, timestamp = _location_cache[cache_key] - if current_time - timestamp < LOCATION_CACHE_TIMEOUT: - return lat, lon - - try: - encoded_city = urllib.parse.quote(city) - url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1" - response = requests.get( - url, timeout=3, headers={"User-Agent": "Modus-Desktop/1.0"} - ) - - if response.status_code == 200: - data = response.json() - if data: - lat = float(data[0]["lat"]) - lon = float(data[0]["lon"]) - _location_cache[cache_key] = (lat, lon, current_time) - return lat, lon - except (requests.RequestException, ValueError, KeyError) as e: - print(f"Error geocoding {city}: {e}") - - return None - - -def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]: - """Fetch weather data from Open-Meteo API.""" - try: - url = ( - f"https://api.open-meteo.com/v1/forecast?" - f"latitude={lat}&longitude={lon}" - f"¤t_weather=true" - f"&daily=temperature_2m_max,temperature_2m_min" - f"&timezone=auto" - f"&forecast_days=1" - ) - - response = requests.get(url, timeout=3) - if response.status_code == 200: - return response.json() - except requests.RequestException as e: - print(f"Error fetching weather data: {e}") - - return None - - -def format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]: - """Format weather data into the expected format.""" - try: - current = weather_data["current_weather"] - daily = weather_data["daily"] - - # Get weather code and map to emoji and description - weather_code = current["weathercode"] - emoji = WEATHER_EMOJI_MAP.get(weather_code, "๐ŸŒค๏ธ") - condition = WEATHER_DESC_MAP.get(weather_code, "Unknown") - gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear") - - # Temperature - temp = f"{round(current['temperature'])}ยฐ" - - # Daily min/max temperatures - max_temp = f"{round(daily['temperature_2m_max'][0])}ยฐ" - min_temp = f"{round(daily['temperature_2m_min'][0])}ยฐ" - - return [emoji, temp, condition, city, max_temp, min_temp, gradient_class] - - except (KeyError, IndexError, TypeError) as e: - print(f"Error formatting weather data: {e}") - return None - - -def get_weather(callback): - """Fetch weather data asynchronously using Open-Meteo API.""" - - def fetch_weather(): - # Get location - location = get_location() - if not location: - return GLib.idle_add(callback, None) - - # Check cache first - cache_key = location.lower() - current_time = time.time() - - if cache_key in _weather_cache: - cached_data, timestamp = _weather_cache[cache_key] - if current_time - timestamp < WEATHER_CACHE_TIMEOUT: - return GLib.idle_add(callback, cached_data) - - # Get coordinates for the location - coords = get_coordinates(location) - if not coords: - return GLib.idle_add(callback, None) - - lat, lon = coords - - # Fetch weather data - weather_data = get_weather_data(lat, lon) - if not weather_data: - return GLib.idle_add(callback, None) - - # Format data - formatted_data = format_weather_data(weather_data, location) - if formatted_data: - # Cache the result - _weather_cache[cache_key] = (formatted_data, current_time) - GLib.idle_add(callback, formatted_data) - else: - GLib.idle_add(callback, None) - - executor.submit(fetch_weather) - - -def update_weather(widget): - """Update weather widget with new data.""" - - def fetch_and_update(): - get_weather(lambda weather_info: update_widget(widget, weather_info)) - return True - - GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update) - fetch_and_update() - - -def update_widget(widget, weather_info): - """Update widget labels with weather information.""" - if weather_info: - widget.weatherinfo = weather_info - widget.update_labels(weather_info) - - -class Weather(Box): - """Weather widget displaying current conditions and forecast.""" - - def __init__(self, parent, **kwargs): - super().__init__( - name="weather-widget", - h_expand=True, - v_expand=True, - justification="right", - orientation="v", - all_visible=False, - **kwargs, - ) - - self.parent = parent - self.weatherinfo = None - - # Create labels with better organization - self._create_labels() - self._layout_labels() - - # Start weather updates - update_weather(self) - - def _create_labels(self): - """Create all weather labels.""" - self.city = Label( - name="city", - label="Loading...", - justification="right", - h_align="start", - max_chars_width=12, - ellipsization="end", - ) - self.temperature = Label(name="temperature", label="--ยฐ", h_align="start") - self.condition_em = Label(name="condition-emoji", label="๐ŸŒค๏ธ", h_align="start") - self.condition = Label( - name="condition", - label="Loading...", - max_chars_width=18, - ellipsization="end", - h_align="start", - ) - self.feels_like = Label(name="feels-like", label="H:-- L:--", h_align="start") - - def _layout_labels(self): - """Add labels to the widget in proper order.""" - labels = [ - self.city, - self.temperature, - self.condition_em, - self.condition, - self.feels_like, - ] - for label in labels: - self.add(label) - - def update_labels(self, weather_info: List[str]): - """Update weather labels with new data.""" - if not weather_info or len(weather_info) != 7: - return - - emoji, temp, condition, location, maxtemp, mintemp, gradient_class = ( - weather_info - ) - maxmin = f"H:{maxtemp} L:{mintemp}" - - # Batch update labels for better performance - label_updates = [ - (self.city, location), - (self.temperature, temp), - (self.condition_em, emoji), - (self.condition, condition), - (self.feels_like, maxmin), - ] - - for label, text in label_updates: - label.set_label(text) - - # Apply gradient background based on weather condition - self.parent.set_visible(True) - - -class WeatherContainer(Box): - """Container for weather widget.""" - - def __init__(self, **kwargs): - super().__init__( - orientation="v", - name="weather-container", - v_expand=True, - v_align="center", - size=(170, 170), - visible=True, - h_align="center", - children=[Weather(self)], - **kwargs, - ) - - -class Date(Box): - """Date widget displaying day, month, and date.""" - - def __init__(self, **kwargs): - super().__init__( - name="date-widget", - h_expand=True, - v_expand=True, - justification="center", - h_align="center", - v_align="start", - orientation="v", - **kwargs, - ) - - # Create date components - self.top = Box(orientation="h", name="date-top", h_expand=True) - - # Use consistent interval for all date components - date_interval = 10000 # 10 seconds - self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day") - self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month") - self.datethree = DateTime( - formatters=["%-d"], interval=date_interval, name="date" - ) - - # Layout components - self.top.add(self.dateone) - self.top.add(self.datetwo) - self.add(self.top) - self.add(self.datethree) - - -class DateContainer(Box): - """Container for date widget.""" - - def __init__(self, **kwargs): - super().__init__( - orientation="v", - name="date-container", - v_expand=True, - size=(170, 170), - v_align="center", - h_align="center", - children=[Date()], - **kwargs, - ) - - -class Calendar(Box): - """Calendar widget displaying current month.""" - - def __init__(self, **kwargs): - # Set Sunday as first day of week - calendar.setfirstweekday(6) # 6 = Sunday - super().__init__( - name="calendar-widget", - h_expand=True, - v_expand=True, - orientation="v", - **kwargs, - ) - - # Cache current date for efficiency - self._update_current_date() - - # Create calendar components - self._create_header() - self._create_days_header() - self._create_calendar_grid() - - # Layout components - self.add(self.month_label) - self.add(self.days_header) - self.add(self.calendar_grid) - - # Schedule updates - invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed) - - def _update_current_date(self): - """Update cached current date values.""" - now = datetime.datetime.now() - self.current_month = now.month - self.current_year = now.year - self.current_day = now.day - - def _create_header(self): - """Create month header label.""" - self.month_label = Label( - name="calendar-month", - label=calendar.month_name[self.current_month], - h_align="start", - justification="left", - ) - - def _create_days_header(self): - """Create day abbreviations header.""" - self.days_header = Box( - name="calendar-days-header", orientation="h", h_expand=True, spacing=2 - ) - - day_names = ["S", "M", "T", "W", "T", "F", "S"] - for i, day_name in enumerate(day_names): - is_weekend = i in (0, 6) # Sunday or Saturday - day_label = Label( - name=( - "calendar-day-header-weekend" - if is_weekend - else "calendar-day-header" - ), - label=day_name, - h_align="center", - h_expand=True, - ) - self.days_header.add(day_label) - - def _create_calendar_grid(self): - """Create calendar grid container.""" - self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1) - self.update_calendar() - - def update_calendar_if_needed(self) -> bool: - """Check if date changed and update calendar if needed.""" - now = datetime.datetime.now() - if ( - now.month != self.current_month - or now.year != self.current_year - or now.day != self.current_day - ): - - self._update_current_date() - self.update_calendar() - return True - - def update_calendar(self): - """Update the calendar grid with current month.""" - # Clear existing calendar efficiently - children = self.calendar_grid.get_children() - for child in children: - self.calendar_grid.remove(child) - - # Update month label - self.month_label.set_label(calendar.month_name[self.current_month]) - - # Generate calendar - cal = calendar.monthcalendar(self.current_year, self.current_month) - current_date = datetime.datetime.now() - - for week in cal: - week_box = Box(orientation="h", spacing=2, h_expand=True) - - for day_index, day in enumerate(week): - if day == 0: - # Empty day slot - day_label = Label( - name="calendar-day-empty", - label="", - h_align="center", - h_expand=True, - ) - else: - # Regular day - is_today = ( - day == self.current_day - and self.current_month == current_date.month - and self.current_year == current_date.year - ) - is_weekend = day_index in (0, 6) # Sunday or Saturday - - if is_today: - name = "calendar-day-today" - elif is_weekend: - name = "calendar-day-weekend" - else: - name = "calendar-day" - - day_label = Label( - name=name, label=str(day), h_align="center", h_expand=True - ) - - week_box.add(day_label) - self.calendar_grid.add(week_box) - - -class CalendarContainer(Box): - """Container for calendar widget.""" - - def __init__(self, **kwargs): - super().__init__( - orientation="v", - name="calendar-box-widget", - v_expand=True, - size=(170, 170), - v_align="center", - h_align="center", - children=[Calendar()], - **kwargs, - ) - - -class SystemInfoBase(Box): - """Base class for system information widgets.""" - - @staticmethod - def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs): - """Create a standardized circular progress bar.""" - return CircularProgressBar( - name=name, - start_angle=270, - end_angle=630, - min_value=0, - max_value=100, - size=size, - **kwargs, - ) - - def __init__(self, name: str, **kwargs): - super().__init__( - layer="bottom", - title="sysinfo", - name=name, - visible=True, - size=(170, 170), - h_expand=True, - v_expand=True, - all_visible=True, - **kwargs, - ) - - # Create progress bar and labels - self.progress = self.create_progress_bar(name="progress") - self.main_label = Label( - label="0%\nLoading", justification="center", name="progress-label" - ) - - # Create info container - self.info_container = Box( - name="info-container", - orientation="v", - spacing=2, - h_align="center", - ) - - # Create main layout - self.progress_container = Box( - name="progress-bar-container", - h_expand=True, - v_expand=True, - orientation="v", - spacing=12, - h_align="center", - v_align="center", - children=[ - Box( - children=[ - Overlay( - child=self.progress, - tooltip_text="", - overlays=self.main_label, - ) - ] - ), - Box( - h_align="center", - justification="centre", - orientation="v", - children=[self.info_container], - ), - ], - ) - - self.add(self.progress_container) - - # Don't start updates here - let subclasses call start_updates() when ready - - def start_updates(self): - """Start the update timer - call this after subclass initialization is complete.""" - invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) - - def create_info_line( - self, indicator_name: str, info_text: str, value_text: str - ) -> Box: - """Create an information line with indicator, label, and value.""" - indicator = Label(label="โ– ", name=indicator_name) - info_label = Label(label=info_text, name="info-text") - value_label = Label(label=value_text, name="info-value") - - line = Box( - orientation="h", - spacing=4, - h_align="start", - children=[indicator, info_label, value_label], - ) - - # Store references for easy updates - line.indicator = indicator - line.info_label = info_label - line.value_label = value_label - - return line - - def update(self) -> bool: - """Override in subclasses.""" - raise NotImplementedError - - -class RamInfo(SystemInfoBase): - """RAM usage information widget.""" - - def __init__(self, **kwargs): - super().__init__("info-box-widget", **kwargs) - - # Create info lines and store references - self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB") - self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB") - - # Add to info container - self.info_container.add(self.used_line) - self.info_container.add(self.free_line) - - # Now that everything is set up, start updates - self.start_updates() - - def update(self) -> bool: - """Update RAM information.""" - try: - mem = psutil.virtual_memory() - - # Update main label - self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM") - - # Calculate values - used_gb = mem.used / (1024**3) - free_gb = mem.available / (1024**3) - - # Update info labels using stored references - self.used_line.value_label.set_label(f"{round(used_gb, 1)}GB") - self.free_line.value_label.set_label(f"{round(free_gb, 1)}GB") - - # Update progress bar (use GLib.idle_add for thread safety) - GLib.idle_add(self.progress.set_value, mem.percent) - - except Exception as e: - print(f"Error updating RAM info: {e}") - - return True - - -class CpuInfo(SystemInfoBase): - """CPU usage and temperature information widget.""" - - def __init__(self, **kwargs): - super().__init__("info-box-widget", **kwargs) - - # Create temperature info components - self.temp_info = Label(label="Temp", name="info-text") - self.temp_value = Label(label="0ยฐC", name="info-value") - - # Create temperature info line (no indicator) - self.temp_line = Box( - orientation="h", - spacing=4, - h_align="start", - children=[self.temp_info, self.temp_value], - ) - - # Add to info container - self.info_container.add(self.temp_line) - - # Now that everything is set up, start updates - self.start_updates() - - def get_cpu_temp(self) -> Optional[float]: - """Get CPU temperature from system sensors.""" - try: - temps = psutil.sensors_temperatures() - if not temps: - return None - - # Search for CPU temperature sensors - cpu_sensor_names = ["coretemp", "k10temp", "cpu"] - cpu_label_patterns = ["package id 0", "core 0", ""] - - for name, entries in temps.items(): - if any(sensor in name.lower() for sensor in cpu_sensor_names): - for entry in entries: - entry_label = (entry.label or "").lower() - if any( - pattern in entry_label for pattern in cpu_label_patterns - ): - return round(entry.current, 1) - except Exception as e: - print(f"Error reading CPU temperature: {e}") - - return None - - def update(self) -> bool: - """Update CPU information.""" - try: - # Get CPU usage - cpu = psutil.cpu_percent() - - # Update main label - self.main_label.set_label(f" {round(cpu):<2} %\nCPU") - - # Update temperature using stored reference - temp = self.get_cpu_temp() - temp_text = f"{temp}ยฐC" if temp is not None else "N/A" - self.temp_value.set_label(temp_text) - - # Update progress bar (use GLib.idle_add for thread safety) - GLib.idle_add(self.progress.set_value, cpu) - - except Exception as e: - print(f"Error updating CPU info: {e}") - - return True - - -class Deskwidgets(Window): - """Desktop widgets manager - handles all desktop widgets.""" - - config = load_config() - - def __init__(self, **kwargs): - # Create the main invisible window that manages the widgets - super().__init__( - name="desktop-widget-manager", - layer="bottom", - title="modus-desktop-widget-manager", - visible=False, # This window is invisible - just manages the others - size=(1, 1), # Minimal size - anchor="top left", - **kwargs, - ) - - # Create separate independent windows as attributes - self.top_left = Window( - anchor="top left", - title="modus-widgets-topleft", - orientation="h", - layer="bottom", - visible=False, # Start hidden until content ready - child=Box( - name="desktop-widgets-container", - children=[ - DateContainer(), - WeatherContainer(), - CalendarContainer(), - ], - ), - ) - - self.bottom_left = Window( - anchor="bottom right", - title="modus-widgets-bottomright", - orientation="h", - layer="bottom", - visible=False, # Start hidden until content ready - child=Box( - name="desktop-widgets-container", - children=[ - CpuInfo(), - RamInfo(), - ], - ), - ) - - # Show widgets after initialization is complete - self.top_left.set_visible(True) - self.bottom_left.set_visible(True) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1d0d377..0000000 --- a/requirements.txt +++ /dev/null @@ -1,17 +0,0 @@ -certifi==2025.8.3 -charset-normalizer==3.4.2 -click==8.2.1 -idna==3.10 -loguru==0.7.3 -pillow==11.3.0 -psutil==7.0.0 -pycairo==1.28.0 -pydbus==0.6.0 -PyGObject==3.52.3 -pyotp==2.9.0 -pyzbar==0.1.9 -RapidFuzz==3.13.0 -requests==2.32.4 -setproctitle==1.3.6 -thefuzz==0.22.1 -urllib3==2.5.0 diff --git a/scripts/gamemode.sh b/scripts/gamemode.sh deleted file mode 100755 index 3652ccd..0000000 --- a/scripts/gamemode.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env sh - -# Check if animations are disabled (game mode is active) -check_gamemode() { - HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}') - if [ "$HYPRGAMEMODE" = 0 ]; then - echo "t" - return 0 - else - echo "f" - return 1 - fi -} - -# Toggle game mode state -toggle_gamemode() { - HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}') - if [ "$HYPRGAMEMODE" = 1 ]; then - hyprctl --batch "\ - keyword animations:enabled 0;\ - keyword decoration:shadow:enabled 0;\ - keyword decoration:blur:enabled 0;\ - keyword general:gaps_in 0;\ - keyword general:gaps_out 0;\ - keyword general:border_size 1;\ - keyword decoration:rounding 0" - exit - fi - hyprctl reload -} - -# Main script logic -case "$1" in -check) - check_gamemode - ;; -*) - toggle_gamemode - ;; -esac diff --git a/scripts/hyprpicker.sh b/scripts/hyprpicker.sh deleted file mode 100755 index 703c7df..0000000 --- a/scripts/hyprpicker.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash - -pick_rgb() { - - # Execute hyprpicker with RGB format and save the output to a variable - hyprpicker -a -n -f rgb && sleep 0.1 - - # Create a temporal 64x64 PNG file with the color in /tmp using convert - magick -size 64x64 xc:"rgb($(wl-paste))" /tmp/color.png - - # Send a notification using the file as an icon - notify-send "RGB color picked" "rgb($(wl-paste))" -i /tmp/color.png -a "Hyprpicker" - - # Remove the temporal file - rm /tmp/color.png - - # Exit - exit 0 - -} - -pick_hex() { - - # Execute hyprpicker and save the output to a variable - hyprpicker -a -n -f hex && sleep 0.1 - - # Create a temporal 64x64 PNG file with the color in /tmp using convert - magick -size 64x64 xc:"$(wl-paste)" /tmp/color.png - - # Send a notification using the file as an icon - notify-send "HEX color picked" "$(wl-paste)" -i /tmp/color.png -a "Hyprpicker" - - # Remove the temporal file - rm /tmp/color.png - - # Exit - exit 0 - -} - -pick_hsv() { - - # Copy the color to the clipboard - echo -n "$(hyprpicker -n -f hsv)" | wl-copy -n - - # Create a temporal 64x64 PNG file with the color in /tmp using convert - magick -size 64x64 xc:"hsv($(wl-paste))" /tmp/color.png - - # Send a notification using the file as an icon - notify-send "HSV color picked" "hsv($(wl-paste))" -i /tmp/color.png -a "Hyprpicker" - - # Remove the temporal file - rm /tmp/color.png - - # Exit - exit 0 - -} - -case "$1" in --rgb) - pick_rgb - ;; --hsv) - pick_hsv - ;; --hex) - pick_hex - ;; - -*) - echo "Usage: $0 [-rgb|-hex|-hsv]" - exit 1 - ;; -esac diff --git a/scripts/screen-capture.sh b/scripts/screen-capture.sh deleted file mode 100755 index a1fd899..0000000 --- a/scripts/screen-capture.sh +++ /dev/null @@ -1,704 +0,0 @@ -#!/bin/env bash - -# Script name -SCRIPT_NAME=$(basename "$0") - -# Function to display usage -usage() { - cat <<EOF -Usage: $SCRIPT_NAME <command> [options] - -Commands: - screenshot <target> Take a screenshot - Targets: - selection - Screenshot selected area - eDP-1 - Screenshot eDP-1 display - HDMI-A-1 - Screenshot HDMI-A-1 display - both - Screenshot both displays - - record <target> Start/stop recording (with audio) - Targets: - selection - Record selected area - eDP-1 - Record eDP-1 display - HDMI-A-1 - Record HDMI-A-1 display - stop - Stop current recording - - record-noaudio <target> Start/stop recording (no audio) - Targets: - selection - Record selected area without audio - eDP-1 - Record eDP-1 display without audio - HDMI-A-1 - Record HDMI-A-1 display without audio - stop - Stop current recording - - record-hq <target> Start/stop high-quality recording (for YouTube) - Targets: - selection - Record selected area in high quality - eDP-1 - Record eDP-1 display in high quality - HDMI-A-1 - Record HDMI-A-1 display in high quality - stop - Stop current recording - - record-gif <target> Start/stop GIF recording - Targets: - selection - Record selected area as GIF - eDP-1 - Record eDP-1 display as GIF - HDMI-A-1 - Record HDMI-A-1 display as GIF - stop - Stop current recording - - status Check if recording is active (exit 0 if recording, 1 if not) - - convert <format> [file] Convert recordings - Formats: - webm - Convert latest MKV to WebM (or specify file) - iphone - Convert latest MKV to iPhone format (or specify file) - youtube - Convert latest video to YouTube format (or specify file) - gif - Convert latest video to GIF (or specify file) - - Optional [file] parameter: - - If not provided, converts the latest recorded video - - If provided, converts the specified file (full path or filename in Recordings folder) - -Examples: - $SCRIPT_NAME screenshot selection - $SCRIPT_NAME record eDP-1 - $SCRIPT_NAME record-noaudio selection - $SCRIPT_NAME record-hq eDP-1 - $SCRIPT_NAME record-gif selection - $SCRIPT_NAME record stop - $SCRIPT_NAME convert gif # Convert latest video to GIF - $SCRIPT_NAME convert youtube # Convert latest video for YouTube - $SCRIPT_NAME convert webm /path/to/video.mkv # Convert specific file to WebM - -EOF - exit 1 -} - -# Check if no arguments provided -if [ $# -eq 0 ]; then - usage -fi - -# Function to send screenshot notification with action buttons -send_screenshot_notification() { - local full_path="$1" - local save_dir=$(dirname "$full_path") - - ACTION=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \ - -A "view=View" -A "edit=Edit" -A "open=Open Folder") - - case "$ACTION" in - view) xdg-open "$full_path" ;; - edit) swappy -f "$full_path" ;; - open) xdg-open "$save_dir" ;; - esac -} - -# Function to send recording notification with action buttons -send_recording_notification() { - local full_path="$1" - local save_dir=$(dirname "$full_path") - - ACTION=$(notify-send -a "Modus" -i "camera-video-symbolic" "Recording saved" "in $full_path" \ - -A "view=View" -A "open=Open Folder") - - case "$ACTION" in - view) xdg-open "$full_path" ;; - open) xdg-open "$save_dir" ;; - esac -} - -wf-recorder_check() { - if pgrep -x "wf-recorder" >/dev/null; then - pkill -INT -x wf-recorder - # Get the recording file path and send notification with actions - if [ -f /tmp/recording.txt ]; then - recording_file=$(cat /tmp/recording.txt) - wl-copy <"$recording_file" - send_recording_notification "$recording_file" - else - notify-send "Recording stopped" "wf-recorder process terminated" - fi - # Clean up recording start time file - rm -f /tmp/recording_start_time.txt - exit 0 - fi -} - -# Function to record with standard settings -record_video() { - local output_file="$1" - shift - - wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1" -} - -# Function to record without audio -record_video_noaudio() { - local output_file="$1" - shift - - wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1" --no-audio -} - -record_high_quality() { - local output_file="$1" - shift - - # High quality settings for YouTube uploads - # - h264_vaapi for hardware encoding (if available) or libx264 for software - # - yuv420p pixel format for maximum compatibility - # - High bitrate (8000k) for quality - # - GOP size of 30 for better seeking - # - Preset 'slow' for better compression efficiency - # - CRF 18 for high quality (lower = better quality, 0-51 scale) - # - Audio at 192k bitrate - # - 60 FPS for smooth motion - # - No color filters to maintain original colors - - # Check if VAAPI hardware encoding is available - if vainfo &>/dev/null && wf-recorder --help | grep -q "h264_vaapi"; then - # Use hardware encoding for better performance - wf-recorder "$@" -f "$output_file" \ - -c h264_vaapi \ - -p "preset=slow" \ - -p "crf=18" \ - -r 60 \ - -b 8000000 \ - -B 192000 \ - --pixel-format yuv420p \ - -g 30 - else - # Fallback to software encoding - wf-recorder "$@" -f "$output_file" \ - -c libx264 \ - -p "preset=slow" \ - -p "crf=18" \ - -r 60 \ - -b 8000000 \ - -B 192000 \ - --pixel-format yuv420p \ - -g 30 - fi -} - -record_gif() { - local output_file="$1" - shift - - # Record temporary video first (MKV format for better quality) - local temp_video="/tmp/gif_recording_$(date +%s).mkv" - echo "$temp_video" >/tmp/gif_temp_video.txt - - # GIF-optimized recording settings: - # - Lower framerate (15 fps) for smaller file size - # - No audio recording - # - Standard codec for compatibility - wf-recorder "$@" -f "$temp_video" \ - -c libvpx-vp9 \ - -r 15 \ - --pixel-format yuv420p \ - --no-audio - - # After recording stops, convert to GIF - if [ -f "$temp_video" ]; then - notify-send "Converting to GIF" "Processing recording..." - - # Create high-quality GIF with optimized palette - # Using ffmpeg with palette generation for better colors - ffmpeg -i "$temp_video" \ - -vf "fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \ - -loop 0 \ - "$output_file" 2>/tmp/gif_conversion.log - - if [ $? -eq 0 ]; then - # Clean up temp file - rm -f "$temp_video" - rm -f /tmp/gif_temp_video.txt - - # Copy to clipboard and send notification with actions - wl-copy <"$output_file" - send_recording_notification "$output_file" - else - error=$(cat /tmp/gif_conversion.log | tail -n 5) - notify-send "GIF Conversion Failed" "Error: $error" - rm -f "$temp_video" - rm -f /tmp/gif_temp_video.txt - fi - fi -} - -# Function to find the latest video file for conversion -find_latest_video() { - local format="$1" - local recording_dir="${HOME}/Videos/Recordings" - - case "$format" in - "webm"|"iphone") - # For webm and iphone, only look for MKV files - find "$recording_dir" -name "*.mkv" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- - ;; - "youtube"|"gif") - # For youtube and gif, look for both MKV and MP4 files - find "$recording_dir" \( -name "*.mkv" -o -name "*.mp4" \) -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- - ;; - *) - echo "" - ;; - esac -} - -# Function to resolve video file path -resolve_video_file() { - local format="$1" - local file_param="$2" - local recording_dir="${HOME}/Videos/Recordings" - - if [ -n "$file_param" ]; then - # File parameter provided - if [ -f "$file_param" ]; then - # Full path provided and exists - echo "$file_param" - elif [ -f "$recording_dir/$file_param" ]; then - # Filename provided, exists in recordings folder - echo "$recording_dir/$file_param" - else - echo "" - fi - else - # No file parameter, find latest - find_latest_video "$format" - fi -} - -# Parse command -COMMAND="$1" -TARGET="$2" -FILE_PARAM="$3" # Optional file parameter for convert command - -# Set up file paths -IMG="${HOME}/Pictures/Screenshots/$(date +%Y-%m-%d_%H-%m-%s).png" -VID="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).mkv" - -case "$COMMAND" in -"screenshot") - case "$TARGET" in - "selection") - grim -g "$(slurp)" "$IMG" - wl-copy <"$IMG" - send_screenshot_notification "$IMG" - ;; - "eDP-1") - grim -c -o eDP-1 "$IMG" - wl-copy <"$IMG" - send_screenshot_notification "$IMG" - ;; - "HDMI-A-1") - grim -c -o HDMI-A-1 "$IMG" - wl-copy <"$IMG" - send_screenshot_notification "$IMG" - ;; - "both") - grim -c -o eDP-1 "${IMG//.png/-eDP-1.png}" - grim -c -o HDMI-A-1 "${IMG//.png/-HDMI-A-1.png}" - montage "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}" -tile 2x1 -geometry +0+0 "$IMG" - wl-copy <"$IMG" - rm "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}" - send_screenshot_notification "$IMG" - ;; - *) - echo "Error: Invalid screenshot target '$TARGET'" - usage - ;; - esac - ;; - -"record") - case "$TARGET" in - "stop") - wf-recorder_check - ;; - "selection") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video "$VID" -g "$(slurp)" - ;; - "eDP-1") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video "$VID" -a -o eDP-1 - ;; - "HDMI-A-1") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video "$VID" -a -o HDMI-A-1 - ;; - *) - echo "Error: Invalid record target '$TARGET'" - usage - ;; - esac - ;; - -"record-noaudio") - case "$TARGET" in - "stop") - wf-recorder_check - ;; - "selection") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video_noaudio "$VID" -g "$(slurp)" - ;; - "eDP-1") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video_noaudio "$VID" -o eDP-1 - ;; - "HDMI-A-1") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video_noaudio "$VID" -o HDMI-A-1 - ;; - *) - echo "Error: Invalid record-noaudio target '$TARGET'" - usage - ;; - esac - ;; - -"record-hq") - # Change file extension to mp4 for high quality recordings - VID_HQ="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s)-hq.mp4" - - case "$TARGET" in - "stop") - wf-recorder_check - ;; - "selection") - wf-recorder_check - echo "$VID_HQ" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "High Quality Recording" "Starting YouTube-quality recording..." - record_high_quality "$VID_HQ" -g "$(slurp)" - ;; - "eDP-1") - wf-recorder_check - echo "$VID_HQ" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "High Quality Recording" "Starting YouTube-quality recording on eDP-1..." - record_high_quality "$VID_HQ" -a -o eDP-1 - ;; - "HDMI-A-1") - wf-recorder_check - echo "$VID_HQ" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "High Quality Recording" "Starting YouTube-quality recording on HDMI-A-1..." - record_high_quality "$VID_HQ" -a -o HDMI-A-1 - ;; - *) - echo "Error: Invalid record-hq target '$TARGET'" - usage - ;; - esac - ;; - -"record-gif") - # GIF files go to a specific location - GIF="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).gif" - - case "$TARGET" in - "stop") - wf-recorder_check - ;; - "selection") - wf-recorder_check - echo "$GIF" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "GIF Recording" "Starting GIF recording (15 FPS)..." - record_gif "$GIF" -g "$(slurp)" - ;; - "eDP-1") - wf-recorder_check - echo "$GIF" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "GIF Recording" "Starting GIF recording on eDP-1..." - record_gif "$GIF" -o eDP-1 - ;; - "HDMI-A-1") - wf-recorder_check - echo "$GIF" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "GIF Recording" "Starting GIF recording on HDMI-A-1..." - record_gif "$GIF" -o HDMI-A-1 - ;; - *) - echo "Error: Invalid record-gif target '$TARGET'" - usage - ;; - esac - ;; - -"status") - # Check if wf-recorder is running - if pgrep -x "wf-recorder" >/dev/null; then - echo "true" - exit 0 - else - echo "false" - exit 0 - fi - ;; - -"convert") - case "$TARGET" in - "webm") - # Check if ffmpeg is installed - if ! command -v ffmpeg >/dev/null 2>&1; then - notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." - exit 1 - fi - - # Resolve the video file to convert - video_file=$(resolve_video_file "webm" "$FILE_PARAM") - - if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then - if [ -n "$FILE_PARAM" ]; then - notify-send "WebM Conversion Error" "File not found: $FILE_PARAM" - else - notify-send "WebM Conversion Error" "No MKV files found in Recordings folder" - fi - exit 1 - fi - - # Ensure it's an MKV file - if [[ "$video_file" != *.mkv ]]; then - notify-send "WebM Conversion Error" "Only MKV files can be converted to WebM. Found: $(basename "$video_file")" - exit 1 - fi - - webm_file="${video_file%.mkv}.webm" - - # Check if webm version doesn't already exist - if [ -f "$webm_file" ]; then - notify-send "WebM Conversion Skipped" "WebM version already exists: $(basename "$webm_file")" - exit 0 - fi - - notify-send "Converting to WebM" "Processing: $(basename "$video_file")" - - # Convert the file - ffmpeg -y -i "$video_file" -c:v libvpx -b:v 1M -c:a libvorbis "$webm_file" 2>/tmp/ffmpeg_error.log - - if [ $? -eq 0 ]; then - file_size=$(du -h "$webm_file" | cut -f1) - notify-send "WebM Conversion Success" "$(basename "$video_file") โ†’ $(basename "$webm_file") ($file_size)" - else - error=$(cat /tmp/ffmpeg_error.log | tail -n 5) - notify-send "WebM Conversion Failed" "Error: $error" - fi - ;; - - "iphone") - # Check if ffmpeg is installed - if ! command -v ffmpeg >/dev/null 2>&1; then - notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." - exit 1 - fi - - # Resolve the video file to convert - video_file=$(resolve_video_file "iphone" "$FILE_PARAM") - - if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then - if [ -n "$FILE_PARAM" ]; then - notify-send "iPhone Conversion Error" "File not found: $FILE_PARAM" - else - notify-send "iPhone Conversion Error" "No MKV files found in Recordings folder" - fi - exit 1 - fi - - # Ensure it's an MKV file - if [[ "$video_file" != *.mkv ]]; then - notify-send "iPhone Conversion Error" "Only MKV files can be converted for iPhone. Found: $(basename "$video_file")" - exit 1 - fi - - base_filename=$(basename "$video_file") - - # Skip files with "iphone" in the filename - if [[ $base_filename == *"iphone"* ]]; then - notify-send "iPhone Conversion Skipped" "File already appears to be iPhone format: $(basename "$video_file")" - exit 0 - fi - - iphone_file="${video_file%.mkv}-iphone.mp4" - - # Check if iPhone version doesn't already exist - if [ -f "$iphone_file" ]; then - notify-send "iPhone Conversion Skipped" "iPhone version already exists: $(basename "$iphone_file")" - exit 0 - fi - - notify-send "Converting for iPhone" "Processing: $(basename "$video_file")" - - # Convert the file - ffmpeg -y -i "$video_file" -vcodec h264 -acodec aac "$iphone_file" 2>/tmp/ffmpeg_error.log - - if [ $? -eq 0 ]; then - file_size=$(du -h "$iphone_file" | cut -f1) - notify-send "iPhone Conversion Success" "$(basename "$video_file") โ†’ $(basename "$iphone_file") ($file_size)" - else - error=$(cat /tmp/ffmpeg_error.log | tail -n 5) - notify-send "iPhone Conversion Failed" "Error: $error" - fi - ;; - - "youtube") - # Check if ffmpeg is installed - if ! command -v ffmpeg >/dev/null 2>&1; then - notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." - exit 1 - fi - - # Resolve the video file to convert - video_file=$(resolve_video_file "youtube" "$FILE_PARAM") - - if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then - if [ -n "$FILE_PARAM" ]; then - notify-send "YouTube Conversion Error" "File not found: $FILE_PARAM" - else - notify-send "YouTube Conversion Error" "No video files found in Recordings folder" - fi - exit 1 - fi - - base_filename=$(basename "$video_file") - - # Skip files already marked as YouTube uploads - if [[ $base_filename == *"youtube"* ]]; then - notify-send "YouTube Conversion Skipped" "File already appears to be YouTube format: $(basename "$video_file")" - exit 0 - fi - - # Create YouTube optimized filename - youtube_file="${video_file%.*}-youtube.mp4" - - # Check if YouTube version doesn't already exist - if [ -f "$youtube_file" ]; then - notify-send "YouTube Conversion Skipped" "YouTube version already exists: $(basename "$youtube_file")" - exit 0 - fi - - notify-send "Converting for YouTube" "Processing: $(basename "$video_file")" - - # YouTube recommended settings: - # - H.264 codec with High profile - # - 1080p or source resolution - # - 60fps or source framerate - # - High bitrate for quality (8-12 Mbps for 1080p60) - # - AAC audio at 384kbps - # - yuv420p pixel format for compatibility - # - Keyframe interval of 2 seconds (GOP) - # - No filters to preserve original colors - - ffmpeg -y -i "$video_file" \ - -c:v libx264 \ - -profile:v high \ - -preset slow \ - -crf 18 \ - -pix_fmt yuv420p \ - -c:a aac \ - -b:a 384k \ - -movflags +faststart \ - "$youtube_file" 2>/tmp/ffmpeg_error.log - - if [ $? -eq 0 ]; then - file_size=$(du -h "$youtube_file" | cut -f1) - notify-send "YouTube Conversion Success" "$(basename "$video_file") โ†’ $(basename "$youtube_file") ($file_size)" - else - error=$(cat /tmp/ffmpeg_error.log | tail -n 5) - notify-send "YouTube Conversion Failed" "Error converting $(basename "$video_file"): $error" - fi - ;; - - "gif") - # Check if ffmpeg is installed - if ! command -v ffmpeg >/dev/null 2>&1; then - notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." - exit 1 - fi - - # Resolve the video file to convert - video_file=$(resolve_video_file "gif" "$FILE_PARAM") - - if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then - if [ -n "$FILE_PARAM" ]; then - notify-send "GIF Conversion Error" "File not found: $FILE_PARAM" - else - notify-send "GIF Conversion Error" "No video files found in Recordings folder" - fi - exit 1 - fi - - base_filename=$(basename "$video_file") - - # Skip files already GIFs - if [[ $base_filename == *.gif ]]; then - notify-send "GIF Conversion Skipped" "File is already a GIF: $(basename "$video_file")" - exit 0 - fi - - # Create GIF filename - gif_file="${video_file%.*}.gif" - - # Check if GIF version doesn't already exist - if [ -f "$gif_file" ]; then - notify-send "GIF Conversion Skipped" "GIF version already exists: $(basename "$gif_file")" - exit 0 - fi - - notify-send "Converting to GIF" "Processing: $(basename "$video_file")" - - # Get video dimensions for scaling - width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=s=x:p=0 "$video_file") - - # Scale down if wider than 800px to keep file size reasonable - if [ "$width" -gt 800 ]; then - scale_filter="scale=800:-1:flags=lanczos," - else - scale_filter="" - fi - - # Create high-quality GIF with optimized palette - ffmpeg -i "$video_file" \ - -vf "${scale_filter}fps=15,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \ - -loop 0 \ - "$gif_file" 2>/tmp/ffmpeg_error.log - - if [ $? -eq 0 ]; then - file_size=$(du -h "$gif_file" | cut -f1) - notify-send "GIF Conversion Success" "$(basename "$video_file") โ†’ $(basename "$gif_file") ($file_size)" - send_recording_notification "$gif_file" - else - error=$(cat /tmp/ffmpeg_error.log | tail -n 5) - notify-send "GIF Conversion Failed" "Error converting $(basename "$video_file"): $error" - fi - ;; - - *) - echo "Error: Invalid convert format '$TARGET'" - usage - ;; - esac - ;; - -*) - echo "Error: Invalid command '$COMMAND'" - usage - ;; -esac diff --git a/services/__init__.py b/services/__init__.py deleted file mode 100644 index 3f03f12..0000000 --- a/services/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Modus services package. -Contains background services and utilities for the shell. -""" diff --git a/services/auth.py b/services/auth.py deleted file mode 100644 index 3a7b070..0000000 --- a/services/auth.py +++ /dev/null @@ -1,348 +0,0 @@ -import json -import os -import subprocess -import time -from datetime import datetime -from pathlib import Path -from urllib.parse import parse_qs, urlparse - -import pyotp -from PIL import Image -from pyzbar.pyzbar import decode - -import config.data as data - - -def get_otp_file_path(): - """Returns the path to the OTP file inside the cache directory.""" - cache_dir = Path(data.CACHE_DIR) / "otp" - # Create the directory if it doesn't exist - cache_dir.mkdir(parents=True, exist_ok=True) - - file_path = cache_dir / "otp_codes.json" - - # Create the file if it doesn't exist - if not file_path.exists(): - with open(file_path, "w") as f: - json.dump([], f) - print(f"File {file_path} created successfully!") - - return file_path - - -def capture_selected_area(filename="/tmp/screenshot.png"): - """ - Uses slurp to let the user select an area of the screen, - then captures that area using grim. - """ - try: - # slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400) - result = subprocess.run(["slurp"], check=True, capture_output=True, text=True) - geometry = result.stdout.strip() - if not geometry: - print("No area selected with slurp.") - return None - except subprocess.CalledProcessError as e: - print("Error selecting area with slurp:", e) - return None - - try: - # grim uses -g to capture a specific region - subprocess.run(["grim", "-g", geometry, filename], check=True) - except subprocess.CalledProcessError as e: - print("Error capturing screenshot with grim:", e) - return None - - return filename - - -def read_and_save_to_json(): - # Get the full path to the JSON file - json_file = get_otp_file_path() - - screenshot = capture_selected_area() - if screenshot is None: - print("Failed to capture the selected area.") - return False - - # Short delay to ensure the file is written - time.sleep(1) - - # Open the captured image - try: - img = Image.open(screenshot) - except Exception as e: - print("Error opening the image:", e) - return False - - # Decode QR Code(s) from the image - decoded_objects = decode(img) - if not decoded_objects: - print("No QR Code detected in the selected area.") - return False - - results = [] - - for obj in decoded_objects: - data = obj.data.decode("utf-8") - print("QR Code detected:", data) - - result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data} - - if data.startswith("otpauth://"): - parsed = urlparse(data) - query = parse_qs(parsed.query) - - # Extract the secret properly - secret = query.get("secret", [None])[0] - issuer_from_query = query.get("issuer", [None])[0] - - # Extract the label (path), which may contain issuer and account - label = parsed.path.lstrip("/") if parsed.path else "" - account_name = label - issuer_from_path = None - - if ":" in label: - parts = label.split(":", 1) - issuer_from_path = parts[0] - account_name = parts[1] - - # Prefer issuer from path if available, otherwise use from query - issuer = issuer_from_path or issuer_from_query - - # Extract the period (default is 30 seconds) - period = int(query.get("period", ["30"])[0]) - - # Create TOTP object with the correct interval - totp = pyotp.TOTP(secret, interval=period) - current_otp = totp.now() - print(f"Generated OTP: {current_otp} (valid for {period} seconds)") - - result_entry.update( - { - "type": "otp", - "secret": secret, - "issuer": issuer, - "account_name": account_name, - } - ) - else: - result_entry["type"] = "unknown" - print("Unrecognized format. Expected a URI like otpauth://") - return False - - results.append(result_entry) - - # Load existing data if the file exists - existing_data = [] - if os.path.exists(json_file): - try: - with open(json_file, "r") as f: - existing_data = json.load(f) - except json.JSONDecodeError: - print(f"Error reading the file {json_file}. Creating a new one.") - - # Append new results - existing_data.extend(results) - - # Save to JSON file - with open(json_file, "w") as f: - json.dump(existing_data, f, indent=4) - - print(f"OTP data saved to {json_file}") - return True - - -def CodeOTP(uri): - parsed = urlparse(uri) - query = parse_qs(parsed.query) - secret = query.get("secret", [None])[0] - - if secret is None: - return None - else: - totp = pyotp.TOTP(secret) - return totp.now() - - -# TOTP/OTP utility functions -def generate_totp(secret: str) -> str: - """Generate TOTP code from secret.""" - try: - return pyotp.TOTP(secret).now() - except Exception as e: - print(f"Error generating TOTP: {e}") - return None - - -def get_time_remaining() -> int: - """Get seconds remaining until next token refresh.""" - return 30 - (int(time.time()) % 30) - - -def get_time_remaining_with_blink() -> str: - """Get time remaining with blinking effect.""" - time_remaining = get_time_remaining() - current_second = int(time.time()) - should_blink = current_second % 2 == 0 - - if should_blink: - return f"<span alpha='30%'>{time_remaining}s</span>" - else: - return f"{time_remaining}s" - - -def validate_base32_secret(secret: str) -> dict: - """Validate and clean Base32 secret.""" - import base64 - import re - - try: - # Clean up the secret - remove spaces, dashes, and convert to uppercase - clean_secret = secret.replace(" ", "").replace("-", "").replace("_", "").upper() - - # Remove any non-base32 characters - clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret) - - # Add padding if needed (Base32 requires padding to multiple of 8) - while len(clean_secret) % 8 != 0: - clean_secret += "=" - - # Validate Base32 format - try: - base64.b32decode(clean_secret) - except Exception as e: - return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"} - - # Test if the secret can generate a valid TOTP - try: - test_totp = pyotp.TOTP(clean_secret) - test_code = test_totp.now() - if not test_code or len(test_code) != 6: - raise ValueError("Generated invalid TOTP code") - except Exception as e: - return {"success": False, "error": f"Cannot generate TOTP: {str(e)}"} - - return {"success": True, "secret": clean_secret} - except Exception as e: - return {"success": False, "error": f"Unexpected error: {str(e)}"} - - -def parse_otpauth_uri(uri: str, account_name: str = "") -> dict: - """Parse otpauth URI and extract account information.""" - try: - parsed = urlparse(uri) - if parsed.scheme != "otpauth" or parsed.netloc != "totp": - return { - "success": False, - "error": "Only otpauth://totp/ URIs are supported", - } - - if not account_name: - account_path = parsed.path.lstrip("/") - if ":" in account_path: - issuer, extracted_name = account_path.split(":", 1) - account_name = extracted_name - else: - account_name = account_path - - params = parse_qs(parsed.query) - secret = params.get("secret", [""])[0] - issuer = params.get("issuer", [""])[0] - algorithm = params.get("algorithm", ["SHA1"])[0] - digits = int(params.get("digits", ["6"])[0]) - period = int(params.get("period", ["30"])[0]) - - if not secret: - return {"success": False, "error": "No secret found in URI"} - - return { - "success": True, - "account_name": account_name, - "secret": secret, - "issuer": issuer, - "algorithm": algorithm, - "digits": digits, - "period": period, - } - except Exception as e: - return {"success": False, "error": f"Error parsing otpauth URI: {str(e)}"} - - -def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict: - """Scan QR code and add OTP account to secrets file.""" - try: - # Capture QR code from screen - screenshot_path = capture_selected_area() - if not screenshot_path: - return {"success": False, "error": "QR scan cancelled or failed"} - - # Decode QR code - try: - img = Image.open(screenshot_path) - decoded_objects = decode(img) - - if not decoded_objects: - return { - "success": False, - "error": "No QR code detected in selected area", - } - - # Process the first QR code found - qr_data = decoded_objects[0].data.decode("utf-8") - print(f"QR Code detected: {qr_data}") - - if qr_data.startswith("otpauth://"): - # Parse otpauth URI - result = parse_otpauth_uri(qr_data, account_name) - if not result["success"]: - return result - - # Load existing secrets - secrets = {} - if os.path.exists(secrets_file_path): - try: - with open(secrets_file_path, "r", encoding="utf-8") as f: - secrets = json.load(f) - except Exception as e: - print(f"Error loading secrets: {e}") - - # Add new account - secrets[result["account_name"]] = { - "secret": result["secret"], - "issuer": result["issuer"], - "algorithm": result["algorithm"], - "digits": result["digits"], - "period": result["period"], - } - - # Save secrets - try: - os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True) - with open(secrets_file_path, "w", encoding="utf-8") as f: - json.dump(secrets, f, indent=2) - except Exception as e: - return { - "success": False, - "error": f"Error saving secrets: {str(e)}", - } - - display_name = ( - f"{result['issuer']} - {result['account_name']}" - if result["issuer"] - else result["account_name"] - ) - return { - "success": True, - "account_name": result["account_name"], - "display_name": display_name, - "message": f"Successfully added OTP account: {display_name}", - } - else: - return {"success": False, "error": "QR code is not an otpauth URI"} - - except Exception as e: - return {"success": False, "error": f"Error processing QR code: {str(e)}"} - - except Exception as e: - return {"success": False, "error": f"Error during QR scan: {str(e)}"} diff --git a/services/battery.py b/services/battery.py deleted file mode 100644 index a28cceb..0000000 --- a/services/battery.py +++ /dev/null @@ -1,251 +0,0 @@ -import psutil -from gi.repository import GLib -from pydbus import SystemBus - -from fabric.core import Property, Service, Signal - -DeviceState = { - 0: "UNKNOWN", - 1: "CHARGING", - 2: "DISCHARGING", - 3: "EMPTY", - 4: "FULLY_CHARGED", - 5: "PENDING_CHARGE", - 6: "PENDING_DISCHARGE", -} - - -class Battery(Service): - @staticmethod - def seconds_to_hours_minutes(seconds): - hours = seconds // 3600 - minutes = (seconds % 3600) // 60 - return f"{hours}h {minutes}m" if hours else f"{minutes}m" - - @staticmethod - def get_battery_icon_level(percentage): - """Get battery icon level based on percentage""" - if percentage >= 90: - return "100" - elif percentage >= 80: - return "090" - elif percentage >= 70: - return "080" - elif percentage >= 60: - return "070" - elif percentage >= 50: - return "060" - elif percentage >= 40: - return "050" - elif percentage >= 30: - return "040" - elif percentage >= 20: - return "030" - elif percentage >= 10: - return "020" - else: - return "010" - - @staticmethod - def get_battery_icon_file(percentage, is_charging, base_path=""): - """Get battery icon file path""" - level = Battery.get_battery_icon_level(percentage) - suffix = "-charging" if is_charging else "" - return f"{base_path}battery/battery-{level}{suffix}.svg" - - @staticmethod - def get_profile_display_name(profile: str) -> str: - """Get user-friendly display name for power profile""" - profile_names = { - "power-saver": "Power Saver", - "powersave": "Power Saver", - "power_saver": "Power Saver", - "balanced": "Balanced", - "balance": "Balanced", - "performance": "Performance", - "performance-mode": "Performance", - } - return profile_names.get(profile, profile.title()) - - @Signal - def changed(self) -> None: ... - - @Signal - def profile_changed(self, value: str) -> None: ... - - @Property(int, "readable") - def percentage(self): - if self._use_psutil_fallback: - if self._psutil_battery: - return int(self._psutil_battery.percent) - return 0 - return int(self._battery.Percentage) - - @Property(str, "readable") - def temperature(self): - if self._use_psutil_fallback: - return "N/A" # psutil doesn't provide temperature - return ( - f"{self._battery.Temperature}ยฐC" - if hasattr(self._battery, "Temperature") - else "N/A" - ) - - @Property(str, "readable") - def time_to_empty(self): - if self._use_psutil_fallback: - if self._psutil_battery and hasattr(self._psutil_battery, "secsleft"): - return self.seconds_to_hours_minutes(self._psutil_battery.secsleft) - return "N/A" - return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToEmpty", 0)) - - @Property(str, "readable") - def time_to_full(self): - if self._use_psutil_fallback: - return "N/A" # psutil doesn't provide time to full - return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToFull", 0)) - - @Property(str, "readable") - def icon_name(self): - if self._use_psutil_fallback: - return "battery" # Generic icon name for psutil fallback - return self._battery.IconName - - @Property(str, "readable") - def state(self): - if self._use_psutil_fallback: - if self._psutil_battery: - # psutil returns power_plugged boolean, convert to state - if self._psutil_battery.power_plugged: - if self._psutil_battery.percent >= 100: - return "FULLY_CHARGED" - else: - return "CHARGING" - else: - return "DISCHARGING" - return "UNKNOWN" - return DeviceState.get(self._battery.State, "UNKNOWN") - - @Property(str, "readable") - def capacity(self): - if self._use_psutil_fallback: - return "N/A" # psutil doesn't provide capacity info - return f"{int(self._battery.Capacity)}%" - - @Property(bool, "readable", default_value=False) - def is_present(self): - if self._use_psutil_fallback: - return self._psutil_battery is not None - return self._battery.IsPresent - - @Property(str, "readable") - def power_profile(self): - if hasattr(self, "_profile_proxy") and self._profile_proxy: - try: - return self._profile_proxy.ActiveProfile - except Exception: - return None - return None - - @Property(list, "readable") - def available_profiles(self): - if hasattr(self, "_profile_proxy") and self._profile_proxy: - try: - profiles = [] - for p in self._profile_proxy.Profiles: - if hasattr(p, "Profile"): - profiles.append(p.Profile) - elif isinstance(p, dict) and "Profile" in p: - profiles.append(p["Profile"]) - elif isinstance(p, str): - profiles.append(p) - return profiles - except Exception: - return [] - return [] - - def change_power_profile(self, profile: str) -> bool: - if not hasattr(self, "_profile_proxy") or not self._profile_proxy: - return False - - # Get available profiles using the same logic as available_profiles property - available_profiles = [] - try: - for p in self._profile_proxy.Profiles: - if hasattr(p, "Profile"): - available_profiles.append(p.Profile) - elif isinstance(p, dict) and "Profile" in p: - available_profiles.append(p["Profile"]) - elif isinstance(p, str): - available_profiles.append(p) - except Exception: - return False - - if profile not in available_profiles: - return False - - try: - self._profile_proxy.ActiveProfile = profile - self.profile_changed.emit(profile) - self.changed.emit() - return True - except Exception: - return False - - def __init__(self): - super().__init__() - self._bus = SystemBus() - self._use_psutil_fallback = False - self._psutil_battery = None - self._profile_proxy = None # Initialize to None first - - # Battery device - try: - self._battery = self._bus.get( - "org.freedesktop.UPower", "/org/freedesktop/UPower/devices/battery_BAT0" - ) - self._battery.onPropertiesChanged = self.handle_battery_change - except Exception: - # Fallback to psutil if UPower is not available - self._use_psutil_fallback = True - try: - self._psutil_battery = psutil.sensors_battery() - if self._psutil_battery is None: - return # No battery found - # Start periodic updates for psutil fallback - increased interval - GLib.timeout_add_seconds(10, self._update_psutil_battery) - except Exception: - return # psutil battery not available either - - # PowerProfiles - Initialize after other attributes - try: - self._profile_proxy = self._bus.get( - "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles" - ) - # Use onPropertiesChanged for consistency with battery device - self._profile_proxy.onPropertiesChanged = ( - lambda _, changed, __: self._handle_profile_props_changed(changed) - ) - except Exception: - self._profile_proxy = None - - self.changed.emit() - - def _update_psutil_battery(self): - """Update psutil battery data periodically""" - try: - self._psutil_battery = psutil.sensors_battery() - self.changed.emit() - except Exception: - pass # Continue trying - return True # Keep the timeout active - - def _handle_profile_props_changed(self, changed): - """Internal handler for property changes that processes only the changed properties""" - if "ActiveProfile" in changed: - new_profile = changed["ActiveProfile"] - self.profile_changed.emit(new_profile) - self.changed.emit() - - def handle_battery_change(self, iface, changed, invalidated): - self.changed.emit() diff --git a/services/brightness.py b/services/brightness.py deleted file mode 100644 index 1031444..0000000 --- a/services/brightness.py +++ /dev/null @@ -1,96 +0,0 @@ -import os - -from gi.repository import GLib -from loguru import logger - -from fabric.core.service import Property, Service, Signal -from fabric.utils import exec_shell_command_async, monitor_file - - -def exec_brightnessctl_async(args: str): - exec_shell_command_async(f"brightnessctl {args}", lambda _: None) - - -# Discover screen backlight device -try: - screen_device = os.listdir("/sys/class/backlight") - screen_device = screen_device[0] if screen_device else "" -except FileNotFoundError: - logger.error("No backlight devices found, brightness control disabled") - screen_device = "" - - -class Brightness(Service): - """Service to manage screen brightness levels.""" - - instance = None - - @staticmethod - def get_initial(): - if Brightness.instance is None: - Brightness.instance = Brightness() - - return Brightness.instance - - @Signal - def screen(self, value: int) -> None: - """Signal emitted when screen brightness changes.""" - # Implement as needed for your application - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - # Path for screen backlight control - self.screen_backlight_path = f"/sys/class/backlight/{screen_device}" - - # Initialize maximum brightness level - self.max_screen = self.do_read_max_brightness(self.screen_backlight_path) - - if screen_device == "": - return - - # Monitor screen brightness file - self.screen_monitor = monitor_file(f"{self.screen_backlight_path}/brightness") - - self.screen_monitor.connect( - "changed", - lambda _, file, *args: self.emit( - "screen", - round(int(file.load_bytes()[0].get_data())), - ), - ) - - # Log the initialization of the service - logger.info(f"Brightness service initialized for device: {screen_device}") - - def do_read_max_brightness(self, path: str) -> int: - # Reads the maximum brightness value from the specified path. - max_brightness_path = os.path.join(path, "max_brightness") - if os.path.exists(max_brightness_path): - with open(max_brightness_path) as f: - return int(f.readline()) - return -1 # Return -1 if file doesn't exist, indicating an error. - - @Property(int, "read-write") - def screen_brightness(self) -> int: - # Property to get or set the screen brightness. - brightness_path = os.path.join(self.screen_backlight_path, "brightness") - if os.path.exists(brightness_path): - with open(brightness_path) as f: - return int(f.readline()) - logger.warning(f"Brightness file does not exist: {brightness_path}") - return -1 # Return -1 if file doesn't exist, indicating error. - - @screen_brightness.setter - def screen_brightness(self, value: int): - # Setter for screen brightness property. - if not (0 <= value <= self.max_screen): - value = max(0, min(value, self.max_screen)) - - try: - exec_brightnessctl_async(f"--device '{screen_device}' set {value}") - self.emit("screen", int((value / self.max_screen) * 100)) - except GLib.Error as e: - logger.error(f"Error setting screen brightness: {e.message}") - except Exception as e: - logger.exception(f"Unexpected error setting screen brightness: {e}") diff --git a/services/custom_notification.py b/services/custom_notification.py deleted file mode 100644 index 7619887..0000000 --- a/services/custom_notification.py +++ /dev/null @@ -1,462 +0,0 @@ -# Standard library imports -import json -import os -import time -from typing import List - -# Fabric imports -import gi - -gi.require_version("Gtk", "3.0") -gi.require_version("GdkPixbuf", "2.0") - -from gi.repository import GdkPixbuf - -import config.data as data -from fabric.core.service import Property, Service, Signal -from fabric.notifications import ( - Notification, - NotificationAction, - NotificationImagePixmap, - Notifications, -) - -gi.require_version("Gtk", "3.0") -gi.require_version("GdkPixbuf", "2.0") - -NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notification_history.json" - - -class CachedNotification(Service): - @classmethod - def create_from_dict(cls, data, **kwargs): - """Create CachedNotification from enhanced JSON data""" - data["timeout"] = 0 - self = cls.__new__(cls) - Service.__init__(self, **kwargs) - self._notification = Notification.deserialize(data) - self._cache_id = data["cached-id"] # Set directly to private var - - # Store cache metadata for cleanup - self.cache_metadata = data.get("cache_metadata", {}) - self.timestamp = data.get("timestamp", int(time.time())) - - return self - - @Signal - def removed_from_cache(self) -> None: ... - - @Property(int, "readable") - def cache_id(self) -> int: - return self._cache_id - - @Property(str, "readable") - def app_name(self) -> str: - return self._notification.app_name - - @Property(str, "readable") - def app_icon(self) -> str: - return self._notification.app_icon - - @Property(str, "readable") - def summary(self) -> str: - return self._notification.summary - - @Property(str, "readable") - def body(self) -> str: - return self._notification.body - - @Property(int, "readable") - def id(self) -> int: - return self._notification.id - - @Property(int, "readable") - def replaces_id(self) -> int: - return self._notification.replaces_id - - @Property(int, "readable") - def urgency(self) -> int: - return self._notification.urgency - - @Property(list[NotificationAction], "readable") - def actions(self) -> list[NotificationAction]: - return self._notification.actions - - @Property(NotificationImagePixmap, "readable") - def image_pixmap(self) -> NotificationImagePixmap: - return self._notification.image_pixmap # type: ignore - - @Property(str, "readable") - def image_file(self) -> str: - return self._notification.image_file # type: ignore - - @Property(object, "readable") - def image_pixbuf(self) -> GdkPixbuf.Pixbuf | None: - try: - if self.image_pixmap: - return self.image_pixmap.as_pixbuf() - if self.image_file and os.path.exists(self.image_file): - try: - return GdkPixbuf.Pixbuf.new_from_file(self.image_file) - except Exception: - # If file can't be loaded, return None - pass - except Exception: - # If any error occurs (including temp file gone), return None safely - pass - return None - - @Property(dict, "readable") - def serialized(self) -> dict: - """Enhanced serialization with cache metadata - stores only cache keys""" - from modules.notification.notification import ( - get_cache_key, - get_notification_image_cache_key, - ) - - # Get better cache keys for icons - app_icon_cache_key = None - notification_image_cache_key = None - - if self.app_icon: - app_icon_cache_key = get_cache_key(self.app_icon, (35, 35), self.app_name) - - # Only try to get notification image cache key if we can safely access image_pixbuf - if self.id: - try: - # First check if we already have the cache key stored - if hasattr(self, 'cache_metadata') and self.cache_metadata: - notification_image_cache_key = self.cache_metadata.get('notification_image_cache_key') - - # If not, try to generate it safely - if not notification_image_cache_key and hasattr(self._notification, 'image_pixbuf'): - try: - # Check if image_pixbuf exists and can be accessed without loading from file - image_pixbuf = getattr(self._notification, 'image_pixbuf', None) - if image_pixbuf: - notification_image_cache_key = get_notification_image_cache_key( - self.id, image_pixbuf - ) - except (AttributeError, OSError, Exception): - # If temp file is gone or any other error, just mark as None - pass - except Exception: - # If any error occurs during cache key generation, skip it - pass - - return { - "cached-id": self.cache_id, - "id": self.id, - "replaces-id": self.replaces_id, - "app-name": self.app_name, - "app-icon": self.app_icon, - "summary": self.summary, - "body": self.body, - "urgency": self.urgency, - "actions": [(action.identifier, action.label) for action in self.actions], - "image-file": self.image_file, - # Only store image-pixmap if no cache key is available (fallback) - "image-pixmap": None, # Don't store image data, only cache key - "timestamp": int(time.time()), - "group": self.app_name, # Group notifications by app name - # Enhanced cache metadata - store only cache keys - "cache_metadata": { - "app_icon_cache_key": app_icon_cache_key, - "notification_image_cache_key": notification_image_cache_key, - "has_cached_image": notification_image_cache_key is not None, - "cache_timestamp": int(time.time()) - } - } - - def __init__(self, notification: Notification, cache_id: int, **kwargs): - super().__init__() - self._notification: Notification = notification - self._cache_id = cache_id - self.cache_metadata = {} - self.timestamp = int(time.time()) - - def remove_from_cache(self): - self.removed_from_cache.emit() - - -class CachedNotifications(Notifications): - """A service to manage the cached notifications.""" - - @Signal - def clear_all(self) -> None: - """Signal emitted when notifications are emptied.""" - pass - - @Signal - def cached_notification_added(self, notification: CachedNotification) -> None: - """Signal emitted when a notification is cached.""" - pass - - @Signal - def cached_notification_removed(self, notification: CachedNotification) -> None: - """Signal emitted when a notification is removed from cache.""" - pass - - @Property(List[CachedNotification], "readable") - def cached_notifications(self) -> List[CachedNotification]: - """Return the cached notifications.""" - return list(self._cached_notifications.values()) - - @Property(int, "readable") - def count(self) -> int: - """Return the count of notifications.""" - return self._count - - @Property(bool, "read-write", default_value=False) - def dont_disturb(self) -> bool: - """Return the pause status.""" - return self._dont_disturb - - def set_dont_disturb(self, value: bool): - """Set the pause status.""" - self._dont_disturb = value - self.notify("dont-disturb") - - def __init__(self, **kwargs): - super().__init__() - self._cached_notifications: dict[int, CachedNotification] = {} - self._signal_handlers = {} # Store signal handlers by notification_id - self._dont_disturb = False - self._count = 0 - self._next_cache_id = 1 # Track next available cache ID - self._session_start_time = int(time.time()) # Track session start time for deduplication - - self.load_cached_notifications() - - # Connect to the notification_added signal to cache new notifications - # Note: self here refers to the CachedNotifications service, which inherits from Notifications - # So we connect to our own notification_added signal - super().notification_added.connect(self.on_notification_added) - - def load_cached_notifications(self) -> dict[int, CachedNotification]: - """Load cached notifications from a JSON file (deserialization).""" - try: - with open(NOTIFICATION_CACHE_FILE, "r") as file: - data = json.load(file) # Load list of serialized notifications - except (FileNotFoundError, json.JSONDecodeError): - # If file doesn't exist or is corrupted, start with empty list - data = [] - - max_cache_id = 0 - for notification in data: - cached_notification = CachedNotification.create_from_dict(notification) - cache_id = cached_notification.cache_id - max_cache_id = max(max_cache_id, cache_id) - - handler_id = cached_notification.connect( - "removed-from-cache", - lambda *args: self.remove_cached_notification( - notification_id=cache_id - ), - ) - self._signal_handlers[cache_id] = handler_id - self._cached_notifications[cache_id] = cached_notification - self._count += 1 - - # Set next cache ID to be higher than any existing ID - self._next_cache_id = max_cache_id + 1 - self.notify("count") - return self._cached_notifications - - def cache_notifications(self) -> None: - """Save cached notifications to a JSON file.""" - # Ensure cache directory exists - os.makedirs(os.path.dirname(NOTIFICATION_CACHE_FILE), exist_ok=True) - - serialized_data = [ - notif.serialized for notif in self._cached_notifications.values() - ] # Convert to serializable format - with open(NOTIFICATION_CACHE_FILE, "w") as file: - json.dump(serialized_data, file, indent=4) - - def clear_all_cached_notifications(self): - """Empty the notifications with enhanced cache cleanup""" - # Clean up all cached files before clearing notifications - from modules.notification.notification import cleanup_all_notification_caches - - for cached_notification in self._cached_notifications.values(): - handler_id = self._signal_handlers.pop(cached_notification.cache_id, None) - if handler_id: - cached_notification.disconnect(handler_id) - - # Clear all notification caches (icons and images) - cleanup_all_notification_caches() - - self._cached_notifications = {} - self.cache_notifications() - self._count = 0 - self._next_cache_id = 1 # Reset cache ID counter - self.notify("count") - self.clear_all.emit() - - def on_notification_added(self, service, notification_id: int) -> None: - """Handle notification added and cache it with enhanced metadata - GUARANTEED STORAGE""" - # Don't call super() - we're handling this ourselves - - # Import logger at the top of the function - from loguru import logger - - notification = self.get_notification_from_id(notification_id) - - if not notification: - logger.error(f"CRITICAL: Failed to get notification with ID {notification_id}") - return - - # Import here to avoid circular imports - from config import data - from modules.notification.notification import ( - preload_notification_assets, - cache_notification_icon, - cache_notification_image, - get_cache_key, - get_notification_image_cache_key - ) - - # Check if this app should be ignored for history (don't cache) - if notification.app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: - # Don't cache notifications from ignored apps, but still allow popup display - logger.debug(f"Ignoring notification from {notification.app_name} (in ignore list)") - return - - # Check for duplicates using both notification ID and timestamp to avoid session restart issues - existing_notification = None - current_time = int(time.time()) - - for cached_notif in self._cached_notifications.values(): - # Only consider it a duplicate if: - # 1. Same notification ID AND - # 2. Notification was cached in the current session (after session start time) AND - # 3. Notification was cached recently (within last 5 minutes) - cached_time = getattr(cached_notif, 'timestamp', 0) - is_recent = (current_time - cached_time) < 300 # 5 minutes - is_current_session = cached_time >= self._session_start_time - - if (cached_notif._notification.id == notification.id and - is_current_session and is_recent): - existing_notification = cached_notif - break - - if existing_notification: - logger.debug(f"Notification ID {notification.id} already cached in current session, skipping") - return - - logger.debug(f"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}...") - - # GUARANTEED STORAGE: Always create and store notification to history first - cache_id = self._next_cache_id - self._next_cache_id += 1 - self._count += 1 - - cached_notification = CachedNotification( - notification=notification, cache_id=cache_id - ) - # Set cache_id directly since it's read-only property - cached_notification._cache_id = cache_id - - # Initialize cache metadata (will be populated below) - cached_notification.cache_metadata = { - "app_icon_cache_key": None, - "notification_image_cache_key": None, - "has_cached_image": False, - "cache_timestamp": int(time.time()) - } - - # IMMEDIATELY store to history before attempting any caching operations - handler_id = cached_notification.connect( - "removed-from-cache", - lambda *args: self.remove_cached_notification(notification_id=cache_id), - ) - self._signal_handlers[cache_id] = handler_id - self._cached_notifications[cache_id] = cached_notification - - # Save to JSON file immediately - GUARANTEED STORAGE - try: - self.cache_notifications() - logger.debug(f"GUARANTEED: Notification {cache_id} stored to history") - except Exception as e: - logger.error(f"CRITICAL: Failed to save notification {cache_id} to history: {e}") - - # Now attempt asset caching (failures here won't affect history storage) - try: - # Preload assets and store cache metadata - preload_notification_assets(notification) - - # Store enhanced cache metadata - app_icon_cache_key = None - notification_image_cache_key = None - - if notification.app_icon: - try: - # Only cache at 35x35 to reduce disk usage - headers will scale this down - app_icon_cache_key = get_cache_key(notification.app_icon, (35, 35), notification.app_name) - cache_notification_icon(notification.app_icon, (35, 35), notification.app_name) - cached_notification.cache_metadata["app_icon_cache_key"] = app_icon_cache_key - logger.debug(f"Cached app icon (35x35) for notification {cache_id}") - except Exception as e: - logger.warning(f"Failed to cache app icon for notification {cache_id}: {e}") - - if hasattr(notification, 'image_pixbuf'): - try: - # Safely try to access image_pixbuf - image_pixbuf = getattr(notification, 'image_pixbuf', None) - if image_pixbuf: - notification_image_cache_key = get_notification_image_cache_key( - notification.id, image_pixbuf - ) - cache_notification_image(notification.id, image_pixbuf, (35, 35)) - cached_notification.cache_metadata["notification_image_cache_key"] = notification_image_cache_key - cached_notification.cache_metadata["has_cached_image"] = True - logger.debug(f"Cached notification image for notification {cache_id}") - except (AttributeError, OSError, Exception) as e: - logger.warning(f"Failed to cache notification image for notification {cache_id}: {e}") - - # Update cached notification with final metadata - self._cached_notifications[cache_id] = cached_notification - - # Save updated metadata to JSON - self.cache_notifications() - - except Exception as e: - logger.error(f"Asset caching failed for notification {cache_id}, but notification is still stored: {e}") - - # Always emit signals regardless of caching success - self.notify("count") - self.emit("cached-notification-added", cached_notification) - - logger.debug(f"Successfully processed notification: Cache ID={cache_id}, Total cached={len(self._cached_notifications)}") - - def remove_cached_notification(self, notification_id: int): - """Remove the notification of given id with enhanced cache cleanup""" - if notification_id in self._cached_notifications: - cached_notification = self._cached_notifications.pop(notification_id) - - # Enhanced cache cleanup using stored metadata - if hasattr(cached_notification, 'cache_metadata'): - cache_metadata = cached_notification.cache_metadata - - # Clean up specific cached files using stored keys - from modules.notification.notification import cleanup_notification_specific_caches - cleanup_notification_specific_caches( - app_icon_source=cached_notification.app_icon, - notification_image_cache_key=cache_metadata.get('notification_image_cache_key') - ) - - self.cache_notifications() # Update JSON - self._count -= 1 - self.notify("count") - - # Get the stored signal handler ID and disconnect it - handler_id = self._signal_handlers.pop(notification_id, None) - if handler_id: - cached_notification.disconnect(handler_id) - - # Emit signal to notify UI that notification was removed - self.emit("cached-notification-removed", cached_notification) - - def toggle_dnd(self): - self.set_dont_disturb(not self.dont_disturb) diff --git a/services/modus.py b/services/modus.py deleted file mode 100644 index f9e9444..0000000 --- a/services/modus.py +++ /dev/null @@ -1,359 +0,0 @@ -import json - -from fabric.core.service import Property, Service, Signal -from fabric.hyprland.service import Hyprland -from loguru import logger - -from services.custom_notification import CachedNotifications - -notification_service = CachedNotifications() - - -class ModusService(Service): - @Signal - def bluetooth_changed(self, new_bluetooth: str) -> None: ... - - @Signal - def volume_changed(self, new_volume: int) -> None: ... - - @Signal - def wlan_changed(self, new_wlan: str) -> None: ... - - @Signal - def battery_changed(self, new_battery: str) -> None: ... - - @Signal - def dock_apps_changed(self, new_dock_apps: str) -> None: ... - - @Signal - def dont_disturb_changed(self, value: bool) -> None: ... - - @Signal - def current_active_app_name_changed(self, value: str) -> None: ... - - @Signal - def current_workspace_changed(self, value: str) -> None: ... - - @Signal - def music_changed(self, value: str) -> None: ... - - @Signal - def current_dropdown_changed(self, value: str) -> None: ... - - @Signal - def dropdowns_hide_changed(self, value: bool) -> None: ... - - @Signal - def dock_width_changed(self, value: int) -> None: ... - - @Signal - def dock_height_changed(self, value: int) -> None: ... - - @Signal - def dock_hidden_changed(self, value: bool) -> None: ... - - @Signal - def show_notificationcenter_changed(self, value: bool) -> None: ... - - @Signal - def notification_count_changed(self, value: int) -> None: ... - - @Property(str, flags="read-write") - def current_active_app_name(self) -> str: - return self._current_active_app_name - - @Property(str, flags="read-write") - def current_workspace(self) -> str: - return self._current_workspace - - @Property(str, flags="read-write") - def bluetooth(self) -> str: - return self._bluetooth - - @Property(str, flags="read-write") - def wlan(self) -> str: - return self._wlan - - @Property(str, flags="read-write") - def battery(self) -> str: - return self._battery - - @Property(int, flags="read-write") - def volume(self) -> int: - return self._volume - - @Property(str, flags="read-write") - def dock_apps(self) -> str: - return self._dock_apps - - @Property(bool, flags="read-write", default_value=False) - def dont_disturb(self) -> bool: - return self._dont_disturb - - @Property(str, flags="read-write") - def music(self) -> str: - return self._music - - @Property(str, flags="read-write") - def current_dropdown(self) -> str: - return self._current_dropdown - - @Property(bool, flags="read-write", default_value=False) - def dropdowns_hide(self) -> bool: - return self._dropdowns_hide - - @Property(int, flags="read-write") - def dock_width(self) -> int: - return self._dock_width - - @Property(int, flags="read-write") - def dock_height(self) -> int: - return self._dock_height - - @Property(bool, flags="read-write", default_value=False) - def dock_hidden(self) -> bool: - return self._dock_hidden - - @Property(bool, flags="read-write", default_value=False) - def show_notificationcenter(self) -> bool: - return self._show_notificationcenter - - @current_active_app_name.setter - def current_active_app_name(self, value: str): - if value != self._current_active_app_name: - self._current_active_app_name = value - self.current_active_app_name_changed(value) - - @current_workspace.setter - def current_workspace(self, value: str): - if value != self._current_workspace: - self._current_workspace = value - self.current_workspace_changed(value) - - @volume.setter - def volume(self, value: int): - if value != self._volume: - self._name = value - self.volume_changed(value) - - @wlan.setter - def wlan(self, value: str): - if value != self._wlan: - self._wlan = value - self.wlan_changed(value) - - @battery.setter - def battery(self, value: str): - if value != self._battery: - self._battery = value - self.battery_changed(value) - - @bluetooth.setter - def bluetooth(self, value: str): - if value != self._bluetooth: - self._bluetooth = value - self.bluetooth_changed(value) - - @dock_apps.setter - def dock_apps(self, value: str): - if value != self._dock_apps: - self._dock_apps = value - self.dock_apps_changed(value) - - @dont_disturb.setter - def dont_disturb(self, value: bool): - if value != self._dont_disturb: - self._dont_disturb = value - self.dont_disturb_changed(value) - - @music.setter - def music(self, value: str): - if value != self._music: - self._music = value - self.music_changed(value) - - @current_dropdown.setter - def current_dropdown(self, value: str): - if value != self._current_dropdown: - self._current_dropdown = value - self.current_dropdown_changed(value) - - @dropdowns_hide.setter - def dropdowns_hide(self, value: bool): - if value != self._dropdowns_hide: - self._dropdowns_hide = value - self.dropdowns_hide_changed(value) - - @dock_width.setter - def dock_width(self, value: int): - if value != self._dock_width: - self._dock_width = value - self.dock_width_changed(value) - - @dock_height.setter - def dock_height(self, value: int): - if value != self._dock_height: - self._dock_height = value - self.dock_height_changed(value) - - @dock_hidden.setter - def dock_hidden(self, value: bool): - if value != self._dock_hidden: - self._dock_hidden = value - self.dock_hidden_changed(value) - - @show_notificationcenter.setter - def show_notificationcenter(self, value: bool): - if value != self._show_notificationcenter: - self._show_notificationcenter = value - self.show_notificationcenter_changed(value) - - def sc(self, signal_name: str, callback: callable, def_value="..."): - self.connect(signal_name, callback) - # Return current property value instead of default - if signal_name == "bluetooth-changed": - return self.bluetooth if self.bluetooth else "Off" - elif signal_name == "wlan-changed": - return self.wlan if self.wlan else "No Connection" - elif signal_name == "battery-changed": - return self.battery if self.battery else "Unknown" - elif signal_name == "music-changed": - return self.music if self.music else "" - else: - return def_value - - def __init__(self): - super().__init__() - self._volume = 0 - self._wlan = "" - self._battery = "" - self._bluetooth = "" - self._dock_apps = "" - self._dont_disturb = False - self._current_active_app_name = "Finder" # Changed from "Hyprland" to "Finder" - self._current_workspace = "1" - self._music = "" - self._current_dropdown = None - self._dropdowns_hide = False - self._dock_hidden = False - self._show_notificationcenter = False - - self._dock_width = 0 - self._dock_height = 0 - - # Initialize Hyprland connection for workspace and window monitoring - self._setup_workspace_monitoring() - self._setup_active_window_monitoring() - - def _setup_workspace_monitoring(self): - """Setup Hyprland connection and workspace monitoring""" - try: - self._hyprland_connection = Hyprland() - - # Get initial workspace - workspace_data = self._hyprland_connection.send_command( - "j/activeworkspace" - ).reply - active_workspace = json.loads(workspace_data.decode("utf-8"))["name"] - self._current_workspace = str(active_workspace) - - # Connect to workspace change events - self._hyprland_connection.connect( - "event::workspace", self._on_workspace_changed - ) - - except Exception as e: - logger.error(f"[ModusService] Failed to setup workspace monitoring: {e}") - self._current_workspace = "1" - - def _setup_active_window_monitoring(self): - """Setup active window monitoring""" - try: - if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection: - return - - # Get initial active window - self._update_active_window() - - # Note: The HyprlandActiveWindow widget from Fabric library - # should handle active window updates automatically. - # We just need to ensure the initial state is correct. - - except Exception as e: - logger.error(f"[ModusService] Failed to setup active window monitoring: {e}") - - def _update_active_window(self): - """Update the current active app name based on active window""" - try: - if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection: - return - - window_data = self._hyprland_connection.send_command("j/activewindow").reply - if not window_data: - self.current_active_app_name = "Finder" - return - - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Handle the case when there's no active window - if not title and not wmclass: - self.current_active_app_name = "Finder" - return - - # Simple app name formatting without circular import - name = wmclass if wmclass else title - if name: - # Basic formatting: capitalize first letter and remove file extensions - name = str(name).title() - if "." in name: - name = name.split(".")[-1] - else: - name = "Finder" - - self.current_active_app_name = name - - except Exception as e: - logger.error(f"[ModusService] Error updating active window: {e}") - self.current_active_app_name = "Finder" - - def _on_workspace_changed(self, obj, signal): - """Handle workspace change events from Hyprland""" - try: - workspace_name = json.loads(signal.data[0]) - self.current_workspace = str(workspace_name) - except Exception as e: - logger.error(f"[ModusService] Error processing workspace change: {e}") - - def remove_notification(self, id: int): - notification_service.remove_cached_notification(id) - self.notification_count_changed(self.notification_count) - - def clear_all_notifications(self): - notification_service.clear_all_cached_notifications() - self.notification_count_changed(self.notification_count) - - def get_cached_notifications(self): - return notification_service.cached_notifications - - def get_deserialized_with_ids(self): - return [ - (notif._notification, notif.cache_id) - for notif in notification_service.cached_notifications - ] - - @property - def notification_count(self) -> int: - return notification_service.count - - def toggle_dnd(self): - notification_service.toggle_dnd() - self.dont_disturb_changed(notification_service.dont_disturb) - - -global modus_service -try: - modus_service = ModusService() -except Exception as e: - logger.error("[Main] Failed to create ModusShellService:", e) diff --git a/services/mpris.py b/services/mpris.py deleted file mode 100644 index 539a727..0000000 --- a/services/mpris.py +++ /dev/null @@ -1,303 +0,0 @@ -# Standard library imports -import contextlib - -import gi - -# Fabric imports -from fabric.core.service import Property, Service, Signal -from fabric.utils import bulk_connect -from gi.repository import GLib -from loguru import logger - - -class PlayerctlImportError(ImportError): - def __init__(self, *args): - super().__init__( - "Playerctl is not installed, please install it first", - *args, - ) - - -try: - gi.require_version("Playerctl", "2.0") - from gi.repository import Playerctl -except ValueError: - raise PlayerctlImportError - - -class MprisPlayer(Service): - """A service to manage a mpris player.""" - - @Signal - def exit(self, value: bool) -> bool: ... - - @Signal - def changed(self) -> None: ... - - def __init__( - self, - player: Playerctl.Player, - **kwargs, - ): - self._signal_connectors: dict = {} - self._player: Playerctl.Player = player - super().__init__(**kwargs) - for sn in ["playback-status", "loop-status", "shuffle"]: - self._signal_connectors[sn] = self._player.connect( - sn, - lambda *args, sn=sn: self.notifier(sn, args), - ) - - self._signal_connectors["exit"] = self._player.connect( - "exit", - self.on_player_exit, - ) - self._signal_connectors["metadata"] = self._player.connect( - "metadata", - lambda *_: self.update_status(), - ) - GLib.idle_add(self.update_status_once) - - def update_status(self): - # schedule each notifier asynchronously. - def notify_property(prop): - if self.get_property(prop) is not None: - self.notifier(prop) - - for prop in [ - "metadata", - "title", - "artist", - "arturl", - "length", - ]: - GLib.idle_add(lambda p=prop: (notify_property(p), False)) - for prop in [ - "can-seek", - "can-pause", - "can-shuffle", - "can-go-next", - "can-go-previous", - ]: - GLib.idle_add(lambda p=prop: (self.notifier(p), False)) - - def update_status_once(self): - # schedule notifier calls for each property - def notify_all(): - for prop in self.list_properties(): # type: ignore - self.notifier(prop.name) - return False - - GLib.idle_add(notify_all, priority=GLib.PRIORITY_DEFAULT_IDLE) - - def notifier(self, name: str, args=None): - def notify_and_emit(): - self.notify(name) - self.emit("changed") - return False - - GLib.idle_add(notify_and_emit, priority=GLib.PRIORITY_DEFAULT_IDLE) - - def on_player_exit(self, player): - for id in list(self._signal_connectors.values()): - with contextlib.suppress(Exception): - self._player.disconnect(id) - del self._signal_connectors - GLib.idle_add(lambda: (self.emit("exit", True), False)) - del self._player - - def toggle_shuffle(self, *_): - if self.can_shuffle: - # schedule the shuffle toggle in the GLib idle loop - GLib.idle_add(lambda: (setattr(self, "shuffle", not self.shuffle), False)) - # else do nothing - - def play_pause(self, *_): - if self.can_pause: - GLib.idle_add(lambda: (self._player.play_pause(), False)) - - def next(self, *_): - if self.can_go_next: - GLib.idle_add(lambda: (self._player.next(), False)) - - def previous(self, *_): - if self.can_go_previous: - GLib.idle_add(lambda: (self._player.previous(), False)) - - # Properties - @Property(str, "readable") - def player_name(self) -> int: - return self._player.get_property("player-name") # type: ignore - - @Property(int, "read-write", default_value=0) - def position(self) -> int: - return self._player.get_property("position") # type: ignore - - @position.setter - def position(self, new_pos: int): - self._player.set_position(new_pos) - - @Property(object, "readable") - def metadata(self) -> dict: - return self._player.get_property("metadata") # type: ignore - - @Property(str or None, "readable") - def arturl(self) -> str | None: - if "mpris:artUrl" in self.metadata.keys(): # type: ignore # noqa: SIM118 - return self.metadata["mpris:artUrl"] # type: ignore - return None - - @Property(str or None, "readable") - def length(self) -> str | None: - if "mpris:length" in self.metadata.keys(): # type: ignore # noqa: SIM118 - return self.metadata["mpris:length"] # type: ignore - return None - - @Property(str, "readable") - def artist(self) -> str: - artist = self._player.get_artist() # type: ignore - if isinstance(artist, (list, tuple)): - return ", ".join(artist) - return artist - - @Property(str, "readable") - def album(self) -> str: - return self._player.get_album() # type: ignore - - @Property(str, "readable") - def title(self): - return self._player.get_title() - - @Property(bool, "read-write", default_value=False) - def shuffle(self) -> bool: - return self._player.get_property("shuffle") # type: ignore - - @shuffle.setter - def shuffle(self, do_shuffle: bool): - self.notifier("shuffle") - return self._player.set_shuffle(do_shuffle) - - @Property(str, "readable") - def playback_status(self) -> str: - return { - Playerctl.PlaybackStatus.PAUSED: "paused", - Playerctl.PlaybackStatus.PLAYING: "playing", - Playerctl.PlaybackStatus.STOPPED: "stopped", - # type: ignore - }.get(self._player.get_property("playback_status"), "unknown") - - @Property(str, "read-write") - def loop_status(self) -> str: - return { - Playerctl.LoopStatus.NONE: "none", - Playerctl.LoopStatus.TRACK: "track", - Playerctl.LoopStatus.PLAYLIST: "playlist", - }.get( - self._player.get_property("loop_status"), "unknown" - ) # type: ignore - - @loop_status.setter - def loop_status(self, status: str): - loop_status = { - "none": Playerctl.LoopStatus.NONE, - "track": Playerctl.LoopStatus.TRACK, - "playlist": Playerctl.LoopStatus.PLAYLIST, - }.get(status) - self._player.set_loop_status(loop_status) if loop_status else None - - @Property(bool, "readable", default_value=False) - def can_go_next(self) -> bool: - return self._player.get_property("can_go_next") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_go_previous(self) -> bool: - return self._player.get_property("can_go_previous") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_seek(self) -> bool: - return self._player.get_property("can_seek") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_pause(self) -> bool: - return self._player.get_property("can_pause") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_shuffle(self) -> bool: - try: - self._player.set_shuffle(self._player.get_property("shuffle")) - return True - except Exception: - return False - - @Property(bool, "readable", default_value=False) - def can_loop(self) -> bool: - try: - self._player.set_shuffle(self._player.get_property("shuffle")) - return True - except Exception: - return False - - -class MprisPlayerManager(Service): - """A service to manage mpris players.""" - - @Signal - def player_appeared(self, player: Playerctl.Player) -> Playerctl.Player: ... - - @Signal - def player_vanished(self, player_name: str) -> str: ... - - def __init__( - self, - **kwargs, - ): - self._manager = Playerctl.PlayerManager.new() - self._signal_connections = [] - - # Track signal connections for cleanup - connections = bulk_connect( - self._manager, - { - "name-appeared": self.on_name_appeared, - "name-vanished": self.on_name_vanished, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self._manager, handler_id)) - - self.add_players() - super().__init__(**kwargs) - - def destroy(self): - """Clean up resources when the manager is destroyed.""" - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect manager signal: {e}") - self._signal_connections.clear() - - # Clean up the manager - if hasattr(self, '_manager'): - del self._manager - - def on_name_appeared(self, manager, player_name: Playerctl.PlayerName): - logger.info(f"[MprisPlayer] {player_name.name} appeared") - new_player = Playerctl.Player.new_from_name(player_name) - manager.manage_player(new_player) - self.emit("player-appeared", new_player) # type: ignore - - def on_name_vanished(self, manager, player_name: Playerctl.PlayerName): - logger.info(f"[MprisPlayer] {player_name.name} vanished") - self.emit("player-vanished", player_name.name) # type: ignore - - def add_players(self): - # type: ignore - for player in self._manager.get_property("player-names"): - self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore - - @Property(object, "readable") - def players(self): - return self._manager.get_property("players") # type: ignore diff --git a/services/network.py b/services/network.py deleted file mode 100644 index 619e81d..0000000 --- a/services/network.py +++ /dev/null @@ -1,667 +0,0 @@ -from gi.repository import NM, GLib -import gi -from typing import List, Optional -from fabric.core.service import Property, Service, Signal -from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case -from loguru import logger - -gi.require_version("NM", "1.0") # Ensure the correct version is loaded - - -class NetworkClient(Service): - """A service to manage network devices""" - - @Signal - def wifi_device_added(self) -> None: ... - - @Signal - def ethernet_device_added(self) -> None: ... - - @Signal - def wifi_device_removed(self) -> None: ... - - @Signal - def ethernet_device_removed(self) -> None: ... - - @Signal - def changed(self) -> None: ... - - @Property(list, "readable") - def connections(self) -> Optional[list]: - """Returns the active connections, if available.""" - return self._client.get_property("connections") if self._client else None - - @Property(object, "readable") - def wifi_device(self) -> Optional[object]: - """Returns the WiFi device if available.""" - return self._wifi_device - - @Property(object, "readable") - def ethernet_device(self) -> Optional[object]: - """Returns the Ethernet device if available.""" - return self._ethernet_device - - @Property(str, "readable") - def primary_connection(self) -> Optional[str]: - """Returns the primary connection if available.""" - return self._client.get_property("primary_connection") if self._client else None - - @Property(str, "readable") - def active_connection(self) -> Optional[str]: - """Returns the active connection if available.""" - return self._client.get_property("active_connection") if self._client else None - - @Property(str, "readable") - def state(self) -> str: - """Returns the current network state.""" - if not self._client: - return "disconnected" - return snake_case_to_kebab_case( - get_enum_member_name( - self._client.get_property("state"), default="disconnected" - ) - ) - - @Property(str, "readable") - def connectivity(self) -> str: - """Returns the connectivity state.""" - if not self._client: - return "disconnected" - return snake_case_to_kebab_case( - get_enum_member_name( - self._client.get_property("connectivity"), default="disconnected" - ) - ) - - @Property(list, "readable") - def devices(self) -> Optional[list]: - """Returns the list of network devices if available.""" - return self._client.get_property("devices") if self._client else None - - @Property(str, "readable") - def hostname(self) -> Optional[str]: - """Returns the hostname if available.""" - return self._client.get_property("hostname") if self._client else None - - @Property(bool, "read-write", default_value=False) - def networking_enabled(self) -> bool: - """Checks if networking is enabled.""" - return ( - self._client.get_property("networking_enabled") if self._client else False - ) - - @networking_enabled.setter - def networking_enabled(self, value: bool): - """Sets the networking state if the client is available.""" - if self._client: - self._client.set_property("networking_enabled", value) - - @Property(bool, "read-write", default_value=False) - def wireless_enabled(self) -> bool: - """Checks if wireless networking is enabled.""" - return self._client.get_property("wireless_enabled") if self._client else False - - @wireless_enabled.setter - def wireless_enabled(self, value: bool): - """Sets the wireless networking state if the client is available.""" - if self._client: - self._client.set_property("wireless_enabled", value) - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - self._client: NM.Client = None - self._wifi_device: Wifi | None = None - self._ethernet_device: Ethernet | None = None - - logger.info("[Network] Initializing client asynchronously...") - - # Start async NM client initialization - NM.Client.new_async(None, self.on_client_ready) - - def on_client_ready(self, source, result): - """Callback when NM.Client is ready.""" - try: - self._client = NM.Client.new_finish(result) # Retrieve client instance - logger.info("[Network] NM.Client initialized successfully!") - - # Connect signals - bulk_connect( - self._client, - { - "device-added": lambda _, device: self.on_device_added( - device=device - ), - "device-removed": lambda _, device: self.on_device_removed( - device=device - ), - "notify::state": lambda *args: self.notifier("state"), - "notify::networking-enabled": lambda *args: self.notifier( - "networking-enabled" - ), - "notify::wireless-enabled": lambda *args: self.notifier( - "wireless-enabled" - ), - # "notify::primary-connection": lambda *args: self.notifier('primary-connection'), - # "notify::active-connection": lambda *args: self.notifier('active-connection'), - # "active-connection-added": lambda *args: self.emit("changed"), - # "active-connection-removed": lambda *args: self.emit("changed") - }, - ) - - # Process devices AFTER client is ready - for device in self.do_get_raw_devices(): - self.on_device_added(device=device) - - self.notify("state") - self.notify("networking-enabled") - self.notify("wireless-enabled") - - except Exception as e: - logger.error(f"[Network] Error initializing NM.Client: {e}") - - def do_get_raw_devices(self) -> list[NM.Device]: - return [ - dev - for dev in self.devices - if dev.get_device_type() in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET) - ] - - def on_device_added(self, device): - device_type = device.get_device_type() - if device_type == NM.DeviceType.WIFI and not self._wifi_device: - logger.info("[Network] WiFi device detected, initializing...") - self._wifi_device = Wifi(client=self, device=device) - self.wifi_device_added.emit() - - elif device_type == NM.DeviceType.ETHERNET and not self._ethernet_device: - logger.info("[Network] Ethernet device detected, initializing...") - self._ethernet_device = Ethernet(client=self, device=device) - self.ethernet_device_added.emit() - - def on_device_removed(self, device): - if device == self._wifi_device: - logger.info("[Network] WiFi device removed.") - self._wifi_device = None - self.wifi_device_removed.emit() - - elif device == self._ethernet_device: - logger.info("[Network] Ethernet device removed.") - self._ethernet_device = None - self.ethernet_device_removed.emit() - - def toggle_network(self): - """Enable or disable Network""" - self.networking_enabled = not self.networking_enabled - - def deactivate_connection(self, connection): - """Disconnect""" - self._client.deactivate_connection_async(connection, None, None) - - def notifier(self, name): - self.notify(name) - self.emit("changed") - - -class AccessPoint(Service): - """A service to manage access points""" - - @Signal - def changed(self) -> None: ... - - @Property(object, "readable") - def device(self) -> object: - return self._device - - @Property(int, "readable") - def strength(self) -> int: - return self._ap.get_property("strength") - - @Property(int, "readable") - def frequency(self) -> int: - return self._ap.get_property("frequency") - - @Property(str, "readable") - def bssid(self) -> str: - return self._ap.get_property("bssid") if self._ap else None - - @Property(str, "readable") - def hw_address(self) -> str: - return self._ap.get_property("hw_address") - - @Property(str, "readable") - def ssid(self) -> str: - ssid = self._ap.get_ssid() - return NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - - @Property(str, "readable") - def icon(self) -> str: - return { - 80: "network-wireless-signal-excellent-symbolic", - 60: "network-wireless-signal-good-symbolic", - 40: "network-wireless-signal-ok-symbolic", - 20: "network-wireless-signal-weak-symbolic", - 00: "network-wireless-signal-none-symbolic", - }.get( - min(80, 20 * round(self.strength / 20)), - "network-wireless-no-route-symbolic", - ) - - @Property(bool, "readable", default_value=False) - def requires_password(self) -> bool: - ssid = self.ssid - settings = self._client.connections - connection = None - for setting in settings: - wifi_setting = setting.get_setting_wireless() - if ( - wifi_setting - and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid - ): - connection = setting - break - if not connection: - return bool(self._ap.get_wpa_flags() or self._ap.get_rsn_flags()) - return False - - @Property(bool, "readable", default_value=False) - def is_active(self) -> bool: - if self._device.active_access_point: - return self.bssid == self._device.active_access_point.get_bssid() - return False - - def __init__(self, device: "Wifi", ap: NM.AccessPoint, **kwargs): - super().__init__(**kwargs) - self._client: NetworkClient = device.client - self._device: Wifi = device - self._ap: NM.AccessPoint = ap - - self._ap.connect("notify::strength", lambda *args: self.notifier("strength")) - self._device.connect( - "notify::active-access-point", lambda *args: self.notifier("is-active") - ) - - def notifier(self, name: str, *args): - self.notify(name) - self.emit("changed") - return - - -class Wifi(Service): - """A service to manage wifi devices""" - - @Signal - def changed(self) -> None: ... - - @Signal - def ap_added(self, ap: AccessPoint) -> None: ... - - @Signal - def ap_removed(self, ap: AccessPoint) -> None: ... - - @Property(NetworkClient, "readable") - def client(self) -> NetworkClient: - """Returns the client""" - return self._client - - @Property(bool, "read-write", default_value=False) - def wireless_enabled(self) -> bool: - """Returns if the wifi is enabled""" - return self._client.get_property("wireless_enabled") - - @wireless_enabled.setter - def wireless_enabled(self, value: bool): - return self._client.set_property("wireless_enabled", value) - - @Property(list[AccessPoint], "readable") - def access_points(self) -> list[AccessPoint]: - return sorted( - self._access_points.values(), key=lambda x: x.is_active, reverse=True - ) - - @Property(AccessPoint, "readable") - def active_access_point(self) -> Optional[AccessPoint]: - return self._active_access_point - - def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs): - super().__init__(**kwargs) - self._client: NetworkClient = client - self._device: NM.DeviceWifi = device - self._active_access_point: NM.AccessPoint | None = None - self._access_points: dict[str, AccessPoint] = {} - - bulk_connect( - self._device, - { - "notify::active-access-point": lambda *args: self.on_access_point_activated(), - "access-point-added": lambda _, ap: self.on_access_point_added(ap=ap), - "access-point-removed": lambda _, ap: self.on_access_point_removed( - ap=ap - ), - # "state-changed": lambda device, new, old, reason: self.on_state_changed(new), - }, - ) - - self._client.connect( - "notify::wireless-enabled", lambda *args: self.notifier("wireless-enabled") - ) - - for ap in self.do_get_access_points(): - self.on_access_point_added(ap=ap) - - self.on_access_point_activated() - - def on_state_changed(self, state): - self.emit("changed") - - def do_get_access_points(self): - return self._device.get_access_points() - - def on_access_point_added(self, ap): - ssid = ap.get_ssid() - ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - - access_point: AccessPoint = AccessPoint(ap=ap, device=self) - - self._access_points[ssid] = access_point - - self.ap_added.emit(access_point) - - # self.notifier("access-points") - - logger.info(f"[Wifi] New access point added with ssid: {ssid}") - - def on_access_point_removed(self, ap): - ssid = ap.get_ssid() - ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - - if not (access_point := self._access_points.pop(ssid, None)): - return logger.warning( - f"[Network] tried to remove a unknwon access point with ssid {ssid}" - ) - - self.ap_removed.emit(access_point) - - logger.info(f"[Wifi] Access point with ssid: {ssid} removed.") - - def on_access_point_activated(self): - if self._device.get_active_access_point(): - self._active_access_point: AccessPoint = AccessPoint( - ap=self._device.get_active_access_point(), device=self - ) - - else: - self._active_access_point = None - - self.notifier("active-access-point") - - logger.info("[Wifi] New active connection") - - def disconnect_wifi(self): - """Disconnect from the current WiFi network.""" - active_connection = self._device.get_active_connection() - self._client.deactivate_connection(active_connection) - logger.info("[Wifi] Wifi network disconnected") - - def scan(self): - self._device.request_scan_async( - None, - lambda device, result: [ - device.request_scan_finish(result), - self.notifier("access-points"), - ], - ) - logger.info("[Wifi] Scan started") - - def toggle_wifi(self): - """Enable or disable WiFi""" - self.wireless_enabled = not self.wireless_enabled - - def connect_to_wifi(self, ap: AccessPoint, password: str = None, callback=None): - """Connect to a WiFi network.""" - - ssid = ap.ssid - - if ssid == "Unknown": - logger.error("Invalid access point data") - if callback: - callback(False, "Invalid access point data") - return False - - logger.info(f"Connecting to WiFi SSID: {ssid}") - - # Check for existing connections - settings = self._client.connections - connection = None - - for setting in settings: - wifi_setting = setting.get_setting_wireless() - if ( - wifi_setting - and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid - ): - connection = setting - break - - def on_activation_result(client, result): - """Handle the result of connection activation""" - try: - active_connection = client.activate_connection_finish(result) - if active_connection: - logger.info(f"Successfully connected to '{ssid}'") - if callback: - callback(True, "Connected successfully") - else: - logger.error(f"Failed to connect to '{ssid}'") - if callback: - callback(False, "Failed to connect. Please try again.") - except Exception as e: - error_msg = str(e).lower() - logger.error(f"Connection to '{ssid}' failed: {e}") - - # Parse NetworkManager error messages and provide user-friendly responses - if "802-11-wireless-security" in error_msg: - if "property is invalid" in error_msg or "psk" in error_msg: - user_msg = "Incorrect password. Please try again." - else: - user_msg = "Security configuration error. Please try again." - elif "secrets were required" in error_msg or "no secrets" in error_msg: - user_msg = "Incorrect password. Please try again." - elif "timeout" in error_msg: - user_msg = "Connection timeout. Please try again." - elif "not found" in error_msg or "no such device" in error_msg: - user_msg = "Network not available. Please try again." - elif "already connected" in error_msg: - user_msg = "Already connected to this network." - else: - user_msg = "Failed to connect. Please try again." - - if callback: - callback(False, user_msg) - - if not connection: - # Create a new connection profile - logger.info(f"Creating new WiFi connection for SSID '{ssid}'") - connection = NM.SimpleConnection.new() - - # Required connection settings - s_con = NM.SettingConnection.new() - s_con.set_property(NM.SETTING_CONNECTION_ID, ssid) - s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless") - s_con.set_property( - NM.SETTING_CONNECTION_INTERFACE_NAME, self._device.get_iface() - ) # Set interface name - connection.add_setting(s_con) - - # Wireless settings - s_wifi = NM.SettingWireless.new() - s_wifi.set_property(NM.SETTING_WIRELESS_SSID, GLib.Bytes.new(ssid.encode())) - s_wifi.set_property( - NM.SETTING_WIRELESS_MODE, "infrastructure" - ) # Ensure mode is correct - connection.add_setting(s_wifi) - - # Security settings (only if password is required and provided) - if ap.requires_password: - if not password: - logger.error("Password required but not provided") - if callback: - callback(False, "Password required but not provided") - return False - - s_sec = NM.SettingWirelessSecurity.new() - s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk") - s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password) - connection.add_setting(s_sec) - - # IPv4 settings - s_ipv4 = NM.SettingIP4Config.new() - s_ipv4.set_property("method", "auto") - connection.add_setting(s_ipv4) - - # IPv6 settings - s_ipv6 = NM.SettingIP6Config.new() - s_ipv6.set_property("method", "auto") - connection.add_setting(s_ipv6) - - # Callback for async connection - def on_connection_added(client, result): - try: - new_connection = client.add_connection_finish(result) - if not new_connection: - logger.error(f"Failed to create connection for '{ssid}'") - if callback: - callback( - False, "Failed to create connection. Please try again." - ) - return - - logger.info(f"Connection for '{ssid}' added successfully") - - # Now activate the newly created connection - client.activate_connection_async( - new_connection, self._device, None, None, on_activation_result - ) - except Exception as e: - error_msg = str(e).lower() - logger.error(f"Failed to add connection: {e}") - - # Parse connection creation errors and provide user-friendly responses - if "802-11-wireless-security" in error_msg: - if "property is invalid" in error_msg or "psk" in error_msg: - user_msg = "Incorrect password. Please try again." - else: - user_msg = "Security configuration error. Please try again." - elif "invalid" in error_msg or "property" in error_msg: - user_msg = "Invalid network configuration. Please try again." - else: - user_msg = "Failed to connect. Please try again." - - if callback: - callback(False, user_msg) - - # Save the new connection - self._client._client.add_connection_async( - connection, True, None, on_connection_added - ) - else: - # Activate existing connection - self._client._client.activate_connection_async( - connection, self._device, None, None, on_activation_result - ) - - return True - - def notifier(self, name: str, *args): - self.notify(name) - self.emit("changed") - return - - -class Ethernet(Service): - """A service to manage ethernet devices""" - - @Signal - def changed(self) -> None: ... - - @Signal - def enabled(self) -> bool: ... - - @Property(int, "readable") - def speed(self) -> str: - speed_mbps = self._device.get_speed() - return f"{speed_mbps} Mb/s" - - @Property(str, "readable") - def state(self) -> str: - return snake_case_to_kebab_case( - get_enum_member_name(self._device.get_state(), default="disconnected") - ) - - @Property(str, "readable") - def internet(self) -> str: - if self._active_connection: - return snake_case_to_kebab_case( - get_enum_member_name( - self._active_connection.get_state(), default="disconnected" - ) - ) - return "disconnected" - - @Property(str, "readable") - def iface(self) -> str: - return self._device.get_iface() if self._device else None - - @Property(str, "readable") - def icon_name(self) -> str: - network = self.internet - if network == "activated": - return "network-wired-symbolic" - elif network == "activating": - return "network-wired-acquiring-symbolic" - return "network-wired-disconnected-symbolic" - - def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None: - super().__init__(**kwargs) - self._client: NM.Client = client - self._device: NM.DeviceEthernet = device - self._active_connection = None - - self._device.connect( - "state-changed", lambda *args: self.on_network_state_changed() - ) - - self.update_active_connection() - - def on_network_state_changed(self): - """Called when networking is toggled on/off.""" - if self.state != "unmanaged": - # Re-initialize device when networking is re-enabled - self.update_active_connection() - else: - self._active_connection = None - - def update_active_connection(self): - """Updates the active connection and connects to its state change signal.""" - active_connection = self._device.get_active_connection() - if active_connection: - self._active_connection = active_connection - self._active_connection.connect( - "state-changed", lambda *args: self.emit("changed") - ) - - def get_network_stats(self): - """Fetch received and transmitted bytes from the system files""" - try: - # Read data from /sys/class/net for accurate speed - with open( - f"/sys/class/net/{self.iface}/statistics/rx_bytes", "r" - ) as rx_file: - rx_bytes = int(rx_file.read().strip()) - with open( - f"/sys/class/net/{self.iface}/statistics/tx_bytes", "r" - ) as tx_file: - tx_bytes = int(tx_file.read().strip()) - return rx_bytes, tx_bytes - except FileNotFoundError: - return None, None diff --git a/services/todo.py b/services/todo.py deleted file mode 100644 index 876505e..0000000 --- a/services/todo.py +++ /dev/null @@ -1,169 +0,0 @@ -# Standard library imports -import json -import uuid -from datetime import datetime -from pathlib import Path - -# Fabric imports -from fabric.core.service import Property, Service - -# Local imports -import config.data as data - - -class TodoService(Service): - """Service for managing persistent todo list with JSON storage""" - - def __init__(self): - super().__init__() - self._todos = [] - self._file_path = self._get_todos_file_path() - self._load_todos() - self._callbacks = [] - - def add_callback(self, callback): - """Add a callback function to be notified of changes""" - self._callbacks.append(callback) - - def remove_callback(self, callback): - """Remove a callback function""" - if callback in self._callbacks: - self._callbacks.remove(callback) - - def _notify_callbacks(self, event_type, data=None): - """Notify all registered callbacks of changes""" - for callback in self._callbacks: - try: - callback(event_type, data) - except Exception as e: - print(f"Error in todo callback: {e}") - - def _get_todos_file_path(self): - """Returns the path to the todos JSON file""" - cache_dir = Path(data.CACHE_DIR) / "todos" - cache_dir.mkdir(parents=True, exist_ok=True) - return cache_dir / "todos.json" - - def _load_todos(self): - """Load todos from JSON file""" - try: - if self._file_path.exists(): - with open(self._file_path, "r", encoding="utf-8") as f: - self._todos = json.load(f) - else: - self._todos = [] - except Exception as e: - print(f"Error loading todos: {e}") - self._todos = [] - - def _save_todos(self): - """Save todos to JSON file""" - try: - with open(self._file_path, "w", encoding="utf-8") as f: - json.dump(self._todos, f, indent=2, ensure_ascii=False) - except Exception as e: - print(f"Error saving todos: {e}") - - @Property(list, "readable") - def todos(self): - """Get all todos""" - return self._todos.copy() - - def add_todo(self, text: str, priority: str = "medium") -> dict: - """Add a new todo item""" - todo = { - "id": str(uuid.uuid4()), - "text": text, - "completed": False, - "priority": priority, # low, medium, high - "created_at": datetime.now().isoformat(), - "updated_at": datetime.now().isoformat(), - } - self._todos.append(todo) - self._save_todos() - self._notify_callbacks("todo-added", todo) - self._notify_callbacks("todos-changed") - return todo - - def delete_todo(self, todo_id: str) -> bool: - """Delete a todo item by ID""" - for i, todo in enumerate(self._todos): - if todo["id"] == todo_id: - deleted_todo = self._todos.pop(i) - self._save_todos() - self._notify_callbacks("todo-deleted", deleted_todo) - self._notify_callbacks("todos-changed") - return True - return False - - def toggle_todo(self, todo_id: str) -> bool: - """Toggle completion status of a todo item""" - for todo in self._todos: - if todo["id"] == todo_id: - todo["completed"] = not todo["completed"] - todo["updated_at"] = datetime.now().isoformat() - self._save_todos() - self._notify_callbacks("todo-toggled", todo) - self._notify_callbacks("todos-changed") - return True - return False - - def edit_todo(self, todo_id: str, new_text: str) -> bool: - """Edit the text of a todo item""" - for todo in self._todos: - if todo["id"] == todo_id: - todo["text"] = new_text - todo["updated_at"] = datetime.now().isoformat() - self._save_todos() - self._notify_callbacks("todo-edited", todo) - self._notify_callbacks("todos-changed") - return True - return False - - def set_priority(self, todo_id: str, priority: str) -> bool: - """Set the priority of a todo item""" - if priority not in ["low", "medium", "high"]: - return False - - for todo in self._todos: - if todo["id"] == todo_id: - todo["priority"] = priority - todo["updated_at"] = datetime.now().isoformat() - self._save_todos() - self._notify_callbacks("todo-priority-changed", todo) - self._notify_callbacks("todos-changed") - return True - return False - - def get_todo(self, todo_id: str) -> dict | None: - """Get a specific todo by ID""" - for todo in self._todos: - if todo["id"] == todo_id: - return todo.copy() - return None - - def clear_completed(self): - """Remove all completed todos""" - initial_count = len(self._todos) - self._todos = [todo for todo in self._todos if not todo["completed"]] - if len(self._todos) < initial_count: - self._save_todos() - self._notify_callbacks("todos-changed") - - def get_stats(self) -> dict: - """Get todo statistics""" - total = len(self._todos) - completed = sum(1 for todo in self._todos if todo["completed"]) - pending = total - completed - - return { - "total": total, - "completed": completed, - "pending": pending, - "completion_rate": (completed / total * 100) if total > 0 else 0, - } - - -# Global service instance -todo_service = TodoService() - diff --git a/styles/about.css b/styles/about.css deleted file mode 100644 index 77ace66..0000000 --- a/styles/about.css +++ /dev/null @@ -1,79 +0,0 @@ -#about-menu { - background-color: alpha(#000, 0.34); - - box-shadow: - inset 0 -0.5px 0 0.5px alpha(#555, 0.7), - inset 0 0.5px 0 0.5px alpha(#777, 0.7); - border: 1px solid alpha(#111, 0.3); - border-top: none; - border-radius: 1.25rem; - padding: 2rem; -} - -#about-options { - background-color: alpha(#333, 0.9); - border-radius: 15px; - padding: 2rem; -} - -#about-logo-box { - margin-top: 2rem; - margin-bottom: 2rem; -} - -#about-button-box { - padding: 1rem; -} -#vendor-label { - color: #999999; - margin-top: -1rem; -} - -#info-label { - color: #888888; -} -#about-info-title-box { - padding-left: 4rem; -} - -#about-name-label { - font-size: 24px; - margin: 0px; - font-weight: 600; -} - -#about-date-label { - font-size: 11px; - margin: 0px; - color: #ddd; - font-weight: 400; - margin-bottom: 2rem; -} - -#about-chip-title-label, -#about-so-title-label, -#about-mem-title-label { - font-size: 12px; - margin: 0px; - margin-right: 10px; - font-weight: 500; -} - -#more-info-button { - background-color: #888888; - border-radius: 5px; - padding: 0px 10px; - margin: 1px 2px; - transition: all 200ms ease-in-out; -} -#more-info-button:hover { - background-color: rgb(72, 130, 255); - background-color: #2369ff; -} - -#more-info-button label { - font-size: 12px; - margin: 0px; - font-weight: 400; - color: #fff; -} diff --git a/styles/battery-widget.css b/styles/battery-widget.css deleted file mode 100644 index 763cfe4..0000000 --- a/styles/battery-widget.css +++ /dev/null @@ -1,149 +0,0 @@ -#battery-widget { - background: rgba(0, 0, 0, 0); - padding-left: 5px; - padding-right: 5px; -} -.battery-main-title { - font-size: 16px; - font-weight: bold; - - color: rgba(255, 255, 255, 0.95); - margin: 6px 10px; -} - -.battery-power-source { - font-size: 0.9em; - font-weight: 500; - color: rgba(255, 255, 255, 0.8); - margin: 0px 12px; -} - -.battery-section-title { - font-size: 0.9em; - font-weight: 500; - color: rgba(255, 255, 255, 0.9); - margin: 8px 12px 4px 12px; -} - -.battery-energy-item { - margin: 4px 12px; - padding: 4px 0; -} - -.battery-status-section { - /* background: rgba(255, 255, 255, 0.06); */ - /* border-radius: 12px; */ - padding: 2px; - /* margin: 8px 0; */ - /* border: 1px solid rgba(255, 255, 255, 0.08); */ -} - -.battery-percentage { - font-size: 16px; - margin-right: 8px; - font-weight: 500; - color: rgba(255, 255, 255, 0.95); -} - -#energy-mode-button-clickable { - background: transparent; - border: none; - border-radius: 8px; - padding: 6px 12px; - margin: 1px 0; -} - -#energy-mode-button-clickable:hover { - background: rgba(255, 255, 255, 0.1); -} - -#energy-mode-icon { - border-radius: 50%; - padding: 4px; - margin-top: -2px; - margin-bottom: -2px; - margin-right: 6px; - margin-left: -6px; - background-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.7); -} - -#energy-mode-icon.connected { - background-color: #007aff; - color: #ffffff; -} -.battery-power-source { - font-size: 12px; - margin-bottom: -4px; - color: alpha(#ffffff, 0.5); -} -.battery-section-title { - font-size: 14px; - color: alpha(#ffffff, 0.7); - margin: 2px 0 0 8px; -} -.battery-power-mode { - font-size: 13px; -} - -.battery-settings-button:hover { - margin-top: -5px; - background: alpha(#ffffff, 0.1); - min-height: 30px; - border-radius: 8px; -} -.battery-settings-button { - margin-top: -5px; - min-height: 30px; - border-radius: 8px; -} -.battery-settings-button label { - font-weight: 500; - color: alpha(#ffffff, 1); -} -#energy-mode-button:first-child { - margin-top: 0; -} -#energy-mode-button { - margin-top: -8px; - margin-bottom: 4px; - color: rgba(255, 255, 255, 0.85); -} -.gamemode-button { - font-size: 12px; - margin-left: -6px; - color: rgba(255, 255, 255, 0.85); -} - -#game-mode-button { - margin: 2px 0; -} -#game-mode-icon { - margin-left: -6px; -} - -#game-mode-button-clickable { - background: transparent; - border: none; - border-radius: 8px; - padding: 6px 12px; - margin: 1px 0; - /* min-height: 36px; */ -} - -#game-mode-button-clickable:hover { - background: rgba(255, 255, 255, 0.1); -} - -#game-mode-icon { - border-radius: 50%; - padding: 4px; - margin-right: 8px; - background-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.7); -} - -#game-mode-icon.connected { - background-color: #007aff; - color: #ffffff; -} diff --git a/styles/colors.css b/styles/colors.css deleted file mode 100644 index 1a391d3..0000000 --- a/styles/colors.css +++ /dev/null @@ -1,32 +0,0 @@ -:vars { - --foreground: #e4e1e9; - --background: #131318; - --cursor: #e4e1e9; - --primary: #bec2ff; - --on-primary: #262b60; - --secondary: #c5c4dd; - --on-secondary: #2e2f42; - --tertiary: #e7b9d5; - --on-tertiary: #45263c; - --surface: #131318; - --surface-bright: #39393f; - --error: #ffb4ab; - --error-dim: #ff8678; - --on-error: #690005; - --error-container: #93000a; - --outline: #91909a; - --shadow: #000000; - --red: #ffb2b9; - --red-dim: #ff7f8b; - --green: #95d5a7; - --green-dim: #70c789; - --yellow: #b8cf84; - --yellow-dim: #a3c15f; - --blue: #bec2ff; - --blue-dim: #8b92ff; - --magenta: #e4b7f3; - --magenta-dim: #d48bec; - --cyan: #82d3e2; - --cyan-dim: #59c4d8; - --white: #82d3e0; -} diff --git a/styles/controlcenter.css b/styles/controlcenter.css deleted file mode 100644 index a60738d..0000000 --- a/styles/controlcenter.css +++ /dev/null @@ -1,988 +0,0 @@ -.title { - font-size: 16px; - font-family: "SF Pro Rounded"; - font-weight: bold; -} -#control-center-menu { - background-color: transparent; - border-radius: 12px; - box-shadow: none; - margin: 0; -} - -#separator { - min-height: 0.09rem; - margin: 3px 0; - background-color: alpha(#fff, 0.2); - border-radius: 8px; -} - -/* #bluetooth-control-window { */ -/* #wifi-control-window, */ -#battery-control-window { - margin: 6px; - /* background-color: alpha(#fff, 0.09); */ - border: 1px solid alpha(#111, 0.3); - box-shadow: inset 0 0 200px 0 alpha(#111, 0.3); - border-radius: 15px; -} - -#bluetooth-title, -#wifi-title { - font-size: 16px; - font-weight: bold; -} -#control-center-widgets { - background-color: alpha(#010101, 0.01); - box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); - /* border: 1px solid alpha(#111, 0.4); */ - border-radius: 12px; - padding: 0.5rem; -} -#focus-widget { -} -#wb-widget, -#brightness-menu { - background-color: alpha(#000, 0.08); - box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4); - border: 1px solid alpha(#111, 0.4); - border-radius: 12px; -} -/* Widgets */ -#wifi-widget, -#bluetooth-widget, -#nightlight-widget { - min-width: 140px; - padding: 5px 0 0px 0; -} - -.icon { - background-color: #2369ff; - font-size: 15px; - padding: 20px 20px; -} - -#bluetooth-widget-label, -#wifi-widget-label, -#nightlight-widget-label { - font-size: 12px; - margin-left: 5px; - font-weight: 500; - color: #999; -} - -/* WiFi Password Dialog */ -#wifi-password-dialog { - background-color: transparent; -} - -#wifi-dialog-background { - background-color: alpha(#fff, 0.05); - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.1); - padding: 20px; - min-width: 450px; -} - -#wifi-dialog-title-container { - /* margin-bottom: 12px; */ -} - -#wifi-dialog-icon { - color: #007aff; - margin-right: 4px; -} - -#wifi-dialog-title { - color: #ffffff; - font-size: 14px; - font-weight: 500; -} - -#wifi-dialog-error { - color: #ff4444; - font-size: 12px; - font-weight: 500; - /* margin-bottom: 8px; */ -} - -#wifi-dialog-password-container { - margin: 5px 0; -} - -#wifi-dialog-password-label { - color: #ffffff; - font-size: 13px; - font-weight: 500; - margin-bottom: 4px; -} - -#wifi-dialog-password-entry { - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 6px; - padding: 8px 12px; - color: #ffffff; - font-size: 13px; -} - -#wifi-dialog-password-entry:focus { - border-color: #007aff; - box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3); - background-color: rgba(255, 255, 255, 0.15); -} - -#wifi-dialog-show-password-box { - margin-top: 4px; -} - -#wifi-dialog-show-password-button { - background-color: transparent; - border: none; - padding: 4px; - border-radius: 4px; - min-width: 24px; - min-height: 24px; -} - -#wifi-dialog-show-password-button:hover { - background-color: alpha(#fff, 0.1); -} - -#wifi-dialog-show-password-button image { - color: #ffffff; -} - -#wifi-dialog-show-password-label { - color: #ffffff; - font-size: 12px; -} - -#wifi-dialog-button-box { - margin-top: -15px; -} - -#wifi-dialog-cancel-button, -#wifi-dialog-join-button { - border-radius: 6px; - font-size: 13px; - font-weight: 500; - min-width: 80px; - min-height: 30px; -} - -#wifi-dialog-cancel-button { - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #ffffff; -} - -#wifi-dialog-cancel-button:hover { - background-color: rgba(255, 255, 255, 0.15); -} - -#wifi-dialog-join-button { - background-color: #007aff; - border: 1px solid #007aff; - color: #ffffff; -} - -#wifi-dialog-join-button:hover { - background-color: #0056cc; - border-color: #0056cc; -} - -#wifi-dialog-join-button.disabled { - opacity: 0.5; - background-color: #555555; - border-color: #555555; - color: #aaaaaa; -} - -#wifi-dialog-join-button.disabled:hover { - background-color: #555555; - border-color: #555555; - color: #aaaaaa; -} - -#bluetooth-widget-title { - font-size: 12px; - font-weight: 500; - color: #fff; -} - -/* #bluetooth-widget-top { */ -/* padding-bottom: 10px; */ -/* border-bottom: 1px solid alpha(#aaa, 0.3); */ -/* } */ - -#device-icon { - border-radius: 50%; - padding: 5px; - margin-bottom: 2.5px; - margin-top: 2.5px; - margin-right: 10px; - background-color: #555; -} - -#devices-title { - color: alpha(#fff, 0.6); -} - -#device-icon.paired { - background-color: #888; -} - -#device-icon.connected { - background-color: #2369ff; -} - -#toggle-button { - min-width: 40px; - min-height: 20px; - background-color: #bababa; - border-radius: 15px; - padding: 2px; - transition: background-color 0.3s ease; -} - -#toggle-button slider { - background-color: #fff; - border-radius: 50%; - min-width: 16px; - min-height: 8px; - transition: background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25); -} - -#toggle-button:checked { - background-color: #4487f6; -} - -/* #toggle-button:checked slider { */ -/* background-color: #fff; */ -/* } */ -/**/ -/* #toggle-button:checked image { */ -/* opacity: 0; */ -/* } */ - -.menu { - background-color: alpha(#000, 0.08); - box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4); - border: 1px solid alpha(#111, 0.4); - border-radius: 12px; - margin: 5px; - /* background-color: alpha(#999, 0.1); */ - /* border: 0.5px solid alpha(#000, 0.4); */ - /* border-radius: 8px; */ - padding: 1rem; -} - -.title { - padding: 0; - font-weight: 500; - font-size: 12px; -} - -#bluetooth-widget-name, -#wifi-widget-name, -#nightlight-widget-name { - margin-top: 6px; - font-size: 14px; - font-weight: bold; -} - -.ct { - margin-left: 5px; -} - -#vol-slider-box { - /* margin-bottom: 2px; */ - margin-top: -10px; - /* border: 1px solid alpha(#fff, 1); */ -} -#volume-widget-slider { - /* margin-bottom: 2px; */ -} -#volume-widget-icon { - font-size: 22px; - margin-top: -35px; - margin-right: -36px; - color: #000; -} - -#brightness-widget-icon { - font-size: 15px; - margin-top: -27px; - margin-right: -29px; - color: #000; -} - -#control-center-menu slider { - background: linear-gradient(135deg, #ffffff 0%, #f0f1f2 100%); - border-color: #000; - min-width: 28px; - min-height: 28px; - margin: -2px; - border-radius: 50%; - /* background-image: none; */ - /* background-color: transparent; */ - /* min-width: 4px; */ - /* min-height: 40px; */ - /* margin: -9px; */ -} - -#control-center-menu slider:hover { - border-color: #999; - min-width: 30px; - min-height: 30px; - margin: -3px; - border-radius: 50%; - /* background-image: none; */ - /* background-color: transparent; */ - /* min-width: 4px; */ - /* min-height: 40px; */ - /* margin: -9px; */ -} -#control-center-menu scale { - background-color: transparent; - margin-top: 10px; - border-radius: 20px; -} - -#control-center-menu trough { - min-width: 25px; - border-radius: 99px; - background-color: alpha(#666, 0.5); - border: 1px solid alpha(#444, 0.3); -} - -#control-center-menu highlight { - background: alpha(#fff, 0.8); - border-radius: 99px; -} - -#control-center-menu mark indicator { - background: none; - background-image: none; - color: alpha(#fff, 0.2); -} - -#control-center-menu mark label { - background: none; - background-image: none; - color: alpha(#fff, 0.2); -} - -/* Per-app volume control styles */ -#per-app-volume-control { - min-height: 140px; - min-width: 350px; - /* background-color: alpha(#000, 0.3); */ - /* background-color: alpha(#999, 0.1); */ - /* box-shadow: inset 0 0 200px 0 alpha(#111, 0.3); */ - /* border: 0.5px solid alpha(#000, 0.4); */ - border-radius: 8px; - padding: 1rem; - - /* padding: 1rem; */ -} - -#apps-scrolled-container { - min-height: 120px; - min-width: 120px; -} -#back-button { - padding: 8px 12px; - background-color: alpha(#fff, 0.1); - border-radius: 8px; - border: none; - color: #fff; - font-size: 13px; - font-weight: 500; - transition: background-color 0.2s ease; -} -#back-button:hover { - background-color: alpha(#fff, 0.18); -} - -/* Apple-style app volume items */ -.apple-app-volume-item { - margin: 3px 0; - padding: 16px; - background-color: alpha(#fff, 0.08); - border-radius: 12px; - border: 1px solid alpha(#fff, 0.12); - transition: background-color 0.2s ease; -} - -.apple-app-volume-item:hover { - background-color: alpha(#fff, 0.12); -} - -.apple-app-name { - font-size: 14px; - font-weight: 600; - color: #fff; - margin-bottom: 4px; - letter-spacing: -0.01em; -} - -/* Apple-style volume slider */ -#apple-volume-slider { - background-color: transparent; - margin-top: 4px; - margin-bottom: 4px; - min-height: 32px; -} - -#apple-volume-slider trough { - min-width: 28px; - min-height: 6px; - border-radius: 3px; - background-color: alpha(#fff, 0.25); - border: none; - box-shadow: inset 0 1px 2px alpha(#000, 0.2); -} - -#apple-volume-slider highlight { - background: linear-gradient(90deg, #007aff 0%, #0051d0 100%); - border-radius: 3px; - border: none; - box-shadow: 0 1px 3px alpha(#007aff, 0.3); -} - -#apple-volume-slider slider { - background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); - /* border: 2px solid alpha(#007aff, 0.8); */ - border-radius: 50%; - min-width: 20px; - min-height: 20px; - margin: -10px; - box-shadow: - 0 2px 8px alpha(#000, 0.15), - 0 1px 3px alpha(#000, 0.2); - transition: all 0.15s ease; -} - -#apple-volume-slider slider:hover { - background: linear-gradient(135deg, #ffffff 0%, #f0f1f2 100%); - border-color: #007aff; - box-shadow: - 0 4px 12px alpha(#000, 0.2), - 0 2px 6px alpha(#007aff, 0.3); - min-width: 22px; - min-height: 22px; - margin: -9px; -} - -#apple-volume-slider slider:active { - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); - min-width: 21px; - min-height: 21px; - margin: -9px; - box-shadow: - 0 2px 6px alpha(#000, 0.25), - 0 1px 3px alpha(#007aff, 0.4); -} - -/* Legacy app volume styles (fallback) */ -.app-volume-item { - margin: 8px 0; - padding: 8px; - background-color: alpha(#fff, 0.05); - border-radius: 6px; - border: 1px solid alpha(#fff, 0.1); -} - -.app-name { - font-size: 13px; - font-weight: 500; - color: #fff; - margin-bottom: 5px; -} - -#app-volume-slider { - background-color: transparent; - margin-top: 5px; -} - -#app-volume-slider trough { - min-width: 20px; - border-radius: 99px; - background-color: alpha(#666, 0.5); - border: 1px solid alpha(#444, 0.3); -} - -#app-volume-slider highlight { - background: alpha(#0078d4, 0.8); - border-radius: 99px; -} - -#app-volume-slider slider { - background-color: #fff; - padding: 2px; - min-width: 4px; - min-height: 18px; - border-radius: 20px; -} - -/* Circle button for per-app volume */ -#per-app-volume-button { - background-color: alpha(#fff, 0.15); - border: 1px solid alpha(#999, 0.6); - /* min-width: 33px; */ - /* min-height: 30px; */ - border-radius: 50%; -} - -#per-app-volume-button:hover { - background-color: alpha(#fff, 0.25); - /* border-color: #007aff; */ - box-shadow: 0 4px 12px alpha(#000, 0.2); -} - -#per-app-volume-button:active { - background-color: alpha(#fff, 0.2); - box-shadow: 0 1px 3px alpha(#000, 0.3); -} - -#per-app-volume-icon { - background-color: alpha(#fff, 0.15); - - border: 2px solid alpha(#999, 0.6); - - border-radius: 50%; - - min-width: 30px; -} - -/* Apple-style seek bar for expanded player */ -#apple-seek-bar { - background-color: transparent; - margin-top: 8px; - margin-bottom: 4px; - min-height: 10px; -} - -#apple-seek-bar trough { - min-width: 28px; - min-height: 3px; - border-radius: 1.5px; - background-color: alpha(#fff, 0.3); - border: none; - box-shadow: inset 0 1px 1px alpha(#000, 0.1); -} - -#apple-seek-bar highlight { - background: #007aff; - border-radius: 1.5px; - border: none; - box-shadow: 0 0 2px alpha(#007aff, 0.4); -} - -#apple-seek-bar slider { - background: #ffffff; - border: 1px solid alpha(#007aff, 0.8); - border-radius: 50%; - min-width: 12px; - min-height: 12px; - margin: -4.5px; - box-shadow: - 0 1px 3px alpha(#000, 0.2), - 0 0 0 1px alpha(#fff, 0.8); - transition: all 0.1s ease; -} - -#apple-seek-bar slider:hover { - background: #ffffff; - border-color: #007aff; - min-width: 14px; - min-height: 14px; - margin: -5.5px; - box-shadow: - 0 2px 6px alpha(#000, 0.25), - 0 0 0 2px alpha(#007aff, 0.3); -} - -#apple-seek-bar slider:active { - background: #f8f9fa; - min-width: 13px; - min-height: 13px; - margin: -5px; - box-shadow: - 0 1px 3px alpha(#000, 0.3), - 0 0 0 2px alpha(#007aff, 0.5); -} - -/* macOS-style expanded player - more compact */ -#macos-outer-player-box { - min-height: 80px; /* Reduced height */ - min-width: 380px; /* Slightly wider to accommodate expanded track info */ - /* margin: 10px; */ - padding: 10px; - /* background-color: alpha(#000, 0.4); */ -} - -#macos-main-section { - margin-left: 5px; -} - -#macos-album-cover-image image { - min-width: 70px; - min-height: 70px; - border-radius: 9px; -} -#macos-album-image { - min-width: 70px; - min-height: 70px; - border-radius: 9px; - background-position: center; - background-size: cover; - box-shadow: 0 2px 8px alpha(#000, 0.3); -} - -#macos-album-image-no { - min-width: 90px; - min-height: 90px; - border-radius: 9px; - margin-left: 12px; - background-position: center; - background-size: cover; - box-shadow: 0 2px 8px alpha(#000, 0.3); -} -#macos-album-image image { - min-width: 70px; - min-height: 70px; - border-radius: 9px; - box-shadow: 0 2px 8px alpha(#000, 0.3); -} - -/* Track info container that expands to fill available space */ -#macos-track-info { - margin-top: 5px; - margin-left: 15px; - margin-right: 15px; -} - -#macos-player-title { - font-weight: 600; - font-size: 16px; - color: #ffffff; - margin-bottom: 2px; -} - -#macos-player-artist { - font-weight: 400; - margin-top: -2px; - font-size: 13px; - color: alpha(#999, 0.8); -} - -#macos-player-album { - font-weight: 400; - margin-top: -4px; - font-size: 13px; - color: alpha(#999, 0.8); - margin-bottom: 8px; -} -/* macOS seek bar styling */ -#macos-seek-bar { - background-color: transparent; - margin: 8px 0 4px 0; - min-height: 10px; -} - -#macos-seek-bar trough { - min-width: 200px; - - min-height: 2px; - border-radius: 2px; - background-color: alpha(#fff, 0.25); - border: none; -} - -#macos-seek-bar highlight { - background: #ffffff; - border-radius: 2px; - border: none; -} - -#macos-seek-bar slider { - background: #ffffff; - border: none; - border-radius: 20%; - min-width: 1px; - min-height: 8px; - margin: -3px; - box-shadow: 0 1px 3px alpha(#000, 0.3); - transition: all 0.1s ease; -} - -#macos-seek-bar slider:hover { - min-width: 4px; - min-height: 14px; - margin: -5px; - box-shadow: 0 2px 6px alpha(#000, 0.4); -} - -#macos-position-label, -#macos-length-label { - font-size: 11px; - color: alpha(#999, 0.9); - font-weight: 400; -} - -/* macOS control buttons - more compact */ -#macos-button-box { - margin-top: -1px; /* Reduced top margin */ -} - -#macos-control-button { - background: transparent; - border: none; - border-radius: 50%; - min-width: 28px; /* Slightly smaller */ - opacity: 0.7; - min-height: 28px; - padding: 5px; /* Reduced padding */ - color: #ffffff; - transition: all 0.15s ease; -} - -#macos-control-button:hover { - opacity: 1; -} - -#macos-play-button { - background: transparent; - border: none; - border-radius: 50%; - min-width: 36px; /* Slightly smaller */ - opacity: 0.7; - min-height: 36px; - padding: 7px; /* Reduced padding */ - color: #ffffff; - transition: all 0.15s ease; -} - -#macos-play-button:hover { - opacity: 1; -} - -/* macOS player switcher dots - compact horizontal layout */ -#macos-stack-buttons-box { - margin: 2px 5px; /* Minimal margins */ -} - -.macos-switcher-dot { - background: alpha(#fff, 0.3); - /* border: none; */ - border-radius: 50%; - min-width: 12px; /* Smaller dots */ - min-height: 12px; - margin: 2px; /* Reduced margins */ - /* transition: all 0.2s ease; */ -} - -.macos-switcher-dot:hover { - background: alpha(#fff, 0.5); -} - -.macos-switcher-dot.active { - background: #ffffff; - box-shadow: 0 0 0 1px alpha(#fff, 0.3); /* Smaller glow */ -} - -/* Per app-volume-item */ - -.compact-app-volume-item { - padding-bottom: 5px; - padding-right: 14px; - border-bottom: 1px solid alpha(#fff, 0.1); -} -#app-icon { - margin-top: 8px; -} -.app-name-compact { - font-size: 14px; - font-weight: bold; - margin-top: 8px; -} - -#compact-app-volume-slider scale { - margin-right: 8px; - padding-right: 8px; -} - -#expanded-seek-bar { - background-color: transparent; - margin: 8px 0 4px 0; - min-height: 10px; -} - -#expanded-seek-bar trough { - min-width: 200px; - - min-height: 2px; - border-radius: 2px; - background-color: alpha(#fff, 0.25); - border: none; -} - -#expanded-seek-bar highlight { - background: #ffffff; - border-radius: 2px; - border: none; -} - -#expanded-seek-bar slider { - background: #ffffff; - border: none; - border-radius: 20%; - min-width: 1px; - min-height: 10px; - margin: -3px; - box-shadow: 0 1px 3px alpha(#000, 0.3); - transition: all 0.1s ease; -} - -#expanded-seek-bar slider:hover { - min-width: 4px; - min-height: 14px; - margin: -5px; - box-shadow: 0 2px 6px alpha(#000, 0.4); -} - -/* macOS-style WiFi interface */ -#wifi-connections, -#bluetooth-connections { - /* background-color: rgba(255, 255, 255, 0.08); */ - background-color: alpha(#fff, 0.05); - box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); - border: 1px solid alpha(#111, 0.4); - border-radius: 8px; - /* padding: 0.5rem; */ - /* border-radius: 12px; */ - padding: 10px; -} - -/* WiFi network slots - macOS style */ -#wifi-network-slot button { - border-radius: 8px; - padding: 4px 4px; - /* min-width: 280px; */ - margin: 2px 0; -} - -#wifi-network-slot button:hover { - background-color: rgba(255, 255, 255, 0.1); -} - -/* WiFi-specific device icon styles */ -#wifi-connections #device-icon { - background-color: rgba(255, 255, 255, 0.1); -} - -#wifi-connections #device-icon.connected { - background-color: #007aff; - color: #ffffff; -} - -#wifi-network-name { - color: #ffffff; - font-size: 14px; - font-weight: 500; -} - -#wifi-lock-icon { - color: rgba(255, 255, 255, 0.6); - margin-left: 8px; -} - -/* Other Networks and Settings buttons */ -#wifi-other-button { - background: transparent; - border: none; - padding: 8px 6px; - margin-bottom: -12px; - margin-top: -6px; - border-radius: 8px; - /* transition: background-color 0.2s ease; */ -} - -#wifi-other-button:hover { - background-color: rgba(255, 255, 255, 0.1); -} -#wifi-other-button:last-child { - margin-bottom: 3px; -} - -#device-button { - /* background-color: #000; */ - border-radius: 8px; -} -.device-slot:hover { - background-color: rgba(255, 255, 255, 0.05); -} - -.button-hovered { - background-color: rgba(255, 255, 255, 0.1); -} - -#wifi-other-device label, -#wifi-other-button label { - font-size: 12px; - font-weight: bold; -} -#app-volume-header { - font-size: 16px; - padding-left: 5px; - margin-top: -2px; - font-weight: bold; - color: #ffffff; -} - -.wifi-icon-box { - background-color: #aaaaaa; - border-radius: 50%; - padding: 2px; - /* margin-right: 8px; */ -} -.wifi-icon-box-connected { - background-color: #007aff; - border-radius: 50%; - padding: 2px; - /* margin-right: 8px; */ -} - -/* #flight-icon { */ -/* margin-left: -10px; */ -/* } */ - -.title-widget { - /* margin-top: -14px; */ - /* margin-left: 5px; */ - font-size: 13px; - font-family: "SF Pro Rounded"; - font-weight: bold; -} -.status-label { - font-size: 11px; - font-weight: 500; - color: #aaa; - /* margin-top: -28px; */ - /* margin-left: 5px; */ - font-family: "SF Pro Rounded"; - /* font-weight: bold; */ - /* margin-bottom: 2px; */ -} - -#app-control-box { - min-width: 100px; -} -#caffeine-widget, -#flight-widget { - padding: 0px 0 5px 0; - min-width: 50px; -} diff --git a/styles/dock.css b/styles/dock.css deleted file mode 100644 index 24eb0f7..0000000 --- a/styles/dock.css +++ /dev/null @@ -1,98 +0,0 @@ -#dock { - background-color: alpha(#fff, 0.07); - padding: 4px 4px; - margin: 4px 4px; - border: none; - border-radius: 16px; - transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); -} - -#dock.shown { - transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); -} - -#dock_item_main_container { - transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); -} - -#dock_item.shown:hover #dock_item_main_container { - margin-top: -12px; -} - -#dock_item.shown.semi_hovered #dock_item_main_container { - margin-top: -7px; -} -/**/ -/* #dock_item.shown.semi_hovered #dock_item_indicator { */ -/* margin-top: 11px; */ -/* } */ - -/* #dock_item.shown.activated:hover #dock_item_main_container { */ -/* margin-top: -12px; */ -/* } */ -/**/ -/* #dock_item.shown.activated:hover #dock_item_indicator { */ -/* margin-top: 16px; */ -/* } */ -/**/ -/* #dock_item.shown.activated #dock_item_main_container { */ -/* margin-top: -4px; */ -/* } */ - -#dock_item.shown.activated #dock_item_icon { - opacity: 1; - /* background-color: #01458e; */ -} - -/* #dock_item.shown.activated #dock_item_indicator { */ -/* min-width: 4px; */ -/* margin-top: 8px; */ -/* background-color: #aac7ff; */ -/* } */ - -#dock_item_icon { - border-radius: 13px; - opacity: 0.7; - transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1); -} - -/* #dock_item_indicator { */ -/* transition: all 0.15s ease-out; */ -/* min-height: 4px; */ -/* border-radius: 4px; */ -/* } */ - -#dock_item { - transition: margin 0.25s cubic-bezier(0.25, 0.1, 0.25, 1); -} - -#dock_item.shown { - padding-left: 4px; - opacity: 1; -} - -#dock_item.shown:first-child { - padding-left: 0px; -} - -.dock_separator { - transition: all 0.25s cubic-bezier(0.25, 0.1, 0.25, 1); - min-width: 1.5px; - min-height: 50px; - margin: 0px 4px; - border-radius: 2px; - background-color: #2b5ea7; -} - -.dock_separator.hidden { - min-width: 0px; - background-color: transparent; - margin: 0px; -} - -/* #dock_item.shown:not(.activated) { */ -/* min-width: 0px; */ -/* min-height: 0px; */ -/* margin-top: 0px; */ -/* background-color: transparent; */ -/* } */ diff --git a/styles/dropdown.css b/styles/dropdown.css deleted file mode 100644 index 2f036ad..0000000 --- a/styles/dropdown.css +++ /dev/null @@ -1,61 +0,0 @@ -#dropdown-menu { - background-color: transparent; - - border-radius: 0.75rem; - margin: 0; -} - -#dropdown-options { - background-color: alpha(#000, 0.3); - box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); - border: 1px solid alpha(#111, 0.4); - border-radius: 0.75rem; - padding: 0.5rem; -} - -#dropdown-option { - background-color: transparent; - border-radius: 5px; - padding: 0px 10px; - margin: 1px 2px; - transition: all 0ms ease-in-out; -} - -#dropdown-option:hover { - transition: all 20ms ease-in-out; - color: #222; - background-color: alpha(#2369ff, 1); -} - -#dropdown-option-label { - font-size: 13px; - margin: 0px; - color: #fff; - font-weight: 400; -} - -#dropdown-option-label:first-child { - padding-right: 5rem; -} - -#dropdown-option:hover #dropdown-option-keybind { - color: #fff; -} - -#dropdown-option-keybind { - font-weight: 500; - color: #aaa; -} - -/* Divider */ - -#dropdown-divider-box { - background-color: transparent; - border-radius: 5px; - padding: 0px 10px; - margin: 2px; -} -#dropdown-divider { - border-bottom: 1px solid alpha(#aaa, 0.3); - margin: 2px 0; -} diff --git a/styles/launcher.css b/styles/launcher.css deleted file mode 100644 index 24382a4..0000000 --- a/styles/launcher.css +++ /dev/null @@ -1,230 +0,0 @@ -#launcher { - background-color: alpha(#000, 0.3); - padding: 0; - border-radius: 12px; - min-width: 640px; - border: 1px solid rgba(255, 255, 255, 0.1); -} - -/* #launcher-search { */ -/* color: #000; */ -/* } */ -#header_box { - padding: 0px 20px 0 20px; - color: #000; -} - -#close-button, -#config-button { - background-color: transparent; - border-radius: 6px; - padding: 6px; - transition: all 0.15s ease; -} - -#close-button:hover, -#close-button:focus, -#config-button:hover, -#config-button:focus { - background-color: rgba(255, 255, 255, 0.1); - border-radius: 6px; -} - -#close-button.focused, -#config-button.focused { - background-color: rgba(0, 122, 255, 0.2); - border-radius: 6px; - box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.4); -} - -#close-button.focused #close-label { - color: #007aff; -} - -#config-button.focused #config-label { - color: #007aff; -} - -#close-button:active { - background-color: rgba(255, 255, 255, 0.2); - border-radius: 6px; -} - -#close-label { - color: rgba(255, 255, 255, 0.7); - font-size: 18px; -} - -#close-button:active #close-label { - color: rgba(255, 255, 255, 0.9); -} - -#config-button:active { - background-color: rgba(255, 255, 255, 0.2); - border-radius: 6px; -} - -#config-label { - color: rgba(255, 255, 255, 0.7); - font-size: 18px; -} - -#config-button:active #config-label { - color: rgba(255, 255, 255, 0.9); -} - -#launcher-icon-label { - font-size: 20px; - padding: 6px; - color: rgba(255, 255, 255, 0.8); -} - -#launcher-search { - font-weight: 400; - font-size: 36px; - background-color: transparent; - color: rgba(255, 255, 255, 0.95); - border: none; - border-radius: 0; - padding: 12px 0; - margin: 0; -} - -#launcher-search:focus { - background-color: transparent; - box-shadow: none; - border: none; -} - -#launcher-search selection { - color: white; - background-color: rgba(0, 122, 255, 0.8); -} - -#launcher-results-scroll { - margin: 0; - border-radius: 0; - background: transparent; - padding: 0 20px 20px 20px; -} - -#launcher-results-scroll scrollbar { - border-radius: 0; - background-color: transparent; - padding: 0; - margin: 0; - min-width: 0; -} - -#launcher-results-scroll scrollbar slider { - border-radius: 2px; - min-width: 4px; - min-height: 20px; - background-color: rgba(255, 255, 255, 0.3); - margin: 0; -} - -#launcher-results-scroll scrollbar:hover slider { - background-color: rgba(255, 255, 255, 0.5); -} - -#launcher-results { - background: transparent; - margin-top: 8px; -} - -#launcher-result-item { - border-radius: 8px; - padding: 12px 16px; - margin: 2px 0; - min-height: 64px; - transition: all 0.15s ease; - background: transparent; -} - -@keyframes loadSlot { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -#launcher-result-item:focus, -#launcher-result-item:selected, -#launcher-result-item:hover, -#launcher-result-item.selected { - border-radius: 8px; - background-color: rgba(0, 122, 255, 0.8); - padding: 12px 16px; - margin: 2px 0; -} - -#launcher-result-item.selected #result-item-title { - color: white; - font-weight: 600; -} - -#launcher-result-item.selected #result-item-subtitle { - color: rgba(255, 255, 255, 0.8); -} - -#launcher-result-item.selected #result-item-plugin { - color: rgba(255, 255, 255, 0.6); -} - -#result-item-main { - min-height: 56px; -} - -#launcher-result-item { - min-height: 56px; -} - -#result-item-icon { - min-width: 56px; - min-height: 56px; - margin-right: 16px; - border-radius: 12px; -} - -#result-item-title { - font-size: 18px; - font-weight: 500; - color: rgba(255, 255, 255, 0.95); - margin-top: 4px; - margin-bottom: 2px; -} - -#result-item-subtitle { - font-size: 14px; - color: rgba(255, 255, 255, 0.6); - margin-bottom: 4px; -} - -#result-item-plugin { - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - font-style: normal; - margin-bottom: 4px; - opacity: 1; - font-weight: 400; -} - -#network-password-entry { - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.05); - color: rgba(255, 255, 255, 0.9); - padding: 12px; - border-radius: 8px; - margin-bottom: 8px; - font-size: 14px; -} - -#network-password-entry:focus { - border: 1px solid rgba(0, 122, 255, 0.6); - background: rgba(255, 255, 255, 0.1); - box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); -} diff --git a/styles/lock.css b/styles/lock.css deleted file mode 100644 index 00fe279..0000000 --- a/styles/lock.css +++ /dev/null @@ -1,65 +0,0 @@ -#lockscreen-bg { - background-size: cover; -} -#indicator-box { - margin-right: 10px; - margin-top: 4px; -} -#container-box { - min-height: 50px; - min-width: 250px; - transition: all 0.7s ease-in-out; -} -#password-entry { - background-color: alpha(#fff, 0.4); - border-radius: 20px; - color: white; - min-width: 220px; - min-height: 40px; - padding-right: 5px; - padding-left: 5px; - font-size: 14px; - font-weight: 500; - transition: all 0.7s ease-in-out; -} -#profile-box { - margin-bottom: 10px; -} -#username { - font-size: 21px; - font-weight: bold; - margin-top: 5px; - transition: all 0.7s ease-in-out; -} -#lock-date label { - background-color: transparent; - color: alpha(#fff, 0.6); - border: none; - font-size: 30px; - margin-top: 80px; - font-weight: bold; -} -#lock-clock label { - background-color: transparent; - color: alpha(#fff, 0.6); - border: none; - font-size: 120px; - margin-top: -20px; - font-weight: bold; -} - -#face-icon image { - margin-bottom: 10px; -} -#face-icon { - padding-bottom: 10px; -} - -#unlock-box { - margin-bottom: 40px; -} -#unlock-text { - font-size: 15px; - color: alpha(#fff, 0.6); - font-weight: bold; -} diff --git a/styles/notification-center.css b/styles/notification-center.css deleted file mode 100644 index 0c041d3..0000000 --- a/styles/notification-center.css +++ /dev/null @@ -1,297 +0,0 @@ -#noti-center-box { - background-color: transparent; - - /* border: 1px solid alpha(#111, 0.3); */ - border-radius: 20px; - margin-right: 10px; - /* padding: 20px; */ - /* margin: 6px; */ -} - -#noti-clear-button { - /* min-width: 10px; */ - background-color: alpha(#1c2328, 0.05); - min-height: 25px; - min-width: 70px; - border: 1px solid #525155; - border-radius: 15px; - margin-top: 10px; -} - -#notif-clear-button label { - font-size: 15px; -} - -#noti-clear-button:hover { - background-color: alpha(#fff, 0.09); -} - -/* Expandable notification group styles */ -#notification-group { - margin: 4px 0; -} - -/* Single notification (no stacking) */ -#single-notification-content { - background-color: alpha(#1c2328, 0.05); - border: 1.5px solid #525155; - border-radius: 12px; - padding: 12px; - transition: all 0.2s ease; -} - -#single-notification-content:hover { - background-color: alpha(#999, 0.9); - border: 1.5px solid #525155; -} - -/* Stacked notification container */ -#notification-stack-container { - background-color: transparent; - margin: 8px 0; -} - -/* Bottom shadow layer (deepest) - offset to show stacking from below */ -#stack-shadow-bottom { - background-color: alpha(#1c2328, 0.05); - border-top: 1px solid #525155; - border-radius: 12px; - opacity: 1; - min-height: 16px; - margin-left: 20px; - margin-right: -4px; - margin-bottom: -10px; -} - -/* Middle shadow layer */ -#stack-shadow-middle { - background-color: alpha(#1c2328, 0.05); - - border-top: 1px solid #525155; - border-radius: 12px; - opacity: 1; - min-height: 20px; - margin-left: 10px; - margin-right: -2px; - margin-bottom: -14px; -} - -/* Main notification content (top layer) */ -#stack-main-notification { - background-color: alpha(#1c2328, 0.05); - - border: 1.2px solid #525155; - border-radius: 12px; - padding: 12px; - transition: all 0.2s ease; -} - -/* Hover effects for stacked notifications */ -#notification-stack-container:hover #stack-main-notification { - background-color: alpha(#525155, 0.1); - border: 1px solid #525155; -} - -#notification-stack-container:hover #stack-shadow-middle { - opacity: 0.7; -} - -#notification-stack-container:hover #stack-shadow-bottom { - opacity: 0.5; -} - -/* Collapsed notification state */ -#notification-content-collapsed { - background-color: alpha(#000, 0.05); - border: 1px solid #525155; - border-radius: 12px; - padding: 12px; - margin: 2px 0; - transition: all 0.2s ease; -} - -#notification-content-collapsed:hover { - background-color: alpha(#525155, 0.1); - border: 1px solid #525155; -} - -/* Notification count badge */ -#notification-count-label { - background-color: alpha(#fff, 0.2); - color: rgba(255, 255, 255, 0.8); - border-radius: 10px; - padding: 2px 6px; - font-size: 10px; - font-weight: 500; - min-width: 16px; -} - -/* Expanded notification group header */ -#notification-group-header { - background-color: alpha(#000, 0.05); - border: 1px solid #525155; - border-radius: 12px 12px 0 0; - padding: 8px 12px; - margin: 2px 0 0 0; -} - -#notification-group-title { - color: #ffffff; - font-weight: bold; - font-size: 14px; -} - -#notification-show-less { - background-color: transparent; - color: rgba(255, 255, 255, 0.7); - border: 1px solid alpha(#fff, 0.2); - border-radius: 6px; - padding: 2px 8px; - font-size: 11px; - margin-right: 8px; -} - -#notification-show-less:hover { - background-color: alpha(#999, 0.7); - color: rgba(255, 255, 255, 0.9); -} - -#notification-close-all { - background-color: transparent; - color: rgba(255, 255, 255, 0.7); - border: 1px solid alpha(#fff, 0.2); - border-radius: 6px; - padding: 2px 8px; - font-size: 12px; - min-width: 24px; -} - -#notification-close-all:hover { - background-color: alpha(#ff5555, 0.2); - color: rgba(255, 255, 255, 0.9); - border-color: alpha(#ff5555, 0.4); -} - -/* Expanded notification list */ -#notification-group-expanded { - border: 1px solid #525155; - border-top: none; - border-radius: 0 0 12px 12px; - /* background-color: alpha(#000, 0.02); */ -} - -/* Stacked notification group styles */ -.stacked-notification-group { - margin: 4px 0; -} - -.stacked-notification-container { -} - -.stacked-notification-item { - background-color: alpha(#000, 0.05); - border: 1px solid #525155; - border-radius: 12px; - transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); -} - -#notif-clear-btn { - border-radius: 10px; - font-size: 12px; - padding: 2px; -} - -#notif-clear-btn:hover { - background-color: var(--surface-bright); -} - -#stacked-notification-item:hover { - background-color: alpha(#525155, 0.1); - border: 1px solid #525155; -} - -/* Stack count indicator */ -#stack-count-indicator { - font-size: 10px; - color: rgba(255, 255, 255, 0.7); - margin: 2px 8px; - font-weight: 500; -} - -/* Expanded view styles - notifications use their default styling */ -#expanded-notification-list { - margin-top: 8px; -} - -/* Make expanded notifications clickable with subtle hover effect */ -#expanded-notification-list:hover { - background-color: alpha(#111, 0.9); - border-radius: 8px; - transition: background-color 0.2s ease; -} - -#notification-group-title { - font-size: 19px; - margin: 10px; - font-weight: bold; -} -#notification-show-less { - background-color: alpha(#1c2328, 0.05); - font-size: 12px; - margin: 10px; - border-radius: 15px; -} - -#notification-close-all { - margin: 10px 10px 10px 0; - background-color: alpha(#1c2328, 0.05); - border-radius: 15px; -} - -#notif-close-button { - background-color: transparent; -} - -#notification-group-expanded { - background-color: transparent; - border: none; -} - -#notification-content { - background-color: transparent; -} -#notification-count-label { - background-color: transparent; - color: white; - font-size: 13px; -} -#notification-centre-notifs { - background-color: alpha(#1c2328, 0.05); - min-height: 40px; - padding: 10px; - border: 1px solid #525155; - border-radius: 12px; -} - -#notification-centre-notifs:last-child { - margin-bottom: 10px; -} - -#notification-close-header { - margin-right: 10px; -} -#notification-close, -#notification-close-header { - min-width: 23px; - min-height: 23px; - border-radius: 50%; -} -#notification-close:hover { - background-color: alpha(#666, 0.8); - min-width: 23px; - min-height: 23px; - border-radius: 50%; -} -#notification-close-summery:hover .notification-close-header button { - background-color: transparent; -} diff --git a/styles/notification.css b/styles/notification.css deleted file mode 100644 index 35a7db0..0000000 --- a/styles/notification.css +++ /dev/null @@ -1,84 +0,0 @@ -#notification { - padding: 10px; - background-color: alpha(#fff, 0.03); - border-radius: 8px; - margin-top: 10px; - margin-right: 10px; - /* margin: 5px; */ -} - -#notification-close-button { - background-color: alpha(#999, 0.3); - border-radius: 8px; -} -#notification-close-button:hover { - background-color: alpha(#2369ff, 0.9); -} - -#notification-action-buttons button { - background-color: alpha(#999, 0.3); - padding: 4px; - border-radius: 7px; - transition: background-color 0.1s ease; -} - -#notification-action-buttons button:hover { - background-color: alpha(#2369ff, 0.9); -} - -#notification-action-buttons button:active { - background-color: var(--primary); -} - -#notification-action-buttons button:active #button-label { - color: var(--shadow); -} - -#notification-image image { - border-radius: 16px; - color: var(--on-surface); -} - -#notification-summary, -#button-label { -} - -#notification-summary { - font-weight: bold; - font-size: 16px; - /* color: var(--primary); */ -} - -#notification-app-name { - color: var(--outline); - font-weight: bold; -} - -#action-button { - margin-top: 8px; -} - -#notif-close-button { - background-color: var(--surface); - border-radius: 16px; - padding: 4px; - transition: background-color 0.1s ease; -} - -#notif-close-button:hover, -#notif-close-button:focus { - background-color: var(--surface-bright); -} - -#notif-close-button:active { - background-color: var(--red-dim); -} - -#notif-close-label { - color: var(--red-dim); - font-size: 20px; -} - -#notif-close-button:active #notif-close-label { - color: var(--shadow); -} diff --git a/styles/osd.css b/styles/osd.css deleted file mode 100644 index c4c2082..0000000 --- a/styles/osd.css +++ /dev/null @@ -1,38 +0,0 @@ -#osd { - background-color: alpha(#fff, 0.09); - padding: 12px 20px; - margin: 70px; - min-height: 200px; - border-radius: 16px; -} - -#osd scale { - min-width: 180px; -} - -#osd trough { - background: var(--surface); - min-height: 15px; - margin-right: 4px; - transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); - border-radius: 16px; -} - -#osd trough highlight { - border-radius: 100px; - background: var(--primary); - transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -#osd.muted trough highlight, -#osd.muted slider, -#osd scale.muted trough highlight, -#osd scale.muted slider { - background-color: var(--surface-bright); -} - -#brighntess-icons.muted, -#vol-icon.muted, -#mic-icon.muted { - color: var(--outline); -} diff --git a/styles/panel.css b/styles/panel.css deleted file mode 100644 index 218c358..0000000 --- a/styles/panel.css +++ /dev/null @@ -1,147 +0,0 @@ -#panel { - background-color: alpha(#fff, 0.07); - - /* border-bottom: 1px solid alpha(#010101, 0.025); */ - margin-top: -4px; - margin-bottom: -4px; - transition: all 120ms ease-in-out; -} - -#panel-icon { - font-size: 18px; -} -#global-title-button { - font-weight: bold; -} - -#panel-button { - margin: 0 4px 0 4px; - min-width: 20px; - min-height: 20px; -} - -#panel-button:hover { - background-color: alpha(#fff, 0.1); -} - -#menubar { - margin: 4px 0; -} - -#menubar label { - font-size: 13px; - /* font-weight: 400; */ -} -#battery-label { - font-weight: 500; - margin-right: 1px; -} -#battery-button { - padding-right: 3px; - padding-left: 3px; - border-radius: 8px; -} - -#battery-button:hover { - background-color: alpha(#fff, 0.1); -} - -#network-button { - padding-right: 3px; - padding-left: 3px; - border-radius: 8px; -} - -#network-button:hover { - background-color: alpha(#fff, 0.1); -} - -#bt-button { - padding-right: 3px; - padding-left: 3px; - border-radius: 8px; -} - -#bt-button:hover { - background-color: alpha(#fff, 0.1); -} - -#tray-button { - border-radius: 8px; - margin: 0 4px 0 0px; - min-width: 20px; - min-height: 20px; -} - -#tray-button:hover { - background-color: alpha(#fff, 0.1); -} - -#date-time { - margin: 0 10px; - font-weight: 500; -} - -#modules-left, -#modules-right { - margin: 0 5px; - padding: 3px 0; -} - -.button { - border-radius: 5px; - padding: 0 5px; - margin: 0 2.5px; - background: radial-gradient(alpha(#aaa, 0) 0%, transparent, transparent); - transition: all 100ms ease-in-out; -} - -.button:hover { - background: radial-gradient(alpha(#aaa, 0.2) 100%, transparent, transparent); -} - -#modus-button label { - font-size: 17px; - margin: 0 10px; -} - -#workspace-indicator { - margin: 0 4px; -} - -#workspaces label { - font-family: "SF Pro Rounded"; - color: white; /* fully transparent text */ -} -#workspaces > button { - padding-top: 0px; - padding-right: 16px; - font-family: "SF Pro Rounded"; - padding-left: 16px; - - margin: 6px 0px; - min-width: 12px; - border: 1px solid var(--outline); - border-radius: 8px; -} - -#workspaces > button:hover { - background-color: alpha(#fff, 0.1); -} - -#workspaces > button.active { - background-color: alpha(#fff, 0.9); - /* font-weight: bold; */ -} - -#workspaces > button.active label { - color: alpha(#000, 1); - font-family: "SF Pro Rounded"; -} -#workspaces > button.urgent { - background-color: alpha(var(--on-error), 0.7); -} - -#workspaces > button.empty { - background-color: transparent; -} diff --git a/styles/player.css b/styles/player.css deleted file mode 100644 index 1a9b651..0000000 --- a/styles/player.css +++ /dev/null @@ -1,178 +0,0 @@ -#player-stack-button { - border-radius: 5px; - min-width: 7px; - min-height: 7px; - margin: 10px 3px; -} - -#button-box-c { - /* margin-left: -10px; */ - - padding-right: 15px; -} - -#player-stack-button:hover { - background-color: #646464; - box-shadow: inset rgba(255, 255, 255, 0.5) 0 0 10px; -} - -#player-stack-button.active { - border-radius: 5px; - background-color: rgb(221.25, 221.25, 223.65); -} - -#outer-player-box { - min-height: 110px; - min-width: 320px; - border-radius: 10px; - margin: 0 6px; - background-color: alpha(#000, 0.3); - box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); -} - -#outer-player-box-c { - min-height: 55px; - min-width: 245px; - border-radius: 8px; - /* margin: 0 6px; */ - /* background-color: alpha(#000, 0.3); */ - /* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */ -} -#outer-no-player-box-c { - min-height: 55px; - min-width: 310px; - border-radius: 8px; - /* margin: 0 6px; */ - /* background-color: alpha(#000, 0.3); */ - /* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */ -} -#box-c { - background-color: alpha(#000, 0.08); - box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4); - border: 1px solid alpha(#111, 0.4); - border-radius: 12px; - margin-top: 0.5rem; - margin-left: 4px; - margin-right: 4px; - margin-bottom: 0.5rem; - - min-height: 50px; - min-width: 330px; - /* border-radius: 8px; */ - /* margin: 0 6px 10px; */ - /* border: 0.5px solid alpha(#000, 0.4); */ - /* background-color: alpha(#999, 0.1); */ - /* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */ -} -#player-box:disabled highlight, -#player-box:disabled progress { - background-color: rgba(205, 214, 244, 0.6); - background-image: none; -} - -#player-info-box-c { - margin-left: 10px; -} -#player-info-box { - margin-left: 10px; -} - -#player-title-c { - /* margin-top: 18px; */ - font-weight: bold; - font-size: 14px; -} -#player-title { - margin-top: 18px; - font-weight: 700; - font-size: 16px; -} -#player-title-no { - font-weight: 700; - margin-left: 120px; - font-size: 16px; -} -#player-app-icon { - margin-bottom: 10px; -} - -#player-artist-c { - margin-top: -5px; - color: #999; - font-size: 12px; -} -#player-artist, -#player-album { - font-weight: 500; - font-size: 12px; -} - -#player-controls { - margin-top: 5px; - margin-bottom: 15px; -} - -.player-icon { - font-size: 16px; -} - -.player-button { - padding: 1px; -} - -#player-box #player-controls #button-box .player-button:disabled { - color: #5f5f5f; -} - -#player-button { - opacity: 0.5; -} -#player-button:hover { - opacity: 0.9; -} - -#btn svg { - margin: 0 2px; -} -.album-image-c { - min-width: 50px; - min-height: 50px; - background-position: center; - margin-top: 10px; - margin-bottom: 10px; - background-size: cover; - margin-left: 10px; - border-radius: 5px; -} -.album-image { - min-width: 70px; - min-height: 70px; - background-position: center; - background-size: cover; - margin-left: 12px; - border-radius: 9px; -} - -#seek-bar slider { - background-image: none; - background-color: transparent; - padding: 0px; -} - -#seek-bar scale { - background-color: transparent; - margin-top: 10px; - border-radius: 10px; -} - -#seek-bar trough { - min-width: 10px; - border-radius: 99px; - background-color: alpha(#666, 0.5); - border: 1px solid alpha(#444, 0.3); -} - -#seek-bar highlight { - background: alpha(#fff, 0.8); - border-radius: 99px; -} diff --git a/styles/switcher.css b/styles/switcher.css deleted file mode 100644 index 5302281..0000000 --- a/styles/switcher.css +++ /dev/null @@ -1,38 +0,0 @@ -#application-switcher-container { - background: var(--shadow); - border-radius: 16px; -} - -#application-switcher-view { - min-height: 120px; - padding: 4px; -} - -#switcher-button { - padding: 4px; - min-width: 100px; -} - -#window-button { - background-color: var(--surface); - border-radius: 4px; -} - -#switcher-button:hover { - background-color: var(--surface); -} - -#window-button.active { - background-color: var(--surface-bright); - border: 3px solid var(--surface-bright); -} - -#switcher-button label { - color: var(--foreground); - font-size: 10px; - margin-top: 4px; -} - -#window-row { - padding: 18px; -} diff --git a/styles/todo.css b/styles/todo.css deleted file mode 100644 index a65ddf4..0000000 --- a/styles/todo.css +++ /dev/null @@ -1,258 +0,0 @@ -/* Todo List Styles - matching control center design */ - -#todo-list-window { - /* background-color: alpha(#fff, 0.05); */ - border-radius: 12px; - box-shadow: none; - margin: 0; -} - -#todo-main-container { - box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); - border-radius: 12px; - padding: 12px; /* Increased padding */ - min-height: 500px; /* Ensure minimum height */ - min-width: 350px; /* Ensure minimum width */ -} - -#todo-header { - margin-bottom: 8px; -} - -#todo-title { - font-size: 16px; - font-family: "SF Pro Rounded"; - font-weight: bold; - color: #ffffff; -} - -#todo-stats { - font-size: 12px; - font-weight: 500; - color: #999; - margin-top: -2px; -} - -/* Add new todo section */ -#todo-add-section { - box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4); - border: 1px solid alpha(#111, 0.4); - border-radius: 12px; - padding: 1rem; - margin-bottom: 8px; - min-height: 40px; /* Ensure minimum height */ -} - -#new-todo-entry { - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 6px; - padding: 8px 12px; - color: #ffffff; - font-size: 13px; - font-family: "SF Pro Rounded"; -} - -#new-todo-entry:focus { - border-color: #007aff; - box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3); - background-color: rgba(255, 255, 255, 0.15); -} - -#add-todo-button { - /* background-color: #007aff; */ - /* border: 1px solid #007aff; */ - border-radius: 6px; - min-width: 20px; - min-height: 12px; - color: #ffffff; - font-size: 16px; -} - -#add-todo-button:hover { - /* background-color: #0056cc; */ - /* border-color: #0056cc; */ -} - -/* Todo items container */ -#todos-scrolled { - background-color: transparent; - min-height: 200px; /* Minimum height for content */ -} - -#todos-container { - padding: 4px; -} - -/* Individual todo items */ -#todo-item { - background-color: alpha(#000, 0.2); /* More visible background */ - border: 1px solid alpha(#111, 0.4); - border-radius: 12px; - padding: 12px; - margin: 4px 0; - min-height: 40px; /* Ensure minimum height */ - transition: background-color 0.2s ease; -} - -#todo-item:hover { - background-color: alpha(#fff, 0.12); -} - -/* Todo item controls */ -#todo-checkbox { - background-color: transparent; - border-radius: 50%; /* Circular appearance */ - min-width: 20px; - min-height: 20px; - margin-right: 8px; -} - -#todo-checkbox:hover { - border-color: #007aff; -} - -/* SVG icon styling for checkboxes and buttons */ -#todo-checkbox-icon { - color: #007aff; -} - -#todo-edit-icon, -#todo-delete-icon, -#add-todo-icon { - color: #ffffff; - opacity: 0.8; -} - -#todo-edit-icon:hover, -#todo-delete-icon:hover, -#add-todo-icon:hover { - opacity: 1; -} - -#todo-text { - font-size: 13px; - font-weight: 400; - color: #ffffff; - font-family: "SF Pro Rounded"; -} - -#todo-date { - font-size: 10px; - font-weight: 400; - color: #999999; - font-family: "SF Pro Rounded"; - margin-top: 2px; - opacity: 0.8; -} - -.todo-date-text { - color: #999999; - font-size: 10px; -} - -#todo-text-entry { - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - padding: 4px 8px; - color: #ffffff; - font-size: 14px; - font-family: "SF Pro Rounded"; -} - -#todo-text-entry:focus { - border-color: #007aff; - box-shadow: 0 0 0 1px rgba(0, 122, 255, 0.3); - background-color: rgba(255, 255, 255, 0.15); -} - -/* Priority, edit, delete buttons */ -#todo-priority, -#todo-edit, -#todo-delete { - background-color: transparent; - border: none; - border-radius: 4px; - min-width: 20px; - min-height: 20px; - margin: 0 2px; - transition: background-color 0.2s ease; -} - -#todo-priority:hover, -#todo-edit:hover, -#todo-delete:hover { - background-color: alpha(#fff, 0.1); -} - -#todo-priority-label, -#todo-edit-label, -#todo-delete-label { - font-size: 12px; -} - -/* Clear completed button */ -#clear-completed-button { - background-color: alpha(#fff, 0.1); - border: 1px solid alpha(#999, 0.3); - border-radius: 8px; - padding: 8px 16px; - color: #999; - font-size: 12px; - font-weight: 500; - font-family: "SF Pro Rounded"; - margin-top: 8px; - transition: background-color 0.2s ease; -} - -#clear-completed-button:hover { - background-color: alpha(#fff, 0.15); - color: #ffffff; -} - -/* Completed todos styling */ -#todo-item.completed { - opacity: 0.6; -} - -#todo-item.completed #todo-text { - text-decoration: line-through; - color: #999; -} - -/* Priority styling */ -.priority-high #todo-priority-label { - color: #ff4444; -} - -.priority-medium #todo-priority-label { - color: #ffaa00; -} - -.priority-low #todo-priority-label { - color: #44ff44; -} - -/* Scrollbar styling to match control center */ -#todos-scrolled scrollbar { - background-color: transparent; - border-radius: 8px; - margin: 2px; - min-width: 8px; -} - -#todos-scrolled scrollbar slider { - background-color: alpha(#fff, 0.3); - border-radius: 4px; - min-width: 6px; - margin: 1px; -} - -#todos-scrolled scrollbar slider:hover { - background-color: alpha(#fff, 0.5); -} - -#todos-scrolled scrollbar slider:active { - background-color: alpha(#fff, 0.7); -} diff --git a/styles/tray.css b/styles/tray.css deleted file mode 100644 index d10b0ef..0000000 --- a/styles/tray.css +++ /dev/null @@ -1,50 +0,0 @@ -tooltip { - border: #474747 solid 1px; - border-radius: 0.75rem; - background-color: alpha(#fff, 0.05); -} - -@keyframes tooltipShow { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -tooltip > * { - padding: 3px 10px; - border-radius: 0.75rem; -} - -menu { - border: #474747 solid 1px; - border-radius: 0.75rem; - transition: all 100ms ease-in-out; - background-color: alpha(#fff, 0.05); -} - -menu > menuitem { - padding: 5px 10px; - margin: 1px 2px; - font-size: 13px; - border-bottom: #474747 solid 1px; -} - -menu > menuitem:last-child { - border-bottom: none; -} -menu > menuitem:hover { - transition: all 20ms ease-in-out; - font-size: 13px; - border-radius: 4px; - color: #222; - background-color: alpha(#2369ff, 1); -} - -menu > menuitem:hover > label { - color: #fff; - font-weight: 400; -} diff --git a/styles/widgets.css b/styles/widgets.css deleted file mode 100644 index 8656200..0000000 --- a/styles/widgets.css +++ /dev/null @@ -1,218 +0,0 @@ -/* Weather Widget CSS */ -#weather-container { - background: linear-gradient(to bottom, #202020, #141414); - margin: 5px 5px 20px 5px; - min-width: 170px; - min-height: 170px; - border-radius: 16px; - transition: background 0.8s ease-in-out; -} - -#weather-widget label { - margin-left: 12px; -} - -#city { - margin-top: 10px; - color: var(--blue-dim); - font-weight: 600; - font-size: 20px; -} -#temperature { - font-size: 50px; - font-weight: 400; -} - -#condition-emoji { - margin-top: 5px; - font-size: 16px; -} - -#condition { - font-size: 15px; - font-weight: 600; -} -#feels-like { - font-weight: 600; - font-size: 15px; -} -/* # */ - -/* Calendar Widget */ -#calendar-box-widget { - background: linear-gradient(to bottom, #202020, #141414); - margin: 5px 20px 20px 10px; - min-width: 170px; - min-height: 170px; - border-radius: 16px; -} - -#calendar-widget { - margin: 5px 10px 0px 10px; -} - -#calendar-month { - font-size: 16px; - font-weight: 600; - color: var(--blue-dim); - margin-bottom: 6px; -} - -#calendar-days-header { - margin-bottom: 4px; -} - -#calendar-day-header { - font-size: 12px; - font-weight: 600; - color: #ffffff; - min-width: 18px; - min-height: 10px; -} - -#calendar-day-header-weekend { - font-size: 12px; - font-weight: 600; - color: #8e8d93; - min-width: 18px; - min-height: 10px; -} - -#calendar-day { - font-size: 12px; - margin: 1.4px 0px 1.4px 0px; - font-weight: 500; - color: #ffffff; - min-width: 18px; - min-height: 10px; -} - -#calendar-day-weekend { - font-size: 12px; - font-weight: 500; - color: #8e8d93; - min-width: 18px; - min-height: 16px; -} - -#calendar-day-today { - font-size: 12px; - font-weight: 700; - color: #191919; - background-color: var(--blue-dim); - border-radius: 50%; - min-width: 18px; - min-height: 16px; - padding: 2px; -} - -#calendar-day-empty { - min-width: 18px; - min-height: 16px; -} - -#calendar-grid { - margin-top: 2px; -} - -/* Date Widget CSS */ -#date-widget label { - margin-top: 10px; -} -#day label, -#month label { - color: var(--foreground); - padding: 5px; - margin-top: 10px; - font-size: 30px; - color: alpha(#fff, 0.9); - font-weight: 600; -} - -#date label { - color: alpha(#fff, 0.9); - - margin-top: -10px; - font-size: 90px; - font-weight: 600; -} -#day label { - color: alpha(var(--blue-dim), 0.9); -} - -#date-container { - background: linear-gradient(to bottom, #202020, #141414); - margin: 5px 10px 20px 5px; - min-width: 170px; - min-height: 170px; - border-radius: 16px; -} - -/* System Info Widget CSS */ -#info-box-widget { - background: linear-gradient(to bottom, #202020, #141414); - margin: 5px 5px 5px 5px; - min-width: 170px; - min-height: 170px; - border-radius: 16px; -} -#ram-progress { - margin-left: 12px; - color: var(--foreground); -} -#progress-label { - font-weight: 600; - font-size: 13px; -} -#progress { - border: solid 8px var(--blue-dim); - color: alpha(#aaaaaa, 0.7); -} - -/* Color indicators for memory usage */ -#used-color-indicator { - color: var(--blue-dim); - font-size: 10px; - font-weight: 900; -} - -#free-color-indicator { - color: #7c7c7c; - font-size: 10px; - font-weight: 900; -} - -#temp-color-indicator { - color: var(--blue-dim); - font-size: 10px; - font-weight: 900; -} - -/* Info text styling */ -#info-text { - font-size: 13px; - font-weight: 500; - color: #8e8e8e; - min-width: 32px; -} - -#info-value { - font-size: 12px; - font-weight: 600; - color: #ffffff; -} - -#info-container { - margin-left: -8px; - margin-top: 5px; -} - -#info { - margin-left: -13px; - margin-top: 5px; - font-size: 14px; - font-weight: 600; -} -#component-name { - font-size: 10px; -} diff --git a/utils/__init__.py b/utils/__init__.py deleted file mode 100644 index 3f03f12..0000000 --- a/utils/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Modus services package. -Contains background services and utilities for the shell. -""" diff --git a/utils/animator.py b/utils/animator.py deleted file mode 100644 index 7dd063d..0000000 --- a/utils/animator.py +++ /dev/null @@ -1,180 +0,0 @@ -from typing import cast - -from gi.repository import GLib, Gtk - -from fabric import Property, Service, Signal - - -class Animator(Service): - @Signal - def finished(self) -> None: ... - - @Property(tuple[float, float, float, float], "read-write") - def bezier_curve(self) -> tuple[float, float, float, float]: - return self._bezier_curve - - @bezier_curve.setter - def bezier_curve(self, value: tuple[float, float, float, float]): - self._bezier_curve = value - return - - @Property(float, "read-write") - def value(self): - return self._value - - @value.setter - def value(self, value: float): - self._value = value - return - - @Property(float, "read-write") - def max_value(self): - return self._max_value - - @max_value.setter - def max_value(self, value: float): - self._max_value = value - return - - @Property(float, "read-write") - def min_value(self): - return self._min_value - - @min_value.setter - def min_value(self, value: float): - self._min_value = value - return - - @Property(bool, "read-write", default_value=False) - def playing(self): - return self._playing - - @playing.setter - def playing(self, value: bool): - self._playing = value - return - - @Property(bool, "read-write", default_value=False) - def repeat(self): - return self._repeat - - @repeat.setter - def repeat(self, value: bool): - self._repeat = value - return - - def __init__( - self, - bezier_curve: tuple[float, float, float, float], - duration: float, - min_value: float = 0.0, - max_value: float = 1.0, - repeat: bool = False, - tick_widget: Gtk.Widget | None = None, - **kwargs, - ): - super().__init__(**kwargs) - self._bezier_curve = (1, 0, 1, 1) - self._duration = 5 - self._value = 0.0 - self._min_value = 0.0 - self._max_value = 1.0 - self._repeat = False - - self.bezier_curve = bezier_curve - self.duration = duration - self.value = min_value - self.min_value = min_value - self.max_value = max_value - self.repeat = repeat - - self.playing = False - self._start_time = None - self._tick_handler = None - self._timeline_pos = 0 - self._tick_widget = tick_widget - - def do_get_time_now(self): - return GLib.get_monotonic_time() / 1_000_000 - - def do_lerp(self, start: float, end: float, time: float) -> float: - return start + (end - start) * time - - def do_interpolate_cubic_bezier(self, time: float) -> float: - y_points = (0, self.bezier_curve[1], self.bezier_curve[3], 1) - return ( - (1 - time) ** 3 * y_points[0] - + 3 * (1 - time) ** 2 * time * y_points[1] - + 3 * (1 - time) * time**2 * y_points[2] - + time**3 * y_points[3] - ) - - def do_ease(self, time: float) -> float: - return self.do_lerp( - self.min_value, self.max_value, self.do_interpolate_cubic_bezier(time) - ) - - def do_update_value(self, delta_time: float): - if not self.playing: - return - - elapsed_time = delta_time - cast(float, self._start_time) - - self._timeline_pos = min(1, elapsed_time / self.duration) - - self.value = self.do_ease(self._timeline_pos) - - if not self._timeline_pos >= 1: - return - - if not self.repeat: - self.value = self.max_value - self.finished() - self.pause() - return - - self._start_time = delta_time - self._timeline_pos = 0 - return - - def do_handle_tick(self, *_): - current_time = self.do_get_time_now() - self.do_update_value(current_time) - return True - - def do_remove_tick_handlers(self): - if self._tick_handler: - if self._tick_widget: - self._tick_widget.remove_tick_callback(self._tick_handler) - else: - GLib.source_remove(self._tick_handler) - self._tick_handler = None - return - - def play(self): - if self.playing: - return - - self._start_time = self.do_get_time_now() - - if not self._tick_handler: - if self._tick_widget: - self._tick_handler = self._tick_widget.add_tick_callback( - self.do_handle_tick - ) - else: - self._tick_handler = GLib.timeout_add(16, self.do_handle_tick) - - self.playing = True - return - - def pause(self): - self.playing = False - return self.do_remove_tick_handlers() - - def stop(self): - if not self._tick_handler: - self._timeline_pos = 0 - self.playing = False - return - return self.do_remove_tick_handlers() diff --git a/utils/app_name_resolver.py b/utils/app_name_resolver.py deleted file mode 100644 index 13b76df..0000000 --- a/utils/app_name_resolver.py +++ /dev/null @@ -1,93 +0,0 @@ -import os -from utils.roam import modus_service - - -class AppName: - def __init__(self, path="/usr/share/applications"): - self.files = os.listdir(path) - self.path = path - - def get_app_name(self, wmclass, format_=False): - desktop_file = "" - for f in self.files: - if f.startswith(wmclass + ".desktop"): - desktop_file = f - - desktop_app_name = wmclass - - if desktop_file == "": - return wmclass - with open(os.path.join(self.path, desktop_file), "r") as f: - lines = f.readlines() - for line in lines: - if line.startswith("Name="): - desktop_app_name = line.split("=")[1].strip() - break - return desktop_app_name - - def get_app_exec(self, wmclass, format_=False): - desktop_file = "" - for f in self.files: - if f.startswith(wmclass + ".desktop"): - desktop_file = f - - desktop_app_name = wmclass - - if desktop_file == "": - return wmclass - with open(os.path.join(self.path, desktop_file), "r") as f: - lines = f.readlines() - for line in lines: - if line.startswith("Exec="): - desktop_app_name = line.split("=")[1].strip() - break - return desktop_app_name - - def get_desktop_file(self, wmclass): - desktop_file = "" - for f in self.files: - if f.startswith(wmclass + ".desktop"): - desktop_file = f - return desktop_file - - def format_app_name(self, title, wmclass, update=False): - # Handle case when both title and wmclass are empty (no active window) - if not title and not wmclass: - name = "Finder" - else: - name = wmclass - if name == "": - name = title - - # Try to get the proper app name from desktop file only if wmclass is not empty - if wmclass: - name = self.get_app_name(wmclass=wmclass) - - # Smart title formatting (capitalize first letter) - name = str(name).title() - if "." in name: - name = name.split(".")[-1] - - if update: - modus_service.current_active_app_name = name - return name - - -# Create a global instance for use across modules -app_name_resolver = AppName() - - -def format_window(title, wmclass): - # Handle the case when HyprlandActiveWindow passes "unknown" instead of empty strings - if (not title or title == "unknown") and (not wmclass or wmclass == "unknown"): - return "Finder" - - # Clean up "unknown" values - if title == "unknown": - title = "" - if wmclass == "unknown": - wmclass = "" - - name = app_name_resolver.format_app_name(title, wmclass, True) - return name - diff --git a/utils/conversion.py b/utils/conversion.py deleted file mode 100644 index c39b4da..0000000 --- a/utils/conversion.py +++ /dev/null @@ -1,620 +0,0 @@ -import threading -import time -from concurrent.futures import ThreadPoolExecutor -from typing import Dict, Optional, Tuple - -import requests - - -class CurrencyCache: - """Thread-safe currency exchange rate cache with background updates.""" - - def __init__(self): - # {base_currency: {rates_data, timestamp}} - self._cache: Dict[str, Dict] = {} - self._cache_lock = threading.Lock() - self._cache_ttl = 3600 # 1 hour in seconds - self._request_timeout = 2 # 2 seconds for faster response - self._executor = ThreadPoolExecutor( - max_workers=2, thread_name_prefix="currency" - ) - self._pending_requests: Dict[str, threading.Event] = {} - - def get_rate(self, from_code: str, to_code: str) -> Optional[Tuple[float, float]]: - """ - Get exchange rate from cache or fetch if needed. - Returns (rate, cache_age_seconds) or None if unavailable. - """ - from_lower = from_code.lower() - to_lower = to_code.lower() - - if from_lower == to_lower: - return 1.0, 0.0 - - current_time = time.time() - - with self._cache_lock: - # Check if we have fresh cached data - if from_lower in self._cache: - cache_entry = self._cache[from_lower] - cache_age = current_time - cache_entry["timestamp"] - - if cache_age < self._cache_ttl and to_lower in cache_entry["rates"]: - rate = cache_entry["rates"][to_lower]["rate"] - return rate, cache_age - - # Check if we're already fetching this currency - if from_lower in self._pending_requests: - # Don't wait, return cached data if available (even if stale) - if ( - from_lower in self._cache - and to_lower in self._cache[from_lower]["rates"] - ): - cache_entry = self._cache[from_lower] - cache_age = current_time - cache_entry["timestamp"] - rate = cache_entry["rates"][to_lower]["rate"] - return rate, cache_age - return None - - # Start background fetch - self._pending_requests[from_lower] = threading.Event() - - # Submit background task to fetch rates - self._executor.submit(self._fetch_rates_background, from_lower) - - # Return stale cached data if available - with self._cache_lock: - if ( - from_lower in self._cache - and to_lower in self._cache[from_lower]["rates"] - ): - cache_entry = self._cache[from_lower] - cache_age = current_time - cache_entry["timestamp"] - rate = cache_entry["rates"][to_lower]["rate"] - return rate, cache_age - - return None - - def _fetch_rates_background(self, from_code: str): - """Fetch exchange rates in background thread.""" - try: - url = f"https://www.floatrates.com/daily/{from_code}.json" - response = requests.get(url, timeout=self._request_timeout) - - if response.status_code == 200: - rates_data = response.json() - current_time = time.time() - - with self._cache_lock: - self._cache[from_code] = { - "rates": rates_data, - "timestamp": current_time, - } - - except Exception as e: - print(f"Background currency fetch failed for {from_code}: {e}") - finally: - # Mark request as complete - with self._cache_lock: - if from_code in self._pending_requests: - self._pending_requests[from_code].set() - del self._pending_requests[from_code] - - def cleanup(self): - """Cleanup resources.""" - self._executor.shutdown(wait=False) - with self._cache_lock: - self._cache.clear() - self._pending_requests.clear() - - -# Global currency cache instance -_currency_cache = CurrencyCache() - - -class Units: - def __init__(self): - self.WEIGHT_CHART: dict[str, tuple[float, float]] = { - "kilogram": (1, 1), - "kg": (1, 1), - "tonne": (1000, 0.001), - "ton": (1000, 0.001), - "gram": (1e-3, 1e3), - "g": (1e-3, 1e3), - "milligram": (1e-6, 1e6), - "mg": (1e-6, 1e6), - "metric-ton": (1000, 0.001), - "metric-tonne": (1000, 0.001), - "long-ton": (1016.04608, 0.0009842073), - "short-ton": (907.184, 0.0011023122), - "pound": (0.453592, 2.2046244202), - "lb": (0.453592, 2.2046244202), - "stone": (6.35029, 0.1574731728), - "st": (6.35029, 0.1574731728), - "ounce": (0.0283495, 35.273990723), - "oz": (0.0283495, 35.273990723), - "carrat": (0.0002, 5000), - "ct": (0.0002, 5000), - "atomic-mass-unit": (1.660540199e-27, 6.022136652e26), - } - - self.LENGTH_CHART: dict[str, float] = { - # meter - "m": 1, - "M": 1, - "meter": 1, - # kilometer - "km": 1e3, - "KM": 1e3, - "kilometer": 1e3, - # centimeter - "cm": 1e-2, - "CM": 1e-2, - "centimeter": 1e-2, - # millimeter - "mm": 1e-3, - "MM": 1e-3, - "millimeter": 1e-3, - # micrometer - "um": 1e-6, - "UM": 1e-6, - "micrometer": 1e-6, - # nanometer - "nm": 1e-9, - "NM": 1e-9, - "nanometer": 1e-9, - # mile - "mi": 1609.344, - "MI": 1609.344, - "mile": 1609.344, - # yard - "yd": 0.9144, - "YD": 0.9144, - "yard": 0.9144, - # foot - "ft": 0.3048, - "FT": 0.3048, - "foot": 0.3048, - "feet": 0.3048, - # inch - "in": 0.0254, - "IN": 0.0254, - "inch": 0.0254, - "inches": 0.0254, - # nautical mile - "nmi": 1852, - "NMI": 1852, - "nautical-mile": 1852, - } - - self.STORAGE_TYPE_CHART: dict[str, float] = { - "bit": 1, - "byte": 8, - "B": 8, - "kilobyte": 8192, - "KB": 8192, - "megabyte": 8388608, - "MB": 8388608, - "gigabyte": 8589934592, - "GB": 8589934592, - "terabyte": 8796093022208, - "TB": 8796093022208, - "petabyte": 9007199254740992, - "PB": 9007199254740992, - "exabyte": 9223372036854775808, - "EB": 9223372036854775808, - } - - self.TEMPERATURE_CHART = { - "celsius": (lambda v: v + 273.15, lambda v: v - 273.15), - "c": (lambda v: v + 273.15, lambda v: v - 273.15), - "fahrenheit": ( - lambda v: (v - 32) * 5 / 9 + 273.15, - lambda v: (v - 273.15) * 9 / 5 + 32, - ), - "f": ( - lambda v: (v - 32) * 5 / 9 + 273.15, - lambda v: (v - 273.15) * 9 / 5 + 32, - ), - "kelvin": (lambda v: v, lambda v: v), - "k": (lambda v: v, lambda v: v), - "rankine": (lambda v: v * 5 / 9, lambda v: v * 9 / 5), - "reaumur": (lambda v: v * 5 / 4 + 273.15, lambda v: (v - 273.15) * 4 / 5), - } - - self.TIME_CHART: dict[str, float] = { - "second": 1, - "s": 1, - "minute": 60, - "min": 60, - "m": 60, - "hour": 3600, - "h": 3600, - "milisecond": 1e-3, - "ms": 1e-3, - "day": 86400, - "d": 86400, - "week": 604800, - "w": 604800, - "fortnight": 1209600, - "month": 2628000, # Approximation (30.44 days) - "mo": 2628000, # Approximation (30.44 days) - "year": 31536000, # Approximation (365 days) - "yr": 31536000, # Approximation (365 days) - "decade": 315360000, # Approximation (10 years) - "dec": 315360000, # Approximation (10 years) - "century": 3153600000, # Approximation (100 years) - "cent": 3153600000, # Approximation (100 years) - "millennium": 31536000000, # Approximation (1000 years) - "millenia": 31536000000, # Approximation (1000 years) - } - - self.LIQUID_VOLUME_CHART: dict[str, float] = { - "liter": 1, - "l": 1, - "milliliter": 1e-3, - "ml": 1e-3, - "gallon": 3.78541, - "quart": 0.946353, - "pint": 0.473176, - "fluid-ounce": 0.0295735, - "fl-oz": 0.0295735, - "oz": 0.0295735, - "ounce": 0.0295735, - "cup": 0.236588, - "tablespoon": 0.0147868, - "tbsp": 0.0147868, - "teaspoon": 0.00492892, - "tsp": 0.00492892, - } - - self.ANGLE_CHART: dict[str, float] = { - "degree": 1, - "deg": 1, - "radian": 57.2958, - "rad": 57.2958, - "gradian": 0.9, - "gon": 0.9, - } - - self.ENERGY_CHART: dict[str, float] = { - "joule": 1, - "j": 1, - "kilojoule": 1000, - "kj": 1000, - "calorie": 4.184, - "cal": 4.184, - "kilocalorie": 4184, - "kcal": 4184, - "watt-hour": 3600, - "wh": 3600, - "kilowatt-hour": 3.6e6, - "kwh": 3.6e6, - } - - self.SPEED_CHART: dict[str, float] = { - "mps": 1, - "kmph": 0.277778, - "mph": 0.44704, - "fps": 0.3048, - "knot": 0.514444, - } - - self.PRESSURE_CHART: dict[str, float] = { - "pascal": 1, - "Pa": 1, - "bar": 100000, - "atm": 101325, - "torr": 133.322, - "mmHg": 133.322, - "psi": 6894.76, - } - - self.FORCE_CHART: dict[str, float] = { - "newton": 1, - "N": 1, - "kilonewton": 1000, - "kN": 1000, - "pound-force": 4.44822, - "lbf": 4.44822, - "dyne": 1e-5, - } - - self.POWER_CHART: dict[str, float] = { - "watt": 1, - "W": 1, - "kilowatt": 1000, - "kW": 1000, - "horsepower": 745.7, - "hp": 745.7, - "megawatt": 1e6, - "MW": 1e6, - } - - self.VOLTAGE_CHART: dict[str, float] = { - "volt": 1, - "V": 1, - "millivolt": 1e-3, - "mV": 1e-3, - "kilovolt": 1000, - "kV": 1000, - "megavolt": 1e6, - "MV": 1e6, - } - - self.CURRENT_CHART: dict[str, float] = { - "ampere": 1, - "A": 1, - "milliampere": 1e-3, - "mA": 1e-3, - "microampere": 1e-6, - "ฮผA": 1e-6, - } - - self.RESISTANCE_CHART: dict[str, float] = { - "ohm": 1, - "ฮฉ": 1, - "kilohm": 1000, - "kฮฉ": 1000, - "megohm": 1e6, - "Mฮฉ": 1e6, - } - - self.CAPACITANCE_CHART: dict[str, float] = { - "farad": 1, - "F": 1, - "millifarad": 1e-3, - "mF": 1e-3, - "microfarad": 1e-6, - "ฮผF": 1e-6, - "nanofarad": 1e-9, - "nF": 1e-9, - } - - self.INDUCTANCE_CHART: dict[str, float] = { - "henry": 1, - "H": 1, - "millihenry": 1e-3, - "mH": 1e-3, - "microhenry": 1e-6, - "ฮผH": 1e-6, - "nanohenry": 1e-9, - "nH": 1e-9, - } - - self.FREQUENCY_CHART: dict[str, float] = { - "hertz": 1, - "Hz": 1, - "kilohertz": 1e3, - "kHz": 1e3, - "megahertz": 1e6, - "MHz": 1e6, - "gigahertz": 1e9, - "GHz": 1e9, - } - - self.LUMINANCE_CHART: dict[str, float] = { - "candela": 1, - "cd": 1, - "lumen": 1, - "lm": 1, - "lux": 1, - "lx": 1, - } - - self.AREA_CHART: dict[str, float] = { - "square-meter": 1, - "m2": 1, - "square-kilometer": 1e6, - "km2": 1e6, - "hectare": 1e4, - "ha": 1e4, - "are": 1e2, - "a": 1e2, - "square-centimeter": 1e-4, - "cm2": 1e-4, - "square-millimeter": 1e-6, - "mm2": 1e-6, - } - - # We no longer use currency_converter here. - - -class Conversion: - def __init__(self): - self.units = Units() - self.currency_cache = _currency_cache - - def convert(self, value: float, from_type: str, to_type: str): - """ - Generalized conversion function that works with all categories, - including currency via floatrates.com. - """ - # Collection of all non-currency charts - charts = { - "WEIGHT_CHART": self.units.WEIGHT_CHART, - "LENGTH_CHART": self.units.LENGTH_CHART, - "TEMPERATURE_CHART": self.units.TEMPERATURE_CHART, - "TIME_CHART": self.units.TIME_CHART, - "LIQUID_VOLUME_CHART": self.units.LIQUID_VOLUME_CHART, - "STORAGE_TYPE_CHART": self.units.STORAGE_TYPE_CHART, - "ANGLE_CHART": self.units.ANGLE_CHART, - "ENERGY_CHART": self.units.ENERGY_CHART, - "SPEED_CHART": self.units.SPEED_CHART, - "PRESSURE_CHART": self.units.PRESSURE_CHART, - "FORCE_CHART": self.units.FORCE_CHART, - "POWER_CHART": self.units.POWER_CHART, - "VOLTAGE_CHART": self.units.VOLTAGE_CHART, - "CURRENT_CHART": self.units.CURRENT_CHART, - "RESISTANCE_CHART": self.units.RESISTANCE_CHART, - "CAPACITANCE_CHART": self.units.CAPACITANCE_CHART, - "INDUCTANCE_CHART": self.units.INDUCTANCE_CHART, - "FREQUENCY_CHART": self.units.FREQUENCY_CHART, - "LUMINANCE_CHART": self.units.LUMINANCE_CHART, - "AREA_CHART": self.units.AREA_CHART, - } - - # 1) Check if it's in any of the charts (non-currency) - for chart_name, chart in charts.items(): - if from_type in chart and to_type in chart: - # Temperatures use lambdas - if chart_name == "TEMPERATURE_CHART": - if from_type == to_type: - return value - to_kelvin = chart[from_type][0] - from_kelvin = chart[to_type][1] - return from_kelvin(to_kelvin(value)) - - # Handle WEIGHT_CHART separately (tuple values) - if chart_name == "WEIGHT_CHART": - if from_type == to_type: - return value - to_kg = chart[from_type][0] - from_kg = chart[to_type][1] - return value * to_kg * from_kg - - # Any other numeric chart - if from_type == to_type: - return value - return value * (chart[from_type] / chart[to_type]) - - # 2) If both are currency codes (e.g. โ€œUSDโ€, โ€œARSโ€) - # we assume they are uppercase and have 3 letters. - if ( - len(from_type) == 3 - and len(to_type) == 3 - and from_type.isalpha() - and to_type.isalpha() - ): - result = self._convert_currency_fast(value, from_type, to_type) - if result is not None: - return result - # Fallback to slow method if fast method fails - return self._convert_currency_via_floatrates(value, from_type, to_type) - - # 3) If it doesn't fall into any case, error. - raise ValueError(f"Unsupported conversion: {from_type} to {to_type}") - - def _convert_currency_fast( - self, value: float, from_code: str, to_code: str - ) -> Optional[float]: - """ - Fast currency conversion using cached exchange rates. - Returns None if rate is not available in cache. - """ - rate_info = self.currency_cache.get_rate(from_code, to_code) - if rate_info is not None: - rate, _ = rate_info # cache_age not needed here - return value * rate - return None - - def _convert_currency_via_floatrates( - self, value: float, from_code: str, to_code: str - ) -> float: - """ - Converts using the JSON from floatrates.com: - - Makes GET to https://www.floatrates.com/daily/{from_lower}.json - - Takes the rate from the to_lower key and multiplies. - """ - from_lower = from_code.lower() - to_lower = to_code.lower() - - if from_lower == to_lower: - return value - - url = f"https://www.floatrates.com/daily/{from_lower}.json" - resp = requests.get(url, timeout=5) - if resp.status_code != 200: - raise ValueError(f"Error getting data from floatrates for {from_code}") - - data = resp.json() - if to_lower not in data: - raise ValueError( - f"Target currency '{to_code}' not found in floatrates response for '{ - from_code - }'" - ) - - rate = data[to_lower]["rate"] - return value * rate - - def parse_input_and_convert(self, input: str): - parts = input.split() - addition = "s" if parts[-1].endswith("s") else "" - - if "and" in parts: # value unit1 and value2 unit2 _ to target_unit - parts.remove("and") - if len(parts) != 6: - raise ValueError( - "Invalid format. Expected: 'value from_type and value2 from_type2 _ to_type'" - ) - - value1, from_type1, value2, from_type2, _, to_type = parts - value1, value2 = float(value1), float(value2) - from_type1 = self.clean_type(from_type1) - from_type2 = self.clean_type(from_type2) - to_type = self.clean_type(to_type) - - if from_type1 == from_type2: - return ( - self.convert(value1 + value2, from_type1, to_type), - to_type + addition, - ) - else: - res = 0 - res += self.convert(value1, from_type1, to_type) - res += self.convert(value2, from_type2, to_type) - return res, to_type + addition - else: - if len(parts) != 4: - raise ValueError( - "Invalid format. Expected: 'value from_type _ to_type'" - ) - value, from_type, _, to_type = parts - value = float(value) - from_type = self.clean_type(from_type) - to_type = self.clean_type(to_type) - return self.convert(value, from_type, to_type), to_type + addition - - def clean_type(self, type: str) -> str: - """ - If it's currency (3 letters), convert to uppercase. - If it ends in 's' (and is not 'celsius'), remove the 's' for - other units.""" - if len(type) == 3 and type.isalpha(): - return type.upper() - if type.endswith("s") and type.lower() != "celsius": - # For tables that have singular/plural - singular = type[:-1].lower() - # If it exists in STORAGE_TYPE_CHART, we use it; - # if not, we return singular in lowercase for other charts. - if singular in self.units.STORAGE_TYPE_CHART: - return singular - return singular.lower() - return type - - def cleanup(self): - """Cleanup resources.""" - self.currency_cache.cleanup() - - def get_currency_cache_info( - self, from_code: str, to_code: str - ) -> Optional[Tuple[bool, float]]: - """ - Get currency cache information for UI display. - Returns (is_fresh, cache_age_seconds) or None if not cached. - """ - rate_info = self.currency_cache.get_rate(from_code, to_code) - if rate_info is not None: - _, cache_age = rate_info # rate not needed here - is_fresh = cache_age < 300 # Consider fresh if less than 5 minutes old - return is_fresh, cache_age - return None - - -# Quick usage example: -if __name__ == "__main__": - conv = Conversion() - # Convert 10 USD to ARS: - result, suffix = conv.parse_input_and_convert("10 USD _ ARS") - print(f"{result:.2f} {suffix}") # Ex: "10 USD _ ARS" -> "38754.23 ARS" diff --git a/utils/functions.py b/utils/functions.py deleted file mode 100644 index edbf259..0000000 --- a/utils/functions.py +++ /dev/null @@ -1,174 +0,0 @@ -import html -import json -import os -import threading -from typing import Dict, List, Optional - -from loguru import logger - -# Threading helper functions - - -def thread(target, *args, **kwargs) -> threading.Thread: - """ - Simply run the given function in a thread. - The provided args and kwargs will be passed to the function. - """ - th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True) - th.start() - return th - - -def run_in_thread(func): - """ - Decorator to run the decorated function in a thread. - """ - - def wrapper(*args, **kwargs): - return thread(func, *args, **kwargs) - - return wrapper - - -@run_in_thread -def write_json_file(data: Dict, path: str): - try: - with open(path, "w") as f: - json.dump(data, f, indent=4, ensure_ascii=False) - except Exception as e: - logger.warning(f"Failed to write json: {e}") - - -def read_json_file(file_path: str) -> Optional[List]: - if not os.path.exists(file_path): - logger.error(f"JSON file {file_path} does not exist.") - return None - - with open(file_path, "r") as file: - try: - return json.load(file) - except json.JSONDecodeError as e: - logger.error(f"Failed to read JSON file {file_path}: {e}") - return None - - -def get_wifi_icon_for_strength(strength: int) -> str: - """ - Get the appropriate WiFi icon based on signal strength. - - Args: - strength: Signal strength from 0-100 - - Returns: - Absolute path to the appropriate WiFi icon - """ - # Get the current directory where this script is located - current_dir = os.path.dirname(os.path.abspath(__file__)) - # Get the project root (parent of utils directory) - project_root = os.path.dirname(current_dir) - - if strength >= 80: - icon_name = "network-wireless-100.svg" - elif strength >= 60: - icon_name = "network-wireless-80.svg" - elif strength >= 40: - icon_name = "network-wireless-60.svg" - elif strength >= 20: - icon_name = "network-wireless-40.svg" - elif strength > 0: - icon_name = "network-wireless-20.svg" - else: - icon_name = "network-wireless-0.svg" - - return os.path.join(project_root, "config", "assets", "icons", "wifi", icon_name) - - -def get_wifi_connecting_icon() -> str: - """ - Get the WiFi connecting icon path. - - Returns: - Absolute path to the WiFi connecting icon - """ - # Get the current directory where this script is located - current_dir = os.path.dirname(os.path.abspath(__file__)) - # Get the project root (parent of utils directory) - project_root = os.path.dirname(current_dir) - - return os.path.join(project_root, "config", "assets", "icons", "wifi", "wifi-connecting.svg") - - -def is_special_workspace_id(ws_id) -> bool: - """ - Check if a workspace ID represents a special workspace. - - Args: - ws_id: Workspace ID (can be int, string, or other types) - - Returns: - True if the workspace is special, False otherwise - """ - try: - # Convert to int if it's a string - workspace_id = int(ws_id) - # Special workspaces have negative IDs - return workspace_id < 0 - except (ValueError, TypeError): - # If it's a string, check if it starts with "special:" - if isinstance(ws_id, str) and ws_id.startswith("special:"): - return True - return False - - -def is_special_workspace(client: dict) -> bool: - """ - Check if a client is in a special workspace. - - Args: - client: Client data dictionary from Hyprland - - Returns: - True if the client is in a special workspace, False otherwise - """ - if "workspace" not in client: - return False - - workspace = client["workspace"] - - # Check workspace name first - if "name" in workspace: - workspace_name = str(workspace["name"]) - # Special workspaces typically start with "special:" or have negative IDs - if workspace_name.startswith("special:"): - return True - - # Check workspace ID - if "id" in workspace: - workspace_id = workspace["id"] - # Special workspaces have negative IDs - if workspace_id < 0: - return True - - return False - - -def escape_markup_text(text: str) -> str: - """ - Escape special characters in text to make it safe for Pango markup. - - Args: - text: Raw text that may contain special characters - - Returns: - Escaped text safe for use in Pango markup - """ - if not text or not isinstance(text, str): - return "" - - # Use html.escape to escape XML/HTML special characters - # This handles &, <, >, and quotes - return html.escape(text) - - - - diff --git a/utils/icon_resolver.py b/utils/icon_resolver.py deleted file mode 100644 index 58ac1e4..0000000 --- a/utils/icon_resolver.py +++ /dev/null @@ -1,106 +0,0 @@ -import json -import os -import re - -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository import GLib, Gtk -from loguru import logger - -import config.data as data - -ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json" -if not os.path.exists(data.CACHE_DIR): - os.makedirs(data.CACHE_DIR) - - -class IconResolver: - def __init__( - self, default_applicaiton_icon: str = "application-x-executable-symbolic" - ): - if os.path.exists(ICON_CACHE_FILE): - with open(ICON_CACHE_FILE) as f: - try: - self._icon_dict = json.load(f) - except json.JSONDecodeError: - logger.info("[ICONS] Cache file does not exist or is corrupted") - self._icon_dict = {} - else: - self._icon_dict = {} - - self.default_applicaiton_icon = default_applicaiton_icon - - def get_icon_name(self, app_id: str): - if app_id in self._icon_dict: - return self._icon_dict[app_id] - new_icon = self._compositor_find_icon(app_id) - logger.info( - f"[ICONS] found new icon: '{new_icon}' for app id: '{app_id}', storing..." - ) - self._store_new_icon(app_id, new_icon) - return new_icon - - def get_icon_pixbuf(self, app_id: str, size: int = 16): - icon_theme = Gtk.IconTheme.get_default() - icon_name = self.get_icon_name(app_id) - try: - # Try to load the resolved icon. - return icon_theme.load_icon(icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE) - except GLib.Error as primary_error: - logger.warning( - f"Warning: Icon '{icon_name}' not found in theme. Error: {primary_error}" - ) - try: - # Fallback to the default application icon. - return icon_theme.load_icon( - self.default_applicaiton_icon, size, Gtk.IconLookupFlags.FORCE_SIZE - ) - except GLib.Error as fallback_error: - logger.error( - f"Error: Fallback icon '{self.default_applicaiton_icon}' also not found. Error: {fallback_error}" - ) - return None - - def _store_new_icon(self, app_id: str, icon: str): - self._icon_dict[app_id] = icon - with open(ICON_CACHE_FILE, "w") as f: - json.dump(self._icon_dict, f) - - def _get_icon_from_desktop_file(self, desktop_file_path: str): - # Retrieve the icon specified in the [Desktop Entry] section. - with open(desktop_file_path) as f: - for line in f.readlines(): - if "Icon=" in line: - return "".join(line[5:].split()) - return self.default_applicaiton_icon - - def _get_desktop_file(self, app_id: str) -> str | None: - data_dirs = GLib.get_system_data_dirs() - for data_dir in data_dirs: - data_dir = os.path.join(data_dir, "applications") - if os.path.exists(data_dir): - files = os.listdir(data_dir) - matching = [ - s for s in files if "".join(app_id.lower().split()) in s.lower() - ] - if matching: - return os.path.join(data_dir, matching[0]) - for word in list(filter(None, re.split(r"-|\.|_|\s", app_id))): - matching = [s for s in files if word.lower() in s.lower()] - if matching: - return os.path.join(data_dir, matching[0]) - return None - - def _compositor_find_icon(self, app_id: str): - icon_theme = Gtk.IconTheme.get_default() - if icon_theme.has_icon(app_id): - return app_id - if icon_theme.has_icon(app_id + "-desktop"): - return app_id + "-desktop" - desktop_file = self._get_desktop_file(app_id) - return ( - self._get_icon_from_desktop_file(desktop_file) - if desktop_file - else self.default_applicaiton_icon - ) diff --git a/utils/inhibit.py b/utils/inhibit.py deleted file mode 100644 index 6b5a106..0000000 --- a/utils/inhibit.py +++ /dev/null @@ -1,186 +0,0 @@ -# From https://github.com/stwa/wayland-idle-inhibitor -# License: WTFPL Version 2 - -import argparse -import os -import subprocess -import sys -from dataclasses import dataclass -from signal import SIGINT, SIGTERM, signal -from threading import Event, Timer - -import setproctitle -from pywayland.client.display import Display -from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import ( - ZwpIdleInhibitManagerV1, -) -from pywayland.protocol.wayland.wl_compositor import WlCompositor -from pywayland.protocol.wayland.wl_registry import WlRegistryProxy -from pywayland.protocol.wayland.wl_surface import WlSurface - - -@dataclass -class GlobalRegistry: - surface: WlSurface | None = None - inhibit_manager: ZwpIdleInhibitManagerV1 | None = None - - -def parse_duration(duration_str: str) -> int: - """Parse duration string into seconds. - Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'on', 'off' - """ - try: - if duration_str.lower() in ["on", "off"]: - return 0 - elif duration_str.endswith("h"): - return int(float(duration_str[:-1]) * 3600) - elif duration_str.endswith("m"): - return int(float(duration_str[:-1]) * 60) - elif duration_str.endswith("s"): - return int(float(duration_str[:-1])) - else: - return int(duration_str) - except ValueError: - raise ValueError( - "Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc." - ) - - -def kill_existing_inhibit_processes(): - """Kill any existing modus-inhibit processes.""" - try: - # Find all modus-inhibit processes except the current one - output = subprocess.check_output(["pgrep", "-f", "modus-inhibit"], text=True) - pids = output.strip().split("\n") - current_pid = str(os.getpid()) - - killed_count = 0 - for pid in pids: - pid = pid.strip() - if pid and pid != current_pid: - try: - subprocess.run(["kill", pid], check=True) - killed_count += 1 - except subprocess.CalledProcessError: - pass # Process might have already exited - - if killed_count > 0: - print(f"Stopped {killed_count} existing inhibit process(es)") - else: - print("No existing inhibit processes were running") - return killed_count - - except subprocess.CalledProcessError: - # No processes found - print("No existing inhibit processes were running") - return 0 - except Exception as e: - print(f"Error stopping existing processes: {e}") - return 0 - - -def handle_registry_global( - wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int -) -> None: - global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry() - - if iface_name == "wl_compositor": - compositor = wl_registry.bind(id_num, WlCompositor, version) - global_registry.surface = compositor.create_surface() # type: ignore - elif iface_name == "zwp_idle_inhibit_manager_v1": - global_registry.inhibit_manager = wl_registry.bind( - id_num, ZwpIdleInhibitManagerV1, version - ) - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Inhibit system idle for a specified duration" - ) - parser.add_argument( - "duration", - nargs="?", - default="0", - help='Duration to inhibit (e.g., "1h", "30m", "45s", "on", "off"). Use "on" for indefinite, "off" to stop.', - ) - args = parser.parse_args() - - # Handle "off" argument - kill existing processes and exit - if args.duration.lower() == "off": - kill_existing_inhibit_processes() - sys.exit(0) - - done = Event() - signal(SIGINT, lambda _, __: done.set()) - signal(SIGTERM, lambda _, __: done.set()) - - global_registry = GlobalRegistry() - - try: - display = Display() - display.connect() - - registry = display.get_registry() # type: ignore - registry.user_data = global_registry - registry.dispatcher["global"] = handle_registry_global - - def shutdown() -> None: - display.dispatch() - display.roundtrip() - display.disconnect() - - display.dispatch() - display.roundtrip() - - if global_registry.surface is None: - print("Error: Failed to create Wayland surface.") - shutdown() - sys.exit(1) - - if global_registry.inhibit_manager is None: - print("Error: Your Wayland compositor does not support idle inhibition.") - print("This usually means either:") - print( - "1. Your compositor (like Hyprland) doesn't support the idle-inhibit protocol" - ) - print("2. The protocol is not enabled in your compositor") - print("\nFor Hyprland, you can enable it by adding to your config:") - print("misc:disable_autoreload = true") - print("misc:enable_wayland_protocols = true") - shutdown() - sys.exit(1) - - inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore - global_registry.surface - ) - - display.dispatch() - display.roundtrip() - - duration = parse_duration(args.duration) - if duration > 0: - print(f"Inhibiting idle for {args.duration}...") - # Set up timer to release inhibition - timer = Timer(duration, lambda: done.set()) - timer.start() - else: - print("Inhibiting idle indefinitely...") - - done.wait() - print("Shutting down...") - - inhibitor.destroy() - if duration > 0: - timer.cancel() - - shutdown() - - except Exception as e: - print(f"Error: {str(e)}") - print("Make sure you're running this under a Wayland session.") - sys.exit(1) - - -if __name__ == "__main__": - setproctitle.setproctitle("modus-inhibit") - main() diff --git a/utils/monitors.py b/utils/monitors.py deleted file mode 100644 index 01b706e..0000000 --- a/utils/monitors.py +++ /dev/null @@ -1,60 +0,0 @@ -import json -import warnings -from typing import Dict -import time -from fabric.hyprland import Hyprland -from gi.repository import Gdk -from functools import lru_cache - -warnings.filterwarnings("ignore", category=DeprecationWarning) - - -def ttl_lru_cache(seconds_to_live: int, maxsize: int = 128): - def wrapper(func): - @lru_cache(maxsize) - def inner(__ttl, *args, **kwargs): - return func(*args, **kwargs) - - return lambda *args, **kwargs: inner( - time.time() // seconds_to_live, *args, **kwargs - ) - - return wrapper - - -class HyprlandWithMonitors(Hyprland): - """A Hyprland class with additional monitor common.""" - - instance = None - - @staticmethod - def get_default(): - if HyprlandWithMonitors.instance is None: - HyprlandWithMonitors.instance = HyprlandWithMonitors() - - return HyprlandWithMonitors.instance - - def __init__(self, commands_only: bool = False, **kwargs): - self.display: Gdk.Display = Gdk.Display.get_default() - super().__init__(commands_only, **kwargs) - - @ttl_lru_cache(100, 5) - def get_all_monitors(self) -> Dict: - monitors = json.loads(self.send_command("j/monitors").reply) - return {monitor["id"]: monitor["name"] for monitor in monitors} - - def get_gdk_monitor_id_from_name(self, plug_name: str) -> int | None: - for i in range(self.display.get_n_monitors()): - if self.display.get_default_screen().get_monitor_plug_name(i) == plug_name: - return i - return None - - def get_gdk_monitor_id(self, hyprland_id: int) -> int | None: - monitors = self.get_all_monitors() - if hyprland_id in monitors: - return self.get_gdk_monitor_id_from_name(monitors[hyprland_id]) - return None - - def get_current_gdk_monitor_id(self) -> int | None: - active_workspace = json.loads(self.send_command("j/activeworkspace").reply) - return self.get_gdk_monitor_id_from_name(active_workspace["monitor"]) diff --git a/utils/occlusion.py b/utils/occlusion.py deleted file mode 100644 index b81ee75..0000000 --- a/utils/occlusion.py +++ /dev/null @@ -1,140 +0,0 @@ -import json -import subprocess - -import config.data as data - - -def get_current_workspace(): - """ - Get the current workspace ID using hyprctl. - """ - try: - result = subprocess.run( - ["hyprctl", "activeworkspace"], capture_output=True, text=True - ) - # Assume the output similar to: "ID <number>" - # Extracting the number from the output - parts = result.stdout.split() - for i, part in enumerate(parts): - if part == "ID" and i + 1 < len(parts): - return int(parts[i + 1]) - except Exception as e: - print(f"Error getting current workspace: {e}") - return -1 - - -def get_screen_dimensions(): - """ - Get screen dimensions from hyprctl. - - Returns: - tuple: (width, height) of the monitor containing the current workspace - """ - try: - # Get current workspace - workspace_id = get_current_workspace() - - # Get monitor information - result = subprocess.run( - ["hyprctl", "-j", "monitors"], capture_output=True, text=True - ) - monitors = json.loads(result.stdout) - - # Find the monitor containing our workspace - for monitor in monitors: - if monitor.get("activeWorkspace", {}).get("id") == workspace_id: - return monitor.get("width", data.CURRENT_WIDTH), monitor.get( - "height", data.CURRENT_HEIGHT - ) - - # Fallback to first monitor - if monitors: - return monitors[0].get("width", data.CURRENT_WIDTH), monitors[0].get( - "height", data.CURRENT_HEIGHT - ) - except Exception as e: - print(f"Error getting screen dimensions: {e}") - - # Default fallback values - return data.CURRENT_WIDTH, data.CURRENT_HEIGHT - - -def check_occlusion(occlusion_region, workspace=None): - """ - Check if a region is occupied by any window on a given workspace. - - Parameters: - occlusion_region: Can be one of: - - tuple (side, size): where side is "top", "bottom", "left", or "right" - and size is the pixel width of the region - - tuple (x, y, width, height): The full region coordinates (legacy format) - workspace (int, optional): The workspace ID to check. If None, the current workspace is used. - - Returns: - bool: True if any window overlaps with the occlusion region, False otherwise. - """ - if workspace is None: - workspace = get_current_workspace() - - # Handle simplified side-based format - if isinstance(occlusion_region, tuple) and len(occlusion_region) == 2: - side, size = occlusion_region - if isinstance(side, str): - # Convert side-based format to coordinates - screen_width, screen_height = get_screen_dimensions() - - if side.lower() == "bottom": - occlusion_region = (0, screen_height - size, screen_width, size) - elif side.lower() == "top": - occlusion_region = (0, 0, screen_width, size) - elif side.lower() == "left": - occlusion_region = (0, 0, size, screen_height) - elif side.lower() == "right": - occlusion_region = (screen_width - size, 0, size, screen_height) - - # Ensure occlusion_region is in the correct format (x, y, width, height) - if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4: - print(f"Invalid occlusion region format: {occlusion_region}") - return False - - try: - result = subprocess.run( - ["hyprctl", "-j", "clients"], capture_output=True, text=True - ) - clients = json.loads(result.stdout) - except Exception as e: - print(f"Error retrieving client windows: {e}") - return False - - occ_x, occ_y, occ_width, occ_height = occlusion_region - occ_x2 = occ_x + occ_width - occ_y2 = occ_y + occ_height - - for client in clients: - # Check if client is mapped - if not client.get("mapped", False): - continue - - # Ensure client has proper workspace information and matches the workspace - client_workspace = client.get("workspace", {}) - if client_workspace.get("id") != workspace: - continue - - # Ensure client has position and size info - position = client.get("at") - size = client.get("size") - if not position or not size: - continue - - x, y = position - width, height = size - win_x1, win_y1 = x, y - win_x2, win_y2 = x + width, y + height - - # Check for intersection between the window and occlusion region - if not ( - win_x2 <= occ_x or win_x1 >= occ_x2 or win_y2 <= occ_y or win_y1 >= occ_y2 - ): - return True # Occlusion region is occupied - - return False # No window overlaps the occlusion region diff --git a/utils/roam.py b/utils/roam.py deleted file mode 100644 index 4127d98..0000000 --- a/utils/roam.py +++ /dev/null @@ -1,41 +0,0 @@ -from loguru import logger - -from fabric.audio import Audio - -from services.modus import ModusService, notification_service as notification_service_instance - -global modus_service -try: - modus_service = ModusService() -except Exception as e: - logger.error(f"[Main] Failed to create ModusService: {e}") - modus_service = None - -if modus_service is None: - logger.warning( - "[Main] ModusService was not initialized. Functionality may be limited." - ) - -global notification_service -try: - notification_service = notification_service_instance -except Exception as e: - logger.error(f"[Main] Failed to create NotificationService: {e}") - notification_service = None - -if notification_service is None: - logger.warning( - "[Main] NotificationService was not initialized. Notifications may not work." - ) - -global audio_service -try: - audio_service = Audio() -except Exception as e: - logger.error(f"[Main] Failed to create AudioService: {e}") - audio_service = None - -if audio_service is None: - logger.warning( - "[Main] AudioService was not initialized. Audio features may not work." - ) diff --git a/widgets/circle_image.py b/widgets/circle_image.py deleted file mode 100644 index aefd7e0..0000000 --- a/widgets/circle_image.py +++ /dev/null @@ -1,125 +0,0 @@ -import math -from typing import Literal - -import cairo -import gi -from fabric.core.service import Property -from fabric.widgets.widget import Widget - -gi.require_version("Gtk", "3.0") -from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402 - - -class CircleImage(Gtk.DrawingArea, Widget): - """A widget that displays an image in a circular shape with a 1:1 aspect ratio.""" - - @Property(int, "read-write") - def angle(self) -> int: - return self._angle - - @angle.setter - def angle(self, value: int): - self._angle = value % 360 - self.queue_draw() - - def __init__( - self, - image_file: str | None = None, - pixbuf: GdkPixbuf.Pixbuf | None = None, - name: str | None = None, - visible: bool = True, - all_visible: bool = False, - style: str | None = None, - tooltip_text: str | None = None, - tooltip_markup: str | None = None, - h_align: ( - Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None - ) = None, - v_align: ( - Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None - ) = None, - h_expand: bool = False, - v_expand: bool = False, - size: int | None = None, - **kwargs, - ): - Gtk.DrawingArea.__init__(self) - Widget.__init__( - self, - name=name, - visible=visible, - all_visible=all_visible, - style=style, - tooltip_text=tooltip_text, - tooltip_markup=tooltip_markup, - h_align=h_align, - v_align=v_align, - h_expand=h_expand, - v_expand=v_expand, - size=size, - **kwargs, - ) - self.size = size if size is not None else 100 # Default size if not provided - self._angle = 0 - # Original image for reprocessing - self._orig_image: GdkPixbuf.Pixbuf | None = None - self._image: GdkPixbuf.Pixbuf | None = None - if image_file: - pix = GdkPixbuf.Pixbuf.new_from_file(image_file) - self._orig_image = pix - self._image = self._process_image(pix) - elif pixbuf: - self._orig_image = pixbuf - self._image = self._process_image(pixbuf) - self.connect("draw", self.on_draw) - - def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf: - """Crop the image to a centered square and scale it to the widgetโ€™s size.""" - width, height = pixbuf.get_width(), pixbuf.get_height() - if width != height: - square_size = min(width, height) - x_offset = (width - square_size) // 2 - y_offset = (height - square_size) // 2 - pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size) - else: - square_size = width - if square_size != self.size: - pixbuf = pixbuf.scale_simple( - self.size, self.size, GdkPixbuf.InterpType.BILINEAR - ) - return pixbuf - - def on_draw(self, widget: "CircleImage", ctx: cairo.Context): - if self._image: - ctx.save() - # Create a circular clipping path - ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi) - ctx.clip() - # Rotate around the center of the square image - ctx.translate(self.size / 2, self.size / 2) - ctx.rotate(self._angle * math.pi / 180.0) - ctx.translate(-self.size / 2, -self.size / 2) - Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0) - ctx.paint() - ctx.restore() - - def set_image_from_file(self, new_image_file: str): - if not new_image_file: - return - pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file) - self._orig_image = pixbuf - self._image = self._process_image(pixbuf) - self.queue_draw() - - def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf): - if not pixbuf: - return - self._orig_image = pixbuf - self._image = self._process_image(pixbuf) - self.queue_draw() - - def set_image_size(self, size: int): - self.size = size - if self._orig_image: - self._image = self._process_image(self._orig_image) - self.queue_draw() diff --git a/widgets/custom_image.py b/widgets/custom_image.py deleted file mode 100644 index 1aae7f6..0000000 --- a/widgets/custom_image.py +++ /dev/null @@ -1,39 +0,0 @@ -import math -from typing import cast - -import cairo -from gi.repository import Gtk - -from fabric.widgets.image import Image - - -class CustomImage(Image): - def do_render_rectangle( - self, cr: cairo.Context, width: int, height: int, radius: int = 0 - ): - cr.move_to(radius, 0) - cr.line_to(width - radius, 0) - cr.arc(width - radius, radius, radius, -(math.pi / 2), 0) - cr.line_to(width, height - radius) - cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2)) - cr.line_to(radius, height) - cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi) - cr.line_to(0, radius) - cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2))) - cr.close_path() - - def do_draw(self, cr: cairo.Context): - context = self.get_style_context() - width, height = self.get_allocated_width(), self.get_allocated_height() - cr.save() - - self.do_render_rectangle( - cr, - width, - height, - cast(int, context.get_property("border-radius", Gtk.StateFlags.NORMAL)), - ) - cr.clip() - Image.do_draw(self, cr) - - cr.restore() diff --git a/widgets/customrevealer.py b/widgets/customrevealer.py deleted file mode 100644 index bf47e52..0000000 --- a/widgets/customrevealer.py +++ /dev/null @@ -1,320 +0,0 @@ -from gi.repository import GLib, Gtk -import gi -import math - -gi.require_version("Gtk", "3.0") - -# TODO: UsE BETTER APPROACH IF POSSIBLE - - -class AnimationManager: - _instance = None - _animating_widgets = set() - _timer_id = None - _containers_to_redraw = set() - - @classmethod - def get_instance(cls): - if cls._instance is None: - cls._instance = cls() - return cls._instance - - def add_widget(self, widget): - self._animating_widgets.add(widget) - if self._timer_id is None: - # Use 120 FPS for ultra-smooth animations like macOS - self._timer_id = GLib.timeout_add(8, self._animate_all) # 120 FPS - - - def remove_widget(self, widget): - self._animating_widgets.discard(widget) - if not self._animating_widgets and self._timer_id: - # Stop timer when no widgets are animating - GLib.source_remove(self._timer_id) - self._timer_id = None - # Clear any pending redraws - self._containers_to_redraw.clear() - - def _animate_all(self): - # Clear previous frame's redraw queue - self._containers_to_redraw.clear() - - widgets_to_remove = [] - - # Process all animations in a single frame - for widget in list(self._animating_widgets): - if not widget._calculate_position(): - widgets_to_remove.append(widget) - else: - container = widget._get_container_for_redraw() - if container: - self._containers_to_redraw.add(container) - - # Apply all position changes at once to prevent conflicts - for widget in self._animating_widgets: - widget._apply_position() - - # Batch redraw calls to minimize performance impact - for container in self._containers_to_redraw: - container.queue_draw() - - # Remove completed animations - for widget in widgets_to_remove: - self.remove_widget(widget) - - return len(self._animating_widgets) > 0 # Continue if widgets remain - - def get_active_widget_count(self): - """Return the number of currently animating widgets""" - return len(self._animating_widgets) - - def _get_optimal_interval(self): - """Keep consistent 120 FPS for macOS-like smoothness""" - return 8 # 120 FPS - - - def _start_timer(self): - interval = self._get_optimal_interval() - self._timer_id = GLib.timeout_add(interval, self._animate_all) - - def _adjust_frame_rate(self): - # No need to adjust frame rate anymore - keep it consistent - pass - - -class MacOSEasing: - """macOS-style easing functions for natural motion""" - - @staticmethod - def ease_out_expo(t): - """Exponential ease out - fast start, slow end""" - return 1 - math.pow(2, -10 * t) if t != 1 else 1 - - @staticmethod - def ease_in_out_quart(t): - """Quartic ease in-out for smooth acceleration/deceleration""" - return 8 * t * t * t * t if t < 0.5 else 1 - math.pow(-2 * t + 2, 4) / 2 - - @staticmethod - def ease_out_back(t): - """Back ease out for slight overshoot effect""" - c1 = 1.70158 - c3 = c1 + 1 - return 1 + c3 * math.pow(t - 1, 3) + c1 * math.pow(t - 1, 2) - - @staticmethod - def ease_out_cubic_bezier(t): - """Custom cubic bezier similar to macOS default (0.25, 0.1, 0.25, 1.0)""" - # Approximation of cubic-bezier(0.25, 0.1, 0.25, 1.0) - return t * t * t * (t * (6 * t - 15) + 10) - - @staticmethod - def ease_in_cubic(t): - """Cubic ease in for smooth acceleration""" - return t * t * t - - @staticmethod - def ease_out_quint(t): - """Quintic ease out for very smooth deceleration""" - return 1 - math.pow(1 - t, 5) - - -class SlideRevealer(Gtk.Overlay): - def __init__(self, child: Gtk.Widget, direction="right", duration=350, size=None): - super().__init__() - - self.child = child - self.direction = direction - self.duration = duration # Slightly faster for snappier feel - self.fixed_size = size - self._revealed = False - self._animating = False - self._start_time = None - self._show_animation = False - self._pending_position = None - self._current_position = (0.0, 0.0) # Use float for sub-pixel positioning - self._animation_id = None # Track individual animation instances - - self._fixed = Gtk.Fixed() - self._fixed.set_has_window(False) - self._fixed.add(child) - self.add_overlay(self._fixed) - - if self.fixed_size: - self.set_size_request(self.fixed_size[0], self.fixed_size[1]) - child.hide() - self.show_all() - else: - child.connect("size-allocate", self._on_size_allocate) - child.hide() - self.show_all() - - def _on_size_allocate(self, _widget, allocation): - if not self.fixed_size: - current_req = self.get_size_request() - if ( - current_req[0] != allocation.width - or current_req[1] != allocation.height - ): - self.set_size_request(allocation.width, allocation.height) - - def set_reveal_child(self, reveal: bool): - if reveal: - self.reveal() - else: - self.hide() - - def reveal(self): - if self._revealed and not self._animating: - return - self._revealed = True - - # Ensure widget is properly laid out before starting animation - if self.get_realized(): - self._start_animation(show=True) - else: - # Wait for widget to be realized - def on_realize(*_): - self._start_animation(show=True) - self.disconnect_by_func(on_realize) - self.connect("realize", on_realize) - - def hide(self): - if not self._revealed and not self._animating: - return - self._revealed = False - self._start_animation(show=False) - - def _start_animation(self, show: bool): - # Stop any existing animation for this widget - if self._animating: - AnimationManager.get_instance().remove_widget(self) - - self._cached_dimensions = self._get_dimensions() - if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0: - self._animating = False - return - - # Use high-precision monotonic time for smooth animations - self._start_time = GLib.get_monotonic_time() - self._animating = True - self._show_animation = show - self._animation_id = id(self) # Unique ID for this animation instance - - if show: - self.child.show() - pos = self._get_offscreen_pos_cached() - self._current_position = (float(pos[0]), float(pos[1])) - self._fixed.move(self.child, int(pos[0]), int(pos[1])) - - def start_with_dimensions(): - AnimationManager.get_instance().add_widget(self) - return False - - # Use idle_add to ensure layout is complete - GLib.idle_add(start_with_dimensions) - - def _calculate_position(self): - if not self._animating: - return False - - elapsed = (GLib.get_monotonic_time() - self._start_time) / 1000 - t = min(elapsed / self.duration, 1.0) - - # Use different easing functions for better smoothness - if self._show_animation: - # Use quintic ease out for very smooth revealing - eased = MacOSEasing.ease_out_quint(t) - else: - # Use cubic ease in for smooth hiding - eased = MacOSEasing.ease_in_cubic(t) - - self._pending_position = self._get_position_at_progress_cached(eased) - - if t >= 1.0: - self._animating = False - self._cached_dimensions = None - self._animation_id = None - if not self._show_animation: - GLib.idle_add(lambda: self.child.hide()) - return False - return True - - def _apply_position(self): - if self._pending_position: - x, y = self._pending_position - # Use sub-pixel positioning for smoother motion - self._current_position = (x, y) - # Round to nearest pixel for actual positioning - pixel_x, pixel_y = int(round(x)), int(round(y)) - self._fixed.move(self.child, pixel_x, pixel_y) - self._pending_position = None - - def _get_container_for_redraw(self): - return self - - def _get_dimensions(self): - if self.fixed_size: - return self.fixed_size - else: - alloc = self.child.get_allocation() - return alloc.width, alloc.height - - def _get_offscreen_pos_cached(self): - w, h = self._cached_dimensions - if self.direction == "left": - return -w, 0 - elif self.direction == "right": - return w, 0 - elif self.direction == "top": - return 0, -h - elif self.direction == "bottom": - return 0, h - return 0, 0 - - def _get_position_at_progress_cached(self, progress): - w, h = self._cached_dimensions - if self._show_animation: - # Showing animation: slide from offscreen to onscreen (0,0) - if self.direction == "left": - return -w + w * progress, 0.0 - elif self.direction == "right": - return w - w * progress, 0.0 - elif self.direction == "top": - return 0.0, -h + h * progress - elif self.direction == "bottom": - return 0.0, h - h * progress - else: - # Hiding animation: slide from onscreen (0,0) to offscreen - if self.direction == "left": - return -w * progress, 0.0 # Slide left (negative x) - elif self.direction == "right": - return w * progress, 0.0 # Slide right (positive x) - elif self.direction == "top": - return 0.0, -h * progress # Slide up (negative y) - elif self.direction == "bottom": - return 0.0, h * progress # Slide down (positive y) - return 0.0, 0.0 - - def set_slide_direction(self, direction): - self.direction = direction - - def is_revealed(self): - return self._revealed - - def is_animating(self): - return self._animating - - def get_child_revealed(self): - return self._revealed - - def stop_animation(self): - if self._animating: - AnimationManager.get_instance().remove_widget(self) - self._animating = False - self._cached_dimensions = None - self._animation_id = None - - def destroy(self): - self.stop_animation() - super().destroy() \ No newline at end of file diff --git a/widgets/dropdown.py b/widgets/dropdown.py deleted file mode 100644 index cb159c9..0000000 --- a/widgets/dropdown.py +++ /dev/null @@ -1,91 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.eventbox import EventBox -from utils.roam import modus_service -from widgets.popup_window import PopupWindow - -dropdowns = [] - - -def dropdown_divider(comment): - return Box( - children=[Box(name="dropdown-divider", h_expand=True)], - name="dropdown-divider-box", - h_align="fill", - h_expand=True, - v_expand=True, - ) - - -class ModusDropdown(PopupWindow): - def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs): - super().__init__( - layer="top", - exclusivity="auto", - name="dropdown-menu", - title="modus", - keyboard_mode="none", - visible=False, - **kwargs, - ) - - self.id = dropdown_id or str(len(dropdowns)) - dropdowns.append(self) - self._mousecapture_parent = None # Will be set by mousecapture - - modus_service.connect("dropdowns-hide-changed", self.hide_dropdown) - - self.dropdown = Box( - children=dropdown_children or [], - h_expand=True, - name="dropdown-options", - orientation="vertical", - ) - - self.child_box = CenterBox(start_children=[self.dropdown]) - - self.event_box = EventBox( - events=["enter-notify-event", "leave-notify-event"], - child=self.child_box, - all_visible=True, - ) - - self.children = [self.event_box] - self.connect("button-press-event", self.hide_dropdown) - self.add_keybinding("Escape", self.hide_dropdown) - - def toggle_dropdown(self, button, parent=None): - self.set_visible(not self.is_visible()) - modus_service.current_dropdown = self.id if self.is_visible() else None - - def _init_mousecapture(self, mousecapture): - """Store reference to mousecapture parent for hiding""" - self._mousecapture_parent = mousecapture - - def hide_dropdown(self, widget, event): - if self.is_visible(): - self.hide() - if str(modus_service.current_dropdown) == str(self.id): - modus_service.current_dropdown = None - - def hide_via_mousecapture(self): - """Hide dropdown via mousecapture parent""" - if self._mousecapture_parent: - self._mousecapture_parent.hide_child_window() - - def _set_mousecapture(self, visible: bool) -> None: - self.set_visible(visible) - if visible: - modus_service.current_dropdown = self.id - else: - if str(modus_service.current_dropdown) == str(self.id): - modus_service.current_dropdown = None - - def on_cursor_enter(self, *_): - self.set_visible(True) - - def on_cursor_leave(self, *_): - if self.is_hovered(): - return - self.set_visible(False) - modus_service.dropdowns_hide = not modus_service.dropdowns_hide diff --git a/widgets/mousecapture.py b/widgets/mousecapture.py deleted file mode 100644 index af432fc..0000000 --- a/widgets/mousecapture.py +++ /dev/null @@ -1,142 +0,0 @@ -from typing import Any - -import cairo -from gi.repository import GLib, GtkLayerShell # type: ignore - -from fabric.widgets.eventbox import EventBox -from fabric.widgets.widget import Widget -from utils.roam import modus_service -from widgets.wayland import WaylandWindow as Window - - -class MouseCapture(Window): - """A background overlay that captures outside clicks without blocking child window interactions""" - - def __init__(self, layer: str, child_window: Window, **kwargs): - super().__init__( - layer="top", # Use top layer to capture events - anchor="top bottom left right", - exclusivity="auto", - title="modus", - name="MouseCapture", - keyboard_mode="none", # Don't steal keyboard - all_visible=False, - visible=False, - **kwargs, - ) - - GtkLayerShell.set_exclusive_zone(self, -1) - - self.child_window = child_window - - # Ensure child window is on overlay layer to be above this capture - if hasattr(self.child_window, "layer"): - self.child_window.layer = "overlay" - - if hasattr(self.child_window, "_init_mousecapture"): - self.child_window._init_mousecapture(self) - - # Create transparent event box that captures clicks - self.event_box = EventBox( - events=["button-press-event"], - all_visible=True, - ) - self.event_box.connect("button-press-event", self.on_overlay_click) - self.children = [self.event_box] - - # Make the overlay transparent - self.set_app_paintable(True) - self.connect("draw", self.on_draw) - - # Add escape key binding to child window - if hasattr(self.child_window, "add_keybinding"): - self.child_window.add_keybinding("Escape", self.hide_child_window) - - def on_draw(self, _widget, cr): - """Make overlay transparent""" - cr.set_source_rgba(0, 0, 0, 0) # Fully transparent - cr.set_operator(cairo.OPERATOR_SOURCE) - cr.paint() - return False - - def on_overlay_click(self, _widget, event): - """Handle overlay clicks - check if click is outside child window""" - if not self.child_window.is_visible(): - return False - - # Get click coordinates - click_x = event.x_root - click_y = event.y_root - - # Get child window bounds - try: - child_x, child_y = self.child_window.get_position() - child_allocation = self.child_window.get_allocation() - - # Check if click is inside child window bounds - inside_child = ( - child_x <= click_x <= child_x + child_allocation.width - and child_y <= click_y <= child_y + child_allocation.height - ) - - if not inside_child: - # Click is outside child window - hide it with delay - GLib.timeout_add( - 50, lambda: self.hide_child_window(None, None) or False - ) - return True # Consume the event - - except Exception as e: - print(f"Error checking click position: {e}") - # If we can't determine position, hide child window to be safe - GLib.timeout_add(50, lambda: self.hide_child_window(None, None) or False) - return True - - # Click is inside child window - don't consume event - return False - - def show_child_window(self, widget: Widget = None, event: Any = None) -> None: - self.set_child_window_visible(True) - - def hide_child_window(self, widget: Widget = None, event: Any = None) -> None: - self.set_child_window_visible(False) - - def set_child_window_visible(self, visible: bool) -> None: - if visible: - self.child_window.show() - self.show() - else: - self.child_window.hide() - self.hide() - - if hasattr(self.child_window, "_set_mousecapture"): - self.child_window._set_mousecapture(visible) - - def toggle_mousecapture(self, *_) -> None: - if self.is_visible(): - self.set_child_window_visible(False) - else: - self.set_child_window_visible(True) - - -class DropDownMouseCapture(MouseCapture): - """A specialized MouseCapture for dropdown menus with service integration""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - modus_service.connect("dropdowns-hide-changed", self.dropdowns_hide_changed) - - def hide_child_window(self, widget: Widget = None, event: Any = None) -> None: - """Hide child window and update dropdown service state""" - # Update service state before hiding to prevent conflicts - if hasattr(self.child_window, "id"): - if str(modus_service.current_dropdown) == str(self.child_window.id): - modus_service.current_dropdown = None - super().hide_child_window(widget, event) - - def dropdowns_hide_changed(self, widget: Widget = None, event: Any = None) -> None: - """Handle dropdown service hide changes""" - if hasattr(self.child_window, "id"): - if modus_service.current_dropdown == self.child_window.id: - return - return self.hide_child_window(widget, event) diff --git a/widgets/popup_window.py b/widgets/popup_window.py deleted file mode 100644 index d6d600c..0000000 --- a/widgets/popup_window.py +++ /dev/null @@ -1,191 +0,0 @@ -import contextlib -import gi # type: ignore -from gi.repository import Gdk, Gtk, GtkLayerShell # type: ignore - -from widgets.wayland import WaylandWindow -from utils.monitors import HyprlandWithMonitors - -gi.require_version("GtkLayerShell", "0.1") - - -class PopupWindow(WaylandWindow): - """A simple popover window that can point to a widget.""" - - def __init__( - self, - parent: WaylandWindow, - pointing_to: Gtk.Widget | None = None, - margin: tuple[int, ...] | str = "0px 0px 0px 0px", - enable_boundary_checking: bool = True, - **kwargs, - ): - super().__init__(**kwargs) - self.exclusivity = "none" - self._is_centered = False - self._parent = parent - self._pointing_widget = pointing_to - self._hyprland = HyprlandWithMonitors() - self._base_margin = self.extract_margin(margin) - self.margin = self._base_margin.values() - self._enable_boundary_checking = enable_boundary_checking - - self.connect("notify::visible", self.do_update_handlers) - - def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]: - if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()): # type: ignore - return 0, 0 - allocation = widget.get_allocation() - x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or ( - 0, - 0, - ) - return round(x / 2), round(y / 2) - - def set_pointing_to(self, widget: Gtk.Widget | None): - if self._pointing_widget: - with contextlib.suppress(Exception): - self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) - self._pointing_widget = widget - return self.do_update_handlers() - - def do_update_handlers(self, *_): - if not self._pointing_widget: - return - - if not self.get_visible(): - try: - self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) - self.disconnect_by_func(self.do_handle_size_allocate) - except Exception: - pass - return - - self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate) - self.connect("size-allocate", self.do_handle_size_allocate) - - return self.do_handle_size_allocate() - - def do_handle_size_allocate(self, *_): - return self.do_reposition(self.do_calculate_edges()) - - def do_calculate_edges(self): - move_axe = "x" - parent_anchor = self._parent.anchor - - if len(parent_anchor) != 3: - self.anchor = "left bottom" - self._is_centered = True - return move_axe - - if ( - GtkLayerShell.Edge.LEFT in parent_anchor - and GtkLayerShell.Edge.RIGHT in parent_anchor - ): - # horizontal -> move on x-axies - move_axe = "x" - if GtkLayerShell.Edge.TOP in parent_anchor: - self.anchor = "left top" - else: - self.anchor = "left bottom" - elif ( - GtkLayerShell.Edge.TOP in parent_anchor - and GtkLayerShell.Edge.BOTTOM in parent_anchor - ): - # vertical -> move on y-axies - move_axe = "y" - if GtkLayerShell.Edge.RIGHT in parent_anchor: - self.anchor = "top right" - else: - self.anchor = "top left" - - self._is_centered = False - return move_axe - - def do_reposition(self, move_axe: str): - parent_margin = self._parent.margin - parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3] - - height = self.get_allocated_height() - width = self.get_allocated_width() - - # Get monitor geometry for boundary checking - current_monitor_id = self._hyprland.get_current_gdk_monitor_id() - if current_monitor_id is not None: - monitor = self._hyprland.display.get_monitor(current_monitor_id) - monitor_geometry = monitor.get_geometry() - monitor_x, monitor_y = monitor_geometry.x, monitor_geometry.y - monitor_width, monitor_height = monitor_geometry.width, monitor_geometry.height - else: - # Fallback to default screen - screen = Gdk.Screen.get_default() - monitor_x, monitor_y = 0, 0 - monitor_width, monitor_height = screen.get_width(), screen.get_height() - - if self._pointing_widget: - coords = self.get_coords_for_widget(self._pointing_widget) - coords_centered = ( - round(coords[0] + self._pointing_widget.get_allocated_width() / 2), - round(coords[1] + self._pointing_widget.get_allocated_height() / 2), - ) - else: - coords_centered = ( - round(self._parent.get_allocated_width() / 2), - round(self._parent.get_allocated_height() / 2), - ) - - if self._is_centered: - # Calculate centered position with boundary checking - centered_x = ( - (monitor_width / 2 - self._parent.get_allocated_width() / 2) - - width / 2 - ) + coords_centered[0] - - # Apply boundary checking only if enabled - if self._enable_boundary_checking: - if centered_x < monitor_x: - centered_x = monitor_x - elif centered_x + width > monitor_x + monitor_width: - centered_x = monitor_x + monitor_width - width - - self.margin = tuple( - a + b - for a, b in zip( - (0, 0, 0, centered_x), - self._base_margin.values(), - ) - ) - return - - # Calculate position with boundary checking - if move_axe == "x": - # Horizontal positioning - calculated_x = round((parent_x_margin + coords_centered[0]) - (width / 2)) - - # Apply boundary checking only if enabled - if self._enable_boundary_checking: - if calculated_x < monitor_x: - calculated_x = monitor_x - elif calculated_x + width > monitor_x + monitor_width: - calculated_x = monitor_x + monitor_width - width - - margin_values = (0, 0, 0, calculated_x) - else: - # Vertical positioning - calculated_y = round((parent_y_margin + coords_centered[1]) - (height / 2)) - - # Apply boundary checking only if enabled - if self._enable_boundary_checking: - if calculated_y < monitor_y: - calculated_y = monitor_y - elif calculated_y + height > monitor_y + monitor_height: - calculated_y = monitor_y + monitor_height - height - - margin_values = (calculated_y, 0, 0, 0) - - self.margin = tuple( - a + b - for a, b in zip( - margin_values, - self._base_margin.values(), - ) - ) diff --git a/widgets/wayland.py b/widgets/wayland.py deleted file mode 100644 index b7c76aa..0000000 --- a/widgets/wayland.py +++ /dev/null @@ -1,370 +0,0 @@ -import re -from collections.abc import Iterable -from enum import Enum -from typing import Literal, cast - -import cairo -import gi -from gi.repository import Gdk, GObject, Gtk -from loguru import logger - -from fabric.core.service import Property -from fabric.utils.helpers import extract_css_values, get_enum_member -from fabric.widgets.window import Window - -gi.require_version("Gtk", "3.0") - -try: - gi.require_version("GtkLayerShell", "0.1") - from gi.repository import GtkLayerShell -except: - raise ImportError( - "looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)" - ) - - -class WaylandWindowExclusivity(Enum): - NONE = 1 - NORMAL = 2 - AUTO = 3 - - -class Layer(GObject.GEnum): - BACKGROUND = 0 - BOTTOM = 1 - TOP = 2 - OVERLAY = 3 - ENTRY_NUMBER = 4 - - -class KeyboardMode(GObject.GEnum): - NONE = 0 - EXCLUSIVE = 1 - ON_DEMAND = 2 - ENTRY_NUMBER = 3 - - -class Edge(GObject.GEnum): - LEFT = 0 - RIGHT = 1 - TOP = 2 - BOTTOM = 3 - ENTRY_NUMBER = 4 - - -class WaylandWindow(Window): - @Property( - Layer, - flags="read-write", - default_value=Layer.TOP, - ) - def layer(self) -> Layer: # type: ignore - return self._layer # type: ignore - - @layer.setter - def layer( - self, - value: Literal["background", "bottom", "top", "overlay"] | Layer, - ) -> None: - self._layer = get_enum_member(Layer, value, default=Layer.TOP) - return GtkLayerShell.set_layer(self, self._layer) - - @Property(int, "read-write") - def monitor(self) -> int: - if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))): - return -1 - display = monitor.get_display() or Gdk.Display.get_default() - for i in range(0, display.get_n_monitors()): - if display.get_monitor(i) is monitor: - return i - return -1 - - @monitor.setter - def monitor(self, monitor: int | Gdk.Monitor) -> bool: - if isinstance(monitor, int): - display = Gdk.Display().get_default() - monitor = display.get_monitor(monitor) - return ( - (GtkLayerShell.set_monitor(self, monitor), True)[1] - if monitor is not None - else False - ) - - @Property(WaylandWindowExclusivity, "read-write") - def exclusivity(self) -> WaylandWindowExclusivity: - return self._exclusivity - - @exclusivity.setter - def exclusivity( - self, value: Literal["none", "normal", "auto"] | WaylandWindowExclusivity - ) -> None: - value = get_enum_member( - WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE - ) - self._exclusivity = value - match value: - case WaylandWindowExclusivity.NORMAL: - return GtkLayerShell.set_exclusive_zone(self, True) - case WaylandWindowExclusivity.AUTO: - return GtkLayerShell.auto_exclusive_zone_enable(self) - case _: - return GtkLayerShell.set_exclusive_zone(self, False) - - @Property(bool, "read-write", default_value=False) - def pass_through(self) -> bool: - return self._pass_through - - @pass_through.setter - def pass_through(self, pass_through: bool = False): - self._pass_through = pass_through - region = cairo.Region() if pass_through is True else None - self.input_shape_combine_region(region) - del region - return - - @Property( - KeyboardMode, - "read-write", - default_value=KeyboardMode.NONE, - ) - def keyboard_mode(self) -> KeyboardMode: - return self._keyboard_mode - - @keyboard_mode.setter - def keyboard_mode( - self, - value: ( - Literal[ - "none", - "exclusive", - "on-demand", - "entry-number", - ] - | KeyboardMode - ), - ): - self._keyboard_mode = get_enum_member( - KeyboardMode, value, default=KeyboardMode.NONE - ) - return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode) - - @Property(tuple[Edge, ...], "read-write") - def anchor(self): - return tuple( - x - for x in [ - Edge.TOP, - Edge.RIGHT, - Edge.BOTTOM, - Edge.LEFT, - ] - if GtkLayerShell.get_anchor(self, x) - ) - - @anchor.setter - def anchor(self, value: str | Iterable[Edge]) -> None: - self._anchor = value - if isinstance(value, (list, tuple)) and all( - isinstance(edge, Edge) for edge in value - ): - for edge in [ - Edge.TOP, - Edge.RIGHT, - Edge.BOTTOM, - Edge.LEFT, - ]: - if edge not in value: - GtkLayerShell.set_anchor(self, edge, False) - GtkLayerShell.set_anchor(self, edge, True) - return - elif isinstance(value, str): - for edge, anchored in WaylandWindow.extract_edges_from_string( - value - ).items(): - GtkLayerShell.set_anchor(self, edge, anchored) - - return - - @Property(tuple[int, ...], flags="read-write") - def margin(self) -> tuple[int, ...]: - return tuple( - GtkLayerShell.get_margin(self, x) - for x in [ - Edge.TOP, - Edge.RIGHT, - Edge.BOTTOM, - Edge.LEFT, - ] - ) - - @margin.setter - def margin(self, value: str | Iterable[int]) -> None: - for edge, mrgv in WaylandWindow.extract_margin(value).items(): - GtkLayerShell.set_margin(self, edge, mrgv) - return - - @Property(object, "read-write") - def keyboard_mode(self): - kb_mode = GtkLayerShell.get_keyboard_mode(self) - if GtkLayerShell.get_keyboard_interactivity(self): - kb_mode = KeyboardMode.EXCLUSIVE - return kb_mode - - @keyboard_mode.setter - def keyboard_mode( - self, - value: Literal["none", "exclusive", "on-demand"] | KeyboardMode, - ): - return GtkLayerShell.set_keyboard_mode( - self, - get_enum_member( - KeyboardMode, - value, - default=KeyboardMode.NONE, - ), - ) - - def __init__( - self, - layer: Literal["background", "bottom", "top", "overlay"] | Layer = Layer.TOP, - anchor: str = "", - margin: str | Iterable[int] = "0px 0px 0px 0px", - exclusivity: ( - Literal["auto", "normal", "none"] | WaylandWindowExclusivity - ) = WaylandWindowExclusivity.NONE, - keyboard_mode: ( - Literal["none", "exclusive", "on-demand"] | KeyboardMode - ) = KeyboardMode.NONE, - pass_through: bool = False, - monitor: int | Gdk.Monitor | None = None, - title: str = "fabric", - type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL, - child: Gtk.Widget | None = None, - name: str | None = None, - visible: bool = True, - all_visible: bool = False, - style: str | None = None, - style_classes: Iterable[str] | str | None = None, - tooltip_text: str | None = None, - tooltip_markup: str | None = None, - h_align: ( - Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None - ) = None, - v_align: ( - Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None - ) = None, - h_expand: bool = False, - v_expand: bool = False, - size: Iterable[int] | int | None = None, - **kwargs, - ): - Window.__init__( - self, - title=title, - type=type, - child=child, - name=name, - visible=False, - all_visible=False, - style=style, - style_classes=style_classes, - tooltip_text=tooltip_text, - tooltip_markup=tooltip_markup, - h_align=h_align, - v_align=v_align, - h_expand=h_expand, - v_expand=v_expand, - size=size, - **kwargs, - ) - self._layer = Layer.ENTRY_NUMBER - self._keyboard_mode = KeyboardMode.NONE - self._anchor = anchor - self._exclusivity = WaylandWindowExclusivity.NONE - self._pass_through = pass_through - - GtkLayerShell.init_for_window(self) - GtkLayerShell.set_namespace(self, title) - self.connect( - "notify::title", - lambda *_: GtkLayerShell.set_namespace(self, self.get_title()), - ) - if monitor is not None: - self.monitor = monitor - self.layer = layer - self.anchor = anchor - self.margin = margin - self.keyboard_mode = keyboard_mode - self.exclusivity = exclusivity - self.pass_through = pass_through - ( - self.show_all() - if all_visible is True - else self.show() if visible is True else None - ) - - def steal_input(self) -> None: - return GtkLayerShell.set_keyboard_interactivity(self, True) - - def return_input(self) -> None: - return GtkLayerShell.set_keyboard_interactivity(self, False) - - # custom overrides - def show(self) -> None: - super().show() - return self.do_handle_post_show_request() - - def show_all(self) -> None: - super().show_all() - return self.do_handle_post_show_request() - - def do_handle_post_show_request(self) -> None: - if not self.get_children(): - logger.warning( - "[WaylandWindow] showing an empty window is not recommended, some compositors might freak out." - ) - self.pass_through = self._pass_through - return - - @staticmethod - def extract_anchor_values(string: str) -> tuple[str, ...]: - """ - extracts the geometry values from a given geometry string. - - :param string: the string containing the geometry values. - :type string: str - :return: a list of unique directions extracted from the geometry string. - :rtype: list - """ - direction_map = {"l": "left", "t": "top", "r": "right", "b": "bottom"} - pattern = re.compile(r"\b(left|right|top|bottom)\b", re.IGNORECASE) - matches = pattern.findall(string) - return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches))) - - @staticmethod - def extract_edges_from_string(string: str) -> dict["Edge", bool]: - anchor_values = WaylandWindow.extract_anchor_values(string.lower()) - return { - Edge.TOP: "top" in anchor_values, - Edge.RIGHT: "right" in anchor_values, - Edge.BOTTOM: "bottom" in anchor_values, - Edge.LEFT: "left" in anchor_values, - } - - @staticmethod - def extract_margin(input: str | Iterable[int]) -> dict["Edge", int]: - margins = ( - extract_css_values(input.lower()) - if isinstance(input, str) - else ( - input - if isinstance(input, (tuple, list)) and len(input) == 4 - else (0, 0, 0, 0) - ) - ) - return { - Edge.TOP: margins[0], - Edge.RIGHT: margins[1], - Edge.BOTTOM: margins[2], - Edge.LEFT: margins[3], - } diff --git a/widgets/wifi_password_dialog.py b/widgets/wifi_password_dialog.py deleted file mode 100644 index 9d53586..0000000 --- a/widgets/wifi_password_dialog.py +++ /dev/null @@ -1,291 +0,0 @@ -import gi -from gi.repository import Gdk, GLib - -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.image import Image -from fabric.widgets.label import Label - -# from widgets.wayland import WaylandWindow as Window - -from fabric.widgets.window import Window - -gi.require_version("Gtk", "3.0") - - -class WiFiPasswordDialog(Window): - def __init__( - self, - ssid: str, - on_connect_callback=None, - on_cancel_callback=None, - on_dialog_closed=None, - **kwargs, - ): - super().__init__( - title="modus", - layer="overlay", - anchor="center", - keyboard_mode="on-demand", - visible=False, - name="wifi-password-dialog", - **kwargs, - ) - - self.ssid = ssid - self.on_connect_callback = on_connect_callback - self.on_cancel_callback = on_cancel_callback - # self.set_size_request(400, 300) - self.set_resizable(False) - self.on_dialog_closed = on_dialog_closed - self.is_connecting = False - self.connection_timeout_id = None - - self._create_dialog_content() - self.connect("key-press-event", self._on_key_press) - self.connect("notify::visible", self._on_visibility_changed) - - def _create_dialog_content(self): - self.wifi_icon = Image( - icon_name="network-wireless-symbolic", size=20, name="wifi-dialog-icon" - ) - - self.title_label = Label( - label=f'The Wi-Fi network "{self.ssid}" requires a WPA2 password.', - name="wifi-dialog-title", - h_align="start", - wrap=True, - max_width_chars=40, - ) - - self.title_container = Box( - orientation="h", - spacing=8, - children=[self.wifi_icon, self.title_label], - name="wifi-dialog-title-container", - h_align="start", - ) - - self.error_label = Label( - label="Incorrect password. Please try again.", - name="wifi-dialog-error", - h_align="center", - visible=False, - ) - - self.password_label = Label( - label="Password:", name="wifi-dialog-password-label", h_align="start" - ) - - self.password_entry = Entry( - placeholder_text="Enter password", - name="wifi-dialog-password-entry", - visibility=False, - h_expand=True, - h_align="fill", - ) - - self.password_entry.connect("activate", lambda *_: self._on_join_clicked()) - self.password_entry.connect("changed", self._on_password_changed) - - self.password_visible = False - self.show_password_button = Button( - image=Image(icon_name="view-conceal-symbolic", size=16), - name="wifi-dialog-show-password-button", - on_clicked=self._on_show_password_clicked, - ) - - self.show_password_label = Label( - label="Show password", name="wifi-dialog-show-password-label" - ) - - self.show_password_box = Box( - orientation="h", - spacing=8, - children=[self.show_password_button, self.show_password_label], - name="wifi-dialog-show-password-box", - ) - - self.cancel_button = Button( - label="Cancel", - name="wifi-dialog-cancel-button", - on_clicked=self._on_cancel_clicked, - ) - - self.join_button = Button( - label="Join", - name="wifi-dialog-join-button", - on_clicked=self._on_join_clicked, - ) - - self.button_box = Box( - orientation="h", - spacing=12, - h_expand=True, - children=[self.cancel_button, self.join_button], - name="wifi-dialog-button-box", - h_align="end", - ) - - self.password_container = Box( - orientation="v", - h_expand=True, - spacing=6, - children=[self.password_label, self.password_entry, self.show_password_box], - name="wifi-dialog-password-container", - ) - - self.content_box = Box( - orientation="v", - h_expand=True, - spacing=12, - children=[ - self.title_container, - self.error_label, - self.password_container, - self.button_box, - ], - name="wifi-dialog-content", - h_align="fill", - v_align="center", - ) - - self.dialog_background = Box( - children=[self.content_box], - name="wifi-dialog-background", - h_align="center", - v_align="center", - ) - - self.children = self.dialog_background - - self._update_join_button_state() - - def _on_password_changed(self, entry): - self._update_join_button_state() - - def _update_join_button_state(self): - password = self.password_entry.get_text().strip() - has_password = len(password) > 0 - - if has_password: - self.join_button.set_opacity(1.0) - self.join_button.remove_style_class("disabled") - else: - self.join_button.set_opacity(0.5) - self.join_button.add_style_class("disabled") - - def _on_show_password_clicked(self, *args): - self.password_visible = not self.password_visible - self.password_entry.set_visibility(self.password_visible) - - icon_name = ( - "view-reveal-symbolic" if self.password_visible else "view-conceal-symbolic" - ) - self.show_password_button.get_image().set_property("icon-name", icon_name) - - def _on_key_press(self, widget, event): - keyval = event.keyval - - if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter: - self._on_join_clicked() - return True - elif keyval == Gdk.KEY_Escape: - self._on_cancel_clicked() - return True - - return False - - def _on_visibility_changed(self, widget, *args): - """Handle visibility changes""" - if self.get_visible(): - GLib.timeout_add(100, lambda: self.password_entry.grab_focus()) - - def _on_cancel_clicked(self, *args): - self.hide() - if self.on_cancel_callback: - self.on_cancel_callback() - if self.on_dialog_closed: - self.on_dialog_closed() - - def _on_join_clicked(self, *args): - if self.is_connecting: - return - - password = self.password_entry.get_text().strip() - if not password: - self.password_entry.grab_focus() - return - - self.is_connecting = True - self.join_button.set_sensitive(False) - - self.connection_timeout_id = GLib.timeout_add(5000, self._connection_timeout) - self.error_label.set_visible(False) - - self.hide() - if self.on_connect_callback: - self.on_connect_callback(self.ssid, password) - if self.on_dialog_closed: - self.on_dialog_closed() - - def _connection_timeout(self): - if self.is_connecting: - self.is_connecting = False - self.join_button.set_sensitive(True) - self.show_error("Connection timeout. Please try again.") - return False - - def show_dialog(self): - if self.connection_timeout_id: - GLib.source_remove(self.connection_timeout_id) - self.connection_timeout_id = None - - self.show_all() - self.password_entry.set_text("") - self.error_label.set_visible(False) - - self.password_visible = False - self.password_entry.set_visibility(False) - self.show_password_button.get_image().set_property( - "icon-name", "view-conceal-symbolic" - ) - - self.is_connecting = False - self.join_button.set_sensitive(True) - - self._update_join_button_state() - - def show_error(self, message="Incorrect password. Please try again."): - if self.connection_timeout_id: - GLib.source_remove(self.connection_timeout_id) - self.connection_timeout_id = None - - self.is_connecting = False - self.join_button.set_sensitive(True) - - if not self.get_visible(): - self.error_label.set_text(message) - self.error_label.set_visible(True) - self.show_all() - GLib.timeout_add(10, lambda: self._focus_and_select_password()) - else: - self.error_label.set_text(message) - self.error_label.set_visible(True) - self._focus_and_select_password() - - def _focus_and_select_password(self): - try: - self.password_entry.grab_focus() - self.password_entry.select_region(0, -1) - return False - except: - return False - - def get_password(self): - return self.password_entry.get_text() - - def destroy_dialog(self): - self.hide() - self.destroy()