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

+ 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 new file mode 100644 index 0000000..a98ab7c Binary files /dev/null and b/assets/screenshots/home.png differ diff --git a/assets/screenshots/lock.png b/assets/screenshots/lock.png new file mode 100644 index 0000000..123f456 Binary files /dev/null and b/assets/screenshots/lock.png differ diff --git a/config/assets/config.json b/config/assets/config.json new file mode 100644 index 0000000..3750756 --- /dev/null +++ b/config/assets/config.json @@ -0,0 +1,16 @@ +{ + "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 new file mode 100644 index 0000000..fa174ff --- /dev/null +++ b/config/assets/emoji.json @@ -0,0 +1,15509 @@ +{ + "๐Ÿ˜€": { + "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 new file mode 100644 index 0000000..6348bc5 --- /dev/null +++ b/config/assets/icons/applets/bluetooth-clear.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/config/assets/icons/applets/bluetooth-off-clear.svg b/config/assets/icons/applets/bluetooth-off-clear.svg new file mode 100644 index 0000000..cea2aa5 --- /dev/null +++ b/config/assets/icons/applets/bluetooth-off-clear.svg @@ -0,0 +1,25 @@ + + + + + diff --git a/config/assets/icons/applets/bluetooth-off.svg b/config/assets/icons/applets/bluetooth-off.svg new file mode 100644 index 0000000..308fba6 --- /dev/null +++ b/config/assets/icons/applets/bluetooth-off.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + diff --git a/config/assets/icons/applets/bluetooth-paired.svg b/config/assets/icons/applets/bluetooth-paired.svg new file mode 100644 index 0000000..c400923 --- /dev/null +++ b/config/assets/icons/applets/bluetooth-paired.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/config/assets/icons/applets/bluetooth.svg b/config/assets/icons/applets/bluetooth.svg new file mode 100644 index 0000000..d3a0b61 --- /dev/null +++ b/config/assets/icons/applets/bluetooth.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + diff --git a/config/assets/icons/applets/brightness.svg b/config/assets/icons/applets/brightness.svg new file mode 100755 index 0000000..bcc61ef --- /dev/null +++ b/config/assets/icons/applets/brightness.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/assets/icons/applets/caffeine-off.svg b/config/assets/icons/applets/caffeine-off.svg new file mode 100644 index 0000000..b59dfe0 --- /dev/null +++ b/config/assets/icons/applets/caffeine-off.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + diff --git a/config/assets/icons/applets/caffeine-on.svg b/config/assets/icons/applets/caffeine-on.svg new file mode 100644 index 0000000..2432b4a --- /dev/null +++ b/config/assets/icons/applets/caffeine-on.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + diff --git a/config/assets/icons/applets/dnd-clear.svg b/config/assets/icons/applets/dnd-clear.svg new file mode 100644 index 0000000..19b053f --- /dev/null +++ b/config/assets/icons/applets/dnd-clear.svg @@ -0,0 +1,4 @@ + + + + diff --git a/config/assets/icons/applets/dnd-off.svg b/config/assets/icons/applets/dnd-off.svg new file mode 100644 index 0000000..fe6a37c --- /dev/null +++ b/config/assets/icons/applets/dnd-off.svg @@ -0,0 +1,53 @@ + + + + + + + diff --git a/config/assets/icons/applets/dnd.svg b/config/assets/icons/applets/dnd.svg new file mode 100644 index 0000000..4ecdd99 --- /dev/null +++ b/config/assets/icons/applets/dnd.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/config/assets/icons/applets/flight-off.svg b/config/assets/icons/applets/flight-off.svg new file mode 100644 index 0000000..d59785c --- /dev/null +++ b/config/assets/icons/applets/flight-off.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + diff --git a/config/assets/icons/applets/flight-on.svg b/config/assets/icons/applets/flight-on.svg new file mode 100644 index 0000000..5a492f1 --- /dev/null +++ b/config/assets/icons/applets/flight-on.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + diff --git a/config/assets/icons/applets/network-wired-offline.svg b/config/assets/icons/applets/network-wired-offline.svg new file mode 100644 index 0000000..c1734ca --- /dev/null +++ b/config/assets/icons/applets/network-wired-offline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/config/assets/icons/applets/network-wired.svg b/config/assets/icons/applets/network-wired.svg new file mode 100644 index 0000000..7b6881e --- /dev/null +++ b/config/assets/icons/applets/network-wired.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/assets/icons/applets/redshift-status-off.svg b/config/assets/icons/applets/redshift-status-off.svg new file mode 100644 index 0000000..e81d366 --- /dev/null +++ b/config/assets/icons/applets/redshift-status-off.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + diff --git a/config/assets/icons/applets/redshift-status-on.svg b/config/assets/icons/applets/redshift-status-on.svg new file mode 100644 index 0000000..aaa2959 --- /dev/null +++ b/config/assets/icons/applets/redshift-status-on.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + diff --git a/config/assets/icons/applets/wifi-clear.svg b/config/assets/icons/applets/wifi-clear.svg new file mode 100644 index 0000000..9276ebe --- /dev/null +++ b/config/assets/icons/applets/wifi-clear.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/config/assets/icons/applets/wifi-off-clear.svg b/config/assets/icons/applets/wifi-off-clear.svg new file mode 100644 index 0000000..d19de29 --- /dev/null +++ b/config/assets/icons/applets/wifi-off-clear.svg @@ -0,0 +1,44 @@ + + + + + + + + + diff --git a/config/assets/icons/applets/wifi-off.svg b/config/assets/icons/applets/wifi-off.svg new file mode 100644 index 0000000..ba5c100 --- /dev/null +++ b/config/assets/icons/applets/wifi-off.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + diff --git a/config/assets/icons/applets/wifi.svg b/config/assets/icons/applets/wifi.svg new file mode 100644 index 0000000..6a0dd08 --- /dev/null +++ b/config/assets/icons/applets/wifi.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/config/assets/icons/battery/battery-000-charging.svg b/config/assets/icons/battery/battery-000-charging.svg new file mode 100644 index 0000000..ac8c28f --- /dev/null +++ b/config/assets/icons/battery/battery-000-charging.svg @@ -0,0 +1,10 @@ + + + + + diff --git a/config/assets/icons/battery/battery-000.svg b/config/assets/icons/battery/battery-000.svg new file mode 100644 index 0000000..c49fb45 --- /dev/null +++ b/config/assets/icons/battery/battery-000.svg @@ -0,0 +1,6 @@ + + + + diff --git a/config/assets/icons/battery/battery-010-charging.svg b/config/assets/icons/battery/battery-010-charging.svg new file mode 100644 index 0000000..c9ea308 --- /dev/null +++ b/config/assets/icons/battery/battery-010-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-010.svg b/config/assets/icons/battery/battery-010.svg new file mode 100644 index 0000000..b88e8fb --- /dev/null +++ b/config/assets/icons/battery/battery-010.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/battery/battery-020-charging.svg b/config/assets/icons/battery/battery-020-charging.svg new file mode 100644 index 0000000..5824ad4 --- /dev/null +++ b/config/assets/icons/battery/battery-020-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-020.svg b/config/assets/icons/battery/battery-020.svg new file mode 100644 index 0000000..87375ae --- /dev/null +++ b/config/assets/icons/battery/battery-020.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/battery/battery-030-charging.svg b/config/assets/icons/battery/battery-030-charging.svg new file mode 100644 index 0000000..c0088ce --- /dev/null +++ b/config/assets/icons/battery/battery-030-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-030.svg b/config/assets/icons/battery/battery-030.svg new file mode 100644 index 0000000..312ab1b --- /dev/null +++ b/config/assets/icons/battery/battery-030.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/battery/battery-040-charging.svg b/config/assets/icons/battery/battery-040-charging.svg new file mode 100644 index 0000000..1327b10 --- /dev/null +++ b/config/assets/icons/battery/battery-040-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-040.svg b/config/assets/icons/battery/battery-040.svg new file mode 100644 index 0000000..ff8a28a --- /dev/null +++ b/config/assets/icons/battery/battery-040.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/battery/battery-050-charging.svg b/config/assets/icons/battery/battery-050-charging.svg new file mode 100644 index 0000000..016985f --- /dev/null +++ b/config/assets/icons/battery/battery-050-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-050.svg b/config/assets/icons/battery/battery-050.svg new file mode 100644 index 0000000..0e6af06 --- /dev/null +++ b/config/assets/icons/battery/battery-050.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/battery/battery-060-charging.svg b/config/assets/icons/battery/battery-060-charging.svg new file mode 100644 index 0000000..e23c733 --- /dev/null +++ b/config/assets/icons/battery/battery-060-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-060.svg b/config/assets/icons/battery/battery-060.svg new file mode 100644 index 0000000..f9c44b9 --- /dev/null +++ b/config/assets/icons/battery/battery-060.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-070-charging.svg b/config/assets/icons/battery/battery-070-charging.svg new file mode 100644 index 0000000..cf18c55 --- /dev/null +++ b/config/assets/icons/battery/battery-070-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-070.svg b/config/assets/icons/battery/battery-070.svg new file mode 100644 index 0000000..abd1a8e --- /dev/null +++ b/config/assets/icons/battery/battery-070.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/battery/battery-080-charging.svg b/config/assets/icons/battery/battery-080-charging.svg new file mode 100644 index 0000000..672c08f --- /dev/null +++ b/config/assets/icons/battery/battery-080-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-080.svg b/config/assets/icons/battery/battery-080.svg new file mode 100644 index 0000000..0a78582 --- /dev/null +++ b/config/assets/icons/battery/battery-080.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/battery/battery-090-charging.svg b/config/assets/icons/battery/battery-090-charging.svg new file mode 100644 index 0000000..2e81ab9 --- /dev/null +++ b/config/assets/icons/battery/battery-090-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-090.svg b/config/assets/icons/battery/battery-090.svg new file mode 100644 index 0000000..8cc1219 --- /dev/null +++ b/config/assets/icons/battery/battery-090.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/battery/battery-100-charging.svg b/config/assets/icons/battery/battery-100-charging.svg new file mode 100644 index 0000000..22471ae --- /dev/null +++ b/config/assets/icons/battery/battery-100-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/config/assets/icons/battery/battery-100.svg b/config/assets/icons/battery/battery-100.svg new file mode 100644 index 0000000..9d9104b --- /dev/null +++ b/config/assets/icons/battery/battery-100.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/brightness/brightness-0.svg b/config/assets/icons/brightness/brightness-0.svg new file mode 100755 index 0000000..d6065c7 --- /dev/null +++ b/config/assets/icons/brightness/brightness-0.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/assets/icons/brightness/brightness-1.svg b/config/assets/icons/brightness/brightness-1.svg new file mode 100755 index 0000000..e5b4101 --- /dev/null +++ b/config/assets/icons/brightness/brightness-1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/assets/icons/brightness/brightness-2.svg b/config/assets/icons/brightness/brightness-2.svg new file mode 100755 index 0000000..df7cfba --- /dev/null +++ b/config/assets/icons/brightness/brightness-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/assets/icons/brightness/brightness-3.svg b/config/assets/icons/brightness/brightness-3.svg new file mode 100755 index 0000000..c3b390b --- /dev/null +++ b/config/assets/icons/brightness/brightness-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/assets/icons/brightness/brightness.svg b/config/assets/icons/brightness/brightness.svg new file mode 100755 index 0000000..bcc61ef --- /dev/null +++ b/config/assets/icons/brightness/brightness.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/assets/icons/mic/microphone-0.svg b/config/assets/icons/mic/microphone-0.svg new file mode 100755 index 0000000..ef1f97a --- /dev/null +++ b/config/assets/icons/mic/microphone-0.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/config/assets/icons/mic/microphone-1.svg b/config/assets/icons/mic/microphone-1.svg new file mode 100755 index 0000000..dfa3008 --- /dev/null +++ b/config/assets/icons/mic/microphone-1.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/config/assets/icons/mic/microphone-2.svg b/config/assets/icons/mic/microphone-2.svg new file mode 100755 index 0000000..0b47426 --- /dev/null +++ b/config/assets/icons/mic/microphone-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/config/assets/icons/mic/microphone-3.svg b/config/assets/icons/mic/microphone-3.svg new file mode 100755 index 0000000..f48bd31 --- /dev/null +++ b/config/assets/icons/mic/microphone-3.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/config/assets/icons/mic/microphone.svg b/config/assets/icons/mic/microphone.svg new file mode 100755 index 0000000..dfa3008 --- /dev/null +++ b/config/assets/icons/mic/microphone.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/config/assets/icons/misc/chevron-left.svg b/config/assets/icons/misc/chevron-left.svg new file mode 100644 index 0000000..8cade29 --- /dev/null +++ b/config/assets/icons/misc/chevron-left.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/config/assets/icons/misc/chevron-right.svg b/config/assets/icons/misc/chevron-right.svg new file mode 100644 index 0000000..f81b1e6 --- /dev/null +++ b/config/assets/icons/misc/chevron-right.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/config/assets/icons/misc/control-center.svg b/config/assets/icons/misc/control-center.svg new file mode 100644 index 0000000..24f2195 --- /dev/null +++ b/config/assets/icons/misc/control-center.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/config/assets/icons/misc/control.svg b/config/assets/icons/misc/control.svg new file mode 100644 index 0000000..ed4e9d7 --- /dev/null +++ b/config/assets/icons/misc/control.svg @@ -0,0 +1,3 @@ + + + diff --git a/config/assets/icons/misc/imac.svg b/config/assets/icons/misc/imac.svg new file mode 100644 index 0000000..47719db --- /dev/null +++ b/config/assets/icons/misc/imac.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/assets/icons/misc/logo.svg b/config/assets/icons/misc/logo.svg new file mode 100644 index 0000000..9fa578d --- /dev/null +++ b/config/assets/icons/misc/logo.svg @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/config/assets/icons/misc/media-record.svg b/config/assets/icons/misc/media-record.svg new file mode 100644 index 0000000..8f71f09 --- /dev/null +++ b/config/assets/icons/misc/media-record.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/config/assets/icons/misc/search.svg b/config/assets/icons/misc/search.svg new file mode 100644 index 0000000..ef84365 --- /dev/null +++ b/config/assets/icons/misc/search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/assets/icons/notification.png b/config/assets/icons/notification.png new file mode 100644 index 0000000..41d66d6 Binary files /dev/null and b/config/assets/icons/notification.png differ diff --git a/config/assets/icons/notifications/notification-active.svg b/config/assets/icons/notifications/notification-active.svg new file mode 100644 index 0000000..63452f8 --- /dev/null +++ b/config/assets/icons/notifications/notification-active.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/config/assets/icons/notifications/notification-disabled.svg b/config/assets/icons/notifications/notification-disabled.svg new file mode 100644 index 0000000..d4cb163 --- /dev/null +++ b/config/assets/icons/notifications/notification-disabled.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/config/assets/icons/notifications/notification-inactive.svg b/config/assets/icons/notifications/notification-inactive.svg new file mode 100644 index 0000000..108e755 --- /dev/null +++ b/config/assets/icons/notifications/notification-inactive.svg @@ -0,0 +1,12 @@ + + + + diff --git a/config/assets/icons/player/Pause.svg b/config/assets/icons/player/Pause.svg new file mode 100644 index 0000000..f95a5b8 --- /dev/null +++ b/config/assets/icons/player/Pause.svg @@ -0,0 +1,7 @@ + + + + Layer 1 + + + \ No newline at end of file diff --git a/config/assets/icons/player/Rewind.svg b/config/assets/icons/player/Rewind.svg new file mode 100644 index 0000000..e52bc1d --- /dev/null +++ b/config/assets/icons/player/Rewind.svg @@ -0,0 +1,3 @@ + + + diff --git a/config/assets/icons/player/audio-switcher.svg b/config/assets/icons/player/audio-switcher.svg new file mode 100644 index 0000000..d50264f --- /dev/null +++ b/config/assets/icons/player/audio-switcher.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/assets/icons/player/fwd.svg b/config/assets/icons/player/fwd.svg new file mode 100644 index 0000000..57f16c3 --- /dev/null +++ b/config/assets/icons/player/fwd.svg @@ -0,0 +1,3 @@ + + + diff --git a/config/assets/icons/player/play.svg b/config/assets/icons/player/play.svg new file mode 100644 index 0000000..d3356bf --- /dev/null +++ b/config/assets/icons/player/play.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/config/assets/icons/power_modes/battery-balanced.svg b/config/assets/icons/power_modes/battery-balanced.svg new file mode 100644 index 0000000..9d9104b --- /dev/null +++ b/config/assets/icons/power_modes/battery-balanced.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/power_modes/battery-performance.svg b/config/assets/icons/power_modes/battery-performance.svg new file mode 100644 index 0000000..3bd7b42 --- /dev/null +++ b/config/assets/icons/power_modes/battery-performance.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + diff --git a/config/assets/icons/power_modes/battery-power.svg b/config/assets/icons/power_modes/battery-power.svg new file mode 100644 index 0000000..87375ae --- /dev/null +++ b/config/assets/icons/power_modes/battery-power.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/config/assets/icons/todo/checkbox-check.svg b/config/assets/icons/todo/checkbox-check.svg new file mode 100644 index 0000000..198f1dd --- /dev/null +++ b/config/assets/icons/todo/checkbox-check.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/config/assets/icons/todo/checkbox-uncheck.svg b/config/assets/icons/todo/checkbox-uncheck.svg new file mode 100644 index 0000000..a0ee941 --- /dev/null +++ b/config/assets/icons/todo/checkbox-uncheck.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + diff --git a/config/assets/icons/todo/delete-symbolic.svg b/config/assets/icons/todo/delete-symbolic.svg new file mode 100644 index 0000000..180f034 --- /dev/null +++ b/config/assets/icons/todo/delete-symbolic.svg @@ -0,0 +1,43 @@ + + + + + + + + diff --git a/config/assets/icons/todo/edit.svg b/config/assets/icons/todo/edit.svg new file mode 100644 index 0000000..42b5282 --- /dev/null +++ b/config/assets/icons/todo/edit.svg @@ -0,0 +1,59 @@ + + + + + + + + image/svg+xml + + + + + + + + diff --git a/config/assets/icons/todo/plus-symbolic.svg b/config/assets/icons/todo/plus-symbolic.svg new file mode 100644 index 0000000..af065bd --- /dev/null +++ b/config/assets/icons/todo/plus-symbolic.svg @@ -0,0 +1,51 @@ + + + + + + + diff --git a/config/assets/icons/todo/to-do-app-symbolic.svg b/config/assets/icons/todo/to-do-app-symbolic.svg new file mode 100644 index 0000000..17cbcdf --- /dev/null +++ b/config/assets/icons/todo/to-do-app-symbolic.svg @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 0000000..2728d98 --- /dev/null +++ b/config/assets/icons/volume/audio-volume-0.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/config/assets/icons/volume/audio-volume-1.svg b/config/assets/icons/volume/audio-volume-1.svg new file mode 100644 index 0000000..8378bed --- /dev/null +++ b/config/assets/icons/volume/audio-volume-1.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/config/assets/icons/volume/audio-volume-2.svg b/config/assets/icons/volume/audio-volume-2.svg new file mode 100644 index 0000000..927462a --- /dev/null +++ b/config/assets/icons/volume/audio-volume-2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/config/assets/icons/volume/audio-volume-3.svg b/config/assets/icons/volume/audio-volume-3.svg new file mode 100644 index 0000000..abd00cb --- /dev/null +++ b/config/assets/icons/volume/audio-volume-3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/config/assets/icons/volume/audio-volume.svg b/config/assets/icons/volume/audio-volume.svg new file mode 100644 index 0000000..abd00cb --- /dev/null +++ b/config/assets/icons/volume/audio-volume.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/config/assets/icons/wifi/network-wireless-0.svg b/config/assets/icons/wifi/network-wireless-0.svg new file mode 100644 index 0000000..79a7d67 --- /dev/null +++ b/config/assets/icons/wifi/network-wireless-0.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/config/assets/icons/wifi/network-wireless-100.svg b/config/assets/icons/wifi/network-wireless-100.svg new file mode 100644 index 0000000..9276ebe --- /dev/null +++ b/config/assets/icons/wifi/network-wireless-100.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/config/assets/icons/wifi/network-wireless-20.svg b/config/assets/icons/wifi/network-wireless-20.svg new file mode 100644 index 0000000..5dd4259 --- /dev/null +++ b/config/assets/icons/wifi/network-wireless-20.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/config/assets/icons/wifi/network-wireless-40.svg b/config/assets/icons/wifi/network-wireless-40.svg new file mode 100644 index 0000000..d323c5c --- /dev/null +++ b/config/assets/icons/wifi/network-wireless-40.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/config/assets/icons/wifi/network-wireless-60.svg b/config/assets/icons/wifi/network-wireless-60.svg new file mode 100644 index 0000000..47c2a37 --- /dev/null +++ b/config/assets/icons/wifi/network-wireless-60.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/config/assets/icons/wifi/network-wireless-80.svg b/config/assets/icons/wifi/network-wireless-80.svg new file mode 100644 index 0000000..6e65071 --- /dev/null +++ b/config/assets/icons/wifi/network-wireless-80.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/config/assets/icons/wifi/wifi-connecting.svg b/config/assets/icons/wifi/wifi-connecting.svg new file mode 100644 index 0000000..6d95e92 --- /dev/null +++ b/config/assets/icons/wifi/wifi-connecting.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/config/assets/launcher.json b/config/assets/launcher.json new file mode 100644 index 0000000..9d411e0 --- /dev/null +++ b/config/assets/launcher.json @@ -0,0 +1,98 @@ +{ + "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 new file mode 100644 index 0000000..e4aa977 --- /dev/null +++ b/config/data.py @@ -0,0 +1,118 @@ +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 new file mode 100644 index 0000000..cfa51cb --- /dev/null +++ b/config/hypr/modus.conf @@ -0,0 +1,53 @@ +# 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 new file mode 100644 index 0000000..9aeb090 --- /dev/null +++ b/config/matugen/templates/hyprland-colors.conf @@ -0,0 +1,27 @@ +$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 new file mode 100644 index 0000000..7d8c607 --- /dev/null +++ b/config/matugen/templates/macos.css @@ -0,0 +1,32 @@ +: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 new file mode 100644 index 0000000..2e175e7 --- /dev/null +++ b/debug_memory.py @@ -0,0 +1,165 @@ +#!/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 new file mode 100755 index 0000000..e17294d --- /dev/null +++ b/install.sh @@ -0,0 +1,187 @@ +#!/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 new file mode 100644 index 0000000..86a889c --- /dev/null +++ b/lock.py @@ -0,0 +1,234 @@ +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 new file mode 100644 index 0000000..738870f --- /dev/null +++ b/main.css @@ -0,0 +1,34 @@ +@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 new file mode 100644 index 0000000..1ba20cf --- /dev/null +++ b/main.py @@ -0,0 +1,88 @@ +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 new file mode 100644 index 0000000..46c7f1c --- /dev/null +++ b/modules/about.py @@ -0,0 +1,537 @@ +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 new file mode 100644 index 0000000..068de6e --- /dev/null +++ b/modules/controlcenter/battery.py @@ -0,0 +1,393 @@ +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 new file mode 100644 index 0000000..08346aa --- /dev/null +++ b/modules/controlcenter/bluetooth.py @@ -0,0 +1,613 @@ +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 new file mode 100644 index 0000000..99d0ad4 --- /dev/null +++ b/modules/controlcenter/expanded_player.py @@ -0,0 +1,1398 @@ +# 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 new file mode 100644 index 0000000..cfa00bc --- /dev/null +++ b/modules/controlcenter/main.py @@ -0,0 +1,1078 @@ +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 new file mode 100644 index 0000000..c5414c2 --- /dev/null +++ b/modules/controlcenter/nightlight.py @@ -0,0 +1,146 @@ +# 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 new file mode 100644 index 0000000..08ba18a --- /dev/null +++ b/modules/controlcenter/per_app_volume.py @@ -0,0 +1,336 @@ +# 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 new file mode 100644 index 0000000..d5a6fd4 --- /dev/null +++ b/modules/controlcenter/player.py @@ -0,0 +1,955 @@ +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 new file mode 100644 index 0000000..6cb612d --- /dev/null +++ b/modules/controlcenter/wifi.py @@ -0,0 +1,715 @@ +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 new file mode 100644 index 0000000..cf3c79e --- /dev/null +++ b/modules/corners.py @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..0c836f4 --- /dev/null +++ b/modules/dock.py @@ -0,0 +1,843 @@ +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 new file mode 100644 index 0000000..547fa73 --- /dev/null +++ b/modules/launcher/__init__.py @@ -0,0 +1,8 @@ +""" +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 new file mode 100644 index 0000000..30ada34 --- /dev/null +++ b/modules/launcher/main.py @@ -0,0 +1,1380 @@ +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 new file mode 100644 index 0000000..13bd1fc --- /dev/null +++ b/modules/launcher/plugin_base.py @@ -0,0 +1,172 @@ +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 new file mode 100644 index 0000000..caf002e --- /dev/null +++ b/modules/launcher/plugin_manager.py @@ -0,0 +1,183 @@ +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 new file mode 100644 index 0000000..666274b --- /dev/null +++ b/modules/launcher/plugins/__init__.py @@ -0,0 +1,3 @@ +""" +Built-in plugins for the launcher. +""" diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py new file mode 100644 index 0000000..832d75b --- /dev/null +++ b/modules/launcher/plugins/applications.py @@ -0,0 +1,185 @@ +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 new file mode 100644 index 0000000..44bb2e8 --- /dev/null +++ b/modules/launcher/plugins/bash_scripts.py @@ -0,0 +1,431 @@ +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 new file mode 100644 index 0000000..ee55139 --- /dev/null +++ b/modules/launcher/plugins/bookmarks.py @@ -0,0 +1,786 @@ +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 new file mode 100644 index 0000000..decde97 --- /dev/null +++ b/modules/launcher/plugins/caffeine.py @@ -0,0 +1,208 @@ +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 new file mode 100644 index 0000000..f97dddb --- /dev/null +++ b/modules/launcher/plugins/calculator.py @@ -0,0 +1,163 @@ +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 new file mode 100644 index 0000000..e332d19 --- /dev/null +++ b/modules/launcher/plugins/clipboard.py @@ -0,0 +1,438 @@ +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 new file mode 100644 index 0000000..8842e80 --- /dev/null +++ b/modules/launcher/plugins/emoji.py @@ -0,0 +1,187 @@ +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 new file mode 100644 index 0000000..7ab0b6d --- /dev/null +++ b/modules/launcher/plugins/otp.py @@ -0,0 +1,850 @@ +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 new file mode 100644 index 0000000..1551b20 --- /dev/null +++ b/modules/launcher/plugins/password.py @@ -0,0 +1,690 @@ +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 new file mode 100644 index 0000000..38b9cd8 --- /dev/null +++ b/modules/launcher/plugins/power.py @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..ded8c20 --- /dev/null +++ b/modules/launcher/plugins/reminders.py @@ -0,0 +1,448 @@ +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 new file mode 100644 index 0000000..05253b6 --- /dev/null +++ b/modules/launcher/plugins/screencapture.py @@ -0,0 +1,687 @@ +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 new file mode 100644 index 0000000..bc7dba0 --- /dev/null +++ b/modules/launcher/plugins/system.py @@ -0,0 +1,242 @@ +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 new file mode 100644 index 0000000..5c93fad --- /dev/null +++ b/modules/launcher/plugins/tmux.py @@ -0,0 +1,334 @@ +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 new file mode 100644 index 0000000..3d4aa28 --- /dev/null +++ b/modules/launcher/plugins/wallpaper.py @@ -0,0 +1,976 @@ +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 new file mode 100644 index 0000000..80f992b --- /dev/null +++ b/modules/launcher/plugins/websearch.py @@ -0,0 +1,259 @@ +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 new file mode 100644 index 0000000..fce3550 --- /dev/null +++ b/modules/launcher/result.py @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..baaaa43 --- /dev/null +++ b/modules/launcher/result_item.py @@ -0,0 +1,163 @@ +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 new file mode 100644 index 0000000..3a568c4 --- /dev/null +++ b/modules/launcher/trigger_config.py @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..77be746 --- /dev/null +++ b/modules/notification/notification.py @@ -0,0 +1,1281 @@ +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 new file mode 100644 index 0000000..3aa77b0 --- /dev/null +++ b/modules/notification/notification_center.py @@ -0,0 +1,923 @@ +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 new file mode 100644 index 0000000..0bd673b --- /dev/null +++ b/modules/notification/unified_cache.py @@ -0,0 +1,197 @@ +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 new file mode 100644 index 0000000..6b27aaa --- /dev/null +++ b/modules/osd.py @@ -0,0 +1,467 @@ +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 new file mode 100644 index 0000000..ac7e63b --- /dev/null +++ b/modules/panel/components/enhanced_system_tray.py @@ -0,0 +1,165 @@ +""" +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 new file mode 100644 index 0000000..7e8e277 --- /dev/null +++ b/modules/panel/components/indicators.py @@ -0,0 +1,477 @@ +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 new file mode 100644 index 0000000..61e7735 --- /dev/null +++ b/modules/panel/components/menubar.py @@ -0,0 +1,411 @@ +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 new file mode 100644 index 0000000..ab318e3 --- /dev/null +++ b/modules/panel/components/recording_indicator.py @@ -0,0 +1,236 @@ +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 new file mode 100644 index 0000000..c05dab0 --- /dev/null +++ b/modules/panel/components/workspace.py @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..5228e7c --- /dev/null +++ b/modules/panel/main.py @@ -0,0 +1,243 @@ +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 new file mode 100644 index 0000000..564c54a --- /dev/null +++ b/modules/switcher.py @@ -0,0 +1,405 @@ +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 new file mode 100644 index 0000000..30afe86 --- /dev/null +++ b/modules/todo/__init__.py @@ -0,0 +1 @@ +# Todo module \ No newline at end of file diff --git a/modules/todo/todo_widget.py b/modules/todo/todo_widget.py new file mode 100644 index 0000000..a19cb96 --- /dev/null +++ b/modules/todo/todo_widget.py @@ -0,0 +1,456 @@ +# 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 new file mode 100644 index 0000000..21ce287 --- /dev/null +++ b/modules/widget.py @@ -0,0 +1,887 @@ +# 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 new file mode 100644 index 0000000..1d0d377 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +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 new file mode 100755 index 0000000..3652ccd --- /dev/null +++ b/scripts/gamemode.sh @@ -0,0 +1,40 @@ +#!/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 new file mode 100755 index 0000000..703c7df --- /dev/null +++ b/scripts/hyprpicker.sh @@ -0,0 +1,75 @@ +#!/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 new file mode 100755 index 0000000..a1fd899 --- /dev/null +++ b/scripts/screen-capture.sh @@ -0,0 +1,704 @@ +#!/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 new file mode 100644 index 0000000..3f03f12 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,4 @@ +""" +Modus services package. +Contains background services and utilities for the shell. +""" diff --git a/services/auth.py b/services/auth.py new file mode 100644 index 0000000..3a7b070 --- /dev/null +++ b/services/auth.py @@ -0,0 +1,348 @@ +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 new file mode 100644 index 0000000..a28cceb --- /dev/null +++ b/services/battery.py @@ -0,0 +1,251 @@ +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 new file mode 100644 index 0000000..1031444 --- /dev/null +++ b/services/brightness.py @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..7619887 --- /dev/null +++ b/services/custom_notification.py @@ -0,0 +1,462 @@ +# 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 new file mode 100644 index 0000000..f9e9444 --- /dev/null +++ b/services/modus.py @@ -0,0 +1,359 @@ +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 new file mode 100644 index 0000000..539a727 --- /dev/null +++ b/services/mpris.py @@ -0,0 +1,303 @@ +# 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 new file mode 100644 index 0000000..619e81d --- /dev/null +++ b/services/network.py @@ -0,0 +1,667 @@ +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 new file mode 100644 index 0000000..876505e --- /dev/null +++ b/services/todo.py @@ -0,0 +1,169 @@ +# 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 new file mode 100644 index 0000000..77ace66 --- /dev/null +++ b/styles/about.css @@ -0,0 +1,79 @@ +#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 new file mode 100644 index 0000000..763cfe4 --- /dev/null +++ b/styles/battery-widget.css @@ -0,0 +1,149 @@ +#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 new file mode 100644 index 0000000..1a391d3 --- /dev/null +++ b/styles/colors.css @@ -0,0 +1,32 @@ +: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 new file mode 100644 index 0000000..a60738d --- /dev/null +++ b/styles/controlcenter.css @@ -0,0 +1,988 @@ +.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 new file mode 100644 index 0000000..24eb0f7 --- /dev/null +++ b/styles/dock.css @@ -0,0 +1,98 @@ +#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 new file mode 100644 index 0000000..2f036ad --- /dev/null +++ b/styles/dropdown.css @@ -0,0 +1,61 @@ +#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 new file mode 100644 index 0000000..24382a4 --- /dev/null +++ b/styles/launcher.css @@ -0,0 +1,230 @@ +#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 new file mode 100644 index 0000000..00fe279 --- /dev/null +++ b/styles/lock.css @@ -0,0 +1,65 @@ +#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 new file mode 100644 index 0000000..0c041d3 --- /dev/null +++ b/styles/notification-center.css @@ -0,0 +1,297 @@ +#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 new file mode 100644 index 0000000..35a7db0 --- /dev/null +++ b/styles/notification.css @@ -0,0 +1,84 @@ +#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 new file mode 100644 index 0000000..c4c2082 --- /dev/null +++ b/styles/osd.css @@ -0,0 +1,38 @@ +#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 new file mode 100644 index 0000000..218c358 --- /dev/null +++ b/styles/panel.css @@ -0,0 +1,147 @@ +#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 new file mode 100644 index 0000000..1a9b651 --- /dev/null +++ b/styles/player.css @@ -0,0 +1,178 @@ +#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 new file mode 100644 index 0000000..5302281 --- /dev/null +++ b/styles/switcher.css @@ -0,0 +1,38 @@ +#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 new file mode 100644 index 0000000..a65ddf4 --- /dev/null +++ b/styles/todo.css @@ -0,0 +1,258 @@ +/* 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 new file mode 100644 index 0000000..d10b0ef --- /dev/null +++ b/styles/tray.css @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..8656200 --- /dev/null +++ b/styles/widgets.css @@ -0,0 +1,218 @@ +/* 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 new file mode 100644 index 0000000..3f03f12 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,4 @@ +""" +Modus services package. +Contains background services and utilities for the shell. +""" diff --git a/utils/animator.py b/utils/animator.py new file mode 100644 index 0000000..7dd063d --- /dev/null +++ b/utils/animator.py @@ -0,0 +1,180 @@ +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 new file mode 100644 index 0000000..13b76df --- /dev/null +++ b/utils/app_name_resolver.py @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..c39b4da --- /dev/null +++ b/utils/conversion.py @@ -0,0 +1,620 @@ +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 new file mode 100644 index 0000000..edbf259 --- /dev/null +++ b/utils/functions.py @@ -0,0 +1,174 @@ +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 new file mode 100644 index 0000000..58ac1e4 --- /dev/null +++ b/utils/icon_resolver.py @@ -0,0 +1,106 @@ +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 new file mode 100644 index 0000000..6b5a106 --- /dev/null +++ b/utils/inhibit.py @@ -0,0 +1,186 @@ +# 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 new file mode 100644 index 0000000..01b706e --- /dev/null +++ b/utils/monitors.py @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..b81ee75 --- /dev/null +++ b/utils/occlusion.py @@ -0,0 +1,140 @@ +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 new file mode 100644 index 0000000..4127d98 --- /dev/null +++ b/utils/roam.py @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..aefd7e0 --- /dev/null +++ b/widgets/circle_image.py @@ -0,0 +1,125 @@ +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 new file mode 100644 index 0000000..1aae7f6 --- /dev/null +++ b/widgets/custom_image.py @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..bf47e52 --- /dev/null +++ b/widgets/customrevealer.py @@ -0,0 +1,320 @@ +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 new file mode 100644 index 0000000..cb159c9 --- /dev/null +++ b/widgets/dropdown.py @@ -0,0 +1,91 @@ +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 new file mode 100644 index 0000000..af432fc --- /dev/null +++ b/widgets/mousecapture.py @@ -0,0 +1,142 @@ +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 new file mode 100644 index 0000000..d6d600c --- /dev/null +++ b/widgets/popup_window.py @@ -0,0 +1,191 @@ +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 new file mode 100644 index 0000000..b7c76aa --- /dev/null +++ b/widgets/wayland.py @@ -0,0 +1,370 @@ +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 new file mode 100644 index 0000000..9d53586 --- /dev/null +++ b/widgets/wifi_password_dialog.py @@ -0,0 +1,291 @@ +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()