diff --git a/Ax-Shell/LICENSE b/Ax-Shell/LICENSE new file mode 100644 index 0000000..9faa44e --- /dev/null +++ b/Ax-Shell/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + Ax-Shell: A hackable shell for Hyprland, powered by Fabric. + Copyright (C) 2025 Adriano Tisera (Axenide) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +You can contact me through my e-mail or my socials: https://zaap.bio/Axenide + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Ax-Shell/assets/ax.png b/Ax-Shell/assets/ax.png new file mode 100644 index 0000000..7f7533f Binary files /dev/null and b/Ax-Shell/assets/ax.png differ diff --git a/Ax-Shell/assets/cover.png b/Ax-Shell/assets/cover.png new file mode 100644 index 0000000..77c1782 Binary files /dev/null and b/Ax-Shell/assets/cover.png differ diff --git a/Ax-Shell/assets/default.png b/Ax-Shell/assets/default.png new file mode 100644 index 0000000..774a4e0 Binary files /dev/null and b/Ax-Shell/assets/default.png differ diff --git a/Ax-Shell/assets/emoji.json b/Ax-Shell/assets/emoji.json new file mode 100644 index 0000000..fa174ff --- /dev/null +++ b/Ax-Shell/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/Ax-Shell/assets/fonts/tabler-icons/tabler-icons.ttf b/Ax-Shell/assets/fonts/tabler-icons/tabler-icons.ttf new file mode 100644 index 0000000..47345ae Binary files /dev/null and b/Ax-Shell/assets/fonts/tabler-icons/tabler-icons.ttf differ diff --git a/Ax-Shell/assets/ko-fi.gif b/Ax-Shell/assets/ko-fi.gif new file mode 100644 index 0000000..3e21d29 Binary files /dev/null and b/Ax-Shell/assets/ko-fi.gif differ diff --git a/Ax-Shell/assets/screenshots/1.png b/Ax-Shell/assets/screenshots/1.png new file mode 100644 index 0000000..756ab2b Binary files /dev/null and b/Ax-Shell/assets/screenshots/1.png differ diff --git a/Ax-Shell/assets/screenshots/2.png b/Ax-Shell/assets/screenshots/2.png new file mode 100644 index 0000000..3efcc65 Binary files /dev/null and b/Ax-Shell/assets/screenshots/2.png differ diff --git a/Ax-Shell/assets/screenshots/3.png b/Ax-Shell/assets/screenshots/3.png new file mode 100644 index 0000000..297b8b1 Binary files /dev/null and b/Ax-Shell/assets/screenshots/3.png differ diff --git a/Ax-Shell/assets/screenshots/4.png b/Ax-Shell/assets/screenshots/4.png new file mode 100644 index 0000000..dddd270 Binary files /dev/null and b/Ax-Shell/assets/screenshots/4.png differ diff --git a/Ax-Shell/assets/screenshots/5.png b/Ax-Shell/assets/screenshots/5.png new file mode 100644 index 0000000..0cb2e47 Binary files /dev/null and b/Ax-Shell/assets/screenshots/5.png differ diff --git a/Ax-Shell/assets/soon.png b/Ax-Shell/assets/soon.png new file mode 100644 index 0000000..17d9f59 Binary files /dev/null and b/Ax-Shell/assets/soon.png differ diff --git a/Ax-Shell/assets/wallpapers_example/example-1.jpg b/Ax-Shell/assets/wallpapers_example/example-1.jpg new file mode 100644 index 0000000..1ab6b4c Binary files /dev/null and b/Ax-Shell/assets/wallpapers_example/example-1.jpg differ diff --git a/Ax-Shell/assets/wallpapers_example/example-2.jpg b/Ax-Shell/assets/wallpapers_example/example-2.jpg new file mode 100644 index 0000000..49da791 Binary files /dev/null and b/Ax-Shell/assets/wallpapers_example/example-2.jpg differ diff --git a/Ax-Shell/assets/wallpapers_example/example-3.jpg b/Ax-Shell/assets/wallpapers_example/example-3.jpg new file mode 100644 index 0000000..fa5efbd Binary files /dev/null and b/Ax-Shell/assets/wallpapers_example/example-3.jpg differ diff --git a/Ax-Shell/assets/wallpapers_example/example-4.webp b/Ax-Shell/assets/wallpapers_example/example-4.webp new file mode 100644 index 0000000..4fce245 Binary files /dev/null and b/Ax-Shell/assets/wallpapers_example/example-4.webp differ diff --git a/Ax-Shell/assets/wallpapers_example/example-5.jpg b/Ax-Shell/assets/wallpapers_example/example-5.jpg new file mode 100644 index 0000000..502e0f2 Binary files /dev/null and b/Ax-Shell/assets/wallpapers_example/example-5.jpg differ diff --git a/Ax-Shell/config/__init__.py b/Ax-Shell/config/__init__.py new file mode 100644 index 0000000..4cde1a6 --- /dev/null +++ b/Ax-Shell/config/__init__.py @@ -0,0 +1,6 @@ +""" +Ax-Shell configuration package. +""" +# Import only specific names actually defined in data.py +# This prevents circular imports by not importing everything +from .data import APP_NAME, APP_NAME_CAP, CACHE_DIR, CONFIG_FILE diff --git a/Ax-Shell/config/cavalcade/cava.ini b/Ax-Shell/config/cavalcade/cava.ini new file mode 100644 index 0000000..63c0d55 --- /dev/null +++ b/Ax-Shell/config/cavalcade/cava.ini @@ -0,0 +1,36 @@ +[general] +bars = 24 + +sensitivity = 100 +framerate = 60 +lower_cutoff_freq = 50 +higher_cutoff_freq = 8000 +autosens = 1 + +# these parameters should not be changed +# bar width and spacing goes wrong with raw output +bar_width = 1 +bar_spacing = 0 + +[output] +method = raw +raw_target = /tmp/cava.fifo +bit_format = 16bit +channels = stereo + +[smoothing] +gravity = 200 +integral = 70 +ignore = 0 +monstercat = 1 + +[eq] +1 = 1.00 +2 = 1.00 +3 = 1.00 +4 = 1.00 +5 = 1.00 +6 = 1.00 +7 = 1.00 +8 = 1.00 +9 = 1.00 diff --git a/Ax-Shell/config/config.py b/Ax-Shell/config/config.py new file mode 100644 index 0000000..6aaf4d9 --- /dev/null +++ b/Ax-Shell/config/config.py @@ -0,0 +1,81 @@ +import os +import sys +from pathlib import Path + + +def _configure_sys_path_for_direct_execution(): + """ + Ajusta sys.path si este script se ejecuta directamente, + para asegurar que las importaciones relativas dentro del paquete 'config' funcionen. + Esto permite ejecutar `python config/config.py` desde cualquier directorio. + """ + if __name__ == "__main__": + current_file_dir = Path(__file__).resolve().parent + project_root = current_file_dir.parent + + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + +_configure_sys_path_for_direct_execution() + +import shutil + +from fabric import Application + +if __name__ == "__main__" and not __package__: + from config.data import APP_NAME, APP_NAME_CAP + from config.settings_gui import HyprConfGUI + from config.settings_utils import load_bind_vars +else: + from .data import APP_NAME, APP_NAME_CAP + from .settings_gui import HyprConfGUI + from .settings_utils import load_bind_vars + + +def open_config(): + """ + Entry point for opening the configuration GUI using Fabric Application. + """ + load_bind_vars() + + show_lock_checkbox = True + dest_lock = os.path.expanduser("~/.config/hypr/hyprlock.conf") + src_lock = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/hyprlock.conf") + if not os.path.exists(dest_lock) and os.path.exists(src_lock): + try: + os.makedirs(os.path.dirname(dest_lock), exist_ok=True) + shutil.copy(src_lock, dest_lock) + show_lock_checkbox = False + print(f"Copied default hyprlock config to {dest_lock}") + except Exception as e: + print(f"Error copying default hyprlock config: {e}") + show_lock_checkbox = os.path.exists(src_lock) + + show_idle_checkbox = True + dest_idle = os.path.expanduser("~/.config/hypr/hypridle.conf") + src_idle = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/hypridle.conf") + if not os.path.exists(dest_idle) and os.path.exists(src_idle): + try: + os.makedirs(os.path.dirname(dest_idle), exist_ok=True) + shutil.copy(src_idle, dest_idle) + show_idle_checkbox = False + print(f"Copied default hypridle config to {dest_idle}") + except Exception as e: + print(f"Error copying default hypridle config: {e}") + show_idle_checkbox = os.path.exists(src_idle) + + app = Application(f"{APP_NAME}-settings") + window = HyprConfGUI( + show_lock_checkbox=show_lock_checkbox, + show_idle_checkbox=show_idle_checkbox, + application=app, + on_destroy=lambda *_: app.quit() + ) + app.add_window(window) + + window.show_all() + app.run() + + +if __name__ == "__main__": + open_config() diff --git a/Ax-Shell/config/data.py b/Ax-Shell/config/data.py new file mode 100644 index 0000000..f567be0 --- /dev/null +++ b/Ax-Shell/config/data.py @@ -0,0 +1,106 @@ +import json +import os + +import gi + +gi.require_version("Gtk", "3.0") +from fabric.utils.helpers import get_relative_path +from gi.repository import Gdk, GLib + +APP_NAME_CAP = "Ax-Shell" +APP_NAME = APP_NAME_CAP.lower() + +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() + +CONFIG_FILE = get_relative_path("../config/config.json") +MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") + + +def load_config(): + """Load the configuration from config.json""" + config_path = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/config.json") + config = {} + + if os.path.exists(config_path): + try: + with open(config_path, "r") as f: + config = json.load(f) + except Exception as e: + print(f"Error loading config: {e}") + + return config + + +# Import defaults from settings_constants to avoid duplication +from .settings_constants import DEFAULTS + +# Load configuration once and use throughout the module +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 file: {e}") + + +def get_default(setting_str: str): + return DEFAULTS[setting_str] if setting_str in DEFAULTS else "" + + +def _get_config_var(setting_str: str): + return config.get(setting_str, get_default(setting_str)) + + +# Set configuration values using defaults from settings_constants +WALLPAPERS_DIR = _get_config_var("wallpapers_dir") +BAR_POSITION = _get_config_var("bar_position") +VERTICAL = BAR_POSITION in ["Left", "Right"] +CENTERED_BAR = _get_config_var("centered_bar") +DATETIME_12H_FORMAT = _get_config_var("datetime_12h_format") +TERMINAL_COMMAND = _get_config_var("terminal_command") +DOCK_ENABLED = _get_config_var("dock_enabled") +DOCK_ALWAYS_SHOW = _get_config_var("dock_always_show") +DOCK_ICON_SIZE = _get_config_var("dock_icon_size") +BAR_WORKSPACE_SHOW_NUMBER = _get_config_var("bar_workspace_show_number") +BAR_WORKSPACE_USE_CHINESE_NUMERALS = _get_config_var( + "bar_workspace_use_chinese_numerals" +) +BAR_HIDE_SPECIAL_WORKSPACE = _get_config_var("bar_hide_special_workspace") +BAR_THEME = _get_config_var("bar_theme") +DOCK_THEME = _get_config_var("dock_theme") +PANEL_THEME = _get_config_var("panel_theme") +PANEL_POSITION = _get_config_var("panel_position") +NOTIF_POS = _get_config_var("notif_pos") + +BAR_COMPONENTS_VISIBILITY = { + "button_apps": _get_config_var("bar_button_apps_visible"), + "systray": _get_config_var("bar_systray_visible"), + "control": _get_config_var("bar_control_visible"), + "network": _get_config_var("bar_network_visible"), + "button_tools": _get_config_var("bar_button_tools_visible"), + "sysprofiles": _get_config_var("bar_sysprofiles_visible"), + "button_overview": _get_config_var("bar_button_overview_visible"), + "ws_container": _get_config_var("bar_ws_container_visible"), + "weather": _get_config_var("bar_weather_visible"), + "battery": _get_config_var("bar_battery_visible"), + "metrics": _get_config_var("bar_metrics_visible"), + "language": _get_config_var("bar_language_visible"), + "date_time": _get_config_var("bar_date_time_visible"), + "button_power": _get_config_var("bar_button_power_visible"), +} + +BAR_METRICS_DISKS = _get_config_var("bar_metrics_disks") +METRICS_VISIBLE = _get_config_var("metrics_visible") +METRICS_SMALL_VISIBLE = _get_config_var("metrics_small_visible") +SELECTED_MONITORS = _get_config_var("selected_monitors") diff --git a/Ax-Shell/config/hypr/hypridle.conf b/Ax-Shell/config/hypr/hypridle.conf new file mode 100644 index 0000000..340ff14 --- /dev/null +++ b/Ax-Shell/config/hypr/hypridle.conf @@ -0,0 +1,27 @@ +general { + lock_cmd = pidof hyprlock || hyprlock # avoid starting multiple hyprlock instances. + before_sleep_cmd = loginctl lock-session # lock before suspend. + after_sleep_cmd = hyprctl dispatch dpms on # to avoid having to press a key twice to turn on the display. +} + +listener { + timeout = 150 # 2.5min. + on-timeout = brightnessctl -s set 10 # set monitor backlight to minimum, avoid 0 on OLED monitor. + on-resume = brightnessctl -r # monitor backlight restore. +} + +listener { + timeout = 300 # 5min + on-timeout = loginctl lock-session # lock screen when timeout has passed +} + +listener { + timeout = 330 # 5.5min + on-timeout = hyprctl dispatch dpms off # screen off when timeout has passed + on-resume = hyprctl dispatch dpms on # screen on when activity is detected after timeout has fired. +} + +listener { + timeout = 1800 # 30min + on-timeout = systemctl suspend # suspend pc +} diff --git a/Ax-Shell/config/hypr/hyprlock.conf b/Ax-Shell/config/hypr/hyprlock.conf new file mode 100644 index 0000000..50adaaf --- /dev/null +++ b/Ax-Shell/config/hypr/hyprlock.conf @@ -0,0 +1,89 @@ +source = ~/.config/Ax-Shell/config/hypr/colors.conf + +# BACKGROUND +background { + monitor = + path = ~/.current.wall #path to background image + blur_passes = 3 + blur_size = 3 + contrast = 1.0 + brightness = 0.5 + vibrancy = 0.0 + vibrancy_darkness = 0.0 +} + +# GENERAL +general { + grace = 0 + hide_cursor = true +} + +# INPUT FIELD +input-field { + monitor = + size = 256, 48 + outline_thickness = 0 + dots_size = 0.2 # Scale of input-field height, 0.2 - 0.8 + dots_spacing = 0.5 # Scale of dots' absolute size, 0.0 - 1.0 + dots_center = true + outer_color = rgba(00000000) + inner_color = rgba(0, 0, 0, 1) + font_color = rgb($foreground) + fail_color = rgb($error) + check_color = rgb($tertiary) + capslock_color = rgb($secondary) + fade_on_empty = false + font_family = Iosevka Nerd Font + placeholder_text = ... #text for input password + hide_input = false + position = 0, -100 + halign = center + valign = center + shadow_passes = 1 + shadow_size = 5 + shadow_boost = 0.5 +} + +# TIME +label { + monitor = + text = cmd[update:1000] echo "$(date +"%H:%M:%S")" + color = rgb($foreground) + font_size = 14 + font_family = Iosevka Nerd Font Bold + position = 0, -150 + halign = center + valign = center + shadow_passes = 1 + shadow_size = 5 + shadow_boost = 0.5 +} + +# USER +label { + monitor = + text = cmd[update:1000] echo "$USER@$(hostname)" + color = rgb($foreground) + font_size = 14 + font_family = Iosevka Nerd Font Bold Italic + position = 0, -50 + halign = center + valign = center + shadow_passes = 1 + shadow_size = 5 + shadow_boost = 0.5 +} + +# PICTURE +image { + path = .face.icon + size = 200 + position = 0, 75 + halign = center + valign = center + border_size = 3 + border_color = rgb($primary) + shadow_passes = 1 + shadow_size = 5 + shadow_boost = 0.5 +} diff --git a/Ax-Shell/config/matugen/templates/ax-shell.css b/Ax-Shell/config/matugen/templates/ax-shell.css new file mode 100644 index 0000000..7d8c607 --- /dev/null +++ b/Ax-Shell/config/matugen/templates/ax-shell.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/Ax-Shell/config/matugen/templates/hyprland-colors.conf b/Ax-Shell/config/matugen/templates/hyprland-colors.conf new file mode 100644 index 0000000..9aeb090 --- /dev/null +++ b/Ax-Shell/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/Ax-Shell/config/settings_constants.py b/Ax-Shell/config/settings_constants.py new file mode 100644 index 0000000..1d93b22 --- /dev/null +++ b/Ax-Shell/config/settings_constants.py @@ -0,0 +1,103 @@ +from fabric.utils.helpers import get_relative_path + +from .data import ( + APP_NAME, + APP_NAME_CAP, +) + +SOURCE_STRING = f""" +# {APP_NAME_CAP} +source = ~/.config/{APP_NAME_CAP}/config/hypr/{APP_NAME}.conf +""" + +DEFAULTS = { + "prefix_restart": "SUPER ALT", + "suffix_restart": "B", + "prefix_axmsg": "SUPER", + "suffix_axmsg": "A", + "prefix_dash": "SUPER", + "suffix_dash": "D", + "prefix_bluetooth": "SUPER", + "suffix_bluetooth": "B", + "prefix_pins": "SUPER", + "suffix_pins": "Q", + "prefix_kanban": "SUPER", + "suffix_kanban": "N", + "prefix_launcher": "SUPER", + "suffix_launcher": "R", + "prefix_tmux": "SUPER", + "suffix_tmux": "T", + "prefix_cliphist": "SUPER", + "suffix_cliphist": "V", + "prefix_toolbox": "SUPER", + "suffix_toolbox": "S", + "prefix_overview": "SUPER", + "suffix_overview": "TAB", + "prefix_wallpapers": "SUPER", + "suffix_wallpapers": "COMMA", + "prefix_randwall": "SUPER SHIFT", + "suffix_randwall": "COMMA", + "prefix_mixer": "SUPER", + "suffix_mixer": "M", + "prefix_emoji": "SUPER", + "suffix_emoji": "PERIOD", + "prefix_power": "SUPER", + "suffix_power": "ESCAPE", + "prefix_caffeine": "SUPER SHIFT", + "suffix_caffeine": "M", + "prefix_toggle": "SUPER CTRL", + "suffix_toggle": "B", + "prefix_css": "SUPER SHIFT", + "suffix_css": "B", + "wallpapers_dir": get_relative_path("../assets/wallpapers_example"), + "prefix_restart_inspector": "SUPER CTRL ALT", + "suffix_restart_inspector": "B", + "bar_position": "Top", + "vertical": False, + "centered_bar": False, + "datetime_12h_format": False, + "terminal_command": "kitty -e", + "auto_append_hyprland": True, + "dock_enabled": True, + "dock_icon_size": 28, + "dock_always_show": False, + "bar_workspace_show_number": False, + "bar_workspace_use_chinese_numerals": False, + "bar_hide_special_workspace": True, + "bar_theme": "Pills", + "dock_theme": "Pills", + "panel_theme": "Notch", + "panel_position": "Center", + "notif_pos": "Top", + "bar_button_apps_visible": True, + "bar_systray_visible": True, + "bar_control_visible": True, + "bar_network_visible": True, + "bar_button_tools_visible": True, + "bar_sysprofiles_visible": True, + "bar_button_overview_visible": True, + "bar_ws_container_visible": True, + "bar_weather_visible": True, + "bar_battery_visible": True, + "bar_metrics_visible": True, + "bar_language_visible": True, + "bar_date_time_visible": True, + "bar_button_power_visible": True, + "corners_visible": True, + "bar_metrics_disks": ["/"], + "metrics_visible": { + "cpu": True, + "ram": True, + "disk": True, + "gpu": True, + }, + "metrics_small_visible": { + "cpu": True, + "ram": True, + "disk": True, + "gpu": True, + }, + "limited_apps_history": ["Spotify"], + "history_ignored_apps": ["Hyprshot"], + "selected_monitors": [], +} diff --git a/Ax-Shell/config/settings_gui.py b/Ax-Shell/config/settings_gui.py new file mode 100644 index 0000000..fffbc6d --- /dev/null +++ b/Ax-Shell/config/settings_gui.py @@ -0,0 +1,1488 @@ +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +import gi + +gi.require_version("Gtk", "3.0") +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.entry import Entry +from fabric.widgets.image import Image as FabricImage +from fabric.widgets.label import Label +from fabric.widgets.scale import Scale +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.stack import Stack +from fabric.widgets.window import Window +from gi.repository import GdkPixbuf, GLib, Gtk +from PIL import Image + +from .data import ( + APP_NAME, + APP_NAME_CAP, +) +from .settings_utils import ( + backup_and_replace, + bind_vars, + get_bind_var, + get_default, + start_config, +) + + +class HyprConfGUI(Window): + def __init__(self, show_lock_checkbox: bool, show_idle_checkbox: bool, **kwargs): + super().__init__( + title="Ax-Shell Settings", + name="axshell-settings-window", + size=(640, 640), + **kwargs, + ) + + self.set_resizable(False) + + self.selected_face_icon = None + self.show_lock_checkbox = show_lock_checkbox + self.show_idle_checkbox = show_idle_checkbox + + root_box = Box(orientation="v", spacing=10, style="margin: 10px;") + self.add(root_box) + + main_content_box = Box(orientation="h", spacing=6, v_expand=True, h_expand=True) + root_box.add(main_content_box) + + self.tab_stack = Stack( + transition_type="slide-up-down", + transition_duration=250, + v_expand=True, + h_expand=True, + ) + + self.key_bindings_tab_content = self.create_key_bindings_tab() + self.appearance_tab_content = self.create_appearance_tab() + self.system_tab_content = self.create_system_tab() + self.about_tab_content = self.create_about_tab() + + self.tab_stack.add_titled( + self.key_bindings_tab_content, "key_bindings", "Key Bindings" + ) + self.tab_stack.add_titled( + self.appearance_tab_content, "appearance", "Appearance" + ) + self.tab_stack.add_titled(self.system_tab_content, "system", "System") + self.tab_stack.add_titled(self.about_tab_content, "about", "About") + + tab_switcher = Gtk.StackSwitcher() + tab_switcher.set_stack(self.tab_stack) + tab_switcher.set_orientation(Gtk.Orientation.VERTICAL) + main_content_box.add(tab_switcher) + main_content_box.add(self.tab_stack) + + button_box = Box(orientation="h", spacing=10, h_align="end") + reset_btn = Button(label="Reset to Defaults", on_clicked=self.on_reset) + button_box.add(reset_btn) + close_btn = Button(label="Close", on_clicked=self.on_close) + button_box.add(close_btn) + accept_btn = Button(label="Apply & Reload", on_clicked=self.on_accept) + button_box.add(accept_btn) + root_box.add(button_box) + + def create_key_bindings_tab(self): + scrolled_window = ScrolledWindow( + h_scrollbar_policy="never", + v_scrollbar_policy="automatic", + h_expand=True, + v_expand=True, + propagate_width=False, + propagate_height=False, + ) + + main_vbox = Box(orientation="v", spacing=10, style="margin: 15px;") + scrolled_window.add(main_vbox) + + keybind_grid = Gtk.Grid() + keybind_grid.set_column_spacing(10) + keybind_grid.set_row_spacing(8) + keybind_grid.set_margin_start(5) + keybind_grid.set_margin_end(5) + keybind_grid.set_margin_top(5) + keybind_grid.set_margin_bottom(5) + + action_label = Label( + markup="Action", h_align="start", style="margin-bottom: 5px;" + ) + modifier_label = Label( + markup="Modifier", h_align="start", style="margin-bottom: 5px;" + ) + separator_label = Label( + label="+", h_align="center", style="margin-bottom: 5px;" + ) + key_label = Label( + markup="Key", h_align="start", style="margin-bottom: 5px;" + ) + + keybind_grid.attach(action_label, 0, 0, 1, 1) + keybind_grid.attach(modifier_label, 1, 0, 1, 1) + keybind_grid.attach(separator_label, 2, 0, 1, 1) + keybind_grid.attach(key_label, 3, 0, 1, 1) + + self.entries = [] + bindings = [ + (f"Reload {APP_NAME_CAP}", "prefix_restart", "suffix_restart"), + ("Message", "prefix_axmsg", "suffix_axmsg"), + ("Dashboard", "prefix_dash", "suffix_dash"), + ("Bluetooth", "prefix_bluetooth", "suffix_bluetooth"), + ("Pins", "prefix_pins", "suffix_pins"), + ("Kanban", "prefix_kanban", "suffix_kanban"), + ("App Launcher", "prefix_launcher", "suffix_launcher"), + ("Tmux", "prefix_tmux", "suffix_tmux"), + ("Clipboard History", "prefix_cliphist", "suffix_cliphist"), + ("Toolbox", "prefix_toolbox", "suffix_toolbox"), + ("Overview", "prefix_overview", "suffix_overview"), + ("Wallpapers", "prefix_wallpapers", "suffix_wallpapers"), + ("Random Wallpaper", "prefix_randwall", "suffix_randwall"), + ("Audio Mixer", "prefix_mixer", "suffix_mixer"), + ("Emoji Picker", "prefix_emoji", "suffix_emoji"), + ("Power Menu", "prefix_power", "suffix_power"), + ("Toggle Caffeine", "prefix_caffeine", "suffix_caffeine"), + ("Toggle Bar", "prefix_toggle", "suffix_toggle"), + ("Reload CSS", "prefix_css", "suffix_css"), + ( + "Restart with inspector", + "prefix_restart_inspector", + "suffix_restart_inspector", + ), + ] + + for i, (label_text, prefix_key, suffix_key) in enumerate(bindings): + row = i + 1 + binding_label = Label(label=label_text, h_align="start") + keybind_grid.attach(binding_label, 0, row, 1, 1) + prefix_entry = Entry(text=get_bind_var(prefix_key)) + keybind_grid.attach(prefix_entry, 1, row, 1, 1) + plus_label = Label(label="+", h_align="center") + keybind_grid.attach(plus_label, 2, row, 1, 1) + suffix_entry = Entry(text=get_bind_var(suffix_key)) + keybind_grid.attach(suffix_entry, 3, row, 1, 1) + self.entries.append((prefix_key, suffix_key, prefix_entry, suffix_entry)) + + main_vbox.add(keybind_grid) + return scrolled_window + + def create_appearance_tab(self): + scrolled_window = ScrolledWindow( + h_scrollbar_policy="never", + v_scrollbar_policy="automatic", + h_expand=True, + v_expand=True, + propagate_width=False, + propagate_height=False, + ) + + vbox = Box(orientation="v", spacing=15, style="margin: 15px;") + scrolled_window.add(vbox) + + top_grid = Gtk.Grid() + top_grid.set_column_spacing(20) + top_grid.set_row_spacing(5) + top_grid.set_margin_bottom(10) + vbox.add(top_grid) + + wall_header = Label(markup="Wallpapers", h_align="start") + top_grid.attach(wall_header, 0, 0, 1, 1) + wall_label = Label(label="Directory:", h_align="start", v_align="center") + top_grid.attach(wall_label, 0, 1, 1, 1) + + chooser_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + chooser_container.set_halign(Gtk.Align.START) + chooser_container.set_valign(Gtk.Align.CENTER) + self.wall_dir_chooser = Gtk.FileChooserButton( + title="Select a folder", action=Gtk.FileChooserAction.SELECT_FOLDER + ) + self.wall_dir_chooser.set_tooltip_text( + "Select the directory containing your wallpaper images" + ) + self.wall_dir_chooser.set_filename(get_bind_var("wallpapers_dir")) + self.wall_dir_chooser.set_size_request(180, -1) + chooser_container.add(self.wall_dir_chooser) + top_grid.attach(chooser_container, 1, 1, 1, 1) + + face_header = Label(markup="Profile Icon", h_align="start") + top_grid.attach(face_header, 2, 0, 2, 1) + current_face = os.path.expanduser("~/.face.icon") + face_image_container = Box( + style_classes=["image-frame"], h_align="center", v_align="center" + ) + self.face_image = FabricImage(size=64) + try: + if os.path.exists(current_face): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(current_face, 64, 64) + self.face_image.set_from_pixbuf(pixbuf) + else: + self.face_image.set_from_icon_name("user-info", Gtk.IconSize.DIALOG) + except Exception as e: + print(f"Error loading face icon: {e}") + self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) + face_image_container.add(self.face_image) + top_grid.attach(face_image_container, 2, 1, 1, 1) + + browse_btn_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + browse_btn_container.set_halign(Gtk.Align.START) + browse_btn_container.set_valign(Gtk.Align.CENTER) + face_btn = Button( + label="Browse...", + tooltip_text="Select a square image for your profile icon", + on_clicked=self.on_select_face_icon, + ) + browse_btn_container.add(face_btn) + top_grid.attach(browse_btn_container, 3, 1, 1, 1) + self.face_status_label = Label(label="", h_align="start") + top_grid.attach(self.face_status_label, 2, 2, 2, 1) + + separator1 = Box( + style="min-height: 1px; background-color: alpha(@fg_color, 0.2); margin: 5px 0px;", + h_expand=True, + ) + vbox.add(separator1) + + # START NEW SECTION FOR DATETIME FORMAT + datetime_format_header = Label( + markup="Date & Time Format", h_align="start" + ) + vbox.add(datetime_format_header) + + datetime_grid = Gtk.Grid() + datetime_grid.set_column_spacing(20) + datetime_grid.set_row_spacing(10) + datetime_grid.set_margin_start(10) + datetime_grid.set_margin_top(5) + datetime_grid.set_margin_bottom(10) # Adds space before the next section + vbox.add(datetime_grid) + + datetime_12h_label = Label( + label="Use 12-Hour Clock", h_align="start", v_align="center" + ) + datetime_grid.attach(datetime_12h_label, 0, 0, 1, 1) + + datetime_12h_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.datetime_12h_switch = Gtk.Switch( + active=get_bind_var("datetime_12h_format") + ) + datetime_12h_switch_container.add(self.datetime_12h_switch) + datetime_grid.attach(datetime_12h_switch_container, 1, 0, 1, 1) + # END NEW SECTION FOR DATETIME FORMAT + + layout_header = Label(markup="Layout Options", h_align="start") + vbox.add(layout_header) + layout_grid = Gtk.Grid() + layout_grid.set_column_spacing(20) + layout_grid.set_row_spacing(10) + layout_grid.set_margin_start(10) + layout_grid.set_margin_top(5) + vbox.add(layout_grid) + + position_label = Label(label="Bar Position", h_align="start", v_align="center") + layout_grid.attach(position_label, 0, 0, 1, 1) + position_combo_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.position_combo = Gtk.ComboBoxText() + self.position_combo.set_tooltip_text("Select the position of the bar") + positions = ["Top", "Bottom", "Left", "Right"] + for pos in positions: + self.position_combo.append_text(pos) + current_position = get_bind_var("bar_position") + try: + self.position_combo.set_active(positions.index(current_position)) + except ValueError: + self.position_combo.set_active(0) + self.position_combo.connect("changed", self.on_position_changed) + position_combo_container.add(self.position_combo) + layout_grid.attach(position_combo_container, 1, 0, 1, 1) + + centered_label = Label( + label="Centered Bar (Left/Right Only)", h_align="start", v_align="center" + ) + layout_grid.attach(centered_label, 2, 0, 1, 1) + centered_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.centered_switch = Gtk.Switch( + active=get_bind_var("centered_bar"), + sensitive=get_bind_var("bar_position") in ["Left", "Right"], + ) + centered_switch_container.add(self.centered_switch) + layout_grid.attach(centered_switch_container, 3, 0, 1, 1) + + dock_label = Label(label="Show Dock", h_align="start", v_align="center") + layout_grid.attach(dock_label, 0, 1, 1, 1) + dock_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.dock_switch = Gtk.Switch(active=get_bind_var("dock_enabled")) + self.dock_switch.connect("notify::active", self.on_dock_enabled_changed) + dock_switch_container.add(self.dock_switch) + layout_grid.attach(dock_switch_container, 1, 1, 1, 1) + + dock_hover_label = Label( + label="Always Show Dock", h_align="start", v_align="center" + ) + layout_grid.attach(dock_hover_label, 2, 1, 1, 1) + dock_hover_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.dock_hover_switch = Gtk.Switch( + active=get_bind_var("dock_always_show"), + sensitive=self.dock_switch.get_active(), + ) + dock_hover_switch_container.add(self.dock_hover_switch) + layout_grid.attach(dock_hover_switch_container, 3, 1, 1, 1) + + dock_size_label = Label( + label="Dock Icon Size", h_align="start", v_align="center" + ) + layout_grid.attach(dock_size_label, 0, 2, 1, 1) + self.dock_size_scale = Scale( + min_value=16, + max_value=48, + value=get_bind_var("dock_icon_size"), + increments=(2, 4), + draw_value=True, + value_position="right", + digits=0, + h_expand=True, + ) + layout_grid.attach(self.dock_size_scale, 1, 2, 3, 1) + + ws_num_label = Label( + label="Show Workspace Numbers", h_align="start", v_align="center" + ) + layout_grid.attach(ws_num_label, 0, 3, 1, 1) + ws_num_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.ws_num_switch = Gtk.Switch( + active=get_bind_var("bar_workspace_show_number") + ) + self.ws_num_switch.connect("notify::active", self.on_ws_num_changed) + ws_num_switch_container.add(self.ws_num_switch) + layout_grid.attach(ws_num_switch_container, 1, 3, 1, 1) + + ws_chinese_label = Label( + label="Use Chinese Numerals", h_align="start", v_align="center" + ) + layout_grid.attach(ws_chinese_label, 2, 3, 1, 1) + ws_chinese_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.ws_chinese_switch = Gtk.Switch( + active=get_bind_var("bar_workspace_use_chinese_numerals"), + sensitive=self.ws_num_switch.get_active(), + ) + ws_chinese_switch_container.add(self.ws_chinese_switch) + layout_grid.attach(ws_chinese_switch_container, 3, 3, 1, 1) + + special_ws_label = Label( + label="Hide Special Workspace", h_align="start", v_align="center" + ) + layout_grid.attach(special_ws_label, 0, 4, 1, 1) + special_ws_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.special_ws_switch = Gtk.Switch( + active=get_bind_var("bar_hide_special_workspace") + ) + special_ws_switch_container.add(self.special_ws_switch) + layout_grid.attach(special_ws_switch_container, 1, 4, 1, 1) + + bar_theme_label = Label(label="Bar Theme", h_align="start", v_align="center") + layout_grid.attach(bar_theme_label, 0, 5, 1, 1) + bar_theme_combo_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.bar_theme_combo = Gtk.ComboBoxText() + self.bar_theme_combo.set_tooltip_text("Select the visual theme for the bar") + themes = ["Pills", "Dense", "Edge"] + for theme in themes: + self.bar_theme_combo.append_text(theme) + current_theme = get_bind_var("bar_theme") + try: + self.bar_theme_combo.set_active(themes.index(current_theme)) + except ValueError: + self.bar_theme_combo.set_active(0) + bar_theme_combo_container.add(self.bar_theme_combo) + layout_grid.attach(bar_theme_combo_container, 1, 5, 3, 1) + + dock_theme_label = Label(label="Dock Theme", h_align="start", v_align="center") + layout_grid.attach(dock_theme_label, 0, 6, 1, 1) + dock_theme_combo_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.dock_theme_combo = Gtk.ComboBoxText() + self.dock_theme_combo.set_tooltip_text("Select the visual theme for the dock") + for theme in themes: + self.dock_theme_combo.append_text(theme) + current_dock_theme = get_bind_var("dock_theme") + try: + self.dock_theme_combo.set_active(themes.index(current_dock_theme)) + except ValueError: + self.dock_theme_combo.set_active(0) + dock_theme_combo_container.add(self.dock_theme_combo) + layout_grid.attach(dock_theme_combo_container, 1, 6, 3, 1) + + panel_theme_label = Label( + label="Panel Theme", h_align="start", v_align="center" + ) + layout_grid.attach(panel_theme_label, 0, 7, 1, 1) + panel_theme_combo_container = Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.panel_theme_combo = Gtk.ComboBoxText() + self.panel_theme_combo.set_tooltip_text( + "Select the theme/mode for panels like toolbox, clipboard, etc." + ) + panel_themes = ["Notch", "Panel"] + for theme in panel_themes: + self.panel_theme_combo.append_text(theme) + current_panel_theme = get_bind_var("panel_theme") + try: + self.panel_theme_combo.set_active(panel_themes.index(current_panel_theme)) + except ValueError: + self.panel_theme_combo.set_active(0) + panel_theme_combo_container.add(self.panel_theme_combo) + layout_grid.attach(panel_theme_combo_container, 1, 7, 1, 1) + self.panel_theme_combo.connect( + "changed", self._on_panel_theme_changed_for_position_sensitivity + ) + + self.panel_position_options = ["Start", "Center", "End"] + + panel_position_label = Label( + label="Panel Position", h_align="start", v_align="center" + ) + layout_grid.attach(panel_position_label, 2, 7, 1, 1) + + panel_position_combo_container = Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.panel_position_combo = Gtk.ComboBoxText() + self.panel_position_combo.set_tooltip_text( + "Select the position for the 'Panel' theme panels" + ) + for option in self.panel_position_options: + self.panel_position_combo.append_text(option) + + current_panel_position = get_bind_var("panel_position") + try: + self.panel_position_combo.set_active( + self.panel_position_options.index(current_panel_position) + ) + except ValueError: + try: + self.panel_position_combo.set_active( + self.panel_position_options.index("Center") + ) + except ValueError: + self.panel_position_combo.set_active(0) + + panel_position_combo_container.add(self.panel_position_combo) + layout_grid.attach(panel_position_combo_container, 3, 7, 1, 1) + + notification_pos_label = Label( + label="Notification Position", h_align="start", v_align="center" + ) + layout_grid.attach(notification_pos_label, 0, 8, 1, 1) + + notification_pos_combo_container = Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + + self.notification_pos_combo = Gtk.ComboBoxText() + self.notification_pos_combo.set_tooltip_text( + "Select where notifications appear on the screen." + ) + + notification_positions_list = ["Top", "Bottom"] + for pos in notification_positions_list: + self.notification_pos_combo.append_text(pos) + + current_notif_pos = get_bind_var("notif_pos") + try: + self.notification_pos_combo.set_active( + notification_positions_list.index(current_notif_pos) + ) + except ValueError: + self.notification_pos_combo.set_active(0) + + self.notification_pos_combo.connect( + "changed", self.on_notification_position_changed + ) + + notification_pos_combo_container.add(self.notification_pos_combo) + layout_grid.attach(notification_pos_combo_container, 1, 8, 3, 1) + + separator2 = Box( + style="min-height: 1px; background-color: alpha(@fg_color, 0.2); margin: 5px 0px;", + h_expand=True, + ) + vbox.add(separator2) + + components_header = Label(markup="Modules", h_align="start") + vbox.add(components_header) + components_grid = Gtk.Grid() + components_grid.set_column_spacing(15) + components_grid.set_row_spacing(8) + components_grid.set_margin_start(10) + components_grid.set_margin_top(5) + vbox.add(components_grid) + + self.component_switches = {} + component_display_names = { + "button_apps": "App Launcher Button", + "systray": "System Tray", + "control": "Control Panel", + "network": "Network Applet", + "button_tools": "Toolbox Button", + "sysprofiles": "Powerprofiles Switcher", + "button_overview": "Overview Button", + "ws_container": "Workspaces", + "weather": "Weather Widget", + "battery": "Battery Indicator", + "metrics": "System Metrics", + "language": "Language Indicator", + "date_time": "Date & Time", + "button_power": "Power Button", + } + + self.corners_switch = Gtk.Switch(active=get_bind_var("corners_visible")) + num_components = len(component_display_names) + 1 + rows_per_column = (num_components + 1) // 2 + + corners_label = Label( + label="Rounded Corners", h_align="start", v_align="center" + ) + components_grid.attach(corners_label, 0, 0, 1, 1) + switch_container_corners = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + switch_container_corners.add(self.corners_switch) + components_grid.attach(switch_container_corners, 1, 0, 1, 1) + + current_row = 0 + current_col = 0 + item_idx = 0 + for i, (name, display) in enumerate(component_display_names.items()): + if item_idx < (rows_per_column - 1): + row = item_idx + 1 + col = 0 + else: + row = item_idx - (rows_per_column - 1) + col = 2 + + component_label = Label(label=display, h_align="start", v_align="center") + components_grid.attach(component_label, col, row, 1, 1) + + switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + component_switch = Gtk.Switch(active=get_bind_var(f"bar_{name}_visible")) + switch_container.add(component_switch) + components_grid.attach(switch_container, col + 1, row, 1, 1) + self.component_switches[name] = component_switch + item_idx += 1 + + self._update_panel_position_sensitivity() + return scrolled_window + + def _on_panel_theme_changed_for_position_sensitivity(self, combo): + self._update_panel_position_sensitivity() + + def _update_panel_position_sensitivity(self): + if hasattr(self, "panel_theme_combo") and hasattr(self, "panel_position_combo"): + selected_theme = self.panel_theme_combo.get_active_text() + is_panel_theme_selected = selected_theme == "Panel" + self.panel_position_combo.set_sensitive(is_panel_theme_selected) + + def on_notification_position_changed(self, combo: Gtk.ComboBoxText): + selected_text = combo.get_active_text() + if selected_text: + bind_vars["notif_pos"] = selected_text + print( + f"Notification position updated in bind_vars: {bind_vars["notif_pos"]}" + ) + + def create_system_tab(self): + scrolled_window = ScrolledWindow( + h_scrollbar_policy="never", + v_scrollbar_policy="automatic", + h_expand=True, + v_expand=True, + propagate_width=False, + propagate_height=False, + ) + + vbox = Box(orientation="v", spacing=15, style="margin: 15px;") + scrolled_window.add(vbox) + + system_grid = Gtk.Grid() + system_grid.set_column_spacing(20) + system_grid.set_row_spacing(10) + system_grid.set_margin_bottom(15) + vbox.add(system_grid) + + # Auto-append checkbox - first option + auto_append_label = Label( + label="Auto-append to hyprland.conf", h_align="start", v_align="center" + ) + system_grid.attach(auto_append_label, 0, 0, 1, 1) + auto_append_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.auto_append_switch = Gtk.Switch( + active=get_bind_var("auto_append_hyprland"), + tooltip_text="Automatically append Ax-Shell source string to hyprland.conf", + ) + auto_append_switch_container.add(self.auto_append_switch) + system_grid.attach(auto_append_switch_container, 1, 0, 1, 1) + + # Monitor Selection - second option + monitor_header = Label(markup="Monitor Selection", h_align="start") + system_grid.attach(monitor_header, 0, 1, 2, 1) + + monitor_label = Label( + label="Show Ax-Shell on monitors:", h_align="start", v_align="center" + ) + system_grid.attach(monitor_label, 0, 2, 1, 1) + + # Create monitor selection container + self.monitor_selection_container = Box( + orientation="v", spacing=5, h_align="start" + ) + self.monitor_checkboxes = {} + + # Get available monitors + try: + from utils.monitor_manager import get_monitor_manager + + monitor_manager = get_monitor_manager() + available_monitors = monitor_manager.get_monitors() + except (ImportError, Exception) as e: + print(f"Could not get monitor info for settings: {e}") + available_monitors = [{"id": 0, "name": "default"}] + + # Get current selection from config + current_selection = get_bind_var("selected_monitors") + + # Create checkboxes for each monitor + for monitor in available_monitors: + monitor_name = monitor.get("name", f'monitor-{monitor.get("id", 0)}') + + checkbox_container = Box(orientation="h", spacing=5, h_align="start") + checkbox = Gtk.CheckButton(label=monitor_name) + + # Check if this monitor is selected (empty selection means all selected) + is_selected = ( + len(current_selection) == 0 or monitor_name in current_selection + ) + checkbox.set_active(is_selected) + + checkbox_container.add(checkbox) + self.monitor_selection_container.add(checkbox_container) + self.monitor_checkboxes[monitor_name] = checkbox + + # Add hint label + hint_label = Label( + markup="Leave all unchecked to show on all monitors", + h_align="start", + ) + self.monitor_selection_container.add(hint_label) + + system_grid.attach(self.monitor_selection_container, 1, 2, 1, 1) + + terminal_header = Label(markup="Terminal Settings", h_align="start") + system_grid.attach(terminal_header, 0, 3, 2, 1) + terminal_label = Label(label="Command:", h_align="start", v_align="center") + system_grid.attach(terminal_label, 0, 4, 1, 1) + self.terminal_entry = Entry( + text=get_bind_var("terminal_command"), + tooltip_text="Command used to launch terminal apps (e.g., 'kitty -e')", + h_expand=True, + ) + system_grid.attach(self.terminal_entry, 1, 4, 1, 1) + hint_label = Label( + markup="Examples: 'kitty -e', 'alacritty -e', 'foot -e'", + h_align="start", + ) + system_grid.attach(hint_label, 0, 5, 2, 1) + + hypr_header = Label(markup="Hyprland Integration", h_align="start") + system_grid.attach(hypr_header, 2, 3, 2, 1) + row = 4 + self.lock_switch = None + if self.show_lock_checkbox: + lock_label = Label( + label="Replace Hyprlock config", h_align="start", v_align="center" + ) + system_grid.attach(lock_label, 2, row, 1, 1) + lock_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.lock_switch = Gtk.Switch( + tooltip_text="Replace Hyprlock configuration with Ax-Shell's custom config" + ) + lock_switch_container.add(self.lock_switch) + system_grid.attach(lock_switch_container, 3, row, 1, 1) + row += 1 + self.idle_switch = None + if self.show_idle_checkbox: + idle_label = Label( + label="Replace Hypridle config", h_align="start", v_align="center" + ) + system_grid.attach(idle_label, 2, row, 1, 1) + idle_switch_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + self.idle_switch = Gtk.Switch( + tooltip_text="Replace Hypridle configuration with Ax-Shell's custom config" + ) + idle_switch_container.add(self.idle_switch) + system_grid.attach(idle_switch_container, 3, row, 1, 1) + row += 1 + if self.show_lock_checkbox or self.show_idle_checkbox: + note_label = Label( + markup="Existing configs will be backed up", + h_align="start", + ) + system_grid.attach(note_label, 2, row, 2, 1) + + # Notifications app lists section + notifications_header = Label( + markup="Notification Settings", h_align="start" + ) + vbox.add(notifications_header) + + notif_grid = Gtk.Grid() + notif_grid.set_column_spacing(20) + notif_grid.set_row_spacing(10) + notif_grid.set_margin_start(10) + notif_grid.set_margin_top(5) + notif_grid.set_margin_bottom(15) + vbox.add(notif_grid) + + # Limited Apps History + limited_apps_label = Label( + label="Limited Apps History:", h_align="start", v_align="center" + ) + notif_grid.attach(limited_apps_label, 0, 0, 1, 1) + + limited_apps_list = get_bind_var("limited_apps_history") + limited_apps_text = ", ".join(f'"{app}"' for app in limited_apps_list) + self.limited_apps_entry = Entry( + text=limited_apps_text, + tooltip_text='Enter app names separated by commas, e.g: "Spotify", "Discord"', + h_expand=True, + ) + notif_grid.attach(self.limited_apps_entry, 1, 0, 1, 1) + + limited_apps_hint = Label( + markup='Apps with limited notification history (format: "App1", "App2")', + h_align="start", + ) + notif_grid.attach(limited_apps_hint, 0, 1, 2, 1) + + # History Ignored Apps + ignored_apps_label = Label( + label="History Ignored Apps:", h_align="start", v_align="center" + ) + notif_grid.attach(ignored_apps_label, 0, 2, 1, 1) + + ignored_apps_list = get_bind_var("history_ignored_apps") + ignored_apps_text = ", ".join(f'"{app}"' for app in ignored_apps_list) + self.ignored_apps_entry = Entry( + text=ignored_apps_text, + tooltip_text='Enter app names separated by commas, e.g: "Hyprshot", "Screenshot"', + h_expand=True, + ) + notif_grid.attach(self.ignored_apps_entry, 1, 2, 1, 1) + + ignored_apps_hint = Label( + markup='Apps whose notifications are ignored in history (format: "App1", "App2")', + h_align="start", + ) + notif_grid.attach(ignored_apps_hint, 0, 3, 2, 1) + + metrics_header = Label(markup="System Metrics Options", h_align="start") + vbox.add(metrics_header) + metrics_grid = Gtk.Grid( + column_spacing=15, row_spacing=8, margin_start=10, margin_top=5 + ) + vbox.add(metrics_grid) + + self.metrics_switches = {} + self.metrics_small_switches = {} + metric_names = {"cpu": "CPU", "ram": "RAM", "disk": "Disk", "gpu": "GPU"} + + metrics_grid.attach(Label(label="Show in Metrics", h_align="start"), 0, 0, 1, 1) + for i, (key, label_text) in enumerate(metric_names.items()): + switch = Gtk.Switch(active=get_bind_var("metrics_visible").get(key, True)) + self.metrics_switches[key] = switch + metrics_grid.attach( + Label(label=label_text, h_align="start"), 0, i + 1, 1, 1 + ) + metrics_grid.attach(switch, 1, i + 1, 1, 1) + + metrics_grid.attach( + Label(label="Show in Small Metrics", h_align="start"), 2, 0, 1, 1 + ) + for i, (key, label_text) in enumerate(metric_names.items()): + switch = Gtk.Switch( + active=get_bind_var("metrics_small_visible").get(key, True) + ) + self.metrics_small_switches[key] = switch + metrics_grid.attach( + Label(label=label_text, h_align="start"), 2, i + 1, 1, 1 + ) + metrics_grid.attach(switch, 3, i + 1, 1, 1) + + def enforce_minimum_metrics(switch_dict): + enabled_switches = [s for s in switch_dict.values() if s.get_active()] + can_disable = len(enabled_switches) > 3 + for s in switch_dict.values(): + s.set_sensitive(True if can_disable or not s.get_active() else False) + + def on_metric_toggle(switch, gparam, switch_dict): + enforce_minimum_metrics(switch_dict) + + for k_s, s_s in self.metrics_switches.items(): + s_s.connect("notify::active", on_metric_toggle, self.metrics_switches) + for k_s, s_s in self.metrics_small_switches.items(): + s_s.connect("notify::active", on_metric_toggle, self.metrics_small_switches) + enforce_minimum_metrics(self.metrics_switches) + enforce_minimum_metrics(self.metrics_small_switches) + + disks_label = Label( + label="Disk directories for Metrics", h_align="start", v_align="center" + ) + vbox.add(disks_label) + self.disk_entries = Box(orientation="v", spacing=8, h_align="start") + + self._create_disk_edit_entry_func = lambda path: self._add_disk_entry_widget( + path + ) + + for p in get_bind_var("bar_metrics_disks"): + self._create_disk_edit_entry_func(p) + vbox.add(self.disk_entries) + + add_container = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.START, + valign=Gtk.Align.CENTER, + ) + add_btn = Button( + label="Add new disk", + on_clicked=lambda _: self._create_disk_edit_entry_func("/"), + ) + add_container.add(add_btn) + vbox.add(add_container) + + return scrolled_window + + def _add_disk_entry_widget(self, path): + """Helper para aรฑadir una fila de entrada de disco al Box disk_entries.""" + bar = Box(orientation="h", spacing=10, h_align="start") + entry = Entry(text=path, h_expand=True) + bar.add(entry) + x_btn = Button(label="X") + x_btn.connect( + "clicked", + lambda _, current_bar_to_remove=bar: self.disk_entries.remove( + current_bar_to_remove + ), + ) + bar.add(x_btn) + self.disk_entries.add(bar) + self.disk_entries.show_all() + + def create_about_tab(self): + vbox = Box(orientation="v", spacing=18, style="margin: 30px;") + vbox.add( + Label( + markup=f"{APP_NAME_CAP}", + h_align="start", + style="font-size: 1.5em; margin-bottom: 8px;", + ) + ) + vbox.add( + Label( + label="A hackable shell for Hyprland, powered by Fabric.", + h_align="start", + style="margin-bottom: 12px;", + ) + ) + repo_box = Box(orientation="h", spacing=6, h_align="start") + repo_label = Label(label="GitHub:", h_align="start") + repo_link = Label( + markup='https://github.com/Axenide/Ax-Shell' + ) + repo_box.add(repo_label) + repo_box.add(repo_link) + vbox.add(repo_box) + + def on_kofi_clicked(_): + import webbrowser + + webbrowser.open("https://ko-fi.com/Axenide") + + kofi_btn = Button( + label="Support on Ko-Fi โค๏ธ", + on_clicked=on_kofi_clicked, + tooltip_text="Support Axenide on Ko-Fi", + style="margin-top: 18px; min-width: 160px;", + ) + vbox.add(kofi_btn) + vbox.add(Box(v_expand=True)) + return vbox + + def on_ws_num_changed(self, switch, gparam): + is_active = switch.get_active() + self.ws_chinese_switch.set_sensitive(is_active) + if not is_active: + self.ws_chinese_switch.set_active(False) + + def on_position_changed(self, combo): + position = combo.get_active_text() + is_vertical = position in ["Left", "Right"] + self.centered_switch.set_sensitive(is_vertical) + if not is_vertical: + self.centered_switch.set_active(False) + + def on_dock_enabled_changed(self, switch, gparam): + is_active = switch.get_active() + self.dock_hover_switch.set_sensitive(is_active) + if not is_active: + self.dock_hover_switch.set_active(False) + + def on_select_face_icon(self, widget): + dialog = Gtk.FileChooserDialog( + title="Select Face Icon", + transient_for=self.get_toplevel(), + action=Gtk.FileChooserAction.OPEN, + ) + dialog.add_buttons( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, + Gtk.ResponseType.OK, + ) + image_filter = Gtk.FileFilter() + image_filter.set_name("Image files") + for mime in ["image/png", "image/jpeg"]: + image_filter.add_mime_type(mime) + for pattern in ["*.png", "*.jpg", "*.jpeg"]: + image_filter.add_pattern(pattern) + dialog.add_filter(image_filter) + if dialog.run() == Gtk.ResponseType.OK: + self.selected_face_icon = dialog.get_filename() + self.face_status_label.label = ( + f"Selected: {os.path.basename(self.selected_face_icon)}" + ) + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( + self.selected_face_icon, 64, 64 + ) + self.face_image.set_from_pixbuf(pixbuf) + except Exception as e: + print(f"Error loading selected face icon preview: {e}") + self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) + dialog.destroy() + + def on_accept(self, widget): + current_bind_vars_snapshot = {} + for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries: + current_bind_vars_snapshot[prefix_key] = prefix_entry.get_text() + current_bind_vars_snapshot[suffix_key] = suffix_entry.get_text() + + current_bind_vars_snapshot["wallpapers_dir"] = ( + self.wall_dir_chooser.get_filename() + ) + + current_bind_vars_snapshot["bar_position"] = ( + self.position_combo.get_active_text() + ) + current_bind_vars_snapshot["vertical"] = current_bind_vars_snapshot[ + "bar_position" + ] in ["Left", "Right"] + + current_bind_vars_snapshot["centered_bar"] = self.centered_switch.get_active() + current_bind_vars_snapshot["datetime_12h_format"] = ( + self.datetime_12h_switch.get_active() + ) + current_bind_vars_snapshot["dock_enabled"] = self.dock_switch.get_active() + current_bind_vars_snapshot["dock_always_show"] = ( + self.dock_hover_switch.get_active() + ) + current_bind_vars_snapshot["dock_icon_size"] = int(self.dock_size_scale.value) + current_bind_vars_snapshot["terminal_command"] = self.terminal_entry.get_text() + current_bind_vars_snapshot["auto_append_hyprland"] = ( + self.auto_append_switch.get_active() + ) + current_bind_vars_snapshot["corners_visible"] = self.corners_switch.get_active() + current_bind_vars_snapshot["bar_workspace_show_number"] = ( + self.ws_num_switch.get_active() + ) + current_bind_vars_snapshot["bar_workspace_use_chinese_numerals"] = ( + self.ws_chinese_switch.get_active() + ) + current_bind_vars_snapshot["bar_hide_special_workspace"] = ( + self.special_ws_switch.get_active() + ) + current_bind_vars_snapshot["bar_theme"] = self.bar_theme_combo.get_active_text() + current_bind_vars_snapshot["dock_theme"] = ( + self.dock_theme_combo.get_active_text() + ) + current_bind_vars_snapshot["panel_theme"] = ( + self.panel_theme_combo.get_active_text() + ) + current_bind_vars_snapshot["panel_position"] = ( + self.panel_position_combo.get_active_text() + ) + selected_notif_pos_text = self.notification_pos_combo.get_active_text() + if selected_notif_pos_text: + current_bind_vars_snapshot["notif_pos"] = selected_notif_pos_text + else: + current_bind_vars_snapshot["notif_pos"] = "Top" + + for component_name, switch in self.component_switches.items(): + current_bind_vars_snapshot[f"bar_{component_name}_visible"] = ( + switch.get_active() + ) + + current_bind_vars_snapshot["metrics_visible"] = { + k: s.get_active() for k, s in self.metrics_switches.items() + } + current_bind_vars_snapshot["metrics_small_visible"] = { + k: s.get_active() for k, s in self.metrics_small_switches.items() + } + current_bind_vars_snapshot["bar_metrics_disks"] = [ + child.get_children()[0].get_text() + for child in self.disk_entries.get_children() + if isinstance(child, Gtk.Box) + and child.get_children() + and isinstance(child.get_children()[0], Entry) + ] + + # Parse notification app lists + def parse_app_list(text): + """Parse comma-separated app names with quotes""" + if not text.strip(): + return [] + apps = [] + for app in text.split(","): + app = app.strip() + if app.startswith('"') and app.endswith('"'): + app = app[1:-1] + elif app.startswith("'") and app.endswith("'"): + app = app[1:-1] + if app: + apps.append(app) + return apps + + current_bind_vars_snapshot["limited_apps_history"] = parse_app_list( + self.limited_apps_entry.get_text() + ) + current_bind_vars_snapshot["history_ignored_apps"] = parse_app_list( + self.ignored_apps_entry.get_text() + ) + + # Save monitor selection + selected_monitors = [] + any_checked = False + for monitor_name, checkbox in self.monitor_checkboxes.items(): + if checkbox.get_active(): + selected_monitors.append(monitor_name) + any_checked = True + + # If no monitors are checked, use empty array (means show on all monitors) + current_bind_vars_snapshot["selected_monitors"] = ( + selected_monitors if any_checked else [] + ) + + selected_icon_path = self.selected_face_icon + replace_lock = self.lock_switch and self.lock_switch.get_active() + replace_idle = self.idle_switch and self.idle_switch.get_active() + + if self.selected_face_icon: + self.selected_face_icon = None + self.face_status_label.label = "" + + def _apply_and_reload_task_thread(user_data): + nonlocal current_bind_vars_snapshot + + from . import settings_utils + + settings_utils.bind_vars.clear() + settings_utils.bind_vars.update(current_bind_vars_snapshot) + + start_time = time.time() + print(f"{start_time:.4f}: Background task started.") + + config_json = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/config/config.json" + ) + os.makedirs(os.path.dirname(config_json), exist_ok=True) + try: + with open(config_json, "w") as f: + json.dump(settings_utils.bind_vars, f, indent=4) + print(f"{time.time():.4f}: Saved config.json.") + except Exception as e: + print(f"Error saving config.json: {e}") + + if selected_icon_path: + print(f"{time.time():.4f}: Processing face icon...") + try: + img = Image.open(selected_icon_path) + side = min(img.size) + left = (img.width - side) // 2 + top = (img.height - side) // 2 + cropped_img = img.crop((left, top, left + side, top + side)) + face_icon_dest = os.path.expanduser("~/.face.icon") + cropped_img.save(face_icon_dest, format="PNG") + print(f"{time.time():.4f}: Face icon saved to {face_icon_dest}") + GLib.idle_add(self._update_face_image_widget, face_icon_dest) + except Exception as e: + print(f"Error processing face icon: {e}") + print(f"{time.time():.4f}: Finished processing face icon.") + + if replace_lock: + print(f"{time.time():.4f}: Replacing hyprlock config...") + src = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/config/hypr/hyprlock.conf" + ) + dest = os.path.expanduser("~/.config/hypr/hyprlock.conf") + if os.path.exists(src): + backup_and_replace(src, dest, "Hyprlock") + else: + print(f"Warning: Source hyprlock config not found at {src}") + print(f"{time.time():.4f}: Finished replacing hyprlock config.") + + if replace_idle: + print(f"{time.time():.4f}: Replacing hypridle config...") + src = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/config/hypr/hypridle.conf" + ) + dest = os.path.expanduser("~/.config/hypr/hypridle.conf") + if os.path.exists(src): + backup_and_replace(src, dest, "Hypridle") + else: + print(f"Warning: Source hypridle config not found at {src}") + print(f"{time.time():.4f}: Finished replacing hypridle config.") + + print( + f"{time.time():.4f}: Checking/Appending hyprland.conf source string..." + ) + hypr_path = os.path.expanduser("~/.config/hypr/hyprland.conf") + try: + from .settings_constants import SOURCE_STRING + + # Check if auto-append is enabled + auto_append_enabled = current_bind_vars_snapshot.get( + "auto_append_hyprland", True + ) + if auto_append_enabled: + needs_append = True + if os.path.exists(hypr_path): + with open(hypr_path, "r") as f: + if SOURCE_STRING.strip() in f.read(): + needs_append = False + else: + os.makedirs(os.path.dirname(hypr_path), exist_ok=True) + + if needs_append: + with open(hypr_path, "a") as f: + f.write("\n" + SOURCE_STRING) + print(f"Appended source string to {hypr_path}") + else: + print("Source string already present in hyprland.conf") + else: + print("Auto-append to hyprland.conf is disabled") + except Exception as e: + print(f"Error updating {hypr_path}: {e}") + print( + f"{time.time():.4f}: Finished checking/appending hyprland.conf source string." + ) + + print(f"{time.time():.4f}: Running start_config()...") + start_config() + print(f"{time.time():.4f}: Finished start_config().") + + print(f"{time.time():.4f}: Initiating Ax-Shell restart using Popen...") + main_py = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/main.py") + kill_cmd = f"killall {APP_NAME}" + start_cmd = ["uwsm", "app", "--", "python", main_py] + try: + kill_proc = subprocess.Popen( + kill_cmd, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + kill_proc.wait(timeout=2) + print(f"{time.time():.4f}: killall process finished (o timed out).") + except subprocess.TimeoutExpired: + print("Warning: killall command timed out.") + except Exception as e: + print(f"Error running killall: {e}") + + try: + subprocess.Popen( + start_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + print(f"{APP_NAME_CAP} restart initiated via Popen.") + except FileNotFoundError as e: + print(f"Error restarting {APP_NAME_CAP}: Command not found ({e})") + except Exception as e: + print(f"Error restarting {APP_NAME_CAP} via Popen: {e}") + + print(f"{time.time():.4f}: Ax-Shell restart commands issued via Popen.") + end_time = time.time() + print( + f"{end_time:.4f}: Background task finished (Total: {end_time - start_time:.4f}s)." + ) + + GLib.Thread.new("apply-reload-task", _apply_and_reload_task_thread, None) + print("Configuration apply/reload task started in background.") + + def _update_face_image_widget(self, icon_path): + try: + if self.face_image and self.face_image.get_window(): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(icon_path, 64, 64) + self.face_image.set_from_pixbuf(pixbuf) + except Exception as e: + print(f"Error reloading face icon preview: {e}") + if self.face_image and self.face_image.get_window(): + self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) + return GLib.SOURCE_REMOVE + + def on_reset(self, widget): + dialog = Gtk.MessageDialog( + transient_for=self.get_toplevel(), + flags=0, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text="Reset all settings to defaults?", + ) + dialog.format_secondary_text( + "This will reset all keybindings and appearance settings to their default values." + ) + if dialog.run() == Gtk.ResponseType.YES: + from . import settings_utils + from .settings_constants import DEFAULTS + + settings_utils.bind_vars.clear() + settings_utils.bind_vars.update(DEFAULTS.copy()) + + for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries: + prefix_entry.set_text(settings_utils.bind_vars[prefix_key]) + suffix_entry.set_text(settings_utils.bind_vars[suffix_key]) + + self.wall_dir_chooser.set_filename( + settings_utils.bind_vars["wallpapers_dir"] + ) + + positions = ["Top", "Bottom", "Left", "Right"] + default_position = get_default("bar_position") + try: + self.position_combo.set_active(positions.index(default_position)) + except ValueError: + self.position_combo.set_active(0) + + self.centered_switch.set_active(get_bind_var("centered_bar")) + self.centered_switch.set_sensitive(default_position in ["Left", "Right"]) + + self.datetime_12h_switch.set_active(get_bind_var("datetime_12h_format")) + + self.dock_switch.set_active(get_bind_var("dock_enabled")) + self.dock_hover_switch.set_active(get_bind_var("dock_always_show")) + self.dock_hover_switch.set_sensitive(self.dock_switch.get_active()) + self.dock_size_scale.set_value(get_bind_var("dock_icon_size")) + self.terminal_entry.set_text(settings_utils.bind_vars["terminal_command"]) + self.auto_append_switch.set_active(get_bind_var("auto_append_hyprland")) + self.ws_num_switch.set_active(get_bind_var("bar_workspace_show_number")) + self.ws_chinese_switch.set_active( + get_bind_var("bar_workspace_use_chinese_numerals") + ) + self.ws_chinese_switch.set_sensitive(self.ws_num_switch.get_active()) + self.special_ws_switch.set_active( + get_bind_var("bar_hide_special_workspace") + ) + + default_theme_val = get_default("bar_theme") + themes = ["Pills", "Dense", "Edge"] + try: + self.bar_theme_combo.set_active(themes.index(default_theme_val)) + except ValueError: + self.bar_theme_combo.set_active(0) + + default_dock_theme_val = get_default("dock_theme") + try: + self.dock_theme_combo.set_active(themes.index(default_dock_theme_val)) + except ValueError: + self.dock_theme_combo.set_active(0) + + default_panel_theme_val = get_default("panel_theme") + panel_themes_options = ["Notch", "Panel"] + try: + self.panel_theme_combo.set_active( + panel_themes_options.index(default_panel_theme_val) + ) + except ValueError: + self.panel_theme_combo.set_active(0) + + default_panel_position_val = get_default("panel_position") + try: + self.panel_position_combo.set_active( + self.panel_position_options.index(default_panel_position_val) + ) + except ValueError: + try: + self.panel_position_combo.set_active( + self.panel_position_options.index("Center") + ) + except ValueError: + self.panel_position_combo.set_active(0) + + default_notif_pos_val = get_default("notif_pos") + notification_positions_list = ["Top", "Bottom"] + try: + self.notification_pos_combo.set_active( + notification_positions_list.index(default_notif_pos_val) + ) + except ValueError: + self.notification_pos_combo.set_active(0) + + for name, switch in self.component_switches.items(): + switch.set_active(get_bind_var(f"bar_{name}_visible")) + self.corners_switch.set_active(get_bind_var("corners_visible")) + + metrics_vis_defaults = get_default("metrics_visible") + for k, s_widget in self.metrics_switches.items(): + s_widget.set_active(metrics_vis_defaults.get(k, True)) + + metrics_small_vis_defaults = get_default("metrics_small_visible") + for k, s_widget in self.metrics_small_switches.items(): + s_widget.set_active(metrics_small_vis_defaults.get(k, True)) + + def enforce_minimum_metrics(switch_dict): + enabled_switches = [ + s_widget + for s_widget in switch_dict.values() + if s_widget.get_active() + ] + can_disable = len(enabled_switches) > 3 + for s_widget in switch_dict.values(): + s_widget.set_sensitive( + True if can_disable or not s_widget.get_active() else False + ) + + enforce_minimum_metrics(self.metrics_switches) + enforce_minimum_metrics(self.metrics_small_switches) + + for child in list(self.disk_entries.get_children()): + self.disk_entries.remove(child) + + for p in get_default("bar_metrics_disks"): + self._add_disk_edit_entry_func(p) + + # Reset notification app lists + limited_apps_list = get_default("limited_apps_history") + limited_apps_text = ", ".join(f'"{app}"' for app in limited_apps_list) + self.limited_apps_entry.set_text(limited_apps_text) + + ignored_apps_list = get_default("history_ignored_apps") + ignored_apps_text = ", ".join(f'"{app}"' for app in ignored_apps_list) + self.ignored_apps_entry.set_text(ignored_apps_text) + + # Reset monitor selection + default_monitors = get_default("selected_monitors") + for monitor_name, checkbox in self.monitor_checkboxes.items(): + # If defaults is empty, check all monitors (show on all) + is_selected = ( + len(default_monitors) == 0 or monitor_name in default_monitors + ) + checkbox.set_active(is_selected) + + self._update_panel_position_sensitivity() + + self.selected_face_icon = None + self.face_status_label.label = "" + current_face = os.path.expanduser("~/.face.icon") + try: + pixbuf = ( + GdkPixbuf.Pixbuf.new_from_file_at_size(current_face, 64, 64) + if os.path.exists(current_face) + else None + ) + if pixbuf: + self.face_image.set_from_pixbuf(pixbuf) + else: + self.face_image.set_from_icon_name("user-info", Gtk.IconSize.DIALOG) + except Exception: + self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) + + if self.lock_switch: + self.lock_switch.set_active(False) + if self.idle_switch: + self.idle_switch.set_active(False) + print("Settings reset to defaults.") + dialog.destroy() + + def on_close(self, widget): + if self.application: + self.application.quit() + else: + self.destroy() diff --git a/Ax-Shell/config/settings_utils.py b/Ax-Shell/config/settings_utils.py new file mode 100644 index 0000000..5bc9fca --- /dev/null +++ b/Ax-Shell/config/settings_utils.py @@ -0,0 +1,405 @@ +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +import gi +import toml + +gi.require_version("Gtk", "3.0") +from fabric.utils.helpers import exec_shell_command_async +from gi.repository import GLib + +# Importar settings_constants para DEFAULTS +from . import settings_constants +from .data import ( # CONFIG_DIR, HOME_DIR no se usan aquรญ directamente + APP_NAME, + APP_NAME_CAP, + get_default, +) + +# Global variable to store binding variables, managed by this module +bind_vars = {} # Se inicializa vacรญo, load_bind_vars lo poblarรก + + +def get_bind_var(setting_str: str): + return bind_vars.get(setting_str, get_default(setting_str)) + + +def deep_update(target: dict, update: dict) -> dict: + """ + Recursively update a nested dictionary with values from another dictionary. + Modifies target in-place. + """ + for key, value in update.items(): + if isinstance(value, dict) and key in target and isinstance(target[key], dict): + # Si el valor es un diccionario y la clave ya existe en target como diccionario, + # entonces actualiza recursivamente. + deep_update(target[key], value) + else: + # De lo contrario, simplemente establece/sobrescribe el valor. + target[key] = value + return target # Aunque modifica in-place, devolverlo es una convenciรณn comรบn + + +def ensure_matugen_config(): + """ + Ensure that the matugen configuration file exists and is updated + with the expected settings. + """ + expected_config = { + "config": { + "reload_apps": True, + "wallpaper": { + "command": "awww", + "arguments": [ + "img", + "-t", + "fade", + "--transition-duration", + "0.5", + "--transition-step", + "255", + "--transition-fps", + "60", + "-f", + "Nearest", + ], + "set": True, + }, + "custom_colors": { + "red": {"color": "#FF0000", "blend": True}, + "green": {"color": "#00FF00", "blend": True}, + "yellow": {"color": "#FFFF00", "blend": True}, + "blue": {"color": "#0000FF", "blend": True}, + "magenta": {"color": "#FF00FF", "blend": True}, + "cyan": {"color": "#00FFFF", "blend": True}, + "white": {"color": "#FFFFFF", "blend": True}, + }, + }, + "templates": { + "hyprland": { + "input_path": f"~/.config/{APP_NAME_CAP}/config/matugen/templates/hyprland-colors.conf", + "output_path": f"~/.config/{APP_NAME_CAP}/config/hypr/colors.conf", + }, + f"{APP_NAME}": { + "input_path": f"~/.config/{APP_NAME_CAP}/config/matugen/templates/{APP_NAME}.css", + "output_path": f"~/.config/{APP_NAME_CAP}/styles/colors.css", + "post_hook": f"fabric-cli exec {APP_NAME} 'app.set_css()' &", + }, + }, + } + + config_path = os.path.expanduser("~/.config/matugen/config.toml") + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + existing_config = {} + if os.path.exists(config_path): + try: + with open(config_path, "r") as f: + existing_config = toml.load(f) + shutil.copyfile(config_path, config_path + ".bak") + except toml.TomlDecodeError: + print( + f"Warning: Could not decode TOML from {config_path}. A new default config will be created." + ) + existing_config = {} # Resetear si estรก corrupto + except Exception as e: + print(f"Error reading or backing up {config_path}: {e}") + # existing_config podrรญa estar parcialmente cargado o vacรญo. + # Continuar para intentar fusionar con defaults. + + # Usamos una copia de existing_config para deep_update si no queremos modificarlo directamente + # o asegurarse que deep_update no lo haga si no es deseado. + # La implementaciรณn actual de deep_update modifica 'target'. + # Para ser mรกs seguros, podemos pasar una copia si existing_config no debe cambiar. + # merged_config = deep_update(existing_config.copy(), expected_config) + # O si existing_config puede ser modificado: + merged_config = deep_update( + existing_config, expected_config + ) # existing_config se modifica in-place + + try: + with open(config_path, "w") as f: + toml.dump(merged_config, f) + except Exception as e: + print(f"Error writing matugen config to {config_path}: {e}") + + current_wall = os.path.expanduser("~/.current.wall") + hypr_colors = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/config/hypr/colors.conf" + ) + css_colors = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/styles/colors.css") + + if ( + not os.path.exists(current_wall) + or not os.path.exists(hypr_colors) + or not os.path.exists(css_colors) + ): + os.makedirs(os.path.dirname(hypr_colors), exist_ok=True) + os.makedirs(os.path.dirname(css_colors), exist_ok=True) + + image_path = "" + if not os.path.exists(current_wall): + example_wallpaper_path = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/assets/wallpapers_example/example-1.jpg" + ) + if os.path.exists(example_wallpaper_path): + try: + # Si ya existe (posiblemente un enlace roto o archivo regular), eliminar y re-enlazar + if os.path.lexists( + current_wall + ): # lexists para no seguir el enlace si es uno + os.remove(current_wall) + os.symlink(example_wallpaper_path, current_wall) + image_path = example_wallpaper_path + except Exception as e: + print(f"Error creating symlink for wallpaper: {e}") + else: + image_path = ( + os.path.realpath(current_wall) + if os.path.islink(current_wall) + else current_wall + ) + + if image_path and os.path.exists(image_path): + print(f"Generating color theme from wallpaper: {image_path}") + try: + matugen_cmd = f"matugen image '{image_path}'" + exec_shell_command_async(matugen_cmd) + print("Matugen color theme generation initiated.") + except FileNotFoundError: + print("Error: matugen command not found. Please install matugen.") + except Exception as e: + print(f"Error initiating matugen: {e}") + elif not image_path: + print( + "Warning: No wallpaper path determined to generate matugen theme from." + ) + else: # image_path existe pero el archivo no + print( + f"Warning: Wallpaper at {image_path} not found. Cannot generate matugen theme." + ) + + +def load_bind_vars(): + """ + Load saved key binding variables from JSON, if available. + Populates the global `bind_vars` in-place. + """ + global bind_vars # Necesario para modificar el objeto global bind_vars + + # 1. Limpiar el diccionario bind_vars existente. + bind_vars.clear() + # 2. Actualizarlo con una copia de DEFAULTS. + bind_vars.update( + settings_constants.DEFAULTS.copy() + ) # Usar .copy() para no modificar DEFAULTS accidentalmente + + config_json = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/config.json") + if os.path.exists(config_json): + try: + with open(config_json, "r") as f: + saved_vars = json.load(f) + # 3. Usar deep_update para fusionar saved_vars en el bind_vars existente. + deep_update(bind_vars, saved_vars) + + # La lรณgica para asegurar la estructura de diccionarios anidados + # como 'metrics_visible' y 'metrics_small_visible' + # debe operar sobre el 'bind_vars' ya actualizado. + for vis_key in ["metrics_visible", "metrics_small_visible"]: + # Asegurar que la clave exista en DEFAULTS como referencia de estructura + if vis_key in settings_constants.DEFAULTS: + default_sub_dict = settings_constants.DEFAULTS[vis_key] + # Si la clave no estรก en bind_vars o no es un diccionario despuรฉs de deep_update, + # restaurarla desde una copia de DEFAULTS para esa clave. + if not isinstance(bind_vars.get(vis_key), dict): + bind_vars[vis_key] = default_sub_dict.copy() + else: + # Si es un diccionario, asegurar que todas las sub-claves de DEFAULTS estรฉn presentes. + current_sub_dict = bind_vars[vis_key] + for m_key, m_val in default_sub_dict.items(): + if m_key not in current_sub_dict: + current_sub_dict[m_key] = m_val + except json.JSONDecodeError: + print( + f"Warning: Could not decode JSON from {config_json}. Using defaults (already initialized)." + ) + # bind_vars ya estรก poblado con DEFAULTS, no se necesita acciรณn adicional aquรญ. + except Exception as e: + print( + f"Error loading config from {config_json}: {e}. Using defaults (already initialized)." + ) + # bind_vars ya estรก poblado con DEFAULTS. + # else: + # Si config_json no existe, bind_vars ya estรก poblado con DEFAULTS. + # print(f"Config file {config_json} not found. Using defaults (already initialized).") + + +def generate_hyprconf() -> str: + """ + Generate the Hypr configuration string using the current bind_vars. + """ + home = os.path.expanduser("~") + # Determine animation type based on bar position + bar_position = get_bind_var("bar_position") + is_vertical = bar_position in ["Left", "Right"] + animation_type = "slidefadevert" if is_vertical else "slidefade" + + return f"""exec-once = uwsm-app $(python {home}/.config/{APP_NAME_CAP}/main.py) +exec = pgrep -x "hypridle" > /dev/null || uwsm app -- hypridle +exec = uwsm app -- awww-daemon +exec-once = wl-paste --type text --watch cliphist store +exec-once = wl-paste --type image --watch cliphist store + +$fabricSend = fabric-cli exec {APP_NAME} +$axMessage = notify-send "Axenide" "FIRE IN THE HOLEโ€ผ๏ธ๐Ÿ—ฃ๏ธ๐Ÿ”ฅ๐Ÿ•ณ๏ธ" -i "{home}/.config/{APP_NAME_CAP}/assets/ax.png" -A "๐Ÿ—ฃ๏ธ" -A "๐Ÿ”ฅ" -A "๐Ÿ•ณ๏ธ" -a "Source Code" + +bind = {get_bind_var("prefix_restart")}, {get_bind_var("suffix_restart")}, exec, killall {APP_NAME}; uwsm-app $(python {home}/.config/{APP_NAME_CAP}/main.py) # Reload {APP_NAME_CAP} +bind = {get_bind_var("prefix_axmsg")}, {get_bind_var("suffix_axmsg")}, exec, $axMessage # Message +bind = {get_bind_var("prefix_dash")}, {get_bind_var("suffix_dash")}, exec, $fabricSend 'notch.open_notch("dashboard")' # Dashboard +bind = {get_bind_var("prefix_bluetooth")}, {get_bind_var("suffix_bluetooth")}, exec, $fabricSend 'notch.open_notch("bluetooth")' # Bluetooth +bind = {get_bind_var("prefix_pins")}, {get_bind_var("suffix_pins")}, exec, $fabricSend 'notch.open_notch("pins")' # Pins +bind = {get_bind_var("prefix_kanban")}, {get_bind_var("suffix_kanban")}, exec, $fabricSend 'notch.open_notch("kanban")' # Kanban +bind = {get_bind_var("prefix_launcher")}, {get_bind_var("suffix_launcher")}, exec, $fabricSend 'notch.open_notch("launcher")' # App Launcher +bind = {get_bind_var("prefix_tmux")}, {get_bind_var("suffix_tmux")}, exec, $fabricSend 'notch.open_notch("tmux")' # Tmux +bind = {get_bind_var("prefix_cliphist")}, {get_bind_var("suffix_cliphist")}, exec, $fabricSend 'notch.open_notch("cliphist")' # Clipboard History +bind = {get_bind_var("prefix_toolbox")}, {get_bind_var("suffix_toolbox")}, exec, $fabricSend 'notch.open_notch("tools")' # Toolbox +bind = {get_bind_var("prefix_overview")}, {get_bind_var("suffix_overview")}, exec, $fabricSend 'notch.open_notch("overview")' # Overview +bind = {get_bind_var("prefix_wallpapers")}, {get_bind_var("suffix_wallpapers")}, exec, $fabricSend 'notch.open_notch("wallpapers")' # Wallpapers +bind = {get_bind_var("prefix_randwall")}, {get_bind_var("suffix_randwall")}, exec, $fabricSend 'notch.dashboard.wallpapers.set_random_wallpaper(None, external=True)' # Random Wallpaper +bind = {get_bind_var("prefix_mixer")}, {get_bind_var("suffix_mixer")}, exec, $fabricSend 'notch.open_notch("mixer")' # Audio Mixer +bind = {get_bind_var("prefix_emoji")}, {get_bind_var("suffix_emoji")}, exec, $fabricSend 'notch.open_notch("emoji")' # Emoji Picker +bind = {get_bind_var("prefix_power")}, {get_bind_var("suffix_power")}, exec, $fabricSend 'notch.open_notch("power")' # Power Menu +bind = {get_bind_var("prefix_caffeine")}, {get_bind_var("suffix_caffeine")}, exec, $fabricSend 'notch.dashboard.widgets.buttons.caffeine_button.toggle_inhibit(external=True)' # Toggle Caffeine +bind = {get_bind_var("prefix_toggle")}, {get_bind_var("suffix_toggle")}, exec, $fabricSend 'from utils.global_keybinds import get_global_keybind_handler; get_global_keybind_handler().toggle_bar()' # Toggle Bar +bind = {get_bind_var("prefix_css")}, {get_bind_var("suffix_css")}, exec, $fabricSend 'app.set_css()' # Reload CSS +bind = {get_bind_var("prefix_restart_inspector")}, {get_bind_var("suffix_restart_inspector")}, exec, killall {APP_NAME}; uwsm-app $(GTK_DEBUG=interactive python {home}/.config/{APP_NAME_CAP}/main.py) # Restart with inspector + +# Wallpapers directory: {get_bind_var("wallpapers_dir")} + +source = {home}/.config/{APP_NAME_CAP}/config/hypr/colors.conf + +layerrule = noanim, fabric + +exec = cp $wallpaper ~/.current.wall + +general {{ + col.active_border = rgb($primary) + col.inactive_border = rgb($surface) + gaps_in = 2 + gaps_out = 4 + border_size = 2 + layout = dwindle +}} + +cursor {{ + no_warps=true +}} + +decoration {{ + blur {{ + enabled = yes + size = 1 + passes = 3 + new_optimizations = yes + contrast = 1 + brightness = 1 + }} + rounding = 14 + shadow {{ + enabled = true + range = 10 + render_power = 2 + color = rgba(0, 0, 0, 0.25) + }} +}} + +animations {{ + enabled = yes + bezier = myBezier, 0.4, 0.0, 0.2, 1.0 + animation = windows, 1, 2.5, myBezier, popin 80% + animation = border, 1, 2.5, myBezier + animation = fade, 1, 2.5, myBezier + animation = workspaces, 1, 2.5, myBezier, {animation_type} 20% +}} +""" + + +def ensure_face_icon(): + """ + Ensure the face icon exists. If not, copy the default icon. + """ + face_icon_path = os.path.expanduser("~/.face.icon") + default_icon_path = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/assets/default.png" + ) + if not os.path.exists(face_icon_path) and os.path.exists(default_icon_path): + try: + shutil.copy(default_icon_path, face_icon_path) + except Exception as e: + print(f"Error copying default face icon: {e}") + + +def backup_and_replace(src: str, dest: str, config_name: str): + """ + Backup the existing configuration file and replace it with a new one. + """ + try: + if os.path.exists(dest): + backup_path = dest + ".bak" + # Asegurarse que el directorio de backup existe si es diferente + # os.makedirs(os.path.dirname(backup_path), exist_ok=True) + shutil.copy(dest, backup_path) + print(f"{config_name} config backed up to {backup_path}") + os.makedirs( + os.path.dirname(dest), exist_ok=True + ) # Ensure dest directory exists + shutil.copy(src, dest) + print(f"{config_name} config replaced from {src}") + except Exception as e: + print(f"Error backing up/replacing {config_name} config: {e}") + + +def start_config(): + """ + Run final configuration steps: ensure necessary configs, write the hyprconf, and reload. + """ + print(f"{time.time():.4f}: start_config: Ensuring matugen config...") + ensure_matugen_config() + print(f"{time.time():.4f}: start_config: Ensuring face icon...") + ensure_face_icon() + print(f"{time.time():.4f}: start_config: Generating hypr conf...") + + hypr_config_dir = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/") + os.makedirs(hypr_config_dir, exist_ok=True) + # Usar APP_NAME para el nombre del archivo .conf para que coincida con SOURCE_STRING corregido + hypr_conf_path = os.path.join(hypr_config_dir, f"{APP_NAME}.conf") + try: + with open(hypr_conf_path, "w") as f: + f.write(generate_hyprconf()) + print(f"Generated Hyprland config at {hypr_conf_path}") + except Exception as e: + print(f"Error writing Hyprland config: {e}") + print(f"{time.time():.4f}: start_config: Finished generating hypr conf.") + + print(f"{time.time():.4f}: start_config: Initiating hyprctl reload...") + try: + # subprocess.run(["hyprctl", "reload"], check=True, capture_output=True, text=True) + exec_shell_command_async("hyprctl reload") # Mantener async para no bloquear + print( + f"{time.time():.4f}: start_config: Hyprland configuration reload initiated." + ) + except FileNotFoundError: + print("Error: hyprctl command not found. Cannot reload Hyprland.") + except ( + subprocess.CalledProcessError + ) as e: # Si usรกramos subprocess.run con check=True + print( + f"Error reloading Hyprland with hyprctl: {e}\nOutput:\n{e.stdout}\n{e.stderr}" + ) + except Exception as e: + print(f"An error occurred initiating hyprctl reload: {e}") + print(f"{time.time():.4f}: start_config: Finished initiating hyprctl reload.") diff --git a/Ax-Shell/install.sh b/Ax-Shell/install.sh new file mode 100755 index 0000000..5f9e14d --- /dev/null +++ b/Ax-Shell/install.sh @@ -0,0 +1,157 @@ +#!/bin/bash + +set -e # Exit immediately if a command fails +set -u # Treat unset variables as errors +set -o pipefail # Prevent errors in a pipeline from being masked + +REPO_URL="https://github.com/Axenide/Ax-Shell.git" +INSTALL_DIR="$HOME/.config/Ax-Shell" +PACKAGES=( + awww-git + brightnessctl + cava + cliphist + ddcutil + fabric-cli-git + gnome-bluetooth-3.0 + gobject-introspection + gpu-screen-recorder + hypridle + hyprlock + hyprpicker + hyprshot + hyprsunset + imagemagick + libnotify + matugen-bin + network-manager-applet + networkmanager + nm-connection-editor + noto-fonts-emoji + nvtop + playerctl + power-profiles-daemon + python-fabric-git + python-gobject + python-ijson + python-numpy + python-pillow + python-psutil + python-pywayland + python-requests + python-setproctitle + python-toml + python-watchdog + swappy + tesseract + tesseract-data-eng + tesseract-data-spa + tmux + ttf-nerd-fonts-symbols-mono + unzip + upower + uwsm + vte3 + webp-pixbuf-loader + wl-clipboard +) + +# Prevent running as root +if [ "$(id -u)" -eq 0 ]; then + echo "Please do not run this script as root." + exit 1 +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 the repository +if [ -d "$INSTALL_DIR" ]; then + echo "Updating Ax-Shell..." + git -C "$INSTALL_DIR" pull +else + echo "Cloning Ax-Shell..." + git clone --depth=1 "$REPO_URL" "$INSTALL_DIR" +fi + +# Install required packages using the detected AUR helper (only if missing) +echo "Installing required packages..." +$aur_helper -Syy --needed --devel --noconfirm "${PACKAGES[@]}" || true + +echo "Installing gray-git..." +yes | $aur_helper -Syy --needed --devel --noconfirm gray-git || true + +echo "Installing required fonts..." + +FONT_URL="https://github.com/zed-industries/zed-fonts/releases/download/1.2.0/zed-sans-1.2.0.zip" +FONT_DIR="$HOME/.fonts/zed-sans" +TEMP_ZIP="/tmp/zed-sans-1.2.0.zip" + +# Check if fonts are already installed +if [ ! -d "$FONT_DIR" ]; then + echo "Downloading fonts from $FONT_URL..." + curl -L -o "$TEMP_ZIP" "$FONT_URL" + + echo "Extracting fonts to $FONT_DIR..." + mkdir -p "$FONT_DIR" + unzip -o "$TEMP_ZIP" -d "$FONT_DIR" + + echo "Cleaning up..." + rm "$TEMP_ZIP" +else + echo "Fonts are already installed. Skipping download and extraction." +fi + +# Network services handling +echo "Configuring network services..." + +# Disable iwd if enabled/active +if systemctl is-enabled --quiet iwd 2>/dev/null || systemctl is-active --quiet iwd 2>/dev/null; then + echo "Disabling iwd..." + sudo systemctl disable --now iwd +else + echo "iwd is already disabled." +fi + +# Enable NetworkManager if not enabled +if ! systemctl is-enabled --quiet NetworkManager 2>/dev/null; then + echo "Enabling NetworkManager..." + sudo systemctl enable NetworkManager +else + echo "NetworkManager is already enabled." +fi + +# Start NetworkManager if not running +if ! systemctl is-active --quiet NetworkManager 2>/dev/null; then + echo "Starting NetworkManager..." + sudo systemctl start NetworkManager +else + echo "NetworkManager is already running." +fi + +# Copy local fonts if not already present +if [ ! -d "$HOME/.fonts/tabler-icons" ]; then + echo "Copying local fonts to $HOME/.fonts/tabler-icons..." + mkdir -p "$HOME/.fonts/tabler-icons" + cp -r "$INSTALL_DIR/assets/fonts/"* "$HOME/.fonts" +else + echo "Local fonts are already installed. Skipping copy." +fi + +python "$INSTALL_DIR/config/config.py" +echo "Starting Ax-Shell..." +killall ax-shell 2>/dev/null || true +uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & +disown + +echo "Installation complete." diff --git a/Ax-Shell/main.css b/Ax-Shell/main.css new file mode 100644 index 0000000..1b961d6 --- /dev/null +++ b/Ax-Shell/main.css @@ -0,0 +1,131 @@ +@import url("./styles/applets.css"); +@import url("./styles/bar.css"); +@import url("./styles/buttons.css"); +@import url("./styles/calendar.css"); +@import url("./styles/colors.css"); +@import url("./styles/controls.css"); +@import url("./styles/dashboard.css"); +@import url("./styles/dock.css"); +@import url("./styles/emoji.css"); +@import url("./styles/extras.css"); +@import url("./styles/kanban.css"); +@import url("./styles/launcher.css"); +@import url("./styles/metrics.css"); +@import url("./styles/notch.css"); +@import url("./styles/notifications.css"); +@import url("./styles/overview.css"); +@import url("./styles/pins.css"); +@import url("./styles/player.css"); +@import url("./styles/power.css"); +@import url("./styles/shadows.css"); +@import url("./styles/tools.css"); +@import url("./styles/wallpapers.css"); +@import url("./styles/systemprofiles.css"); +@import url("./styles/workspaces.css"); + +* { + all: unset; + color: var(--foreground); + font-size: unset; + font-family: unset; + border-radius: 16px; +} + +#applet-stack, +#calendar, +#header, +#player, +#metrics, +#pin-cell-box, +#kanban-header { + background-color: alpha(black, 0.5); +} + +#update-window { + background: linear-gradient( + 135deg, + var(--primary) 0%, + var(--primary) 1%, + var(--shadow) 1%, + var(--shadow) 2%, + var(--outline) 2%, + var(--outline) 3%, + var(--shadow) 3%, + var(--shadow) 4%, + var(--surface-bright) 4%, + var(--surface-bright) 5%, + var(--shadow) 5%, + var(--shadow) 6%, + var(--surface) 6%, + var(--surface) 7%, + var(--shadow) 7%, + var(--shadow) 8%, + var(--shadow) 93%, + var(--surface) 93%, + var(--surface) 94%, + var(--shadow) 94%, + var(--shadow) 95%, + var(--surface-bright) 95%, + var(--surface-bright) 96%, + var(--shadow) 96%, + var(--shadow) 97%, + var(--outline) 97%, + var(--outline) 98%, + var(--shadow) 98%, + var(--shadow) 99%, + var(--primary) 99%, + var(--primary) 100% + ); + border-radius: 0; +} + +#update-button { + background-color: var(--primary); + border-radius: 16px; + padding: 8px 16px; + font-weight: bold; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#update-button:hover { + background-color: var(--foreground); + border-radius: 8px; +} + +#update-button:active { + background-color: var(--primary); +} + +#update-button label { + color: var(--shadow); +} + +#later-button { + background-color: var(--surface-bright); + border-radius: 16px; + padding: 8px 16px; + font-weight: bold; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#later-button:hover { + background-color: var(--outline); + border-radius: 8px; +} + +#later-button:active { + background-color: var(--surface-bright); +} + +#toggle-updater-button { + background-color: var(--surface-bright); + border-radius: 16px; + padding: 8px 16px; + font-weight: bold; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#toggle-updater-button:hover { + background-color: var(--outline); + border-radius: 8px; +} diff --git a/Ax-Shell/main.py b/Ax-Shell/main.py new file mode 100644 index 0000000..22f93fc --- /dev/null +++ b/Ax-Shell/main.py @@ -0,0 +1,150 @@ +import os + +import gi + +gi.require_version("GLib", "2.0") +import setproctitle +from fabric import Application +from fabric.utils import exec_shell_command_async, get_relative_path +from gi.repository import GLib + +from config.data import APP_NAME, APP_NAME_CAP, CACHE_DIR, CONFIG_FILE, HOME_DIR +from modules.bar import Bar +from modules.corners import Corners +from modules.dock import Dock +from modules.notch import Notch +from modules.notifications import NotificationPopup +from modules.updater import run_updater + +fonts_updated_file = f"{CACHE_DIR}/fonts_updated" + +if __name__ == "__main__": + setproctitle.setproctitle(APP_NAME) + + if not os.path.isfile(CONFIG_FILE): + config_script_path = get_relative_path("config/config.py") + exec_shell_command_async(f"python {config_script_path}") + + current_wallpaper = os.path.expanduser("~/.current.wall") + if not os.path.exists(current_wallpaper): + example_wallpaper = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/assets/wallpapers_example/example-1.jpg" + ) + os.symlink(example_wallpaper, current_wallpaper) + + # Load configuration + from config.data import load_config + + config = load_config() + + GLib.idle_add(run_updater) + # Every hour + GLib.timeout_add(3600000, run_updater) + + # Initialize multi-monitor services + try: + from utils.monitor_manager import get_monitor_manager + from services.monitor_focus import get_monitor_focus_service + from utils.global_keybinds import init_global_keybind_objects + + monitor_manager = get_monitor_manager() + monitor_focus_service = get_monitor_focus_service() + monitor_manager.set_monitor_focus_service(monitor_focus_service) + init_global_keybind_objects() + + # Get all available monitors + all_monitors = monitor_manager.get_monitors() + multi_monitor_enabled = True + except ImportError: + # Fallback to single monitor mode + all_monitors = [{'id': 0, 'name': 'default'}] + monitor_manager = None + multi_monitor_enabled = False + + # Filter monitors based on selected_monitors configuration + selected_monitors_config = config.get("selected_monitors", []) + + # If selected_monitors is empty, show on all monitors (current behavior) + if not selected_monitors_config: + monitors = all_monitors + print("Ax-Shell: No specific monitors selected, showing on all monitors") + else: + # Filter monitors to only include selected ones + monitors = [] + selected_monitor_names = set(selected_monitors_config) + + for monitor in all_monitors: + monitor_name = monitor.get('name', f'monitor-{monitor.get("id", 0)}') + if monitor_name in selected_monitor_names: + monitors.append(monitor) + print(f"Ax-Shell: Including monitor '{monitor_name}' (selected)") + else: + print(f"Ax-Shell: Excluding monitor '{monitor_name}' (not selected)") + + # Fallback: if no valid monitors found, use all monitors + if not monitors: + print("Ax-Shell: No valid selected monitors found, falling back to all monitors") + monitors = all_monitors + + # Create application components list + app_components = [] + corners = None + notification = None + + # Create components for each monitor + for monitor in monitors: + monitor_id = monitor['id'] + + # Create corners only for the first monitor (shared across all) + if monitor_id == 0: + corners = Corners() + # Set corners visibility based on config + corners_visible = config.get("corners_visible", True) + corners.set_visible(corners_visible) + app_components.append(corners) + + # Create monitor-specific components + if multi_monitor_enabled: + bar = Bar(monitor_id=monitor_id) + notch = Notch(monitor_id=monitor_id) + dock = Dock(monitor_id=monitor_id) + else: + # Single monitor fallback + bar = Bar() + notch = Notch() + dock = Dock() + + # Connect bar and notch + bar.notch = notch + notch.bar = bar + + # Create notification popup for the first monitor only + if monitor_id == 0: + notification = NotificationPopup(widgets=notch.dashboard.widgets) + app_components.append(notification) + + # Register instances in monitor manager if available + if multi_monitor_enabled and monitor_manager: + monitor_manager.register_monitor_instances(monitor_id, { + 'bar': bar, + 'notch': notch, + 'dock': dock, + 'corners': corners if monitor_id == 0 else None + }) + + # Add components to app list + app_components.extend([bar, notch, dock]) + + # Create the application with all components + app = Application(f"{APP_NAME}", *app_components) + + 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/Ax-Shell/modules/__init__.py b/Ax-Shell/modules/__init__.py new file mode 100644 index 0000000..e508c59 --- /dev/null +++ b/Ax-Shell/modules/__init__.py @@ -0,0 +1,4 @@ +""" +Ax-Shell modules package. +Contains UI components and functionality modules. +""" diff --git a/Ax-Shell/modules/bar.py b/Ax-Shell/modules/bar.py new file mode 100644 index 0000000..9f09ae5 --- /dev/null +++ b/Ax-Shell/modules/bar.py @@ -0,0 +1,627 @@ +import json +import os + +from fabric.hyprland.service import HyprlandEvent +from fabric.hyprland.widgets import HyprlandLanguage as Language +from fabric.hyprland.widgets import HyprlandWorkspaces as Workspaces +from fabric.hyprland.widgets import WorkspaceButton, get_hyprland_connection +from fabric.utils.helpers import exec_shell_command_async +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.label import Label +from fabric.widgets.revealer import Revealer +from gi.repository import Gdk, GLib, Gtk + +import config.data as data +import modules.icons as icons +from modules.controls import ControlSmall +from modules.dock import Dock +from modules.metrics import Battery, MetricsSmall, NetworkApplet +from modules.systemprofiles import Systemprofiles +from modules.systemtray import SystemTray +from modules.weather import Weather +from widgets.wayland import WaylandWindow as Window + +CHINESE_NUMERALS = ["ไธ€", "ไบŒ", "ไธ‰", "ๅ››", "ไบ”", "ๅ…ญ", "ไธƒ", "ๅ…ซ", "ไน", "ใ€‡"] + +# Tooltips +tooltip_apps = f"""Launcher +โ€ข Apps: Type to search. + +โ€ข Calculator [Prefix "="]: Solve a math expression. + e.g. "=2+2" + +โ€ข Converter [Prefix ";"]: Convert between units. + e.g. ";100 USD to EUR", ";10 km to miles" + +โ€ข Special Commands [Prefix ":"]: + :update - Open {data.APP_NAME_CAP}'s updater. + :d - Open Dashboard. + :w - Open Wallpapers.""" + +tooltip_power = """Power Menu""" +tooltip_tools = """Toolbox""" +tooltip_overview = """Overview""" + + +class Bar(Window): + def __init__(self, monitor_id: int = 0, **kwargs): + self.monitor_id = monitor_id + + super().__init__( + name="bar", + layer="top", + exclusivity="auto", + visible=True, + all_visible=True, + monitor=monitor_id, + ) + + self.anchor_var = "" + self.margin_var = "" + + match data.BAR_POSITION: + case "Top": + self.anchor_var = "left top right" + case "Bottom": + self.anchor_var = "left bottom right" + case "Left": + self.anchor_var = "left" if data.CENTERED_BAR else "left top bottom" + case "Right": + self.anchor_var = "right" if data.CENTERED_BAR else "top right bottom" + case _: + self.anchor_var = "left top right" + + if data.VERTICAL: + match data.BAR_THEME: + case "Edge": + self.margin_var = "-8px -8px -8px -8px" + case _: + self.margin_var = "-4px -8px -4px -4px" + else: + match data.BAR_THEME: + case "Edge": + self.margin_var = "-8px -8px -8px -8px" + case _: + if data.BAR_POSITION == "Bottom": + self.margin_var = "-8px -4px -4px -4px" + else: + self.margin_var = "-4px -4px -8px -4px" + + self.set_anchor(self.anchor_var) + self.set_margin(self.margin_var) + + self.notch = kwargs.get("notch", None) + self.component_visibility = data.BAR_COMPONENTS_VISIBILITY + + self.dock_instance = None + self.integrated_dock_widget = None + + # Calculate workspace range based on monitor_id + # Monitor 0: workspaces 1-10, Monitor 1: workspaces 11-20, etc. + start_workspace = self.monitor_id * 10 + 1 + end_workspace = start_workspace + 10 + workspace_range = range(start_workspace, end_workspace) + + self.workspaces = Workspaces( + name="workspaces", + invert_scroll=True, + empty_scroll=True, + v_align="fill", + orientation="h" if not data.VERTICAL else "v", + spacing=8, + buttons=[ + WorkspaceButton( + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + id=i, + label=None, + style_classes=["vertical"] if data.VERTICAL else None, + ) + for i in workspace_range + ], + buttons_factory=( + None + if data.BAR_HIDE_SPECIAL_WORKSPACE + else Workspaces.default_buttons_factory + ), + ) + + self.workspaces_num = Workspaces( + name="workspaces-num", + invert_scroll=True, + empty_scroll=True, + v_align="fill", + orientation="h" if not data.VERTICAL else "v", + spacing=0 if not data.BAR_WORKSPACE_USE_CHINESE_NUMERALS else 4, + buttons=[ + WorkspaceButton( + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + id=i, + label=( + CHINESE_NUMERALS[(i - start_workspace)] + if data.BAR_WORKSPACE_USE_CHINESE_NUMERALS + and 0 <= (i - start_workspace) < len(CHINESE_NUMERALS) + else str(i) + ), + ) + for i in workspace_range + ], + buttons_factory=( + None + if data.BAR_HIDE_SPECIAL_WORKSPACE + else Workspaces.default_buttons_factory + ), + ) + + self.ws_container = Box( + name="workspaces-container", + children=( + self.workspaces + if not data.BAR_WORKSPACE_SHOW_NUMBER + else self.workspaces_num + ), + ) + + self.button_tools = Button( + name="button-bar", + tooltip_markup=tooltip_tools, + on_clicked=lambda *_: self.tools_menu(), + child=Label(name="button-bar-label", markup=icons.toolbox), + ) + + self.connection = get_hyprland_connection() + self.button_tools.connect("enter_notify_event", self.on_button_enter) + self.button_tools.connect("leave_notify_event", self.on_button_leave) + + self.systray = SystemTray() + + self.weather = Weather() + self.sysprofiles = Systemprofiles() + + self.network = NetworkApplet() + + self.lang_label = Label(name="lang-label") + self.language = Button( + name="language", h_align="center", v_align="center", child=self.lang_label + ) + self.on_language_switch() + self.connection.connect("event::activelayout", self.on_language_switch) + + # Determine date-time format based on the new setting + if data.DATETIME_12H_FORMAT: + time_format_horizontal = "%I:%M %p" + time_format_vertical = "%I\n%M\n%p" + else: + time_format_horizontal = "%H:%M" + time_format_vertical = "%H\n%M" + + self.date_time = DateTime( + name="date-time", + formatters=( + [time_format_horizontal] + if not data.VERTICAL + else [time_format_vertical] + ), + h_align="center" if not data.VERTICAL else "fill", + v_align="center", + h_expand=True, + v_expand=True, + style_classes=["vertical"] if data.VERTICAL else [], + ) + + self.button_apps = Button( + name="button-bar", + tooltip_markup=tooltip_apps, + on_clicked=lambda *_: self.search_apps(), + child=Label(name="button-bar-label", markup=icons.apps), + ) + self.button_apps.connect("enter_notify_event", self.on_button_enter) + self.button_apps.connect("leave_notify_event", self.on_button_leave) + + self.button_power = Button( + name="button-bar", + tooltip_markup=tooltip_power, + on_clicked=lambda *_: self.power_menu(), + child=Label(name="button-bar-label", markup=icons.shutdown), + ) + self.button_power.connect("enter_notify_event", self.on_button_enter) + self.button_power.connect("leave_notify_event", self.on_button_leave) + + self.button_overview = Button( + name="button-bar", + tooltip_markup=tooltip_overview, + on_clicked=lambda *_: self.overview(), + child=Label(name="button-bar-label", markup=icons.windows), + ) + self.button_overview.connect("enter_notify_event", self.on_button_enter) + self.button_overview.connect("leave_notify_event", self.on_button_leave) + + self.control = ControlSmall() + self.metrics = MetricsSmall() + self.battery = Battery() + + self.apply_component_props() + + self.rev_right = [ + self.metrics, + self.control, + ] + + self.revealer_right = Revealer( + name="bar-revealer", + transition_type="slide-left", + child_revealed=True, + child=Box( + name="bar-revealer-box", + orientation="h", + spacing=4, + children=self.rev_right if not data.VERTICAL else None, + ), + ) + + self.boxed_revealer_right = Box( + name="boxed-revealer", + children=[ + self.revealer_right, + ], + ) + + self.rev_left = [ + self.weather, + self.sysprofiles, + self.network, + ] + + self.revealer_left = Revealer( + name="bar-revealer", + transition_type="slide-right", + child_revealed=True, + child=Box( + name="bar-revealer-box", + orientation="h", + spacing=4, + children=self.rev_left if not data.VERTICAL else None, + ), + ) + + self.boxed_revealer_left = Box( + name="boxed-revealer", + children=[ + self.revealer_left, + ], + ) + + self.h_start_children = [ + self.button_apps, + self.ws_container, + self.button_overview, + self.boxed_revealer_left, + ] + + self.h_end_children = [ + self.boxed_revealer_right, + self.battery, + self.systray, + self.button_tools, + self.language, + self.date_time, + self.button_power, + ] + + self.v_start_children = [ + self.button_apps, + self.systray, + self.control, + self.sysprofiles, + self.network, + self.button_tools, + ] + + self.v_center_children = [ + self.button_overview, + self.ws_container, + self.weather, + ] + + self.v_end_children = [ + self.battery, + self.metrics, + self.language, + self.date_time, + self.button_power, + ] + + self.v_all_children = [] + self.v_all_children.extend(self.v_start_children) + self.v_all_children.extend(self.v_center_children) + self.v_all_children.extend(self.v_end_children) + + # Create embedded dock when bar is in center position (regardless of DOCK_ENABLED setting) + should_embed_dock = ( + data.BAR_POSITION == "Bottom" + or (data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Top", "Bottom"]) + ) + + if should_embed_dock: + if not data.VERTICAL: + self.dock_instance = Dock(integrated_mode=True) + self.integrated_dock_widget = self.dock_instance.wrapper + + is_centered_bar = data.VERTICAL and getattr(data, "CENTERED_BAR", False) + + bar_center_actual_children = None + + if self.integrated_dock_widget is not None: + bar_center_actual_children = self.integrated_dock_widget + elif data.VERTICAL: + bar_center_actual_children = Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=4, + children=( + self.v_all_children if is_centered_bar else self.v_center_children + ), + ) + + self.bar_inner = CenterBox( + name="bar-inner", + orientation=( + Gtk.Orientation.HORIZONTAL + if not data.VERTICAL + else Gtk.Orientation.VERTICAL + ), + h_align="fill", + v_align="fill", + start_children=( + None + if is_centered_bar + else Box( + name="start-container", + spacing=4, + orientation=( + Gtk.Orientation.HORIZONTAL + if not data.VERTICAL + else Gtk.Orientation.VERTICAL + ), + children=( + self.h_start_children + if not data.VERTICAL + else self.v_start_children + ), + ) + ), + center_children=bar_center_actual_children, + end_children=( + None + if is_centered_bar + else Box( + name="end-container", + spacing=4, + orientation=( + Gtk.Orientation.HORIZONTAL + if not data.VERTICAL + else Gtk.Orientation.VERTICAL + ), + children=( + self.h_end_children + if not data.VERTICAL + else self.v_end_children + ), + ) + ), + ) + + self.children = self.bar_inner + + self.hidden = False + + self.themed_children = [ + self.button_apps, + self.button_overview, + self.button_power, + self.button_tools, + self.language, + self.date_time, + self.ws_container, + self.weather, + self.network, + self.battery, + self.metrics, + self.systray, + self.control, + ] + if self.integrated_dock_widget: + self.themed_children.append(self.integrated_dock_widget) + + current_theme = data.BAR_THEME + theme_classes = ["pills", "dense", "edge", "edgecenter"] + for tc in theme_classes: + self.bar_inner.remove_style_class(tc) + + self.style = None + match current_theme: + case "Pills": + self.style = "pills" + case "Dense": + self.style = "dense" + case "Edge": + if data.VERTICAL and data.CENTERED_BAR: + self.style = "edgecenter" + else: + self.style = "edge" + case _: + self.style = "pills" + + self.bar_inner.add_style_class(self.style) + + if self.integrated_dock_widget and hasattr( + self.integrated_dock_widget, "add_style_class" + ): + for theme_class_to_remove in ["pills", "dense", "edge"]: + style_context = self.integrated_dock_widget.get_style_context() + if style_context.has_class(theme_class_to_remove): + self.integrated_dock_widget.remove_style_class( + theme_class_to_remove + ) + self.integrated_dock_widget.add_style_class(self.style) + + if data.BAR_THEME == "Dense" or data.BAR_THEME == "Edge": + for child in self.themed_children: + if hasattr(child, "add_style_class"): + child.add_style_class("invert") + + match data.BAR_POSITION: + case "Top": + self.bar_inner.add_style_class("top") + case "Bottom": + self.bar_inner.add_style_class("bottom") + case "Left": + self.bar_inner.add_style_class("left") + case "Right": + self.bar_inner.add_style_class("right") + case _: + self.bar_inner.add_style_class("top") + + if data.VERTICAL: + self.bar_inner.add_style_class("vertical") + + self.systray._update_visibility() + self.chinese_numbers() + + def apply_component_props(self): + components = { + "button_apps": self.button_apps, + "systray": self.systray, + "control": self.control, + "network": self.network, + "button_tools": self.button_tools, + "button_overview": self.button_overview, + "ws_container": self.ws_container, + "weather": self.weather, + "battery": self.battery, + "metrics": self.metrics, + "language": self.language, + "date_time": self.date_time, + "button_power": self.button_power, + "sysprofiles": self.sysprofiles, + } + + for component_name, widget in components.items(): + if component_name in self.component_visibility: + widget.set_visible(self.component_visibility[component_name]) + + def toggle_component_visibility(self, component_name): + components = { + "button_apps": self.button_apps, + "systray": self.systray, + "control": self.control, + "network": self.network, + "button_tools": self.button_tools, + "button_overview": self.button_overview, + "ws_container": self.ws_container, + "weather": self.weather, + "battery": self.battery, + "metrics": self.metrics, + "language": self.language, + "date_time": self.date_time, + "button_power": self.button_power, + "sysprofiles": self.sysprofiles, + } + + if component_name in components and component_name in self.component_visibility: + self.component_visibility[component_name] = not self.component_visibility[ + component_name + ] + components[component_name].set_visible( + self.component_visibility[component_name] + ) + + config_file = os.path.expanduser( + f"~/.config/{data.APP_NAME}/config/config.json" + ) + if os.path.exists(config_file): + try: + with open(config_file, "r") as f: + config = json.load(f) + + config[f"bar_{component_name}_visible"] = self.component_visibility[ + component_name + ] + + with open(config_file, "w") as f: + json.dump(config, f, indent=4) + except Exception as e: + print(f"Error updating config file: {e}") + + return self.component_visibility[component_name] + + return None + + def on_button_enter(self, widget, event): + window = widget.get_window() + if window: + window.set_cursor(Gdk.Cursor.new_from_name(widget.get_display(), "hand2")) + + def on_button_leave(self, widget, event): + window = widget.get_window() + if window: + window.set_cursor(None) + + def on_button_clicked(self, *args): + exec_shell_command_async("notify-send 'Botรณn presionado' 'ยกFunciona!'") + + def search_apps(self): + if self.notch: + self.notch.open_notch("launcher") + + def overview(self): + if self.notch: + self.notch.open_notch("overview") + + def power_menu(self): + if self.notch: + self.notch.open_notch("power") + + def tools_menu(self): + if self.notch: + self.notch.open_notch("tools") + + def on_language_switch(self, _=None, event: HyprlandEvent = None): + try: + lang_data = ( + event.data[1] + if event and event.data and len(event.data) > 1 + else Language().get_label() + ) + except json.JSONDecodeError: + lang_data = "UNK" # Fallback to default language label + self.language.set_tooltip_text(lang_data) + if not data.VERTICAL: + self.lang_label.set_label(lang_data[:3].upper()) + else: + self.lang_label.add_style_class("icon") + self.lang_label.set_markup(icons.keyboard) + + def toggle_hidden(self): + self.hidden = not self.hidden + if self.hidden: + self.bar_inner.add_style_class("hidden") + else: + self.bar_inner.remove_style_class("hidden") + # Ensure notch is above bar when bar is shown + if self.notch: + # Focus the notch window to bring it to front + GLib.idle_add(lambda: exec_shell_command_async("hyprctl dispatch focuswindow class:notch") if self.notch else None) + + def chinese_numbers(self): + if data.BAR_WORKSPACE_USE_CHINESE_NUMERALS: + self.workspaces_num.add_style_class("chinese") + else: + self.workspaces_num.remove_style_class("chinese") diff --git a/Ax-Shell/modules/bluetooth.py b/Ax-Shell/modules/bluetooth.py new file mode 100644 index 0000000..df7ddf8 --- /dev/null +++ b/Ax-Shell/modules/bluetooth.py @@ -0,0 +1,160 @@ +from fabric.bluetooth import BluetoothClient, BluetoothDevice +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.scrolledwindow import ScrolledWindow + +import modules.icons as icons + + +class BluetoothDeviceSlot(CenterBox): + def __init__(self, device: BluetoothDevice, **kwargs): + super().__init__(name="bluetooth-device", **kwargs) + self.device = device + self.device.connect("changed", self.on_changed) + self.device.connect( + "notify::closed", lambda *_: self.device.closed and self.destroy() + ) + + self.connection_label = Label(name="bluetooth-connection", markup=icons.bluetooth_disconnected) + self.connect_button = Button( + name="bluetooth-connect", + label="Connect", + on_clicked=lambda *_: self.device.set_connecting(not self.device.connected), + style_classes=["connected"] if self.device.connected else None, + ) + + self.start_children = [ + Box( + spacing=8, + h_expand=True, + h_align="fill", + children=[ + Image(icon_name=device.icon_name + "-symbolic", size=16), + Label(label=device.name, h_expand=True, h_align="start", ellipsization="end"), + self.connection_label, + ], + ) + ] + self.end_children = self.connect_button + + self.device.emit("changed") + + def on_changed(self, *_): + self.connection_label.set_markup( + icons.bluetooth_connected if self.device.connected else icons.bluetooth_disconnected + ) + if self.device.connecting: + self.connect_button.set_label( + "Connecting..." if not self.device.connecting else "..." + ) + else: + self.connect_button.set_label( + "Connect" if not self.device.connected else "Disconnect" + ) + if self.device.connected: + self.connect_button.add_style_class("connected") + else: + self.connect_button.remove_style_class("connected") + return + +class BluetoothConnections(Box): + def __init__(self, **kwargs): + super().__init__( + name="bluetooth", + spacing=4, + orientation="vertical", + **kwargs, + ) + + self.widgets = kwargs["widgets"] + + self.buttons = self.widgets.buttons.bluetooth_button + self.bt_status_text = self.buttons.bluetooth_status_text + self.bt_status_button = self.buttons.bluetooth_status_button + self.bt_icon = self.buttons.bluetooth_icon + self.bt_label = self.buttons.bluetooth_label + self.bt_menu_button = self.buttons.bluetooth_menu_button + self.bt_menu_label = self.buttons.bluetooth_menu_label + + self.client = BluetoothClient(on_device_added=self.on_device_added) + self.scan_label = Label(name="bluetooth-scan-label", markup=icons.radar) + self.scan_button = Button( + name="bluetooth-scan", + child=self.scan_label, + tooltip_text="Scan for Bluetooth devices", + on_clicked=lambda *_: self.client.toggle_scan() + ) + self.back_button = Button( + name="bluetooth-back", + child=Label(name="bluetooth-back-label", markup=icons.chevron_left), + on_clicked=lambda *_: self.widgets.show_notif() + ) + + self.client.connect("notify::enabled", lambda *_: self.status_label()) + self.client.connect( + "notify::scanning", + lambda *_: self.update_scan_label() + ) + + self.paired_box = Box(spacing=2, orientation="vertical") + self.available_box = Box(spacing=2, orientation="vertical") + + content_box = Box(spacing=4, orientation="vertical") + content_box.add(self.paired_box) + content_box.add(Label(name="bluetooth-section", label="Available")) + content_box.add(self.available_box) + + self.children = [ + CenterBox( + name="bluetooth-header", + start_children=self.back_button, + center_children=Label(name="bluetooth-text", label="Bluetooth Devices"), + end_children=self.scan_button + ), + ScrolledWindow( + name="bluetooth-devices", + min_content_size=(-1, -1), + child=content_box, + v_expand=True, + propagate_width=False, + propagate_height=False, + ), + ] + + self.client.notify("scanning") + self.client.notify("enabled") + + def status_label(self): + print(self.client.enabled) + if self.client.enabled: + self.bt_status_text.set_label("Enabled") + for i in [self.bt_status_button, self.bt_status_text, self.bt_icon, self.bt_label, self.bt_menu_button, self.bt_menu_label]: + i.remove_style_class("disabled") + self.bt_icon.set_markup(icons.bluetooth) + else: + self.bt_status_text.set_label("Disabled") + for i in [self.bt_status_button, self.bt_status_text, self.bt_icon, self.bt_label, self.bt_menu_button, self.bt_menu_label]: + i.add_style_class("disabled") + self.bt_icon.set_markup(icons.bluetooth_off) + + def on_device_added(self, client: BluetoothClient, address: str): + if not (device := client.get_device(address)): + return + slot = BluetoothDeviceSlot(device) + + if device.paired: + return self.paired_box.add(slot) + return self.available_box.add(slot) + + def update_scan_label(self): + if self.client.scanning: + self.scan_label.add_style_class("scanning") + self.scan_button.add_style_class("scanning") + self.scan_button.set_tooltip_text("Stop scanning for Bluetooth devices") + else: + self.scan_label.remove_style_class("scanning") + self.scan_button.remove_style_class("scanning") + self.scan_button.set_tooltip_text("Scan for Bluetooth devices") diff --git a/Ax-Shell/modules/buttons.py b/Ax-Shell/modules/buttons.py new file mode 100644 index 0000000..ec14178 --- /dev/null +++ b/Ax-Shell/modules/buttons.py @@ -0,0 +1,472 @@ +import subprocess + +import gi +from fabric.utils.helpers import exec_shell_command_async +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from gi.repository import Gdk, GLib, Gtk + +import config.data as data + +gi.require_version('Gtk', '3.0') +import modules.icons as icons +from services.network import NetworkClient + + +def add_hover_cursor(widget): + widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) + widget.connect("enter-notify-event", lambda w, e: w.get_window().set_cursor(Gdk.Cursor.new_from_name(w.get_display(), "pointer")) if w.get_window() else None) + widget.connect("leave-notify-event", lambda w, e: w.get_window().set_cursor(None) if w.get_window() else None) + +class NetworkButton(Box): + def __init__(self, **kwargs): + self.widgets_instance = kwargs.pop("widgets") + self.network_client = NetworkClient() + self._animation_timeout_id = None + self._animation_step = 0 + self._animation_direction = 1 + + self.network_icon = Label( + name="network-icon", + markup=None, + ) + self.network_label = Label( + name="network-label", + label="Wi-Fi", + justification="left", + ) + self.network_label_box = Box(children=[self.network_label, Box(h_expand=True)]) + self.network_ssid = Label( + name="network-ssid", + justification="left", + ) + self.network_ssid_box = Box(children=[self.network_ssid, Box(h_expand=True)]) + self.network_text = Box( + name="network-text", + orientation="v", + h_align="start", + v_align="center", + children=[self.network_label_box, self.network_ssid_box], + ) + self.network_status_box = Box( + h_align="start", + v_align="center", + spacing=10, + + children=[self.network_icon, self.network_text], + ) + self.network_status_button = Button( + name="network-status-button", + h_expand=True, + child=self.network_status_box, + on_clicked=lambda *_: self.network_client.wifi_device.toggle_wifi() if self.network_client.wifi_device else None, + ) + add_hover_cursor(self.network_status_button) + + self.network_menu_label = Label( + name="network-menu-label", + markup=icons.chevron_right, + ) + self.network_menu_button = Button( + name="network-menu-button", + child=self.network_menu_label, + on_clicked=lambda *_: self.widgets_instance.show_network_applet(), + ) + add_hover_cursor(self.network_menu_button) + + super().__init__( + name="network-button", + orientation="h", + h_align="fill", + v_align="fill", + h_expand=True, + v_expand=True, + spacing=0, + children=[self.network_status_button, self.network_menu_button], + ) + + self.widgets_list_internal = [self, self.network_icon, self.network_label, + self.network_ssid, self.network_status_button, + self.network_menu_button, self.network_menu_label] + + self.network_client.connect('device-ready', self._on_wifi_ready) + + GLib.idle_add(self._initial_update) + + def _initial_update(self): + self.update_state() + return False + + def _on_wifi_ready(self, *args): + if self.network_client.wifi_device: + self.network_client.wifi_device.connect('notify::enabled', self.update_state) + self.network_client.wifi_device.connect('notify::ssid', self.update_state) + self.update_state() + + def _animate_searching(self): + """Animate wifi icon when searching for networks""" + wifi_icons = [icons.wifi_0, icons.wifi_1, icons.wifi_2, icons.wifi_3, icons.wifi_2, icons.wifi_1] + + wifi = self.network_client.wifi_device + if not self.network_icon or not wifi or not wifi.enabled: + self._stop_animation() + return False + + if wifi.state == "activated" and wifi.ssid != "Disconnected": + self._stop_animation() + return False + + GLib.idle_add(self.network_icon.set_markup, wifi_icons[self._animation_step]) + + self._animation_step = (self._animation_step + 1) % len(wifi_icons) + + return True + + def _start_animation(self): + if self._animation_timeout_id is None: + self._animation_step = 0 + self._animation_direction = 1 + + self._animation_timeout_id = GLib.timeout_add(500, self._animate_searching) + + def _stop_animation(self): + if self._animation_timeout_id is not None: + GLib.source_remove(self._animation_timeout_id) + self._animation_timeout_id = None + + def update_state(self, *args): + """Update the button state based on network status""" + wifi = self.network_client.wifi_device + ethernet = self.network_client.ethernet_device + + if wifi and not wifi.enabled: + self._stop_animation() + self.network_icon.set_markup(icons.wifi_off) + for widget in self.widgets_list_internal: + widget.add_style_class("disabled") + self.network_ssid.set_label("Disabled") + return + + for widget in self.widgets_list_internal: + widget.remove_style_class("disabled") + + if wifi and wifi.enabled: + if wifi.state == "activated" and wifi.ssid != "Disconnected": + self._stop_animation() + self.network_ssid.set_label(wifi.ssid) + + if wifi.strength > 0: + strength = wifi.strength + if strength < 25: + self.network_icon.set_markup(icons.wifi_0) + elif strength < 50: + self.network_icon.set_markup(icons.wifi_1) + elif strength < 75: + self.network_icon.set_markup(icons.wifi_2) + else: + self.network_icon.set_markup(icons.wifi_3) + else: + self.network_ssid.set_label("Enabled") + self._start_animation() + + try: + primary_device = self.network_client.primary_device + except AttributeError: + primary_device = "wireless" + + if primary_device == "wired": + self._stop_animation() + if ethernet and ethernet.internet == "activated": + self.network_icon.set_markup(icons.world) + else: + self.network_icon.set_markup(icons.world_off) + else: + if not wifi: + self._stop_animation() + self.network_icon.set_markup(icons.wifi_off) + elif wifi.state == "activated" and wifi.ssid != "Disconnected" and wifi.strength > 0: + self._stop_animation() + strength = wifi.strength + if strength < 25: + self.network_icon.set_markup(icons.wifi_0) + elif strength < 50: + self.network_icon.set_markup(icons.wifi_1) + elif strength < 75: + self.network_icon.set_markup(icons.wifi_2) + else: + self.network_icon.set_markup(icons.wifi_3) + else: + self._start_animation() + +class BluetoothButton(Box): + def __init__(self, **kwargs): + super().__init__( + name="bluetooth-button", + orientation="h", + h_align="fill", + v_align="fill", + h_expand=True, + v_expand=True, + ) + self.widgets = kwargs["widgets"] + + self.bluetooth_icon = Label( + name="bluetooth-icon", + markup=icons.bluetooth, + ) + self.bluetooth_label = Label( + name="bluetooth-label", + label="Bluetooth", + justification="left", + ) + self.bluetooth_label_box = Box(children=[self.bluetooth_label, Box(h_expand=True)]) + self.bluetooth_status_text = Label( + name="bluetooth-status", + label="Disabled", + justification="left", + ) + self.bluetooth_status_box = Box(children=[self.bluetooth_status_text, Box(h_expand=True)]) + self.bluetooth_text = Box( + orientation="v", + h_align="start", + v_align="center", + children=[self.bluetooth_label_box, self.bluetooth_status_box], + ) + self.bluetooth_status_container = Box( + h_align="start", + v_align="center", + spacing=10, + children=[self.bluetooth_icon, self.bluetooth_text], + ) + self.bluetooth_status_button = Button( + name="bluetooth-status-button", + h_expand=True, + child=self.bluetooth_status_container, + on_clicked=lambda *_: self.widgets.bluetooth.client.toggle_power(), + ) + add_hover_cursor(self.bluetooth_status_button) + self.bluetooth_menu_label = Label( + name="bluetooth-menu-label", + markup=icons.chevron_right, + ) + self.bluetooth_menu_button = Button( + name="bluetooth-menu-button", + on_clicked=lambda *_: self.widgets.show_bt(), + child=self.bluetooth_menu_label, + ) + add_hover_cursor(self.bluetooth_menu_button) + + self.add(self.bluetooth_status_button) + self.add(self.bluetooth_menu_button) + +class NightModeButton(Button): + def __init__(self): + self.night_mode_icon = Label( + name="night-mode-icon", + markup=icons.night, + ) + self.night_mode_label = Label( + name="night-mode-label", + label="Night Mode", + justification="left", + ) + self.night_mode_label_box = Box(children=[self.night_mode_label, Box(h_expand=True)]) + self.night_mode_status = Label( + name="night-mode-status", + label="Enabled", + justification="left", + ) + self.night_mode_status_box = Box(children=[self.night_mode_status, Box(h_expand=True)]) + self.night_mode_text = Box( + name="night-mode-text", + orientation="v", + h_align="start", + v_align="center", + children=[self.night_mode_label_box, self.night_mode_status_box], + ) + self.night_mode_box = Box( + h_align="start", + v_align="center", + spacing=10, + children=[self.night_mode_icon, self.night_mode_text], + ) + + super().__init__( + name="night-mode-button", + h_expand=True, + child=self.night_mode_box, + on_clicked=self.toggle_hyprsunset, + ) + add_hover_cursor(self) + + self.widgets = [self, self.night_mode_label, self.night_mode_status, self.night_mode_icon] + self.check_hyprsunset() + + def toggle_hyprsunset(self, *args): + """ + Toggle the 'hyprsunset' process: + - If running, kill it and mark as 'Disabled'. + - If not running, start it and mark as 'Enabled'. + """ + GLib.Thread.new("hyprsunset-toggle", self._toggle_hyprsunset_thread, None) + + def _toggle_hyprsunset_thread(self, user_data): + """Background thread to check and toggle hyprsunset without blocking UI.""" + try: + subprocess.check_output(["pgrep", "hyprsunset"]) + exec_shell_command_async("pkill hyprsunset") + GLib.idle_add(self.night_mode_status.set_label, "Disabled") + GLib.idle_add(self._add_disabled_style) + except subprocess.CalledProcessError: + exec_shell_command_async("hyprsunset -t 3500") + GLib.idle_add(self.night_mode_status.set_label, "Enabled") + GLib.idle_add(self._remove_disabled_style) + + def _add_disabled_style(self): + """Helper to add disabled style to all widgets.""" + for widget in self.widgets: + widget.add_style_class("disabled") + + def _remove_disabled_style(self): + """Helper to remove disabled style from all widgets.""" + for widget in self.widgets: + widget.remove_style_class("disabled") + + def check_hyprsunset(self, *args): + """ + Update the button state based on whether hyprsunset is running. + """ + GLib.Thread.new("hyprsunset-check", self._check_hyprsunset_thread, None) + + def _check_hyprsunset_thread(self, user_data): + """Background thread to check hyprsunset status without blocking UI.""" + try: + subprocess.check_output(["pgrep", "hyprsunset"]) + GLib.idle_add(self.night_mode_status.set_label, "Enabled") + GLib.idle_add(self._remove_disabled_style) + except subprocess.CalledProcessError: + GLib.idle_add(self.night_mode_status.set_label, "Disabled") + GLib.idle_add(self._add_disabled_style) + +class CaffeineButton(Button): + def __init__(self): + self.caffeine_icon = Label( + name="caffeine-icon", + markup=icons.coffee, + ) + self.caffeine_label = Label( + name="caffeine-label", + label="Caffeine", + justification="left", + ) + self.caffeine_label_box = Box(children=[self.caffeine_label, Box(h_expand=True)]) + self.caffeine_status = Label( + name="caffeine-status", + label="Enabled", + justification="left", + ) + self.caffeine_status_box = Box(children=[self.caffeine_status, Box(h_expand=True)]) + self.caffeine_text = Box( + name="caffeine-text", + orientation="v", + h_align="start", + v_align="center", + children=[self.caffeine_label_box, self.caffeine_status_box], + ) + self.caffeine_box = Box( + h_align="start", + v_align="center", + spacing=10, + children=[self.caffeine_icon, self.caffeine_text], + ) + super().__init__( + name="caffeine-button", + h_expand=True, + child=self.caffeine_box, + on_clicked=self.toggle_inhibit, + ) + add_hover_cursor(self) + + self.widgets = [self, self.caffeine_label, self.caffeine_status, self.caffeine_icon] + self.check_inhibit() + + def toggle_inhibit(self, *args, external=False): + """ + Toggle the 'ax-inhibit' process: + - If running, kill it and mark as 'Disabled' (add 'disabled' class). + - If not running, start it and mark as 'Enabled' (remove 'disabled' class). + """ + GLib.Thread.new("caffeine-toggle", self._toggle_inhibit_thread, external) + + def _toggle_inhibit_thread(self, external): + """Background thread to toggle inhibit without blocking UI.""" + try: + subprocess.check_output(["pgrep", "ax-inhibit"]) + exec_shell_command_async("pkill ax-inhibit") + GLib.idle_add(self.caffeine_status.set_label, "Disabled") + GLib.idle_add(self._add_disabled_style) + except subprocess.CalledProcessError: + exec_shell_command_async(f"python {data.HOME_DIR}/.config/{data.APP_NAME_CAP}/scripts/inhibit.py") + GLib.idle_add(self.caffeine_status.set_label, "Enabled") + GLib.idle_add(self._remove_disabled_style) + + if external: + # Different if enabled or disabled + status = "Disabled" if self.caffeine_status.get_label() == "Disabled" else "Enabled" + message = "Disabled ๐Ÿ’ค" if status == "Disabled" else "Enabled โ˜€๏ธ" + exec_shell_command_async(f"notify-send 'โ˜• Caffeine' '{message}' -a '{data.APP_NAME_CAP}' -e") + + def _add_disabled_style(self): + """Helper to add disabled style to all widgets.""" + for widget in self.widgets: + widget.add_style_class("disabled") + + def _remove_disabled_style(self): + """Helper to remove disabled style from all widgets.""" + for widget in self.widgets: + widget.remove_style_class("disabled") + + def check_inhibit(self, *args): + GLib.Thread.new("caffeine-check", self._check_inhibit_thread, None) + + def _check_inhibit_thread(self, user_data): + """Background thread to check inhibit status without blocking UI.""" + try: + subprocess.check_output(["pgrep", "ax-inhibit"]) + GLib.idle_add(self.caffeine_status.set_label, "Enabled") + GLib.idle_add(self._remove_disabled_style) + except subprocess.CalledProcessError: + GLib.idle_add(self.caffeine_status.set_label, "Disabled") + GLib.idle_add(self._add_disabled_style) + +class Buttons(Gtk.Grid): + def __init__(self, **kwargs): + super().__init__(name="buttons-grid") + self.set_row_homogeneous(True) + self.set_column_homogeneous(True) + self.set_row_spacing(4) + self.set_column_spacing(4) + self.set_vexpand(False) + + self.widgets = kwargs["widgets"] + + self.network_button = NetworkButton(widgets=self.widgets) + self.bluetooth_button = BluetoothButton(widgets=self.widgets) + self.night_mode_button = NightModeButton() + self.caffeine_button = CaffeineButton() + + if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]): + + self.attach(self.network_button, 0, 0, 1, 1) + self.attach(self.bluetooth_button, 1, 0, 1, 1) + self.attach(self.night_mode_button, 0, 1, 1, 1) + self.attach(self.caffeine_button, 1, 1, 1, 1) + else: + + self.attach(self.network_button, 0, 0, 1, 1) + self.attach(self.bluetooth_button, 1, 0, 1, 1) + self.attach(self.night_mode_button, 2, 0, 1, 1) + self.attach(self.caffeine_button, 3, 0, 1, 1) + + self.show_all() diff --git a/Ax-Shell/modules/calendar.py b/Ax-Shell/modules/calendar.py new file mode 100644 index 0000000..a9f6cae --- /dev/null +++ b/Ax-Shell/modules/calendar.py @@ -0,0 +1,374 @@ +import calendar +import subprocess +from datetime import datetime, timedelta + +import gi +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label + +import modules.icons as icons + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, Gtk, Gio + + +class Calendar(Gtk.Box): + def __init__(self, view_mode="month"): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8, name="calendar") + self.view_mode = view_mode + self.first_weekday = 0 # Default: Monday, will be updated async + + self.set_halign(Gtk.Align.CENTER) + self.set_hexpand(False) + + self.current_day_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + if self.view_mode == "month": + self.current_shown_date = self.current_day_date.replace(day=1) + self.current_year = self.current_shown_date.year + self.current_month = self.current_shown_date.month + self.current_day = self.current_day_date.day # Solo para resaltar en create_month_view + self.previous_key = (self.current_year, self.current_month) + elif self.view_mode == "week": + # current_shown_date es el primer dรญa (segรบn locale) de la semana actual + days_to_subtract = (self.current_day_date.weekday() - self.first_weekday + 7) % 7 + self.current_shown_date = self.current_day_date - timedelta(days=days_to_subtract) + self.current_year = self.current_shown_date.year # Para el header + self.current_month = self.current_shown_date.month # Para el header + iso_year, iso_week, _ = self.current_shown_date.isocalendar() + self.previous_key = (iso_year, iso_week) + self.set_halign(Gtk.Align.FILL) + self.set_hexpand(True) + self.set_valign(Gtk.Align.CENTER) + self.set_vexpand(False) + + self.cache_threshold = 3 # Umbral para mantener vistas en cachรฉ + + self.month_views = {} # Reutilizado para vistas de semana tambiรฉn + + self.prev_button = Gtk.Button( # Nombre genรฉrico del botรณn + name="prev-month-button", + child=Label(name="month-button-label", markup=icons.chevron_left) # CSS puede ser genรฉrico + ) + self.prev_button.connect("clicked", self.on_prev_clicked) + + self.month_label = Gtk.Label(name="month-label") # El nombre es histรณrico, pero muestra mes/aรฑo + + self.next_button = Gtk.Button( # Nombre genรฉrico del botรณn + name="next-month-button", + child=Label(name="month-button-label", markup=icons.chevron_right) # CSS puede ser genรฉrico + ) + self.next_button.connect("clicked", self.on_next_clicked) + + self.header = CenterBox( + spacing=4, + name="header", + start_children=[self.prev_button], + center_children=[self.month_label], + end_children=[self.next_button], + ) + + self.add(self.header) + + self.weekday_row = Gtk.Box(spacing=4, name="weekday-row") + self.pack_start(self.weekday_row, False, False, 0) + + self.stack = Gtk.Stack(name="calendar-stack") + self.stack.set_transition_duration(250) + self.pack_start(self.stack, True, True, 0) + + self.update_header() # Llamar antes de update_calendar para que el primer header sea correcto + self.update_calendar() + self.setup_periodic_update() + self.setup_dbus_listeners() + + # Initialize locale settings asynchronously + GLib.Thread.new("calendar-locale", self._init_locale_settings_thread, None) + + def _init_locale_settings_thread(self, user_data): + """Background thread to initialize locale settings without blocking UI.""" + try: + origin_date_str = subprocess.check_output(["locale", "week-1stday"], text=True).strip() + first_weekday_val = int(subprocess.check_output(["locale", "first_weekday"], text=True).strip()) + + origin_date = datetime.fromisoformat(origin_date_str) + # Esta lรณgica calcula el dรญa de la semana (0-6, Lunes=0) que es considerado el primero + # segรบn la configuraciรณn regional combinada de week-1stday y first_weekday. + date_of_first_day_of_week_config = origin_date + timedelta(days=first_weekday_val - 1) + new_first_weekday = date_of_first_day_of_week_config.weekday() # Lunes=0, ..., Domingo=6 + + # Update the first_weekday on main thread and refresh calendar if needed + GLib.idle_add(self._update_first_weekday, new_first_weekday) + except Exception as e: + print(f"Error getting locale first weekday: {e}") + # Keep default value (0 = Monday) + + def _update_first_weekday(self, new_first_weekday): + """Update first weekday setting and refresh calendar if changed.""" + if self.first_weekday != new_first_weekday: + self.first_weekday = new_first_weekday + # Clear cache and refresh calendar with new locale settings + self.month_views.clear() + # Remove all current stack children to force regeneration + for child in self.stack.get_children(): + self.stack.remove(child) + # Update header (which includes weekday labels) and calendar + self.update_header() + self.update_calendar() + return False # Don't repeat this idle callback + + def setup_periodic_update(self): + # Check for date changes every second + GLib.timeout_add(1000, self.check_date_change) + + def setup_dbus_listeners(self): + # Listen for system suspend/resume events + bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) + bus.signal_subscribe( + None, # sender + 'org.freedesktop.login1.Manager', # interface + 'PrepareForSleep', # signal + '/org/freedesktop/login1', # path + None, # arg0 + Gio.DBusSignalFlags.NONE, + self.on_suspend_resume, # callback + None # user_data + ) + + def check_date_change(self): + now = datetime.now() + current_date = now.replace(hour=0, minute=0, second=0, microsecond=0) + if current_date != self.current_day_date: + self.on_midnight() + return True # Continue the timer + + def on_suspend_resume(self, connection, sender_name, object_path, interface_name, signal_name, parameters, user_data): + # Check date when resuming from suspend + self.check_date_change() + + def on_midnight(self): + now = datetime.now() + self.current_day_date = now.replace(hour=0, minute=0, second=0, microsecond=0) + + key_to_remove_for_today_highlight = None + if self.view_mode == "month": + # Actualizar la fecha base para la vista de mes si es necesario (aunque usualmente no cambia a medianoche) + self.current_shown_date = self.current_day_date.replace(day=1) + self.current_year = self.current_shown_date.year + self.current_month = self.current_shown_date.month + self.current_day = self.current_day_date.day # Actualizar el dรญa actual + key_to_remove_for_today_highlight = (self.current_year, self.current_month) + elif self.view_mode == "week": + days_to_subtract = (self.current_day_date.weekday() - self.first_weekday + 7) % 7 + self.current_shown_date = self.current_day_date - timedelta(days=days_to_subtract) + self.current_year = self.current_shown_date.year # Para el header + self.current_month = self.current_shown_date.month # Para el header + iso_year, iso_week, _ = self.current_shown_date.isocalendar() + key_to_remove_for_today_highlight = (iso_year, iso_week) + + # Eliminar la vista actual de la cachรฉ para forzar la regeneraciรณn con el nuevo "hoy" resaltado + if key_to_remove_for_today_highlight and key_to_remove_for_today_highlight in self.month_views: + widget = self.month_views.pop(key_to_remove_for_today_highlight) + self.stack.remove(widget) + # Si la vista eliminada era la actual, previous_key podrรญa quedar desactualizado + # pero update_calendar lo corregirรก al establecer la nueva vista. + + self.update_calendar() # Esto regenerarรก la vista si fue eliminada y actualizarรก el resaltado + return False # Importante para que el timeout no se repita automรกticamente + + def update_header(self): + # self.current_shown_date es el primer dรญa del mes (modo mes) o el primer dรญa de la semana (modo semana) + # El encabezado siempre muestra el mes y aรฑo de self.current_shown_date + self.month_label.set_text( + self.current_shown_date.strftime("%B %Y").capitalize() + ) + + for child in self.weekday_row.get_children(): + self.weekday_row.remove(child) + + day_initials = self.get_weekday_initials() + for day_initial in day_initials: + label = Gtk.Label(label=day_initial.upper(), name="weekday-label") + self.weekday_row.pack_start(label, True, True, 0) + self.weekday_row.show_all() + + def update_calendar(self): + new_key = None + child_name = "" # Renombrado de child_name_prefix + view_widget = None + + if self.view_mode == "month": + new_key = (self.current_year, self.current_month) + child_name = f"{self.current_year}_{self.current_month}" + if new_key not in self.month_views: + view_widget = self.create_month_view(self.current_year, self.current_month) + elif self.view_mode == "week": + iso_year, iso_week, _ = self.current_shown_date.isocalendar() + new_key = (iso_year, iso_week) + child_name = f"{iso_year}_w{iso_week}" + if new_key not in self.month_views: + # Pasar self.current_shown_date directamente a create_week_view + view_widget = self.create_week_view(self.current_shown_date) + + if new_key is None: return + + if new_key > self.previous_key: + self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) + elif new_key < self.previous_key: + self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT) + # else: no transition if key is the same (e.g. on_midnight for same month/week) + + self.previous_key = new_key + + if view_widget: # Si se creรณ una nueva vista + self.month_views[new_key] = view_widget + self.stack.add_titled(view_widget, child_name, child_name) + + self.stack.set_visible_child_name(child_name) + # El encabezado se actualiza ANTES de llamar a update_calendar en __init__ y on_clicked, + # y tambiรฉn en on_midnight si es necesario. + # Pero si la vista cambia (ej. de Enero a Febrero), el encabezado debe reflejarlo. + self.update_header() # Asegurar que el header estรก sincronizado con la vista actual + self.stack.show_all() + + self.prune_cache() + + def prune_cache(self): + def get_key_index(key_tuple): + year, num = key_tuple # num es month o week_number + if self.view_mode == "month": # Asumiendo que la clave es (aรฑo, mes) + return year * 12 + (num - 1) + else: # Asumiendo que la clave es (aรฑo_iso, semana_iso) + return year * 53 + num # Usar 53 para cubrir aรฑos con 53 semanas ISO + + current_index = get_key_index(self.previous_key) # previous_key es la clave de la vista actual + keys_to_remove = [] + for key_iter in self.month_views: + if abs(get_key_index(key_iter) - current_index) > self.cache_threshold: + keys_to_remove.append(key_iter) + for key_to_remove in keys_to_remove: + widget = self.month_views.pop(key_to_remove) + self.stack.remove(widget) + + def create_month_view(self, year, month): + grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=False, name="calendar-grid") + cal = calendar.Calendar(firstweekday=self.first_weekday) + month_days = cal.monthdayscalendar(year, month) + + while len(month_days) < 6: # Asegurar 6 filas para consistencia visual + month_days.append([0] * 7) # [0] representa un dรญa vacรญo + + for row, week in enumerate(month_days): + for col, day_num in enumerate(week): + day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box") + top_spacer = Gtk.Box(hexpand=True, vexpand=True) + middle_box = Gtk.Box(hexpand=True, vexpand=True) + bottom_spacer = Gtk.Box(hexpand=True, vexpand=True) + + if day_num == 0: + label = Label(name="day-empty", markup=icons.dot) + else: + label = Gtk.Label(label=str(day_num), name="day-label") + day_date_obj = datetime(year, month, day_num) + if day_date_obj == self.current_day_date: + label.get_style_context().add_class("current-day") + + middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0) + middle_box.pack_start(label, False, False, 0) + middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0) + + day_box.pack_start(top_spacer, True, True, 0) + day_box.pack_start(middle_box, True, True, 0) + day_box.pack_start(bottom_spacer, True, True, 0) + grid.attach(day_box, col, row, 1, 1) + grid.show_all() + return grid + + def create_week_view(self, first_day_of_week_to_display): + grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=False, name="calendar-grid-week-view") # Podrรญa tener estilo diferente + + # El mes de referencia para atenuar es el mes de first_day_of_week_to_display + # que es self.current_shown_date, y su mes es self.current_month (actualizado en nav) + reference_month_for_dimming = first_day_of_week_to_display.month + + for col in range(7): + current_day_in_loop = first_day_of_week_to_display + timedelta(days=col) + + day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box") # Reusar estilo de day-box + top_spacer = Gtk.Box(hexpand=True, vexpand=True) + middle_box = Gtk.Box(hexpand=True, vexpand=True) + bottom_spacer = Gtk.Box(hexpand=True, vexpand=True) + + label = Gtk.Label(label=str(current_day_in_loop.day), name="day-label") + + if current_day_in_loop == self.current_day_date: + label.get_style_context().add_class("current-day") + + if current_day_in_loop.month != reference_month_for_dimming: + label.get_style_context().add_class("dim-label") # Necesita CSS: .dim-label { opacity: 0.5; } o similar + + middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0) + middle_box.pack_start(label, False, False, 0) + middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0) + + day_box.pack_start(top_spacer, True, True, 0) + day_box.pack_start(middle_box, True, True, 0) + day_box.pack_start(bottom_spacer, True, True, 0) + + grid.attach(day_box, col, 0, 1, 1) # Todos los dรญas en la fila 0 + + # Para mantener una altura similar a la vista mensual, se podrรญan aรฑadir filas vacรญas. + # Esto es opcional y depende del diseรฑo deseado. + # for r_idx in range(1, 6): # Aรฑadir 5 filas vacรญas + # empty_row_placeholder = Gtk.Box(name="day-empty-placeholder", hexpand=True, vexpand=True, height_request=20) # Ajustar altura + # grid.attach(empty_row_placeholder, 0, r_idx, 7, 1) # Abarca las 7 columnas + + grid.show_all() + return grid + + def get_weekday_initials(self): + # Genera las iniciales de los dรญas de la semana comenzando por self.first_weekday + # datetime(2024, 1, 1) es Lunes. Su weekday() es 0. + # Si self.first_weekday es 0 (Lunes), queremos que el primer dรญa sea Lunes. + # i=0: datetime(2024, 1, 1 + 0) -> Lunes + # Si self.first_weekday es 6 (Domingo), queremos que el primer dรญa sea Domingo. + # i=0: datetime(2024, 1, 1 + 6) -> Domingo + # Esta lรณgica es correcta. + return [(datetime(2024, 1, 1) + timedelta(days=(self.first_weekday + i) % 7)).strftime("%a")[:1] for i in range(7)] + + + def on_prev_clicked(self, widget): + if self.view_mode == "month": + current_month_val = self.current_shown_date.month + current_year_val = self.current_shown_date.year + if current_month_val == 1: + self.current_shown_date = self.current_shown_date.replace(year=current_year_val - 1, month=12) + else: + self.current_shown_date = self.current_shown_date.replace(month=current_month_val - 1) + self.current_year = self.current_shown_date.year + self.current_month = self.current_shown_date.month + elif self.view_mode == "week": + self.current_shown_date -= timedelta(days=7) + self.current_year = self.current_shown_date.year # Actualizar para el header + self.current_month = self.current_shown_date.month # Actualizar para el header y dimming + + # self.update_header() # Se llama dentro de update_calendar + self.update_calendar() + + def on_next_clicked(self, widget): + if self.view_mode == "month": + current_month_val = self.current_shown_date.month + current_year_val = self.current_shown_date.year + if current_month_val == 12: + self.current_shown_date = self.current_shown_date.replace(year=current_year_val + 1, month=1) + else: + self.current_shown_date = self.current_shown_date.replace(month=current_month_val + 1) + self.current_year = self.current_shown_date.year + self.current_month = self.current_shown_date.month + elif self.view_mode == "week": + self.current_shown_date += timedelta(days=7) + self.current_year = self.current_shown_date.year # Actualizar para el header + self.current_month = self.current_shown_date.month # Actualizar para el header y dimming + + # self.update_header() # Se llama dentro de update_calendar + self.update_calendar() diff --git a/Ax-Shell/modules/cavalcade.py b/Ax-Shell/modules/cavalcade.py new file mode 100644 index 0000000..cd3acab --- /dev/null +++ b/Ax-Shell/modules/cavalcade.py @@ -0,0 +1,350 @@ +import configparser +import ctypes +import os +import re +import signal +import struct +import subprocess +from math import pi + +from fabric.utils.helpers import get_relative_path +from fabric.widgets.overlay import Overlay +from gi.repository import Gdk, GLib, Gtk +from loguru import logger + + +def get_bars(file_path): + config = configparser.ConfigParser() + config.read(file_path) + return int(config['general']['bars']) + +CAVA_CONFIG = get_relative_path("../config/cavalcade/cava.ini") + +bars = get_bars(CAVA_CONFIG) + +def set_death_signal(): + """ + Set the death signal of the child process to SIGTERM so that if the parent + process is killed, the child (cava) is automatically terminated. + """ + libc = ctypes.CDLL("libc.so.6") + PR_SET_PDEATHSIG = 1 + libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM) + +class Cava: + """ + CAVA wrapper. + Launch cava process with certain settings and read output. + """ + NONE = 0 + RUNNING = 1 + RESTARTING = 2 + CLOSING = 3 + + def data_handler(self, *a, **kw): + """Call all registered handlers with the provided arguments.""" + for h in self._handlers: + h(*a, **kw) + + def register_handler(self, handler): + self._handlers.append(handler) + + def __init__(self): + self.bars = bars + self.path = "/tmp/cava.fifo" + + self.cava_config_file = CAVA_CONFIG + self._handlers = [] + self._started = False + self.command = ["cava", "-p", self.cava_config_file] + self.state = self.NONE + self.process = None + + self.env = dict(os.environ) + self.env["LC_ALL"] = "en_US.UTF-8" # not sure if it's necessary + + is_16bit = True + self.byte_type, self.byte_size, self.byte_norm = ("H", 2, 65535) if is_16bit else ("B", 1, 255) + + if not os.path.exists(self.path): + os.mkfifo(self.path) + + self.fifo_fd = None + self.fifo_dummy_fd = None + self.io_watch_id = None + + def _run_process(self): + try: + self.process = subprocess.Popen( + self.command, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=self.env, + preexec_fn=set_death_signal # Ensure cava gets killed when the parent dies. + ) + self.state = self.RUNNING + except Exception: + logger.exception("Fail to launch cava") + + def _start_io_reader(self): + # Open FIFO in non-blocking mode for reading + self.fifo_fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK) + # Open dummy write end to prevent getting an EOF on our FIFO + self.fifo_dummy_fd = os.open(self.path, os.O_WRONLY | os.O_NONBLOCK) + self.io_watch_id = GLib.io_add_watch(self.fifo_fd, GLib.IO_IN, self._io_callback) + + def _io_callback(self, source, condition): + chunk = self.byte_size * self.bars # number of bytes for given format + try: + if self.fifo_fd is None: + return False + + data = os.read(self.fifo_fd, chunk) + except OSError as e: + if e.errno == 11: # EAGAIN - would block, normal for non-blocking + return True + elif e.errno == 9: # EBADF - bad file descriptor + GLib.idle_add(self.restart) + return False + else: + return False + except Exception: + return False + + # When no data is read, do not remove the IO watch immediately. + if len(data) < chunk: + if len(data) == 0: + # No data available, continue watching + return True + else: + return True + + try: + fmt = self.byte_type * self.bars # format string for struct.unpack + sample = [i / self.byte_norm for i in struct.unpack(fmt, data)] + GLib.idle_add(self.data_handler, sample) + except (struct.error, Exception): + return True + + return True + + def _on_stop(self): + if self.state == self.RESTARTING: + self.start() + elif self.state == self.RUNNING: + self.state = self.NONE + + def start(self): + """Launch cava""" + if self._started: + return + self._start_io_reader() + self._run_process() + self._started = True + + def restart(self): + """Restart cava process""" + if self.state == self.RUNNING: + self.state = self.RESTARTING + if self.process and self.process.poll() is None: + self.process.kill() + elif self.state == self.NONE: + self.start() + + def close(self): + """Stop cava process""" + self.state = self.CLOSING + + # Stop IO watch first + if self.io_watch_id: + GLib.source_remove(self.io_watch_id) + self.io_watch_id = None + + # Close file descriptors safely + if self.fifo_fd is not None: + try: + os.close(self.fifo_fd) + except OSError: + pass + finally: + self.fifo_fd = None + + if self.fifo_dummy_fd is not None: + try: + os.close(self.fifo_dummy_fd) + except OSError: + pass + finally: + self.fifo_dummy_fd = None + + # Kill process if still running + if self.process and self.process.poll() is None: + try: + self.process.kill() + self.process.wait(timeout=2.0) # Wait up to 2 seconds + except subprocess.TimeoutExpired: + self.process.kill() + except Exception: + pass + + # Remove FIFO file + if os.path.exists(self.path): + try: + os.remove(self.path) + except OSError: + pass + +class AttributeDict(dict): + """Dictionary with keys as attributes. Does nothing but easy reading""" + def __getattr__(self, attr): + return self.get(attr, 3) + + def __setattr__(self, attr, value): + self[attr] = value + +class Spectrum: + """Spectrum drawing""" + def __init__(self): + self.silence_value = 0 + self.audio_sample = [] + self.color = None + self._cached_color = None + self._color_file_mtime = 0 + + self.area = Gtk.DrawingArea() + self.area.connect("draw", self.redraw) + self.area.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + + self.sizes = AttributeDict() + self.sizes.area = AttributeDict() + self.sizes.bar = AttributeDict() + + self.silence = 10 + self.max_height = 12 + + self.area.connect("configure-event", self.size_update) + self.color_update() + + def is_silence(self, value): + """Check if volume level critically low during last iterations""" + self.silence_value = 0 if value > 0 else self.silence_value + 1 + return self.silence_value > self.silence + + def update(self, data): + """Audio data processing""" + self.color_update_cached() + self.audio_sample = data + if not self.is_silence(self.audio_sample[0]): + self.area.queue_draw() + elif self.silence_value == (self.silence + 1): + self.audio_sample = [0] * self.sizes.number + self.area.queue_draw() + + def redraw(self, widget, cr): + """Draw spectrum graph""" + cr.set_source_rgba(*self.color) + dx = 3 + + center_y = self.sizes.area.height / 2 # center vertical of the drawing area + for i, value in enumerate(self.audio_sample): + width = self.sizes.area.width / self.sizes.number - self.sizes.padding + radius = width / 2 + height = max(self.sizes.bar.height * min(value, 1), self.sizes.zero) / 2 + if height == self.sizes.zero / 2 + 1: + height *= 0.5 + + height = min(height, self.max_height) + + # Draw rectangle and arcs for rounded ends + cr.rectangle(dx, center_y - height, width, height * 2) + cr.arc(dx + radius, center_y - height, radius, 0, 2 * pi) + cr.arc(dx + radius, center_y + height, radius, 0, 2 * pi) + + cr.close_path() + dx += width + self.sizes.padding + cr.fill() + + def size_update(self, *args): + """Update drawing geometry""" + self.sizes.number = bars + self.sizes.padding = 100 / bars + self.sizes.zero = 0 + + self.sizes.area.width = self.area.get_allocated_width() + self.sizes.area.height = self.area.get_allocated_height() - 2 + + tw = self.sizes.area.width - self.sizes.padding * (self.sizes.number - 1) + self.sizes.bar.width = max(int(tw / self.sizes.number), 1) + self.sizes.bar.height = self.sizes.area.height + + def color_update_cached(self): + """Set drawing color with caching to avoid file reads on every frame""" + color_file = get_relative_path("../styles/colors.css") + try: + # Check if file has been modified + current_mtime = os.path.getmtime(color_file) + if current_mtime != self._color_file_mtime or self._cached_color is None: + self._color_file_mtime = current_mtime + + color = "#a5c8ff" # default value + with open(color_file, "r") as f: + content = f.read() + m = re.search(r"--primary:\s*(#[0-9a-fA-F]{6})", content) + if m: + color = m.group(1) + + red = int(color[1:3], 16) / 255 + green = int(color[3:5], 16) / 255 + blue = int(color[5:7], 16) / 255 + self._cached_color = Gdk.RGBA(red=red, green=green, blue=blue, alpha=1.0) + + self.color = self._cached_color + except Exception: + if self._cached_color is None: + # Fallback to default color + self._cached_color = Gdk.RGBA(red=0.647, green=0.784, blue=1.0, alpha=1.0) + self.color = self._cached_color + + def color_update(self): + """Set drawing color according to current settings by reading primary color from CSS""" + color = "#a5c8ff" # default value + try: + with open(get_relative_path("../styles/colors.css"), "r") as f: + content = f.read() + m = re.search(r"--primary:\s*(#[0-9a-fA-F]{6})", content) + if m: + color = m.group(1) + except Exception: + pass + red = int(color[1:3], 16) / 255 + green = int(color[3:5], 16) / 255 + blue = int(color[5:7], 16) / 255 + self.color = Gdk.RGBA(red=red, green=green, blue=blue, alpha=1.0) + + +_instances = {} + + +def getCava() -> Cava: + if "cava" not in _instances: + _instances["cava"] = Cava() + return _instances["cava"] + + +class SpectrumRender: + def __init__(self, mode=None, **kwargs): + super().__init__(**kwargs) + self.mode = mode + + self.draw = Spectrum() + self.cava = getCava() + self.cava.register_handler(self.draw.update) + + self.cava.start() + + def get_spectrum_box(self): + # Get the spectrum box + box = Overlay(name="cavalcade", h_align='center', v_align='center') + box.set_size_request(180, 40) + box.add_overlay(self.draw.area) + return box diff --git a/Ax-Shell/modules/cliphist.py b/Ax-Shell/modules/cliphist.py new file mode 100644 index 0000000..a62f206 --- /dev/null +++ b/Ax-Shell/modules/cliphist.py @@ -0,0 +1,519 @@ +import os +import re +import subprocess +import sys +import tempfile + +from fabric.utils import idle_add, remove_handler +from fabric.utils.helpers import get_relative_path +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 fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GdkPixbuf, GLib + +import modules.icons as icons + + +class ClipHistory(Box): + def __init__(self, **kwargs): + super().__init__( + name="clip-history", + visible=False, + all_visible=False, + **kwargs, + ) + + self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-") + self.image_cache = {} + + self.notch = kwargs["notch"] + self.selected_index = -1 + self._arranger_handler = 0 + self.clipboard_items = [] + self._loading = False + self._pending_updates = False + + self.viewport = Box(name="viewport", spacing=4, orientation="v") + self.search_entry = Entry( + name="search-entry", + placeholder="Search Clipboard History...", + h_expand=True, + h_align="fill", + notify_text=self.filter_items, + on_activate=lambda entry, *_: self.use_selected_item(), + on_key_press_event=self.on_search_entry_key_press, + ) + self.search_entry.props.xalign = 0.5 + + self.scrolled_window = ScrolledWindow( + name="scrolled-window", + spacing=10, + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + child=self.viewport, + propagate_width=False, + propagate_height=False, + ) + + self.header_box = Box( + name="header_box", + spacing=10, + orientation="h", + children=[ + Button( + name="clear-button", + child=Label(name="clear-label", markup=icons.trash), + on_clicked=lambda *_: self.clear_history(), + ), + self.search_entry, + Button( + name="close-button", + child=Label(name="close-label", markup=icons.cancel), + tooltip_text="Exit", + on_clicked=lambda *_: self.close() + ), + ], + ) + + self.history_box = Box( + name="launcher-box", + spacing=10, + h_expand=True, + orientation="v", + children=[ + self.header_box, + self.scrolled_window, + ], + ) + + self.add(self.history_box) + self.show_all() + + def close(self): + """Close the clipboard history panel""" + self.viewport.children = [] + self.selected_index = -1 + self.notch.close_notch() + + def open(self): + """Open the clipboard history panel and load items""" + if self._loading: + return + self._loading = True + self.search_entry.set_text("") + self.search_entry.grab_focus() + + # Use GLib.Thread for proper async execution + GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None) + + def _load_clipboard_items_thread(self, user_data): + """Background thread worker for loading clipboard items""" + try: + result = subprocess.run( + ["cliphist", "list"], + capture_output=True, + check=True + ) + # Decode stdout with error handling + stdout_str = result.stdout.decode('utf-8', errors='replace') + lines = stdout_str.strip().split('\n') + new_items = [] + for line in lines: + if not line or " 1 else "0" + content = parts[1] if len(parts) > 1 else item + + + display_text = content.strip() + if len(display_text) > 100: + display_text = display_text[:97] + "..." + + + is_image = self.is_image_data(content) + + if is_image: + + button = Button( + name="slot-button", + child=Box( + name="slot-box", + orientation="h", + spacing=10, + children=[ + Image(name="clip-icon", h_align="start"), + Label( + name="clip-label", + label="[Image]", + ellipsization="end", + v_align="center", + h_align="start", + h_expand=True, + ), + ], + ), + tooltip_text="Image in clipboard", + on_clicked=lambda *_, id=item_id: self.paste_item(id), + ) + + self._load_image_preview_async(item_id, button) + else: + + button = self.create_text_item_button(item_id, display_text) + + + button.connect("key-press-event", lambda widget, event, id=item_id: self.on_item_key_press(widget, event, id)) + + + button.set_can_focus(True) + button.add_events(Gdk.EventMask.KEY_PRESS_MASK) + + return button + + def _load_image_preview_async(self, item_id, button): + """Load image preview asynchronously using background thread""" + GLib.Thread.new("image-preview", self._load_image_preview_thread, (item_id, button)) + + def _load_image_preview_thread(self, data): + """Background thread worker for loading image preview""" + item_id, button = data + try: + if item_id in self.image_cache: + pixbuf = self.image_cache[item_id] + GLib.idle_add(self._update_image_button, button, pixbuf) + return + + result = subprocess.run( + ["cliphist", "decode", item_id], + capture_output=True, + check=True + ) + loader = GdkPixbuf.PixbufLoader() + loader.write(result.stdout) + loader.close() + pixbuf = loader.get_pixbuf() + width, height = pixbuf.get_width(), pixbuf.get_height() + max_size = 72 + 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)) + pixbuf = pixbuf.scale_simple(new_width, new_height, GdkPixbuf.InterpType.BILINEAR) + self.image_cache[item_id] = pixbuf + + GLib.idle_add(self._update_image_button, button, pixbuf) + except Exception as e: + print(f"Error loading image preview: {e}", file=sys.stderr) + + def _update_image_button(self, button, pixbuf): + """Update the button with the loaded image preview""" + box = button.get_child() + if box and len(box.get_children()) > 0: + image_widget = box.get_children()[0] + if isinstance(image_widget, Image): + image_widget.set_from_pixbuf(pixbuf) + + def create_text_item_button(self, item_id, display_text): + """Create a button for a text clipboard item""" + return Button( + name="slot-button", + child=Box( + name="slot-box", + orientation="h", + spacing=10, + children=[ + Label( + name="clip-icon", + markup=icons.clip_text, + h_align="start", + ), + Label( + name="clip-label", + label=display_text, + ellipsization="end", + v_align="center", + h_align="start", + h_expand=True, + ), + ], + ), + tooltip_text=display_text, + on_clicked=lambda *_: self.paste_item(item_id), + ) + + def is_image_data(self, content): + """Determine if clipboard content is likely an image""" + + return ( + content.startswith("data:image/") or + content.startswith("\x89PNG") or + content.startswith("GIF8") or + content.startswith("\xff\xd8\xff") or + re.match(r'^\s* visible_bottom: + + new_value = y + height - page_size + adj.set_value(new_value) + return False + GLib.idle_add(scroll) + + def use_selected_item(self): + """Use (paste) the selected clipboard item""" + children = self.viewport.get_children() + if not children or self.selected_index == -1 or self.selected_index >= len(self.clipboard_items): + return + + + item_line = self.clipboard_items[self.selected_index] + item_id = item_line.split('\t', 1)[0] + self.paste_item(item_id) + + def delete_selected_item(self): + """Delete the selected clipboard item""" + children = self.viewport.get_children() + if not children or self.selected_index == -1: + return + + + item_line = self.clipboard_items[self.selected_index] + item_id = item_line.split('\t', 1)[0] + self.delete_item(item_id) + + def on_item_key_press(self, widget, event, item_id): + """Handle key press events on clipboard items""" + if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + + self.paste_item(item_id) + return True + return False + + def __del__(self): + """Clean up temporary files on destruction""" + try: + if hasattr(self, 'tmp_dir') and os.path.exists(self.tmp_dir): + import shutil + shutil.rmtree(self.tmp_dir) + self.image_cache.clear() + except Exception as e: + print(f"Error cleaning up temporary files: {e}", file=sys.stderr) diff --git a/Ax-Shell/modules/controls.py b/Ax-Shell/modules/controls.py new file mode 100644 index 0000000..daf4c52 --- /dev/null +++ b/Ax-Shell/modules/controls.py @@ -0,0 +1,872 @@ +from fabric.audio.service import Audio +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.eventbox import EventBox +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.scale import Scale +from gi.repository import Gdk, GLib + +import config.data as data +import modules.icons as icons +from services.brightness import Brightness + + +class VolumeSlider(Scale): + def __init__(self, **kwargs): + super().__init__( + name="control-slider", + orientation="h", + h_expand=True, + h_align="fill", + has_origin=True, + increments=(0.01, 0.1), + **kwargs, + ) + self.audio = Audio() + self.audio.connect("notify::speaker", self.on_new_speaker) + if self.audio.speaker: + self.audio.speaker.connect("changed", self.on_speaker_changed) + self.connect("change-value", self.on_change_value) + self.add_style_class("vol") + self._pending_value = None + self._update_source_id = None + self._debounce_timeout = 100 + self._updating_from_audio = False + self.on_speaker_changed() + + def on_new_speaker(self, *args): + if self.audio.speaker: + self.audio.speaker.connect("changed", self.on_speaker_changed) + self.on_speaker_changed() + + def on_change_value(self, widget, scroll, value): + if self._updating_from_audio: + return False + if self.audio.speaker: + self._pending_value = value * 100 + if self._update_source_id is not None: + GLib.source_remove(self._update_source_id) + self._update_source_id = GLib.timeout_add( + self._debounce_timeout, self._update_volume_callback + ) + return False + + def _update_volume_callback(self): + if self._pending_value is not None and self.audio.speaker: + self.audio.speaker.volume = self._pending_value + self._pending_value = None + self._update_source_id = None + return False + + def on_speaker_changed(self, *_): + if not self.audio.speaker: + return + self._updating_from_audio = True + self.value = self.audio.speaker.volume / 100 + self._updating_from_audio = False + + if self.audio.speaker.muted: + self.add_style_class("muted") + else: + self.remove_style_class("muted") + + +class MicSlider(Scale): + def __init__(self, **kwargs): + super().__init__( + name="control-slider", + orientation="h", + h_expand=True, + has_origin=True, + increments=(0.01, 0.1), + **kwargs, + ) + self.audio = Audio() + self.audio.connect("notify::microphone", self.on_new_microphone) + if self.audio.microphone: + self.audio.microphone.connect("changed", self.on_microphone_changed) + self.connect("change-value", self.on_change_value) + self.add_style_class("mic") + self._updating_from_audio = False + self.on_microphone_changed() + + def on_new_microphone(self, *args): + if self.audio.microphone: + self.audio.microphone.connect("changed", self.on_microphone_changed) + self.on_microphone_changed() + + def on_change_value(self, widget, scroll, value): + if self._updating_from_audio: + return False + if self.audio.microphone: + self.audio.microphone.volume = value * 100 + return False + + def on_microphone_changed(self, *_): + if not self.audio.microphone: + return + self._updating_from_audio = True + self.value = self.audio.microphone.volume / 100 + self._updating_from_audio = False + + if self.audio.microphone.muted: + self.add_style_class("muted") + else: + self.remove_style_class("muted") + + +class BrightnessSlider(Scale): + def __init__(self, **kwargs): + super().__init__( + name="control-slider", + orientation="h", + h_expand=True, + has_origin=True, + increments=(5, 10), + **kwargs, + ) + self.client = Brightness.get_initial() + if self.client.screen_brightness == -1: + self.destroy() + return + + self.set_range(0, self.client.max_screen) + self.set_value(self.client.screen_brightness) + self.add_style_class("brightness") + + self._pending_value = None + self._update_source_id = None + self._updating_from_brightness = False + self._debounce_timeout = 100 + + self.connect("change-value", self.on_scale_move) + self.client.connect("screen", self.on_brightness_changed) + + def on_scale_move(self, widget, scroll, moved_pos): + if self._updating_from_brightness: + return False + self._pending_value = moved_pos + if self._update_source_id is not None: + GLib.source_remove(self._update_source_id) + self._update_source_id = GLib.timeout_add( + self._debounce_timeout, self._update_brightness_callback + ) + return False + + def _update_brightness_callback(self): + if self._pending_value is not None: + value_to_set = self._pending_value + self._pending_value = None + if value_to_set != self.client.screen_brightness: + self.client.screen_brightness = value_to_set + self._update_source_id = None + return False + else: + self._update_source_id = None + return False + + def on_brightness_changed(self, client, _): + self._updating_from_brightness = True + self.set_value(self.client.screen_brightness) + self._updating_from_brightness = False + percentage = int((self.client.screen_brightness / self.client.max_screen) * 100) + self.set_tooltip_text(f"{percentage}%") + + def destroy(self): + if self._update_source_id is not None: + GLib.source_remove(self._update_source_id) + super().destroy() + + +class BrightnessSmall(Box): + def __init__(self, **kwargs): + super().__init__(name="button-bar-brightness", **kwargs) + self.brightness = Brightness.get_initial() + if self.brightness.screen_brightness == -1: + self.destroy() + return + + self.progress_bar = CircularProgressBar( + name="button-brightness", + size=28, + line_width=2, + start_angle=150, + end_angle=390, + ) + self.brightness_label = Label( + name="brightness-label", markup=icons.brightness_high + ) + self.brightness_button = Button(child=self.brightness_label) + self.event_box = EventBox( + events=["scroll", "smooth-scroll"], + child=Overlay(child=self.progress_bar, overlays=self.brightness_button), + ) + self.event_box.connect("scroll-event", self.on_scroll) + self.add(self.event_box) + self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK) + + self._updating_from_brightness = False + self._pending_value = None + self._update_source_id = None + self._debounce_timeout = 100 + + # Don't connect to progress_bar value changes - only use scroll events + self.brightness.connect("screen", self.on_brightness_changed) + self.on_brightness_changed() + + def on_scroll(self, widget, event): + if self.brightness.max_screen == -1: + return + + step_size = 5 + current_brightness = self.brightness.screen_brightness + + if event.delta_y < 0: + new_brightness = min( + current_brightness + step_size, self.brightness.max_screen + ) + elif event.delta_y > 0: + new_brightness = max(current_brightness - step_size, 0) + else: + return + + # Directly update brightness, don't touch progress_bar + self._pending_value = new_brightness + if self._update_source_id is not None: + GLib.source_remove(self._update_source_id) + self._update_source_id = GLib.timeout_add( + self._debounce_timeout, self._update_brightness_callback + ) + + def _update_brightness_callback(self): + if ( + self._pending_value is not None + and self._pending_value != self.brightness.screen_brightness + ): + self.brightness.screen_brightness = self._pending_value + self._pending_value = None + self._update_source_id = None + return False + + def on_brightness_changed(self, *args): + if self.brightness.max_screen == -1: + return + normalized = ( + self.brightness.screen_brightness / self.brightness.max_screen + if self.brightness.max_screen > 0 + else 0 + ) + self._updating_from_brightness = True + self.progress_bar.value = normalized + self._updating_from_brightness = False + + brightness_percentage = int(normalized * 100) + if brightness_percentage >= 75: + self.brightness_label.set_markup(icons.brightness_high) + elif brightness_percentage >= 24: + self.brightness_label.set_markup(icons.brightness_medium) + else: + self.brightness_label.set_markup(icons.brightness_low) + self.set_tooltip_text(f"{brightness_percentage}%") + + def destroy(self): + if self._update_source_id is not None: + GLib.source_remove(self._update_source_id) + super().destroy() + + +class VolumeSmall(Box): + def __init__(self, **kwargs): + super().__init__(name="button-bar-vol", **kwargs) + self.audio = Audio() + self.progress_bar = CircularProgressBar( + name="button-volume", + size=28, + line_width=2, + start_angle=150, + end_angle=390, + ) + self.vol_label = Label(name="vol-label", markup=icons.vol_high) + self.vol_button = Button(on_clicked=self.toggle_mute, child=self.vol_label) + self.event_box = EventBox( + events=["scroll", "smooth-scroll"], + child=Overlay(child=self.progress_bar, overlays=self.vol_button), + ) + self.audio.connect("notify::speaker", self.on_new_speaker) + if self.audio.speaker: + self.audio.speaker.connect("changed", self.on_speaker_changed) + self.event_box.connect("scroll-event", self.on_scroll) + self.add(self.event_box) + self.on_speaker_changed() + self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK) + + def on_new_speaker(self, *args): + if self.audio.speaker: + self.audio.speaker.connect("changed", self.on_speaker_changed) + self.on_speaker_changed() + + def toggle_mute(self, event): + current_stream = self.audio.speaker + if current_stream: + current_stream.muted = not current_stream.muted + if current_stream.muted: + self.on_speaker_changed() + self.progress_bar.add_style_class("muted") + self.vol_label.add_style_class("muted") + else: + self.on_speaker_changed() + self.progress_bar.remove_style_class("muted") + self.vol_label.remove_style_class("muted") + + def on_scroll(self, _, event): + if not self.audio.speaker: + return + if event.direction == Gdk.ScrollDirection.SMOOTH: + if abs(event.delta_y) > 0: + self.audio.speaker.volume -= event.delta_y + if abs(event.delta_x) > 0: + self.audio.speaker.volume += event.delta_x + + def on_speaker_changed(self, *_): + if not self.audio.speaker: + return + + vol_high_icon = icons.vol_high + vol_medium_icon = icons.vol_medium + vol_mute_icon = icons.vol_off + vol_off_icon = icons.vol_mute + + if "bluetooth" in self.audio.speaker.icon_name: + vol_high_icon = icons.bluetooth_connected + vol_medium_icon = icons.bluetooth + vol_mute_icon = icons.bluetooth_off + vol_off_icon = icons.bluetooth_disconnected + + self.progress_bar.value = self.audio.speaker.volume / 100 + + if self.audio.speaker.muted: + self.vol_button.get_child().set_markup(vol_mute_icon) + self.progress_bar.add_style_class("muted") + self.vol_label.add_style_class("muted") + self.set_tooltip_text("Muted") + return + else: + self.progress_bar.remove_style_class("muted") + self.vol_label.remove_style_class("muted") + self.set_tooltip_text(f"{round(self.audio.speaker.volume)}%") + if self.audio.speaker.volume > 74: + self.vol_button.get_child().set_markup(vol_high_icon) + elif self.audio.speaker.volume > 0: + self.vol_button.get_child().set_markup(vol_medium_icon) + else: + self.vol_button.get_child().set_markup(vol_off_icon) + + +class MicSmall(Box): + def __init__(self, **kwargs): + super().__init__(name="button-bar-mic", **kwargs) + self.audio = Audio() + self.progress_bar = CircularProgressBar( + name="button-mic", + size=28, + line_width=2, + start_angle=150, + end_angle=390, + ) + self.mic_label = Label(name="mic-label", markup=icons.mic) + self.mic_button = Button(on_clicked=self.toggle_mute, child=self.mic_label) + self.event_box = EventBox( + events=["scroll", "smooth-scroll"], + child=Overlay(child=self.progress_bar, overlays=self.mic_button), + ) + self.audio.connect("notify::microphone", self.on_new_microphone) + if self.audio.microphone: + self.audio.microphone.connect("changed", self.on_microphone_changed) + self.event_box.connect("scroll-event", self.on_scroll) + self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK) + self.add(self.event_box) + self.on_microphone_changed() + + def on_new_microphone(self, *args): + if self.audio.microphone: + self.audio.microphone.connect("changed", self.on_microphone_changed) + self.on_microphone_changed() + + def toggle_mute(self, event): + current_stream = self.audio.microphone + if current_stream: + current_stream.muted = not current_stream.muted + if current_stream.muted: + self.mic_button.get_child().set_markup(icons.mic_mute) + self.progress_bar.add_style_class("muted") + self.mic_label.add_style_class("muted") + else: + self.on_microphone_changed() + self.progress_bar.remove_style_class("muted") + self.mic_label.remove_style_class("muted") + + def on_scroll(self, _, event): + if not self.audio.microphone: + return + if event.direction == Gdk.ScrollDirection.SMOOTH: + if abs(event.delta_y) > 0: + self.audio.microphone.volume -= event.delta_y + if abs(event.delta_x) > 0: + self.audio.microphone.volume += event.delta_x + + def on_microphone_changed(self, *_): + if not self.audio.microphone: + return + if self.audio.microphone.muted: + self.mic_button.get_child().set_markup(icons.mic_mute) + self.progress_bar.add_style_class("muted") + self.mic_label.add_style_class("muted") + self.set_tooltip_text("Muted") + return + else: + self.progress_bar.remove_style_class("muted") + self.mic_label.remove_style_class("muted") + self.progress_bar.value = self.audio.microphone.volume / 100 + self.set_tooltip_text(f"{round(self.audio.microphone.volume)}%") + if self.audio.microphone.volume >= 1: + self.mic_button.get_child().set_markup(icons.mic) + else: + self.mic_button.get_child().set_markup(icons.mic_mute) + + +class BrightnessIcon(Box): + def __init__(self, **kwargs): + super().__init__(name="brightness-icon", **kwargs) + self.brightness = Brightness.get_initial() + if self.brightness.screen_brightness == -1: + self.destroy() + return + + self.brightness_label = Label( + name="brightness-label-dash", + markup=icons.brightness_high, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.brightness_button = Button( + child=self.brightness_label, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + + self.event_box = EventBox( + events=["scroll", "smooth-scroll"], + child=self.brightness_button, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.event_box.connect("scroll-event", self.on_scroll) + self.add(self.event_box) + + self._pending_value = None + self._update_source_id = None + self._updating_from_brightness = False + + self.brightness.connect("screen", self.on_brightness_changed) + self.on_brightness_changed() + self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK) + + def on_scroll(self, _, event): + if self.brightness.max_screen == -1: + return + + step_size = 5 + current_brightness = self.brightness.screen_brightness + + if event.direction == Gdk.ScrollDirection.SMOOTH: + if event.delta_y < 0: + new_brightness = min( + current_brightness + step_size, self.brightness.max_screen + ) + elif event.delta_y > 0: + new_brightness = max(current_brightness - step_size, 0) + else: + return + else: + if event.direction == Gdk.ScrollDirection.UP: + new_brightness = min( + current_brightness + step_size, self.brightness.max_screen + ) + elif event.direction == Gdk.ScrollDirection.DOWN: + new_brightness = max(current_brightness - step_size, 0) + else: + return + + self._pending_value = new_brightness + if self._update_source_id is None: + self._update_source_id = GLib.timeout_add( + 100, self._update_brightness_callback + ) + + def _update_brightness_callback(self): + if ( + self._pending_value is not None + and self._pending_value != self.brightness.screen_brightness + ): + self.brightness.screen_brightness = self._pending_value + self._pending_value = None + return True + else: + self._update_source_id = None + return False + + def on_brightness_changed(self, *args): + if self.brightness.max_screen == -1: + return + + self._updating_from_brightness = True + normalized = self.brightness.screen_brightness / self.brightness.max_screen + brightness_percentage = int(normalized * 100) + + if brightness_percentage >= 75: + self.brightness_label.set_markup("๓ฐƒ ") + elif brightness_percentage >= 24: + self.brightness_label.set_markup("๓ฐƒ ") + else: + self.brightness_label.set_markup("๓ฐƒ ") + self.set_tooltip_text(f"{brightness_percentage}%") + self._updating_from_brightness = False + + def destroy(self): + if self._update_source_id is not None: + GLib.source_remove(self._update_source_id) + super().destroy() + + +class VolumeIcon(Box): + def __init__(self, **kwargs): + super().__init__(name="vol-icon", **kwargs) + self.audio = Audio() + + self.vol_label = Label( + name="vol-label-dash", + markup="", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.vol_button = Button( + on_clicked=self.toggle_mute, + child=self.vol_label, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + + self.event_box = EventBox( + events=["scroll", "smooth-scroll"], + child=self.vol_button, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.event_box.connect("scroll-event", self.on_scroll) + self.add(self.event_box) + + self._pending_value = None + self._update_source_id = None + self._periodic_update_source_id = None + + self.audio.connect("notify::speaker", self.on_new_speaker) + if self.audio.speaker: + self.audio.speaker.connect("changed", self.on_speaker_changed) + + self._periodic_update_source_id = GLib.timeout_add_seconds( + 2, self.update_device_icon + ) + self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK) + + def on_scroll(self, _, event): + if not self.audio.speaker: + return + + step_size = 5 + current_volume = self.audio.speaker.volume + + if event.direction == Gdk.ScrollDirection.SMOOTH: + if event.delta_y < 0: + new_volume = min(current_volume + step_size, 100) + elif event.delta_y > 0: + new_volume = max(current_volume - step_size, 0) + else: + return + else: + if event.direction == Gdk.ScrollDirection.UP: + new_volume = min(current_volume + step_size, 100) + elif event.direction == Gdk.ScrollDirection.DOWN: + new_volume = max(current_volume - step_size, 0) + else: + return + + self._pending_value = new_volume + if self._update_source_id is None: + self._update_source_id = GLib.timeout_add(100, self._update_volume_callback) + + def _update_volume_callback(self): + if ( + self._pending_value is not None + and self._pending_value != self.audio.speaker.volume + ): + self.audio.speaker.volume = self._pending_value + self._pending_value = None + return True + else: + self._update_source_id = None + return False + + def on_new_speaker(self, *args): + if self.audio.speaker: + self.audio.speaker.connect("changed", self.on_speaker_changed) + self.on_speaker_changed() + + def toggle_mute(self, event): + current_stream = self.audio.speaker + if current_stream: + current_stream.muted = not current_stream.muted + + self.on_speaker_changed() + + def on_speaker_changed(self, *_): + if not self.audio.speaker: + self.vol_label.set_markup("") + self.remove_style_class("muted") + self.vol_label.remove_style_class("muted") + self.vol_button.remove_style_class("muted") + self.set_tooltip_text("No audio device") + return + + if self.audio.speaker.muted: + self.vol_label.set_markup(icons.headphones) + self.add_style_class("muted") + self.vol_label.add_style_class("muted") + self.vol_button.add_style_class("muted") + self.set_tooltip_text("Muted") + else: + self.remove_style_class("muted") + self.vol_label.remove_style_class("muted") + self.vol_button.remove_style_class("muted") + + self.update_device_icon() + self.set_tooltip_text(f"{round(self.audio.speaker.volume)}%") + + def update_device_icon(self): + if not self.audio.speaker: + self.vol_label.set_markup("") + + return True + + if self.audio.speaker.muted: + return True + + try: + device_type = self.audio.speaker.port.type + if device_type == "headphones": + self.vol_label.set_markup(icons.headphones) + elif device_type == "speaker": + self.vol_label.set_markup(icons.headphones) + else: + self.vol_label.set_markup(icons.headphones) + + except AttributeError: + self.vol_label.set_markup(icons.headphones) + + return True + + def destroy(self): + if self._update_source_id is not None: + GLib.source_remove(self._update_source_id) + + if ( + hasattr(self, "_periodic_update_source_id") + and self._periodic_update_source_id is not None + ): + GLib.source_remove(self._periodic_update_source_id) + super().destroy() + + +class MicIcon(Box): + def __init__(self, **kwargs): + super().__init__(name="mic-icon", **kwargs) + self.audio = Audio() + + self.mic_label = Label( + name="mic-label-dash", + markup=icons.mic, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.mic_button = Button( + on_clicked=self.toggle_mute, + child=self.mic_label, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + + self.event_box = EventBox( + events=["scroll", "smooth-scroll"], + child=self.mic_button, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.event_box.connect("scroll-event", self.on_scroll) + self.add(self.event_box) + + self._pending_value = None + self._update_source_id = None + + self.audio.connect("notify::microphone", self.on_new_microphone) + if self.audio.microphone: + self.audio.microphone.connect("changed", self.on_microphone_changed) + self.on_microphone_changed() + self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK) + + def on_scroll(self, _, event): + if not self.audio.microphone: + return + + step_size = 5 + current_volume = self.audio.microphone.volume + + if event.direction == Gdk.ScrollDirection.SMOOTH: + if event.delta_y < 0: + new_volume = min(current_volume + step_size, 100) + elif event.delta_y > 0: + new_volume = max(current_volume - step_size, 0) + else: + return + else: + if event.direction == Gdk.ScrollDirection.UP: + new_volume = min(current_volume + step_size, 100) + elif event.direction == Gdk.ScrollDirection.DOWN: + new_volume = max(current_volume - step_size, 0) + else: + return + + self._pending_value = new_volume + if self._update_source_id is None: + self._update_source_id = GLib.timeout_add(100, self._update_volume_callback) + + def _update_volume_callback(self): + if ( + self._pending_value is not None + and self._pending_value != self.audio.microphone.volume + ): + self.audio.microphone.volume = self._pending_value + self._pending_value = None + return True + else: + self._update_source_id = None + return False + + def on_new_microphone(self, *args): + if self.audio.microphone: + self.audio.microphone.connect("changed", self.on_microphone_changed) + self.on_microphone_changed() + + def toggle_mute(self, event): + current_stream = self.audio.microphone + if current_stream: + current_stream.muted = not current_stream.muted + if current_stream.muted: + self.mic_button.get_child().set_markup("๏„ฐ") + self.mic_label.add_style_class("muted") + self.mic_button.add_style_class("muted") + else: + self.on_microphone_changed() + self.mic_label.remove_style_class("muted") + self.mic_button.remove_style_class("muted") + + def on_microphone_changed(self, *_): + if not self.audio.microphone: + return + if self.audio.microphone.muted: + self.mic_button.get_child().set_markup("๏„ฐ") + self.add_style_class("muted") + self.mic_label.add_style_class("muted") + self.set_tooltip_text("Muted") + return + else: + self.remove_style_class("muted") + self.mic_label.remove_style_class("muted") + + self.set_tooltip_text(f"{round(self.audio.microphone.volume)}%") + if self.audio.microphone.volume >= 1: + self.mic_button.get_child().set_markup("๏„ฐ") + else: + self.mic_button.get_child().set_markup("๏„ฐ") + + def destroy(self): + if self._update_source_id is not None: + GLib.source_remove(self._update_source_id) + super().destroy() + + +class ControlSliders(Box): + def __init__(self, **kwargs): + super().__init__( + name="control-sliders", + orientation="h", + spacing=8, + **kwargs, + ) + + brightness = Brightness.get_initial() + + if brightness.screen_brightness != -1: + brightness_row = Box( + orientation="h", spacing=0, h_expand=True, h_align="fill" + ) + brightness_row.add(BrightnessIcon()) + brightness_row.add(BrightnessSlider()) + self.add(brightness_row) + + volume_row = Box(orientation="h", spacing=0, h_expand=True, h_align="fill") + volume_row.add(VolumeIcon()) + volume_row.add(VolumeSlider()) + self.add(volume_row) + + mic_row = Box(orientation="h", spacing=0, h_expand=True, h_align="fill") + mic_row.add(MicIcon()) + mic_row.add(MicSlider()) + self.add(mic_row) + + self.show_all() + + +class ControlSmall(Box): + def __init__(self, **kwargs): + brightness = Brightness.get_initial() + children = [] + if brightness.screen_brightness != -1: + children.append(BrightnessSmall()) + children.extend([VolumeSmall(), MicSmall()]) + super().__init__( + name="control-small", + orientation="h" if not data.VERTICAL else "v", + spacing=4, + children=children, + **kwargs, + ) + self.show_all() diff --git a/Ax-Shell/modules/corners.py b/Ax-Shell/modules/corners.py new file mode 100644 index 0000000..c02887f --- /dev/null +++ b/Ax-Shell/modules/corners.py @@ -0,0 +1,71 @@ +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +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/Ax-Shell/modules/dashboard.py b/Ax-Shell/modules/dashboard.py new file mode 100644 index 0000000..683f3df --- /dev/null +++ b/Ax-Shell/modules/dashboard.py @@ -0,0 +1,158 @@ +import random + +import gi +from fabric.utils import get_relative_path +from fabric.widgets.box import Box +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.stack import Stack + +import config.data as data + +gi.require_version("Gtk", "3.0") +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk + +import modules.icons as icons +from modules.kanban import Kanban +from modules.mixer import Mixer +from modules.pins import Pins +from modules.wallpapers import WallpaperSelector +from modules.widgets import Widgets + + +class Dashboard(Box): + def __init__(self, **kwargs): + super().__init__( + name="dashboard", + orientation="v", + spacing=8, + h_align="center", + v_align="center", + h_expand=True, + visible=True, + all_visible=True, + ) + + self.notch = kwargs["notch"] + + self.widgets = Widgets(notch=self.notch) + self.pins = Pins() + self.kanban = Kanban() + self.wallpapers = WallpaperSelector() + self.mixer = Mixer() + + self.stack = Stack( + name="stack", + transition_type="slide-left-right", + transition_duration=500, + v_expand=True, + v_align="fill", + h_expand=True, + h_align="fill", + ) + + self.stack.set_homogeneous(False) + + self.switcher = Gtk.StackSwitcher( + name="switcher", + spacing=8, + ) + + self.stack.add_titled(self.widgets, "widgets", "Widgets") + self.stack.add_titled(self.pins, "pins", "Pins") + self.stack.add_titled(self.kanban, "kanban", "Kanban") + self.stack.add_titled(self.wallpapers, "wallpapers", "Wallpapers") + self.stack.add_titled(self.mixer, "mixer", "Mixer") + + self.switcher.set_stack(self.stack) + self.switcher.set_hexpand(True) + self.switcher.set_homogeneous(True) + self.switcher.set_can_focus(True) + + self.stack.connect("notify::visible-child", self.on_visible_child_changed) + + self.add(self.switcher) + self.add(self.stack) + + if data.PANEL_THEME == "Panel" and ( + data.BAR_POSITION in ["Left", "Right"] + or data.PANEL_POSITION in ["Start", "End"] + ): + GLib.idle_add(self._setup_switcher_icons) + + # Close on right click if the event isn't handled + self.connect( + "button-release-event", + lambda widget, event: (event.button == 3 and self.notch.close_notch()), + ) + self.show_all() + + def _setup_switcher_icons(self): + icon_details_map = { + "Widgets": {"icon": icons.widgets, "name": "widgets"}, + "Pins": {"icon": icons.pins, "name": "pins"}, + "Kanban": {"icon": icons.kanban, "name": "kanban"}, + "Wallpapers": {"icon": icons.wallpapers, "name": "wallpapers"}, + "Mixer": {"icon": icons.speaker, "name": "mixer"}, + } + + buttons = self.switcher.get_children() + for btn in buttons: + if isinstance(btn, Gtk.ToggleButton): + original_gtk_label = None + for child_widget in btn.get_children(): + if isinstance(child_widget, Gtk.Label): + original_gtk_label = child_widget + break + + if original_gtk_label: + label_text = original_gtk_label.get_text() + if label_text in icon_details_map: + details = icon_details_map[label_text] + icon_markup = details["icon"] + css_name_suffix = details["name"] + + btn.remove(original_gtk_label) + + new_icon_label = Label( + name=f"switcher-icon-{css_name_suffix}", markup=icon_markup + ) + btn.add(new_icon_label) + new_icon_label.show_all() + return GLib.SOURCE_REMOVE + + def go_to_next_child(self): + children = self.stack.get_children() + current_index = self.get_current_index(children) + next_index = (current_index + 1) % len(children) + self.stack.set_visible_child(children[next_index]) + + def go_to_previous_child(self): + children = self.stack.get_children() + current_index = self.get_current_index(children) + previous_index = (current_index - 1 + len(children)) % len(children) + self.stack.set_visible_child(children[previous_index]) + + def get_current_index(self, children): + current_child = self.stack.get_visible_child() + return children.index(current_child) if current_child in children else -1 + + def on_visible_child_changed(self, stack, param): + visible = stack.get_visible_child() + if visible == self.wallpapers: + self.wallpapers.search_entry.set_text("") + self.wallpapers.search_entry.grab_focus() + + def go_to_section(self, section_name): + """Navigate to a specific section in the dashboard.""" + if section_name == "widgets": + self.stack.set_visible_child(self.widgets) + elif section_name == "pins": + self.stack.set_visible_child(self.pins) + elif section_name == "kanban": + self.stack.set_visible_child(self.kanban) + elif section_name == "wallpapers": + self.stack.set_visible_child(self.wallpapers) + elif section_name == "mixer": + self.stack.set_visible_child(self.mixer) diff --git a/Ax-Shell/modules/dock.py b/Ax-Shell/modules/dock.py new file mode 100644 index 0000000..ebe683e --- /dev/null +++ b/Ax-Shell/modules/dock.py @@ -0,0 +1,857 @@ +import json +import logging + +import cairo +from fabric.hyprland.widgets import get_hyprland_connection +from fabric.utils import (exec_shell_command, exec_shell_command_async, + get_relative_path, idle_add, remove_handler) +from fabric.utils.helpers import get_desktop_applications +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.revealer import Revealer +from gi.repository import Gdk, GLib, Gtk + +import config.data as data +from modules.corners import MyCorner +from utils.icon_resolver import IconResolver +from widgets.wayland import WaylandWindow as Window + + +def read_config(): + """Read and return the full configuration from the JSON file, handling missing file.""" + config_path = get_relative_path("../config/dock.json") + try: + with open(config_path, "r") as file: + config_data = json.load(file) + + if "pinned_apps" in config_data and config_data["pinned_apps"] and isinstance(config_data["pinned_apps"][0], str): + all_apps = get_desktop_applications() + app_map = {app.name: app for app in all_apps if app.name} + + old_pinned = config_data["pinned_apps"] + config_data["pinned_apps"] = [] + + for app_id in old_pinned: + app = app_map.get(app_id) + if app: + app_data_obj = { + "name": app.name, + "display_name": app.display_name, + "window_class": app.window_class, + "executable": app.executable, + "command_line": app.command_line + } + config_data["pinned_apps"].append(app_data_obj) + else: + config_data["pinned_apps"].append({"name": app_id}) + + except (FileNotFoundError, json.JSONDecodeError): + config_data = {"pinned_apps": []} + return config_data + +def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: + alloc = widget.get_allocation() + surface = cairo.ImageSurface( + cairo.Format.ARGB32, + alloc.width, + alloc.height, + ) + cr = cairo.Context(surface) + cr.set_source_rgba(255, 255, 255, 0) + cr.rectangle(0, 0, alloc.width, alloc.height) + cr.fill() + widget.draw(cr) + return surface + +class Dock(Window): + _instances = [] + + def __init__(self, monitor_id: int = 0, integrated_mode: bool = False, **kwargs): + self.monitor_id = monitor_id + + self.integrated_mode = integrated_mode + self.icon_size = 20 if self.integrated_mode else data.DOCK_ICON_SIZE + self.effective_occlusion_size = 36 + self.icon_size + self.always_show = data.DOCK_ALWAYS_SHOW if not self.integrated_mode else False + + anchor_to_set: str + revealer_transition_type: str + + self.actual_dock_is_horizontal: bool + main_box_orientation_val: Gtk.Orientation + main_box_h_align_val: str + dock_wrapper_orientation_val: Gtk.Orientation + + if not self.integrated_mode: + + self.actual_dock_is_horizontal = not data.VERTICAL + + if self.actual_dock_is_horizontal: + anchor_to_set = "bottom" + revealer_transition_type = "slide-up" + main_box_orientation_val = Gtk.Orientation.VERTICAL + main_box_h_align_val = "center" + dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL + else: + + if data.BAR_POSITION == "Left": + anchor_to_set = "right" + revealer_transition_type = "slide-left" + elif data.BAR_POSITION == "Right": + anchor_to_set = "left" + revealer_transition_type = "slide-right" + else: + anchor_to_set = "right" + revealer_transition_type = "slide-left" + + main_box_orientation_val = Gtk.Orientation.HORIZONTAL + main_box_h_align_val = "end" if anchor_to_set == "right" else "start" + dock_wrapper_orientation_val = Gtk.Orientation.VERTICAL + + super().__init__( + name="dock-window", + layer="top", + anchor=anchor_to_set, + margin="0px 0px 0px 0px", + exclusivity="auto" if self.always_show else "none", + monitor=monitor_id, + **kwargs, + ) + Dock._instances.append(self) + else: + self.actual_dock_is_horizontal = True + dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL + + anchor_to_set = "bottom" + revealer_transition_type = "slide-up" + main_box_orientation_val = Gtk.Orientation.VERTICAL + main_box_h_align_val = "center" + + if not self.integrated_mode: + match data.BAR_POSITION: + case "Top": + self.set_margin("-8px 0px 0px 0px") + case "Bottom": + self.set_margin("0px 0px 0px 0px") + case "Left": + self.set_margin("0px 0px 0px -8px") + case "Right": + self.set_margin("0px -8px 0px 0px") + case _: + self.set_margin("0px 0px 0px 0px") + + self.config = read_config() + self.conn = get_hyprland_connection() + self.icon_resolver = IconResolver() + self.pinned = self.config.get("pinned_apps", []) + self.config_path = get_relative_path("../config/dock.json") + self.app_map = {} + self._all_apps = get_desktop_applications() + self.app_identifiers = self._build_app_identifiers_map() + + self.hide_id = None + self._arranger_handler = None + self._drag_in_progress = False + self.is_mouse_over_dock_area = False + self._prevent_occlusion = False + self._forced_occlusion = False + + self.view = Box(name="viewport", spacing=4) + self.wrapper = Box(name="dock", children=[self.view], style_classes=["left"] if data.BAR_POSITION == "Right" else []) + + self.wrapper.set_orientation(dock_wrapper_orientation_val) + self.view.set_orientation(dock_wrapper_orientation_val) + + if self.integrated_mode: + self.wrapper.add_style_class("integrated") + else: + + if dock_wrapper_orientation_val == Gtk.Orientation.VERTICAL: + self.wrapper.add_style_class("vertical") + else: + self.wrapper.remove_style_class("vertical") + + match data.DOCK_THEME: + case "Pills": + self.wrapper.add_style_class("pills") + case "Dense": + self.wrapper.add_style_class("dense") + case "Edge": + self.wrapper.add_style_class("edge") + case _: + self.wrapper.add_style_class("pills") + + if not self.integrated_mode: + self.dock_eventbox = EventBox() + self.dock_eventbox.add(self.wrapper) + self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter) + self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave) + + self.corner_left = Box() + self.corner_right = Box() + self.corner_top = Box() + self.corner_bottom = Box() + + if self.actual_dock_is_horizontal: + self.corner_left = Box( + name="dock-corner-left", orientation=Gtk.Orientation.VERTICAL, h_align="start", + children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-right")] + ) + self.corner_right = Box( + name="dock-corner-right", orientation=Gtk.Orientation.VERTICAL, h_align="end", + children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-left")] + ) + self.dock_full = Box( + name="dock-full", orientation=Gtk.Orientation.HORIZONTAL, h_expand=True, h_align="fill", + children=[self.corner_left, self.dock_eventbox, self.corner_right] + ) + else: + + + + + if anchor_to_set == "right": + self.corner_top = Box( + name="dock-corner-top", orientation=Gtk.Orientation.HORIZONTAL, v_align="start", + children=[Box(h_expand=True, h_align="fill"), MyCorner("bottom-right")] + ) + self.corner_bottom = Box( + name="dock-corner-bottom", orientation=Gtk.Orientation.HORIZONTAL, v_align="end", + children=[Box(h_expand=True, h_align="fill"), MyCorner("top-right")] + ) + else: + self.corner_top = Box( + name="dock-corner-top", orientation=Gtk.Orientation.HORIZONTAL, v_align="start", + children=[MyCorner("bottom-left"), Box(h_expand=True, h_align="fill")] + ) + self.corner_bottom = Box( + name="dock-corner-bottom", orientation=Gtk.Orientation.HORIZONTAL, v_align="end", + children=[MyCorner("top-left"), Box(h_expand=True, h_align="fill")] + ) + + self.dock_full = Box( + name="dock-full", orientation=Gtk.Orientation.VERTICAL, v_expand=True, v_align="fill", + children=[self.corner_top, self.dock_eventbox, self.corner_bottom] + ) + + self.dock_revealer = Revealer( + name="dock-revealer", + transition_type=revealer_transition_type, + transition_duration=250, + child_revealed=False, + child=self.dock_full + ) + + self.hover_activator = EventBox() + + self.hover_activator.set_size_request(-1 if self.actual_dock_is_horizontal else 1, 1 if self.actual_dock_is_horizontal else -1) + self.hover_activator.connect("enter-notify-event", self._on_hover_enter) + self.hover_activator.connect("leave-notify-event", self._on_hover_leave) + + self.main_box = Box( + orientation=main_box_orientation_val, + children=[self.hover_activator, self.dock_revealer] if data.BAR_POSITION != "Right" else [self.dock_revealer, self.hover_activator], + h_align=main_box_h_align_val, + ) + self.add(self.main_box) + + if data.DOCK_THEME in ["Edge", "Dense"]: + for corner in [self.corner_left, self.corner_right, self.corner_top, self.corner_bottom]: + corner.set_visible(False) + + + # Hide normal dock when it should be embedded in the bar OR when dock is disabled + should_be_embedded = (data.BAR_POSITION == "Bottom") or (data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Top", "Bottom"]) + if should_be_embedded or not data.DOCK_ENABLED: + self.set_visible(False) + + if self.always_show: + self.dock_full.add_style_class("occluded") + + self.view.drag_source_set( + Gdk.ModifierType.BUTTON1_MASK, + [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE + ) + self.view.drag_dest_set( + Gtk.DestDefaults.ALL, + [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE + ) + self.view.connect("drag-data-get", self.on_drag_data_get) + self.view.connect("drag-data-received", self.on_drag_data_received) + self.view.connect("drag-begin", self.on_drag_begin) + self.view.connect("drag-end", self.on_drag_end) + + if self.conn.ready: + self.update_dock() + if not self.integrated_mode: GLib.timeout_add(500, self.check_occlusion_state) + else: + self.conn.connect("event::ready", self.update_dock) + if not self.integrated_mode: self.conn.connect("event::ready", lambda *args: GLib.timeout_add(250, self.check_occlusion_state)) + + # Listen to window events to update dock when apps open/close + self.conn.connect("event::openwindow", self.update_dock) + self.conn.connect("event::closewindow", self.update_dock) + + if not self.integrated_mode: + self.conn.connect("event::workspace", self.check_hide) + + GLib.timeout_add_seconds(2, self.check_config_change) + + def _build_app_identifiers_map(self): + identifiers = {} + for app in self._all_apps: + if app.name: identifiers[app.name.lower()] = app + if app.display_name: identifiers[app.display_name.lower()] = app + if app.window_class: identifiers[app.window_class.lower()] = app + if app.executable: identifiers[app.executable.split('/')[-1].lower()] = app + if app.command_line: identifiers[app.command_line.split()[0].split('/')[-1].lower()] = app + return identifiers + + def _normalize_window_class(self, class_name): + if not class_name: return "" + normalized = class_name.lower() + suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"] + for suffix in suffixes: + if normalized.endswith(suffix): + normalized = normalized[:-len(suffix)] + return normalized + + def _classes_match(self, class1, class2): + if not class1 or not class2: return False + norm1 = self._normalize_window_class(class1) + norm2 = self._normalize_window_class(class2) + return norm1 == norm2 + + def on_drag_begin(self, widget, drag_context): + self._drag_in_progress = True + Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget)) + + def _on_hover_enter(self, *args): + if self.integrated_mode: return + self.is_mouse_over_dock_area = True + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = None + self.dock_revealer.set_reveal_child(True) + if not self.always_show: + self.dock_full.remove_style_class("occluded") + + def _on_hover_leave(self, *args): + if self.integrated_mode: return + self.is_mouse_over_dock_area = False + if self._forced_occlusion: + self.dock_revealer.set_reveal_child(False) + else: + self.delay_hide() + + def _on_dock_enter(self, widget, event): + if self.integrated_mode: return True + self.is_mouse_over_dock_area = True + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = None + self.dock_revealer.set_reveal_child(True) + if not self.always_show: + self.dock_full.remove_style_class("occluded") + return True + + def _on_dock_leave(self, widget, event): + if self.integrated_mode: return True + if event.detail == Gdk.NotifyType.INFERIOR: + return False + + self.is_mouse_over_dock_area = False + + if self._forced_occlusion: + self.dock_revealer.set_reveal_child(False) + else: + self.delay_hide() + + if not self.always_show: + self.dock_full.add_style_class("occluded") + return True + + def find_app(self, app_identifier): + if not app_identifier: return None + if isinstance(app_identifier, dict): + for key in ["window_class", "executable", "command_line", "name", "display_name"]: + if key in app_identifier and app_identifier[key]: + app = self.find_app_by_key(app_identifier[key]) + if app: return app + return None + return self.find_app_by_key(app_identifier) + + def find_app_by_key(self, key_value): + if not key_value: return None + normalized_id = str(key_value).lower() + if normalized_id in self.app_identifiers: + return self.app_identifiers[normalized_id] + for app in self._all_apps: + if app.name and normalized_id in app.name.lower(): return app + if app.display_name and normalized_id in app.display_name.lower(): return app + if app.window_class and normalized_id in app.window_class.lower(): return app + if app.executable and normalized_id in app.executable.lower(): return app + if app.command_line and normalized_id in app.command_line.lower(): return app + return None + + def update_app_map(self): + self._all_apps = get_desktop_applications() + self.app_map = {app.name: app for app in self._all_apps if app.name} + self.app_identifiers = self._build_app_identifiers_map() + + def create_button(self, app_identifier, instances): + desktop_app = self.find_app(app_identifier) + icon_img = None + display_name = None + + if desktop_app: + icon_img = desktop_app.get_icon_pixbuf(size=self.icon_size) + display_name = desktop_app.display_name or desktop_app.name + + id_value = app_identifier["name"] if isinstance(app_identifier, dict) else app_identifier + + if not icon_img: + icon_img = self.icon_resolver.get_icon_pixbuf(id_value, self.icon_size) + + if not icon_img: + icon_img = self.icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", self.icon_size) + if not icon_img: + icon_img = self.icon_resolver.get_icon_pixbuf("image-missing", self.icon_size) + + items = [Image(pixbuf=icon_img)] + tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown") + if not display_name and instances and instances[0].get("title"): + tooltip = instances[0]["title"] + + button = Button( + child= Box(name="dock-icon", orientation="v", h_align="center", children=items), + on_clicked=lambda *a: self.handle_app(app_identifier, instances, desktop_app), + tooltip_text=tooltip, name="dock-app-button", + ) + button.app_identifier = app_identifier + button.desktop_app = desktop_app + button.instances = instances + if instances: button.add_style_class("instance") + + button.drag_source_set( + Gdk.ModifierType.BUTTON1_MASK, + [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE + ) + button.connect("drag-begin", self.on_drag_begin) + button.connect("drag-end", self.on_drag_end) + button.drag_dest_set( + Gtk.DestDefaults.ALL, + [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE + ) + button.connect("drag-data-get", self.on_drag_data_get) + button.connect("drag-data-received", self.on_drag_data_received) + button.connect("enter-notify-event", self._on_child_enter) + return button + + def handle_app(self, app_identifier, instances, desktop_app=None): + if not instances: + if not desktop_app: desktop_app = self.find_app(app_identifier) + if desktop_app: + launch_success = desktop_app.launch() + if not launch_success: + if desktop_app.command_line: exec_shell_command_async(f"nohup {desktop_app.command_line} &") + elif desktop_app.executable: exec_shell_command_async(f"nohup {desktop_app.executable} &") + else: + cmd_to_run = None + if isinstance(app_identifier, dict): + if "command_line" in app_identifier and app_identifier["command_line"]: cmd_to_run = app_identifier['command_line'] + elif "executable" in app_identifier and app_identifier["executable"]: cmd_to_run = app_identifier['executable'] + elif "name" in app_identifier and app_identifier["name"]: cmd_to_run = app_identifier['name'] + elif isinstance(app_identifier, str): cmd_to_run = app_identifier + if cmd_to_run: exec_shell_command_async(f"nohup {cmd_to_run} &") + else: + focused = self.get_focused() + idx = next((i for i, inst in enumerate(instances) if inst["address"] == focused), -1) + next_inst = instances[(idx + 1) % len(instances)] + exec_shell_command(f"hyprctl dispatch focuswindow address:{next_inst['address']}") + + def _on_child_enter(self, widget, event): + if self.integrated_mode: return False + self.is_mouse_over_dock_area = True + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = None + return False + + def delay_hide(self): + if self.integrated_mode: return + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = GLib.timeout_add(250, self.hide_dock_if_not_hovered) + + def hide_dock_if_not_hovered(self): + if self.integrated_mode: + return False + self.hide_id = None + if not self.is_mouse_over_dock_area and not self._drag_in_progress and not self._prevent_occlusion: + if not self.always_show: + self.dock_revealer.set_reveal_child(False) + return False + + def check_hide(self, *args): + if self.integrated_mode: + return + if self.is_mouse_over_dock_area or self._drag_in_progress or self._prevent_occlusion: + return + + clients = self.get_clients() + current_ws = self.get_workspace() + ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws] + + if self.always_show: + if not self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(True) + self.dock_full.remove_style_class("occluded") + else: + if self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(False) + self.dock_full.add_style_class("occluded") + + def update_dock(self, *args): + self.update_app_map() + arranger_handler = getattr(self, "_arranger_handler", None) + if arranger_handler: remove_handler(arranger_handler) + clients = self.get_clients() + + running_windows = {} + for c in clients: + window_id = None + if class_name := c.get("initialClass", "").lower(): window_id = class_name + elif class_name := c.get("class", "").lower(): window_id = class_name + elif title := c.get("title", "").lower(): + possible_name = title.split(" - ")[0].strip() + if possible_name and len(possible_name) > 1: window_id = possible_name + else: window_id = title + if not window_id: window_id = "unknown-app" + running_windows.setdefault(window_id, []).append(c) + normalized_id = self._normalize_window_class(window_id) + if normalized_id != window_id: + running_windows.setdefault(normalized_id, []).extend(running_windows[window_id]) + + pinned_buttons = [] + used_window_classes = set() + + for app_data_item in self.pinned: + app = self.find_app(app_data_item) + instances = [] + matched_class = None + possible_identifiers = [] + + if isinstance(app_data_item, dict): + for key in ["window_class", "executable", "command_line", "name", "display_name"]: + if key in app_data_item and app_data_item[key]: possible_identifiers.append(app_data_item[key].lower()) + elif isinstance(app_data_item, str): possible_identifiers.append(app_data_item.lower()) + + if app: + if app.window_class: possible_identifiers.append(app.window_class.lower()) + if app.executable: possible_identifiers.append(app.executable.split('/')[-1].lower()) + if app.command_line: + cmd_parts = app.command_line.split() + if cmd_parts: possible_identifiers.append(cmd_parts[0].split('/')[-1].lower()) + if app.name: possible_identifiers.append(app.name.lower()) + if app.display_name: possible_identifiers.append(app.display_name.lower()) + + possible_identifiers = list(set(possible_identifiers)) + + for identifier in possible_identifiers: + if identifier in running_windows: + instances = running_windows[identifier]; matched_class = identifier; break + normalized = self._normalize_window_class(identifier) + if normalized in running_windows: + instances = running_windows[normalized]; matched_class = normalized; break + for window_class_key in running_windows: + if len(identifier) >= 3 and identifier in window_class_key: + instances = running_windows[window_class_key]; matched_class = window_class_key + break + if matched_class: break + + if matched_class: + used_window_classes.add(matched_class) + used_window_classes.add(self._normalize_window_class(matched_class)) + + pinned_buttons.append(self.create_button(app_data_item, instances)) + + open_buttons = [] + for class_name, instances in running_windows.items(): + if class_name not in used_window_classes: + app = None + app = self.app_identifiers.get(class_name) + if not app: + norm_class = self._normalize_window_class(class_name) + app = self.app_identifiers.get(norm_class) + if not app: app = self.find_app_by_key(class_name) + if not app and instances and instances[0].get("title"): + title = instances[0].get("title", "") + potential_name = title.split(" - ")[0].strip() + if len(potential_name) > 2: app = self.find_app_by_key(potential_name) + + if app: + app_data_obj = { + "name": app.name, "display_name": app.display_name, + "window_class": app.window_class, "executable": app.executable, + "command_line": app.command_line + } + identifier = app_data_obj + else: identifier = class_name + open_buttons.append(self.create_button(identifier, instances)) + + children = pinned_buttons + separator_orientation = Gtk.Orientation.VERTICAL if self.view.get_orientation() == Gtk.Orientation.HORIZONTAL else Gtk.Orientation.HORIZONTAL + if pinned_buttons and open_buttons: + children += [Box(orientation=separator_orientation, v_expand=False, h_expand=False, h_align="center", v_align="center", name="dock-separator")] + children += open_buttons + + self.view.children = children + if not self.integrated_mode: + idle_add(self._update_size) + self._drag_in_progress = False + if not self.integrated_mode: + self.check_occlusion_state() + + def _update_size(self): + if self.integrated_mode: return False + width, _ = self.view.get_preferred_width() + self.set_size_request(width, -1) + return False + + def get_clients(self): + try: return json.loads(self.conn.send_command("j/clients").reply.decode()) + except json.JSONDecodeError: return [] + + def get_focused(self): + try: return json.loads(self.conn.send_command("j/activewindow").reply.decode()).get("address", "") + except json.JSONDecodeError: return "" + + def get_workspace(self): + try: return json.loads(self.conn.send_command("j/activeworkspace").reply.decode()).get("id", 0) + except json.JSONDecodeError: return 0 + + def check_occlusion_state(self): + if self.integrated_mode: + return False + + # When forced occlusion is active, only show on hover + if self._forced_occlusion: + if self.is_mouse_over_dock_area: + if not self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(True) + self.dock_full.remove_style_class("occluded") + else: + if self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(False) + self.dock_full.add_style_class("occluded") + return True + + if self.is_mouse_over_dock_area or self._drag_in_progress or self._prevent_occlusion: + if not self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(True) + if not self.always_show: + self.dock_full.remove_style_class("occluded") + return True + + if self.always_show: + if not self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(True) + self.dock_full.remove_style_class("occluded") + else: + if self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(False) + self.dock_full.add_style_class("occluded") + + return True + + def _find_drag_target(self, widget): + children = self.view.get_children() + while widget is not None and widget not in children: + widget = widget.get_parent() if hasattr(widget, "get_parent") else None + return widget + + def on_drag_data_get(self, widget, drag_context, data_obj, info, time): + target = self._find_drag_target(widget.get_parent() if isinstance(widget, Box) else widget) + if target is not None: + index = self.view.get_children().index(target) + data_obj.set_text(str(index), -1) + + def on_drag_data_received(self, widget, drag_context, x, y, data_obj, info, time): + target = self._find_drag_target(widget.get_parent() if isinstance(widget, Box) else widget) + if target is None: return + try: source_index = int(data_obj.get_text()) + except (TypeError, ValueError): return + + children = self.view.get_children() + try: target_index = children.index(target) + except ValueError: return + + if source_index != target_index: + separator_index = -1 + for i, child_item_loop in enumerate(children): + if child_item_loop.get_name() == "dock-separator": + separator_index = i; break + cross_section_drag = (separator_index != -1 and + ((source_index < separator_index and target_index > separator_index) or + (source_index > separator_index and target_index < separator_index))) + + child_item_to_move = children.pop(source_index) + children.insert(target_index, child_item_to_move) + self.view.children = children + self.update_pinned_apps(skip_update=not cross_section_drag) + if cross_section_drag: GLib.idle_add(self.update_dock) + + def on_drag_end(self, widget, drag_context): + if not self._drag_in_progress: + return + + def process_drag_end(): + display = Gdk.Display.get_default() + _, x, y, _ = display.get_pointer() + + # Get the widget's allocation to check if drag ended outside + alloc = self.view.get_allocation() + widget_x = alloc.x + widget_y = alloc.y + widget_width = alloc.width + widget_height = alloc.height + + # Check if pointer is outside the dock area + if not (widget_x <= x <= widget_x + widget_width and widget_y <= y <= widget_y + widget_height): + app_id_dragged = widget.app_identifier + instances_dragged = widget.instances + + # Remove pinned app + app_index_dragged = -1 + for i, pinned_app_item in enumerate(self.pinned): + if isinstance(app_id_dragged, dict) and isinstance(pinned_app_item, dict): + if app_id_dragged.get("name") == pinned_app_item.get("name"): + app_index_dragged = i + break + elif app_id_dragged == pinned_app_item: + app_index_dragged = i + break + + if app_index_dragged >= 0: + self.pinned.pop(app_index_dragged) + self.config["pinned_apps"] = self.pinned + self.update_pinned_apps_file() + self.update_dock() + elif instances_dragged: + address = instances_dragged[0].get("address") + if address: + exec_shell_command(f"hyprctl dispatch focuswindow address:{address}") + + self._drag_in_progress = False + if not self.integrated_mode: + self.check_occlusion_state() + + GLib.idle_add(process_drag_end) + def check_config_change(self): + new_config = read_config() + if not self.integrated_mode: + new_always_show = data.DOCK_ALWAYS_SHOW + if self.always_show != new_always_show: + self.always_show = new_always_show + self.check_occlusion_state() + + if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): + self.config = new_config + self.pinned = self.config.get("pinned_apps", []) + self.update_app_map() + self.update_dock() + return True + + def update_pinned_apps_file(self): + config_path = get_relative_path("../config/dock.json") + try: + with open(config_path, "w") as file: + json.dump(self.config, file, indent=4) + return True + except Exception as e: + logging.error(f"Failed to write dock config: {e}") + return False + + def update_pinned_apps(self, skip_update=False): + pinned_children_data = [] + for child_widget in self.view.get_children(): + if child_widget.get_name() == "dock-separator": break + if hasattr(child_widget, "app_identifier"): + if hasattr(child_widget, "desktop_app") and child_widget.desktop_app: + app = child_widget.desktop_app + app_data_obj = { + "name": app.name, "display_name": app.display_name, + "window_class": app.window_class, "executable": app.executable, + "command_line": app.command_line + } + pinned_children_data.append(app_data_obj) + else: + pinned_children_data.append(child_widget.app_identifier) + + self.config["pinned_apps"] = pinned_children_data + self.pinned = pinned_children_data + file_updated = self.update_pinned_apps_file() + if file_updated and not skip_update: + self.update_dock() + + @staticmethod + def notify_config_change(): + for dock_instance in Dock._instances: + GLib.idle_add(dock_instance.check_config_change_immediate) + + def check_config_change_immediate(self): + new_config = read_config() + + if not self.integrated_mode: + previous_always_show = self.always_show + self.always_show = data.DOCK_ALWAYS_SHOW + + if previous_always_show != self.always_show: + self.check_occlusion_state() + + if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): + self.config = new_config + self.pinned = self.config.get("pinned_apps", []) + self.update_app_map() + self.update_dock() + return False + + @staticmethod + def update_visibility(visible): + for dock in Dock._instances: + dock.set_visible(visible) + if visible: + GLib.idle_add(dock.check_occlusion_state) + else: + if hasattr(dock, 'dock_revealer') and dock.dock_revealer.get_reveal_child(): + dock.dock_revealer.set_reveal_child(False) + + def force_occlusion(self): + """Force dock to hide and act as if always_show is False.""" + if self.integrated_mode: + return + # Save current always_show state + self._saved_always_show = self.always_show + # Set to False to enable hover behavior + self.always_show = False + self._forced_occlusion = True + if not self.is_mouse_over_dock_area: + self.dock_revealer.set_reveal_child(False) + + def restore_from_occlusion(self): + """Restore dock to its previous always_show state.""" + if self.integrated_mode: + return + self._forced_occlusion = False + # Restore saved always_show state + if hasattr(self, '_saved_always_show'): + self.always_show = self._saved_always_show + delattr(self, '_saved_always_show') + self.check_occlusion_state() diff --git a/Ax-Shell/modules/emoji.py b/Ax-Shell/modules/emoji.py new file mode 100644 index 0000000..56c5982 --- /dev/null +++ b/Ax-Shell/modules/emoji.py @@ -0,0 +1,321 @@ +import os +import subprocess + +import ijson +from fabric.utils import remove_handler +from fabric.utils.helpers import get_relative_path +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.entry import Entry +from fabric.widgets.label import Label +from fabric.widgets.stack import Stack +from gi.repository import Gdk + +import config.data as data +import modules.icons as icons + +vertical_mode = data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]) + +emoji_rows = 3 if not vertical_mode else 9 +emoji_columns = 9 if not vertical_mode else 5 + +class EmojiPicker(Box): + def __init__(self, **kwargs): + super().__init__( + name="emoji", + visible=False, + all_visible=False, + **kwargs, + ) + + self.notch = kwargs["notch"] + self.selected_index = -1 + self.emojis_per_page = emoji_columns * emoji_rows + self.current_page_index = 0 + self.filtered_emojis = [] + self.total_pages = 0 + + self._arranger_handler: int = 0 + self._all_emojis = self._load_emoji_data() + + self.stack = Stack( + name="viewport", + spacing=4, + orientation="v", + transition_type="slide-up-down", + transition_duration=200, + ) + self.search_entry = Entry( + name="search-entry", + placeholder="Search Emojis...", + h_expand=True, + notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), + on_activate=lambda entry, *_: self.on_search_entry_activate(entry.get_text()), + on_key_press_event=self.on_search_entry_key_press, + ) + self.search_entry.props.xalign = 0.5 + self.header_box = Box( + name="header_box", + spacing=10, + orientation="h", + children=[ + self.search_entry, + Button( + name="close-button", + child=Label(name="close-label", markup=icons.cancel), + tooltip_text="Exit", + on_clicked=lambda *_: self.close_picker() + ), + ], + ) + + self.picker_box = Box( + name="picker-box", + spacing=10, + h_expand=True, + orientation="v", + children=[ + self.header_box, + self.stack, + ], + ) + + self.resize_viewport() + + self.add(self.picker_box) + self.show_all() + + def _load_emoji_data(self): + emoji_data = {} + emoji_file_path = get_relative_path("../assets/emoji.json") + if not os.path.exists(emoji_file_path): + print(f"Emoji JSON file not found at: {emoji_file_path}") + return {} + + with open(emoji_file_path, 'r') as f: + for emoji_char, emoji_info in ijson.kvitems(f, ''): + emoji_data[emoji_char] = emoji_info + return emoji_data + + def close_picker(self): + self.stack.children = [] + self.selected_index = -1 + self.notch.close_notch() + + def open_picker(self): + self.search_entry.set_text("") + self.current_page_index = 0 + self.arrange_viewport() + self.search_entry.grab_focus() + + def arrange_viewport(self, query: str = ""): + remove_handler(self._arranger_handler) if self._arranger_handler else None + self.stack.children = [] + self.selected_index = -1 + self.current_page_index = 0 + + self.filtered_emojis = [ + (emoji_char, emoji_info) + for emoji_char, emoji_info in self._all_emojis.items() + if query.casefold() in (emoji_info.get("name", "") + " " + emoji_info.get("group", "")).casefold() + ] + self.total_pages = (len(self.filtered_emojis) + self.emojis_per_page - 1) // self.emojis_per_page if self.filtered_emojis else 0 + + self.load_page(self.current_page_index) + + should_resize = not query + + if should_resize: + self.resize_viewport() + if query.strip() != "" and self.get_all_emoji_buttons(): + self.update_selection(0) + + def load_page(self, page_index): + self.update_selection(-1) + page_box = Box(name=f"page-box-{page_index}", orientation="v", spacing=4) + start_index = page_index * self.emojis_per_page + end_index = min((page_index + 1) * self.emojis_per_page, len(self.filtered_emojis)) + page_emojis = self.filtered_emojis[start_index:end_index] + + grid_box = Box(name="emoji-grid-box", orientation="v", spacing=2) + + row_box = None + for i, (emoji_char, emoji_info) in enumerate(page_emojis): + if i % emoji_columns == 0: + row_box = Box(name="emoji-row-box", orientation="h", spacing=2) + grid_box.add(row_box) + if row_box is not None: + row_box.add(self.bake_emoji_slot(emoji_char, emoji_info)) + page_box.add(grid_box) + self.stack.add_named(page_box, f"page-{page_index}") + self.stack.set_visible_child_name(f"page-{page_index}") + page_box.show_all() + + + buttons = self.get_all_emoji_buttons() + if buttons and self.selected_index != -1: + page_relative_index = self.selected_index % self.emojis_per_page + if page_relative_index < len(buttons): + self.update_selection(page_relative_index) + else: + self.update_selection(len(buttons) - 1) + + + def resize_viewport(self): + return False + + def bake_emoji_slot(self, emoji_char: str, emoji_info: dict, **kwargs) -> Button: + button = Button( + name="emoji-slot-button", + child=Box( + name="emoji-slot-box", + orientation="horizontal", + halign="center", + valign="center", + children=[ + Label( + name="emoji-char-label", + label=emoji_char, + use_markup=True, + v_align="center", + h_align="center", + css_name="emoji-char-label" + ), + ], + ), + tooltip_text=emoji_info.get("name", "Unknown"), + on_clicked=lambda *_: (self.copy_emoji_to_clipboard(emoji_char), self.close_picker()), + **kwargs, + ) + return button + + def update_selection(self, new_index: int): + buttons = self.get_all_emoji_buttons() + if not buttons: + self.selected_index = -1 + return + + if self.selected_index != -1 and self.selected_index < len(buttons): + current_button = buttons[self.selected_index] + current_button.get_style_context().remove_class("selected") + if not buttons or current_button not in buttons: + self.selected_index = -1 + + if 0 <= new_index < len(buttons): + new_button = buttons[new_index] + new_button.get_style_context().add_class("selected") + self.selected_index = new_index + else: + self.selected_index = -1 + + + def get_all_emoji_buttons(self): + buttons = [] + current_page = self.stack.get_visible_child() + if current_page and current_page.get_children(): + if current_page.get_children()[0].get_children(): + for row_box in current_page.get_children()[0].get_children(): + buttons.extend(row_box.get_children()) + return buttons + + + def on_search_entry_activate(self, text): + buttons = self.get_all_emoji_buttons() + if buttons: + if self.selected_index != -1: + buttons[self.selected_index].clicked() + elif buttons and text.strip() != "": + buttons[0].clicked() + + def on_search_entry_key_press(self, widget, event): + if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right): + self.move_selection_2d(event.keyval) + return True + elif event.keyval == Gdk.KEY_Escape: + self.close_picker() + return True + return False + + def move_selection_2d(self, keyval): + buttons = self.get_all_emoji_buttons() + total_items_current_page = len(buttons) + if total_items_current_page == 0: + return + + rows = emoji_rows + columns = emoji_columns + + if self.selected_index == -1: + if keyval in (Gdk.KEY_Down, Gdk.KEY_Right): + new_index = 0 + elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left): + new_index = total_items_current_page - 1 + else: + return + else: + current_index_page = self.selected_index + row = current_index_page // columns + col = current_index_page % columns + + if keyval == Gdk.KEY_Right: + new_col = (col + 1) % columns + new_row = row + if new_col == 0: + new_row = (row + 1) + elif keyval == Gdk.KEY_Left: + new_col = (col - 1) % columns + new_row = row + if new_col == (columns - 1): + new_row = (row - 1) + elif keyval == Gdk.KEY_Down: + new_row = row + 1 + new_col = col + elif keyval == Gdk.KEY_Up: + new_row = row - 1 + new_col = col + else: + return + + if new_row >= rows: + if self.current_page_index < self.total_pages - 1: + current_col = col # Keep track of current column + self.current_page_index += 1 + self.load_page(self.current_page_index) + new_index = current_col # Try to keep the same column + if new_index >= total_items_current_page: # if column is out of bound, select last + new_index = total_items_current_page - 1 + self.selected_index = -1 + self.update_selection(new_index) + return + else: + new_index = total_items_current_page - 1 + elif new_row < 0: + if self.current_page_index > 0: + current_col = col # Keep track of current column + self.current_page_index -= 1 + self.load_page(self.current_page_index) + new_index = (rows - 1) * columns + current_col # Select last row, same column + if new_index >= total_items_current_page: # if column is out of bound, select last + new_index = total_items_current_page -1 + self.selected_index = -1 + self.update_selection(new_index) + return + else: + new_index = 0 + else: + new_index = new_row * columns + new_col + if new_index >= total_items_current_page: + new_index = total_items_current_page - 1 + + if new_index < 0: + new_index = 0 + elif new_index >= total_items_current_page: + new_index = total_items_current_page - 1 + + self.update_selection(new_index) + + def copy_emoji_to_clipboard(self, emoji_char: str): + try: + subprocess.run(["wl-copy"], input=emoji_char.encode('utf-8'), check=True) + except subprocess.CalledProcessError as e: + print(f"Clipboard copy failed: {e}") diff --git a/Ax-Shell/modules/icons.py b/Ax-Shell/modules/icons.py new file mode 100644 index 0000000..b1c32cc --- /dev/null +++ b/Ax-Shell/modules/icons.py @@ -0,0 +1,197 @@ +# Parameters +font_family: str = "tabler-icons" +font_weight: str = "normal" + +span: str = f"" + +# Panels +apps: str = "" +dashboard: str = "" +chat: str = "" +windows: str = "" + +# Bar +colorpicker: str = "" +media: str = "" + +# Toolbox + +toolbox: str = "" +ssfull: str = "" +ssregion: str = "" +sswindow: str = "" +screenshots: str = "" +screenrecord: str = "" +recordings: str = "" +ocr: str = "ﳃ" +gamemode: str = "" +gamemode_off: str = "" +close: str = "" + +# Circles +temp: str = "" +disk: str = "" +battery: str = "" +memory: str = "流" +cpu: str = "" +gpu: str = "" + +# AIchat +reload: str = "" +detach: str = "" + +# Wallpapers +add: str = "" +sort: str = "" +circle: str = "" + +# Chevrons +chevron_up: str = "" +chevron_down: str = "" +chevron_left: str = "" +chevron_right: str = "" + +# Power +lock: str = "" +suspend: str = "" +logout: str = "" +reboot: str = "" +shutdown: str = "" + +# Power Manager +power_saving: str = "" +power_balanced: str = "勺" +power_performance: str = "" +charging: str = "" +discharging: str = "" +alert: str = "" +bat_charging: str = "" +bat_discharging: str = "" +bat_low: str = "=" +bat_full: str = "" + + +# Applets +wifi_0: str = "" +wifi_1: str = "" +wifi_2: str = "" +wifi_3: str = "" +world: str = "" +world_off: str = "" +bluetooth: str = "" +night: str = "" +coffee: str = "" +notifications: str = "" + +wifi_off: str = "" +bluetooth_off: str = "" +night_off: str = "" +notifications_off: str = "" + +notifications_clear: str = "" + +download: str = "" +upload: str = "" + +# Bluetooth +bluetooth_connected: str = "" +bluetooth_disconnected: str = "" + +# Player +pause: str = "" +play: str = "" +stop: str = "" +skip_back: str = "" +skip_forward: str = "" +prev: str = "" +next: str = "" +shuffle: str = "" +repeat: str = "" +music: str = "" +rewind_backward_5: str = "謹" +rewind_forward_5: str = "難" + +# Volume +vol_off: str = "" +vol_mute: str = "" +vol_medium: str = "" +vol_high: str = "" + +mic: str = "" +mic_mute: str = "" + +speaker: str = "𐁅" +headphones: str = "屮" +mic_filled: str = "️" + +# Overview +circle_plus: str = "" + +# Pins +paperclip: str = "" + +# Clipboard Manager +clipboard: str = "" +clip_text: str = "" + +# Confirm +accept: str = "" +cancel: str = "" +trash: str = "" + +# Config +config: str = "" + +# Icons +firefox: str = "" +chromium: str = "" +spotify: str = "ﺆ" +disc: str = "𐀾" +disc_off: str = "" + +# Brightness +brightness_low: str = "" +brightness_medium: str = "" +brightness_high: str = "" + +brightness: str = "" + +# Dashboard +widgets: str = "" +pins: str = "" +kanban: str = "" +wallpapers: str = "" +sparkles: str = "" + +# Misc +dot: str = "" +palette: str = "" +cloud_off: str = "" +loader: str = "" +radar: str = "" +emoji: str = "" +keyboard: str = "" +terminal: str = "" +timer_off: str = "" +timer_on: str = "" +spy: str = "" + +# Dice +dice_1: str = "" +dice_2: str = "" +dice_3: str = "" +dice_4: str = "" +dice_5: str = "" +dice_6: str = "" + +exceptions: list[str] = ["font_family", "font_weight", "span"] + + +def apply_span() -> None: + global_dict = globals() + for key in global_dict: + if key not in exceptions and not key.startswith("__"): + global_dict[key] = f"{span}{global_dict[key]}" + + +apply_span() diff --git a/Ax-Shell/modules/kanban.py b/Ax-Shell/modules/kanban.py new file mode 100644 index 0000000..ce096c9 --- /dev/null +++ b/Ax-Shell/modules/kanban.py @@ -0,0 +1,364 @@ +import json +import os +from pathlib import Path + +import cairo +import gi +from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow + +import config.data as data +import modules.icons as icons + +gi.require_version('Gtk', '3.0') +from gi.repository import Gdk, GLib, GObject, Gtk + + +def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: + alloc = widget.get_allocation() + surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height) + cr = cairo.Context(surface) + + cr.set_source_rgba(0, 0, 0, 0) + cr.rectangle(0, 0, alloc.width, alloc.height) + cr.fill() + widget.draw(cr) + return surface + +class InlineEditor(Gtk.Box): + __gsignals__ = { + 'confirmed': (GObject.SignalFlags.RUN_LAST, None, (str,)), + 'canceled': (GObject.SignalFlags.RUN_LAST, None, ()) + } + + def __init__(self, initial_text=""): + super().__init__(name="inline-editor", spacing=4) + + self.text_view = Gtk.TextView() + self.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + buffer = self.text_view.get_buffer() + buffer.set_text(initial_text) + + self.text_view.connect("key-press-event", self.on_key_press) + + confirm_btn = Gtk.Button(name="kanban-btn", child=Label(name="kanban-btn-label", markup=icons.accept)) + confirm_btn.connect("clicked", self.on_confirm) + confirm_btn.get_style_context().add_class("flat") + + cancel_btn = Gtk.Button(name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.cancel)) + cancel_btn.connect("clicked", self.on_cancel) + cancel_btn.get_style_context().add_class("flat") + + + sw = ScrolledWindow(name="scrolled-window", propagate_height=False, propagate_width=False) + sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + sw.set_min_content_height(50) + sw.add(self.text_view) + + self.button_box = Box(children=[confirm_btn, cancel_btn], spacing=4) + self.center_box = CenterBox(center_children=[self.button_box], orientation="v") + + self.pack_start(sw, True, True, 0) + self.pack_start(self.center_box, False, False, 0) + self.show_all() + + def on_confirm(self, widget): + buffer = self.text_view.get_buffer() + start, end = buffer.get_bounds() + text = buffer.get_text(start, end, True).strip() + if text: + self.emit('confirmed', text) + else: + self.emit('canceled') + + def on_cancel(self, widget): + self.emit('canceled') + + def on_key_press(self, widget, event): + + if event.keyval == Gdk.KEY_Escape: + self.emit('canceled') + return True + + + if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + state = event.get_state() + if state & Gdk.ModifierType.SHIFT_MASK: + + buffer = self.text_view.get_buffer() + cursor_iter = buffer.get_iter_at_mark(buffer.get_insert()) + buffer.insert(cursor_iter, "\n") + return True + else: + + self.on_confirm(widget) + return True + return False + +class KanbanNote(Gtk.EventBox): + __gsignals__ = { + 'changed': (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self, text): + super().__init__() + self.text = text + + self.setup_ui() + self.setup_dnd() + self.connect("button-press-event", self.on_button_press) + + def setup_ui(self): + self.box = Gtk.Box(name="kanban-note", spacing=4) + self.label = Gtk.Label(label=self.text) + self.label.set_line_wrap(True) + + self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) + + self.delete_btn = Gtk.Button(name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.trash)) + self.delete_btn.connect("clicked", self.on_delete_clicked) + + self.center_btn = CenterBox(orientation="v", start_children=[self.delete_btn]) + + self.box.pack_start(self.label, True, True, 0) + self.box.pack_start(self.center_btn, False, False, 0) + self.add(self.box) + self.show_all() + + def setup_dnd(self): + self.drag_source_set( + Gdk.ModifierType.BUTTON1_MASK, + [Gtk.TargetEntry.new('UTF8_STRING', Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE + ) + self.connect("drag-data-get", self.on_drag_data_get) + self.connect("drag-data-delete", self.on_drag_data_delete) + + self.connect("drag-begin", self.on_drag_begin) + + def on_button_press(self, widget, event): + if event.type != Gdk.EventType._2BUTTON_PRESS: + return True + self.start_edit() + return False + + def on_drag_begin(self, widget, context): + surface = createSurfaceFromWidget(self) + Gtk.drag_set_icon_surface(context, surface) + + def on_drag_data_get(self, widget, drag_context, data, info, time): + data.set_text(self.label.get_text(), -1) + + def on_drag_data_delete(self, widget, drag_context): + self.get_parent().destroy() + + def on_delete_clicked(self, button): + self.get_parent().destroy() + + + def start_edit(self): + row = self.get_parent() + editor = InlineEditor(self.label.get_text()) + + def on_confirmed(editor, text): + self.label.set_text(text) + row.remove(editor) + row.add(self) + row.show_all() + self.emit('changed') + + def on_canceled(editor): + row.remove(editor) + row.add(self) + row.show_all() + + editor.connect('confirmed', on_confirmed) + editor.connect('canceled', on_canceled) + + row.remove(self) + row.add(editor) + row.show_all() + + GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False)) + +class KanbanColumn(Gtk.Frame): + __gsignals__ = { + 'changed': (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self, title): + super().__init__(name="kanban-column") + self.title = title + self.setup_ui() + self.setup_dnd() + self.set_hexpand(True) + self.set_vexpand(True) + + def setup_ui(self): + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self.listbox = Gtk.ListBox() + self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) + + self.add_btn = Gtk.Button(name="kanban-btn-add", child=Label(name="kanban-btn-label", markup=icons.add)) + header = CenterBox(name="kanban-header", center_children=[Label(name="column-header", label=self.title)], end_children=[self.add_btn]) + self.box.pack_start(header, False, False, 0) + + self.add_btn.connect("clicked", self.on_add_clicked) + + self.scroller = ScrolledWindow(name="scrolled-window", propagate_height=False, propagate_width=False) + self.scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.scroller.add(self.listbox) + self.scroller.set_vexpand(True) + + self.box.pack_start(self.scroller, True, True, 0) + self.box.pack_start(self.add_btn, False, False, 0) + self.add(self.box) + self.show_all() + + def setup_dnd(self): + self.listbox.drag_dest_set( + Gtk.DestDefaults.ALL, + [Gtk.TargetEntry.new('UTF8_STRING', Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE + ) + + self.listbox.connect("drag-data-received", self.on_drag_data_received) + self.listbox.connect("drag-motion", self.on_drag_motion) + self.listbox.connect("drag-leave", self.on_drag_leave) + + def on_add_clicked(self, button): + editor = InlineEditor() + row = Gtk.ListBoxRow(name="kanban-row") + row.add(editor) + self.listbox.add(row) + self.listbox.show_all() + editor.text_view.grab_focus() + + def on_confirmed(editor, text): + note = KanbanNote(text) + note.connect('changed', lambda x: self.emit('changed')) + row.remove(editor) + row.add(note) + self.listbox.show_all() + self.emit('changed') + + def on_canceled(editor): + row.destroy() + + def scroll_to_bottom(): + adj = self.scroller.get_vadjustment() + adj.set_value(adj.get_upper()) + + editor.connect('confirmed', on_confirmed) + editor.connect('canceled', on_canceled) + + GLib.idle_add(scroll_to_bottom) # ensure this is called after row is loaded + + def add_note(self, text, suppress_signal=False): + note = KanbanNote(text) + note.connect('changed', lambda x: self.emit('changed')) + row = Gtk.ListBoxRow(name="kanban-row") + row.add(note) + row.connect('destroy', lambda x: self.emit('changed')) + self.listbox.add(row) + self.listbox.show_all() + if not suppress_signal: + self.emit('changed') + + def get_notes(self): + return [ + row.get_children()[0].label.get_text() + for row in self.listbox.get_children() + if isinstance(row.get_children()[0], KanbanNote) + ] + + def clear_notes(self, suppress_signal=False): + for row in self.listbox.get_children(): + row.destroy() + if not suppress_signal: + self.emit('changed') + + def on_drag_data_received(self, widget, drag_context, x, y, data, info, time): + text = data.get_text() + if text: + row = self.listbox.get_row_at_y(y) + new_note = KanbanNote(text) + new_note.connect('changed', lambda x: self.emit('changed')) + new_row = Gtk.ListBoxRow(name="kanban-row") + new_row.add(new_note) + new_row.connect('destroy', lambda x: self.emit('changed')) + + if row: + self.listbox.insert(new_row, row.get_index()) + else: + self.listbox.add(new_row) + + self.listbox.show_all() + drag_context.finish(True, False, time) + self.emit('changed') + + def on_drag_motion(self, widget, drag_context, x, y, time): + Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time) + return True + + def on_drag_leave(self, widget, drag_context, time): + widget.get_parent().get_parent().drag_unhighlight() + +class Kanban(Gtk.Box): + STATE_FILE = Path(os.path.expanduser("~/.kanban.json")) + + def __init__(self): + super().__init__(name="kanban") + + self.grid = Gtk.Grid(column_spacing=4, column_homogeneous=True, row_spacing=4, row_homogeneous=True) + self.grid.set_vexpand(True) + self.add(self.grid) + + self.columns = [ + KanbanColumn("To Do"), + KanbanColumn("In Progress"), + KanbanColumn("Done") + ] + + vertical_mode = True if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]) else False + + for i, column in enumerate(self.columns): + if vertical_mode == False: + self.grid.attach(column, i, 0, 1, 1) + else: + self.grid.attach(column, 0, i, 1, 1) + column.connect('changed', lambda x: self.save_state()) + + self.load_state() + self.show_all() + + def save_state(self): + state = { + "columns": [ + {"title": col.title, "notes": col.get_notes()} + for col in self.columns + ] + } + try: + with open(self.STATE_FILE, "w") as f: + json.dump(state, f, indent=2) + except Exception as e: + print(f"Error saving state: {e}") + + def load_state(self): + try: + with open(self.STATE_FILE, "r") as f: + state = json.load(f) + for col_data in state["columns"]: + for column in self.columns: + if column.title == col_data["title"]: + column.clear_notes(suppress_signal=True) + for note_text in col_data["notes"]: + column.add_note(note_text, suppress_signal=True) + break + except FileNotFoundError: + pass + except Exception as e: + print(f"Error loading state: {e}") diff --git a/Ax-Shell/modules/launcher.py b/Ax-Shell/modules/launcher.py new file mode 100644 index 0000000..b930eda --- /dev/null +++ b/Ax-Shell/modules/launcher.py @@ -0,0 +1,787 @@ +import json +import math +import operator +import os +import re +import subprocess +from collections.abc import Iterator + +import numpy as np +from fabric.utils import (DesktopApp, exec_shell_command_async, + get_desktop_applications, idle_add, remove_handler) +from fabric.utils.helpers import get_relative_path +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 fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GLib + +import config.data as data +import modules.icons as icons +from modules.dock import Dock +from modules.updater import run_updater +from utils.conversion import Conversion + +tooltip_settings = f"Open {data.APP_NAME_CAP} Settings" +tooltip_close = "Close" + +class AppLauncher(Box): + def __init__(self, **kwargs): + super().__init__( + name="app-launcher", + visible=False, + all_visible=False, + **kwargs, + ) + + self.notch = kwargs["notch"] + self.selected_index = -1 + + self._arranger_handler: int = 0 + self._all_apps = get_desktop_applications() + + + self.converter = Conversion() + self.calc_history_path = f"{data.CACHE_DIR}/calc.json" + if os.path.exists(self.calc_history_path): + with open(self.calc_history_path, "r") as f: + self.calc_history = json.load(f) + else: + self.calc_history = [] + + self.conversion_history_path = f"{data.CACHE_DIR}/conversion.json" + if os.path.exists(self.conversion_history_path): + with open(self.conversion_history_path, "r") as f: + self.conversion_history = json.load(f) + else: + self.conversion_history = [] + + self.viewport = Box(name="viewport", spacing=4, orientation="v") + self.search_entry = Entry( + name="search-entry", + placeholder="Search Applications...", + h_expand=True, + h_align="fill", + notify_text=self.notify_text, + on_activate=lambda entry, *_: self.on_search_entry_activate(entry.get_text()), + on_key_press_event=self.on_search_entry_key_press, + ) + self.search_entry.props.xalign = 0.5 + self.scrolled_window = ScrolledWindow( + name="scrolled-window", + spacing=10, + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + child=self.viewport, + propagate_width=False, + propagate_height=False, + ) + + self.header_box = Box( + name="header_box", + spacing=10, + orientation="h", + children=[ + Button( + name="config-button", + tooltip_markup=tooltip_settings, + child=Label(name="config-label", markup=icons.config), + on_clicked=lambda *_: (exec_shell_command_async(f"python {get_relative_path('../config/config.py')}"), self.close_launcher()), + ), + self.search_entry, + Button( + name="close-button", + tooltip_markup=tooltip_close, + child=Label(name="close-label", markup=icons.cancel), + tooltip_text="Exit", + on_clicked=lambda *_: self.close_launcher() + ), + ], + ) + + self.launcher_box = Box( + name="launcher-box", + spacing=10, + h_expand=True, + orientation="v", + children=[ + self.header_box, + self.scrolled_window, + ], + ) + + self.resize_viewport() + + self.add(self.launcher_box) + self.show_all() + + def close_launcher(self): + self.viewport.children = [] + self.selected_index = -1 + self.notch.close_notch() + + def open_launcher(self): + self._all_apps = get_desktop_applications() + self.arrange_viewport() + + + def clear_selection(): + + entry = self.search_entry + if entry.get_text(): + pos = len(entry.get_text()) + entry.set_position(pos) + entry.select_region(pos, pos) + return False + + + GLib.idle_add(clear_selection) + + def ensure_initialized(self): + """Make sure the launcher is initialized with apps list before opening""" + if not hasattr(self, '_initialized'): + + self._all_apps = get_desktop_applications() + self._initialized = True + return True + return False + + def arrange_viewport(self, query: str = ""): + if query.startswith("="): + + self.update_calculator_viewport() + return + if query.startswith(";"): + # In conversion mode, update history view once (not per keystroke) + self.update_conversion_viewport() + return + remove_handler(self._arranger_handler) if self._arranger_handler else None + self.viewport.children = [] + self.selected_index = -1 + + def extract_command_name(command_line): + """Extract base command name from command line, removing paths and arguments""" + if not command_line: + return "" + # Remove common shell wrappers + if command_line.startswith("/bin/sh -c"): + # Handle wrapped commands like "/bin/sh -c "\$SHELL -i -c scrcpy"" + return "" + # Split by spaces and take first part (the command) + cmd = command_line.split()[0] if command_line.split() else "" + # Extract just the command name from full paths + if "/" in cmd: + cmd = cmd.split("/")[-1] + return cmd + + filtered_apps_iter = iter( + sorted( + [ + app + for app in self._all_apps + if query.casefold() + in ( + (app.display_name or "") + + (" " + app.name + " ") + + (app.generic_name or "") + + (" " + (app.command_line or "") + " ") + + (" " + (app.executable or "") + " ") + + (" " + extract_command_name(app.command_line) + " ") + ).casefold() + ], + key=lambda app: (app.display_name or "").casefold(), + ) + ) + should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps) + + self._arranger_handler = idle_add( + lambda apps_iter: self.add_next_application(apps_iter) or self.handle_arrange_complete(should_resize, query), + filtered_apps_iter, + pin=True, + ) + + def handle_arrange_complete(self, should_resize, query): + if query.strip() != "" and self.viewport.get_children(): + self.update_selection(0) + return False + + def add_next_application(self, apps_iter: Iterator[DesktopApp]): + if not (app := next(apps_iter, None)): + return False + self.viewport.add(self.bake_application_slot(app)) + return True + + def resize_viewport(self): + # Removed set_min_content_width to prevent size retention issues + # when switching between modules in the notch stack + pass + + def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button: + button = Button( + name="slot-button", + child=Box( + name="slot-box", + orientation="h", + spacing=10, + children=[ + Image(name="app-icon", pixbuf=app.get_icon_pixbuf(size=24), h_align="start"), + Label( + name="app-label", + label=app.display_name or "Unknown", + ellipsization="end", + v_align="center", + h_align="center", + ), + Label( + name="app-desc", + label=app.description or "", + ellipsization="end", + v_align="center", + h_align="start", + h_expand=True, + ), + ], + ), + tooltip_text=app.description, + on_clicked=lambda *_: (app.launch(), self.close_launcher()), + **kwargs, + ) + return button + + def update_selection(self, new_index: int): + + if self.selected_index != -1 and self.selected_index < len(self.viewport.get_children()): + current_button = self.viewport.get_children()[self.selected_index] + current_button.get_style_context().remove_class("selected") + + if new_index != -1 and new_index < len(self.viewport.get_children()): + new_button = self.viewport.get_children()[new_index] + new_button.get_style_context().add_class("selected") + self.selected_index = new_index + self.scroll_to_selected(new_button) + else: + self.selected_index = -1 + + def scroll_to_selected(self, button): + def scroll(): + adj = self.scrolled_window.get_vadjustment() + alloc = button.get_allocation() + if alloc.height == 0: + return False + + y = alloc.y + height = alloc.height + page_size = adj.get_page_size() + current_value = adj.get_value() + + visible_top = current_value + visible_bottom = current_value + page_size + + if y < visible_top: + + adj.set_value(y) + elif y + height > visible_bottom: + + new_value = y + height - page_size + adj.set_value(new_value) + + return False + GLib.idle_add(scroll) + + def on_search_entry_activate(self, text): + if text.startswith("="): + + if self.selected_index == -1: + self.evaluate_calculator_expression(text) + return + if text.startswith(";"): + # If in calculator mode and no history item is selected, evaluate new expression. + if self.selected_index == -1: + self.evaluate_calculator_expression(text) + return + match text: + case ":w": + self.notch.open_notch("wallpapers") + case ":d": + self.notch.open_notch("dashboard") + case ":p": + self.notch.open_notch("power") + case ":update": + GLib.idle_add(lambda: run_updater(force=True)) + case ":settings": + exec_shell_command_async(f"python {get_relative_path('../config/config.py')}") + self.close_launcher() + case ":config": + exec_shell_command_async(f"python {get_relative_path('../config/config.py')}") + self.close_launcher() + case _: + children = self.viewport.get_children() + if children: + + if text.strip() == "" and self.selected_index == -1: + return + selected_index = self.selected_index if self.selected_index != -1 else 0 + if 0 <= selected_index < len(children): + children[selected_index].clicked() + + def on_search_entry_key_press(self, widget, event): + text = widget.get_text() + + + if text.startswith("="): + if event.keyval == Gdk.KEY_Down: + self.move_selection(1) + return True + elif event.keyval == Gdk.KEY_Up: + self.move_selection(-1) + return True + elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + + if self.selected_index != -1 and self.selected_index < len(self.calc_history): + if event.state & Gdk.ModifierType.SHIFT_MASK: + + self.delete_selected_calc_history() + else: + + selected_text = self.calc_history[self.selected_index] + self.copy_text_to_clipboard(selected_text) + + self.selected_index = -1 + else: + + self.selected_index = -1 + + self.evaluate_calculator_expression(text) + return True + elif event.keyval == Gdk.KEY_Escape: + self.close_launcher() + return True + return False + if text.startswith(";"): + if event.keyval == Gdk.KEY_Down: + self.move_selection(1) + return True + elif event.keyval == Gdk.KEY_Up: + self.move_selection(-1) + return True + elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + # In conversion mode, if a history item is highlighted: + if self.selected_index != -1 and self.selected_index < len(self.conversion_history): + if event.state & Gdk.ModifierType.SHIFT_MASK: + # Shift+Enter deletes the selected calculator history item + self.delete_selected_conversion_history() + else: + # Normal Enter copies the result + selected_text = self.conversion_history[self.selected_index] + self.copy_text_to_clipboard(selected_text) + # Clear selection so new expressions are evaluated on further Return presses + self.selected_index = -1 + else: + # Force reset selection index + self.selected_index = -1 + # No item selected, evaluate the expression + self.evaluate_conversion_expression(text) + return True + elif event.keyval == Gdk.KEY_Escape: + self.close_launcher() + return True + return False + else: + + if event.keyval == Gdk.KEY_Down: + self.move_selection(1) + return True + elif event.keyval == Gdk.KEY_Up: + self.move_selection(-1) + return True + elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter) and (event.state & Gdk.ModifierType.SHIFT_MASK): + + self.add_selected_app_to_dock() + return True + elif event.keyval == Gdk.KEY_Escape: + self.close_launcher() + return True + return False + + def notify_text(self, entry, *_): + """Handle text changes in the search entry""" + text = entry.get_text() + if text.startswith("="): + self.update_calculator_viewport() + + self.selected_index = -1 + elif text.startswith(";"): + self.update_conversion_viewport() + # Always reset selection when typing a new expression + self.selected_index = -1 + else: + self.arrange_viewport(text) + + def add_selected_app_to_dock(self): + """Adds the currently selected application to the dock.json file with comprehensive metadata.""" + children = self.viewport.get_children() + if not children or self.selected_index == -1 or self.selected_index >= len(children): + return + + selected_button = children[self.selected_index] + + selected_app = next((app for app in self._all_apps if app.display_name == selected_button.get_child().get_children()[1].props.label), None) + if not selected_app: + return + + app_data = {k: v for k, v in { + "name": selected_app.name, + "display_name": selected_app.display_name, + "window_class": selected_app.window_class, + "executable": selected_app.executable, + "command_line": selected_app.command_line, + "icon_name": selected_app.icon_name + }.items() if v is not None} + + config_path = get_relative_path("../config/dock.json") + try: + with open(config_path, "r") as file: + data = json.load(file) + except (FileNotFoundError, json.JSONDecodeError): + data = {"pinned_apps": []} + + already_pinned = False + for pinned_app in data.get("pinned_apps", []): + if isinstance(pinned_app, dict) and pinned_app.get("name") == app_data["name"]: + already_pinned = True + + pinned_app.update(app_data) + break + elif isinstance(pinned_app, str) and pinned_app == app_data["name"]: + already_pinned = True + + data["pinned_apps"].remove(pinned_app) + data["pinned_apps"].append(app_data) + break + + if not already_pinned: + data.setdefault("pinned_apps", []).append(app_data) + + + with open(config_path, "w") as file: + json.dump(data, file, indent=4) + + + Dock.notify_config_change() + + def move_selection(self, delta: int): + children = self.viewport.get_children() + if not children: + return + + if self.selected_index == -1 and delta == 1: + new_index = 0 + else: + new_index = self.selected_index + delta + new_index = max(0, min(new_index, len(children) - 1)) + self.update_selection(new_index) + + def save_calc_history(self): + with open(self.calc_history_path, "w") as f: + json.dump(self.calc_history, f) + + def save_conversion_history(self): + with open(self.conversion_history_path, "w") as f: + json.dump(self.conversion_history, f) + + def evaluate_calculator_expression(self, text: str): + + print(f"Evaluating calculator expression: {text}") + + + expr = text.lstrip("=").strip() + if not expr: + return + + + replacements = { + "^": "**", + "ร—": "*", + "รท": "/", + "ฯ€": "np.pi", + "pi": "np.pi", + "e": "np.e", + "sin(": "np.sin(", + "cos(": "np.cos(", + "tan(": "np.tan(", + "log(": "np.log10(", + "ln(": "np.log(", + "sqrt(": "np.sqrt(", + "abs(": "np.abs(", + "exp(": "np.exp(" + } + + + for old, new in replacements.items(): + expr = expr.replace(old, new) + + + expr = re.sub(r'(\d+)!', r'np.factorial(\1)', expr) + + + for old, new in [("[", "("), ("]", ")"), ("{", "("), ("}", ")")]: + expr = expr.replace(old, new) + + + safe_dict = { + 'np': np, + 'math': math, + 'arange': np.arange, + 'linspace': np.linspace, + 'array': np.array + } + + try: + + result = eval(expr, {"__builtins__": None}, safe_dict) + + + if isinstance(result, np.ndarray): + if result.size > 10: + result_str = f"Array of shape {result.shape}" + else: + result_str = str(result) + elif isinstance(result, (int, float, np.number)): + + if isinstance(result, (int, np.integer)) or result.is_integer(): + result_str = str(int(result)) + else: + result_str = f"{float(result):.10g}" + else: + result_str = str(result) + + except Exception as e: + result_str = f"Error: {str(e)}" + + + self.calc_history.insert(0, f"{text} => {result_str}") + self.save_calc_history() + self.update_calculator_viewport() + + def evaluate_conversion_expression(self, text: str): + print(f"Evaluating conversion expression: {text}") + expr = text.lstrip(";").strip() + if not expr: + return + + # Add loading entry + loading_entry = f"{text} => Loading..." + self.conversion_history.insert(0, loading_entry) + self.update_conversion_viewport() + + # Perform conversion in thread + def do_conversion(): + try: + result_value, result_type = self.converter.parse_input_and_convert(expr) + if result_type is None: + result_str = f"{result_value:.2f}" + else: + result_str = f"{result_value:.2f} {result_type}" + except: + result_str = "Error: Invalid conversion expression" + + # Update the history entry + GLib.idle_add(self._update_conversion_result, text, result_str) + + GLib.Thread.new("conversion", do_conversion, None) + + def _update_conversion_result(self, text, result_str): + # Replace the loading entry with the result + if self.conversion_history and self.conversion_history[0].startswith(f"{text} => Loading"): + self.conversion_history[0] = f"{text} => {result_str}" + else: + # Fallback: insert new + self.conversion_history.insert(0, f"{text} => {result_str}") + self.save_conversion_history() + self.update_conversion_viewport() + + def update_calculator_viewport(self): + self.viewport.children = [] + for item in self.calc_history: + btn = self.create_calc_history_button(item) + self.viewport.add(btn) + + if self.selected_index >= len(self.calc_history): + self.selected_index = -1 + + def update_conversion_viewport(self): + self.viewport.children = [] + for item in self.conversion_history: + btn = self.create_conversion_history_button(item) + self.viewport.add(btn) + # Don't reset selection index here automatically + # Ensure selection state stays valid + if self.selected_index >= len(self.conversion_history): + self.selected_index = -1 + + def create_calc_history_button(self, text: str) -> Button: + + if "=>" in text: + parts = text.split("=>") + expression = parts[0].strip() + result = parts[1].strip() + + + display_text = text + if len(result) > 50: + display_text = f"{expression} => {result[:47]}..." + + btn = Button( + name="slot-button", + child=Box( + name="calc-slot-box", + orientation="h", + spacing=10, + children=[ + Label( + name="calc-label", + label=display_text, + ellipsization="end", + v_align="center", + h_align="center", + ), + ], + ), + tooltip_text=text, + on_clicked=lambda *_: self.copy_text_to_clipboard(text), + ) + else: + + btn = Button( + name="slot-button", + child=Box( + name="calc-slot-box", + orientation="h", + spacing=10, + children=[ + Label( + name="calc-label", + label=text, + ellipsization="end", + v_align="center", + h_align="center", + ), + ], + ), + tooltip_text=text, + on_clicked=lambda *_: self.copy_text_to_clipboard(text), + ) + return btn + + def create_conversion_history_button(self, text: str) -> Button: + # Parse the result to create a more readable display + if "=>" in text: + parts = text.split("=>") + expression = parts[0].strip() + result = parts[1].strip() + + # For very long results, truncate for display but keep full in tooltip + display_text = text + if len(result) > 50: # Truncate long results + display_text = f"{expression} => {result[:47]}..." + + btn = Button( + name="slot-button", # reuse existing CSS styling + child=Box( + name="calc-slot-box", + orientation="h", + spacing=10, + children=[ + Label( + name="calc-label", + label=display_text, + ellipsization="end", + v_align="center", + h_align="center", + ), + ], + ), + tooltip_text=text, + on_clicked=lambda *_: self.copy_text_to_clipboard(text), + ) + else: + # Fallback for non-calculation entries + btn = Button( + name="slot-button", + child=Box( + name="calc-slot-box", + orientation="h", + spacing=10, + children=[ + Label( + name="calc-label", + label=text, + ellipsization="end", + v_align="center", + h_align="center", + ), + ], + ), + tooltip_text=text, + on_clicked=lambda *_: self.copy_text_to_clipboard(text), + ) + return btn + + def copy_text_to_clipboard(self, text: str): + + parts = text.split("=>", 1) + copy_text = parts[1].strip() if len(parts) > 1 else text + try: + subprocess.run(["wl-copy"], input=copy_text.encode(), check=True) + except subprocess.CalledProcessError as e: + print(f"Clipboard copy failed: {e}") + + def delete_selected_calc_history(self): + if self.selected_index != -1 and self.selected_index < len(self.calc_history): + + current_index = self.selected_index + + + del self.calc_history[current_index] + self.save_calc_history() + + + new_index = 0 if current_index == 0 else current_index - 1 + + + self.selected_index = -1 + + + self.update_calculator_viewport() + + + if len(self.calc_history) > 0: + self.update_selection(min(new_index, len(self.calc_history) - 1)) + + def delete_selected_conversion_history(self): + if self.selected_index != -1 and self.selected_index < len(self.conversion_history): + # Store the current index before deletion + current_index = self.selected_index + + # Delete the item + del self.conversion_history[current_index] + self.save_conversion_history() + + # Determine the new selection index + # If we deleted the first item, stay at index 0 + # Otherwise, move to the previous item + new_index = 0 if current_index == 0 else current_index - 1 + + # Reset selection before updating viewport + self.selected_index = -1 + + # Update the viewport + self.update_conversion_viewport() + + # If we still have items, select the determined index + if len(self.conversion_history) > 0: + self.update_selection(min(new_index, len(self.conversion_history) - 1)) diff --git a/Ax-Shell/modules/metrics.py b/Ax-Shell/modules/metrics.py new file mode 100644 index 0000000..093070b --- /dev/null +++ b/Ax-Shell/modules/metrics.py @@ -0,0 +1,717 @@ +import json +import logging +import subprocess +import time + +import psutil +from fabric.core.fabricator import Fabricator +from fabric.utils.helpers import invoke_repeater +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.eventbox import EventBox +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.revealer import Revealer +from fabric.widgets.scale import Scale +from gi.repository import GLib + +import config.data as data +from modules.upower.upower import UPowerManager +import modules.icons as icons +from services.network import NetworkClient + +logger = logging.getLogger(__name__) + +class MetricsProvider: + """ + Class responsible for obtaining centralized CPU, memory, disk usage, and battery metrics. + It updates periodically so that all widgets querying it display the same values. + """ + def __init__(self): + self.gpu = [] + self.cpu = 0.0 + self.mem = 0.0 + self.disk = [] + + self.upower = UPowerManager() + self.display_device = self.upower.get_display_device() + self.bat_percent = 0.0 + self.bat_charging = None + self.bat_time = 0 + + self._gpu_update_running = False + self._gpu_update_counter = 0 + + GLib.timeout_add_seconds(2, self._update) + + def _update(self): + self.cpu = psutil.cpu_percent(interval=0) + self.mem = psutil.virtual_memory().percent + self.disk = [psutil.disk_usage(path).percent for path in data.BAR_METRICS_DISKS] + + self._gpu_update_counter += 1 + if self._gpu_update_counter >= 5: # Update GPU every 10 seconds (5 * 2s) + self._gpu_update_counter = 0 + if not self._gpu_update_running: + self._start_gpu_update_async() + + battery = self.upower.get_full_device_information(self.display_device) + if battery is None: + self.bat_percent = 0.0 + self.bat_charging = None + self.bat_time = 0 + else: + self.bat_percent = battery['Percentage'] + self.bat_charging = battery['State'] == 1 + self.bat_time = battery['TimeToFull'] if self.bat_charging else battery['TimeToEmpty'] + + return True + + def _start_gpu_update_async(self): + """Starts a new GLib thread to run nvtop in the background.""" + self._gpu_update_running = True + + GLib.Thread.new("nvtop-thread", lambda _: self._run_nvtop_in_thread(), None) + + def _run_nvtop_in_thread(self): + """Runs nvtop via subprocess in a separate GLib thread.""" + output = None + error_message = None + try: + result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=10) + output = result + except FileNotFoundError: + error_message = "nvtop command not found." + logger.warning(error_message) + except subprocess.CalledProcessError as e: + error_message = f"nvtop failed with exit code {e.returncode}: {e.stderr.strip()}" + logger.error(error_message) + except subprocess.TimeoutExpired: + error_message = "nvtop command timed out." + logger.error(error_message) + except Exception as e: + error_message = f"Unexpected error running nvtop: {e}" + logger.error(error_message) + + GLib.idle_add(self._process_gpu_output, output, error_message) + self._gpu_update_running = False + + def _process_gpu_output(self, output, error_message): + """Process nvtop JSON output on the main loop.""" + try: + if error_message: + logger.error(f"GPU update failed: {error_message}") + self.gpu = [] + elif output: + info = json.loads(output) + try: + self.gpu = [ + ( + int(v["gpu_util"].strip("%")) + if v["gpu_util"] is not None + else 0 + ) + for v in info + ] + except (KeyError, ValueError, TypeError) as e: + logger.error(f"Failed parsing nvtop JSON: {e}") + self.gpu = [] + else: + logger.warning("nvtop returned no output.") + self.gpu = [] + except json.JSONDecodeError as e: + logger.error(f"JSON decode error: {e}") + self.gpu = [] + except Exception as e: + logger.error(f"Error processing nvtop output: {e}") + self.gpu = [] + + return False + + def get_metrics(self): + return (self.cpu, self.mem, self.disk, self.gpu) + + def get_battery(self): + return (self.bat_percent, self.bat_charging, self.bat_time) + + def get_gpu_info(self): + try: + result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=5) + return json.loads(result) + except FileNotFoundError: + logger.warning("nvtop not found; GPU info unavailable.") + return [] + except subprocess.CalledProcessError as e: + logger.error(f"nvtop init sync failed: {e}") + return [] + except subprocess.TimeoutExpired: + logger.error("nvtop init call timed out.") + return [] + except json.JSONDecodeError as e: + logger.error(f"Init JSON parse error: {e}") + return [] + except Exception as e: + logger.error(f"Unexpected error during GPU init: {e}") + return [] + +shared_provider = MetricsProvider() + +class SingularMetric: + def __init__(self, id, name, icon): + self.usage = Scale( + name=f"{id}-usage", + value=0.25, + orientation='v', + inverted=True, + v_align='fill', + v_expand=True, + ) + + self.label = Label( + name=f"{id}-label", + markup=icon, + ) + + self.box = Box( + name=f"{id}-box", + orientation='v', + spacing=8, + children=[ + self.usage, + self.label, + ] + ) + + self.box.set_tooltip_markup(f"{icon} {name}") + +class Metrics(Box): + def __init__(self, **kwargs): + super().__init__( + name="metrics", + spacing=8, + h_align="center", + v_align="fill", + visible=True, + all_visible=True, + ) + + visible = getattr(data, "METRICS_VISIBLE", {'cpu': True, 'ram': True, 'disk': True, 'gpu': True}) + disks = [SingularMetric("disk", f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", icons.disk) + for path in data.BAR_METRICS_DISKS] if visible.get('disk', True) else [] + + gpu_info = shared_provider.get_gpu_info() + gpus = [SingularMetric(f"gpu", f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", icons.gpu) + for v in gpu_info] if visible.get('gpu', True) else [] + + self.cpu = SingularMetric("cpu", "CPU", icons.cpu) if visible.get('cpu', True) else None + self.ram = SingularMetric("ram", "RAM", icons.memory) if visible.get('ram', True) else None + self.disk = disks + self.gpu = gpus + + self.scales = [] + if self.disk: self.scales.extend([v.box for v in self.disk]) + if self.ram: self.scales.append(self.ram.box) + if self.cpu: self.scales.append(self.cpu.box) + if self.gpu: self.scales.extend([v.box for v in self.gpu]) + + if self.cpu: self.cpu.usage.set_sensitive(False) + if self.ram: self.ram.usage.set_sensitive(False) + for disk in self.disk: + disk.usage.set_sensitive(False) + for gpu in self.gpu: + gpu.usage.set_sensitive(False) + + for x in self.scales: + self.add(x) + + GLib.timeout_add_seconds(2, self.update_status) + + def update_status(self): + cpu, mem, disks, gpus = shared_provider.get_metrics() + + if self.cpu: + self.cpu.usage.value = cpu / 100.0 + if self.ram: + self.ram.usage.value = mem / 100.0 + for i, disk in enumerate(self.disk): + + if i < len(disks): + disk.usage.value = disks[i] / 100.0 + for i, gpu in enumerate(self.gpu): + + if i < len(gpus): + gpu.usage.value = gpus[i] / 100.0 + return True + +class SingularMetricSmall: + def __init__(self, id, name, icon): + self.name_markup = name + self.icon_markup = icon + + self.icon = Label(name="metrics-icon", markup=icon) + self.circle = CircularProgressBar( + name="metrics-circle", + value=0, + size=28, + line_width=2, + start_angle=150, + end_angle=390, + style_classes=id, + child=self.icon, + ) + + self.level = Label(name="metrics-level", style_classes=id, label="0%") + self.revealer = Revealer( + name=f"metrics-{id}-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.level, + child_revealed=False, + ) + + self.box = Box( + name=f"metrics-{id}-box", + orientation="h", + spacing=0, + children=[self.circle, self.revealer], + ) + + def markup(self): + return f"{self.icon_markup} {self.name_markup}" if not data.VERTICAL else f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}" + +class MetricsSmall(Button): + def __init__(self, **kwargs): + super().__init__(name="metrics-small", **kwargs) + + main_box = Box( + + spacing=0, + orientation="h" if not data.VERTICAL else "v", + visible=True, + all_visible=True, + ) + + visible = getattr(data, "METRICS_SMALL_VISIBLE", {'cpu': True, 'ram': True, 'disk': True, 'gpu': True}) + disks = [SingularMetricSmall("disk", f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", icons.disk) + for path in data.BAR_METRICS_DISKS] if visible.get('disk', True) else [] + + gpu_info = shared_provider.get_gpu_info() + gpus = [SingularMetricSmall(f"gpu", f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", icons.gpu) + for v in gpu_info] if visible.get('gpu', True) else [] + + self.cpu = SingularMetricSmall("cpu", "CPU", icons.cpu) if visible.get('cpu', True) else None + self.ram = SingularMetricSmall("ram", "RAM", icons.memory) if visible.get('ram', True) else None + self.disk = disks + self.gpu = gpus + + for disk in self.disk: + main_box.add(disk.box) + main_box.add(Box(name="metrics-sep")) + if self.ram: + main_box.add(self.ram.box) + main_box.add(Box(name="metrics-sep")) + if self.cpu: + main_box.add(self.cpu.box) + for gpu in self.gpu: + main_box.add(Box(name="metrics-sep")) + main_box.add(gpu.box) + + self.add(main_box) + + self.connect("enter-notify-event", self.on_mouse_enter) + self.connect("leave-notify-event", self.on_mouse_leave) + + GLib.timeout_add_seconds(2, self.update_metrics) + + self.hide_timer = None + self.hover_counter = 0 + + def _format_percentage(self, value: int) -> str: + """Formato natural del porcentaje sin forzar ancho fijo.""" + return f"{value}%" + + def on_mouse_enter(self, widget, event): + if not data.VERTICAL: + self.hover_counter += 1 + if self.hide_timer is not None: + GLib.source_remove(self.hide_timer) + self.hide_timer = None + + if self.cpu: self.cpu.revealer.set_reveal_child(True) + if self.ram: self.ram.revealer.set_reveal_child(True) + for disk in self.disk: + disk.revealer.set_reveal_child(True) + for gpu in self.gpu: + gpu.revealer.set_reveal_child(True) + return False + + def on_mouse_leave(self, widget, event): + if not data.VERTICAL: + if self.hover_counter > 0: + self.hover_counter -= 1 + if self.hover_counter == 0: + if self.hide_timer is not None: + GLib.source_remove(self.hide_timer) + self.hide_timer = GLib.timeout_add(500, self.hide_revealer) + return False + + def hide_revealer(self): + if not data.VERTICAL: + if self.cpu: self.cpu.revealer.set_reveal_child(False) + if self.ram: self.ram.revealer.set_reveal_child(False) + for disk in self.disk: + disk.revealer.set_reveal_child(False) + for gpu in self.gpu: + gpu.revealer.set_reveal_child(False) + self.hide_timer = None + return False + + def update_metrics(self): + cpu, mem, disks, gpus = shared_provider.get_metrics() + + if self.cpu: + self.cpu.circle.set_value(cpu / 100.0) + self.cpu.level.set_label(self._format_percentage(int(cpu))) + if self.ram: + self.ram.circle.set_value(mem / 100.0) + self.ram.level.set_label(self._format_percentage(int(mem))) + for i, disk in enumerate(self.disk): + + if i < len(disks): + disk.circle.set_value(disks[i] / 100.0) + disk.level.set_label(self._format_percentage(int(disks[i]))) + for i, gpu in enumerate(self.gpu): + + if i < len(gpus): + gpu.circle.set_value(gpus[i] / 100.0) + gpu.level.set_label(self._format_percentage(int(gpus[i]))) + + tooltip_metrics = [] + if self.disk: tooltip_metrics.extend(self.disk) + if self.ram: tooltip_metrics.append(self.ram) + if self.cpu: tooltip_metrics.append(self.cpu) + if self.gpu: tooltip_metrics.extend(self.gpu) + self.set_tooltip_markup((" - " if not data.VERTICAL else "\n").join([v.markup() for v in tooltip_metrics])) + + return True + +class Battery(Button): + def __init__(self, **kwargs): + super().__init__(name="metrics-small", **kwargs) + + main_box = Box( + + spacing=0, + orientation="h", + visible=True, + all_visible=True, + ) + + self.bat_icon = Label(name="metrics-icon", markup=icons.battery) + self.bat_circle = CircularProgressBar( + name="metrics-circle", + value=0, + size=28, + line_width=2, + start_angle=150, + end_angle=390, + style_classes="bat", + child=self.bat_icon, + ) + self.bat_level = Label(name="metrics-level", style_classes="bat", label="100%") + self.bat_revealer = Revealer( + name="metrics-bat-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.bat_level, + child_revealed=False, + ) + self.bat_box = Box( + name="metrics-bat-box", + orientation="h", + spacing=0, + children=[self.bat_circle, self.bat_revealer], + ) + + main_box.add(self.bat_box) + + self.add(main_box) + + self.connect("enter-notify-event", self.on_mouse_enter) + self.connect("leave-notify-event", self.on_mouse_leave) + + self.batt_fabricator = Fabricator( + poll_from=lambda v: shared_provider.get_battery(), + on_changed=lambda f, v: self.update_battery, + interval=1000, + stream=False, + default_value=0 + ) + self.batt_fabricator.changed.connect(self.update_battery) + GLib.idle_add(self.update_battery, None, shared_provider.get_battery()) + + self.hide_timer = None + self.hover_counter = 0 + + def _format_percentage(self, value: int) -> str: + """Formato natural del porcentaje sin forzar ancho fijo.""" + return f"{value}%" + + def on_mouse_enter(self, widget, event): + if not data.VERTICAL: + self.hover_counter += 1 + if self.hide_timer is not None: + GLib.source_remove(self.hide_timer) + self.hide_timer = None + + self.bat_revealer.set_reveal_child(True) + return False + + def on_mouse_leave(self, widget, event): + if not data.VERTICAL: + if self.hover_counter > 0: + self.hover_counter -= 1 + if self.hover_counter == 0: + if self.hide_timer is not None: + GLib.source_remove(self.hide_timer) + self.hide_timer = GLib.timeout_add(500, self.hide_revealer) + return False + + def hide_revealer(self): + if not data.VERTICAL: + self.bat_revealer.set_reveal_child(False) + self.hide_timer = None + return False + + def update_battery(self, sender, battery_data): + value, charging, time = battery_data + if value == 0: + self.set_visible(False) + else: + self.set_visible(True) + self.bat_circle.set_value(value / 100) + percentage = int(value) + self.bat_level.set_label(self._format_percentage(percentage)) + + if percentage <= 15: + self.bat_icon.add_style_class("alert") + self.bat_circle.add_style_class("alert") + else: + self.bat_icon.remove_style_class("alert") + self.bat_circle.remove_style_class("alert") + + if time < 60: + time_status = f"{int(time)}sec" + elif time < 60 * 60: + time_status = f"{int(time / 60)}min" + else: + time_status = f"{int(time / 60 / 60)}h" + + if percentage == 100 and charging == False: + self.bat_icon.set_markup(icons.battery) + charging_status = f"{icons.bat_full} Fully Charged - {time_status} left" + elif percentage == 100 and charging == True: + self.bat_icon.set_markup(icons.battery) + charging_status = f"{icons.bat_full} Fully Charged" + elif charging == True: + self.bat_icon.set_markup(icons.charging) + charging_status = f"{icons.bat_charging} Charging - {time_status} left" + elif percentage <= 15 and charging == False: + self.bat_icon.set_markup(icons.alert) + charging_status = f"{icons.bat_low} Low Battery - {time_status} left" + elif charging == False: + self.bat_icon.set_markup(icons.discharging) + charging_status = f"{icons.bat_discharging} Discharging - {time_status} left" + else: + self.bat_icon.set_markup(icons.battery) + charging_status = "Battery" + + self.set_tooltip_markup(f"{charging_status}" if not data.VERTICAL else f"{charging_status}: {percentage}%") + +class NetworkApplet(Button): + def __init__(self, **kwargs): + super().__init__(name="button-bar", **kwargs) + self.download_label = Label(name="download-label", markup="Download: 0 B/s") + self.network_client = NetworkClient() + self.upload_label = Label(name="upload-label", markup="Upload: 0 B/s") + self.wifi_label = Label(name="network-icon-label", markup="WiFi: Unknown") + + self.is_mouse_over = False + self.downloading = False + self.uploading = False + + self.download_icon = Label(name="download-icon-label", markup=icons.download, v_align="center", h_align="center", h_expand=True, v_expand=True) + self.upload_icon = Label(name="upload-icon-label", markup=icons.upload, v_align="center", h_align="center", h_expand=True, v_expand=True) + + self.download_box = Box( + children=[self.download_icon, self.download_label], + ) + + self.upload_box = Box( + children=[self.upload_label, self.upload_icon], + ) + + self.download_revealer = Revealer(child=self.download_box, transition_type = "slide-right" if not data.VERTICAL else "slide-down", child_revealed=False) + self.upload_revealer = Revealer(child=self.upload_box, transition_type="slide-left" if not data.VERTICAL else "slide-up",child_revealed=False) + + self.children = Box( + orientation="h" if not data.VERTICAL else "v", + children=[self.upload_revealer, self.wifi_label, self.download_revealer], + ) + + if data.VERTICAL: + self.download_label.set_visible(False) + self.upload_label.set_visible(False) + self.upload_icon.set_margin_top(4) + self.download_icon.set_margin_bottom(4) + + self.last_counters = psutil.net_io_counters() + self.last_time = time.time() + invoke_repeater(1000, self.update_network) + + self.connect("enter-notify-event", self.on_mouse_enter) + self.connect("leave-notify-event", self.on_mouse_leave) + + def update_network(self): + current_time = time.time() + elapsed = current_time - self.last_time + current_counters = psutil.net_io_counters() + download_speed = (current_counters.bytes_recv - self.last_counters.bytes_recv) / elapsed + upload_speed = (current_counters.bytes_sent - self.last_counters.bytes_sent) / elapsed + download_str = self.format_speed(download_speed) + upload_str = self.format_speed(upload_speed) + self.download_label.set_markup(download_str) + self.upload_label.set_markup(upload_str) + + self.downloading = (download_speed >= 10e6) + self.uploading = (upload_speed >= 2e6) + + if not self.is_mouse_over: + if self.downloading: + self.download_urgent() + elif self.uploading: + self.upload_urgent() + else: + self.remove_urgent() + + show_download = self.downloading or (self.is_mouse_over and not data.VERTICAL) + show_upload = self.uploading or (self.is_mouse_over and not data.VERTICAL) + self.download_revealer.set_reveal_child(show_download) + self.upload_revealer.set_reveal_child(show_upload) + + primary_device = None + if self.network_client: + primary_device = self.network_client.primary_device + + tooltip_base = "" + tooltip_vertical = "" + + if primary_device == "wired" and self.network_client.ethernet_device: + ethernet_state = self.network_client.ethernet_device.internet + + if ethernet_state == "activated": + self.wifi_label.set_markup(icons.world) + elif ethernet_state == "activating": + self.wifi_label.set_markup(icons.world) + else: + self.wifi_label.set_markup(icons.world_off) + + tooltip_base = "Ethernet Connection" + tooltip_vertical = f"SSID: Ethernet\nUpload: {upload_str}\nDownload: {download_str}" + + elif self.network_client and self.network_client.wifi_device: + if self.network_client.wifi_device.ssid != "Disconnected": + strength = self.network_client.wifi_device.strength + + if strength >= 75: + self.wifi_label.set_markup(icons.wifi_3) + elif strength >= 50: + self.wifi_label.set_markup(icons.wifi_2) + elif strength >= 25: + self.wifi_label.set_markup(icons.wifi_1) + else: + self.wifi_label.set_markup(icons.wifi_0) + + tooltip_base = self.network_client.wifi_device.ssid + tooltip_vertical = f"SSID: {self.network_client.wifi_device.ssid}\nUpload: {upload_str}\nDownload: {download_str}" + else: + self.wifi_label.set_markup(icons.world_off) + tooltip_base = "Disconnected" + tooltip_vertical = f"SSID: Disconnected\nUpload: {upload_str}\nDownload: {download_str}" + else: + self.wifi_label.set_markup(icons.world_off) + tooltip_base = "Disconnected" + tooltip_vertical = f"SSID: Disconnected\nUpload: {upload_str}\nDownload: {download_str}" + + if data.VERTICAL: + self.set_tooltip_text(tooltip_vertical) + else: + self.set_tooltip_text(tooltip_base) + + self.last_counters = current_counters + self.last_time = current_time + return True + + def format_speed(self, speed): + if speed < 1024: + return f"{speed:.0f} B/s" + elif speed < 1024 * 1024: + return f"{speed / 1024:.1f} KB/s" + else: + return f"{speed / (1024 * 1024):.1f} MB/s" + + def on_mouse_enter(self, *_): + self.is_mouse_over = True + if not data.VERTICAL: + + self.download_revealer.set_reveal_child(True) + self.upload_revealer.set_reveal_child(True) + return + + def on_mouse_leave(self, *_): + self.is_mouse_over = False + if not data.VERTICAL: + + self.download_revealer.set_reveal_child(self.downloading) + self.upload_revealer.set_reveal_child(self.uploading) + + if self.downloading: + self.download_urgent() + elif self.uploading: + self.upload_urgent() + else: + self.remove_urgent() + return + + def upload_urgent(self): + self.add_style_class("upload") + self.wifi_label.add_style_class("urgent") + self.upload_label.add_style_class("urgent") + self.upload_icon.add_style_class("urgent") + self.download_icon.add_style_class("urgent") + self.download_label.add_style_class("urgent") + self.upload_revealer.set_reveal_child(True) + self.download_revealer.set_reveal_child(self.downloading) + return + + def download_urgent(self): + self.add_style_class("download") + self.wifi_label.add_style_class("urgent") + self.download_label.add_style_class("urgent") + self.download_icon.add_style_class("urgent") + self.upload_icon.add_style_class("urgent") + self.upload_label.add_style_class("urgent") + self.download_revealer.set_reveal_child(True) + self.upload_revealer.set_reveal_child(self.uploading) + return + + def remove_urgent(self): + self.remove_style_class("download") + self.remove_style_class("upload") + self.wifi_label.remove_style_class("urgent") + self.download_label.remove_style_class("urgent") + self.upload_label.remove_style_class("urgent") + self.download_icon.remove_style_class("urgent") + self.upload_icon.remove_style_class("urgent") + return diff --git a/Ax-Shell/modules/mixer.py b/Ax-Shell/modules/mixer.py new file mode 100644 index 0000000..0feb56c --- /dev/null +++ b/Ax-Shell/modules/mixer.py @@ -0,0 +1,233 @@ +import math + +import gi +from fabric.audio.service import Audio +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.scale import Scale +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import GLib + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import config.data as data + +vertical_mode = ( + True + if data.PANEL_THEME == "Panel" + and ( + data.BAR_POSITION in ["Left", "Right"] + or data.PANEL_POSITION in ["Start", "End"] + ) + else False +) + + +class MixerSlider(Scale): + def __init__(self, stream, **kwargs): + super().__init__( + name="control-slider", + orientation="h", + h_expand=True, + h_align="fill", + has_origin=True, + increments=(0.01, 0.1), + style_classes=["no-icon"], + **kwargs, + ) + + self.stream = stream + self._updating_from_stream = False + self.set_value(stream.volume / 100) + self.set_size_request(-1, 30) # Fixed height for sliders + + self.connect("value-changed", self.on_value_changed) + stream.connect("changed", self.on_stream_changed) + + # Apply appropriate style class based on stream type + if hasattr(stream, "type"): + if "microphone" in stream.type.lower() or "input" in stream.type.lower(): + self.add_style_class("mic") + else: + self.add_style_class("vol") + else: + # Default to volume style + self.add_style_class("vol") + + # Set initial tooltip and muted state + self.set_tooltip_text(f"{stream.volume:.0f}%") + self.update_muted_state() + + def on_value_changed(self, _): + if self._updating_from_stream: + return + if self.stream: + self.stream.volume = self.value * 100 + self.set_tooltip_text(f"{self.value * 100:.0f}%") + + def on_stream_changed(self, stream): + self._updating_from_stream = True + self.value = stream.volume / 100 + self.set_tooltip_text(f"{stream.volume:.0f}%") + self.update_muted_state() + self._updating_from_stream = False + + def update_muted_state(self): + if self.stream.muted: + self.add_style_class("muted") + else: + self.remove_style_class("muted") + + +class MixerSection(Box): + def __init__(self, title, **kwargs): + super().__init__( + name="mixer-section", + orientation="v", + spacing=8, + h_expand=True, + v_expand=False, # Prevent vertical stretching + ) + + self.title_label = Label( + name="mixer-section-title", + label=title, + h_expand=True, + h_align="fill", + ) + + self.content_box = Box( + name="mixer-content", + orientation="v", + spacing=8, + h_expand=True, + v_expand=False, # Prevent vertical stretching + ) + + self.add(self.title_label) + self.add(self.content_box) + + def update_streams(self, streams): + for child in self.content_box.get_children(): + self.content_box.remove(child) + + for stream in streams: + label_text = stream.description + if hasattr(stream, "type") and "application" in stream.type.lower(): + label_text = getattr(stream, "name", stream.description) + + stream_container = Box( + orientation="v", + spacing=4, + h_expand=True, + v_expand=False, # Prevent vertical stretching + ) + + label = Label( + name="mixer-stream-label", + label=f"[{math.ceil(stream.volume)}%] {stream.description}", + h_expand=True, + h_align="start", + v_align="center", + ellipsization="end", + max_chars_width=45, + height_request=20, # Fixed height for labels + ) + + slider = MixerSlider(stream) + + stream_container.add(label) + stream_container.add(slider) + self.content_box.add(stream_container) + + self.content_box.show_all() + + +class Mixer(Box): + def __init__(self, **kwargs): + super().__init__( + name="mixer", + orientation="v", + spacing=8, + h_expand=True, + v_expand=True, # Allow Mixer to expand to parent height + ) + + try: + self.audio = Audio() + except Exception as e: + error_label = Label( + label=f"Audio service unavailable: {str(e)}", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.add(error_label) + return + + self.main_container = Box( + orientation="h" if not vertical_mode else "v", + spacing=8, + h_expand=True, + v_expand=True, # Allow main_container to expand + ) + self.main_container.set_homogeneous(True) # Equal sizing for outputs and inputs + + # ScrolledWindow for Outputs + self.outputs_scrolled = ScrolledWindow( + name="outputs-scrolled", + h_expand=True, + v_expand=False, # Prevent vertical expansion + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, # Vertical scrollbar when needed + hscrollbar_policy=Gtk.PolicyType.NEVER, # Disable horizontal scrollbar + ) + self.outputs_section = MixerSection("Outputs") + self.outputs_scrolled.add(self.outputs_section) + self.outputs_scrolled.set_size_request(-1, 150) # Fixed height of 150px + self.outputs_scrolled.set_max_content_height(150) # Enforce max height + + # ScrolledWindow for Inputs + self.inputs_scrolled = ScrolledWindow( + name="inputs-scrolled", + h_expand=True, + v_expand=False, # Prevent vertical expansion + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, # Vertical scrollbar when needed + hscrollbar_policy=Gtk.PolicyType.NEVER, # Disable horizontal scrollbar + ) + self.inputs_section = MixerSection("Inputs") + self.inputs_scrolled.add(self.inputs_section) + self.inputs_scrolled.set_size_request(-1, 150) # Fixed height of 150px + self.inputs_scrolled.set_max_content_height(150) # Enforce max height + + self.main_container.add(self.outputs_scrolled) + self.main_container.add(self.inputs_scrolled) + + self.add(self.main_container) + self.set_size_request(-1, 300) # Optional: Set total height to 300px (150px per section) + + self.audio.connect("changed", self.on_audio_changed) + self.audio.connect("stream-added", self.on_audio_changed) + self.audio.connect("stream-removed", self.on_audio_changed) + + self.update_mixer() + self.show_all() + + def on_audio_changed(self, *args): + self.update_mixer() + + def update_mixer(self): + outputs = [] + inputs = [] + + if self.audio.speaker: + outputs.append(self.audio.speaker) + outputs.extend(self.audio.applications) + + if self.audio.microphone: + inputs.append(self.audio.microphone) + inputs.extend(self.audio.recorders) + + self.outputs_section.update_streams(outputs) + self.inputs_section.update_streams(inputs) diff --git a/Ax-Shell/modules/network.py b/Ax-Shell/modules/network.py new file mode 100644 index 0000000..d93458e --- /dev/null +++ b/Ax-Shell/modules/network.py @@ -0,0 +1,194 @@ +import gi + +gi.require_version('Gtk', '3.0') +gi.require_version('NM', '1.0') +from fabric.utils import bulk_connect +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.scrolledwindow import ScrolledWindow +from gi.repository import NM, GLib, Gtk + +import modules.icons as icons +from services.network import NetworkClient + + +class WifiAccessPointSlot(CenterBox): + def __init__(self, ap_data: dict, network_service: NetworkClient, wifi_service, **kwargs): + super().__init__(name="wifi-ap-slot", **kwargs) + self.ap_data = ap_data + self.network_service = network_service + self.wifi_service = wifi_service + + ssid = ap_data.get("ssid", "Unknown SSID") + icon_name = ap_data.get("icon-name", "network-wireless-signal-none-symbolic") + + self.is_active = False + active_ap_details = ap_data.get("active-ap") + if active_ap_details and hasattr(active_ap_details, 'get_bssid') and active_ap_details.get_bssid() == ap_data.get("bssid"): + self.is_active = True + + self.ap_icon = Image(icon_name=icon_name, size=24) + self.ap_label = Label(label=ssid, h_expand=True, h_align="start", ellipsization="end") + + self.connect_button = Button( + name="wifi-connect-button", + label="Connected" if self.is_active else "Connect", + sensitive=not self.is_active, + on_clicked=self._on_connect_clicked, + style_classes=["connected"] if self.is_active else None, + ) + + self.set_start_children([ + Box(spacing=8, h_expand=True, h_align="fill", children=[ + self.ap_icon, + self.ap_label, + ]) + ]) + self.set_end_children([self.connect_button]) + + def _on_connect_clicked(self, _): + if not self.is_active and self.ap_data.get("bssid"): + self.connect_button.set_label("Connecting...") + self.connect_button.set_sensitive(False) + self.network_service.connect_wifi_bssid(self.ap_data["bssid"]) + + +class NetworkConnections(Box): + def __init__(self, **kwargs): + super().__init__( + name="network-connections", + orientation="vertical", + spacing=4, + **kwargs, + ) + self.widgets = kwargs.get("widgets") + self.network_client = NetworkClient() + + self.status_label = Label(label="Initializing Wi-Fi...", h_expand=True, h_align="center") + + self.back_button = Button( + name="network-back", + child=Label(name="network-back-label", markup=icons.chevron_left), + on_clicked=lambda *_: self.widgets.show_notif() + ) + + + self.wifi_toggle_button_icon = Label(markup=icons.wifi_3) + self.wifi_toggle_button = Button( + name="wifi-toggle-button", + child=self.wifi_toggle_button_icon, + tooltip_text="Toggle Wi-Fi", + on_clicked=self._toggle_wifi + ) + + + self.refresh_button_icon = Label(name="network-refresh-label", markup=icons.reload) + self.refresh_button = Button( + name="network-refresh", + child=self.refresh_button_icon, + tooltip_text="Scan for Wi-Fi networks", + on_clicked=self._refresh_access_points + ) + + header_box = CenterBox( + name="network-header", + start_children=[self.back_button], + center_children=[Label(name="network-title", label="Wi-Fi Networks")], + end_children=[Box(orientation="horizontal", spacing=4, children=[self.refresh_button])] + ) + + self.ap_list_box = Box(orientation="vertical", spacing=4) + scrolled_window = ScrolledWindow( + name="network-ap-scrolled-window", + child=self.ap_list_box, + h_expand=True, + v_expand=True, + propagate_width=False, + propagate_height=False, + ) + + self.add(header_box) + self.add(self.status_label) + self.add(scrolled_window) + + self.network_client.connect("device-ready", self._on_device_ready) + self.wifi_toggle_button.set_sensitive(False) + self.refresh_button.set_sensitive(False) + + def _on_device_ready(self, _client): + + if self.network_client.wifi_device: + self.network_client.wifi_device.connect("changed", self._load_access_points) + self.network_client.wifi_device.connect("notify::enabled", self._update_wifi_status_ui) + self._update_wifi_status_ui() + if self.network_client.wifi_device.enabled: + self._load_access_points() + else: + self.status_label.set_label("Wi-Fi disabled.") + self.status_label.set_visible(True) + else: + self.status_label.set_label("Wi-Fi device not available.") + self.status_label.set_visible(True) + self.wifi_toggle_button.set_sensitive(False) + self.refresh_button.set_sensitive(False) + + def _update_wifi_status_ui(self, *args): + if self.network_client.wifi_device: + enabled = self.network_client.wifi_device.enabled + self.wifi_toggle_button.set_sensitive(True) + self.refresh_button.set_sensitive(enabled) + + if enabled: + self.wifi_toggle_button_icon.set_markup(icons.wifi_3) + else: + self.wifi_toggle_button_icon.set_markup(icons.wifi_off) + self.status_label.set_label("Wi-Fi disabled.") + self.status_label.set_visible(True) + self._clear_ap_list() + + if enabled and not self.ap_list_box.get_children(): + GLib.idle_add(self._refresh_access_points) + else: + self.wifi_toggle_button.set_sensitive(False) + self.refresh_button.set_sensitive(False) + + def _toggle_wifi(self, _): + if self.network_client.wifi_device: + self.network_client.wifi_device.toggle_wifi() + + def _refresh_access_points(self, _=None): + if self.network_client.wifi_device and self.network_client.wifi_device.enabled: + self.status_label.set_label("Scanning for Wi-Fi networks...") + self.status_label.set_visible(True) + self._clear_ap_list() + self.network_client.wifi_device.scan() + return False + + def _clear_ap_list(self): + for child in self.ap_list_box.get_children(): + child.destroy() + + def _load_access_points(self, *args): + if not self.network_client.wifi_device or not self.network_client.wifi_device.enabled: + self._clear_ap_list() + self.status_label.set_label("Wi-Fi disabled.") + self.status_label.set_visible(True) + return + + self._clear_ap_list() + + access_points = self.network_client.wifi_device.access_points + + if not access_points: + self.status_label.set_label("No Wi-Fi networks found.") + self.status_label.set_visible(True) + else: + self.status_label.set_visible(False) + sorted_aps = sorted(access_points, key=lambda x: x.get("strength", 0), reverse=True) + for ap_data in sorted_aps: + slot = WifiAccessPointSlot(ap_data, self.network_client, self.network_client.wifi_device) + self.ap_list_box.add(slot) + self.ap_list_box.show_all() diff --git a/Ax-Shell/modules/notch.py b/Ax-Shell/modules/notch.py new file mode 100644 index 0000000..10ded5b --- /dev/null +++ b/Ax-Shell/modules/notch.py @@ -0,0 +1,1447 @@ +from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow +from fabric.utils.helpers import FormattedString, get_desktop_applications +from fabric.widgets.box import Box +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.stack import Stack +from fabric.audio.service import Audio +from gi.repository import Gdk, GLib, Gtk, Pango + +import config.data as data +from modules.cliphist import ClipHistory +from modules.corners import MyCorner +from modules.dashboard import Dashboard +from modules.emoji import EmojiPicker +from modules.launcher import AppLauncher +from modules.overview import Overview +from modules.player import PlayerSmall +from modules.power import PowerMenu +from modules.tmux import TmuxManager +from modules.tools import Toolbox +from utils.icon_resolver import IconResolver +from utils.occlusion import check_occlusion +from widgets.wayland import WaylandWindow as Window + + +class Notch(Window): + def __init__(self, monitor_id: int = 0, **kwargs): + self.monitor_id = monitor_id + self.monitor_manager = None + + # Get monitor manager + try: + from utils.monitor_manager import get_monitor_manager + self.monitor_manager = get_monitor_manager() + except ImportError: + pass + is_panel_vertical = False + if data.PANEL_THEME == "Panel": + is_panel_vertical = data.VERTICAL + + anchor_val = "top" + revealer_transition_type = "slide-down" + + if data.PANEL_THEME == "Notch": + anchor_val = "top" + revealer_transition_type = "slide-down" + elif data.PANEL_THEME == "Panel": + if is_panel_vertical: + if data.BAR_POSITION == "Left": + match data.PANEL_POSITION: + case "Start": + anchor_val = "left top" + revealer_transition_type = "slide-right" + case "Center": + anchor_val = "left" + revealer_transition_type = "slide-right" + case "End": + anchor_val = "left bottom" + revealer_transition_type = "slide-right" + case _: + anchor_val = "left" + revealer_transition_type = "slide-right" + elif data.BAR_POSITION == "Right": + match data.PANEL_POSITION: + case "Start": + anchor_val = "right top" + revealer_transition_type = "slide-left" + case "Center": + anchor_val = "right" + revealer_transition_type = "slide-left" + case "End": + anchor_val = "right bottom" + revealer_transition_type = "slide-left" + case _: + anchor_val = "right" + revealer_transition_type = "slide-left" + else: + if data.BAR_POSITION == "Top": + match data.PANEL_POSITION: + case "Start": + anchor_val = "top left" + revealer_transition_type = "slide-down" + case "Center": + anchor_val = "top" + revealer_transition_type = "slide-down" + case "End": + anchor_val = "top right" + revealer_transition_type = "slide-down" + case _: + anchor_val = "top" + revealer_transition_type = "slide-down" + elif data.BAR_POSITION == "Bottom": + match data.PANEL_POSITION: + case "Start": + anchor_val = "bottom left" + revealer_transition_type = "slide-up" + case "Center": + anchor_val = "bottom" + revealer_transition_type = "slide-up" + case "End": + anchor_val = "bottom right" + revealer_transition_type = "slide-up" + case _: + anchor_val = "bottom" + revealer_transition_type = "slide-up" + + default_top_anchor_margin_str = "-40px 8px 8px 8px" + pills_margin_top_str = "-40px 0px 0px 0px" + dense_edge_margin_top_str = "-46px 0px 0px 0px" + current_margin_str = "" + + if data.PANEL_THEME == "Panel": + current_margin_str = "0px 0px 0px 0px" + else: + if data.VERTICAL: + current_margin_str = "0px 0px 0px 0px" + else: + if data.BAR_POSITION == "Bottom": + current_margin_str = "0px 0px 0px 0px" + else: + match data.BAR_THEME: + case "Pills": + current_margin_str = pills_margin_top_str + case "Dense" | "Edge": + current_margin_str = dense_edge_margin_top_str + case _: + current_margin_str = default_top_anchor_margin_str + + super().__init__( + name="notch", + layer="overlay", + anchor=anchor_val, + margin=current_margin_str, + keyboard_mode="none", + exclusivity="none" if data.PANEL_THEME == "Notch" else "normal", + visible=True, + all_visible=True, + monitor=monitor_id, + ) + + # Audio display variables + self.VOLUME_DISPLAY_DURATION = 2000 + self._current_display_timeout_id = None + self._suppress_first_audio_display = True + + self._typed_chars_buffer = "" + self._launcher_transitioning = False + self._launcher_transition_timeout = None + + self.bar = kwargs.get("bar", None) + self.is_hovered = False + self.connect("realize", self._on_realize) + self._prevent_occlusion = False + self._occlusion_timer_id = None + self._forced_occlusion = False + + self.icon_resolver = IconResolver() + self._all_apps = get_desktop_applications() + self.app_identifiers = self._build_app_identifiers_map() + + self.dashboard = Dashboard(notch=self) + self.nhistory = self.dashboard.widgets.notification_history + + self.applet_stack = self.dashboard.widgets.applet_stack + self.btdevices = self.dashboard.widgets.bluetooth + self.nwconnections = self.dashboard.widgets.network_connections + + self.btdevices.set_visible(False) + self.nwconnections.set_visible(False) + + self.launcher = AppLauncher(notch=self) + self.overview = Overview(monitor_id=monitor_id) + self.emoji = EmojiPicker(notch=self) + self.power = PowerMenu(notch=self) + self.tmux = TmuxManager(notch=self) + self.cliphist = ClipHistory(notch=self) + + # Audio service initialization + self.audio = Audio() + + # Volume display widgets + self.volume_icon = Image( + name="volume-display-icon", + icon_name="audio-volume-high-symbolic", + icon_size=16 + ) + self.volume_icon.set_valign(Gtk.Align.CENTER) + + self.volume_label = Label( + name="volume-display-label", + label="..." + ) + self.volume_label.set_valign(Gtk.Align.CENTER) + + self.volume_bar = Gtk.ProgressBar( + name="volume-display-bar" + ) + self.volume_bar.set_fraction(1.0) + self.volume_bar.set_show_text(False) + self.volume_bar.set_hexpand(False) + self.volume_bar.set_valign(Gtk.Align.CENTER) + + self.volume_box = Box( + name="volume-display-box", + orientation="h", + spacing=8, + h_align="center", + v_align="center", + children=[ + self.volume_icon, + self.volume_bar, + self.volume_label + ] + ) + + # Microphone display widgets + self.mic_icon = Image( + name="mic-display-icon", + icon_name="microphone-sensitivity-high-symbolic", + icon_size=16 + ) + self.mic_icon.set_valign(Gtk.Align.CENTER) + + self.mic_label = Label( + name="mic-display-label", + label="..." + ) + self.mic_label.set_valign(Gtk.Align.CENTER) + + self.mic_bar = Gtk.ProgressBar( + name="mic-display-bar" + ) + self.mic_bar.set_fraction(1.0) + self.mic_bar.set_show_text(False) + self.mic_bar.set_valign(Gtk.Align.CENTER) + + self.mic_box = Box( + name="mic-display-box", + orientation="h", + spacing=8, + h_align="center", + v_align="center", + children=[ + self.mic_icon, + self.mic_bar, + self.mic_label + ] + ) + + self.window_label = Label( + name="notch-window-label", + h_expand=True, + h_align="fill", + ) + + self.window_icon = Image( + name="notch-window-icon", icon_name="application-x-executable", icon_size=20 + ) + + self.active_window = ActiveWindow( + name="hyprland-window", + h_expand=True, + h_align="fill", + formatter=FormattedString( + f"{{'Desktop' if not win_title or win_title == 'unknown' else win_title}}", + ), + ) + + self.active_window_box = CenterBox( + name="active-window-box", + h_expand=True, + h_align="fill", + start_children=self.window_icon, + center_children=self.active_window, + end_children=None, + ) + + self.active_window_box.connect( + "button-press-event", + lambda widget, event: (self.open_notch("dashboard"), False)[1], + ) + + self.active_window.connect("notify::label", self.update_window_icon) + + if data.PANEL_THEME == "Notch": + self.active_window.connect("notify::label", self.on_active_window_changed) + + self.active_window.get_children()[0].set_hexpand(True) + self.active_window.get_children()[0].set_halign(Gtk.Align.FILL) + self.active_window.get_children()[0].set_ellipsize(Pango.EllipsizeMode.END) + + self.active_window.connect( + "notify::label", lambda *_: self.restore_label_properties() + ) + + self.player_small = PlayerSmall() + self.user_label = Label( + name="compact-user", label=f"{data.USERNAME}@{data.HOSTNAME}" + ) + + self.player_small.mpris_manager.connect( + "player-appeared", + lambda *_: self.compact_stack.set_visible_child(self.player_small), + ) + self.player_small.mpris_manager.connect( + "player-vanished", self.on_player_vanished + ) + + self.compact_stack = Stack( + name="notch-compact-stack", + v_expand=True, + h_expand=True, + transition_type="slide-up-down", + transition_duration=100, + children=[ + self.user_label, + self.active_window_box, + self.player_small, + # Add audio display widgets to compact stack + self.volume_box, + self.mic_box, + ], + ) + self.compact_stack.set_visible_child(self.active_window_box) + + self.compact = Gtk.EventBox(name="notch-compact") + self.compact.set_visible(True) + self.compact.add(self.compact_stack) + self.compact.add_events( + Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.SMOOTH_SCROLL_MASK + ) + self.compact.connect("scroll-event", self._on_compact_scroll) + self.compact.connect( + "button-press-event", + lambda widget, event: (self.open_notch("dashboard"), False)[1], + ) + self.compact.connect("enter-notify-event", self.on_button_enter) + self.compact.connect("leave-notify-event", self.on_button_leave) + + self.tools = Toolbox(notch=self) + self.stack = Stack( + name="notch-content", + v_expand=True, + h_expand=True, + style_classes=["invert"] + if (not data.VERTICAL and data.BAR_THEME in ["Dense", "Edge"]) + and data.BAR_POSITION not in ["Bottom"] + else [], + transition_type="crossfade", + transition_duration=250, + children=[ + self.compact, + self.launcher, + self.dashboard, + self.overview, + self.emoji, + self.power, + self.tools, + self.tmux, + self.cliphist, + ], + ) + + if data.PANEL_THEME == "Panel": + self.stack.add_style_class("panel") + + self.stack.add_style_class(data.BAR_POSITION.lower()) + self.stack.add_style_class(data.PANEL_POSITION.lower()) + + if is_panel_vertical or ( + data.PANEL_POSITION in ["Start", "End"] and data.PANEL_THEME == "Panel" + ): + self.compact.set_size_request(260, 40) + self.launcher.set_size_request(320, 635) + self.tmux.set_size_request(320, 635) + self.cliphist.set_size_request(320, 635) + self.dashboard.set_size_request(410, 900) + + else: + self.compact.set_size_request(260, 40) + self.launcher.set_size_request(480, 244) + self.tmux.set_size_request(480, 244) + self.cliphist.set_size_request(480, 244) + self.dashboard.set_size_request(1093, 472) + + self.stack.set_interpolate_size(True) + self.stack.set_homogeneous(False) + + self.corner_left = Box( + name="notch-corner-left", + orientation="v", + h_align="start", + children=[MyCorner("top-right")], + ) + + self.corner_right = Box( + name="notch-corner-right", + orientation="v", + h_align="end", + children=[MyCorner("top-left")], + ) + + self.notch_box = CenterBox( + name="notch-box", + orientation="h", + h_align="center", + v_align="center", + start_children=self.corner_left, + center_children=self.stack, + end_children=self.corner_right, + ) + + self.notch_box.add_style_class(data.PANEL_THEME.lower()) + + self.notch_revealer = Revealer( + name="notch-revealer", + transition_type=revealer_transition_type, + transition_duration=250, + child_revealed=True, + child=self.notch_box, + ) + + self.notch_revealer.set_size_request(-1, 1) + + self.notch_complete = Box( + name="notch-complete", + orientation="v" if is_panel_vertical else "h", + children=[self.notch_revealer], + ) + + self._is_notch_open = False + self._scrolling = False + + if data.VERTICAL: + vert_comp_size = { + "Pills": 38, + "Dense": 50, + "Edge": 44, + }.get(data.BAR_THEME, 38) + + if is_panel_vertical: + vert_comp_size = 1 + + self.vert_comp_left = Box(name="vert-comp") + self.vert_comp_left.set_size_request(vert_comp_size, 0) + self.vert_comp_left.set_sensitive(False) + + self.vert_comp_right = Box(name="vert-comp") + self.vert_comp_right.set_size_request(vert_comp_size, 0) + self.vert_comp_right.set_sensitive(False) + + self.notch_children = [ + self.vert_comp_left, + self.notch_complete, + self.vert_comp_right, + ] + else: + self.notch_children = [self.notch_complete] + + self.notch_wrap = Box( + name="notch-wrap", + children=self.notch_children, + ) + + # Create top-level EventBox that wraps the entire notch for hover detection + if data.PANEL_THEME == "Notch": + self.hover_eventbox = Gtk.EventBox(name="notch-hover-eventbox") + self.hover_eventbox.add(self.notch_wrap) + self.hover_eventbox.set_visible(True) + # Set minimum size to ensure hover detection area is always available + self.hover_eventbox.set_size_request(260, 4) # Width matches compact size, min height for hover + self.hover_eventbox.add_events( + Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK + ) + self.hover_eventbox.connect( + "enter-notify-event", self.on_notch_hover_area_enter + ) + self.hover_eventbox.connect( + "leave-notify-event", self.on_notch_hover_area_leave + ) + self.add(self.hover_eventbox) + else: + self.add(self.notch_wrap) + self.show_all() + + # Connect audio signals after a short delay + GLib.timeout_add(100, self._connect_audio_signals) + + self.add_keybinding("Escape", lambda *_: self.close_notch()) + self.add_keybinding("Ctrl Tab", lambda *_: self.dashboard.go_to_next_child()) + self.add_keybinding( + "Ctrl Shift ISO_Left_Tab", lambda *_: self.dashboard.go_to_previous_child() + ) + + self.update_window_icon() + + self.active_window.connect( + "button-press-event", + lambda widget, event: (self.open_notch("dashboard"), False)[1], + ) + + if data.PANEL_THEME != "Notch": + for corner in [self.corner_left, self.corner_right]: + corner.set_visible(False) + + self._current_window_class = self._get_current_window_class() + + # Always enable occlusion detection for fullscreen windows + GLib.timeout_add(500, self._check_occlusion) + + if data.PANEL_THEME == "Notch": + self.notch_revealer.set_reveal_child(True) + else: + self.notch_revealer.set_reveal_child(False) + + self.connect("key-press-event", self.on_key_press) + + # Audio-related methods + def _connect_audio_signals(self, retry_count=0): + max_retries = 5 + + try: + if self.audio: + self.audio.connect("notify::speaker", self._on_speaker_changed) + self.audio.connect("notify::microphone", self._on_microphone_changed) + + if self.audio.speaker: + self.audio.speaker.connect("changed", self._on_speaker_changed_signal) + GLib.idle_add(self._update_volume_widgets_silently) + + if self.audio.microphone: + self.audio.microphone.connect("changed", self._on_microphone_changed_signal) + GLib.idle_add(self._update_mic_widgets_silently) + + GLib.timeout_add(500, self._enable_audio_display) + return False + + except Exception as e: + print(f"Audio connection error (attempt {retry_count + 1}): {e}") + + if retry_count < max_retries - 1: + GLib.timeout_add(1000, lambda: self._connect_audio_signals(retry_count + 1)) + + return False + + def _on_speaker_changed(self, audio_service, speaker): + if self.audio.speaker: + try: + self.audio.speaker.disconnect_by_func(self._on_speaker_changed_signal) + except: + pass + self.audio.speaker.connect("changed", self._on_speaker_changed_signal) + self._update_volume_widgets_silently() + + def _on_microphone_changed(self, audio_service, microphone): + if self.audio.microphone: + try: + self.audio.microphone.disconnect_by_func(self._on_microphone_changed_signal) + except: + pass + self.audio.microphone.connect("changed", self._on_microphone_changed_signal) + self._update_mic_widgets_silently() + + def _on_speaker_changed_signal(self, speaker, *args): + self._handle_speaker_change() + + def _on_microphone_changed_signal(self, microphone, *args): + self._handle_microphone_change() + + def _handle_speaker_change(self): + if not self.audio or not self.audio.speaker: + return + + if self._suppress_first_audio_display: + self._update_volume_widgets_silently() + return + + speaker = self.audio.speaker + volume = speaker.volume + is_muted = speaker.muted + + volume_int = int(round(volume)) + volume_percentage = volume_int / 100.0 + self.volume_bar.set_fraction(volume_percentage) + + self._update_volume_appearance(volume_int, is_muted) + + if is_muted: + self.volume_icon.set_from_icon_name("audio-volume-muted-symbolic", 16) + self.volume_label.set_text("Muted") + elif volume_int == 0: + self.volume_icon.set_from_icon_name("audio-volume-muted-symbolic", 16) + self.volume_label.set_text("Muted") + else: + if volume_int <= 33: + icon_name = "audio-volume-low-symbolic" + elif volume_int <= 66: + icon_name = "audio-volume-medium-symbolic" + else: + icon_name = "audio-volume-high-symbolic" + + self.volume_icon.set_from_icon_name(icon_name, 16) + self.volume_label.set_text(f"{volume_int}%") + + if not self._is_notch_open: + self.show_volume_display() + + def _handle_microphone_change(self): + if not self.audio or not self.audio.microphone: + return + + if self._suppress_first_audio_display: + self._update_mic_widgets_silently() + return + + microphone = self.audio.microphone + volume = microphone.volume + is_muted = microphone.muted + + volume_int = int(round(volume)) + volume_percentage = volume_int / 100.0 + self.mic_bar.set_fraction(volume_percentage) + + self._update_mic_appearance(volume_int, is_muted) + + if is_muted: + self.mic_icon.set_from_icon_name("microphone-disabled-symbolic", 16) + self.mic_label.set_text("Muted") + else: + self.mic_icon.set_from_icon_name("microphone-sensitivity-high-symbolic", 16) + self.mic_label.set_text(f"{volume_int}%") + + if not self._is_notch_open: + self.show_mic_display() + + def _enable_audio_display(self): + self._suppress_first_audio_display = False + return False + + def _update_volume_appearance(self, volume_int, is_muted): + volume_box_style = self.volume_box.get_style_context() + volume_icon_style = self.volume_icon.get_style_context() + volume_bar_style = self.volume_bar.get_style_context() + + for cls in ["volume-muted", "volume-low", "volume-medium", "volume-high"]: + volume_box_style.remove_class(cls) + volume_icon_style.remove_class(cls) + volume_bar_style.remove_class(cls) + + if is_muted or volume_int == 0: + volume_box_style.add_class("volume-muted") + volume_icon_style.add_class("volume-muted") + volume_bar_style.add_class("volume-muted") + elif volume_int <= 33: + volume_box_style.add_class("volume-low") + volume_icon_style.add_class("volume-low") + volume_bar_style.add_class("volume-low") + elif volume_int <= 66: + volume_box_style.add_class("volume-medium") + volume_icon_style.add_class("volume-medium") + volume_bar_style.add_class("volume-medium") + else: + volume_box_style.add_class("volume-high") + volume_icon_style.add_class("volume-high") + volume_bar_style.add_class("volume-high") + + def _update_mic_appearance(self, volume_int, is_muted): + mic_box_style = self.mic_box.get_style_context() + mic_icon_style = self.mic_icon.get_style_context() + mic_bar_style = self.mic_bar.get_style_context() + + for cls in ["mic-muted", "mic-low", "mic-medium", "mic-high"]: + mic_box_style.remove_class(cls) + mic_icon_style.remove_class(cls) + mic_bar_style.remove_class(cls) + + if is_muted: + mic_box_style.add_class("mic-muted") + mic_icon_style.add_class("mic-muted") + mic_bar_style.add_class("mic-muted") + elif volume_int <= 33: + mic_box_style.add_class("mic-low") + mic_icon_style.add_class("mic-low") + mic_bar_style.add_class("mic-low") + elif volume_int <= 66: + mic_box_style.add_class("mic-medium") + mic_icon_style.add_class("mic-medium") + mic_bar_style.add_class("mic-medium") + else: + mic_box_style.add_class("mic-high") + mic_icon_style.add_class("mic-high") + mic_bar_style.add_class("mic-high") + + def _update_volume_widgets_silently(self): + if not self.audio or not self.audio.speaker: + return + + speaker = self.audio.speaker + volume = speaker.volume + is_muted = speaker.muted + + volume_int = int(round(volume)) + volume_percentage = volume_int / 100.0 + self.volume_bar.set_fraction(volume_percentage) + + self._update_volume_appearance(volume_int, is_muted) + + if is_muted: + self.volume_icon.set_from_icon_name("audio-volume-muted-symbolic", 16) + self.volume_label.set_text("Muted") + elif volume_int == 0: + self.volume_icon.set_from_icon_name("audio-volume-muted-symbolic", 16) + self.volume_label.set_text("Muted") + else: + if volume_int <= 33: + icon_name = "audio-volume-low-symbolic" + elif volume_int <= 66: + icon_name = "audio-volume-medium-symbolic" + else: + icon_name = "audio-volume-high-symbolic" + + self.volume_icon.set_from_icon_name(icon_name, 16) + self.volume_label.set_text(f"{volume_int}%") + + def _update_mic_widgets_silently(self): + if not self.audio or not self.audio.microphone: + return + + microphone = self.audio.microphone + volume = microphone.volume + is_muted = microphone.muted + + volume_int = int(round(volume)) + volume_percentage = volume_int / 100.0 + self.mic_bar.set_fraction(volume_percentage) + + self._update_mic_appearance(volume_int, is_muted) + + if is_muted: + self.mic_icon.set_from_icon_name("microphone-disabled-symbolic", 16) + self.mic_label.set_text(" Muted") + else: + self.mic_icon.set_from_icon_name("microphone-sensitivity-high-symbolic", 16) + self.mic_label.set_text(f" {volume_int}%") + + def show_volume_display(self): + if self._is_notch_open: + return + + if self._current_display_timeout_id: + GLib.source_remove(self._current_display_timeout_id) + + self.compact_stack.set_visible_child(self.volume_box) + self._current_display_timeout_id = GLib.timeout_add( + self.VOLUME_DISPLAY_DURATION, + self.return_to_normal_view + ) + + def show_mic_display(self): + if self._is_notch_open: + return + + if self._current_display_timeout_id: + GLib.source_remove(self._current_display_timeout_id) + + self.compact_stack.set_visible_child(self.mic_box) + self._current_display_timeout_id = GLib.timeout_add( + self.VOLUME_DISPLAY_DURATION, + self.return_to_normal_view + ) + + def return_to_normal_view(self): + self._current_display_timeout_id = None + + if not self._is_notch_open: + current_child = self.compact_stack.get_visible_child() + if current_child in [self.volume_box, self.mic_box]: + self.compact_stack.set_visible_child(self.active_window_box) + + return False + + def on_button_enter(self, widget, event): + self.is_hovered = True + window = widget.get_window() + if window: + window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2)) + return True + + def on_button_leave(self, widget, event): + if event.detail == Gdk.NotifyType.INFERIOR: + return False + + self.is_hovered = False + window = widget.get_window() + if window: + window.set_cursor(None) + return True + + def _on_realize(self, widget): + """Ensure the notch window is raised above the bar.""" + self.get_window().raise_() + + def on_notch_hover_area_enter(self, widget, event): + """Handle hover enter for the entire notch area""" + self.is_hovered = True + if data.PANEL_THEME == "Notch" and data.BAR_POSITION != "Top": + self.notch_revealer.set_reveal_child(True) + return False + + def on_notch_hover_area_leave(self, widget, event): + """Handle hover leave for the entire notch area""" + + if event.detail == Gdk.NotifyType.INFERIOR: + return False + + self.is_hovered = False + + return False + + def close_notch(self): + if self.monitor_manager: + self.monitor_manager.set_notch_state(self.monitor_id, False) + + self.set_keyboard_mode("none") + self.notch_box.remove_style_class("open") + self.stack.remove_style_class("open") + + self.bar.revealer_right.set_reveal_child(True) + self.bar.revealer_left.set_reveal_child(True) + self.applet_stack.set_visible_child(self.nhistory) + self._is_notch_open = False + self.stack.set_visible_child(self.compact) + if data.PANEL_THEME != "Notch": + self.notch_revealer.set_reveal_child(False) + + if self.bar and not self.bar.get_visible() and data.BAR_POSITION == "Top": + if data.BAR_THEME == "Pills": + self.set_margin("-40px 0px 0px 0px") + elif data.BAR_THEME in ["Dense", "Edge"]: + self.set_margin("-46px 0px 0px 0px") + else: + self.set_margin("-40px 8px 8px 8px") + + def open_notch(self, widget_name: str): + # Debug info for troubleshooting + if hasattr(self, '_debug_monitor_focus') and self._debug_monitor_focus: + print(f"DEBUG: open_notch called on monitor {self.monitor_id} for widget '{widget_name}'") + + # Handle monitor focus switching - always check real focused monitor from Hyprland + if self.monitor_manager: + # Get real focused monitor directly from Hyprland to ensure accuracy + real_focused_monitor_id = self._get_real_focused_monitor_id() + + # Update monitor manager if we got a valid result + if real_focused_monitor_id is not None: + # Update the monitor manager's focused monitor + self.monitor_manager._focused_monitor_id = real_focused_monitor_id + if hasattr(self, '_debug_monitor_focus') and self._debug_monitor_focus: + print(f"DEBUG: Real focused monitor from Hyprland: {real_focused_monitor_id}") + + focused_monitor_id = self.monitor_manager.get_focused_monitor_id() + focused_notch = self.monitor_manager.get_instance(focused_monitor_id, 'notch') + + # Close notches on other monitors + self.monitor_manager.close_all_notches_except(focused_monitor_id) + + if focused_notch and hasattr(focused_notch, 'open_notch'): + # Open notch on focused monitor + focused_notch._open_notch_internal(widget_name) + self.monitor_manager.set_notch_state(focused_monitor_id, True, widget_name) + + def _get_real_focused_monitor_id(self): + """Get the real focused monitor ID directly from Hyprland.""" + # Use thread to avoid blocking UI + self._focused_monitor_result = None + GLib.Thread.new("get-focused-monitor", self._get_focused_monitor_thread, None) + # Wait for result (not ideal, but for compatibility) + import time + start = time.time() + while self._focused_monitor_result is None and time.time() - start < 2.0: + time.sleep(0.01) + return self._focused_monitor_result + + def _get_focused_monitor_thread(self, user_data): + try: + import json + import subprocess + + # Get focused monitor from Hyprland + result = subprocess.run( + ["hyprctl", "monitors", "-j"], + capture_output=True, + text=True, + check=True, + timeout=2.0 + ) + + monitors = json.loads(result.stdout) + for i, monitor in enumerate(monitors): + if monitor.get('focused', False): + self._focused_monitor_result = i + return + + except (subprocess.CalledProcessError, json.JSONDecodeError, + FileNotFoundError, subprocess.TimeoutExpired) as e: + print(f"Warning: Could not get focused monitor from Hyprland: {e}") + + self._focused_monitor_result = None + + def _open_notch_internal(self, widget_name: str): + + self.notch_revealer.set_reveal_child(True) + self.notch_box.add_style_class("open") + self.stack.add_style_class("open") + current_stack_child = self.stack.get_visible_child() + is_dashboard_currently_visible = current_stack_child == self.dashboard + + if widget_name == "network_applet": + if is_dashboard_currently_visible: + if ( + self.dashboard.stack.get_visible_child() == self.dashboard.widgets + and self.applet_stack.get_visible_child() == self.nwconnections + ): + self.close_notch() + return + + self.set_keyboard_mode("exclusive") + self.dashboard.go_to_section("widgets") + self.applet_stack.set_visible_child(self.nwconnections) + return + + elif widget_name == "bluetooth": + if is_dashboard_currently_visible: + if ( + self.dashboard.stack.get_visible_child() == self.dashboard.widgets + and self.applet_stack.get_visible_child() == self.btdevices + ): + self.close_notch() + return + + self.set_keyboard_mode("exclusive") + self.dashboard.go_to_section("widgets") + self.applet_stack.set_visible_child(self.btdevices) + return + + elif widget_name == "dashboard": + if is_dashboard_currently_visible: + if ( + self.dashboard.stack.get_visible_child() == self.dashboard.widgets + and self.applet_stack.get_visible_child() == self.nhistory + ): + self.close_notch() + return + + self.set_keyboard_mode("exclusive") + self.dashboard.go_to_section("widgets") + self.applet_stack.set_visible_child(self.nhistory) + return + + dashboard_sections_map = { + "pins": self.dashboard.pins, + "kanban": self.dashboard.kanban, + "wallpapers": self.dashboard.wallpapers, + "mixer": self.dashboard.mixer, + } + if widget_name in dashboard_sections_map: + section_widget_instance = dashboard_sections_map[widget_name] + + if ( + is_dashboard_currently_visible + and self.dashboard.stack.get_visible_child() == section_widget_instance + ): + self.close_notch() + return + + target_widget_on_stack = None + action_on_open = None + focus_action = None + + hide_bar_revealers = False + + widget_configs = { + "tmux": {"instance": self.tmux, "action": self.tmux.open_manager}, + "cliphist": { + "instance": self.cliphist, + "action": lambda: GLib.idle_add(self.cliphist.open), + }, + "launcher": { + "instance": self.launcher, + "action": self.launcher.open_launcher, + "focus": lambda: ( + self.launcher.search_entry.set_text(""), + self.launcher.search_entry.grab_focus(), + ), + }, + "emoji": { + "instance": self.emoji, + "action": self.emoji.open_picker, + "focus": lambda: ( + self.emoji.search_entry.set_text(""), + self.emoji.search_entry.grab_focus(), + ), + }, + "overview": {"instance": self.overview, "hide_revealers": True}, + "power": {"instance": self.power}, + "tools": {"instance": self.tools}, + } + + if widget_name in widget_configs: + config = widget_configs[widget_name] + target_widget_on_stack = config["instance"] + action_on_open = config.get("action") + focus_action = config.get("focus") + hide_bar_revealers = config.get("hide_revealers", False) + + if current_stack_child == target_widget_on_stack: + self.close_notch() + return + else: + target_widget_on_stack = self.dashboard + hide_bar_revealers = True + + self.set_keyboard_mode("exclusive") + self.stack.set_visible_child(target_widget_on_stack) + + if action_on_open: + action_on_open() + if focus_action: + focus_action() + + if target_widget_on_stack == self.dashboard: + if widget_name == "bluetooth": + self.dashboard.go_to_section("widgets") + self.applet_stack.set_visible_child(self.btdevices) + elif widget_name == "network_applet": + self.dashboard.go_to_section("widgets") + self.applet_stack.set_visible_child(self.nwconnections) + elif widget_name in dashboard_sections_map: + self.dashboard.go_to_section(widget_name) + elif widget_name == "dashboard": + self.dashboard.go_to_section("widgets") + self.applet_stack.set_visible_child(self.nhistory) + + if ( + data.BAR_POSITION in ["Top", "Bottom"] + and data.PANEL_THEME == "Panel" + or data.BAR_POSITION in ["Bottom"] + and data.PANEL_THEME == "Notch" + ): + self.bar.revealer_right.set_reveal_child(True) + self.bar.revealer_left.set_reveal_child(True) + else: + self.bar.revealer_right.set_reveal_child(not hide_bar_revealers) + self.bar.revealer_left.set_reveal_child(not hide_bar_revealers) + + if self.bar and not self.bar.get_visible() and data.BAR_POSITION == "Top": + self.set_margin("0px 8px 8px 8px") + + self._is_notch_open = True + + def toggle_hidden(self): + self.hidden = not self.hidden + self.set_visible(not self.hidden) + + def _on_compact_scroll(self, widget, event): + if self._scrolling: + return True + + children = self.compact_stack.get_children() + current = children.index(self.compact_stack.get_visible_child()) + new_index = current + + if event.direction == Gdk.ScrollDirection.SMOOTH: + if event.delta_y < -0.1: + new_index = (current - 1) % len(children) + elif event.delta_y > 0.1: + new_index = (current + 1) % len(children) + else: + return False + elif event.direction == Gdk.ScrollDirection.UP: + new_index = (current - 1) % len(children) + elif event.direction == Gdk.ScrollDirection.DOWN: + new_index = (current + 1) % len(children) + else: + return False + + self.compact_stack.set_visible_child(children[new_index]) + self._scrolling = True + GLib.timeout_add(500, self._reset_scrolling) + return True + + def _reset_scrolling(self): + self._scrolling = False + return False + + def on_player_vanished(self, *args): + if self.player_small.mpris_label.get_label() == "Nothing Playing": + self.compact_stack.set_visible_child(self.active_window_box) + + def restore_label_properties(self): + label = self.active_window.get_children()[0] + if isinstance(label, Gtk.Label): + label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_hexpand(True) + label.set_halign(Gtk.Align.FILL) + label.queue_resize() + + self.update_window_icon() + + def _build_app_identifiers_map(self): + """Build a mapping of app identifiers (class names, executables, names) to DesktopApp objects""" + identifiers = {} + for app in self._all_apps: + if app.name: + identifiers[app.name.lower()] = app + + if app.display_name: + identifiers[app.display_name.lower()] = app + + if app.window_class: + identifiers[app.window_class.lower()] = app + + if app.executable: + exe_basename = app.executable.split("/")[-1].lower() + identifiers[exe_basename] = app + + if app.command_line: + cmd_base = app.command_line.split()[0].split("/")[-1].lower() + identifiers[cmd_base] = app + + return identifiers + + def find_app(self, app_id: str): + """Find a DesktopApp object by various identifiers using the pre-built map.""" + normalized_id = app_id.lower() + return self.app_identifiers.get(normalized_id) + + def update_window_icon(self, *args): + """Update the window icon based on the current active window title""" + + label_widget = self.active_window.get_children()[0] + if not isinstance(label_widget, Gtk.Label): + return + + title = label_widget.get_text() + if title == "Desktop" or not title: + self.window_icon.set_visible(False) + return + + self.window_icon.set_visible(True) + + from fabric.hyprland.widgets import get_hyprland_connection + + conn = get_hyprland_connection() + if conn: + try: + import json + + active_window_json = conn.send_command("j/activewindow").reply.decode() + active_window_data = json.loads(active_window_json) + app_id = active_window_data.get( + "initialClass", "" + ) or active_window_data.get("class", "") + + icon_size = 20 + desktop_app = self.find_app(app_id) + + icon_pixbuf = None + if desktop_app: + icon_pixbuf = desktop_app.get_icon_pixbuf(size=icon_size) + + if not icon_pixbuf: + icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_id, icon_size) + + if not icon_pixbuf and "-" in app_id: + base_app_id = app_id.split("-")[0] + icon_pixbuf = self.icon_resolver.get_icon_pixbuf( + base_app_id, icon_size + ) + + if icon_pixbuf: + self.window_icon.set_from_pixbuf(icon_pixbuf) + else: + try: + self.window_icon.set_from_icon_name( + "application-x-executable", 20 + ) + except: + self.window_icon.set_from_icon_name( + "application-x-executable-symbolic", 20 + ) + except Exception as e: + print(f"Error updating window icon: {e}") + try: + self.window_icon.set_from_icon_name("application-x-executable", 20) + except: + self.window_icon.set_from_icon_name( + "application-x-executable-symbolic", 20 + ) + else: + try: + self.window_icon.set_from_icon_name("application-x-executable", 20) + except: + self.window_icon.set_from_icon_name( + "application-x-executable-symbolic", 20 + ) + + def _check_occlusion(self): + """ + Check if top 40px of the screen is occluded by any window + and update the notch_revealer accordingly. + """ + + occlusion_edge = "top" + occlusion_size = 40 + + if self._forced_occlusion: + # When forced occlusion is active, show only on hover + self.notch_revealer.set_reveal_child(self.is_hovered) + elif not (self.is_hovered or self._is_notch_open or self._prevent_occlusion): + is_occluded = check_occlusion((occlusion_edge, occlusion_size)) + self.notch_revealer.set_reveal_child(not is_occluded) + + return True + + def force_occlusion(self): + """Force notch to occlusion mode (hidden).""" + self._forced_occlusion = True + self._prevent_occlusion = False + self.notch_revealer.set_reveal_child(False) + # Start occlusion check timer if in vertical mode (left/right) + if data.BAR_POSITION in ["Left", "Right"]: + GLib.timeout_add(100, self._check_occlusion) + + def restore_from_occlusion(self): + """Restore notch from occlusion mode.""" + import config.data as data + self._forced_occlusion = False + if data.PANEL_THEME == "Notch": + if data.BAR_POSITION == "Top": + self.notch_revealer.set_reveal_child(True) + else: + self._prevent_occlusion = False + + def _get_current_window_class(self): + """Get the class of the currently active window""" + try: + from fabric.hyprland.widgets import get_hyprland_connection + + conn = get_hyprland_connection() + if conn: + import json + + active_window_json = conn.send_command("j/activewindow").reply.decode() + active_window_data = json.loads(active_window_json) + return active_window_data.get( + "initialClass", "" + ) or active_window_data.get("class", "") + except Exception as e: + print(f"Error getting window class: {e}") + return "" + + def on_active_window_changed(self, *args): + """ + Temporarily remove the 'occluded' class when active window class changes + to make the notch visible momentarily. + """ + + if data.PANEL_THEME != "Notch": + return + + new_window_class = self._get_current_window_class() + + if new_window_class != self._current_window_class: + self._current_window_class = new_window_class + + if self._occlusion_timer_id is not None: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + + self._prevent_occlusion = True + self.notch_revealer.set_reveal_child(True) + + self._occlusion_timer_id = GLib.timeout_add( + 500, self._restore_occlusion_check + ) + + def _restore_occlusion_check(self): + """Re-enable occlusion checking after temporary visibility""" + + self._prevent_occlusion = False + self._occlusion_timer_id = None + + return False + + def open_launcher_with_text(self, initial_text): + """Open the launcher with initial text in the search field.""" + + self._launcher_transitioning = True + + if initial_text: + self._typed_chars_buffer = initial_text + + if self.stack.get_visible_child() == self.launcher: + current_text = self.launcher.search_entry.get_text() + self.launcher.search_entry.set_text(current_text + initial_text) + + self.launcher.search_entry.set_position(-1) + self.launcher.search_entry.select_region(-1, -1) + self.launcher.search_entry.grab_focus() + return + + self.set_keyboard_mode("exclusive") + + for style in [ + "launcher", + "dashboard", + "notification", + "overview", + "emoji", + "power", + "tools", + "tmux", + ]: + self.stack.remove_style_class(style) + for w in [ + self.launcher, + self.dashboard, + self.overview, + self.emoji, + self.power, + self.tools, + self.tmux, + self.cliphist, + ]: + w.remove_style_class("open") + + self.stack.add_style_class("launcher") + self.stack.set_visible_child(self.launcher) + self.launcher.add_style_class("open") + + self.launcher.ensure_initialized() + + self.launcher.open_launcher() + + if self._launcher_transition_timeout: + GLib.source_remove(self._launcher_transition_timeout) + + self._launcher_transition_timeout = GLib.timeout_add( + 150, self._finalize_launcher_transition + ) + + self.bar.revealer_right.set_reveal_child(True) + self.bar.revealer_left.set_reveal_child(True) + + self._is_notch_open = True + + def _finalize_launcher_transition(self): + """Apply buffered text and finalize launcher transition""" + + if self._typed_chars_buffer: + entry = self.launcher.search_entry + entry.set_text(self._typed_chars_buffer) + + entry.grab_focus() + + GLib.timeout_add(10, self._ensure_no_text_selection) + GLib.timeout_add(50, self._ensure_no_text_selection) + GLib.timeout_add(100, self._ensure_no_text_selection) + + print(f"Applied buffered text: '{self._typed_chars_buffer}'") + + self._typed_chars_buffer = "" + + self._launcher_transitioning = False + self._launcher_transition_timeout = None + + return False + + def _ensure_no_text_selection(self): + """Make absolutely sure no text is selected in the search entry""" + entry = self.launcher.search_entry + + text_len = len(entry.get_text()) + + entry.set_position(text_len) + + entry.select_region(text_len, text_len) + + if not entry.has_focus(): + entry.grab_focus() + + GLib.idle_add(lambda: entry.select_region(text_len, text_len)) + + return False + + def on_key_press(self, widget, event): + """Handle key presses at the notch level""" + + if self._launcher_transitioning: + keyval = event.keyval + keychar = chr(keyval) if 32 <= keyval <= 126 else None + + is_valid_char = ( + (keyval >= Gdk.KEY_a and keyval <= Gdk.KEY_z) + or (keyval >= Gdk.KEY_A and keyval <= Gdk.KEY_Z) + or (keyval >= Gdk.KEY_0 and keyval <= Gdk.KEY_9) + or keyval + in (Gdk.KEY_space, Gdk.KEY_underscore, Gdk.KEY_minus, Gdk.KEY_period) + ) + + if is_valid_char and keychar: + self._typed_chars_buffer += keychar + print( + f"Buffered character: {keychar}, buffer now: '{self._typed_chars_buffer}'" + ) + return True + + if ( + self.stack.get_visible_child() == self.dashboard + and self.dashboard.stack.get_visible_child() == self.dashboard.widgets + ): + if self.stack.get_visible_child() == self.launcher: + return False + + keyval = event.keyval + keychar = chr(keyval) if 32 <= keyval <= 126 else None + + is_valid_char = ( + (keyval >= Gdk.KEY_a and keyval <= Gdk.KEY_z) + or (keyval >= Gdk.KEY_A and keyval <= Gdk.KEY_Z) + or (keyval >= Gdk.KEY_0 and keyval <= Gdk.KEY_9) + or keyval + in (Gdk.KEY_space, Gdk.KEY_underscore, Gdk.KEY_minus, Gdk.KEY_period) + ) + + if is_valid_char and keychar: + print(f"Notch received keypress: {keychar}") + + self.open_launcher_with_text(keychar) + return True + + return False \ No newline at end of file diff --git a/Ax-Shell/modules/notifications.py b/Ax-Shell/modules/notifications.py new file mode 100644 index 0000000..e0b662c --- /dev/null +++ b/Ax-Shell/modules/notifications.py @@ -0,0 +1,1434 @@ +import json +import locale +import os +import uuid +from datetime import datetime, timedelta + +from fabric.notifications.service import Notification, NotificationAction, Notifications +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 gi.repository import GdkPixbuf, GLib, Gtk +from loguru import logger + +import config.data as data +import modules.icons as icons +from widgets.image import CustomImage +from widgets.wayland import WaylandWindow as Window + +PERSISTENT_DIR = f"/tmp/{data.APP_NAME}/notifications" +PERSISTENT_HISTORY_FILE = os.path.join(PERSISTENT_DIR, "notification_history.json") + + +# Get configurable app lists from settings +def get_limited_apps_history(): + config = data.load_config() + return config.get("limited_apps_history", ["Spotify"]) + + +def get_history_ignored_apps(): + config = data.load_config() + return config.get("history_ignored_apps", ["Hyprshot"]) + + +def cache_notification_pixbuf(notification_box): + """ + Saves a scaled pixbuf (48x48) in the cache directory and returns the cache file path. + """ + notification = notification_box.notification + if notification.image_pixbuf: + os.makedirs(PERSISTENT_DIR, exist_ok=True) + cache_file = os.path.join( + PERSISTENT_DIR, f"notification_{notification_box.uuid}.png" + ) + logger.debug( + f"Caching image for notification {notification.id} to: {cache_file}" + ) + try: + scaled = notification.image_pixbuf.scale_simple( + 48, 48, GdkPixbuf.InterpType.BILINEAR + ) + scaled.savev(cache_file, "png", [], []) + logger.info( + f"Successfully cached image for notification {notification.id} to: {cache_file}" + ) + return cache_file + except Exception as e: + logger.error(f"Error caching image for notification {notification.id}: {e}") + return None + else: + logger.debug(f"Notification {notification.id} has no image_pixbuf to cache.") + return None + + +def load_scaled_pixbuf(notification_box, width, height): + """ + Loads and scales a pixbuf for a notification_box, prioritizing cached images. + """ + notification = notification_box.notification + if not hasattr(notification_box, "notification") or notification is None: + logger.error( + "load_scaled_pixbuf: notification_box.notification is None or not set!" + ) + return None + + pixbuf = None + if ( + hasattr(notification_box, "cached_image_path") + and notification_box.cached_image_path + and os.path.exists(notification_box.cached_image_path) + ): + try: + logger.debug( + f"Attempting to load cached image from: {notification_box.cached_image_path} for notification {notification.id}" + ) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(notification_box.cached_image_path) + if pixbuf: + pixbuf = pixbuf.scale_simple( + width, height, GdkPixbuf.InterpType.BILINEAR + ) + logger.info( + f"Successfully loaded cached image from: {notification_box.cached_image_path} for notification {notification.id}" + ) + return pixbuf + except Exception as e: + logger.error( + f"Error loading cached image from {notification_box.cached_image_path} for notification {notification.id}: {e}" + ) + logger.warning( + f"Falling back to notification.image_pixbuf for notification {notification.id}" + ) + + if notification.image_pixbuf: + logger.debug( + f"Loading image directly from notification.image_pixbuf for notification {notification.id}" + ) + pixbuf = notification.image_pixbuf.scale_simple( + width, height, GdkPixbuf.InterpType.BILINEAR + ) + return pixbuf + + logger.debug( + f"No image_pixbuf or cached image found, trying app icon for notification {notification.id}" + ) + return get_app_icon_pixbuf(notification.app_icon, width, height) + + +def get_app_icon_pixbuf(icon_path, width, height): + """ + Loads and scales a pixbuf from an app icon path. + """ + if not icon_path: + return None + 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 None + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) + return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) + except Exception as e: + logger.error(f"Failed to load or scale icon: {e}") + return None + + +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", + h_expand=True, + h_align="fill", + ellipsization="end", + max_chars_width=1, + 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, *_): + self.action.invoke() + self.action.parent.close("dismissed-by-user") + + +class NotificationBox(Box): + def __init__(self, notification: Notification, timeout_ms=5000, **kwargs): + super().__init__( + name="notification-box", + orientation="v", + h_align="fill", + h_expand=True, + children=[], + ) + self.notification = notification + self.uuid = str(uuid.uuid4()) + + if timeout_ms == 0: + self.timeout_ms = 0 + else: + live_timeout = getattr(self.notification, "timeout", -1) + self.timeout_ms = live_timeout if live_timeout != -1 else timeout_ms + self._timeout_id = None + self._container = None + self.cached_image_path = None + + if self.timeout_ms > 0: + self.start_timeout() + + if self.notification.image_pixbuf: + cache_path = cache_notification_pixbuf(self) + if cache_path: + self.cached_image_path = cache_path + logger.debug( + f"NotificationBox {self.uuid}: Cached image path set to: {self.cached_image_path}" + ) + else: + logger.warning( + f"NotificationBox {self.uuid}: Caching failed, cached_image_path not set." + ) + else: + logger.debug(f"NotificationBox {self.uuid}: No image to cache.") + + content = self.create_content() + action_buttons = self.create_action_buttons() + self.add(content) + if action_buttons: + self.add(action_buttons) + + self.connect("enter-notify-event", self.on_hover_enter) + self.connect("leave-notify-event", self.on_hover_leave) + + self._destroyed = False + self._is_history = False + logger.debug( + f"NotificationBox {self.uuid} created for notification {notification.id}" + ) + + def set_is_history(self, is_history): + self._is_history = is_history + + def set_container(self, container): + self._container = container + + def get_container(self): + return self._container + + def create_header(self): + notification = self.notification + self.app_icon_image = ( + Image( + name="notification-icon", + image_file=notification.app_icon[7:], + size=24, + ) + if "file://" in notification.app_icon + else Image( + name="notification-icon", + icon_name="dialog-information-symbolic" or notification.app_icon, + icon_size=24, + ) + ) + self.app_name_label_header = Label( + notification.app_name, name="notification-app-name", h_align="start" + ) + self.header_close_button = self.create_close_button() + + return CenterBox( + name="notification-title", + start_children=[ + Box( + spacing=4, + children=[ + self.app_icon_image, + self.app_name_label_header, + ], + ) + ], + end_children=[self.header_close_button], + ) + + def create_content(self): + notification = self.notification + pixbuf = load_scaled_pixbuf(self, 48, 48) + self.notification_image_box = Box( + name="notification-image", + orientation="v", + children=[CustomImage(pixbuf=pixbuf), Box(v_expand=True)], + ) + self.notification_summary_label = Label( + name="notification-summary", + markup=notification.summary, + h_align="start", + max_chars_width=16, + ellipsization="end", + ) + self.notification_app_name_label_content = Label( + name="notification-app-name", + markup=notification.app_name, + h_align="start", + max_chars_width=16, + ellipsization="end", + ) + self.notification_body_label = ( + Label( + markup=notification.body, + h_align="start", + max_chars_width=34, + ellipsization="end", + ) + if notification.body + else Box() + ) + self.notification_body_label.set_single_line_mode( + True + ) if notification.body else None + self.notification_text_box = Box( + name="notification-text", + orientation="v", + v_align="center", + h_expand=True, + h_align="start", + children=[ + Box( + name="notification-summary-box", + orientation="h", + children=[ + self.notification_summary_label, + Box( + name="notif-sep", + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ), + self.notification_app_name_label_content, + ], + ), + self.notification_body_label, + ], + ) + self.content_close_button = self.create_close_button() + self.content_close_button_box = Box( + orientation="v", + children=[ + self.content_close_button, + ], + ) + + return Box( + name="notification-content", + spacing=8, + children=[ + self.notification_image_box, + self.notification_text_box, + self.content_close_button_box, + ], + ) + + def create_action_buttons(self): + notification = self.notification + if not notification.actions: + return None + + grid = Gtk.Grid() + grid.set_column_homogeneous(True) + grid.set_column_spacing(4) + for i, action in enumerate(notification.actions): + action_button = ActionButton(action, i, len(notification.actions), self) + grid.attach(action_button, i, 0, 1, 1) + return grid + + def create_close_button(self): + self.close_button = Button( + name="notif-close-button", + child=Label(name="notif-close-label", markup=icons.cancel), + on_clicked=lambda *_: self.notification.close("dismissed-by-user"), + ) + 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) + ) + return self.close_button + + def on_hover_enter(self, *args): + if self._container: + self._container.pause_and_reset_all_timeouts() + + def on_hover_leave(self, *args): + if self._container: + self._container.resume_all_timeouts() + + 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): + if not self._destroyed: + try: + logger.debug( + f"Notification {self.notification.id} timeout expired, closing notification." + ) + self.notification.close("expired") + self.stop_timeout() + except Exception as e: + logger.error( + f"Error in close_notification for notification {self.notification.id}: {e}" + ) + return False + + def destroy(self, from_history_delete=False): + logger.debug( + f"NotificationBox destroy called for notification: {self.notification.id}, from_history_delete: {from_history_delete}, is_history: {self._is_history}" + ) + if ( + hasattr(self, "cached_image_path") + and self.cached_image_path + and os.path.exists(self.cached_image_path) + and (not self._is_history or from_history_delete) + ): + try: + os.remove(self.cached_image_path) + logger.info(f"Deleted cached image: {self.cached_image_path}") + except Exception as e: + logger.error( + f"Error deleting cached image {self.cached_image_path}: {e}" + ) + self._destroyed = True + self.stop_timeout() + super().destroy() + + def hover_button(self, button): + if self._container: + self._container.pause_and_reset_all_timeouts() + + def unhover_button(self, button): + if self._container: + self._container.resume_all_timeouts() + + +class HistoricalNotification(object): + def __init__( + self, id, app_icon, summary, body, app_name, timestamp, cached_image_path=None + ): + self.id = id + self.app_icon = app_icon + self.summary = summary + self.body = body + self.app_name = app_name + self.timestamp = timestamp + self.cached_image_path = cached_image_path + self.image_pixbuf = None + self.actions = [] + self.cached_scaled_pixbuf = None + + +class NotificationHistory(Box): + def __init__(self, **kwargs): + super().__init__(name="notification-history", orientation="v", **kwargs) + + self.containers = [] + self.header_label = Label( + name="nhh", + label="Notifications", + h_align="start", + h_expand=True, + ) + self.header_switch = Gtk.Switch(name="dnd-switch") + self.header_switch.set_vexpand(False) + self.header_switch.set_valign(Gtk.Align.CENTER) + self.header_switch.set_active(False) + self.header_clean = Button( + name="nhh-button", + child=Label(name="nhh-button-label", markup=icons.trash), + on_clicked=self.clear_history, + ) + self.do_not_disturb_enabled = False + self.header_switch.connect("notify::active", self.on_do_not_disturb_changed) + self.dnd_label = Label(name="dnd-label", markup=icons.notifications_off) + + self.history_header = CenterBox( + name="notification-history-header", + spacing=8, + start_children=[self.header_switch, self.dnd_label], + center_children=[self.header_label], + end_children=[self.header_clean], + ) + self.notifications_list = Box( + name="notifications-list", + orientation="v", + spacing=4, + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + ) + self.no_notifications_label = Label( + name="no-notif", + markup=icons.notifications_clear, + v_align="fill", + h_align="fill", + v_expand=True, + h_expand=True, + justification="center", + ) + self.no_notifications_box = Box( + name="no-notifications-box", + v_align="fill", + h_align="fill", + v_expand=True, + h_expand=True, + children=[self.no_notifications_label], + ) + self.scrolled_window = ScrolledWindow( + name="notification-history-scrolled-window", + orientation="v", + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + propagate_width=False, + propagate_height=False, + ) + self.scrolled_window_viewport_box = Box( + orientation="v", + children=[self.notifications_list, self.no_notifications_box], + ) + self.scrolled_window.add_with_viewport(self.scrolled_window_viewport_box) + self.persistent_notifications = [] + self.add(self.history_header) + self.add(self.scrolled_window) + GLib.idle_add(self._load_persistent_history().__next__) + + def get_ordinal(self, n): + if 11 <= (n % 100) <= 13: + return "th" + else: + return {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") + + def get_date_header(self, dt): + now = datetime.now() + today = now.date() + date = dt.date() + if date == today: + return "Today" + elif date == today - timedelta(days=1): + return "Yesterday" + else: + original_locale = locale.getlocale(locale.LC_TIME) + try: + locale.setlocale(locale.LC_TIME, ("en_US", "UTF-8")) + except locale.Error: + locale.setlocale(locale.LC_TIME, "C") + try: + day = dt.day + ordinal = self.get_ordinal(day) + month = dt.strftime("%B") + if dt.year == now.year: + result = f"{month} {day}{ordinal}" + else: + result = f"{month} {day}{ordinal}, {dt.year}" + finally: + locale.setlocale(locale.LC_TIME, original_locale) + return result + + def schedule_midnight_update(self): + now = datetime.now() + next_midnight = datetime.combine( + now.date() + timedelta(days=1), datetime.min.time() + ) + delta_seconds = (next_midnight - now).total_seconds() + GLib.timeout_add_seconds(int(delta_seconds), self.on_midnight) + + def on_midnight(self): + self.rebuild_with_separators() + self.schedule_midnight_update() + return GLib.SOURCE_REMOVE + + def create_date_separator(self, date_header): + return Box( + name="notif-date-sep", + children=[ + Label( + name="notif-date-sep-label", + label=date_header, + h_align="center", + h_expand=True, + ) + ], + ) + + def rebuild_with_separators(self): + GLib.idle_add(self._do_rebuild_with_separators) + + def _do_rebuild_with_separators(self): + children = list(self.notifications_list.get_children()) + for child in children: + self.notifications_list.remove(child) + + current_date_header = None + last_date_header = None + for container in sorted( + self.containers, key=lambda x: x.arrival_time, reverse=True + ): + arrival_time = container.arrival_time + date_header = self.get_date_header(arrival_time) + if date_header != current_date_header: + sep = self.create_date_separator(date_header) + self.notifications_list.add(sep) + current_date_header = date_header + last_date_header = date_header + self.notifications_list.add(container) + + if not self.containers and last_date_header: + for child in list(self.notifications_list.get_children()): + if child.get_name() == "notif-date-sep": + self.notifications_list.remove(child) + + self.notifications_list.show_all() + self.update_no_notifications_label_visibility() + + def on_do_not_disturb_changed(self, switch, pspec): + self.do_not_disturb_enabled = switch.get_active() + logger.info( + f"Do Not Disturb mode {'enabled' if self.do_not_disturb_enabled else 'disabled'}" + ) + + def clear_history(self, *args): + for child in self.notifications_list.get_children()[:]: + container = child + notif_box = ( + container.notification_box + if hasattr(container, "notification_box") + else None + ) + if notif_box: + notif_box.destroy(from_history_delete=True) + self.notifications_list.remove(child) + child.destroy() + + if os.path.exists(PERSISTENT_HISTORY_FILE): + try: + os.remove(PERSISTENT_HISTORY_FILE) + logger.info("Notification history cleared and persistent file deleted.") + except Exception as e: + logger.error(f"Error deleting persistent history file: {e}") + self.persistent_notifications = [] + self.containers = [] + self.rebuild_with_separators() + + def _load_persistent_history(self): + if not os.path.exists(PERSISTENT_DIR): + os.makedirs(PERSISTENT_DIR, exist_ok=True) + if os.path.exists(PERSISTENT_HISTORY_FILE): + try: + with open(PERSISTENT_HISTORY_FILE, "r") as f: + self.persistent_notifications = json.load(f) + for note in reversed(self.persistent_notifications): + self._add_historical_notification(note) + yield True + except Exception as e: + logger.error(f"Error loading persistent history: {e}") + GLib.idle_add(self.update_no_notifications_label_visibility) + self._cleanup_orphan_cached_images() + self.schedule_midnight_update() + + def _save_persistent_history(self): + try: + with open(PERSISTENT_HISTORY_FILE, "w") as f: + json.dump(self.persistent_notifications, f) + except Exception as e: + logger.error(f"Error saving persistent history: {e}") + + def delete_historical_notification(self, note_id, container): + if hasattr(container, "notification_box"): + notif_box = container.notification_box + notif_box.destroy(from_history_delete=True) + + target_note_id_str = str(note_id) + + new_persistent_notifications = [] + removed_from_list = False + for note_in_list in self.persistent_notifications: + current_note_id_str = str(note_in_list.get("id")) + if current_note_id_str == target_note_id_str: + removed_from_list = True + + continue + new_persistent_notifications.append(note_in_list) + + if removed_from_list: + self.persistent_notifications = new_persistent_notifications + logger.info( + f"Notification with ID {target_note_id_str} was marked for removal from persistent_notifications list." + ) + else: + logger.warning( + f"Notification with ID {target_note_id_str} was NOT found in persistent_notifications list. The list remains unchanged." + ) + + self._save_persistent_history() + container.destroy() + self.containers = [c for c in self.containers if c != container] + self.rebuild_with_separators() + + def _add_historical_notification(self, note): + hist_notif = HistoricalNotification( + id=note.get("id"), + app_icon=note.get("app_icon"), + summary=note.get("summary"), + body=note.get("body"), + app_name=note.get("app_name"), + timestamp=note.get("timestamp"), + cached_image_path=note.get("cached_image_path"), + ) + + hist_box = NotificationBox(hist_notif, timeout_ms=0) + hist_box.uuid = hist_notif.id + hist_box.cached_image_path = hist_notif.cached_image_path + hist_box.set_is_history(True) + for child in hist_box.get_children(): + if child.get_name() == "notification-action-buttons": + hist_box.remove(child) + container = Box( + name="notification-container", + orientation="v", + h_align="fill", + h_expand=True, + ) + container.notification_box = hist_box + try: + arrival = datetime.fromisoformat(hist_notif.timestamp) + except Exception: + arrival = datetime.now() + container.arrival_time = arrival + + def compute_time_label(arrival_time): + return arrival_time.strftime("%H:%M") + + self.hist_time_label = Label( + name="notification-timestamp", + markup=compute_time_label(container.arrival_time), + h_align="start", + ellipsization="end", + ) + self.hist_notif_image_box = Box( + name="notification-image", + orientation="v", + children=[ + CustomImage(pixbuf=load_scaled_pixbuf(hist_box, 48, 48)), + Box(v_expand=True), + ], + ) + self.hist_notif_summary_label = Label( + name="notification-summary", + markup=hist_notif.summary, + h_align="start", + ellipsization="end", + ) + + self.hist_notif_app_name_label = Label( + name="notification-app-name", + markup=f"{hist_notif.app_name}", + h_align="start", + ellipsization="end", + ) + + self.hist_notif_body_label = ( + Label( + name="notification-body", + markup=hist_notif.body, + h_align="start", + ellipsization="end", + line_wrap="word-char", + ) + if hist_notif.body + else Box() + ) + self.hist_notif_body_label.set_single_line_mode( + True + ) if hist_notif.body else None + + self.hist_notif_summary_box = Box( + name="notification-summary-box", + orientation="h", + children=[ + self.hist_notif_summary_label, + Box( + name="notif-sep", + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ), + self.hist_notif_app_name_label, + Box( + name="notif-sep", + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ), + self.hist_time_label, + ], + ) + self.hist_notif_text_box = Box( + name="notification-text", + orientation="v", + v_align="center", + h_expand=True, + children=[ + self.hist_notif_summary_box, + self.hist_notif_body_label, + ], + ) + self.hist_notif_close_button = Button( + name="notif-close-button", + child=Label(name="notif-close-label", markup=icons.cancel), + on_clicked=lambda *_: self.delete_historical_notification( + hist_notif.id, container + ), + ) + self.hist_notif_close_button_box = Box( + orientation="v", + children=[ + self.hist_notif_close_button, + Box(v_expand=True), + ], + ) + content_box = Box( + name="notification-box-hist", + spacing=8, + children=[ + self.hist_notif_image_box, + self.hist_notif_text_box, + self.hist_notif_close_button_box, + ], + ) + container.add(content_box) + self.containers.insert(0, container) + self.rebuild_with_separators() + self.update_no_notifications_label_visibility() + + def add_notification(self, notification_box): + app_name = notification_box.notification.app_name + if app_name in get_history_ignored_apps(): + logger.info( + f"Ignoring notification from {app_name} as it is in the ignored list." + ) + notification_box.destroy(from_history_delete=True) + return + + if app_name in get_limited_apps_history(): + self.clear_history_for_app(app_name) + + if len(self.containers) >= 50: + oldest_container = self.containers.pop() + if ( + hasattr(oldest_container, "notification_box") + and hasattr(oldest_container.notification_box, "cached_image_path") + and oldest_container.notification_box.cached_image_path + and os.path.exists(oldest_container.notification_box.cached_image_path) + ): + try: + os.remove(oldest_container.notification_box.cached_image_path) + logger.info( + f"Deleted cached image of oldest notification due to history limit: {oldest_container.notification_box.cached_image_path}" + ) + except Exception as e: + logger.error( + f"Error deleting cached image of oldest notification: {e}" + ) + oldest_container.destroy() + + def on_container_destroy(container): + if ( + hasattr(container, "_timestamp_timer_id") + and container._timestamp_timer_id + ): + GLib.source_remove(container._timestamp_timer_id) + if hasattr(container, "notification_box"): + notif_box = container.notification_box + container.destroy() + self.containers.remove(container) + self.rebuild_with_separators() + self.update_no_notifications_label_visibility() + + container = Box( + name="notification-container", + orientation="v", + h_align="fill", + h_expand=True, + ) + container.arrival_time = datetime.now() + + def compute_time_label(arrival_time): + return arrival_time.strftime("%H:%M") + + self.current_time_label = Label( + name="notification-timestamp", + markup=compute_time_label(container.arrival_time), + ) + self.current_notif_image_box = Box( + name="notification-image", + orientation="v", + children=[ + CustomImage(pixbuf=load_scaled_pixbuf(notification_box, 48, 48)), + Box(v_expand=True, v_align="fill"), + ], + ) + self.current_notif_summary_label = Label( + name="notification-summary", + markup=notification_box.notification.summary, + h_align="start", + ellipsization="end", + ) + self.current_notif_app_name_label = Label( + name="notification-app-name", + markup=f"{notification_box.notification.app_name}", + h_align="start", + ellipsization="end", + ) + self.current_notif_body_label = ( + Label( + name="notification-body", + markup=notification_box.notification.body, + h_align="start", + ellipsization="end", + line_wrap="word-char", + ) + if notification_box.notification.body + else Box() + ) + self.current_notif_body_label.set_single_line_mode( + True + ) if notification_box.notification.body else None + self.current_notif_summary_box = Box( + name="notification-summary-box", + orientation="h", + children=[ + self.current_notif_summary_label, + Box( + name="notif-sep", + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ), + self.current_notif_app_name_label, + Box( + name="notif-sep", + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ), + self.current_time_label, + ], + ) + self.current_notif_text_box = Box( + name="notification-text", + orientation="v", + v_align="center", + h_expand=True, + children=[ + self.current_notif_summary_box, + self.current_notif_body_label, + ], + ) + self.current_notif_close_button = Button( + name="notif-close-button", + child=Label(name="notif-close-label", markup=icons.cancel), + on_clicked=lambda *_: on_container_destroy(container), + ) + self.current_notif_close_button_box = Box( + orientation="v", + children=[ + self.current_notif_close_button, + Box(v_expand=True), + ], + ) + content_box = Box( + name="notification-content", + spacing=8, + children=[ + self.current_notif_image_box, + self.current_notif_text_box, + self.current_notif_close_button_box, + ], + ) + container.notification_box = notification_box + hist_box = Box( + name="notification-box-hist", + orientation="v", + h_align="fill", + h_expand=True, + ) + hist_box.add(content_box) + content_box.get_children()[2].get_children()[0].connect( + "clicked", lambda *_: on_container_destroy(container) + ) + container.add(hist_box) + self.containers.insert(0, container) + self.rebuild_with_separators() + self._append_persistent_notification(notification_box, container.arrival_time) + self.update_no_notifications_label_visibility() + + def _append_persistent_notification(self, notification_box, arrival_time): + note = { + "id": notification_box.uuid, + "app_icon": notification_box.notification.app_icon, + "summary": notification_box.notification.summary, + "body": notification_box.notification.body, + "app_name": notification_box.notification.app_name, + "timestamp": arrival_time.isoformat(), + "cached_image_path": notification_box.cached_image_path, + } + self.persistent_notifications.insert(0, note) + self.persistent_notifications = self.persistent_notifications[:50] + self._save_persistent_history() + + def _cleanup_orphan_cached_images(self): + logger.debug("Starting orphan cached image cleanup.") + if not os.path.exists(PERSISTENT_DIR): + logger.debug("Cache directory does not exist, skipping cleanup.") + return + + cached_files = [ + f + for f in os.listdir(PERSISTENT_DIR) + if f.startswith("notification_") and f.endswith(".png") + ] + if not cached_files: + logger.debug("No cached image files found, skipping cleanup.") + return + + history_uuids = { + note.get("id") for note in self.persistent_notifications if note.get("id") + } + deleted_count = 0 + for cached_file in cached_files: + try: + uuid_from_filename = cached_file[len("notification_") : -len(".png")] + if uuid_from_filename not in history_uuids: + cache_file_path = os.path.join(PERSISTENT_DIR, cached_file) + os.remove(cache_file_path) + logger.info(f"Deleted orphan cached image: {cache_file_path}") + deleted_count += 1 + else: + logger.debug( + f"Cached image {cached_file} found in history, keeping it." + ) + except Exception as e: + logger.error( + f"Error processing cached file {cached_file} during cleanup: {e}" + ) + + if deleted_count > 0: + logger.info( + f"Orphan cached image cleanup finished. Deleted {deleted_count} images." + ) + else: + logger.info("Orphan cached image cleanup finished. No orphan images found.") + + def update_no_notifications_label_visibility(self): + has_notifications = bool(self.containers) + self.no_notifications_box.set_visible(not has_notifications) + self.notifications_list.set_visible(has_notifications) + + def clear_history_for_app(self, app_name): + """Clears all notifications in history for a specific app.""" + containers_to_remove = [] + persistent_notes_to_remove_ids = set() + for container in list(self.containers): + if ( + hasattr(container, "notification_box") + and container.notification_box.notification.app_name == app_name + ): + containers_to_remove.append(container) + persistent_notes_to_remove_ids.add(container.notification_box.uuid) + + for container in containers_to_remove: + if ( + hasattr(container, "notification_box") + and hasattr(container.notification_box, "cached_image_path") + and container.notification_box.cached_image_path + and os.path.exists(container.notification_box.cached_image_path) + ): + try: + os.remove(container.notification_box.cached_image_path) + logger.info( + f"Deleted cached image of replaced history notification: {container.notification_box.cached_image_path}" + ) + except Exception as e: + logger.error( + f"Error deleting cached image of replaced history notification: {e}" + ) + self.containers.remove(container) + self.notifications_list.remove(container) + container.notification_box.destroy(from_history_delete=True) + container.destroy() + + self.persistent_notifications = [ + note + for note in self.persistent_notifications + if note.get("id") not in persistent_notes_to_remove_ids + ] + self._save_persistent_history() + self.rebuild_with_separators() + self.update_no_notifications_label_visibility() + + +class NotificationContainer(Box): + def __init__( + self, + notification_history_instance: NotificationHistory, + revealer_transition_type: str = "slide-down", + ): + super().__init__(name="notification-container-main", orientation="v", spacing=4) + self.notification_history = notification_history_instance + + self._server = Notifications() + self._server.connect("notification-added", self.on_new_notification) + self._pending_removal = False + self._is_destroying = False + + self.stack = Gtk.Stack( + name="notification-stack", + transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT, + transition_duration=200, + visible=True, + ) + self.navigation = Box( + name="notification-navigation", spacing=4, h_align="center" + ) + self.stack_box = Box( + name="notification-stack-box", + h_align="center", + h_expand=False, + children=[self.stack], + ) + self.prev_button = Button( + name="nav-button", + child=Label(name="nav-button-label", markup=icons.chevron_left), + on_clicked=self.show_previous, + ) + self.close_all_button = Button( + name="nav-button", + child=Label(name="nav-button-label", markup=icons.cancel), + on_clicked=self.close_all_notifications, + ) + self.close_all_button_label = self.close_all_button.get_child() + self.close_all_button_label.add_style_class("close") + self.next_button = Button( + name="nav-button", + child=Label(name="nav-button-label", markup=icons.chevron_right), + on_clicked=self.show_next, + ) + for button in [self.prev_button, self.close_all_button, self.next_button]: + button.connect( + "enter-notify-event", lambda *_: self.pause_and_reset_all_timeouts() + ) + button.connect("leave-notify-event", lambda *_: self.resume_all_timeouts()) + self.navigation.add(self.prev_button) + self.navigation.add(self.close_all_button) + self.navigation.add(self.next_button) + + self.navigation_revealer = Revealer( + transition_type="slide-down", + transition_duration=200, + child=self.navigation, + reveal_child=False, + ) + + self.notification_box_container = Box( + name="notification-box-internal-container", + orientation="v", + children=[self.stack_box, self.navigation_revealer], + ) + + self.main_revealer = Revealer( + name="notification-main-revealer", + transition_type=revealer_transition_type, + transition_duration=250, + child_revealed=False, + child=self.notification_box_container, + ) + + self.add(self.main_revealer) + + self.notifications = [] + self.current_index = 0 + self.update_navigation_buttons() + self._destroyed_notifications = set() + + def on_new_notification(self, fabric_notif, id): + notification_history_instance = self.notification_history + if notification_history_instance.do_not_disturb_enabled: + logger.info( + "Do Not Disturb mode enabled: adding notification directly to history." + ) + notification = fabric_notif.get_notification_from_id(id) + new_box = NotificationBox(notification) + if notification.image_pixbuf: + cache_notification_pixbuf(new_box) + notification_history_instance.add_notification(new_box) + return + + notification = fabric_notif.get_notification_from_id(id) + new_box = NotificationBox(notification) + new_box.set_container(self) + notification.connect("closed", self.on_notification_closed) + + app_name = notification.app_name + if app_name in get_limited_apps_history(): + notification_history_instance.clear_history_for_app(app_name) + + existing_notification_index = -1 + for index, existing_box in enumerate(self.notifications): + if existing_box.notification.app_name == app_name: + existing_notification_index = index + break + + if existing_notification_index != -1: + old_notification_box = self.notifications.pop( + existing_notification_index + ) + self.stack.remove(old_notification_box) + old_notification_box.destroy() + + self.stack.add_named(new_box, str(id)) + self.notifications.append(new_box) + self.current_index = len(self.notifications) - 1 + self.stack.set_visible_child(new_box) + else: + while len(self.notifications) >= 5: + oldest_notification = self.notifications[0] + notification_history_instance.add_notification(oldest_notification) + self.stack.remove(oldest_notification) + self.notifications.pop(0) + if self.current_index > 0: + self.current_index -= 1 + self.stack.add_named(new_box, str(id)) + self.notifications.append(new_box) + self.current_index = len(self.notifications) - 1 + self.stack.set_visible_child(new_box) + else: + while len(self.notifications) >= 5: + oldest_notification = self.notifications[0] + notification_history_instance.add_notification(oldest_notification) + self.stack.remove(oldest_notification) + self.notifications.pop(0) + if self.current_index > 0: + self.current_index -= 1 + self.stack.add_named(new_box, str(id)) + self.notifications.append(new_box) + self.current_index = len(self.notifications) - 1 + self.stack.set_visible_child(new_box) + + for notification_box in self.notifications: + notification_box.start_timeout() + self.main_revealer.show_all() + self.main_revealer.set_reveal_child(True) + self.update_navigation_buttons() + + def show_previous(self, *args): + if self.current_index > 0: + self.current_index -= 1 + self.stack.set_visible_child(self.notifications[self.current_index]) + self.update_navigation_buttons() + + def show_next(self, *args): + if self.current_index < len(self.notifications) - 1: + self.current_index += 1 + self.stack.set_visible_child(self.notifications[self.current_index]) + self.update_navigation_buttons() + + def update_navigation_buttons(self): + self.prev_button.set_sensitive(self.current_index > 0) + self.next_button.set_sensitive(self.current_index < len(self.notifications) - 1) + should_reveal = len(self.notifications) > 1 + self.navigation_revealer.set_reveal_child(should_reveal) + + def on_notification_closed(self, notification, reason): + if self._is_destroying: + return + if notification.id in self._destroyed_notifications: + return + self._destroyed_notifications.add(notification.id) + try: + logger.info(f"Notification {notification.id} closing with reason: {reason}") + notif_to_remove = None + for i, notif_box in enumerate(self.notifications): + if notif_box.notification.id == notification.id: + notif_to_remove = (i, notif_box) + break + if not notif_to_remove: + return + i, notif_box = notif_to_remove + reason_str = str(reason) + + notification_history_instance = self.notification_history + + if reason_str == "NotificationCloseReason.DISMISSED_BY_USER": + logger.info( + f"Cleaning up resources for dismissed notification {notification.id}" + ) + notif_box.destroy() + elif ( + reason_str == "NotificationCloseReason.EXPIRED" + or reason_str == "NotificationCloseReason.CLOSED" + or reason_str == "NotificationCloseReason.UNDEFINED" + ): + logger.info( + f"Adding notification {notification.id} to history (reason: {reason_str})" + ) + notif_box.set_is_history(True) + notification_history_instance.add_notification(notif_box) + notif_box.stop_timeout() + else: + logger.warning( + f"Unknown close reason: {reason_str} for notification {notification.id}. Defaulting to destroy." + ) + notif_box.destroy() + + new_index = i + if i == self.current_index: + new_index = max(0, i - 1) + elif i < self.current_index: + new_index = self.current_index - 1 + + if notif_box.get_parent() == self.stack: + self.stack.remove(notif_box) + self.notifications.pop(i) + + if new_index >= len(self.notifications) and len(self.notifications) > 0: + new_index = len(self.notifications) - 1 + + self.current_index = new_index + + if not self.notifications: + self._is_destroying = True + self.main_revealer.set_reveal_child(False) + self._destroy_container() + return + else: + self.stack.set_visible_child(self.notifications[self.current_index]) + + self.update_navigation_buttons() + except Exception as e: + logger.error(f"Error closing notification: {e}") + + def _destroy_container(self): + try: + self.notifications.clear() + self._destroyed_notifications.clear() + for child in self.stack.get_children(): + self.stack.remove(child) + child.destroy() + self.current_index = 0 + except Exception as e: + logger.error(f"Error cleaning up the container: {e}") + finally: + self._is_destroying = False + return False + + def pause_and_reset_all_timeouts(self): + if self._is_destroying: + return + for notification in self.notifications[:]: + try: + if not notification._destroyed and notification.get_parent(): + notification.stop_timeout() + except Exception as e: + logger.error(f"Error pausing timeout: {e}") + + def resume_all_timeouts(self): + if self._is_destroying: + return + for notification in self.notifications[:]: + try: + if not notification._destroyed and notification.get_parent(): + notification.start_timeout() + except Exception as e: + logger.error(f"Error resuming timeout: {e}") + + def close_all_notifications(self, *args): + notifications_to_close = self.notifications.copy() + for notification_box in notifications_to_close: + notification_box.notification.close("dismissed-by-user") + + +class NotificationPopup(Window): + def __init__(self, **kwargs): + y_pos = data.NOTIF_POS.lower() + x_pos = "right" + + if ( + data.BAR_POSITION in ["Top", "Bottom"] + and data.PANEL_POSITION == "End" + or x_pos == data.BAR_POSITION.lower() + ): + x_pos = "left" + + super().__init__( + name="notification-popup", + anchor=f"{x_pos} {y_pos}", + layer="top", + keyboard_mode="none", + exclusivity="none", + visible=True, + all_visible=True, + ) + + self.widgets = kwargs.get("widgets", None) + + self.notification_history = ( + self.widgets.notification_history if self.widgets else NotificationHistory() + ) + self.notification_container = NotificationContainer( + notification_history_instance=self.notification_history, + revealer_transition_type="slide-down" if y_pos == "top" else "slide-up", + ) + + self.show_box = Box() + self.show_box.set_size_request(1, 1) + + self.add( + Box( + name="notification-popup-box", + orientation="v", + children=[self.notification_container, self.show_box], + ) + ) diff --git a/Ax-Shell/modules/overview.py b/Ax-Shell/modules/overview.py new file mode 100644 index 0000000..ce0638f --- /dev/null +++ b/Ax-Shell/modules/overview.py @@ -0,0 +1,442 @@ +# Thanks to https://github.com/muhchaudhary for the original code. You are a legend. +import json + +import cairo +import gi +from fabric.hyprland.service import Hyprland +from fabric.utils.helpers import get_desktop_applications +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 loguru import logger + +import config.data as data +import modules.icons as icons +# WIP icon resolver (app_id to guessing the icon name) +from utils.icon_resolver import IconResolver + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, Gtk + +screen = Gdk.Screen.get_default() +CURRENT_WIDTH = screen.get_width() +CURRENT_HEIGHT = screen.get_height() + +icon_resolver = IconResolver() +connection = Hyprland() +BASE_SCALE = 0.1 # Base scale factor for overview + +# Credit to Aylur for the drag and drop code +TARGET = [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)] + +# Credit to Aylur for the createSurfaceFromWidget code +def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: + alloc = widget.get_allocation() + surface = cairo.ImageSurface( + cairo.Format.ARGB32, + alloc.width, + alloc.height, + ) + cr = cairo.Context(surface) + cr.set_source_rgba(255, 255, 255, 0) + cr.rectangle(0, 0, alloc.width, alloc.height) + cr.fill() + widget.draw(cr) + return surface + + +class HyprlandWindowButton(Button): + def __init__( + self, + window: Box, + title: str, + address: str, + app_id: str, + size, + transform: int = 0, + ): + self.transform = transform % 4 + self.size = size if transform in [0, 2] else (size[1], size[0]) + self.address = address + self.app_id = app_id + self.title = title + self.window: Box = window + + # Compute dynamic icon sizes based on the button size. + # Using the minimum dimension of the button for scaling. + icon_size_main = int(min(self.size) * 0.5) # adjust factor as needed + + # Enhanced icon resolution using desktop apps + desktop_app = window.find_app(app_id) + + # Get icon using improved method with fallbacks + icon_pixbuf = None + if desktop_app: + icon_pixbuf = desktop_app.get_icon_pixbuf(size=icon_size_main) + + if not icon_pixbuf: + # Fallback to IconResolver + icon_pixbuf = icon_resolver.get_icon_pixbuf(app_id, icon_size_main) + + if not icon_pixbuf: + # Additional fallbacks for common apps + icon_pixbuf = icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", icon_size_main) + if not icon_pixbuf: + icon_pixbuf = icon_resolver.get_icon_pixbuf("image-missing", icon_size_main) + + # Ensure icon is scaled to the correct size + if icon_pixbuf and (icon_pixbuf.get_width() != icon_size_main or icon_pixbuf.get_height() != icon_size_main): + icon_pixbuf = icon_pixbuf.scale_simple( + icon_size_main, + icon_size_main, + gi.repository.GdkPixbuf.InterpType.BILINEAR + ) + + super().__init__( + name="overview-client-box", + image=Image(pixbuf=icon_pixbuf), + tooltip_text=title, + size=size, + on_clicked=self.on_button_click, + on_button_press_event=lambda _, event: connection.send_command( + f"/dispatch closewindow address:{address}" + ) + if event.button == 3 + else None, + on_drag_data_get=lambda _s, _c, data, *_: data.set_text( + address, len(address) + ), + on_drag_begin=lambda _, context: Gtk.drag_set_icon_surface( + context, createSurfaceFromWidget(self) + ), + ) + + # Store the desktop_app for later use + self.desktop_app = desktop_app + + self.drag_source_set( + start_button_mask=Gdk.ModifierType.BUTTON1_MASK, + targets=TARGET, + actions=Gdk.DragAction.COPY, + ) + + self.connect("key_press_event", self.on_key_press_event) + + def on_key_press_event(self, widget, event): + if event.get_state() & Gdk.ModifierType.SHIFT_MASK: + if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_space): + connection.send_command(f"/dispatch closewindow address:{self.address}") + return True + return False + + def update_image(self, image): + # Compute overlay icon size dynamically. + icon_size_overlay = int(min(self.size) * 0.5) # adjust factor as needed + + # Enhanced icon resolution for overlay + icon_pixbuf = None + if hasattr(self, 'desktop_app') and self.desktop_app: + icon_pixbuf = self.desktop_app.get_icon_pixbuf(size=icon_size_overlay) + + if not icon_pixbuf: + icon_pixbuf = icon_resolver.get_icon_pixbuf(self.app_id, icon_size_overlay) + + if not icon_pixbuf: + icon_pixbuf = icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", icon_size_overlay) + if not icon_pixbuf: + icon_pixbuf = icon_resolver.get_icon_pixbuf("image-missing", icon_size_overlay) + + # Ensure icon is scaled to the correct size + if icon_pixbuf and (icon_pixbuf.get_width() != icon_size_overlay or icon_pixbuf.get_height() != icon_size_overlay): + icon_pixbuf = icon_pixbuf.scale_simple( + icon_size_overlay, + icon_size_overlay, + gi.repository.GdkPixbuf.InterpType.BILINEAR + ) + + self.set_image( + Overlay( + child=image, + overlays=Image( + name="overview-icon", + pixbuf=icon_pixbuf, + h_align="center", + v_align="end", + tooltip_text=self.title, + ), + ) + ) + + def on_button_click(self, *_): + connection.send_command(f"/dispatch focuswindow address:{self.address}") + + +class WorkspaceEventBox(EventBox): + def __init__(self, workspace_id: int, fixed: Gtk.Fixed | None = None, monitor_width: int = None, monitor_height: int = None, monitor_scale: float = 1.0): + self.fixed = fixed + + # Use provided monitor dimensions or fallback to current screen + width = monitor_width or CURRENT_WIDTH + height = monitor_height or CURRENT_HEIGHT + + # Workspace containers should maintain consistent size across monitors + # Only use BASE_SCALE, don't multiply by monitor_scale for the container + container_scale = BASE_SCALE + + super().__init__( + name="overview-workspace-bg", + h_expand=True, + v_expand=True, + size=(int(width * container_scale), int(height * container_scale)), + child=fixed + if fixed + else Label( + name="overview-add-label", + h_expand=True, + v_expand=True, + markup=icons.circle_plus, + ), + on_drag_data_received=lambda _w, _c, _x, _y, data, *_: connection.send_command( + f"/dispatch movetoworkspacesilent {workspace_id},address:{data.get_data().decode()}" + ), + ) + self.drag_dest_set( + Gtk.DestDefaults.ALL, + TARGET, + Gdk.DragAction.COPY, + ) + if fixed: + fixed.show_all() + + + +class Overview(Box): + def __init__(self, monitor_id: int = 0, **kwargs): + self.monitor_id = monitor_id + self.monitor_manager = None + self.workspace_start = 1 + self.workspace_end = 10 + + # Get monitor manager and workspace range + try: + from utils.monitor_manager import get_monitor_manager + self.monitor_manager = get_monitor_manager() + self.workspace_start, self.workspace_end = self.monitor_manager.get_workspace_range_for_monitor(monitor_id) + except ImportError: + # Fallback if monitor manager not available + pass + + # Get monitor dimensions + monitor_width = CURRENT_WIDTH + monitor_height = CURRENT_HEIGHT + + if self.monitor_manager: + monitor_info = self.monitor_manager.get_monitor_by_id(monitor_id) + if monitor_info: + monitor_width = monitor_info['width'] + monitor_height = monitor_info['height'] + # Initialize as a Box instead of a PopupWindow. + super().__init__(name="overview", orientation="v", spacing=8, **kwargs) + self.workspace_boxes: dict[int, Box] = {} + self.clients: dict[str, HyprlandWindowButton] = {} + + # Initialize app registry for better icon resolution + self._all_apps = get_desktop_applications() + self.app_identifiers = self._build_app_identifiers_map() + + # Remove the window_class_aliases dictionary completely + + connection.connect("event::openwindow", self.do_update) + connection.connect("event::closewindow", self.do_update) + connection.connect("event::movewindow", self.do_update) + self.update() + + def _normalize_window_class(self, class_name): + """Normalize window class by removing common suffixes and lowercase.""" + if not class_name: + return "" + + normalized = class_name.lower() + + # Remove common suffixes + suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"] + for suffix in suffixes: + if normalized.endswith(suffix): + normalized = normalized[:-len(suffix)] + + return normalized + + def _classes_match(self, class1, class2): + """Check if two window class names match with stricter comparison.""" + if not class1 or not class2: + return False + + # Normalize both classes + norm1 = self._normalize_window_class(class1) + norm2 = self._normalize_window_class(class2) + + # Direct match after normalization + if norm1 == norm2: + return True + + # Don't do substring matching as it's too error-prone + # This avoids incorrectly matching flatpak apps and others + return False + + def _build_app_identifiers_map(self): + """Build a mapping of app identifiers (class names, executables, names) to DesktopApp objects""" + identifiers = {} + for app in self._all_apps: + # Map by name (lowercase) + if app.name: + identifiers[app.name.lower()] = app + + # Map by display name + if app.display_name: + identifiers[app.display_name.lower()] = app + + # Map by window class if available + if app.window_class: + identifiers[app.window_class.lower()] = app + + # Map by executable name if available + if app.executable: + exe_basename = app.executable.split('/')[-1].lower() + identifiers[exe_basename] = app + + # Map by command line if available (without parameters) + if app.command_line: + cmd_base = app.command_line.split()[0].split('/')[-1].lower() + identifiers[cmd_base] = app + + return identifiers + + def find_app(self, app_identifier): + """Return the DesktopApp object by matching any app identifier.""" + if not app_identifier: + return None + + # Try direct lookup in our identifiers map + normalized_id = str(app_identifier).lower() + if normalized_id in self.app_identifiers: + return self.app_identifiers[normalized_id] + + # Try with normalized class name + norm_id = self._normalize_window_class(normalized_id) + if norm_id in self.app_identifiers: + return self.app_identifiers[norm_id] + + # More targeted matching with exact names only + for app in self._all_apps: + if app.name and app.name.lower() == normalized_id: + return app + if app.window_class and app.window_class.lower() == normalized_id: + return app + if app.display_name and app.display_name.lower() == normalized_id: + return app + # Try with executable basename + if app.executable: + exe_base = app.executable.split('/')[-1].lower() + if exe_base == normalized_id: + return app + # Try with command basename + if app.command_line: + cmd_base = app.command_line.split()[0].split('/')[-1].lower() + if cmd_base == normalized_id: + return app + + return None + + def update(self, signal_update=False): + self._all_apps = get_desktop_applications() + self.app_identifiers = self._build_app_identifiers_map() + for client in self.clients.values(): + client.destroy() + self.clients.clear() + for workspace in self.workspace_boxes.values(): + workspace.destroy() + self.workspace_boxes.clear() + + if data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Left", "Right"]: + rows = 5 + cols = 2 + else: + rows = 2 + cols = 5 + + self.children = [Box(spacing=8) for _ in range(rows)] + + # Get monitor dimensions and scale for scaling + monitor_width = CURRENT_WIDTH + monitor_height = CURRENT_HEIGHT + monitor_scale = 1.0 + + if self.monitor_manager: + monitor_info = self.monitor_manager.get_monitor_by_id(self.monitor_id) + if monitor_info: + monitor_width = monitor_info['width'] + monitor_height = monitor_info['height'] + monitor_scale = monitor_info.get('scale', 1.0) + + # Calculate effective scale for this monitor + # Higher scale monitors need larger overview elements to appear the same physical size + effective_scale = BASE_SCALE * monitor_scale + + monitors = { + monitor["id"]: (monitor["x"], monitor["y"], monitor["transform"]) + for monitor in json.loads(connection.send_command("j/monitors").reply.decode()) + } + + # Filter clients to only show those in this monitor's workspace range + for client in json.loads(connection.send_command("j/clients").reply.decode()): + workspace_id = client["workspace"]["id"] + if workspace_id > 0 and self.workspace_start <= workspace_id <= self.workspace_end: + btn = HyprlandWindowButton( + window=self, + title=client["title"], + address=client["address"], + app_id=client["initialClass"], + size=(client["size"][0] * effective_scale, client["size"][1] * effective_scale), + transform=monitors[client["monitor"]][2], + ) + self.clients[client["address"]] = btn + w_id = workspace_id + if w_id not in self.workspace_boxes: + self.workspace_boxes[w_id] = Gtk.Fixed.new() + self.workspace_boxes[w_id].put( + btn, + abs(client["at"][0] - monitors[client["monitor"]][0]) * effective_scale, + abs(client["at"][1] - monitors[client["monitor"]][1]) * effective_scale, + ) + + # Generate workspaces only for this monitor's range + for w_id in range(self.workspace_start, self.workspace_end + 1): + idx = w_id - self.workspace_start + if rows == 2: + row = 0 if idx < cols else 1 + else: + row = idx // cols + overview_row = self.children[row] + overview_row.add( + Box( + name="overview-workspace-box", + orientation="vertical", + children=[ + Label(name="overview-workspace-label", label=f"Workspace {w_id}"), + WorkspaceEventBox( + w_id, + self.workspace_boxes.get(w_id), + monitor_width=monitor_width, + monitor_height=monitor_height, + monitor_scale=monitor_scale + ), + ], + ) + ) + + def do_update(self, *_): + logger.info(f"[Overview] Updating for :{_[1].name}") + self.update(signal_update=True) diff --git a/Ax-Shell/modules/pins.py b/Ax-Shell/modules/pins.py new file mode 100644 index 0000000..d8a8a8f --- /dev/null +++ b/Ax-Shell/modules/pins.py @@ -0,0 +1,498 @@ +import gi + +import config.data as data + +gi.require_version('Gtk', '3.0') +import json +import os +import re +import subprocess +import tempfile +import urllib.parse +import urllib.request +from pathlib import Path + +import cairo +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +import modules.icons as icons + +SAVE_FILE = os.path.expanduser("~/.pins.json") + +icon_size = 80 +if data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]: + icon_size = 36 + +def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: + alloc = widget.get_allocation() + surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height) + cr = cairo.Context(surface) + cr.set_source_rgba(1, 1, 1, 0) + cr.rectangle(0, 0, alloc.width, alloc.height) + cr.fill() + widget.draw(cr) + return surface + +def open_file(filepath): + try: + subprocess.Popen(["xdg-open", filepath]) + except Exception as e: + print("Error opening file:", e) + +def open_url(url): + try: + subprocess.Popen(["xdg-open", url]) + except Exception as e: + print("Error opening URL:", e) + +def is_url(text): + + url_pattern = re.compile( + r'^(https?|ftp)://' + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' + r'localhost|' + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' + r'(?::\d+)?' + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + return bool(url_pattern.match(text)) + +def get_favicon_url(url): + """Extract the base domain from a URL and construct a favicon URL.""" + parsed_url = urllib.parse.urlparse(url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + return f"{base_url}/favicon.ico" + +def download_favicon(url, callback): + """Download a favicon asynchronously and call the callback with the result.""" + favicon_url = get_favicon_url(url) + + def do_download(): + temp_file = None + try: + temp_fd, temp_path = tempfile.mkstemp(suffix='.ico') + os.close(temp_fd) + temp_file = temp_path + + urllib.request.urlretrieve(favicon_url, temp_path) + + GLib.idle_add(callback, temp_path) + except Exception as e: + print(f"Error downloading favicon: {e}") + + if temp_file and os.path.exists(temp_file): + try: + os.remove(temp_file) + except: + pass + GLib.idle_add(callback, None) + + GLib.Thread.new("favicon-download", do_download, None) + +class FileChangeHandler(FileSystemEventHandler): + def __init__(self, app): + self.app = app + + def on_any_event(self, event): + if event.is_directory: + return + + for cell in self.app.cells: + if cell.content_type == 'file' and cell.content: + try: + cell_real = os.path.realpath(cell.content) + src_real = os.path.realpath(event.src_path) + dest_real = os.path.realpath(getattr(event, 'dest_path', '')) + if cell_real == src_real or (dest_real and cell_real == dest_real): + GLib.idle_add(self.handle_file_event, cell, event) + except Exception: + pass + + def handle_file_event(self, cell, event): + if event.event_type == 'deleted': + cell.clear_cell() + self.app.save_state() + elif event.event_type == 'moved': + if hasattr(event, 'dest_path') and os.path.exists(event.dest_path): + cell.content = event.dest_path + cell.update_display() + self.app.save_state() + self.app.add_monitor_for_path(os.path.dirname(event.dest_path)) + +class Cell(Gtk.EventBox): + def __init__(self, app, content=None, content_type=None): + super().__init__(name="pin-cell") + self.app = app + self.content = content + self.content_type = content_type + self.box = Box(name="pin-cell-box", orientation="v", spacing=4) + self.add(self.box) + + + self.favicon_temp_path = None + + target_dest = Gtk.TargetEntry.new("text/uri-list", 0, 0) + self.drag_dest_set(Gtk.DestDefaults.ALL, [target_dest], Gdk.DragAction.COPY) + self.connect("drag-data-received", self.on_drag_data_received) + + targets = [ + Gtk.TargetEntry.new("text/uri-list", 0, 0), + Gtk.TargetEntry.new("text/plain", 0, 1) + ] + self.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, targets, Gdk.DragAction.COPY) + self.connect("drag-data-get", self.on_drag_data_get) + + self.connect("button-press-event", self.on_button_press) + + self.connect("drag-begin", self.on_drag_begin) + + self.update_display() + + def update_display(self): + + if self.favicon_temp_path and os.path.exists(self.favicon_temp_path): + try: + os.remove(self.favicon_temp_path) + self.favicon_temp_path = None + except Exception as e: + print(f"Error removing temp favicon: {e}") + + for child in self.box.get_children(): + self.box.remove(child) + + if self.content is None: + label = Label(name="pin-add", markup=icons.paperclip) + self.box.pack_start(label, True, True, 0) + else: + if self.content_type == 'file': + widget = self.get_file_preview(self.content) + self.box.pack_start(widget, True, True, 0) + label = Label(name="pin-file", label=os.path.basename(self.content), justification="center", ellipsization="middle") + self.box.pack_start(label, False, False, 0) + elif self.content_type == 'text': + if is_url(self.content): + + icon_container = Box(name="pin-icon-container", orientation="v") + self.box.pack_start(icon_container, True, True, 0) + + + url_icon = Label(name="pin-url-icon", markup=icons.world, style=f"font-size: {icon_size}px;") + icon_container.pack_start(url_icon, True, True, 0) + + + domain = re.sub(r'^https?://', '', self.content) + domain = domain.split('/')[0] + label = Label(name="pin-url", label=domain, justification="center", ellipsization="end") + self.box.pack_start(label, False, False, 0) + + + download_favicon( + self.content, + lambda path: self.update_favicon(icon_container, url_icon, path) + ) + else: + + label = Label(name="pin-text", label=self.content.split('\n')[0], justification="center", ellipsization="end", line_wrap="word-char") + self.box.pack_start(label, True, True, 0) + self.box.show_all() + if not self.app.loading_state: + self.app.save_state() + + def update_favicon(self, container, icon_widget, favicon_path): + """Update the icon with the downloaded favicon or keep the default.""" + if not favicon_path or not os.path.exists(favicon_path): + + return + + try: + + self.favicon_temp_path = favicon_path + + + if data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Left", "Right"]: + + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + favicon_path, width=36, height=36, preserve_aspect_ratio=True) + else: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + favicon_path, width=48, height=48, preserve_aspect_ratio=True) + + + container.remove(icon_widget) + + + img = Gtk.Image.new_from_pixbuf(pixbuf) + img.set_name("pin-favicon") + container.pack_start(img, True, True, 0) + + + container.show_all() + except Exception as e: + print(f"Error setting favicon: {e}") + + + def get_file_preview(self, filepath): + try: + file = Gio.File.new_for_path(filepath) + info = file.query_info("standard::content-type", Gio.FileQueryInfoFlags.NONE, None) + content_type = info.get_content_type() + except Exception: + content_type = None + + icon_theme = Gtk.IconTheme.get_default() + + if content_type == "inode/directory": + try: + pixbuf = icon_theme.load_icon("default-folder", icon_size, 0) + return Gtk.Image.new_from_pixbuf(pixbuf) + except Exception: + print("Error loading folder icon") + return Gtk.Image.new_from_icon_name("default-folder", Gtk.IconSize.DIALOG) + + if content_type and content_type.startswith("image/"): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + filepath, width=icon_size, height=icon_size, preserve_aspect_ratio=True) + return Gtk.Image.new_from_pixbuf(pixbuf) + except Exception as e: + print("Error loading image preview:", e) + + elif content_type and content_type.startswith("video/"): + try: + pixbuf = icon_theme.load_icon("video-x-generic", icon_size, 0) + return Gtk.Image.new_from_pixbuf(pixbuf) + except Exception: + print("Error loading video icon") + return Gtk.Image.new_from_icon_name("video-x-generic", Gtk.IconSize.DIALOG) + else: + icon_name = "text-x-generic" + if content_type: + themed_icon = Gio.content_type_get_icon(content_type) + if hasattr(themed_icon, 'get_names'): + names = themed_icon.get_names() + if names: + icon_name = names[0] + try: + pixbuf = icon_theme.load_icon(icon_name, icon_size, 0) + return Gtk.Image.new_from_pixbuf(pixbuf) + except Exception: + print("Error loading icon", icon_name) + return Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) + + def on_drag_data_received(self, widget, drag_context, x, y, data, info, time): + if self.content is None and data.get_length() >= 0: + uris = data.get_uris() + if uris: + try: + filepath, _ = GLib.filename_from_uri(uris[0]) + self.content = filepath + self.content_type = 'file' + self.update_display() + except Exception as e: + print("Error getting file from URI:", e) + drag_context.finish(True, False, time) + + def on_drag_data_get(self, widget, drag_context, data, info, time): + if self.content is None: + return + if info == 0 and self.content_type == 'file': + uri = GLib.filename_to_uri(self.content) + data.set_uris([uri]) + elif info == 1 and self.content_type == 'text': + data.set_text(self.content, -1) + + def on_drag_begin(self, widget, context): + + if self.content_type == 'file': + surface = createSurfaceFromWidget(self) + Gtk.drag_set_icon_surface(context, surface) + + def on_button_press(self, widget, event): + if self.content is None: + if event.button == 1: + self.select_file() + elif event.button == 2: + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + text = clipboard.wait_for_text() + if text: + self.content = text + self.content_type = 'text' + self.update_display() + else: + if self.content_type == 'file': + if event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS: + open_file(self.content) + elif event.button == 3: + self.clear_cell() + elif self.content_type == 'text': + if event.button == 1: + + if is_url(self.content): + + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(self.content, -1) + + + if not (event.state & Gdk.ModifierType.CONTROL_MASK): + open_url(self.content) + else: + + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(self.content, -1) + elif event.button == 3: + self.clear_cell() + return True + + def select_file(self): + dialog = Gtk.FileChooserDialog( + title="Select File", + parent=self.get_toplevel(), + action=Gtk.FileChooserAction.OPEN + ) + dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, Gtk.ResponseType.OK) + if dialog.run() == Gtk.ResponseType.OK: + filepath = dialog.get_filename() + self.content = filepath + self.content_type = 'file' + self.update_display() + dialog.destroy() + + def clear_cell(self): + + if self.favicon_temp_path and os.path.exists(self.favicon_temp_path): + try: + os.remove(self.favicon_temp_path) + self.favicon_temp_path = None + except Exception as e: + print(f"Error removing temp favicon: {e}") + + self.content = None + self.content_type = None + self.update_display() + +class Pins(Gtk.Box): + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + self.loading_state = True + self.monitored_paths = set() + self.observer = Observer() + self.event_handler = FileChangeHandler(self) + + self.cells = [] + + + grid = Gtk.Grid(row_spacing=8, column_spacing=8, name="pin-grid") + grid.set_column_homogeneous(True) + grid.set_row_homogeneous(True) + + + + + + + + + scrolled_window = ScrolledWindow(child=grid, name="scrolled-window", style_classes="pins", propagate_width=False, propagate_height=False) + scrolled_window.set_hexpand(True) + scrolled_window.set_vexpand(True) + scrolled_window.set_halign(Gtk.Align.FILL) + scrolled_window.set_valign(Gtk.Align.FILL) + self.pack_start(scrolled_window, True, True, 0) + + + if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]): + for row in range(10): + for col in range(3): + cell = Cell(self) + self.cells.append(cell) + grid.attach(cell, col, row, 1, 1) + else: + for row in range(6): + for col in range(5): + cell = Cell(self) + self.cells.append(cell) + grid.attach(cell, col, row, 1, 1) + + self.load_state() + self.loading_state = False + self.start_file_monitoring() + + self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY) + self.connect("drag-data-received", self.on_drag_data_received) + + def start_file_monitoring(self): + for cell in self.cells: + if cell.content_type == 'file' and cell.content: + dir_path = os.path.dirname(cell.content) + if os.path.exists(dir_path) and dir_path not in self.monitored_paths: + self.observer.schedule(self.event_handler, dir_path, recursive=False) + self.monitored_paths.add(dir_path) + self.observer.start() + + def add_monitor_for_path(self, path): + if path not in self.monitored_paths and os.path.exists(path): + self.observer.schedule(self.event_handler, path, recursive=False) + self.monitored_paths.add(path) + + def save_state(self): + state = [] + for cell in self.cells: + state.append({ + 'content_type': cell.content_type, + 'content': cell.content + }) + try: + with open(SAVE_FILE, 'w') as f: + json.dump(state, f) + except Exception as e: + print("Error saving state:", e) + + def load_state(self): + if not os.path.exists(SAVE_FILE): + return + try: + with open(SAVE_FILE, 'r') as f: + state = json.load(f) + for i, cell_data in enumerate(state): + if i < len(self.cells): + content = cell_data.get('content') + content_type = cell_data.get('content_type') + self.cells[i].content = content + self.cells[i].content_type = content_type + self.cells[i].update_display() + except Exception as e: + print("Error loading state:", e) + + def on_drag_data_received(self, widget, drag_context, x, y, data, info, time): + if data.get_length() >= 0: + uris = data.get_uris() + for uri in uris: + try: + filepath, _ = GLib.filename_from_uri(uri) + for cell in self.cells: + if cell.content is None: + cell.content = filepath + cell.content_type = 'file' + cell.update_display() + break + except Exception as e: + print("Error getting file from URI:", e) + drag_context.finish(True, False, time) + + def stop_monitoring(self): + + for cell in self.cells: + if hasattr(cell, 'favicon_temp_path') and cell.favicon_temp_path and os.path.exists(cell.favicon_temp_path): + try: + os.remove(cell.favicon_temp_path) + except Exception: + pass + + self.observer.stop() + self.observer.join() diff --git a/Ax-Shell/modules/player.py b/Ax-Shell/modules/player.py new file mode 100644 index 0000000..d67d3a3 --- /dev/null +++ b/Ax-Shell/modules/player.py @@ -0,0 +1,706 @@ +import os +import tempfile +import urllib.parse +import urllib.request + +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.stack import Stack +from gi.repository import Gdk, Gio, GLib, Gtk + +import config.data as data +import modules.icons as icons +from modules.cavalcade import SpectrumRender +from services.mpris import MprisPlayer, MprisPlayerManager +from widgets.circle_image import CircleImage + +vertical_mode = False +if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]): + vertical_mode = True + +def get_player_icon_markup_by_name(player_name): + if player_name: + pn = player_name.lower() + if pn == "firefox": + return icons.firefox + elif pn == "spotify": + return icons.spotify + elif pn in ("chromium", "brave"): + return icons.chromium + return icons.disc + +def add_hover_cursor(widget): + widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) + widget.connect("enter-notify-event", lambda w, event: w.get_window().set_cursor( + Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "pointer"))) + widget.connect("leave-notify-event", lambda w, event: w.get_window().set_cursor(None)) + +class PlayerBox(Box): + def __init__(self, mpris_player=None): + super().__init__(orientation="v", h_align="fill", spacing=0, h_expand=False, v_expand=not vertical_mode) + self.mpris_player = mpris_player + self._progress_timer_id = None + + self.cover = CircleImage( + name="player-cover", + image_file=os.path.expanduser("~/.current.wall"), + size=162 if not vertical_mode else 96, + h_align="center", + v_align="center", + ) + self.cover_placerholder = CircleImage( + name="player-cover", + size=198 if not vertical_mode else 132, + h_align="center", + v_align="center", + ) + self.title = Label(name="player-title", h_expand=True, h_align="fill", ellipsization="end", max_chars_width=1, style_classes=["vertical"] if vertical_mode else []) + self.album = Label(name="player-album", h_expand=True, h_align="fill", ellipsization="end", max_chars_width=1) + self.artist = Label(name="player-artist", h_expand=True, h_align="fill", ellipsization="end", max_chars_width=1) + self.progressbar = CircularProgressBar( + name="player-progress", + size=198 if not vertical_mode else 132, + h_align="center", + v_align="center", + start_angle=180, + end_angle=360, + ) + self.time = Label(name="player-time", label="--:-- / --:--") + self.overlay = Overlay( + child=self.cover_placerholder, + overlays=[self.progressbar, self.cover], + ) + self.overlay_container = CenterBox(name="player-overlay", center_children=[self.overlay]) + self.title.set_label("Nothing Playing") + self.album.set_label("Enjoy the silence") + self.artist.set_label("ยฏ\\_(ใƒ„)_/ยฏ") + self.progressbar.set_value(0.0) + self.prev = Button( + name="player-btn", + child=Label(name="player-btn-label", markup=icons.prev), + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + self.backward = Button( + name="player-btn", + child=Label(name="player-btn-label", markup=icons.skip_back), + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + self.play_pause = Button( + name="player-btn", + child=Label(name="player-btn-label", markup=icons.play, style_classes=["play-pause"]), + style_classes=["play-pause"], + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + self.forward = Button( + name="player-btn", + child=Label(name="player-btn-label", markup=icons.skip_forward), + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + self.next = Button( + name="player-btn", + child=Label(name="player-btn-label", markup=icons.next), + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + add_hover_cursor(self.prev) + add_hover_cursor(self.backward) + add_hover_cursor(self.play_pause) + add_hover_cursor(self.forward) + add_hover_cursor(self.next) + self.btn_box = CenterBox( + name="player-btn-box", + orientation="h", + center_children=[ + Box( + orientation="h", + spacing=8, + h_expand=True, + h_align="fill", + children=[ + self.prev, + self.backward, + self.play_pause, + self.forward, + self.next, + ] + ) + ] + ) + + self.p_children=[ + self.overlay_container, + self.title, + self.album, + self.artist, + self.btn_box, + self.time, + ] if not vertical_mode else [ + self.overlay_container, + Box( + orientation="v", + spacing=4, + h_expand=True, + h_align="fill", + v_expand=False, + v_align="center", + children=[ + self.title, + self.album, + self.btn_box, + self.artist, + self.time, + ] + ) + ] + + self.player_box = Box( + name="player-box", + orientation="v" if not vertical_mode else "h", + v_align="center", + spacing=4, + children=self.p_children, + ) + self.add(self.player_box) + if mpris_player: + self._apply_mpris_properties() + self.prev.connect("clicked", self._on_prev_clicked) + self.play_pause.connect("clicked", self._on_play_pause_clicked) + self.backward.connect("clicked", self._on_backward_clicked) + self.forward.connect("clicked", self._on_forward_clicked) + self.next.connect("clicked", self._on_next_clicked) + self.mpris_player.connect("changed", self._on_mpris_changed) + else: + self.play_pause.get_child().set_markup(icons.stop) + self.play_pause.add_style_class("stop") + + self.backward.add_style_class("disabled") + self.forward.add_style_class("disabled") + self.prev.add_style_class("disabled") + self.next.add_style_class("disabled") + self.progressbar.set_value(0.0) + self.time.set_text("--:-- / --:--") + + def _apply_mpris_properties(self): + mp = self.mpris_player + self.title.set_visible(bool(mp.title and mp.title.strip())) + if mp.title and mp.title.strip(): + self.title.set_text(mp.title) + self.album.set_visible(bool(mp.album and mp.album.strip())) + if mp.album and mp.album.strip(): + self.album.set_text(mp.album) + self.artist.set_visible(bool(mp.artist and mp.artist.strip())) + if mp.artist and mp.artist.strip(): + self.artist.set_text(mp.artist) + if mp.arturl: + parsed = urllib.parse.urlparse(mp.arturl) + if parsed.scheme == "file": + local_arturl = urllib.parse.unquote(parsed.path) + self._set_cover_image(local_arturl) + elif parsed.scheme in ("http", "https"): + GLib.Thread.new("download-artwork", self._download_and_set_artwork, mp.arturl) + else: + self._set_cover_image(mp.arturl) + else: + fallback = os.path.expanduser("~/.current.wall") + self._set_cover_image(fallback) + file_obj = Gio.File.new_for_path(fallback) + monitor = file_obj.monitor_file(Gio.FileMonitorFlags.NONE, None) + monitor.connect("changed", self.on_wallpaper_changed) + self._wallpaper_monitor = monitor + self.update_play_pause_icon() + + self.progressbar.set_visible(True) + self.time.set_visible(True) + + player_name = mp.player_name.lower() if hasattr(mp, "player_name") and mp.player_name else "" + can_seek = hasattr(mp, "can_seek") and mp.can_seek + + if player_name == "firefox" or not can_seek: + # Firefox and non-seekable players don't support progress tracking + self.backward.add_style_class("disabled") + self.forward.add_style_class("disabled") + self.progressbar.set_value(0.0) + self.time.set_text("--:-- / --:--") + # Stop the timer since we can't track progress + if self._progress_timer_id: + GLib.source_remove(self._progress_timer_id) + self._progress_timer_id = None + else: + # Enable seeking controls + self.backward.remove_style_class("disabled") + self.forward.remove_style_class("disabled") + + # Use adaptive timer based on playback status instead of fixed 1-second polling + self._start_adaptive_progress_timer() + + if hasattr(mp, "can_go_previous") and mp.can_go_previous: + self.prev.remove_style_class("disabled") + else: + self.prev.add_style_class("disabled") + + if hasattr(mp, "can_go_next") and mp.can_go_next: + self.next.remove_style_class("disabled") + else: + self.next.add_style_class("disabled") + + def _start_adaptive_progress_timer(self): + """Start progress timer with adaptive interval based on playback status""" + if self._progress_timer_id: + GLib.source_remove(self._progress_timer_id) + + # Use longer intervals when paused to reduce CPU usage + if hasattr(self.mpris_player, 'playback_status') and self.mpris_player.playback_status == "playing": + interval = 1000 # 1 second when playing + else: + interval = 5000 # 5 seconds when paused/stopped + + self._progress_timer_id = GLib.timeout_add(interval, self._update_progress) + self._update_progress() # Update immediately + + def _set_cover_image(self, image_path): + if image_path and os.path.isfile(image_path): + self.cover.set_image_from_file(image_path) + else: + fallback = os.path.expanduser("~/.current.wall") + self.cover.set_image_from_file(fallback) + file_obj = Gio.File.new_for_path(fallback) + monitor = file_obj.monitor_file(Gio.FileMonitorFlags.NONE, None) + monitor.connect("changed", self.on_wallpaper_changed) + self._wallpaper_monitor = monitor + + def _download_and_set_artwork(self, arturl): + """ + Download the artwork from the given URL asynchronously and update the cover image + using GLib.idle_add to ensure UI updates occur on the main thread. + """ + try: + parsed = urllib.parse.urlparse(arturl) + suffix = os.path.splitext(parsed.path)[1] or ".png" + with urllib.request.urlopen(arturl) as response: + data = response.read() + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + temp_file.write(data) + temp_file.close() + local_arturl = temp_file.name + except Exception: + local_arturl = os.path.expanduser("~/.current.wall") + GLib.idle_add(self._set_cover_image, local_arturl) + return None + + def update_play_pause_icon(self): + if self.mpris_player.playback_status == "playing": + self.play_pause.get_child().set_markup(icons.pause) + self.play_pause.add_style_class("playing") + else: + self.play_pause.get_child().set_markup(icons.play) + self.play_pause.remove_style_class("playing") + + def on_wallpaper_changed(self, monitor, file, other_file, event): + self.cover.set_image_from_file(os.path.expanduser("~/.current.wall")) + + def _on_prev_clicked(self, button): + if self.mpris_player: + self.mpris_player.previous() + + def _on_play_pause_clicked(self, button): + if self.mpris_player: + self.mpris_player.play_pause() + self.update_play_pause_icon() + + def _on_backward_clicked(self, button): + + if self.mpris_player and self.mpris_player.can_seek and "disabled" not in self.backward.get_style_context().list_classes(): + new_pos = max(0, self.mpris_player.position - 5000000) + self.mpris_player.position = new_pos + + def _on_forward_clicked(self, button): + + if self.mpris_player and self.mpris_player.can_seek and "disabled" not in self.forward.get_style_context().list_classes(): + new_pos = self.mpris_player.position + 5000000 + self.mpris_player.position = new_pos + + def _on_next_clicked(self, button): + if self.mpris_player: + self.mpris_player.next() + + def _update_progress(self): + + if not self.mpris_player: + + if self._progress_timer_id: + GLib.source_remove(self._progress_timer_id) + self._progress_timer_id = None + return False + + try: + current = self.mpris_player.position + except Exception: + current = 0 + try: + total = int(self.mpris_player.length or 0) + except Exception: + total = 0 + + if total <= 0: + progress = 0.0 + self.time.set_text("--:-- / --:--") + + else: + progress = (current / total) + self.time.set_text(f"{self._format_time(current)} / {self._format_time(total)}") + + self.progressbar.set_value(progress) + return True + + def _format_time(self, us): + seconds = int(us / 1000000) + minutes = seconds // 60 + seconds = seconds % 60 + return f"{minutes}:{seconds:02}" + + def _update_metadata(self): + if not self.mpris_player: + return False + self._apply_mpris_properties() + return True + + def _on_mpris_changed(self, *args): + + if not hasattr(self, "_update_pending") or not self._update_pending: + self._update_pending = True + + GLib.idle_add(self._apply_mpris_properties_debounced) + + def _apply_mpris_properties_debounced(self): + """Apply MPRIS properties with debouncing and restart adaptive timer""" + if self.mpris_player: + self._apply_mpris_properties() + else: + # Clean up timer when player is removed + if self._progress_timer_id: + GLib.source_remove(self._progress_timer_id) + self._progress_timer_id = None + self._update_pending = False + return False + +class Player(Box): + def __init__(self): + super().__init__(name="player", orientation="v", h_align="fill", spacing=0, h_expand=False, v_expand=not vertical_mode) + self.player_stack = Stack( + name="player-stack", + transition_type="slide-left-right", + transition_duration=500, + v_align="center", + v_expand=not vertical_mode, + ) + self.switcher = Gtk.StackSwitcher( + name="player-switcher" if not vertical_mode else "player-switcher-vertical", + spacing=8, + ) + self.switcher.set_stack(self.player_stack) + self.switcher.set_halign(Gtk.Align.CENTER) + self.mpris_manager = MprisPlayerManager() + players = self.mpris_manager.players + if players: + for p in players: + mp = MprisPlayer(p) + pb = PlayerBox(mpris_player=mp) + self.player_stack.add_titled(pb, mp.player_name, mp.player_name) + else: + pb = PlayerBox(mpris_player=None) + self.player_stack.add_titled(pb, "nothing", "Nothing Playing") + self.mpris_manager.connect("player-appeared", self.on_player_appeared) + self.mpris_manager.connect("player-vanished", self.on_player_vanished) + self.switcher.set_visible(True) + self.add(self.player_stack) + self.add(self.switcher) + GLib.idle_add(self._replace_switcher_labels) + + def on_player_appeared(self, manager, player): + children = self.player_stack.get_children() + if len(children) == 1 and not getattr(children[0], "mpris_player", None): + self.player_stack.remove(children[0]) + mp = MprisPlayer(player) + pb = PlayerBox(mpris_player=mp) + self.player_stack.add_titled(pb, mp.player_name, mp.player_name) + + self.switcher.set_visible(True) + GLib.idle_add(lambda: self._update_switcher_for_player(mp.player_name)) + GLib.idle_add(self._replace_switcher_labels) + + def on_player_vanished(self, manager, player_name): + for child in self.player_stack.get_children(): + if hasattr(child, "mpris_player") and child.mpris_player and child.mpris_player.player_name == player_name: + self.player_stack.remove(child) + break + if not any(getattr(child, "mpris_player", None) for child in self.player_stack.get_children()): + pb = PlayerBox(mpris_player=None) + self.player_stack.add_titled(pb, "nothing", "Nothing Playing") + self.switcher.set_visible(True) + GLib.idle_add(self._replace_switcher_labels) + + def _replace_switcher_labels(self): + buttons = self.switcher.get_children() + for btn in buttons: + if isinstance(btn, Gtk.ToggleButton): + default_label = None + for child in btn.get_children(): + if isinstance(child, Gtk.Label): + default_label = child + break + if default_label: + label_player_name = getattr(default_label, "player_name", default_label.get_text().lower()) + icon_markup = get_player_icon_markup_by_name(label_player_name) + btn.remove(default_label) + new_label = Label(name="player-label", markup=icon_markup) + new_label.player_name = label_player_name + btn.add(new_label) + new_label.show_all() + return False + + def _update_switcher_for_player(self, player_name): + for btn in self.switcher.get_children(): + if isinstance(btn, Gtk.ToggleButton): + default_label = None + for child in btn.get_children(): + if isinstance(child, Gtk.Label): + default_label = child + break + if default_label: + label_player_name = getattr(default_label, "player_name", default_label.get_text().lower()) + if label_player_name == player_name.lower(): + icon_markup = get_player_icon_markup_by_name(player_name) + btn.remove(default_label) + new_label = Label(name="player-label", markup=icon_markup) + new_label.player_name = player_name.lower() + btn.add(new_label) + new_label.show_all() + return False + +class PlayerSmall(CenterBox): + def __init__(self): + super().__init__(name="player-small", orientation="h", h_align="fill", v_align="center") + self._show_artist = False + self._display_options = ["cavalcade", "title", "artist"] + self._display_index = 0 + self._current_display = "cavalcade" + + self.mpris_icon = Button( + name="compact-mpris-icon", + h_align="center", + v_align="center", + child=Label(name="compact-mpris-icon-label", markup=icons.disc) + ) + + self.mpris_icon.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + self.mpris_icon.connect("button-press-event", self._on_icon_button_press) + + child = self.mpris_icon.get_child() + child.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + child.connect("button-press-event", lambda widget, event: True) + + add_hover_cursor(self.mpris_icon) + + self.mpris_label = Label( + name="compact-mpris-label", + label="Nothing Playing", + ellipsization="end", + max_chars_width=26, + h_align="center", + ) + self.mpris_button = Button( + name="compact-mpris-button", + h_align="center", + v_align="center", + child=Label(name="compact-mpris-button-label", markup=icons.play) + ) + self.mpris_button.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + self.mpris_button.connect("button-press-event", self._on_play_pause_button_press) + + add_hover_cursor(self.mpris_button) + + self.cavalcade = SpectrumRender() + self.cavalcade_box = self.cavalcade.get_spectrum_box() + + self.center_stack = Stack( + name="compact-mpris", + transition_type="crossfade", + transition_duration=100, + v_align="center", + v_expand=False, + children=[ + self.cavalcade_box, + self.mpris_label, + ] + ) + self.center_stack.set_visible_child(self.cavalcade_box) + + self.mpris_small = CenterBox( + name="compact-mpris", + orientation="h", + h_expand=True, + h_align="fill", + v_align="center", + v_expand=False, + start_children=self.mpris_icon, + center_children=self.center_stack, + end_children=self.mpris_button, + ) + + self.add(self.mpris_small) + + self.mpris_manager = MprisPlayerManager() + self.mpris_player = None + + self.current_index = 0 + + players = self.mpris_manager.players + if players: + mp = MprisPlayer(players[self.current_index]) + self.mpris_player = mp + self._apply_mpris_properties() + self.mpris_player.connect("changed", self._on_mpris_changed) + else: + self._apply_mpris_properties() + + self.mpris_manager.connect("player-appeared", self.on_player_appeared) + self.mpris_manager.connect("player-vanished", self.on_player_vanished) + self.mpris_button.connect("clicked", self._on_play_pause_clicked) + + def _apply_mpris_properties(self): + if not self.mpris_player: + self.mpris_label.set_text("Nothing Playing") + self.mpris_button.get_child().set_markup(icons.stop) + self.mpris_icon.get_child().set_markup(icons.disc) + if self._current_display != "cavalcade": + self.center_stack.set_visible_child(self.mpris_label) + else: + self.center_stack.set_visible_child(self.cavalcade_box) + return + + mp = self.mpris_player + + player_name = mp.player_name.lower() if hasattr(mp, "player_name") and mp.player_name else "" + icon_markup = get_player_icon_markup_by_name(player_name) + self.mpris_icon.get_child().set_markup(icon_markup) + self.update_play_pause_icon() + + if self._current_display == "title": + text = (mp.title if mp.title and mp.title.strip() else "Nothing Playing") + self.mpris_label.set_text(text) + self.center_stack.set_visible_child(self.mpris_label) + elif self._current_display == "artist": + text = (mp.artist if mp.artist else "Nothing Playing") + self.mpris_label.set_text(text) + self.center_stack.set_visible_child(self.mpris_label) + else: + self.center_stack.set_visible_child(self.cavalcade_box) + + def _on_icon_button_press(self, widget, event): + from gi.repository import Gdk + if event.type == Gdk.EventType.BUTTON_PRESS: + players = self.mpris_manager.players + if not players: + return True + + if event.button == 2: + self._display_index = (self._display_index + 1) % len(self._display_options) + self._current_display = self._display_options[self._display_index] + self._apply_mpris_properties() + return True + + if event.button == 1: + self.current_index = (self.current_index + 1) % len(players) + elif event.button == 3: + self.current_index = (self.current_index - 1) % len(players) + if self.current_index < 0: + self.current_index = len(players) - 1 + + mp_new = MprisPlayer(players[self.current_index]) + self.mpris_player = mp_new + + self.mpris_player.connect("changed", self._on_mpris_changed) + self._apply_mpris_properties() + return True + return True + + def _on_play_pause_button_press(self, widget, event): + if event.type == Gdk.EventType.BUTTON_PRESS: + if event.button == 1: + if self.mpris_player: + self.mpris_player.previous() + self.mpris_button.get_child().set_markup(icons.prev) + GLib.timeout_add(500, self._restore_play_pause_icon) + elif event.button == 3: + if self.mpris_player: + self.mpris_player.next() + self.mpris_button.get_child().set_markup(icons.next) + GLib.timeout_add(500, self._restore_play_pause_icon) + elif event.button == 2: + if self.mpris_player: + self.mpris_player.play_pause() + self.update_play_pause_icon() + return True + return True + + def _restore_play_pause_icon(self): + self.update_play_pause_icon() + return False + + def _on_icon_clicked(self, widget): + pass + + def update_play_pause_icon(self): + if self.mpris_player and self.mpris_player.playback_status == "playing": + self.mpris_button.get_child().set_markup(icons.pause) + else: + self.mpris_button.get_child().set_markup(icons.play) + + def _on_play_pause_clicked(self, button): + if self.mpris_player: + self.mpris_player.play_pause() + self.update_play_pause_icon() + + def _on_mpris_changed(self, *args): + + self._apply_mpris_properties() + + def on_player_appeared(self, manager, player): + + if not self.mpris_player: + mp = MprisPlayer(player) + self.mpris_player = mp + self._apply_mpris_properties() + self.mpris_player.connect("changed", self._on_mpris_changed) + + def on_player_vanished(self, manager, player_name): + players = self.mpris_manager.players + if players and self.mpris_player and self.mpris_player.player_name == player_name: + if players: + self.current_index = self.current_index % len(players) + new_player = MprisPlayer(players[self.current_index]) + self.mpris_player = new_player + self.mpris_player.connect("changed", self._on_mpris_changed) + else: + self.mpris_player = None + elif not players: + self.mpris_player = None + self._apply_mpris_properties() diff --git a/Ax-Shell/modules/power.py b/Ax-Shell/modules/power.py new file mode 100644 index 0000000..c42dcf5 --- /dev/null +++ b/Ax-Shell/modules/power.py @@ -0,0 +1,131 @@ +from fabric.utils.helpers import exec_shell_command_async +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label + +import config.data as data +import modules.icons as icons + +tooltip_lock = "Lock" +tooltip_suspend = "Suspend" +tooltip_logout = "Logout" +tooltip_reboot = "Reboot" +tooltip_shutdown = "Shutdown" + + +class PowerMenu(Box): + def __init__(self, **kwargs): + orientation = "h" + if data.PANEL_THEME == "Panel" and ( + data.BAR_POSITION in ["Left", "Right"] + or data.PANEL_POSITION in ["Start", "End"] + ): + orientation = "v" + + super().__init__( + name="power-menu", + orientation=orientation, + spacing=4, + v_align="center", + h_align="center", + visible=True, + **kwargs, + ) + + self.notch = kwargs["notch"] + + self.btn_lock = Button( + name="power-menu-button", + tooltip_markup=tooltip_lock, + child=Label(name="button-label", markup=icons.lock), + on_clicked=self.lock, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_suspend = Button( + name="power-menu-button", + tooltip_markup=tooltip_suspend, + child=Label(name="button-label", markup=icons.suspend), + on_clicked=self.suspend, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_logout = Button( + name="power-menu-button", + tooltip_markup=tooltip_logout, + child=Label(name="button-label", markup=icons.logout), + on_clicked=self.logout, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_reboot = Button( + name="power-menu-button", + tooltip_markup=tooltip_reboot, + child=Label(name="button-label", markup=icons.reboot), + on_clicked=self.reboot, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_shutdown = Button( + name="power-menu-button", + tooltip_markup=tooltip_shutdown, + child=Label(name="button-label", markup=icons.shutdown), + on_clicked=self.poweroff, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.buttons = [ + self.btn_lock, + self.btn_suspend, + self.btn_logout, + self.btn_reboot, + self.btn_shutdown, + ] + + for button in self.buttons: + self.add(button) + + self.show_all() + + def close_menu(self): + self.notch.close_notch() + + def lock(self, *args): + print("Locking screen...") + exec_shell_command_async("loginctl lock-session") + self.close_menu() + + def suspend(self, *args): + print("Suspending system...") + exec_shell_command_async("systemctl suspend") + self.close_menu() + + def logout(self, *args): + print("Logging out...") + exec_shell_command_async("hyprctl dispatch exit") + self.close_menu() + + def reboot(self, *args): + print("Rebooting system...") + exec_shell_command_async("systemctl reboot") + self.close_menu() + + def poweroff(self, *args): + print("Powering off...") + exec_shell_command_async("systemctl poweroff") + self.close_menu() diff --git a/Ax-Shell/modules/shader.py b/Ax-Shell/modules/shader.py new file mode 100644 index 0000000..be71e39 --- /dev/null +++ b/Ax-Shell/modules/shader.py @@ -0,0 +1,346 @@ +from collections.abc import Iterable +from enum import Enum +from typing import Literal, cast, overload + +import gi +import OpenGL.GL as GL +from fabric import Property, Signal +from fabric.widgets.widget import Widget +from OpenGL.GL.shaders import compileProgram, compileShader + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk + + +class ShadertoyUniformType(Enum): + FLOAT = 1 + INTEGER = 2 + VECTOR = 3 + TEXTURE = 4 + +class ShadertoyCompileError(Exception): ... + +class Shadertoy(Gtk.GLArea, Widget): + @Signal + def ready(self) -> None: ... + + @Property(str, "read-write") + def shader_buffer(self) -> str: + return self._shader_buffer + + @shader_buffer.setter + def shader_buffer(self, shader_buffer: str) -> None: + self._shader_buffer = shader_buffer + if not self._ready: + return + self._shader_uniforms.clear() + self.do_realize() + self.queue_draw() + return + + DEFAULT_VERTEX_SHADER = """ + + in vec2 position; + + void main() { + gl_Position = vec4(position, 0.0, 1.0); + } + """ + + DEFAULT_FRAGMENT_UNIFORMS = """ + + uniform vec3 iResolution; // viewport resolution (in pixels) + uniform float iTime; // shader playback time (in seconds) + uniform float iTimeDelta; // render time (in seconds) + uniform float iFrameRate; // shader frame rate + uniform int iFrame; // shader playback frame + uniform float iChannelTime[4]; // channel playback time (in seconds) + uniform vec3 iChannelResolution[4]; // channel resolution (in pixels) + uniform vec4 iMouse; // mouse pixel coords. xy: current (if MLB down), zw: click + uniform sampler2D iChannel0; // input channel. XX = 2D/Cube + uniform sampler2D iChannel1; + uniform sampler2D iChannel2; + uniform sampler2D iChannel3; + uniform vec4 iDate; // (year, month, day, time in seconds) + uniform float iSampleRate; // sound sample rate (i.e., 44100) + + """ + + FRAGMENT_MAIN_FUNCTION = """ + void main() { + mainImage(gl_FragColor, gl_FragCoord.xy); + } + """ + + def __init__( + self, + shader_buffer: str, + shader_uniforms: list[ + tuple[ + str, + ShadertoyUniformType, + bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf, + ] + ] + | 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, + ): + Gtk.GLArea.__init__( + self + ) + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self._shader_buffer = shader_buffer + self._shader_uniforms = shader_uniforms or [] + + self.set_required_version(3, 3) + self.set_has_depth_buffer(False) + self.set_has_stencil_buffer(False) + + self._ready = False + self._program = None + self._vao = None + self._quad_vbo = None + self._texture_units = {} + + self._start_time = GLib.get_monotonic_time() / 1e6 + self._frame_time = self._start_time + self._frame_count = 0 + + self._tick_id = self.add_tick_callback(lambda *_: (self.queue_draw(), True)[1]) + + def do_bake_program(self): + try: + vertex_shader = compileShader( + self.DEFAULT_VERTEX_SHADER, GL.GL_VERTEX_SHADER + ) + fragment_shader = compileShader( + self.DEFAULT_FRAGMENT_UNIFORMS + + self._shader_buffer + + self.FRAGMENT_MAIN_FUNCTION, + GL.GL_FRAGMENT_SHADER, + ) + except Exception as e: + raise ShadertoyCompileError( + f"couldn't compile the provided shader, OpenGL error:\n {e}" + ) + + return compileProgram(vertex_shader, fragment_shader) + + def do_realize(self, *_): + Gtk.GLArea.do_realize(self) + if not self._ready: + ctx = self.get_context() + if (err := self.get_error()) or not ctx: + raise RuntimeError( + f"couldn't initialize the drawing context, error: {err or 'context is None'}" + ) + + ctx.make_current() + + if self._program: + GL.glDeleteProgram(self._program) + self._program = None + self._program = self.do_bake_program() + + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + + self._quad_vbo = GL.glGenBuffers(1) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._quad_vbo) + + quad_verts = (-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0) + array_type = GL.GLfloat * len(quad_verts) + + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + len(quad_verts) * 4, + array_type(*quad_verts), + GL.GL_STATIC_DRAW, + ) + + self._vao = GL.glGenVertexArrays(1) + GL.glBindVertexArray(self._vao) + + position = GL.glGetAttribLocation(self._program, "position") + GL.glEnableVertexAttribArray(position) + GL.glVertexAttribPointer(position, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + + for uname, utype, uvalue in self._shader_uniforms: + self.set_uniform(uname, utype, uvalue) + + self._ready = True + self.ready() + return + + def do_get_timing(self) -> tuple[float, float, float]: + current_time = GLib.get_monotonic_time() / 1e6 + delta_time = current_time - self._frame_time + return current_time, delta_time, (1.0 / delta_time) if delta_time > 0 else 0.0 + + def do_post_render(self, time: float): + self._frame_time = time + self._frame_count += 1 + return + + def do_render(self, ctx: Gdk.GLContext): + if not self._program: + if self._tick_id: + self.remove_tick_callback(self._tick_id) + self._tick_id = 0 + return False + + GL.glUseProgram(self._program) + + GL.glClear(GL.GL_COLOR_BUFFER_BIT) + + alloc = self.get_allocation() + width: int = alloc.width + height: int = alloc.height + mouse_pos = cast(tuple[int, int], self.get_pointer()) + + current_time, delta_time, frame_rate = self.do_get_timing() + + self.set_uniform( + "iTime", ShadertoyUniformType.FLOAT, current_time - self._start_time + ) + self.set_uniform("iFrame", ShadertoyUniformType.INTEGER, self._frame_count) + self.set_uniform("iTimeDelta", ShadertoyUniformType.FLOAT, delta_time) + self.set_uniform("iFrameRate", ShadertoyUniformType.FLOAT, frame_rate) + self.set_uniform( + "iResolution", ShadertoyUniformType.VECTOR, (width, height, 1.0) + ) + self.set_uniform( + "iMouse", + ShadertoyUniformType.VECTOR, + (mouse_pos[0], height - mouse_pos[1], 0, 0), + ) + + GL.glBindVertexArray(self._vao) + GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4) + self.do_post_render(current_time) + return True + + def do_resize(self, width: int, height: int): + Gtk.GLArea.do_resize(self, width, height) + GL.glViewport(0, 0, width, height) + return + + @overload + def set_uniform( + self, name: str, type: Literal[ShadertoyUniformType.FLOAT], value: float + ): ... + + @overload + def set_uniform( + self, name: str, type: Literal[ShadertoyUniformType.INTEGER], value: int + ): ... + + @overload + def set_uniform( + self, + name: str, + type: Literal[ShadertoyUniformType.VECTOR], + value: tuple[float, ...], + ): ... + + @overload + def set_uniform( + self, + name: str, + type: Literal[ShadertoyUniformType.TEXTURE], + value: GdkPixbuf.Pixbuf, + ): ... + + def set_uniform( + self, + name: str, + type: ShadertoyUniformType, + value: bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf, + ): + if not self._program: + raise RuntimeError("the shader program is not initialized") + GL.glUseProgram(self._program) + location = GL.glGetUniformLocation(self._program, name) + match type: + case ShadertoyUniformType.VECTOR: + value = cast(tuple[float, ...], value) + ( + GL.glUniform2f + if (vlen := len(value)) == 2 + else GL.glUniform3f + if vlen == 3 + else GL.glUniform4f + )(location, *value) + case ShadertoyUniformType.FLOAT: + GL.glUniform1f(location, value) + case ShadertoyUniformType.INTEGER: + GL.glUniform1i(location, value) + case ShadertoyUniformType.TEXTURE: + + value = cast(GdkPixbuf.Pixbuf, value).flip(False) + format = GL.GL_RGBA if value.get_has_alpha() else GL.GL_RGB + + if name not in self._texture_units: + texture = GL.glGenTextures(1) + self._texture_units[name] = (len(self._texture_units), texture) + else: + texture_unit, texture = self._texture_units[name] + + texture_unit = self._texture_units[name][0] + GL.glActiveTexture(GL.GL_TEXTURE0 + texture_unit) + GL.glBindTexture(GL.GL_TEXTURE_2D, texture) + + GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_REPEAT) + GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_REPEAT) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR + ) + + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + 0, + format, + value.get_width(), + value.get_height(), + 0, + format, + GL.GL_UNSIGNED_BYTE, + value.get_pixels(), + ) + GL.glGenerateMipmap(GL.GL_TEXTURE_2D) + + GL.glUniform1i(location, texture_unit) diff --git a/Ax-Shell/modules/systemprofiles.py b/Ax-Shell/modules/systemprofiles.py new file mode 100644 index 0000000..ad6c307 --- /dev/null +++ b/Ax-Shell/modules/systemprofiles.py @@ -0,0 +1,154 @@ +import subprocess + +from fabric.utils.helpers import exec_shell_command_async +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label + +import config.data as data +import modules.icons as icons + + +class Systemprofiles(Box): + def __init__(self, **kwargs): + super().__init__( + name="systemprofiles", + orientation="h" if not data.VERTICAL else "v", + spacing=3, + ) + + if data.BAR_THEME == "Dense" or data.BAR_THEME == "Edge": + self.add_style_class("invert") + + self.bat_save = None + self.bat_balanced = None + self.bat_perf = None + + children = [] + + try: + result = subprocess.run( + ["powerprofilesctl", "list"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + available_profiles = result.stdout + except (subprocess.CalledProcessError, FileNotFoundError): + available_profiles = "" + + if "power-saver" in available_profiles: + self.bat_save = Button( + name="battery-save", + child=Label(name="battery-save-label", markup=icons.power_saving), + on_clicked=lambda *_: self.set_power_mode("power-saver"), + tooltip_text="Power saving mode", + ) + children.append(self.bat_save) + + if "balanced" in available_profiles: + self.bat_balanced = Button( + name="battery-balanced", + child=Label(name="battery-balanced-label", markup=icons.power_balanced), + on_clicked=lambda *_: self.set_power_mode("balanced"), + tooltip_text="Balanced mode", + ) + children.append(self.bat_balanced) + + if "performance" in available_profiles: + self.bat_perf = Button( + name="battery-performance", + child=Label( + name="battery-performance-label", markup=icons.power_performance + ), + on_clicked=lambda *_: self.set_power_mode("performance"), + tooltip_text="Performance mode", + ) + children.append(self.bat_perf) + + # Group the mode buttons into a container. + if children: + self.add( + Box( + name="power-mode-switcher", + orientation="h" if not data.VERTICAL else "v", + spacing=4, + children=children, + ) + ) + + if data.BAR_COMPONENTS_VISIBILITY.get("sysprofiles", False): + self.get_current_power_mode() + self.hide_timer = None + self.hover_counter = 0 + # self.set_power_mode("balanced") + + def get_current_power_mode(self): + try: + # Run the command to get the current power mode + result = subprocess.run( + ["powerprofilesctl", "get"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + + # Get the output and strip unnecessary whitespace + output = result.stdout.strip() + + # Validate the output + if output in ["power-saver", "balanced", "performance"]: + self.current_mode = output + else: + self.current_mode = "balanced" + + # Update button styles based on the current mode + self.update_button_styles() + + except subprocess.CalledProcessError as err: + print(f"Command failed: {err}") + self.current_mode = "balanced" + + except Exception as err: + print(f"Error retrieving current power mode: {err}") + self.current_mode = "balanced" + + def set_power_mode(self, mode): + """ + Switches power mode by running the corresponding auto-cpufreq command. + mode: one of 'powersave', 'balanced', or 'performance' + """ + commands = { + "power-saver": "powerprofilesctl set power-saver", + "balanced": "powerprofilesctl set balanced", + "performance": "powerprofilesctl set performance", + } + if mode in commands: + try: + exec_shell_command_async(commands[mode]) + self.current_mode = mode + self.update_button_styles() + except Exception as err: + # Optionally, handle errors or display a notification. + print(f"Error setting power mode: {err}") + + def update_button_styles(self): + """ + Optionally updates button styles to reflect the current mode. + Adjust the styling method based on your toolkit's capabilities. + """ + if self.bat_save: + self.bat_save.remove_style_class("active") + if self.bat_balanced: + self.bat_balanced.remove_style_class("active") + if self.bat_perf: + self.bat_perf.remove_style_class("active") + + if self.current_mode == "power-saver" and self.bat_save: + self.bat_save.add_style_class("active") + elif self.current_mode == "balanced" and self.bat_balanced: + self.bat_balanced.add_style_class("active") + elif self.current_mode == "performance" and self.bat_perf: + self.bat_perf.add_style_class("active") diff --git a/Ax-Shell/modules/systemtray.py b/Ax-Shell/modules/systemtray.py new file mode 100644 index 0000000..b3aa277 --- /dev/null +++ b/Ax-Shell/modules/systemtray.py @@ -0,0 +1,154 @@ +import gi + +gi.require_version("Gray", "0.1") +import logging +import os + +from fabric.widgets.box import Box +from gi.repository import Gdk, GdkPixbuf, GLib, Gray, Gtk + +import config.data as data + +logger = logging.getLogger(__name__) + +class SystemTray(Box): + def __init__(self, pixel_size: int = 20, **kwargs) -> None: + orientation = Gtk.Orientation.HORIZONTAL if not data.VERTICAL else Gtk.Orientation.VERTICAL + super().__init__( + name="systray", + orientation=orientation, + spacing=8, + **kwargs + ) + self.enabled = True + super().set_visible(False) + self.pixel_size = pixel_size + self.buttons_by_id = {} + self.items_by_id = {} + + self.watcher = Gray.Watcher() + self.watcher.connect("item-added", self.on_watcher_item_added) + + def set_visible(self, visible: bool): + self.enabled = visible + self._update_visibility() + + def _update_visibility(self): + has = len(self.get_children()) > 0 + super().set_visible(self.enabled and has) + + def _get_item_pixbuf(self, item: Gray.Item) -> GdkPixbuf.Pixbuf: + try: + pm = Gray.get_pixmap_for_pixmaps(item.get_icon_pixmaps(), self.pixel_size) + if pm: + return pm.as_pixbuf(self.pixel_size, GdkPixbuf.InterpType.HYPER) + + name = item.get_icon_name() + # If IconName is a file path, prioritize loading directly from the file + if name and os.path.exists(name): + try: + return GdkPixbuf.Pixbuf.new_from_file_at_scale( + name, self.pixel_size, self.pixel_size, True + ) + except Exception as e: + # The file path exists but loading fails, falling back to theme search + logger.debug( + f"Load icon from file failed: {e}; fallback to theme for '{name}'" + ) + + theme = Gtk.IconTheme.new() + path = item.get_icon_theme_path() + if path: + theme.prepend_search_path(path) + return theme.load_icon(name, self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE) + except GLib.Error as e: + logger.debug(f"Icon load error {e}") + return Gtk.IconTheme.get_default().load_icon( + "image-missing", self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE + ) + + def _refresh_item_ui(self, item: Gray.Item, button: Gtk.Button): + pixbuf = self._get_item_pixbuf(item) + img = button.get_image() + if isinstance(img, Gtk.Image): + img.set_from_pixbuf(pixbuf) + else: + new = Gtk.Image.new_from_pixbuf(pixbuf) + button.set_image(new) + new.show() + tip = None + if hasattr(item, 'get_tooltip_text'): + tip = item.get_tooltip_text() + elif hasattr(item, 'get_title'): + tip = item.get_title() + if tip: + button.set_tooltip_text(tip) + else: + button.set_has_tooltip(False) + + def on_watcher_item_added(self, _, identifier: str): + item = self.watcher.get_item_for_identifier(identifier) + if not item: + return + + if identifier in self.buttons_by_id: + self.buttons_by_id[identifier].destroy() + del self.buttons_by_id[identifier] + del self.items_by_id[identifier] + + btn = self.do_bake_item_button(item) + self.buttons_by_id[identifier] = btn + self.items_by_id[identifier] = item + + item.connect("notify::icon-pixmaps", + lambda itm, pspec: self._refresh_item_ui(itm, btn)) + item.connect("notify::icon-name", + lambda itm, pspec: self._refresh_item_ui(itm, btn)) + + try: + item.connect("icon-changed", lambda itm: self._refresh_item_ui(itm, btn)) + except TypeError: + pass + + item.connect("removed", lambda itm: self.on_item_instance_removed(identifier, itm)) + + self.add(btn) + btn.show_all() + self._update_visibility() + + def do_bake_item_button(self, item: Gray.Item) -> Gtk.Button: + btn = Gtk.Button() + btn.connect("button-press-event", lambda b, e: self.on_button_click(b, item, e)) + img = Gtk.Image.new_from_pixbuf(self._get_item_pixbuf(item)) + btn.set_image(img) + tip = item.get_tooltip_text() if hasattr(item, 'get_tooltip_text') else getattr(item, 'get_title', lambda: None)() + if tip: + btn.set_tooltip_text(tip) + return btn + + def on_item_instance_removed(self, identifier: str, removed_item: Gray.Item): + if self.items_by_id.get(identifier) is removed_item: + btn = self.buttons_by_id.pop(identifier, None) + self.items_by_id.pop(identifier, None) + if btn: + btn.destroy() + self._update_visibility() + + def on_button_click(self, button: Gtk.Button, item: Gray.Item, event: Gdk.EventButton): + if event.button == Gdk.BUTTON_PRIMARY: + try: + item.activate(int(event.x_root), int(event.y_root)) + except Exception as e: + logger.error(f"Activate error: {e}") + elif event.button == Gdk.BUTTON_SECONDARY: + menu = getattr(item, 'get_menu', lambda: None)() + if isinstance(menu, Gtk.Menu): + menu.popup_at_widget(button, Gdk.Gravity.SOUTH_WEST, + Gdk.Gravity.NORTH_WEST, event) + else: + cm = getattr(item, 'context_menu', None) + if cm: + try: + cm(int(event.x_root), int(event.y_root)) + except Exception as e: + logger.error(f"ContextMenu error: {e}") diff --git a/Ax-Shell/modules/tmux.py b/Ax-Shell/modules/tmux.py new file mode 100644 index 0000000..4d69e78 --- /dev/null +++ b/Ax-Shell/modules/tmux.py @@ -0,0 +1,553 @@ +import os +import subprocess + +from fabric.utils import exec_shell_command_async, idle_add, remove_handler +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.entry import Entry +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GLib, Gtk + +import config.data as data +import modules.icons as icons + + +class TmuxManager(Box): + def __init__(self, **kwargs): + super().__init__( + name="tmux-manager", + visible=False, + all_visible=False, + **kwargs, + ) + + self.notch = kwargs["notch"] + self.selected_index = -1 # Track the selected item index + + self._arranger_handler: int = 0 + + self.viewport = Box(name="viewport", spacing=4, orientation="v") + self.session_name_entry = Entry( + name="session-name-entry", + placeholder="Create Tmux Session...", + h_expand=True, + h_align="fill", + on_activate=lambda entry, *_: self.create_session(entry.get_text()), + on_key_press_event=self.on_entry_key_press, + ) + self.session_name_entry.props.xalign = 0.5 + self.scrolled_window = ScrolledWindow( + name="scrolled-window", + spacing=10, + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + child=self.viewport, + propagate_width=False, + propagate_height=False, + ) + + self.header_box = Box( + name="header_box", + spacing=10, + orientation="h", + children=[ + Button( + name="new-session-button", + child=Label(name="new-session-label", markup=icons.add), + tooltip_text="Create New Session", + on_clicked=lambda *_: self.create_session(self.session_name_entry.get_text()), + ), + self.session_name_entry, + Button( + name="close-button", + child=Label(name="close-label", markup=icons.cancel), + tooltip_text="Exit", + on_clicked=lambda *_: self.close_manager() + ), + ], + ) + + self.tmux_box = Box( + name="tmux-box", + spacing=10, + h_expand=True, + orientation="v", + children=[ + self.header_box, + self.scrolled_window, + ], + ) + + self.add(self.tmux_box) + self.show_all() + + def close_manager(self): + """Close the tmux manager""" + self.viewport.children = [] + self.selected_index = -1 # Reset selection + self.notch.close_notch() + + def open_manager(self): + """Open the tmux manager and refresh sessions""" + self.refresh_sessions() + self.session_name_entry.set_text("") + self.session_name_entry.grab_focus() + + def refresh_sessions(self): + """Get tmux sessions and populate the viewport""" + remove_handler(self._arranger_handler) if self._arranger_handler else None + self.viewport.children = [] + self.selected_index = -1 # Clear selection when viewport changes + + # Get tmux sessions + sessions = self.get_tmux_sessions() + if not sessions: + # Create a container box to better center the message + container = Box( + name="no-tmux-container", + orientation="v", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True + ) + + # Show a message if no sessions + label = Label( + name="no-tmux", + markup=icons.terminal, + h_align="center", + v_align="center", + ) + + container.add(label) + + self.viewport.add(container) + return + + # Add session slots to viewport + for session in sessions: + self.viewport.add(self.create_session_slot(session)) + + 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 + ) + if result.returncode == 0: + return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()] + return [] + except Exception as e: + print(f"Error getting tmux sessions: {e}") + return [] + + def create_session_slot(self, session_name): + """Create a button for a tmux session""" + # Create an entry for inline editing (initially hidden) + name_entry = Entry( + name="session-name-entry", + text=session_name, + visible=False, + on_activate=lambda entry, *_: self.finish_rename(button, session_name, entry), + on_key_press_event=self.on_rename_key_press, + ) + + # Create the label showing the session name + name_label = Label( + name="app-label", + label=session_name, + ellipsization="end", + v_align="center", + h_align="center", + ) + + # Session slot content box + slot_box = Box( + name="slot-box", + orientation="h", + spacing=10, + children=[ + Label( + name="tmux-icon", + markup=icons.terminal, # Use existing terminal icon + ), + name_label, + name_entry, + ], + ) + + button = Button( + name="slot-button", # reuse existing CSS styling + child=slot_box, + tooltip_text=f"Attach to session: {session_name}", + on_clicked=lambda *_: self.attach_to_session(session_name), + can_focus=True, # Ensure the button can receive focus + ) + + # Add double-click handler to start renaming + button.connect("button-press-event", self.on_session_click, session_name, name_label, name_entry) + + # Add key press handler for 'r' to rename + button.connect("key-press-event", self.on_slot_key_press, session_name, name_label, name_entry) + + # Store reference to entry and label in button for later access + button.name_entry = name_entry + button.name_label = name_label + button.session_name = session_name + + return button + + def on_session_click(self, button, event, session_name, label, entry): + """Handle clicks on session buttons""" + # Handle double-click to rename + if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS and event.button == 1: + self.start_rename(button, session_name, label, entry) + return True + # Handle right click for context menu + elif event.button == 3: + menu = Gtk.Menu() + + # Rename option + rename_item = Gtk.MenuItem(label="Rename") + rename_item.connect("activate", lambda _: self.start_rename(button, session_name, label, entry)) + menu.append(rename_item) + + # Kill option + kill_item = Gtk.MenuItem(label="Kill Session") + kill_item.connect("activate", lambda _: self.kill_session(session_name)) + menu.append(kill_item) + + menu.show_all() + menu.popup_at_pointer(event) + return True + + return False + + def start_rename(self, button, session_name, label, entry): + """Start inline renaming of a session""" + # Hide label, show entry + label.set_visible(False) + entry.set_visible(True) + + # Focus entry and select all text + entry.grab_focus() + entry.select_region(0, -1) + + # Mark button as being edited + button.get_style_context().add_class("editing") + + def finish_rename(self, button, old_name, entry): + """Finish renaming a session""" + new_name = entry.get_text().strip() + + # Only rename if the name changed and isn't empty + if new_name and new_name != old_name: + self.rename_session(old_name, new_name) + + # Reset UI state + self.cancel_rename(button) + + def cancel_rename(self, button): + """Cancel renaming operation""" + # Restore original view + button.name_entry.set_visible(False) + button.name_label.set_visible(True) + + # Remove editing style + button.get_style_context().remove_class("editing") + + # Return focus to session name entry + self.session_name_entry.grab_focus() + + def on_rename_key_press(self, entry, event): + """Handle key presses in the rename entry""" + if event.keyval == Gdk.KEY_Escape: + # Find the parent button + parent = entry.get_parent() + while parent and not isinstance(parent, Button): + parent = parent.get_parent() + + if parent: + self.cancel_rename(parent) + return True + + return False + + def on_session_right_click(self, button, event, session_name): + """Handle right-click on a session button to show context menu""" + if event.button == 3: # Right click + menu = Gtk.Menu() + + # Rename option + rename_item = Gtk.MenuItem(label="Rename") + rename_item.connect("activate", lambda _: self.start_rename( + button, + session_name, + button.name_label, + button.name_entry + )) + menu.append(rename_item) + + # Kill option + kill_item = Gtk.MenuItem(label="Kill Session") + kill_item.connect("activate", lambda _: self.kill_session(session_name)) + menu.append(kill_item) + + menu.show_all() + menu.popup_at_pointer(event) + return True + + return False + + def on_entry_key_press(self, widget, event): + """Handle key press events in the entry""" + if event.keyval == Gdk.KEY_Escape: + self.close_manager() + return True + + # Custom navigation with UP/DOWN keys removed + return False + + def scroll_to_selected(self, button): + """Scroll to ensure the selected button is visible""" + def scroll(): + adj = self.scrolled_window.get_vadjustment() + alloc = button.get_allocation() + if alloc.height == 0: + return False # Retry if allocation isn't ready + + y = alloc.y + height = alloc.height + page_size = adj.get_page_size() + current_value = adj.get_value() + + # Calculate visible boundaries + visible_top = current_value + visible_bottom = current_value + page_size + + if y < visible_top: + # Item above viewport - align to top + adj.set_value(y) + elif y + height > visible_bottom: + # Item below viewport - align to bottom + new_value = y + height - page_size + adj.set_value(new_value) + # No action if already fully visible + return False + GLib.idle_add(scroll) + + def create_session(self, session_name): + """Create a new tmux session""" + if not session_name: + # Get existing session names + existing_sessions = self.get_tmux_sessions() + + # Find the next available number + counter = 0 + while str(counter) in existing_sessions: + counter += 1 + + session_name = str(counter) + + try: + # Clean the session name (replace spaces with underscores) + clean_name = session_name.strip().replace(" ", "_") + + # Create session + subprocess.run( + ["tmux", "new-session", "-d", "-s", clean_name], + check=True + ) + + # Refresh the session list + self.refresh_sessions() + + # Clear entry + self.session_name_entry.set_text("") + + # Launch a terminal and attach to this session + terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {clean_name}") + exec_shell_command_async(terminal_cmd) + + # Close manager + self.close_manager() + + except Exception as e: + print(f"Error creating tmux session: {e}") + + def attach_to_session(self, session_name): + """Attach to an existing tmux session""" + try: + # Launch a terminal and attach to this session + terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {session_name}") + exec_shell_command_async(terminal_cmd) + self.close_manager() + except Exception as e: + print(f"Error attaching to tmux session: {e}") + + def get_terminal_command(self, cmd): + """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}" + + def rename_session_dialog(self, old_name): + """Show dialog to rename a session""" + dialog = Gtk.Dialog( + title="Rename Session", + transient_for=None, + flags=0 + ) + + dialog.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OK, Gtk.ResponseType.OK + ) + + content_area = dialog.get_content_area() + entry = Gtk.Entry() + entry.set_text(old_name) + entry.set_activates_default(True) + content_area.add(entry) + + dialog.set_default_response(Gtk.ResponseType.OK) + dialog.show_all() + + response = dialog.run() + if response == Gtk.ResponseType.OK: + new_name = entry.get_text() + if new_name and new_name != old_name: + self.rename_session(old_name, new_name) + + dialog.destroy() + + def rename_session(self, old_name, new_name): + """Rename a tmux session""" + try: + # Clean the session name (replace spaces with underscores) + clean_name = new_name.strip().replace(" ", "_") + + # Rename session + subprocess.run( + ["tmux", "rename-session", "-t", old_name, clean_name], + check=True + ) + + # Refresh the session list + self.refresh_sessions() + + except Exception as e: + print(f"Error renaming tmux session: {e}") + + def kill_session(self, session_name): + """Kill a tmux session""" + try: + # Kill session + subprocess.run( + ["tmux", "kill-session", "-t", session_name], + check=True + ) + + # Refresh the session list + self.refresh_sessions() + + # Close the notch after killing session + self.close_manager() + + except Exception as e: + print(f"Error killing tmux session: {e}") + + # Add new method to handle key presses on session slots + def on_slot_key_press(self, button, event, session_name, label, entry): + """Handle key presses on session buttons""" + # Print debugging info + print(f"Key pressed: {event.keyval}, State: {event.state}") + + # Check if 'r' key was pressed for renaming + if event.keyval == Gdk.KEY_r: + self.start_rename(button, session_name, label, entry) + return True + # Check for 'K' (capital K) which indicates Shift is pressed + elif event.keyval == Gdk.KEY_K: + print("Shift+K detected - killing session without confirmation") + self.kill_session(session_name) + return True + # Check for lowercase 'k' + elif event.keyval == Gdk.KEY_k: + print("Regular k detected - showing confirmation") + self.show_kill_confirmation_menu(button, session_name) + return True + # Check if Delete key was pressed for killing session + elif event.keyval == Gdk.KEY_Delete: + self.show_kill_confirmation_menu(button, session_name) + return True + return False + + def show_kill_confirmation_menu(self, button, session_name): + """Show a confirmation menu for killing a session""" + menu = Gtk.Menu() + + # Confirmation message as a disabled menu item + msg_item = Gtk.MenuItem(label=f"Kill session '{session_name}'?") + msg_item.set_sensitive(False) + menu.append(msg_item) + + # Separator + menu.append(Gtk.SeparatorMenuItem()) + + # Confirm option + confirm_item = Gtk.MenuItem(label="Confirm") + confirm_item.connect("activate", lambda _: self.kill_session(session_name)) + menu.append(confirm_item) + + # Cancel option + cancel_item = Gtk.MenuItem(label="Cancel") + # Close notch on cancel + cancel_item.connect("activate", lambda _: self.close_manager()) + menu.append(cancel_item) + + menu.show_all() + + # Show the menu positioned at the button + menu.popup_at_widget( + button, + Gdk.Gravity.SOUTH_WEST, + Gdk.Gravity.NORTH_WEST, + None + ) diff --git a/Ax-Shell/modules/tools.py b/Ax-Shell/modules/tools.py new file mode 100644 index 0000000..ffea6a7 --- /dev/null +++ b/Ax-Shell/modules/tools.py @@ -0,0 +1,456 @@ +import os +import subprocess + +from fabric.utils.helpers import exec_shell_command_async, get_relative_path +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from gi.repository import Gdk, GLib +from loguru import logger + +import config.data as data +import modules.icons as icons + +SCREENSHOT_SCRIPT = get_relative_path("../scripts/screenshot.sh") +POMODORO_SCRIPT = get_relative_path("../scripts/pomodoro.sh") +OCR_SCRIPT = get_relative_path("../scripts/ocr.sh") +GAMEMODE_SCRIPT = get_relative_path("../scripts/gamemode.sh") +SCREENRECORD_SCRIPT = get_relative_path("../scripts/screenrecord.sh") + +# Tooltips +## Screenshot +tooltip_ssregion = """Region Screenshot +Left Click: Take a screenshot of a selected region. +Right Click: Take a mockup screenshot of a selected region.""" + +tooltip_ssfull = """Screenshot +Left Click: Take a fullscreen screenshot. +Right Click: Take a mockup fullscreen screenshot.""" + +tooltip_sswindow = """Window Screenshot +Left Click: Take a screenshot of the active window. +Right Click: Take a mockup screenshot of the active window.""" + +tooltip_screenshots = "Screenshots Directory" + +tooltip_screenrecord = "Screen Recorder" +tooltip_recordings = "Recordings Directory" + +tooltip_ocr = "OCR" +tooltip_colorpicker = """Color Picker +Mouse: +Left Click: HEX +Middle Click: HSV +Right Click: RGB + +Keyboard: +Enter: HEX +Shift+Enter: RGB +Ctrl+Enter: HSV""" + +tooltip_gamemode = "Game Mode\nDisables effects and window animations for better performance." +tooltip_pomodoro = "Pomodoro Timer" +tooltip_emoji = "Emoji Picker" + + +class Toolbox(Box): + def __init__(self, **kwargs): + orientation = "h" + if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]): + orientation = "v" + + super().__init__( + name="toolbox", + orientation=orientation, + spacing=4, + v_align="center", + h_align="center", + visible=True, + **kwargs, + ) + + self.notch = kwargs["notch"] + + self.btn_ssregion = Button( + name="toolbox-button", + tooltip_markup=tooltip_ssregion, + child=Label(name="button-label", markup=icons.ssregion), + on_clicked=self.ssregion, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + self.btn_ssregion.set_can_focus(True) + self.btn_ssregion.connect("button-press-event", self.on_ssregion_click) + self.btn_ssregion.connect("key-press-event", self.on_ssregion_key) + + self.btn_ssfull = Button( + name="toolbox-button", + tooltip_markup=tooltip_ssfull, + child=Label(name="button-label", markup=icons.ssfull), + on_clicked=self.ssfull, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_ssfull.set_can_focus(True) + self.btn_ssfull.connect("button-press-event", self.on_ssfull_click) + self.btn_ssfull.connect("key-press-event", self.on_ssfull_key) + + self.btn_sswindow = Button( + name="toolbox-button", + tooltip_markup=tooltip_sswindow, + child=Label(name="button-label", markup=icons.sswindow), + on_clicked=self.sswindow, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_sswindow.set_can_focus(True) + self.btn_sswindow.connect("button-press-event", self.on_sswindow_click) + self.btn_sswindow.connect("key-press-event", self.on_sswindow_key) + + self.btn_screenrecord = Button( + name="toolbox-button", + tooltip_markup=tooltip_screenrecord, + child=Label(name="button-label", markup=icons.screenrecord), + on_clicked=self.screenrecord, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_ocr = Button( + name="toolbox-button", + tooltip_markup=tooltip_ocr, + child=Label(name="button-label", markup=icons.ocr), + on_clicked=self.ocr, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_color = Button( + name="toolbox-button", + tooltip_markup=tooltip_colorpicker, + child=Label( + name="button-bar-label", + markup=icons.colorpicker + ), + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_gamemode = Button( + name="toolbox-button", + tooltip_markup=tooltip_gamemode, + child=Label(name="button-label", markup=icons.gamemode), + on_clicked=self.gamemode, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_pomodoro = Button( + name="toolbox-button", + tooltip_markup=tooltip_pomodoro, + child=Label(name="button-label", markup=icons.timer_off), + on_clicked=self.pomodoro, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_color.set_can_focus(True) + + self.btn_color.connect("button-press-event", self.colorpicker) + self.btn_color.connect("key_press_event", self.colorpicker_key) + + self.btn_emoji = Button( + name="toolbox-button", + tooltip_markup=tooltip_emoji, + child=Label(name="button-label", markup=icons.emoji), + on_clicked=self.emoji, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_screenshots_folder = Button( + name="toolbox-button", + tooltip_markup=tooltip_screenshots, + child=Label(name="button-label", markup=icons.screenshots), + on_clicked=self.open_screenshots_folder, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.btn_recordings_folder = Button( + name="toolbox-button", + tooltip_markup=tooltip_recordings, + child=Label(name="button-label", markup=icons.recordings), + on_clicked=self.open_recordings_folder, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ) + + self.buttons = [ + self.btn_ssregion, + self.btn_sswindow, + self.btn_ssfull, + self.btn_screenshots_folder, + Box(name="tool-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"), + self.btn_screenrecord, + self.btn_recordings_folder, + Box(name="tool-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"), + self.btn_ocr, + self.btn_color, + Box(name="tool-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"), + self.btn_gamemode, + self.btn_pomodoro, + self.btn_emoji, + ] + + for button in self.buttons: + self.add(button) + + self.show_all() + + self.recorder_timer_id = GLib.timeout_add_seconds(2, self.update_screenrecord_state) + self.gamemode_updater = GLib.timeout_add_seconds(2, self.gamemode_check) + self.pomodoro_updater = GLib.timeout_add_seconds(2, self.pomodoro_check) + + def close_menu(self): + self.notch.close_notch() + + def ssfull(self, *args, mockup=False): + cmd = f"bash {SCREENSHOT_SCRIPT} p" + if mockup: + cmd += " mockup" + exec_shell_command_async(cmd) + self.close_menu() + + def on_ssfull_click(self, button, event): + if event.type == Gdk.EventType.BUTTON_PRESS: + if event.button == 1: + self.ssfull() + elif event.button == 3: + self.ssfull(mockup=True) + return True + return False + + def on_ssfull_key(self, widget, event): + if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: + modifiers = event.get_state() + if modifiers & Gdk.ModifierType.SHIFT_MASK: + self.ssfull(mockup=True) + else: + self.ssfull() + return True + return False + + def ssregion(self, *args): + exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} s") + self.close_menu() + + def on_ssregion_click(self, button, event): + if event.type == Gdk.EventType.BUTTON_PRESS: + if event.button == 1: + self.ssregion() + elif event.button == 3: + exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} s mockup") + self.close_menu() + return True + return False + + def on_ssregion_key(self, widget, event): + if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: + modifiers = event.get_state() + if modifiers & Gdk.ModifierType.SHIFT_MASK: + exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} s mockup") + self.close_menu() + else: + self.ssregion() + return True + return False + + def sswindow(self, *args): + exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} w") + self.close_menu() + + def on_sswindow_click(self, button, event): + if event.type == Gdk.EventType.BUTTON_PRESS: + if event.button == 1: + self.sswindow() + elif event.button == 3: + exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} w mockup") + self.close_menu() + return True + return False + + def on_sswindow_key(self, widget, event): + if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: + modifiers = event.get_state() + if modifiers & Gdk.ModifierType.SHIFT_MASK: + exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} w mockup") + self.close_menu() + else: + self.sswindow() + return True + return False + + def screenrecord(self, *args): + + exec_shell_command_async(f"bash -c 'nohup bash {SCREENRECORD_SCRIPT} > /dev/null 2>&1 & disown'") + self.close_menu() + + def pomodoro(self, *args): + exec_shell_command_async(f"bash -c 'nohup bash {POMODORO_SCRIPT} > /dev/null 2>&1 & disown'") + self.close_menu() + + def pomodoro_check(self): + """Check pomodoro status using proper background threading""" + GLib.Thread.new("pomodoro-check", self._pomodoro_check_thread, None) + return True + + def _pomodoro_check_thread(self, user_data): + """Background thread to check pomodoro status""" + try: + result = subprocess.run("pgrep -f pomodoro.sh", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + running = result.returncode == 0 + except Exception: + running = False + + GLib.idle_add(self._update_pomodoro_ui, running) + + def _update_pomodoro_ui(self, running): + """Update pomodoro UI from main thread""" + if running: + self.btn_pomodoro.get_child().set_markup(icons.timer_on) + self.btn_pomodoro.add_style_class("pomodoro") + else: + self.btn_pomodoro.get_child().set_markup(icons.timer_off) + self.btn_pomodoro.remove_style_class("pomodoro") + return False + + def ocr(self, *args): + exec_shell_command_async(f"bash {OCR_SCRIPT} s") + self.close_menu() + + def gamemode(self, *args): + exec_shell_command_async(f"bash {GAMEMODE_SCRIPT}") + self.gamemode_check() + self.close_menu() + + def gamemode_check(self): + """Check gamemode status using proper background threading""" + GLib.Thread.new("gamemode-check", self._gamemode_check_thread, None) + return True + + def _gamemode_check_thread(self, user_data): + """Background thread to check gamemode status""" + try: + result = subprocess.run(f"bash {GAMEMODE_SCRIPT} check", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + enabled = result.stdout == b't\n' + except Exception: + enabled = False + + GLib.idle_add(self._update_gamemode_ui, enabled) + + def _update_gamemode_ui(self, enabled): + """Update gamemode UI from main thread""" + if enabled: + self.btn_gamemode.get_child().set_markup(icons.gamemode_off) + else: + self.btn_gamemode.get_child().set_markup(icons.gamemode) + return False + + def colorpicker(self, button, event): + if event.type == Gdk.EventType.BUTTON_PRESS: + cmd = { + 1: "-hex", + 2: "-hsv", + 3: "-rgb" + }.get(event.button) + + if cmd: + exec_shell_command_async(f"bash {get_relative_path('../scripts/hyprpicker.sh')} {cmd}") + self.close_menu() + + def colorpicker_key(self, widget, event): + if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: + modifiers = event.get_state() + cmd = "-hex" + + match modifiers & (Gdk.ModifierType.SHIFT_MASK | Gdk.ModifierType.CONTROL_MASK): + case Gdk.ModifierType.SHIFT_MASK: + cmd = "-rgb" + case Gdk.ModifierType.CONTROL_MASK: + cmd = "-hsv" + + exec_shell_command_async(f"bash {get_relative_path('../scripts/hyprpicker.sh')} {cmd}") + self.close_menu() + return True + return False + + def update_screenrecord_state(self): + """Check screen recording status using proper background threading""" + GLib.Thread.new("screenrecord-check", self._screenrecord_check_thread, None) + return True + + def _screenrecord_check_thread(self, user_data): + """Background thread to check screen recording status""" + try: + result = subprocess.run("pgrep -f gpu-screen-recorder", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + running = result.returncode == 0 + except Exception: + running = False + + GLib.idle_add(self._update_screenrecord_ui, running) + + def _update_screenrecord_ui(self, running): + """Update screen recording UI from main thread""" + if running: + self.btn_screenrecord.get_child().set_markup(icons.stop) + self.btn_screenrecord.add_style_class("recording") + else: + self.btn_screenrecord.get_child().set_markup(icons.screenrecord) + self.btn_screenrecord.remove_style_class("recording") + return False + + def open_screenshots_folder(self, *args): + screenshots_dir = os.path.join(os.environ.get('XDG_PICTURES_DIR', + os.path.expanduser('~/Pictures')), + 'Screenshots') + + os.makedirs(screenshots_dir, exist_ok=True) + exec_shell_command_async(f"xdg-open {screenshots_dir}") + self.close_menu() + + def open_recordings_folder(self, *args): + recordings_dir = os.path.join(os.environ.get('XDG_VIDEOS_DIR', + os.path.expanduser('~/Videos')), + 'Recordings') + + os.makedirs(recordings_dir, exist_ok=True) + exec_shell_command_async(f"xdg-open {recordings_dir}") + self.close_menu() + + def emoji(self, *args): + self.notch.open_notch("emoji") diff --git a/Ax-Shell/modules/updater.py b/Ax-Shell/modules/updater.py new file mode 100644 index 0000000..4e39c07 --- /dev/null +++ b/Ax-Shell/modules/updater.py @@ -0,0 +1,571 @@ +import json +import os +import shutil +import socket +import subprocess +import sys +import time +from pathlib import Path + +import gi + +# Insertion for embedded VTE terminal +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("Vte", "2.91") +from gi.repository import Gdk, GLib, Gtk, Vte + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from fabric.utils.helpers import get_relative_path + +import config.data as data + +# File locations +VERSION_FILE = get_relative_path("../version.json") +REMOTE_VERSION_FILE = "/tmp/remote_version.json" +REMOTE_URL = "https://raw.githubusercontent.com/Axenide/Ax-Shell/refs/heads/main/version.json" +REPO_DIR = get_relative_path("../") + +SNOOZE_FILE_NAME = "updater_snooze.txt" +UPDATER_DISABLE_FILE_NAME = "updater_disabled.flag" +SNOOZE_DURATION_SECONDS = 8 * 60 * 60 # 8 hours + +# --- Global state for standalone execution control --- +_QUIT_GTK_IF_NO_WINDOW_STANDALONE = False + +def get_cache_dir(): + """Returns the cache directory path, creating it if necessary.""" + cache_dir_base = data.CACHE_DIR or os.path.expanduser(f"~/.cache/{data.APP_NAME}") + try: + os.makedirs(cache_dir_base, exist_ok=True) + except Exception as e: + print(f"Error creating cache directory {cache_dir_base}: {e}") + return cache_dir_base + +def get_snooze_file_path(): + """ + Returns the path to the 'snooze' file inside ~/.cache/APP_NAME. + """ + return os.path.join(get_cache_dir(), SNOOZE_FILE_NAME) + +def get_disable_file_path(): + """ + Returns the path to the 'updater_disabled.flag' file inside ~/.cache/APP_NAME. + """ + return os.path.join(get_cache_dir(), UPDATER_DISABLE_FILE_NAME) + + +def fetch_remote_version(): + """ + Downloads the remote version JSON using curl, with timeout and error handling. + """ + try: + subprocess.run( + ["curl", "-sL", "--connect-timeout", "10", REMOTE_URL, "-o", REMOTE_VERSION_FILE], + check=False, + timeout=15 + ) + except subprocess.TimeoutExpired: + print("Error: curl timed out while fetching the remote version.") + except FileNotFoundError: + print("Error: curl not found. Please install curl.") + except Exception as e: + print(f"Error fetching remote version: {e}") + + +def get_local_version(): + """ + Reads the local version file and returns (version, changelog). + """ + if os.path.exists(VERSION_FILE): + try: + with open(VERSION_FILE, "r") as f: + data_content = json.load(f) + return data_content.get("version", "0.0.0"), data_content.get("changelog", []) + except json.JSONDecodeError: + print(f"Error: Invalid JSON in local file: {VERSION_FILE}") + return "0.0.0", [] + except Exception as e: + print(f"Error reading local version file {VERSION_FILE}: {e}") + return "0.0.0", [] + return "0.0.0", [] + + +def get_remote_version(): + """ + Reads the downloaded remote file and returns (version, changelog, download_url, pkg_update). + """ + if os.path.exists(REMOTE_VERSION_FILE): + try: + with open(REMOTE_VERSION_FILE, "r") as f: + data_content = json.load(f) + return ( + data_content.get("version", "0.0.0"), + data_content.get("changelog", []), + data_content.get("download_url", "#"), + data_content.get("pkg_update", True), # Default to True if missing + ) + except json.JSONDecodeError: + print(f"Error: Invalid JSON in remote file: {REMOTE_VERSION_FILE}") + return "0.0.0", [], "#", True + except Exception as e: + print(f"Error reading remote version file {REMOTE_VERSION_FILE}: {e}") + return "0.0.0", [], "#", True + return "0.0.0", [], "#", True + + +def update_local_version_file(): + """ + Replaces the local version with the remote one by moving the downloaded JSON to the local version file. + """ + if os.path.exists(REMOTE_VERSION_FILE): + try: + shutil.move(REMOTE_VERSION_FILE, VERSION_FILE) + except Exception as e: + print(f"Error updating local version file: {e}") + raise + + +def is_connected(): + """ + Checks basic connectivity by attempting to connect to www.google.com:80. + """ + try: + socket.create_connection(("www.google.com", 80), timeout=5) + return True + except OSError: + return False + + +class UpdateWindow(Gtk.Window): + def __init__(self, latest_version, changelog, pkg_update, is_standalone_mode=False): + super().__init__(name="update-window", title=f"{data.APP_NAME_CAP} Updater") + self.set_default_size(500, 480) + self.set_border_width(16) + self.set_resizable(False) + self.set_position(Gtk.WindowPosition.CENTER) + self.set_keep_above(True) + self.set_type_hint(Gdk.WindowTypeHint.DIALOG) + + self.is_standalone_mode = is_standalone_mode + self.quit_gtk_main_on_destroy = False + self.pkg_update = pkg_update # Store pkg_update + + # Main vertical container + self.main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) + self.add(self.main_vbox) + + # Title + title_label = Gtk.Label(name="update-title") + title_label.set_markup("๐Ÿ“ฆ Update Available โœจ") + title_label.get_style_context().add_class("title-1") + self.main_vbox.pack_start(title_label, False, False, 10) + + # Version info text + info_label = Gtk.Label( + label=f"A new version ({latest_version}) of {data.APP_NAME_CAP} is available." + ) + info_label.set_xalign(0) + info_label.set_line_wrap(True) + self.main_vbox.pack_start(info_label, False, False, 0) + + # Changelog header + changelog_header_label = Gtk.Label() + changelog_header_label.set_markup("Changelog:") + changelog_header_label.set_xalign(0) + self.main_vbox.pack_start(changelog_header_label, False, False, 5) + + # โ€” Scrollable window for the changelog (using Gtk.Label with markup) โ€” + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_hexpand(True) + scrolled_window.set_vexpand(True) + scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + + if changelog: + # Each entry may already contain Pango tags (, , etc.) + joined = "\n".join(f"โ€ข {change}" for change in changelog) + else: + joined = "No specific changes listed for this version." + + self.changelog_label = Gtk.Label() + self.changelog_label.set_xalign(0) + self.changelog_label.set_yalign(0) + self.changelog_label.set_line_wrap(Gtk.WrapMode.WORD_CHAR) # Gtk.WrapMode instead of just True + self.changelog_label.set_selectable(False) + self.changelog_label.set_markup(joined) + + scrolled_window.add(self.changelog_label) + self.main_vbox.pack_start(scrolled_window, True, True, 0) + + # ProgressBar (will be shown if we need to indicate status, although with VTE it remains unused) + self.progress_bar = Gtk.ProgressBar() + self.progress_bar.set_no_show_all(True) + self.progress_bar.set_visible(False) + self.main_vbox.pack_start(self.progress_bar, False, False, 5) + + # Button container + action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + self.main_vbox.pack_start(action_box, False, False, 10) + + # "Disable/Enable Updater" Button (aligned left) + self.toggle_updater_button = Gtk.Button(name="toggle-updater-button") + self.toggle_updater_button.connect("clicked", self.on_toggle_updater_clicked) + self._update_toggle_updater_button_label() # Set initial label + action_box.pack_start(self.toggle_updater_button, False, False, 0) + + # Box for right-aligned buttons + right_aligned_buttons_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + right_aligned_buttons_box.set_halign(Gtk.Align.END) + action_box.pack_end(right_aligned_buttons_box, True, True, 0) # This box expands + + # Update button (will now show embedded VTE terminal) + self.update_button = Gtk.Button(name="update-button", label="Update") + self.update_button.get_style_context().add_class("suggested-action") + self.update_button.connect("clicked", self.on_update_clicked) + right_aligned_buttons_box.pack_end(self.update_button, False, False, 0) + + # 'Later' button + self.close_button = Gtk.Button(name="later-button", label="Later") + self.close_button.connect("clicked", self.on_later_clicked) + right_aligned_buttons_box.pack_end(self.close_button, False, False, 0) + + self.connect("destroy", self.on_window_destroyed) + + # Placeholder for embedded terminal + self.terminal_container = None + self.vte_terminal = None + + def _update_toggle_updater_button_label(self): + disable_file = get_disable_file_path() + if os.path.exists(disable_file): + self.toggle_updater_button.set_label("Enable Updater") + else: + self.toggle_updater_button.set_label("Disable Updater") + + def on_toggle_updater_clicked(self, _widget): + disable_file = get_disable_file_path() + try: + if os.path.exists(disable_file): + os.remove(disable_file) + print("Updater enabled.") + else: + with open(disable_file, "w") as f: + pass # File content doesn't matter, its existence is the flag + print("Updater disabled.") + self._update_toggle_updater_button_label() + except Exception as e: + print(f"Error toggling updater state: {e}") + error_dialog = Gtk.MessageDialog( + transient_for=self, + flags=0, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text="Error Changing Updater Setting", + ) + error_dialog.format_secondary_text(f"Could not change the updater setting: {e}") + error_dialog.run() + error_dialog.destroy() + + def on_later_clicked(self, _widget): + """ + When 'Later' is clicked, create/update the snooze file and close the window. + """ + snooze_file_path = get_snooze_file_path() + try: + with open(snooze_file_path, "w") as f: + f.write(str(time.time())) + print(f"Update snoozed. Snooze file at: {snooze_file_path}") + except Exception as e: + print(f"Error creating snooze file {snooze_file_path}: {e}") + self.destroy() + + def on_update_clicked(self, _widget): + """ + When 'Update' is pressed, disable buttons, hide the progress bar, + and create a VTE terminal to run the update command. + """ + # Disable the buttons so they can't be clicked again + self.update_button.set_sensitive(False) + self.close_button.set_sensitive(False) + self.toggle_updater_button.set_sensitive(False) # Disable toggle button during update + + # Hide the progress bar (we don't need it now) + self.progress_bar.set_visible(False) + + # If there's no container for the terminal, create it + if self.terminal_container is None: + # Scrollable container so the terminal can scroll + self.terminal_container = Gtk.ScrolledWindow() + self.terminal_container.set_hexpand(True) + self.terminal_container.set_vexpand(True) + self.terminal_container.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + + # Create the VTE terminal + self.vte_terminal = Vte.Terminal() + self.vte_terminal.set_size(120, 48) + # Make update window larger + self.set_default_size(720, 540) + self.terminal_container.add(self.vte_terminal) + # Insert the terminal at the end of main_vbox + self.main_vbox.pack_start(self.terminal_container, True, True, 0) + + # Show everything + self.show_all() + + # Command to run in the terminal + if self.pkg_update: + update_command = "curl -fsSL https://raw.githubusercontent.com/Axenide/Ax-Shell/main/install.sh | bash" + else: + # Ensure REPO_DIR is correctly defined at the top of the file. + update_command = f"git -C \"{REPO_DIR}\" pull && echo 'Reloading in 3...' && sleep 1 && echo '2...' && sleep 1 && echo '1...' && sleep 1 && killall {data.APP_NAME} && setsid python \"{REPO_DIR}main.py\"" + + + # Spawn the process asynchronously inside the terminal + self.vte_terminal.spawn_async( + Vte.PtyFlags.DEFAULT, + os.environ.get("HOME", "/"), # CWD for the command + ["/bin/bash", "-lc", update_command], # Command and args + [], # envv + GLib.SpawnFlags.DO_NOT_REAP_CHILD, # spawn_flags + None, # child_setup + None, # child_setup_data + -1, # timeout + None, # cancellable + None, # callback_data for Vte.Terminal.spawn_async_wait_finish + self.on_curl_script_exit, # callback for when process finishes + None # user_data for callback + ) + + def on_curl_script_exit(self, terminal, exit_status, user_data): + """ + Callback when the script running in the VTE terminal finishes. + Depending on exit_status, success or failure is considered. + """ + # exit_status is encoded: 0 means success + if exit_status == 0: + # Call the success routine, which restarts the app + GLib.idle_add(self.handle_update_success) + else: + # If there was an error, read the last part of the buffer to display it + end_iter = self.vte_terminal.get_end_iter() + start_iter = self.vte_terminal.get_iter_at_line(max(0, self.vte_terminal.get_line_count() - 5)) + error_excerpt = self.vte_terminal.get_text_range(start_iter, end_iter, False) + GLib.idle_add(self.handle_update_failure, f"Script exited with status {exit_status}. Last lines:\n{error_excerpt}") + + def handle_update_success(self): + """ + Shows a success message, updates local version.json, and restarts the application. + """ + # Update the local version.json with the fetched remote one + try: + update_local_version_file() + print("Local version.json updated successfully.") + except Exception as e: + print(f"Failed to update local version.json: {e}") + # Optionally, you could show an error message to the user here + # For now, we'll proceed with the restart if the script itself was successful. + + # If there was any progress bar timeout, remove it + if hasattr(self, "pulse_timeout_id"): + GLib.source_remove(self.pulse_timeout_id) + delattr(self, "pulse_timeout_id") + + # Replace the terminal (or other widget) with a brief message + # First remove the terminal to show the progress bar and text + if self.terminal_container: + self.main_vbox.remove(self.terminal_container) + + # Prepare the progress bar to indicate success + self.progress_bar.set_visible(True) + self.progress_bar.set_fraction(1.0) + self.progress_bar.set_text("Update Complete. Restarting application...") + self.progress_bar.set_show_text(True) + + # Force it to show + self.show_all() + + # After 2 seconds, close and restart + GLib.timeout_add_seconds(2, self.trigger_restart_and_close) + + def trigger_restart_and_close(self): + """ + Closes the window and relaunches the application. + """ + self.destroy() + try: + print("Restarting application...") + # Relaunch the application + os.execv(sys.executable, [sys.executable] + sys.argv) + except Exception as e: + print(f"Error during application restart: {e}") + # Fallback or error message if execv fails + # For instance, you might want to just quit GTK if restart fails in standalone mode. + if self.is_standalone_mode and self.quit_gtk_main_on_destroy: + Gtk.main_quit() + return False # So the timeout runs only once + + def handle_update_failure(self, error_message): + """ + Shows an error dialog if the script execution fails. + """ + # If there was any progress bar timeout, remove it + if hasattr(self, "pulse_timeout_id"): + GLib.source_remove(self.pulse_timeout_id) + delattr(self, "pulse_timeout_id") + + # Indicate failure in the progress bar + self.progress_bar.set_visible(True) + self.progress_bar.set_fraction(0.0) + self.progress_bar.set_text("Update Failed.") + self.progress_bar.set_show_text(True) + + # Buttons are re-enabled to retry or close + self.update_button.set_sensitive(True) + self.close_button.set_sensitive(True) + self.toggle_updater_button.set_sensitive(True) # Re-enable toggle button + + # Error dialog + error_dialog = Gtk.MessageDialog( + transient_for=self, + flags=0, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text="Update Failed", + ) + error_dialog.format_secondary_text(error_message) + error_dialog.run() + error_dialog.destroy() + + def on_window_destroyed(self, _widget): + """ + If the window is destroyed and we're in standalone mode, quit Gtk.main(). + """ + if hasattr(self, "pulse_timeout_id"): + GLib.source_remove(self.pulse_timeout_id) + delattr(self, "pulse_timeout_id") + + if self.quit_gtk_main_on_destroy: + Gtk.main_quit() + + +def _initiate_update_check_flow(is_standalone_mode, force=False): # Added force argument with default + """ + Logic that checks connection, snooze, and downloads the remote version. + If there's a new version or force is True, launches the update window. + """ + global _QUIT_GTK_IF_NO_WINDOW_STANDALONE + + # --- Check if updater is permanently disabled --- + disable_file_path = get_disable_file_path() + if os.path.exists(disable_file_path) and not force: + print(f"Updater is disabled via {UPDATER_DISABLE_FILE_NAME}. Skipping update check.") + if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE: + GLib.idle_add(Gtk.main_quit) + return + + if not is_connected(): + print("No internet connection. Skipping update check.") + if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE: + GLib.idle_add(Gtk.main_quit) + return + + fetch_remote_version() + latest_version, changelog, _, pkg_update = get_remote_version() # Unpack pkg_update + + if force: + print(f"Force update mode enabled. Opening updater for version {latest_version}.") + if latest_version == "0.0.0" and not changelog: # And pkg_update will be True (default) + print(f"Warning: Could not fetch remote version details for {data.APP_NAME_CAP}. Updater will show default/empty info.") + GLib.idle_add(launch_update_window, latest_version, changelog, pkg_update, is_standalone_mode) # Pass pkg_update + return # Exit after launching in force mode + + # --- Regular update check flow (if not forced) --- + snooze_file_path = get_snooze_file_path() + if os.path.exists(snooze_file_path): + try: + with open(snooze_file_path, "r") as f: + snooze_timestamp_str = f.read().strip() + snooze_timestamp = float(snooze_timestamp_str) + + current_time = time.time() + if current_time - snooze_timestamp < SNOOZE_DURATION_SECONDS: + snooze_until_time = snooze_timestamp + SNOOZE_DURATION_SECONDS + snooze_until_time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(snooze_until_time)) + print(f"Check postponed. It will resume after {snooze_until_time_str}.") + if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE: + GLib.idle_add(Gtk.main_quit) + return + else: + print("Snooze period expired. Removing file and checking for updates.") + os.remove(snooze_file_path) + except ValueError: + print(f"Error: invalid content in snooze file. Removing: {snooze_file_path}") + try: + os.remove(snooze_file_path) + except OSError as e_remove: + print(f"Error removing corrupt snooze file: {e_remove}") + except Exception as e_snooze: + print(f"Error processing snooze file {snooze_file_path}: {e_snooze}. Proceeding with check.") + try: + os.remove(snooze_file_path) # Attempt to remove problematic snooze file + except OSError as e_remove_generic: + print(f"Error removing problematic snooze file: {e_remove_generic}") + + + current_version, _ = get_local_version() + + # Basic version comparison (not strict semver) + if latest_version > current_version and latest_version != "0.0.0": + GLib.idle_add(launch_update_window, latest_version, changelog, pkg_update, is_standalone_mode) # Pass pkg_update + else: + print(f"{data.APP_NAME_CAP} is up to date or the remote version is invalid.") + if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE: + GLib.idle_add(Gtk.main_quit) + + +def launch_update_window(latest_version, changelog, pkg_update, is_standalone_mode): + """ + Creates and shows the update window. + """ + win = UpdateWindow(latest_version, changelog, pkg_update, is_standalone_mode) # Pass pkg_update + if is_standalone_mode: + win.quit_gtk_main_on_destroy = True + win.show_all() + + +def check_for_updates(): + """ + Entry point when called from the main application. + Initiates an update check in a background thread without force. + """ + # Create wrapper function for GLib.Thread compatibility + def _update_check_wrapper(user_data): + _initiate_update_check_flow(False, False) + + GLib.Thread.new("update-check", _update_check_wrapper, None) + + +def run_updater(force=False): # Modified to accept force argument + """ + Standalone entry point: starts Gtk.main and the update check. + Args: + force (bool): If True, opens the updater even if the version isn't outdated or snoozed. + Defaults to False. + """ + global _QUIT_GTK_IF_NO_WINDOW_STANDALONE + _QUIT_GTK_IF_NO_WINDOW_STANDALONE = True + + # Create wrapper function for GLib.Thread compatibility + def _standalone_update_wrapper(user_data): + _initiate_update_check_flow(True, force) + + GLib.Thread.new("standalone-update-check", _standalone_update_wrapper, None) + + Gtk.main() + + +if __name__ == "__main__": + # Example of how to run with force=True: + # run_updater(force=True) + # By default, runs with force=False: + run_updater() diff --git a/Ax-Shell/modules/upower/LICENSE b/Ax-Shell/modules/upower/LICENSE new file mode 100644 index 0000000..56ede23 --- /dev/null +++ b/Ax-Shell/modules/upower/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Oscar Svensson (wogscpar) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Ax-Shell/modules/upower/__init__.py b/Ax-Shell/modules/upower/__init__.py new file mode 100644 index 0000000..cd8c722 --- /dev/null +++ b/Ax-Shell/modules/upower/__init__.py @@ -0,0 +1,5 @@ +""" +UPower wrapper using DBus +Copyright (c) 2017 Oscar Svensson (wogscpar) +https://github.com/wogscpar/upower_python +""" diff --git a/Ax-Shell/modules/upower/upower.py b/Ax-Shell/modules/upower/upower.py new file mode 100644 index 0000000..2696d53 --- /dev/null +++ b/Ax-Shell/modules/upower/upower.py @@ -0,0 +1,152 @@ +import dbus + +class UPowerManager(): + + def __init__(self): + self.UPOWER_NAME = "org.freedesktop.UPower" + self.UPOWER_PATH = "/org/freedesktop/UPower" + + self.DBUS_PROPERTIES = "org.freedesktop.DBus.Properties" + self.bus = dbus.SystemBus() + + def detect_devices(self): + upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH) + upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME) + + devices = upower_interface.EnumerateDevices() + return devices + + def get_display_device(self): + upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH) + upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME) + + dispdev = upower_interface.GetDisplayDevice() + return dispdev + + def get_critical_action(self): + upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH) + upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME) + + critical_action = upower_interface.GetCriticalAction() + return critical_action + + def get_device_percentage(self, battery): + battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery) + battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES) + + return battery_proxy_interface.Get(self.UPOWER_NAME + ".Device", "Percentage") + + def get_full_device_information(self, battery): + battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery) + battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES) + + # Use GetAll to retrieve all properties in a single DBus call + all_properties = battery_proxy_interface.GetAll(self.UPOWER_NAME + ".Device") + + # Extract properties with default values for missing keys + information_table = { + 'HasHistory': all_properties.get('HasHistory', False), + 'HasStatistics': all_properties.get('HasStatistics', False), + 'IsPresent': all_properties.get('IsPresent', False), + 'IsRechargeable': all_properties.get('IsRechargeable', False), + 'Online': all_properties.get('Online', False), + 'PowerSupply': all_properties.get('PowerSupply', False), + 'Capacity': all_properties.get('Capacity', 0.0), + 'Energy': all_properties.get('Energy', 0.0), + 'EnergyEmpty': all_properties.get('EnergyEmpty', 0.0), + 'EnergyFull': all_properties.get('EnergyFull', 0.0), + 'EnergyFullDesign': all_properties.get('EnergyFullDesign', 0.0), + 'EnergyRate': all_properties.get('EnergyRate', 0.0), + 'Luminosity': all_properties.get('Luminosity', 0.0), + 'Percentage': all_properties.get('Percentage', 0.0), + 'Temperature': all_properties.get('Temperature', 0.0), + 'Voltage': all_properties.get('Voltage', 0.0), + 'TimeToEmpty': all_properties.get('TimeToEmpty', 0), + 'TimeToFull': all_properties.get('TimeToFull', 0), + 'IconName': all_properties.get('IconName', ''), + 'Model': all_properties.get('Model', ''), + 'NativePath': all_properties.get('NativePath', ''), + 'Serial': all_properties.get('Serial', ''), + 'Vendor': all_properties.get('Vendor', ''), + 'State': all_properties.get('State', 0), + 'Technology': all_properties.get('Technology', 0), + 'Type': all_properties.get('Type', 0), + 'WarningLevel': all_properties.get('WarningLevel', 0), + 'UpdateTime': all_properties.get('UpdateTime', 0) + } + + return information_table + + def is_lid_present(self): + upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH) + upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES) + + is_lid_present = bool(upower_interface.Get(self.UPOWER_NAME, 'LidIsPresent')) + return is_lid_present + + def is_lid_closed(self): + upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH) + upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES) + + is_lid_closed = bool(upower_interface.Get(self.UPOWER_NAME, 'LidIsClosed')) + return is_lid_closed + + def on_battery(self): + upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH) + upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES) + + on_battery = bool(upower_interface.Get(self.UPOWER_NAME, 'OnBattery')) + return on_battery + + def has_wakeup_capabilities(self): + upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH + "/Wakeups") + upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES) + + has_wakeup_capabilities = bool(upower_interface.Get(self.UPOWER_NAME+ '.Wakeups', 'HasCapability')) + return has_wakeup_capabilities + + def get_wakeups_data(self): + upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH + "/Wakeups") + upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME + '.Wakeups') + + data = upower_interface.GetData() + return data + + def get_wakeups_total(self): + upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH + "/Wakeups") + upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME + '.Wakeups') + + data = upower_interface.GetTotal() + return data + + def is_loading(self, battery): + battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery) + battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES) + + state = int(battery_proxy_interface.Get(self.UPOWER_NAME + ".Device", "State")) + + if (state == 1): + return True + else: + return False + + def get_state(self, battery): + battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery) + battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES) + + state = int(battery_proxy_interface.Get(self.UPOWER_NAME + ".Device", "State")) + + if (state == 0): + return "Unknown" + elif (state == 1): + return "Loading" + elif (state == 2): + return "Discharging" + elif (state == 3): + return "Empty" + elif (state == 4): + return "Fully charged" + elif (state == 5): + return "Pending charge" + elif (state == 6): + return "Pending discharge" diff --git a/Ax-Shell/modules/wallpapers.py b/Ax-Shell/modules/wallpapers.py new file mode 100644 index 0000000..122ce92 --- /dev/null +++ b/Ax-Shell/modules/wallpapers.py @@ -0,0 +1,629 @@ +import colorsys +import concurrent.futures +import hashlib +import os +import random # <--- Aร‘ADIDO +import shutil +from concurrent.futures import ThreadPoolExecutor + +from fabric.utils.helpers import exec_shell_command_async +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.entry import Entry +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango +from PIL import Image + +import config.config +import config.data as data +import modules.icons as icons + + +class WallpaperSelector(Box): + CACHE_DIR = f"{data.CACHE_DIR}/thumbs" # Changed from wallpapers to thumbs + + def __init__(self, **kwargs): + # Delete the old cache directory if it exists + old_cache_dir = f"{data.CACHE_DIR}/wallpapers" + if os.path.exists(old_cache_dir): + shutil.rmtree(old_cache_dir) + + super().__init__( + name="wallpapers", + spacing=4, + orientation="v", + h_expand=False, + v_expand=False, + **kwargs, + ) + os.makedirs(self.CACHE_DIR, exist_ok=True) + + self.files = [] + GLib.idle_add(self._load_wallpapers_async().__next__) + self.thumbnails = [] + self.thumbnail_queue = [] + self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor + + # Variable to control the selection (similar to AppLauncher) + self.selected_index = -1 + + # Initialize UI components + self.viewport = Gtk.IconView(name="wallpaper-icons") + self.viewport.set_model(Gtk.ListStore(GdkPixbuf.Pixbuf, str)) + self.viewport.set_pixbuf_column(0) + # Hide text column so only the image is shown + self.viewport.set_text_column(-1) + self.viewport.set_item_width(0) + self.viewport.connect("item-activated", self.on_wallpaper_selected) + # self.viewport.connect("selection-changed", self._on_selection_changed) # Removed connection + + self.scrolled_window = ScrolledWindow( + name="scrolled-window", + spacing=10, + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + child=self.viewport, + propagate_width=False, + propagate_height=False, + ) + + self.search_entry = Entry( + name="search-entry-walls", + placeholder="Search Wallpapers...", + h_expand=True, + h_align="fill", + notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), + on_key_press_event=self.on_search_entry_key_press, + ) + self.search_entry.props.xalign = 0.5 + self.search_entry.connect("focus-out-event", self.on_search_entry_focus_out) + + 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", + } + + self.scheme_dropdown = Gtk.ComboBoxText() + self.scheme_dropdown.set_name("scheme-dropdown") + self.scheme_dropdown.set_tooltip_text("Select color scheme") + for key, display_name in self.schemes.items(): + self.scheme_dropdown.append(key, display_name) + self.scheme_dropdown.set_active_id("scheme-tonal-spot") + self.scheme_dropdown.connect("changed", self.on_scheme_changed) + + # Load matugen state from the dedicated file + self.matugen_enabled = True # Default to True + try: + with open(data.MATUGEN_STATE_FILE, "r") as f: + content = f.read().strip().lower() + if content == "false": + self.matugen_enabled = False + elif content == "true": + self.matugen_enabled = True + # Any other content defaults to True + except FileNotFoundError: + # File doesn't exist, keep default True and create it on first toggle + pass + except Exception as e: + print(f"Error reading matugen state file: {e}") + # Keep default True on error + + # Create a switcher to enable/disable Matugen (enabled by default) + self.matugen_switcher = Gtk.Switch(name="matugen-switcher") + self.matugen_switcher.set_tooltip_text("Toggle dynamic colors") + self.matugen_switcher.set_vexpand(False) + self.matugen_switcher.set_hexpand(False) + self.matugen_switcher.set_valign(Gtk.Align.CENTER) + self.matugen_switcher.set_halign(Gtk.Align.CENTER) + self.matugen_switcher.set_active(self.matugen_enabled) + self.matugen_switcher.connect("notify::active", self.on_switch_toggled) + + self.mat_icon = Label(name="mat-label", markup=icons.palette) + + self.random_wall = Button( + name="random-wall-button", + child=Label(name="random-wall-label", markup=icons.dice_1), + tooltip_text="Random Wallpaper", + ) + self.random_wall.connect("clicked", self.set_random_wallpaper) # <--- Aร‘ADIDO + + # Add the switcher to the header_box's start_children + self.header_box = Box( + name="header-box", + spacing=8, + orientation="h", + children=[ + self.random_wall, + self.search_entry, + self.scheme_dropdown, + self.matugen_switcher, + ], + ) + + self.add(self.header_box) + + # Create the custom color selector components + self.hue_slider = Gtk.Scale( + orientation=Gtk.Orientation.HORIZONTAL, # Changed from VERTICAL + adjustment=Gtk.Adjustment( + value=0, lower=0, upper=360, step_increment=1, page_increment=10 + ), + draw_value=False, # Hide the default value text + digits=0, + # inverted=True, # Removed inverted for horizontal + name="hue-slider", # For CSS styling + ) + + # Changed expand/align for horizontal orientation + self.hue_slider.set_hexpand(True) + self.hue_slider.set_halign(Gtk.Align.FILL) + self.hue_slider.set_vexpand(False) # Ensure it doesn't expand vertically + self.hue_slider.set_valign(Gtk.Align.CENTER) # Center vertically within its box + + self.apply_color_button = Button( + name="apply-color-button", + child=Label(name="apply-color-label", markup=icons.accept), + ) + self.apply_color_button.connect("clicked", self.on_apply_color_clicked) + self.apply_color_button.set_vexpand( + False + ) # Ensure button doesn't expand vertically + self.apply_color_button.set_valign(Gtk.Align.CENTER) # Center button vertically + + self.custom_color_selector_box = Box( + orientation="h", + spacing=5, + name="custom-color-selector-box", # Changed orientation to horizontal + h_align="center", # Center the horizontal box + ) + self.custom_color_selector_box.add(self.hue_slider) + self.custom_color_selector_box.add(self.apply_color_button) + self.custom_color_selector_box.set_halign(Gtk.Align.FILL) + + # Add the scrolled window (grid) and the custom color selector box directly + # to the main WallpaperSelector box (which is already vertical) + self.pack_start(self.scrolled_window, True, True, 0) # Add grid, expand + self.pack_start( + self.custom_color_selector_box, False, False, 0 + ) # Add custom selector, don't expand + + # Removed the old main_content_box and its add + + self._start_thumbnail_thread() + self.connect("map", self.on_map) + self.setup_file_monitor() + self.show_all() + self.randomize_dice_icon() + # Ensure the search entry gets focus when starting + self.search_entry.grab_focus() + + def _load_wallpapers_async(self): + """Non-blocking wallpaper processing.""" + + # Process old wallpapers: use os.scandir for efficiency and only loop + # over image files that actually need renaming (they're not already lowercase + # and with hyphens instead of spaces) + with os.scandir(data.WALLPAPERS_DIR) as entries: + for entry in entries: + if entry.is_file() and self._is_image(entry.name): + # Check if the file needs renaming: file should be lowercase and have hyphens instead of spaces + if entry.name != entry.name.lower() or " " in entry.name: + new_name = entry.name.lower().replace(" ", "-") + full_path = os.path.join(data.WALLPAPERS_DIR, entry.name) + new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name) + try: + os.rename(full_path, new_full_path) + print( + f"Renamed old wallpaper '{full_path}' to '{new_full_path}'" + ) + except Exception as e: + print(f"Error renaming file {full_path}: {e}") + yield + + # Process files in small batches to keep UI responsive + file_list = os.listdir(data.WALLPAPERS_DIR) + batch_size = 20 + + # Process files in batches + for i in range(0, len(file_list), batch_size): + batch = file_list[i : i + batch_size] + for filename in batch: + if self._is_image(filename): + self.files.append(filename) + + # Sort the current batch to maintain order + self.files.sort() + + # Yield to let the main loop process events + yield True + + # Final sort of the complete list + self.files.sort() + + # Start thumbnail loading after files are processed + self._start_thumbnail_thread() + + # Return False to stop the idle callback + yield False + + def randomize_dice_icon(self): + dice_icons = [ + icons.dice_1, + icons.dice_2, + icons.dice_3, + icons.dice_4, + icons.dice_5, + icons.dice_6, + ] + chosen_icon = random.choice(dice_icons) + label = self.random_wall.get_child() + if isinstance(label, Label): + label.set_markup(chosen_icon) + + def set_random_wallpaper(self, widget, external=False): + if not self.files: + print("No wallpapers available to set a random one.") + return + + file_name = random.choice(self.files) + full_path = os.path.join(data.WALLPAPERS_DIR, file_name) + selected_scheme = self.scheme_dropdown.get_active_id() + current_wall = os.path.expanduser(f"~/.current.wall") + + if os.path.isfile(current_wall) or os.path.islink( + current_wall + ): # Check for link too + os.remove(current_wall) + os.symlink(full_path, current_wall) + + if self.matugen_switcher.get_active(): + exec_shell_command_async( + f'matugen image "{full_path}" -t {selected_scheme}' + ) + else: + exec_shell_command_async( + f'awww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' + ) + + print(f"Set random wallpaper: {file_name}") + + if external: + exec_shell_command_async( + f"notify-send '๐ŸŽฒ Wallpaper' 'Setting a random wallpaper ๐ŸŽจ' -a '{data.APP_NAME_CAP}' -i '{full_path}' -e" + ) + + self.randomize_dice_icon() + + def setup_file_monitor(self): + gfile = Gio.File.new_for_path(data.WALLPAPERS_DIR) + self.file_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None) + self.file_monitor.connect("changed", self.on_directory_changed) + + def on_directory_changed(self, monitor, file, other_file, event_type): + file_name = file.get_basename() + if event_type == Gio.FileMonitorEvent.DELETED: + if file_name in self.files: + self.files.remove(file_name) + cache_path = self._get_cache_path(file_name) + if os.path.exists(cache_path): + try: + os.remove(cache_path) + except Exception as e: + print(f"Error deleting cache {cache_path}: {e}") + self.thumbnails = [(p, n) for p, n in self.thumbnails if n != file_name] + GLib.idle_add(self.arrange_viewport, self.search_entry.get_text()) + elif event_type == Gio.FileMonitorEvent.CREATED: + if self._is_image(file_name): + # Convert filename to lowercase and replace spaces with "-" + new_name = file_name.lower().replace(" ", "-") + full_path = os.path.join(data.WALLPAPERS_DIR, file_name) + new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name) + if new_name != file_name: + try: + os.rename(full_path, new_full_path) + file_name = new_name + print(f"Renamed file '{full_path}' to '{new_full_path}')") + except Exception as e: + print(f"Error renaming file {full_path}: {e}") + if file_name not in self.files: + self.files.append(file_name) + self.files.sort() + self.executor.submit(self._process_file, file_name) + elif event_type == Gio.FileMonitorEvent.CHANGED: + if self._is_image(file_name) and file_name in self.files: + cache_path = self._get_cache_path(file_name) + if os.path.exists(cache_path): + try: + os.remove(cache_path) + except Exception as e: + print(f"Error deleting cache for changed file {file_name}: {e}") + self.executor.submit(self._process_file, file_name) + + def arrange_viewport(self, query: str = ""): + model = self.viewport.get_model() + model.clear() + filtered_thumbnails = [ + (thumb, name) + for thumb, name in self.thumbnails + if query.casefold() in name.casefold() + ] + filtered_thumbnails.sort(key=lambda x: x[1].lower()) + for pixbuf, file_name in filtered_thumbnails: + model.append([pixbuf, file_name]) + # If the search entry is empty, no icon is selected; otherwise, select the first one. + if query.strip() == "": + self.viewport.unselect_all() + self.selected_index = -1 + elif len(model) > 0: + self.update_selection(0) + + def on_wallpaper_selected(self, iconview, path): + model = iconview.get_model() + file_name = model[path][1] + full_path = os.path.join(data.WALLPAPERS_DIR, file_name) + selected_scheme = self.scheme_dropdown.get_active_id() + current_wall = os.path.expanduser(f"~/.current.wall") + if os.path.isfile(current_wall) or os.path.islink(current_wall): + os.remove(current_wall) + os.symlink(full_path, current_wall) + if self.matugen_switcher.get_active(): + # Matugen is enabled: run the normal command. + exec_shell_command_async( + f'matugen image "{full_path}" -t {selected_scheme}' + ) + else: + # Matugen is disabled: run the alternative awww command. + exec_shell_command_async( + f'awww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' + ) + + def on_scheme_changed(self, combo): + selected_scheme = combo.get_active_id() + print(f"Color scheme selected: {selected_scheme}") + + def on_search_entry_key_press(self, widget, event): + if event.state & Gdk.ModifierType.SHIFT_MASK: + if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down): + schemes_list = list(self.schemes.keys()) + current_id = self.scheme_dropdown.get_active_id() + current_index = ( + schemes_list.index(current_id) if current_id in schemes_list else 0 + ) + new_index = ( + (current_index - 1) % len(schemes_list) + if event.keyval == Gdk.KEY_Up + else (current_index + 1) % len(schemes_list) + ) + self.scheme_dropdown.set_active(new_index) + return True + elif event.keyval == Gdk.KEY_Right: + self.scheme_dropdown.popup() + return True + + if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right): + self.move_selection_2d(event.keyval) + return True + elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + if self.selected_index != -1: + path = Gtk.TreePath.new_from_indices([self.selected_index]) + self.on_wallpaper_selected(self.viewport, path) + return True + return False + + # Removed _on_selection_changed method + + def move_selection_2d(self, keyval): + model = self.viewport.get_model() + total_items = len(model) + if total_items == 0: + return + + # --- Determine Column Count --- + columns = self.viewport.get_columns() + + # If get_columns returns 0 or -1 (auto), try to estimate by checking item rows + if columns <= 0 and total_items > 0: + estimated_cols = 0 + try: + # Check the row of the first item (should be 0) + first_item_path = Gtk.TreePath.new_from_indices([0]) + base_row = self.viewport.get_item_row(first_item_path) + + # Find the index of the first item in the *next* row + for i in range(1, total_items): + path = Gtk.TreePath.new_from_indices([i]) + row = self.viewport.get_item_row(path) + if row > base_row: + estimated_cols = i # The number of items in the first row + break + + # If loop finished without finding a new row, all items are in one row + if estimated_cols == 0: + estimated_cols = total_items + + columns = max(1, estimated_cols) + except Exception: + # Fallback if get_item_row fails (e.g., widget not realized) + columns = 1 + elif columns <= 0 and total_items == 0: + columns = 1 # Should not happen due to early return, but safe + + # Ensure columns is at least 1 after all checks + columns = max(1, columns) + + # --- Navigation Logic --- + current_index = self.selected_index + new_index = current_index + + if current_index == -1: + # If nothing is selected, select the first or last item based on direction + if keyval in (Gdk.KEY_Down, Gdk.KEY_Right): + new_index = 0 + elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left): + new_index = total_items - 1 + if total_items == 0: + new_index = -1 # Handle edge case + + else: + # Calculate potential new index based on key press + if keyval == Gdk.KEY_Up: + potential_new_index = current_index - columns + # Only update if the new index is valid (>= 0) + if potential_new_index >= 0: + new_index = potential_new_index + elif keyval == Gdk.KEY_Down: + potential_new_index = current_index + columns + # Only update if the new index is valid (< total_items) + if potential_new_index < total_items: + new_index = potential_new_index + elif keyval == Gdk.KEY_Left: + # Only update if not already in the first column (index % columns != 0) + # and the index is greater than 0 + if current_index > 0 and current_index % columns != 0: + new_index = current_index - 1 + elif keyval == Gdk.KEY_Right: + # Only update if not in the last column ((index + 1) % columns != 0) + # and not the very last item (index < total_items - 1) + if ( + current_index < total_items - 1 + and (current_index + 1) % columns != 0 + ): + new_index = current_index + 1 + + # Only update if the index actually changed and is valid + if new_index != self.selected_index and 0 <= new_index < total_items: + self.update_selection(new_index) + elif ( + total_items > 0 + and self.selected_index == -1 + and 0 <= new_index < total_items + ): + # Handle selecting the first item when starting from -1 + self.update_selection(new_index) + + def update_selection(self, new_index: int): + self.viewport.unselect_all() + path = Gtk.TreePath.new_from_indices([new_index]) + self.viewport.select_path(path) + self.viewport.scroll_to_path( + path, False, 0.5, 0.5 + ) # Ensure the selected icon is visible + self.selected_index = new_index + + def _start_thumbnail_thread(self): + thread = GLib.Thread.new("thumbnail-loader", self._preload_thumbnails, None) + + def _preload_thumbnails(self, _data): + futures = [ + self.executor.submit(self._process_file, file_name) + for file_name in self.files + ] + concurrent.futures.wait(futures) + GLib.idle_add(self._process_batch) + + def _process_file(self, file_name): + full_path = os.path.join(data.WALLPAPERS_DIR, file_name) + cache_path = self._get_cache_path(file_name) + if not os.path.exists(cache_path): + try: + with Image.open(full_path) as img: + width, height = img.size + side = min(width, height) + left = (img.width - side) // 2 + top = (height - side) // 2 + right = left + side + bottom = top + side + img_cropped = img.crop((left, top, right, bottom)) + img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS) + img_cropped.save(cache_path, "PNG") + except Exception as e: + print(f"Error processing {file_name}: {e}") + return + self.thumbnail_queue.append((cache_path, file_name)) + GLib.idle_add(self._process_batch) + + def _process_batch(self): + batch = self.thumbnail_queue[:10] + del self.thumbnail_queue[:10] + for cache_path, file_name in batch: + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) + self.thumbnails.append((pixbuf, file_name)) + self.viewport.get_model().append([pixbuf, file_name]) + except Exception as e: + print(f"Error loading thumbnail {cache_path}: {e}") + if self.thumbnail_queue: + GLib.idle_add(self._process_batch) + return False + + def _get_cache_path(self, file_name: str) -> str: + file_hash = hashlib.md5(file_name.encode("utf-8")).hexdigest() + return os.path.join(self.CACHE_DIR, f"{file_hash}.png") + + @staticmethod + def _is_image(file_name: str) -> bool: + return file_name.lower().endswith( + (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") + ) + + def on_search_entry_focus_out(self, widget, event): + if self.get_mapped(): + widget.grab_focus() + return False + + def on_map(self, widget): + """Handles the map signal to set initial visibility of the color selector.""" + # Set visibility based on the loaded state when the widget becomes visible + self.custom_color_selector_box.set_visible(not self.matugen_enabled) + + def hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: + """Converts 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 rgba_to_hex(self, rgba: Gdk.RGBA) -> str: + """Converts Gdk.RGBA to a HEX color string.""" + r = int(rgba.red * 255) + g = int(rgba.green * 255) + b = int(rgba.blue * 255) + return f"#{r:02X}{g:02X}{b:02X}" + + def on_switch_toggled(self, switch, gparam): + """Handles the toggling of the Matugen switch.""" + is_active = switch.get_active() + self.matugen_enabled = is_active + # self.scheme_dropdown.set_sensitive(is_active) + self.custom_color_selector_box.set_visible(not is_active) # Toggle visibility + + # Save the state to the dedicated file + try: + with open(data.MATUGEN_STATE_FILE, "w") as f: + f.write(str(is_active)) + except Exception as e: + print(f"Error writing matugen state file: {e}") + + def on_apply_color_clicked(self, button): + """Applies the color selected by the hue slider via matugen.""" + hue_value = self.hue_slider.get_value() # Get value from 0-360 + hex_color = self.hsl_to_rgb_hex(hue_value) # Convert HSL(hue, 1.0, 0.5) to HEX + print(f"Applying color from slider: H={hue_value}, HEX={hex_color}") + selected_scheme = self.scheme_dropdown.get_active_id() + # Run matugen with the chosen hex color and selected scheme + exec_shell_command_async( + f'matugen color hex "{hex_color}" -t {selected_scheme}' + ) + # Optionally save the chosen color to config if needed later + # config.config.bind_vars["matugen_hex_color"] = hex_color + # config.config.save_config() # Removed as save_config doesn't exist diff --git a/Ax-Shell/modules/weather.py b/Ax-Shell/modules/weather.py new file mode 100644 index 0000000..c15d210 --- /dev/null +++ b/Ax-Shell/modules/weather.py @@ -0,0 +1,106 @@ +import subprocess +import urllib.parse + +import gi +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from gi.repository import GLib + +gi.require_version("Gtk", "3.0") +import config.data as data +import modules.icons as icons + + +class Weather(Button): + def __init__(self, **kwargs) -> None: + super().__init__(name="weather", orientation="h", spacing=8, **kwargs) + self.label = Label(name="weather-label", markup=icons.loader) + self.add(self.label) + self.show_all() + self.enabled = False # Will be set by apply_component_props + self.has_weather_data = False + self.fetching = False # Prevent concurrent fetches + # Fetch weather every 10 minutes (600 seconds) + GLib.timeout_add_seconds(600, self.fetch_weather) + # Delay initial fetch to allow visibility config to be applied first (runs only once) + GLib.timeout_add(100, self._initial_fetch) + + def set_visible(self, visible): + """Override to track external visibility setting""" + self.enabled = visible + + # If being disabled, always hide + if not visible: + super().set_visible(False) + return + + # If being enabled, only show if we have weather data + if hasattr(self, "has_weather_data") and self.has_weather_data: + super().set_visible(True) + # If no weather data yet, remain hidden until fetch completes + + def _initial_fetch(self): + """Initial fetch that runs only once""" + self.fetch_weather() + return False # Don't repeat this timeout + + def fetch_weather(self): + # Prevent concurrent fetches + if self.fetching: + return True + + self.fetching = True + GLib.Thread.new("weather-fetch", self._fetch_weather_thread, None) + return True + + def _fetch_weather_thread(self, user_data): + url = ( + "https://wttr.in/?format=%c+%t" + if not data.VERTICAL + else "https://wttr.in/?format=%c" + ) + + tooltip_url = "https://wttr.in/?format=%l:+%C,+%t+(%f),+Humidity:+%h,+Wind:+%w" + + try: + # Use curl to fetch weather data + result = subprocess.run( + ["curl", "-sf", "--max-time", "5", url], + capture_output=True, + text=True, + timeout=6, + ) + + if result.returncode == 0 and result.stdout: + weather_data = result.stdout.strip() + if "Unknown" in weather_data: + self.has_weather_data = False + GLib.idle_add(self.set_visible, False) + else: + self.has_weather_data = True + + # Fetch tooltip data + tooltip_result = subprocess.run( + ["curl", "-sf", "--max-time", "5", tooltip_url], + capture_output=True, + text=True, + timeout=6, + ) + if tooltip_result.returncode == 0 and tooltip_result.stdout: + tooltip_text = tooltip_result.stdout.strip() + GLib.idle_add(self.set_tooltip_text, tooltip_text) + + GLib.idle_add(self.set_visible, self.enabled) + GLib.idle_add(self.label.set_label, weather_data.replace(" ", "")) + else: + self.has_weather_data = False + GLib.idle_add(self.label.set_markup, f"{icons.cloud_off} Unavailable") + GLib.idle_add(self.set_visible, False) + except Exception as e: + self.has_weather_data = False + print(f"Error fetching weather: {e}") + GLib.idle_add(self.label.set_markup, f"{icons.cloud_off} Error") + GLib.idle_add(self.set_visible, False) + finally: + # Always reset fetching flag when done + self.fetching = False diff --git a/Ax-Shell/modules/widgets.py b/Ax-Shell/modules/widgets.py new file mode 100644 index 0000000..c0c4e4c --- /dev/null +++ b/Ax-Shell/modules/widgets.py @@ -0,0 +1,166 @@ +import gi + +gi.require_version("Gtk", "3.0") +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.stack import Stack + +import config.data as data +from modules.bluetooth import BluetoothConnections +from modules.buttons import Buttons +from modules.calendar import Calendar +from modules.controls import ControlSliders +from modules.metrics import Metrics +from modules.network import NetworkConnections +from modules.notifications import NotificationHistory +from modules.player import Player + + +class Widgets(Box): + def __init__(self, **kwargs): + super().__init__( + name="dash-widgets", + h_align="fill", + v_align="fill", + h_expand=True, + v_expand=True, + visible=True, + all_visible=True, + ) + + vertical_layout = False + if data.PANEL_THEME == "Panel" and ( + data.BAR_POSITION in ["Left", "Right"] + or data.PANEL_POSITION in ["Start", "End"] + ): + vertical_layout = True + + calendar_view_mode = "week" if vertical_layout else "month" + + self.calendar = Calendar(view_mode=calendar_view_mode) + + self.notch = kwargs["notch"] + + self.buttons = Buttons(widgets=self) + self.bluetooth = BluetoothConnections(widgets=self) + + self.box_1 = Box( + name="box-1", + h_expand=True, + v_expand=True, + ) + + self.box_2 = Box( + name="box-2", + h_expand=True, + v_expand=True, + ) + + self.box_3 = Box( + name="box-3", + v_expand=True, + ) + + self.controls = ControlSliders() + + self.player = Player() + + self.metrics = Metrics() + + self.notification_history = NotificationHistory() + + self.network_connections = NetworkConnections(widgets=self) + + self.applet_stack = Stack( + h_expand=True, + v_expand=True, + transition_type="slide-left-right", + children=[ + self.notification_history, + self.network_connections, + self.bluetooth, + ], + ) + + self.applet_stack_box = Box( + name="applet-stack", + h_expand=True, + v_expand=True, + h_align="fill", + children=[ + self.applet_stack, + ], + ) + + if not vertical_layout: + self.children_1 = [ + Box( + name="container-sub-1", + h_expand=True, + v_expand=True, + spacing=8, + children=[ + self.calendar, + self.applet_stack_box, + ], + ), + self.metrics, + ] + else: + self.children_1 = [ + self.applet_stack_box, + self.calendar, # Weekly view when vertical + self.player, + ] + + self.container_1 = Box( + name="container-1", + h_expand=True, + v_expand=True, + orientation="h" if not vertical_layout else "v", + spacing=8, + children=self.children_1, + ) + + self.container_2 = Box( + name="container-2", + h_expand=True, + v_expand=True, + orientation="v", + spacing=8, + children=[ + self.buttons, + self.controls, + self.container_1, + ], + ) + + if not vertical_layout: + self.children_3 = [ + self.player, + self.container_2, + ] + else: # vertical_layout + self.children_3 = [ + self.container_2, + ] + + self.container_3 = Box( + name="container-3", + h_expand=True, + v_expand=True, + orientation="h", + spacing=8, + children=self.children_3, + ) + + self.add(self.container_3) + + def show_bt(self): + self.applet_stack.set_visible_child(self.bluetooth) + + def show_notif(self): + self.applet_stack.set_visible_child(self.notification_history) + + def show_network_applet(self): + self.notch.open_notch("network_applet") diff --git a/Ax-Shell/scripts/gamemode.sh b/Ax-Shell/scripts/gamemode.sh new file mode 100644 index 0000000..22eea15 --- /dev/null +++ b/Ax-Shell/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/Ax-Shell/scripts/hyprland_multimonitor_config.conf b/Ax-Shell/scripts/hyprland_multimonitor_config.conf new file mode 100644 index 0000000..b894995 --- /dev/null +++ b/Ax-Shell/scripts/hyprland_multimonitor_config.conf @@ -0,0 +1,71 @@ +# Configuraciรณn de Hyprland para Multi-Monitor Support en Ax-Shell + +# Configurar monitors +monitor=DP-1,1920x1080@60,0x0,1 +monitor=HDMI-A-1,1920x1080@60,1920x0,1 + +# Configurar workspaces por monitor +# Monitor 0: Workspaces 1-10 +workspace=1,monitor:DP-1 +workspace=2,monitor:DP-1 +workspace=3,monitor:DP-1 +workspace=4,monitor:DP-1 +workspace=5,monitor:DP-1 +workspace=6,monitor:DP-1 +workspace=7,monitor:DP-1 +workspace=8,monitor:DP-1 +workspace=9,monitor:DP-1 +workspace=10,monitor:DP-1 + +# Monitor 1: Workspaces 11-20 +workspace=11,monitor:HDMI-A-1 +workspace=12,monitor:HDMI-A-1 +workspace=13,monitor:HDMI-A-1 +workspace=14,monitor:HDMI-A-1 +workspace=15,monitor:HDMI-A-1 +workspace=16,monitor:HDMI-A-1 +workspace=17,monitor:HDMI-A-1 +workspace=18,monitor:HDMI-A-1 +workspace=19,monitor:HDMI-A-1 +workspace=20,monitor:HDMI-A-1 + +# Keybinds globales para Ax-Shell multi-monitor +# Estos comandos se ejecutan en el monitor con foco actual + +# Abrir launcher en monitor con foco +bind = SUPER, SPACE, exec, python ~/.config/Ax-Shell/scripts/toggle_launcher.py + +# Abrir overview en monitor con foco +bind = SUPER, TAB, exec, python ~/.config/Ax-Shell/scripts/toggle_overview.py + +# Navegar entre workspaces del monitor actual +bind = SUPER, 1, workspace, 1 +bind = SUPER, 2, workspace, 2 +bind = SUPER, 3, workspace, 3 +bind = SUPER, 4, workspace, 4 +bind = SUPER, 5, workspace, 5 +bind = SUPER, 6, workspace, 6 +bind = SUPER, 7, workspace, 7 +bind = SUPER, 8, workspace, 8 +bind = SUPER, 9, workspace, 9 +bind = SUPER, 0, workspace, 10 + +# Mover ventanas a workspaces del monitor actual +bind = SUPER SHIFT, 1, movetoworkspacesilent, 1 +bind = SUPER SHIFT, 2, movetoworkspacesilent, 2 +bind = SUPER SHIFT, 3, movetoworkspacesilent, 3 +bind = SUPER SHIFT, 4, movetoworkspacesilent, 4 +bind = SUPER SHIFT, 5, movetoworkspacesilent, 5 +bind = SUPER SHIFT, 6, movetoworkspacesilent, 6 +bind = SUPER SHIFT, 7, movetoworkspacesilent, 7 +bind = SUPER SHIFT, 8, movetoworkspacesilent, 8 +bind = SUPER SHIFT, 9, movetoworkspacesilent, 9 +bind = SUPER SHIFT, 0, movetoworkspacesilent, 10 + +# Cambiar entre monitores +bind = SUPER, bracketleft, focusmonitor, DP-1 +bind = SUPER, bracketright, focusmonitor, HDMI-A-1 + +# Mover ventana al otro monitor +bind = SUPER SHIFT, bracketleft, movewindow, mon:DP-1 +bind = SUPER SHIFT, bracketright, movewindow, mon:HDMI-A-1 \ No newline at end of file diff --git a/Ax-Shell/scripts/hyprpicker.sh b/Ax-Shell/scripts/hyprpicker.sh new file mode 100644 index 0000000..4202da1 --- /dev/null +++ b/Ax-Shell/scripts/hyprpicker.sh @@ -0,0 +1,80 @@ +#!/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/Ax-Shell/scripts/inhibit.py b/Ax-Shell/scripts/inhibit.py new file mode 100644 index 0000000..1f31da3 --- /dev/null +++ b/Ax-Shell/scripts/inhibit.py @@ -0,0 +1,83 @@ +# From https://github.com/stwa/wayland-idle-inhibitor +# License: WTFPL Version 2 + +import sys +from dataclasses import dataclass +from signal import SIGINT, SIGTERM, signal +from threading import Event + +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 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: + done = Event() + signal(SIGINT, lambda _, __: done.set()) + signal(SIGTERM, lambda _, __: done.set()) + + global_registry = GlobalRegistry() + + 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 or global_registry.inhibit_manager is None: + print("Wayland seems not to support idle_inhibit_unstable_v1 protocol.") + shutdown() + sys.exit(1) + + inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore + global_registry.surface + ) + + display.dispatch() + display.roundtrip() + + print("Inhibiting idle...") + done.wait() + print("Shutting down...") + + inhibitor.destroy() + + shutdown() + + +if __name__ == "__main__": + setproctitle.setproctitle("ax-inhibit") + main() diff --git a/Ax-Shell/scripts/ocr.sh b/Ax-Shell/scripts/ocr.sh new file mode 100755 index 0000000..90be478 --- /dev/null +++ b/Ax-Shell/scripts/ocr.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Captura con hyprshot (selecciรณn de regiรณn) y envรญa imagen RAW a stdout +ocr_text=$(hyprshot -m region -z -r -s | tesseract -l eng - - 2>/dev/null) + +# Comprueba si Tesseract devolviรณ algo +if [[ -n "$ocr_text" ]]; then + # Copia el texto reconocido al portapapeles + echo -n "$ocr_text" | wl-copy + notify-send -a "Ax-Shell" "OCR Success" "Text Copied to Clipboard" +else + notify-send -a "Ax-Shell" "OCR Failed" "No text recognized or operation failed" +fi diff --git a/Ax-Shell/scripts/pomodoro.sh b/Ax-Shell/scripts/pomodoro.sh new file mode 100644 index 0000000..4efe7a9 --- /dev/null +++ b/Ax-Shell/scripts/pomodoro.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +WORK_MINUTES=25 +BREAK_MINUTES=5 +LONG_BREAK_MINUTES=15 +POMODOROS_PER_LONG_BREAK=4 + +# Get the PID of this script (excluding the grep process itself) +MYPID=$$ +if pgrep -f "pomodoro.sh" | grep -qv "$MYPID"; then + # Another instance is running - kill it + notify-send "Pomodoro Timer" "Timer stopped" -a "Pomodoro" + pkill -KILL -f "pomodoro.sh" + exit +fi + +# Initialize counters +pomodoro_count=0 + +# Main loop +while true; do + # Work period + notify-send "Pomodoro Timer" "Work time! ($WORK_MINUTES minutes)" -a "Pomodoro" + sleep ${WORK_MINUTES}m + + ((pomodoro_count++)) + + if ((pomodoro_count % POMODOROS_PER_LONG_BREAK == 0)); then + notify-send "Pomodoro Timer" "Great job! Take a long break ($LONG_BREAK_MINUTES minutes)" -a "Pomodoro" + sleep ${LONG_BREAK_MINUTES}m + else + notify-send "Pomodoro Timer" "Good work! Take a short break ($BREAK_MINUTES minutes)" -a "Pomodoro" + sleep ${BREAK_MINUTES}m + fi + +done diff --git a/Ax-Shell/scripts/screenrecord.sh b/Ax-Shell/scripts/screenrecord.sh new file mode 100755 index 0000000..8f547eb --- /dev/null +++ b/Ax-Shell/scripts/screenrecord.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Check if XDG_VIDEOS_DIR is not set +if [ -z "$XDG_VIDEOS_DIR" ]; then + XDG_VIDEOS_DIR="$HOME/Videos" +fi + +# Directorio donde se guardarรกn las grabaciones +SAVE_DIR="$XDG_VIDEOS_DIR/Recordings" +mkdir -p "$SAVE_DIR" + +# Si ya estรก corriendo gpu-screen-recorder, se envรญa SIGINT para detenerlo correctamente +if pgrep -f "gpu-screen-recorder" >/dev/null; then + pkill -SIGINT -f "gpu-screen-recorder" + + # Espera un momento para asegurarse de que la grabaciรณn se haya detenido y el archivo estรฉ listo + sleep 1 + + # Obtiene el รบltimo archivo grabado + LAST_VIDEO=$(ls -t "$SAVE_DIR"/*.mp4 2>/dev/null | head -n 1) + + # Notificaciรณn con acciones: "View" abre el archivo, "Open folder" abre la carpeta + ACTION=$(notify-send -a "Ax-Shell" "โฌœ Recording stopped" \ + -A "view=View" -A "open=Open folder") + + if [ "$ACTION" = "view" ] && [ -n "$LAST_VIDEO" ]; then + xdg-open "$LAST_VIDEO" + elif [ "$ACTION" = "open" ]; then + xdg-open "$SAVE_DIR" + fi + exit 0 +fi + +# Nombre del archivo de salida para la nueva grabaciรณn +OUTPUT_FILE="$SAVE_DIR/$(date +%Y-%m-%d-%H-%M-%S).mp4" + +# Iniciar la grabaciรณn +notify-send -a "Ax-Shell" "๐Ÿ”ด Recording started" +gpu-screen-recorder -w screen -q ultra -a default_output -ac opus -cr full -f 60 -o "$OUTPUT_FILE" diff --git a/Ax-Shell/scripts/screenshot.sh b/Ax-Shell/scripts/screenshot.sh new file mode 100755 index 0000000..227e725 --- /dev/null +++ b/Ax-Shell/scripts/screenshot.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env sh + +sleep 0.5 + +if [ -z "$XDG_PICTURES_DIR" ]; then + XDG_PICTURES_DIR="$HOME/Pictures" +fi + +save_dir="${3:-$XDG_PICTURES_DIR/Screenshots}" +save_file=$(date +'%y%m%d_%Hh%Mm%Ss_screenshot.png') +full_path="$save_dir/$save_file" +mkdir -p "$save_dir" + +mockup_mode="$2" + +print_error() { + cat < [mockup] + ...valid actions are... + p : print selected screen + s : snip selected region + w : snip focused window +EOF +} + +case $1 in + p) + hyprshot -z -s -m output -o "$save_dir" -f "$save_file" + ;; + s) + hyprshot -z -s -m region -o "$save_dir" -f "$save_file" + ;; + w) + hyprshot -s -m window -o "$save_dir" -f "$save_file"; + ;; + *) + print_error + exit 1 + ;; +esac + +if [ -f "$full_path" ]; then + # Copiar al portapapeles si no es mockup + if [ "$mockup_mode" != "mockup" ]; then + if command -v wl-copy >/dev/null 2>&1; then + wl-copy < "$full_path" + elif command -v xclip >/dev/null 2>&1; then + xclip -selection clipboard -t image/png < "$full_path" + fi + fi + + # Procesar mockup + if [ "$mockup_mode" = "mockup" ]; then + temp_file="${full_path%.png}_temp.png" + mockup_file="${full_path%.png}_mockup.png" + mockup_success=true + + # Redondear esquinas y transparencia + if [ "$mockup_success" = true ]; then + magick "$full_path" \ + \( +clone -alpha extract -draw 'fill black polygon 0,0 0,20 20,0 fill white circle 20,20 20,0' \ + \( +clone -flip \) -compose Multiply -composite \ + \( +clone -flop \) -compose Multiply -composite \ + \) -alpha off -compose CopyOpacity -composite "$temp_file" || mockup_success=false + fi + + # Aรฑadir sombra + if [ "$mockup_success" = true ]; then + magick "$temp_file" \ + \( +clone -background black -shadow 60x20+0+10 -alpha set -channel A -evaluate multiply 1 +channel \) \ + +swap -background none -layers merge +repage "$mockup_file" || mockup_success=false + fi + + if [ "$mockup_success" = true ] && [ -f "$mockup_file" ]; then + rm "$temp_file" + mv "$mockup_file" "$full_path" + if command -v wl-copy >/dev/null 2>&1; then + wl-copy < "$full_path" + elif command -v xclip >/dev/null 2>&1; then + xclip -selection clipboard -t image/png < "$full_path" + fi + else + echo "Warning: Mockup processing failed, manteniendo original." >&2 + rm -f "$temp_file" "$mockup_file" + if [ "$mockup_mode" = "mockup" ]; then + if command -v wl-copy >/dev/null 2>&1; then + wl-copy < "$full_path" + elif command -v xclip >/dev/null 2>&1; then + xclip -selection clipboard -t image/png < "$full_path" + fi + fi + fi + fi + + ACTION=$(notify-send -a "Ax-Shell" -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 +else + notify-send -a "Ax-Shell" "Screenshot Aborted" +fi diff --git a/Ax-Shell/scripts/toggle_launcher.py b/Ax-Shell/scripts/toggle_launcher.py new file mode 100644 index 0000000..1fec87e --- /dev/null +++ b/Ax-Shell/scripts/toggle_launcher.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +""" +Example script for opening the launcher on the focused monitor. +This script uses the global keybind handler to open the launcher +on whichever monitor currently has focus. +""" + +import sys +import os + +# Add the Ax-Shell directory to Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + from utils.global_keybinds import get_global_keybind_handler + + handler = get_global_keybind_handler() + success = handler.open_launcher() + + if success: + print("Launcher opened on focused monitor") + sys.exit(0) + else: + print("Failed to open launcher") + sys.exit(1) + +except ImportError as e: + print(f"Error importing Ax-Shell modules: {e}") + sys.exit(1) +except Exception as e: + print(f"Error opening launcher: {e}") + sys.exit(1) \ No newline at end of file diff --git a/Ax-Shell/scripts/toggle_overview.py b/Ax-Shell/scripts/toggle_overview.py new file mode 100644 index 0000000..286a1f7 --- /dev/null +++ b/Ax-Shell/scripts/toggle_overview.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +""" +Example script for opening the overview on the focused monitor. +This script uses the global keybind handler to open the overview +on whichever monitor currently has focus. +""" + +import sys +import os + +# Add the Ax-Shell directory to Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + from utils.global_keybinds import get_global_keybind_handler + + handler = get_global_keybind_handler() + success = handler.open_overview() + + if success: + print("Overview opened on focused monitor") + sys.exit(0) + else: + print("Failed to open overview") + sys.exit(1) + +except ImportError as e: + print(f"Error importing Ax-Shell modules: {e}") + sys.exit(1) +except Exception as e: + print(f"Error opening overview: {e}") + sys.exit(1) \ No newline at end of file diff --git a/Ax-Shell/services/__init__.py b/Ax-Shell/services/__init__.py new file mode 100644 index 0000000..5a7cd3e --- /dev/null +++ b/Ax-Shell/services/__init__.py @@ -0,0 +1,4 @@ +""" +Ax-Shell services package. +Contains background services and utilities for the shell. +""" diff --git a/Ax-Shell/services/brightness.py b/Ax-Shell/services/brightness.py new file mode 100644 index 0000000..54b8b24 --- /dev/null +++ b/Ax-Shell/services/brightness.py @@ -0,0 +1,357 @@ +import os +import subprocess +import re +import time + +from fabric.core.service import Property, Service, Signal +from fabric.utils import exec_shell_command_async, monitor_file +from gi.repository import GLib +from loguru import logger + +import utils.functions as helpers +from utils.colors import Colors + + +class Brightness(Service): + """Service for controlling screen brightness using ddcutil or brightnessctl backends. + + The service works with RAW values (0 to max_screen) for both backends: + - brightnessctl: raw values are device-specific (e.g., 0-96000) + - ddcutil: raw values are percentages (0-100) + + The 'screen' signal emits percentage values (0-100) for UI display. + """ + + instance = None + DDCUTIL_PARAMS = "--disable-dynamic-sleep --sleep-multiplier=0.05" + MIN_CHANGE_THRESHOLD = 2 # Minimum brightness change to apply (percent) + CACHE_INTERVAL = 3 # Cache duration in seconds + POLL_INTERVAL = 500 # File polling interval in ms + + @staticmethod + def get_initial(): + """Singleton to get Brightness service instance.""" + if Brightness.instance is None: + Brightness.instance = Brightness() + return Brightness.instance + + @Signal + def screen(self, value: int) -> None: + """Signal emitted when screen brightness changes (value: percentage from 0 to 100).""" + pass + + def __init__(self, backend=None, **kwargs): + """Initialize service with automatic backend detection.""" + super().__init__(**kwargs) + self._pending_raw = None + self._timer_id = None + self._poll_timer_id = None + self._lock = GLib.Mutex() + self._last_percent = -1 + self._last_raw = -1 + self._last_update_time = 0 + self._last_file_mtime = 0 + + # Detect backend + self.backend = self._detect_backend(backend) + + self.max_screen = self._read_max_brightness() or 100 + + if self.backend: + if self.backend == "ddcutil": + # Initialize brightness cache + GLib.timeout_add(100, lambda: self._update_brightness_cache()) + else: + # Setup polling for brightness file + self._setup_polling() + + def _setup_polling(self): + """Setup periodic polling of brightness file.""" + try: + file_path = f"/sys/class/backlight/{self._get_screen_device()}/brightness" + if os.path.exists(file_path): + # Initialize cache with current value + with open(file_path) as f: + self._last_raw = int(f.readline().strip()) + self._last_percent = ( + int((self._last_raw / self.max_screen) * 100) + if self.max_screen > 0 + else 0 + ) + + self._last_file_mtime = os.path.getmtime(file_path) + self._poll_timer_id = GLib.timeout_add( + self.POLL_INTERVAL, self._check_brightness_file + ) + except Exception as e: + logger.error(f"Error setting up brightness polling: {e}") + + def _check_brightness_file(self): + """Periodically check brightness file for changes.""" + try: + file_path = f"/sys/class/backlight/{self._get_screen_device()}/brightness" + if os.path.exists(file_path): + current_mtime = os.path.getmtime(file_path) + if current_mtime > self._last_file_mtime: + self._last_file_mtime = current_mtime + with open(file_path) as f: + raw = int(f.readline().strip()) + + if raw != self._last_raw: + self._last_raw = raw + percent = ( + int((raw / self.max_screen) * 100) + if self.max_screen > 0 + else 0 + ) + if ( + abs(percent - self._last_percent) + >= self.MIN_CHANGE_THRESHOLD + ): + self._last_percent = percent + self.emit("screen", percent) + return True + except Exception as e: + logger.error(f"Error checking brightness file: {e}") + return True + + def _detect_backend(self, backend): + """Detect appropriate backend for brightness control.""" + if backend: + logger.info(f"Using forced backend: {backend}") + return backend + + # Try brightnessctl first (preferred for laptop internal displays) + if helpers.executable_exists("brightnessctl"): + device = self._get_screen_device() + if device: # Non-empty string means device found + logger.info(f"Using brightnessctl backend with device: {device}") + return "brightnessctl" + else: + logger.debug( + "brightnessctl is available but no backlight devices found in /sys/class/backlight/" + ) + + # Try ddcutil for external monitors (via DDC/CI protocol) + if helpers.executable_exists("ddcutil"): + bus = self._detect_ddcutil_bus() + if bus != -1: + self.ddcutil_bus = bus + logger.info(f"Using ddcutil backend with I2C bus: {bus}") + return "ddcutil" + else: + logger.debug( + "ddcutil is available but no DDC/CI capable monitors detected" + ) + + logger.warning( + "No available backend for brightness control - no backlight devices or DDC/CI monitors found" + ) + return None + + def _get_screen_device(self): + """Return first backlight device from sysfs.""" + try: + return os.listdir("/sys/class/backlight")[0] + except Exception: + return "" + + def _detect_ddcutil_bus(self): + """Detect I2C bus number for ddcutil.""" + try: + process = subprocess.run( + ["ddcutil", "detect"], text=True, capture_output=True, timeout=2 + ) + if process.returncode == 0: + match = re.search(r"I2C bus:\s*/dev/i2c-(\d+)", process.stdout) + return int(match.group(1)) if match else -1 + return -1 + except Exception: + return -1 + + def _read_max_brightness(self): + """Read maximum brightness value""" + if self.backend: + if self.backend == "ddcutil": + try: + process = subprocess.run( + [ + "ddcutil", + "--bus", + str(self.ddcutil_bus), + *self.DDCUTIL_PARAMS.split(), + "getvcp", + "10", + ], + text=True, + capture_output=True, + timeout=2, + ) + + if process.returncode == 0: + match = re.search( + r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)", + process.stdout, + ) + if match: + return int(match.group(2)) + except Exception as e: + logger.error(f"Error executing ddcutil: {e}") + else: + try: + with open( + f"/sys/class/backlight/{self._get_screen_device()}/max_brightness" + ) as f: + return int(f.readline().strip()) + except Exception: + return None + + def _update_brightness_cache(self): + """Update brightness cache with current value.""" + if self.backend == "ddcutil": + self.screen_brightness # This will update the cache + return False + + @Property(int, "read-write") + def screen_brightness(self): + """Getter returns current brightness in RAW value (0 to max_screen).""" + if not self.backend: + return -1 + + if self.backend == "brightnessctl": + # Return cached raw value if available + if self._last_raw != -1: + return self._last_raw + + try: + with open( + f"/sys/class/backlight/{self._get_screen_device()}/brightness" + ) as f: + raw = int(f.readline().strip()) + self._last_raw = raw + return raw + except Exception as e: + logger.error(f"Error reading brightness file: {e}") + return -1 + elif self.backend == "ddcutil": + # Use cached value if recent enough + if ( + time.time() - self._last_update_time < self.CACHE_INTERVAL + and self._last_raw != -1 + ): + return self._last_raw + + try: + process = subprocess.run( + [ + "ddcutil", + "--bus", + str(self.ddcutil_bus), + *self.DDCUTIL_PARAMS.split(), + "getvcp", + "10", + ], + text=True, + capture_output=True, + timeout=2, + ) + + if process.returncode == 0: + match = re.search( + r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)", + process.stdout, + ) + if match: + current = int(match.group(1)) + # For ddcutil, raw value IS the current value (0-100) + self._last_raw = current + self._last_update_time = time.time() + return current + except Exception as e: + logger.error(f"Error executing ddcutil: {e}") + + return self._last_raw if self._last_raw != -1 else -1 + + @screen_brightness.setter + def screen_brightness(self, value: int): + """Setter accepts brightness value in RAW (0 to max_screen).""" + self._lock.lock() + try: + # Limit value between 0 and max_screen + value = max(0, min(value, self.max_screen)) + + # Check if change is significant enough (in percentage terms) + current_percent = ( + int((self._last_raw / self.max_screen) * 100) + if self._last_raw != -1 and self.max_screen > 0 + else -1 + ) + new_percent = ( + int((value / self.max_screen) * 100) if self.max_screen > 0 else 0 + ) + + if ( + abs(new_percent - current_percent) < self.MIN_CHANGE_THRESHOLD + and self._last_raw != -1 + ): + return + + self._pending_raw = value + + # Use a single timer for applying changes + if self._timer_id: + GLib.source_remove(self._timer_id) + self._timer_id = GLib.timeout_add(50, self._apply_brightness) + finally: + self._lock.unlock() + + def _apply_brightness(self): + """Apply pending brightness change with optimized debouncing.""" + self._lock.lock() + try: + if self._pending_raw is None: + self._timer_id = None + return False + + raw = self._pending_raw + self._pending_raw = None + self._timer_id = None + finally: + self._lock.unlock() + + try: + # Update cache before executing command for faster UI response + self._last_raw = raw + + # Calculate percentage for signal emission + percent = int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0 + + if self.backend == "brightnessctl": + self.emit("screen", percent) + exec_shell_command_async( + f"brightnessctl --device '{self._get_screen_device()}' set {raw}" + ) + elif self.backend == "ddcutil": + self._last_update_time = time.time() + self.emit("screen", percent) + exec_shell_command_async( + f"ddcutil --bus {self.ddcutil_bus} {self.DDCUTIL_PARAMS} --terse setvcp 10 {raw}", + lambda exit_code, stdout, stderr: logger.error( + f"ddcutil error (code {exit_code}): {stderr}" + ) + if exit_code != 0 + else None, + ) + except Exception as e: + logger.error(f"Error setting brightness: {e}") + return False + + def cleanup(self): + """Clean up resources when service is stopped.""" + if self._timer_id: + GLib.source_remove(self._timer_id) + self._timer_id = None + + if self._poll_timer_id: + GLib.source_remove(self._poll_timer_id) + self._poll_timer_id = None diff --git a/Ax-Shell/services/monitor_focus.py b/Ax-Shell/services/monitor_focus.py new file mode 100644 index 0000000..bfa9c9c --- /dev/null +++ b/Ax-Shell/services/monitor_focus.py @@ -0,0 +1,233 @@ +import json +import subprocess +import threading +from typing import Optional + + +class Signal: + """Simple signal implementation for monitor focus service.""" + + def __init__(self): + self._callbacks = [] + + def connect(self, callback): + """Connect a callback to this signal.""" + self._callbacks.append(callback) + + def emit(self, *args, **kwargs): + """Emit the signal to all connected callbacks.""" + for callback in self._callbacks: + try: + callback(*args, **kwargs) + except Exception as e: + print(f"Error in signal callback: {e}") + + +class MonitorFocusService: + """ + Service to track monitor focus changes through Hyprland events. + + Listens to 'focusedmon' and 'workspace' events and emits signals + when monitor focus changes. + """ + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if hasattr(self, '_initialized'): + return + + self._initialized = True + self._monitor_name_to_id = {} + self._monitor_info = {} # Store rich monitor information + self._current_workspace = 1 + self._current_monitor_name = "" + self._listening = False + self._thread = None + + # Signals + self.monitor_focused = Signal() + self.workspace_changed = Signal() + + self._update_monitor_mapping() + self.start_listening() + + def _update_monitor_mapping(self): + """Update the monitor name to ID mapping with rich monitor information.""" + try: + # Import here to avoid circular imports + from utils.monitor_manager import get_monitor_manager + manager = get_monitor_manager() + monitors = manager.get_monitors() + + self._monitor_name_to_id = {} + self._monitor_info = {} # Store rich monitor information + for monitor in monitors: + monitor_name = monitor['name'] + monitor_id = monitor['id'] + self._monitor_name_to_id[monitor_name] = monitor_id + self._monitor_info[monitor_id] = { + 'name': monitor_name, + 'width': monitor.get('width', 1920), + 'height': monitor.get('height', 1080), + 'x': monitor.get('x', 0), + 'y': monitor.get('y', 0), + 'scale': monitor.get('scale', 1.0), + 'focused': monitor.get('focused', False) + } + except ImportError: + # Fallback if monitor manager not available yet + self._monitor_name_to_id = {} + self._monitor_info = {} + + def start_listening(self): + """Start listening to Hyprland events in a separate thread.""" + if self._listening: + return + + self._listening = True + self._thread = threading.Thread(target=self._listen_to_hyprland, daemon=True) + self._thread.start() + + def stop_listening(self): + """Stop listening to Hyprland events.""" + self._listening = False + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.0) + + def _listen_to_hyprland(self): + """Listen to Hyprland events via socat.""" + try: + process = subprocess.Popen( + ["socat", "-U", "-", "UNIX-CONNECT:/tmp/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1 + ) + + while self._listening and process.poll() is None: + if process.stdout: + line = process.stdout.readline() + if line: + self._handle_hyprland_event(line.strip()) + + except (subprocess.SubprocessError, FileNotFoundError) as e: + print(f"MonitorFocusService: Error listening to Hyprland: {e}") + except Exception as e: + print(f"MonitorFocusService: Unexpected error: {e}") + + def _handle_hyprland_event(self, event_line: str): + """Parse and handle Hyprland event.""" + try: + if '>>' not in event_line: + return + + parts = event_line.split('>>') + if len(parts) < 2: + return + + event_type = parts[0] + event_data = parts[1] + + if event_type == "focusedmon": + self._handle_focused_monitor(event_data) + elif event_type == "workspace": + self._handle_workspace_change(event_data) + + except Exception as e: + print(f"MonitorFocusService: Error handling event '{event_line}': {e}") + + def _handle_focused_monitor(self, data: str): + """Handle focusedmon event: monitor_name,workspace_name""" + try: + parts = data.split(',') + if len(parts) >= 2: + monitor_name = parts[0] + workspace_name = parts[1] + + # Update monitor mapping if needed + if monitor_name not in self._monitor_name_to_id: + self._update_monitor_mapping() + + monitor_id = self._monitor_name_to_id.get(monitor_name, 0) + + # Extract workspace ID from name + try: + workspace_id = int(workspace_name) + except ValueError: + workspace_id = 1 + + self._current_monitor_name = monitor_name + self._current_workspace = workspace_id + + # Emit signal + self.monitor_focused.emit(monitor_name, monitor_id, workspace_id) + + except Exception as e: + print(f"MonitorFocusService: Error in _handle_focused_monitor: {e}") + + def _handle_workspace_change(self, data: str): + """Handle workspace event: workspace_name""" + try: + workspace_name = data.strip() + + # Extract workspace ID + try: + workspace_id = int(workspace_name) + except ValueError: + workspace_id = 1 + + self._current_workspace = workspace_id + + # Emit signal + self.workspace_changed.emit(workspace_id, self._current_monitor_name) + + except Exception as e: + print(f"MonitorFocusService: Error in _handle_workspace_change: {e}") + + def get_current_monitor_id(self) -> int: + """Get current monitor ID.""" + return self._monitor_name_to_id.get(self._current_monitor_name, 0) + + def get_current_workspace(self) -> int: + """Get current workspace ID.""" + return self._current_workspace + + def get_monitor_id_by_name(self, monitor_name: str) -> Optional[int]: + """Get monitor ID by name.""" + return self._monitor_name_to_id.get(monitor_name) + + def get_monitor_info(self, monitor_id: int) -> Optional[dict]: + """Get rich monitor information by ID.""" + return self._monitor_info.get(monitor_id) + + def get_current_monitor_info(self) -> Optional[dict]: + """Get rich information for current monitor.""" + current_id = self.get_current_monitor_id() + return self.get_monitor_info(current_id) + + def get_monitor_scale(self, monitor_id: int) -> float: + """Get monitor scale factor by ID.""" + info = self.get_monitor_info(monitor_id) + return info.get('scale', 1.0) if info else 1.0 + + def get_current_monitor_scale(self) -> float: + """Get current monitor scale factor.""" + return self.get_monitor_scale(self.get_current_monitor_id()) + + +# Singleton accessor +_monitor_focus_service_instance = None + +def get_monitor_focus_service() -> MonitorFocusService: + """Get the global MonitorFocusService instance.""" + global _monitor_focus_service_instance + if _monitor_focus_service_instance is None: + _monitor_focus_service_instance = MonitorFocusService() + return _monitor_focus_service_instance \ No newline at end of file diff --git a/Ax-Shell/services/mpris.py b/Ax-Shell/services/mpris.py new file mode 100644 index 0000000..eb9c155 --- /dev/null +++ b/Ax-Shell/services/mpris.py @@ -0,0 +1,279 @@ +# Standard library imports +import contextlib + +# Third-party imports +import gi +from gi.repository import GLib # type: ignore +from loguru import logger + +# Fabric imports +from fabric.core.service import Property, Service, Signal +from fabric.utils import bulk_connect + +class PlayerctlImportError(ImportError): + """An error to raise when playerctl is not installed.""" + def __init__(self, *args): + super().__init__( + "Playerctl is not installed, please install it first", + *args, + ) + +# Try to import Playerctl, raise custom error if not available +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", "volume", "seeked"]: + 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 *args: self.update_status(), + ) + GLib.idle_add(lambda *args: 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) -> str: + if self._player is None: + return "" + title_data = self._player.get_title() + return title_data if isinstance(title_data, str) else "" + + @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", + }.get(self._player.get_property("playback_status"), "unknown") # type: ignore + + @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() + bulk_connect( + self._manager, + { + "name-appeared": self.on_name_appeard, + "name-vanished": self.on_name_vanished, + }, + ) + self.add_players() + super().__init__(**kwargs) + + def on_name_appeard(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): + for player in self._manager.get_property("player-names"): # type: ignore + 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/Ax-Shell/services/network.py b/Ax-Shell/services/network.py new file mode 100644 index 0000000..32e9d1c --- /dev/null +++ b/Ax-Shell/services/network.py @@ -0,0 +1,323 @@ +from typing import Any, List, Literal + +import gi +from fabric.core.service import Property, Service, Signal +from fabric.utils import bulk_connect, exec_shell_command_async +from gi.repository import Gio +from loguru import logger + +try: + gi.require_version("NM", "1.0") + from gi.repository import NM +except ValueError: + logger.error("Failed to start network manager") + + +class Wifi(Service): + """A service to manage the wifi connection.""" + + @Signal + def changed(self) -> None: ... + + @Signal + def enabled(self) -> bool: ... + + def __init__(self, client: NM.Client, device: NM.DeviceWifi, **kwargs): + self._client: NM.Client = client + self._device: NM.DeviceWifi = device + self._ap: NM.AccessPoint | None = None + self._ap_signal: int | None = None + super().__init__(**kwargs) + + self._client.connect( + "notify::wireless-enabled", + lambda *args: self.notifier("enabled", args), + ) + if self._device: + bulk_connect( + self._device, + { + "notify::active-access-point": lambda *args: self._activate_ap(), + "access-point-added": lambda *args: self.emit("changed"), + "access-point-removed": lambda *args: self.emit("changed"), + "state-changed": lambda *args: self.ap_update(), + }, + ) + self._activate_ap() + + def ap_update(self): + self.emit("changed") + for sn in [ + "enabled", + "internet", + "strength", + "frequency", + "access-points", + "ssid", + "state", + "icon-name", + ]: + self.notify(sn) + + def _activate_ap(self): + if self._ap: + self._ap.disconnect(self._ap_signal) + self._ap = self._device.get_active_access_point() + if not self._ap: + return + + self._ap_signal = self._ap.connect( + "notify::strength", lambda *args: self.ap_update() + ) # type: ignore + + def toggle_wifi(self): + self._client.wireless_set_enabled(not self._client.wireless_get_enabled()) + + # def set_active_ap(self, ap): + # self._device.access + + def scan(self): + self._device.request_scan_async( + None, + lambda device, result: [ + device.request_scan_finish(result), + self.emit("changed"), + ], + ) + + def notifier(self, name: str, *args): + self.notify(name) + self.emit("changed") + return + + @Property(bool, "read-write", default_value=False) + def enabled(self) -> bool: # type: ignore + return bool(self._client.wireless_get_enabled()) + + @enabled.setter + def enabled(self, value: bool): + self._client.wireless_set_enabled(value) + + @Property(int, "readable") + def strength(self): + return self._ap.get_strength() if self._ap else -1 + + @Property(str, "readable") + def icon_name(self): + if not self._ap: + return "network-wireless-disabled-symbolic" + + if self.internet == "activated": + 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._ap.get_strength() / 20)), + "network-wireless-no-route-symbolic", + ) + if self.internet == "activating": + return "network-wireless-acquiring-symbolic" + + return "network-wireless-offline-symbolic" + + @Property(int, "readable") + def frequency(self): + return self._ap.get_frequency() if self._ap else -1 + + @Property(int, "readable") + def internet(self): + return { + NM.ActiveConnectionState.ACTIVATED: "activated", + NM.ActiveConnectionState.ACTIVATING: "activating", + NM.ActiveConnectionState.DEACTIVATING: "deactivating", + NM.ActiveConnectionState.DEACTIVATED: "deactivated", + }.get( + self._device.get_active_connection().get_state(), + "unknown", + ) + + @Property(object, "readable") + def access_points(self) -> List[object]: + points: list[NM.AccessPoint] = self._device.get_access_points() + + def make_ap_dict(ap: NM.AccessPoint): + return { + "bssid": ap.get_bssid(), + # "address": ap.get_ + "last_seen": ap.get_last_seen(), + "ssid": NM.utils_ssid_to_utf8(ap.get_ssid().get_data()) + if ap.get_ssid() + else "Unknown", + "active-ap": self._ap, + "strength": ap.get_strength(), + "frequency": ap.get_frequency(), + "icon-name": { + 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(ap.get_strength() / 20)), + "network-wireless-no-route-symbolic", + ), + } + + return list(map(make_ap_dict, points)) + + @Property(str, "readable") + def ssid(self): + if not self._ap: + return "Disconnected" + ssid = self._ap.get_ssid().get_data() + return NM.utils_ssid_to_utf8(ssid) if ssid else "Unknown" + + @Property(int, "readable") + def state(self): + return { + NM.DeviceState.UNMANAGED: "unmanaged", + NM.DeviceState.UNAVAILABLE: "unavailable", + NM.DeviceState.DISCONNECTED: "disconnected", + NM.DeviceState.PREPARE: "prepare", + NM.DeviceState.CONFIG: "config", + NM.DeviceState.NEED_AUTH: "need_auth", + NM.DeviceState.IP_CONFIG: "ip_config", + NM.DeviceState.IP_CHECK: "ip_check", + NM.DeviceState.SECONDARIES: "secondaries", + NM.DeviceState.ACTIVATED: "activated", + NM.DeviceState.DEACTIVATING: "deactivating", + NM.DeviceState.FAILED: "failed", + }.get(self._device.get_state(), "unknown") + + +class Ethernet(Service): + """A service to manage the ethernet connection.""" + + @Signal + def changed(self) -> None: ... + + @Signal + def enabled(self) -> bool: ... + + @Property(int, "readable") + def speed(self) -> int: + return self._device.get_speed() + + @Property(str, "readable") + def internet(self) -> str: + return { + NM.ActiveConnectionState.ACTIVATED: "activated", + NM.ActiveConnectionState.ACTIVATING: "activating", + NM.ActiveConnectionState.DEACTIVATING: "deactivating", + NM.ActiveConnectionState.DEACTIVATED: "deactivated", + }.get( + self._device.get_active_connection().get_state(), + "disconnected", + ) + + @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" + + elif self._device.get_connectivity != NM.ConnectivityState.FULL: + return "network-wired-no-route-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 + + for pn in ( + "active-connection", + "icon-name", + "internet", + "speed", + "state", + ): + self._device.connect(f"notify::{pn}", lambda *_: self.notifier(pn)) + + self._device.connect("notify::speed", lambda *_: print(_)) + + def notifier(self, pn): + self.notify(pn) + self.emit("changed") + + +class NetworkClient(Service): + """A service to manage the network connections.""" + + @Signal + def device_ready(self) -> None: ... + + def __init__(self, **kwargs): + self._client: NM.Client | None = None + self.wifi_device: Wifi | None = None + self.ethernet_device: Ethernet | None = None + super().__init__(**kwargs) + NM.Client.new_async( + cancellable=None, + callback=self._init_network_client, + **kwargs, + ) + + def _init_network_client(self, client: NM.Client, task: Gio.Task, **kwargs): + self._client = client + wifi_device: NM.DeviceWifi | None = self._get_device(NM.DeviceType.WIFI) # type: ignore + ethernet_device: NM.DeviceEthernet | None = self._get_device( + NM.DeviceType.ETHERNET + ) + + if wifi_device: + self.wifi_device = Wifi(self._client, wifi_device) + self.emit("device-ready") + + if ethernet_device: + self.ethernet_device = Ethernet(client=self._client, device=ethernet_device) + self.emit("device-ready") + + self.notify("primary-device") + + def _get_device(self, device_type) -> Any: + devices: List[NM.Device] = self._client.get_devices() # type: ignore + return next( + ( + x + for x in devices + if x.get_device_type() == device_type + and x.get_active_connection() is not None + ), + None, + ) + + def _get_primary_device(self) -> Literal["wifi", "wired"] | None: + if not self._client: + return None + return ( + "wifi" + if "wireless" + in str(self._client.get_primary_connection().get_connection_type()) + else "wired" + if "ethernet" + in str(self._client.get_primary_connection().get_connection_type()) + else None + ) + + def connect_wifi_bssid(self, bssid): + # We are using nmcli here, idk im lazy + exec_shell_command_async( + f"nmcli device wifi connect {bssid}", lambda *args: print(args) + ) + + @Property(str, "readable") + def primary_device(self) -> Literal["wifi", "wired"] | None: + return self._get_primary_device() diff --git a/Ax-Shell/styles/applets.css b/Ax-Shell/styles/applets.css new file mode 100644 index 0000000..96d3446 --- /dev/null +++ b/Ax-Shell/styles/applets.css @@ -0,0 +1,126 @@ +#bluetooth-header, +#network-header { + border: 2px solid var(--surface); + padding: 4px; + border-radius: 12px; +} + +#bluetooth-device, +#wifi-ap-slot { + border: 2px solid var(--surface); + border-radius: 12px; + padding: 4px; +} + +#bluetooth-scan, +#bluetooth-back, +#network-refresh, +#network-back { + background-color: var(--surface); + border-radius: 8px; + padding: 4px; +} + +#bluetooth-back-label, +#network-back-label { + font-size: 20px; +} + +#bluetooth-scan:hover, +#bluetooth-back:hover, +#network-refresh:hover, +#network-back:hover { + background-color: var(--surface-bright); +} + +#bluetooth-scan label, +#bluetooth-toggle label, +#wifi-connect-button label { + font-weight: bold; +} + +#bluetooth-text, +#bluetooth-section { + font-weight: bold; +} + +#bluetooth-section { + background-color: var(--surface); + border-radius: 12px; + padding: 8px; +} + +#bluetooth-connection { + font-size: 20px; +} + +#bluetooth-connect, +#wifi-connect-button { + font-weight: bold; + background-color: var(--surface); + border-radius: 8px; + padding: 8px; +} + +#wifi-connect-button.connected { + background-color: var(--green); +} + +#wifi-connect-button.connected label { + color: var(--shadow); +} + +#bluetooth-connect.connected { + background-color: var(--blue); +} + +#bluetooth-connect.connected label { + color: var(--shadow); +} + +#bluetooth-connect:hover { + background-color: var(--surface-bright); +} + +#bluetooth-paired, +#bluetooth-available { + background-color: var(--surface); + border-radius: 20px; + padding: 4px; +} + +#bt-sep { + /*padding: 1px;*/ + border-radius: 16px; + /*background-color: var(--surface);*/ + /*margin: 0 16px;*/ +} + +#bluetooth-scan-label, +#network-refresh-label { + font-size: 20px; + color: var(--primary); +} + +#bluetooth-scan.scanning { + animation: blink 0.5s ease infinite; +} + +#bluetooth-scan-label.scanning { + animation: blink 0.5s ease infinite; +} + +@keyframes blink { + 0% { + background-color: var(--blue); + color: var(--shadow); + } + 50% { + background-color: var(--shadow); + color: var(--blue); + } + 100% { + background-color: var(--blue); + color: var(--shadow); + } +} diff --git a/Ax-Shell/styles/bar.css b/Ax-Shell/styles/bar.css new file mode 100644 index 0000000..f6b2f2d --- /dev/null +++ b/Ax-Shell/styles/bar.css @@ -0,0 +1,229 @@ +#bar-inner, +#bar-inner.pills { + margin: 8px; + opacity: 1; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#bar-inner.dense, +#bar-inner.edge, +#bar-inner.edge.vertical, +#bar-inner.edgecenter.vertical { + padding: 4px; + background-color: var(--shadow); + border-style: solid; + border-color: var(--surface); +} + +#bar-inner.dense { + border-width: 2px; +} + +#bar-inner.edge { + border-width: 0 0 2px 0; + border-radius: 0; +} + +/* #bar-inner.edge.vertical { */ +/* border-width: 0 2px 0 0; */ +/* border-radius: 0; */ +/* } */ + +#bar-inner.edge.bottom { + border-width: 2px 0 0 0; + border-radius: 0; +} + +#bar-inner.edge.vertical.right { + border-width: 0 0 0 2px; + border-radius: 0; +} + +#bar-inner.edge.vertical.left { + border-width: 0 2px 0 0; + border-radius: 0; +} + +#bar-inner.edgecenter.vertical { + border-width: 2px; + border-left-width: 0; + border-radius: 0 16px 16px 0; +} + +#bar-inner.edge.vertical.right { + border-width: 0 0 0 2px; + border-radius: 0; +} + +#bar-inner.edgecenter.vertical.right { + border-width: 2px; + border-right-width: 0; + border-radius: 16px 0 0 16px; +} + +#bar-inner.hidden { + margin: 8px; + margin-top: -40px; + opacity: 0; +} + +#date-time { + background-color: var(--shadow); + min-height: 36px; + padding: 0 8px; +} + +#date-time.vertical { + padding: 8px 0; +} + +#date-time.invert { + background-color: var(--surface); + border-radius: 12px; +} + +menu > menuitem > label, +#date-time > label { + font-weight: bold; +} + +#language { + min-height: 36px; + background-color: var(--shadow); + padding: 0 8px; +} + +#language.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#lang-label { + font-weight: bold; +} + +#lang-label.icon { + color: var(--primary); + font-size: 20px; +} + +#weather { + background-color: var(--shadow); + padding: 0 8px; + min-height: 36px; +} + +#weather.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#weather-label { + font-weight: bold; +} + +#systray { + background-color: var(--shadow); + padding: 8px; +} + +#systray.invert { + background-color: var(--surface); + border-radius: 12px; +} + +menu { + border: solid 1px; + border-radius: 16px; + border-color: var(--surface); + background-color: var(--shadow); + padding: 6px; +} + +menu > menuitem { + border-radius: 10px; + padding: 6px 10px; +} + +menu > menuitem:hover { + background-color: var(--primary); +} + +menu > menuitem:hover > label { + color: var(--shadow); +} + +tooltip { + border: solid 1px; + border-color: var(--surface); + background-color: var(--shadow); + animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1); +} + +@keyframes tooltipShow { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +tooltip > * { + padding: 6px 10px; + border-radius: 10px; +} + +#button-bar { + padding: 4px; + min-width: 28px; + min-height: 28px; + background-color: var(--shadow); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#button-bar.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#button-bar-label { + color: var(--primary); + font-size: 20px; + padding: 4px; + border-radius: 40px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#button-bar:hover #button-bar-label { + border-radius: 12px; + background-color: var(--surface_bright); +} + +#button-bar:active #button-bar-label { + border-radius: 40px; + color: var(--shadow); + background-color: var(--primary); +} + +#corner { + background-color: var(--shadow); + border-radius: 0; +} + +#corner-container { + min-width: 20px; + min-height: 20px; +} + +#button-corner { + border-radius: 0px; + min-width: 20px; + min-height: 20px; +} + +#notch, +#bar { + border-radius: 0px; +} diff --git a/Ax-Shell/styles/battery.css b/Ax-Shell/styles/battery.css new file mode 100644 index 0000000..bbce840 --- /dev/null +++ b/Ax-Shell/styles/battery.css @@ -0,0 +1,69 @@ +#battery { + background-color: var(--shadow); + padding: 4px; + border-radius: 16px; + margin-top: 4px; +} + +#battery-circle { + color: var(--surface-bright); + border: 3px solid var(--primary); +} + +#battery-circle.alert { + border: 2px solid var(--red-dim); +} + +#battery-icon { + font-size: 20px; +} + +#battery-icon.alert { + color: var(--red-dim); +} + +#battery-save, +#battery-balanced, +#battery-performance { + border-radius: 12px; + min-width: 28px; + min-height: 28px; +} + +#battery-save-label, +#battery-balanced-label, +#battery-performance-label { + color: var(--outline); + font-size: 20px; +} + +#battery-save:hover, +#battery-balanced:hover, +#battery-performance:hover { + background-color: var(--surface-bright); +} + +#battery-save:hover #battery-save-label, +#battery-balanced:hover #battery-balanced-label, +#battery-performance:hover #battery-performance-label { + color: var(--primary); +} + +#battery-save.active, +#battery-balanced.active, +#battery-performance.active { + background-color: var(--primary); +} + +#battery-save.active #battery-save-label, +#battery-balanced.active #battery-balanced-label, +#battery-performance.active #battery-performance-label { + color: var(--shadow); +} + +#battery-level { + color: var(--primary); + font-weight: bold; + margin: 0 4px; +} + diff --git a/Ax-Shell/styles/buttons.css b/Ax-Shell/styles/buttons.css new file mode 100644 index 0000000..4e35690 --- /dev/null +++ b/Ax-Shell/styles/buttons.css @@ -0,0 +1,157 @@ +/* === Botones Base === */ +#network-button, +#bluetooth-button, +#night-mode-button, +#caffeine-button { + min-height: 52px; + border-radius: 16px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +/* Tamaรฑos y estilos especรญficos */ +#network-button, +#bluetooth-button { + background-color: unset; + padding: 0; +} + +#night-mode-button, +#caffeine-button { + background-color: var(--primary); + padding: 0 10px; +} + +#night-mode-button.disabled, +#caffeine-button.disabled { + background-color: var(--surface); + border-radius: 26px; +} + +/* Botones internos (status y menรบ) */ +#network-status-button, +#network-menu-button, +#bluetooth-status-button, +#bluetooth-menu-button { + background-color: var(--primary); + padding: 0 8px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#network-menu-button, +#bluetooth-menu-button { + border-left: 1px solid var(--outline); + min-width: 24px; +} + +/* Estados Hover para todos los botones relevantes */ +#network-status-button:hover, +#network-menu-button:hover, +#bluetooth-status-button:hover, +#bluetooth-menu-button:hover, +#bluetooth-button:hover, +#night-mode-button:hover, +#caffeine-button:hover { + background-color: var(--foreground); +} + +#network-menu-button:active, +#bluetooth-menu-button:active { + border-radius: 0 26px 26px 0; +} + +/* Estados Disabled */ +#network-status-button.disabled, +#network-menu-button.disabled, +#bluetooth-status-button.disabled, +#bluetooth-menu-button.disabled { + background-color: var(--surface); + border-radius: 26px 0 0 26px; +} + +#network-menu-button.disabled, +#bluetooth-menu-button.disabled { + border-left: 1px solid var(--surface-bright); + border-radius: 0 26px 26px 0; +} + +#network-status-button.disabled:hover, +#network-menu-button.disabled:hover, +#bluetooth-status-button.disabled:hover, +#bluetooth-menu-button.disabled:hover, +#night-mode-button.disabled:hover, +#caffeine-button.disabled:hover { + background-color: var(--surface-bright); +} + +/* Bordes diferenciados para botones compuestos */ +#network-status-button, +#bluetooth-status-button { + border-radius: 16px 0 0 16px; +} + +#network-menu-button, +#bluetooth-menu-button { + border-radius: 0 16px 16px 0; +} + +/* === Estilos de Iconos === */ +#network-icon, +#bluetooth-icon, +#night-mode-icon, +#caffeine-icon { + color: var(--shadow); + font-size: 24px; +} + +#network-icon.disabled, +#bluetooth-icon.disabled, +#night-mode-icon.disabled, +#caffeine-icon.disabled { + color: var(--outline); +} + +/* === Estilos de Etiquetas de Menรบ === */ +#network-menu-label, +#bluetooth-menu-label { + color: var(--shadow); + font-size: 16px; +} + +#network-menu-label.disabled, +#bluetooth-menu-label.disabled { + color: var(--outline); +} + +/* === Estilos de Etiquetas Principales === */ +#network-label, +#bluetooth-label, +#night-mode-label, +#caffeine-label { + color: var(--shadow); + font-size: 14px; + font-weight: bold; + /* margin-bottom: -4px; */ +} + +#network-label.disabled, +#bluetooth-label.disabled, +#night-mode-label.disabled, +#caffeine-label.disabled { + color: var(--outline); +} + +/* === Estilos de Texto de Estado === */ +#network-ssid, +#bluetooth-status, +#night-mode-status, +#caffeine-status { + color: var(--surface-bright); + font-size: 12px; +} + +#network-ssid.disabled, +#bluetooth-status.disabled, +#night-mode-status.disabled, +#caffeine-status.disabled { + color: var(--outline); +} diff --git a/Ax-Shell/styles/calendar.css b/Ax-Shell/styles/calendar.css new file mode 100644 index 0000000..f6ab2f9 --- /dev/null +++ b/Ax-Shell/styles/calendar.css @@ -0,0 +1,79 @@ +#calendar { + /* background-color: var(--shadow); */ + border-radius: 20px; + border: 4px solid var(--surface); + padding: 4px; +} + +#header { + /* background-color: var(--shadow); */ + border-radius: 12px; + border: 2px solid var(--surface); + padding: 4px; +} + +#weekday-row { + background-color: var(--surface); + border-radius: 8px; + padding: 4px; +} + +#month-label, +#weekday-label, +#day-label { + font-weight: bold; +} + +#day-label, +#day-empty { + min-width: 32px; + min-height: 32px; + font-size: 9pt; +} + +#day-empty { + color: var(--surface-bright); +} + +#weekday-label { + color: var(--primary); + font-size: 9pt; +} + +#calendar-grid { + border-radius: 16px; + padding: 4px; +} + +#day-label.current-day { + background-color: var(--foreground); + color: var(--shadow); + border-radius: 20px; +} + +#prev-month-button, +#next-month-button { + background-color: var(--surface); + border-radius: 8px; + padding: 4px; +} + +#prev-month-button:hover, +#next-month-button:hover { + background-color: var(--surface-bright); +} + +#prev-month-button:active, +#next-month-button:active { + background-color: var(--primary); +} + +#month-button-label { + color: var(--primary); + font-size: 20px; +} + +#prev-month-button:active #month-button-label, +#next-month-button:active #month-button-label { + color: var(--shadow); +} diff --git a/Ax-Shell/styles/controls.css b/Ax-Shell/styles/controls.css new file mode 100644 index 0000000..416bba0 --- /dev/null +++ b/Ax-Shell/styles/controls.css @@ -0,0 +1,251 @@ +#control-slider { + background-color: var(--surface); + margin: 0px 0px 0px 0px; + border-radius: 2px 10px 10px 2px; +} + +#control-slider.no-icon { + border-radius: 10px; +} + +#control-slider trough { + min-height: 32px; + padding-right: 4px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#control-slider trough highlight { + border-radius: 0 2px 2px 0; + min-height: 28px; + margin-right: 8px; + background-color: var(--primary); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#control-slider.no-icon trough highlight { + border-radius: 10px 2px 2px 10px; +} + +#control-slider.muted trough highlight, +#control-slider.muted slider { + background-color: var(--surface-bright); +} + +#control-slider slider { + border-radius: 2px; + background-color: var(--primary); + min-width: 4px; + min-height: 40px; + margin: -8px 0; + box-shadow: + -4px 0 0px 2px var(--shadow), + 4px 0 0px 2px var(--shadow); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#control-slider slider:hover { + background-color: var(--foreground); +} + +#control-small { + padding: 4px; + background-color: var(--shadow); +} + +#control-small.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#button-volume, +#button-mic, +#button-brightness { + color: var(--surface-bright); + border: 3px solid var(--primary); +} + +#button-volume.muted, +#button-mic.muted { + color: var(--surface-bright); + border: 3px solid var(--outline); +} + +#vol-label, +#mic-label, +#brightness-label { + font-size: 16px; +} + +#vol-label.muted, +#mic-label.muted { + color: var(--outline); +} + +#vol-icon, +#mic-icon, +#brightness-icon { + min-width: 32px; + min-height: 32px; + padding-right: 8px; + background-color: var(--primary); + border-radius: 12px 0 0 12px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#vol-icon.muted, +#mic-icon.muted { + background-color: var(--surface-bright); +} + +#vol-label-dash, +#mic-label-dash, +#brightness-label-dash { + color: var(--shadow); + font-size: 20px; + font-family: "Symbols Nerd Font Mono"; +} + +#vol-label-dash.muted, +#mic-label-dash.muted { + color: var(--surface); +} + +#mixer-section { + border-radius: 20px; + border: 4px solid var(--surface); + padding: 4px; +} + +#mixer-section-title { + font-weight: bold; + padding: 8px; + border: 2px solid var(--surface); + border-radius: 12px; +} + +#mixer-content { + padding: 0 8px; +} + +#mixer-stream-label { + color: var(--outline); + font-weight: bold; +} + + +/* Volume & Mic Display CSS for Notch */ + +#volume-progress-bar { + border-color: var(--magenta); + color: alpha(var(--magenta), 0.3); + background-color: transparent; +} + +#volume-progress-bar label { + font-size: 12px; +} + + +#volume-progress-bar.volume-low, +#volume-progress-bar.volume-low label { + border-color: var(--cyan); + color: var(--cyan); +} +#volume-progress-bar.volume-low { + color: alpha(var(--cyan), 0.3); +} + +#volume-progress-bar.volume-medium, +#volume-progress-bar.volume-medium label { + border-color: var(--blue); + color: var(--blue); +} +#volume-progress-bar.volume-medium { + color: alpha(var(--blue), 0.3); +} + +#volume-progress-bar.volume-high, +#volume-progress-bar.volume-high label { + border-color: var(--magenta); + color: var(--magenta); +} +#volume-progress-bar.volume-high { + color: alpha(var(--magenta), 0.3); +} + +#volume-progress-bar.volume-muted, +#volume-progress-bar.volume-muted label { + border-color: var(--outline); + color: var(--outline); +} +#volume-progress-bar.volume-muted { + color: alpha(var(--outline), 0.2); +} + + +progressbar, +progressbar trough { + min-height: 2px; + background-color: alpha(var(--outline), 0.3); + border-radius: 2px; +} + +progressbar progress { + min-height: 4px; + border-radius: 2px; + background-color: var(--magenta); +} + + +#volume-display-bar progress, +#volume-display-box.volume-high progress { + background-color: var(--magenta); +} + +#volume-display-box.volume-low progress { + background-color: var(--cyan); +} + +#volume-display-box.volume-medium progress { + background-color: var(--blue); +} + +#volume-display-box.volume-muted progress { + background-color: var(--outline); +} + + +#volume-icon-bar { + background-color: var(--shadow); + border-radius: 8px; + padding: 4px 8px; + margin: 0px 2px; +} + +#volume-icon-bar label { + font-size: 14px; + font-weight: 600; + color: var(--foreground); +} + +#volume-icon-bar.volume-low label { + color: var(--cyan); +} +#volume-icon-bar.volume-medium label { + color: var(--blue); +} +#volume-icon-bar.volume-high label { + color: var(--magenta); +} +#volume-icon-bar.volume-muted label { + color: var(--outline); +} + +/* ===== MIC PROGRESS BAR ===== */ +#mic-display-box.mic-active progress { + background-color: var(--secondary); +} + +#mic-display-box.mic-muted progress { + background-color: var(--outline); +} \ No newline at end of file diff --git a/Ax-Shell/styles/dashboard.css b/Ax-Shell/styles/dashboard.css new file mode 100644 index 0000000..99d0b19 --- /dev/null +++ b/Ax-Shell/styles/dashboard.css @@ -0,0 +1,142 @@ +#box-1, +#box-2, +#box-3, +#box-x { + border-radius: 20px; + background-color: var(--surface); +} + +#container-1, +#container-2, +#container-3 { + border-radius: 20px; +} + +#container-sub-1 { + /* min-width: 750px; */ +} + +#box-3 { + min-width: 88px; + /*min-height: 226px;*/ +} + +#box-x { + min-height: 28px; +} + +#switcher.stack-switcher > * { + /* background-color: var(--shadow); */ + padding: 4px; + border-radius: 20px; + transition: all 0.1s ease; +} + +#switcher.stack-switcher > *:checked { + background-color: var(--surface); +} + +#switcher.stack-switcher > *:active, +#switcher.stack-switcher > *:hover { + background-color: var(--surface-bright); +} + +#switcher.stack-switcher > *:focus { + background-color: var(--surface-bright); +} + +#switcher.stack-switcher button label { + font-weight: bold; +} + +#switcher.stack-switcher > *:checked label { + color: var(--primary); +} + +#coming-soon-label { + font-size: 16px; + font-weight: bold; + font-style: italic; +} + +#metrics { + border-radius: 16px; + border: 4px solid var(--surface); + padding: 8px; +} + +/* Common trough style */ +#gpu-usage trough, +#cpu-usage trough, +#ram-usage trough, +#disk-usage trough { + background-color: var(--surface); + border-radius: 4px; +} + +/* Common highlight style */ +#gpu-usage trough highlight, +#cpu-usage trough highlight, +#ram-usage trough highlight, +#disk-usage trough highlight { + border-radius: 2px 2px; + margin-top: 4px; +} + +/* Highlight colors por recurso */ +#gpu-usage trough highlight, +#gpu-usage slider, +#cpu-usage trough highlight, +#cpu-usage slider { + background-color: var(--primary); +} +#ram-usage trough highlight, +#ram-usage slider { + background-color: var(--secondary); +} +#disk-usage trough highlight, +#disk-usage slider { + background-color: var(--tertiary); +} + +/* Common slider style */ +#gpu-usage slider, +#cpu-usage slider, +#ram-usage slider, +#disk-usage slider { + border-radius: 4px; + min-width: 16px; + min-height: 2px; + margin: 0 -4px; + box-shadow: + 0 0 0px 2px var(--shadow), + 0 0 0px 2px var(--shadow); +} + +/* Common label style */ +#gpu-label, +#cpu-label, +#ram-label, +#disk-label { + font-size: 16px; +} + +/* Label colors por recurso */ +#gpu-label, +#cpu-label { + color: var(--primary); +} +#ram-label { + color: var(--secondary); +} +#disk-label { + color: var(--tertiary); +} + +#applet-stack { + /* min-width: 420px; */ + border-radius: 20px; + border: 4px solid var(--surface); + padding: 4px; + /* min-width: 478px; */ +} diff --git a/Ax-Shell/styles/dock.css b/Ax-Shell/styles/dock.css new file mode 100644 index 0000000..309206a --- /dev/null +++ b/Ax-Shell/styles/dock.css @@ -0,0 +1,112 @@ +#dock { + background-color: var(--shadow); + padding: 8px; + margin: 8px 8px 0 8px; + border-radius: 20px 20px 0 0; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#dock.vertical { + margin: 8px 0 8px 8px; + border-radius: 20px 0 0 20px; +} + +#dock.vertical.left { + margin: 8px 8px 8px 0; + border-radius: 0 20px 20px 0; +} + +#dock.dense { + margin: 8px 8px 4px 8px; + border-radius: 20px; + border: 2px solid var(--surface); +} + +#dock.edge { + border-radius: 20px 20px 0 0; + border: 2px solid var(--surface); + border-bottom: none; +} + +#dock.dense.vertical { + margin: 8px 4px 8px 8px; + border-radius: 20px; + border: 2px solid var(--surface); +} + +#dock.edge.vertical { + border-radius: 20px 0 0 20px; + border: 2px solid var(--surface); + border-right: none; +} + +#dock.integrated { + background-color: var(--shadow); + padding: 4px; + margin: 0; + border-radius: 16px; + border: none; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#dock.integrated.edge, +#dock.integrated.dense { + background-color: var(--surface); + padding: 4px; + margin: 0; + border-radius: 12px; + border: none; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#dock-full { + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); + opacity: 1; +} + +#dock-separator { + padding: 2px; + border-radius: 16px; + background-color: var(--surface-bright); +} + +#dock-app-button { + padding: 4px; + border-radius: 24px; + box-shadow: 0 0 4px alpha(var(--shadow), 0.5); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#dock-app-button:hover { + background-color: var(--surface-bright); +} + +#dock-app-button:hover.instance { + background-color: var(--outline); +} + +#dock-app-button:active, +#dock-app-button:active.instance { + background-color: var(--primary); +} + +#dock-app-button.instance { + background: var(--surface-bright); + border-radius: 12px; +} + +#dock-corner-left { + margin: 0 -8px 0 0; +} + +#dock-corner-right { + margin: 0 0 0 -8px; +} + +#dock-corner-top { + margin: 0 0 -8px 0; +} + +#dock-corner-bottom { + margin: -8px 0 0 0; +} diff --git a/Ax-Shell/styles/emoji.css b/Ax-Shell/styles/emoji.css new file mode 100644 index 0000000..0c0c413 --- /dev/null +++ b/Ax-Shell/styles/emoji.css @@ -0,0 +1,83 @@ +#emoji #search-entry { + font-weight: bold; + background-color: var(--surface); + color: var(--foreground); + border-radius: 16px; + padding: 10px; +} + +#emoji #search-entry selection { + color: var(--background); + background-color: var(--primary); +} + +#emoji #close-button { + background-color: var(--surface); + border-radius: 16px; + padding: 8px; +} + +#emoji #close-button:hover, +#emoji #close-button:focus { + background-color: var(--surface-bright); +} + +#emoji #close-button:active { + background-color: var(--red-dim); +} + +#emoji #close-label { + color: var(--red-dim); + font-size: 24px; +} + +#emoji #close-button:active #close-label { + color: var(--shadow); +} + +#emoji #emoji-slot-button { + border-radius: 40px; + padding: 16px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes loadEmojiSlot { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +#emoji #emoji-slot-button:hover, +#emoji #emoji-slot-button:focus, +#emoji #emoji-slot-button:selected, +#emoji #emoji-slot-button.selected { + border-radius: 16px; + background-color: var(--surface-bright); +} + +#emoji #emoji-slot-button:active { + background-color: var(--primary); +} + +#emoji #emoji-name-label { + color: var(--foreground); + font-weight: bold; +} + +#emoji #emoji-slot-button:hover #emoji-name-label, +#emoji #emoji-slot-button:focus #emoji-name-label, +#emoji #emoji-slot-button:selected #emoji-name-label, +#emoji #emoji-slot-button.selected #emoji-name-label { + color: var(--primary); +} + +#emoji #emoji-slot-button:active #emoji-name-label { + color: var(--shadow); +} + +#emoji #emoji-char-label { + font-size: 24px; +} diff --git a/Ax-Shell/styles/extras.css b/Ax-Shell/styles/extras.css new file mode 100644 index 0000000..ab96b26 --- /dev/null +++ b/Ax-Shell/styles/extras.css @@ -0,0 +1,6 @@ +#no-notif, +#no-tmux, +#no-clip { + font-size: 96px; + color: var(--surface); +} diff --git a/Ax-Shell/styles/kanban.css b/Ax-Shell/styles/kanban.css new file mode 100644 index 0000000..6c889ba --- /dev/null +++ b/Ax-Shell/styles/kanban.css @@ -0,0 +1,75 @@ +#kanban { + /* background-color: var(--shadow); */ + border-radius: 20px; + border: 4px solid var(--surface); + padding: 4px; +} + +#kanban-header { + border: 2px solid var(--surface); + border-radius: 12px; + padding: 4px; +} + +#column-header { + padding: 4px; + font-weight: bold; + color: var(--primary); +} + +#kanban-note { + background-color: var(--surface); + border-radius: 4px; + padding: 8px; + margin-bottom: 4px; +} + +#kanban-row:first-child > * > * { + border-radius: 16px 16px 4px 4px; +} + +#kanban-row:last-child > * > * { + border-radius: 4px 4px 16px 16px; +} + +#inline-editor { + border: 2px solid var(--surface-bright); + border-radius: 12px; + padding: 8px; +} + +#kanban-btn, +#kanban-btn-add { + background-color: var(--surface); + border-radius: 8px; + padding: 4px; +} + +#kanban-btn { + background-color: var(--shadow); +} + +#kanban-btn:hover, +#kanban-btn-add:hover { + background-color: var(--surface-bright); +} + +#kanban-btn:active, +#kanban-btn-add:active { + background-color: var(--primary); +} + +#kanban-btn-label, +#kanban-btn-neg { + font-size: 20px; + color: var(--primary); +} + +#kanban-btn-neg { + color: var(--red-dim); +} + +#kanban-btn:active #kanban-btn-label, +#kanban-btn-add:active #kanban-btn-label { + color: var(--shadow); +} diff --git a/Ax-Shell/styles/launcher.css b/Ax-Shell/styles/launcher.css new file mode 100644 index 0000000..1fc4fe2 --- /dev/null +++ b/Ax-Shell/styles/launcher.css @@ -0,0 +1,167 @@ +#search-entry, +#session-name-entry, +#search-entry-walls { + font-weight: bold; + background-color: var(--surface); + color: var(--foreground); + border-radius: 16px; + padding: 10px; +} + +#search-entry selection, +#search-entry-walls selection, +#session-name-entry selection { + color: var(--background); + background-color: var(--primary); +} + +#close-button, +#clear-button, +#config-button, +#new-session-button, +#random-wall-button { + background-color: var(--surface); + border-radius: 40px; + padding: 8px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#close-button:hover, +#close-button:focus, +#clear-button:hover, +#clear-button:focus, +#config-button:hover, +#config-button:focus, +#new-session-button:hover, +#new-session-button:focus, +#random-wall-button:hover { + background-color: var(--surface-bright); + border-radius: 16px; +} + +#close-button:active, +#clear-button:active { + background-color: var(--red-dim); + border-radius: 40px; +} + +#close-label, +#clear-label { + color: var(--red-dim); + font-size: 24px; +} + +#close-button:active #close-label, +#clear-button:active #clear-label { + color: var(--shadow); +} + +#config-button:active, +#new-session-button:active, +#random-wall-button:active { + background-color: var(--primary); + border-radius: 40px; +} + +#config-label, +#new-session-label, +#random-wall-label { + color: var(--primary); + font-size: 24px; +} + +#config-button:active #config-label, +#new-session-button:active #new-session-label, +#random-wall-button:active #random-wall-label { + color: var(--shadow); +} + +#scrolled-window { + border-radius: 16px; +} + +#scrolled-window scrollbar, +#bluetooth-devices scrollbar, +#network-ap-scrolled-window scrollbar { + border-radius: 10px; + background-color: var(--surface); + padding: 4px; + margin-left: 6px; +} + +#scrolled-window slider, +#bluetooth-devices slider, +#network-ap-scrolled-window slider { + border-radius: 8px; + min-width: 16px; + min-height: 48px; + background-color: var(--primary); +} + +#slot-button { + border-radius: 40px; + padding: 0 16px; + animation: loadSlot 0.5s ease; + min-height: 52px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes loadSlot { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +#slot-button:hover, +#slot-button:focus, +#slot-button:selected, +#slot-button.selected { + border-radius: 16px; + background-color: var(--surface); + padding-left: 20px; +} + +#slot-button:active { + background-color: var(--primary); +} + +#app-icon { + margin: -8px; + margin-right: 4px; +} + +#app-label { + color: var(--foreground); + font-weight: bold; +} + +#slot-button:hover #app-label, +#slot-button:focus #app-label, +#slot-button:selected #app-label, +#slot-button.selected #app-label { + color: var(--primary); +} + +#slot-button:active #app-label { + color: var(--shadow); +} + +#tmux-icon, +#clip-icon { + font-size: 20px; + color: var(--primary); +} + +#app-desc { + color: var(--outline); + font-size: 12px; + font-style: italic; +} + +#clip-label { + font-weight: bold; +} diff --git a/Ax-Shell/styles/metrics.css b/Ax-Shell/styles/metrics.css new file mode 100644 index 0000000..ddc0fd5 --- /dev/null +++ b/Ax-Shell/styles/metrics.css @@ -0,0 +1,103 @@ +#metrics-small { + background-color: var(--shadow); + padding: 4px; +} + +#metrics-small.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#metrics-circle { + color: var(--surface-bright); + border: 3px solid var(--primary); +} + +#metrics-circle.bat { + border: 3px solid var(--green); +} + +#metrics-circle.alert { + border: 3px solid var(--red-dim); +} + +#metrics-icon { + font-size: 16px; +} + +#metrics-icon.alert { + color: var(--red-dim); +} + +#metrics-level { + font-weight: bold; + margin: 0 4px; +} + +#metrics-sep { + min-width: 4px; +} + +#network-icon-label { + color: var(--foreground); + font-size: 20px; + padding: 4px; + border-radius: 11px; + transition: all 0.1s ease; +} + +#download-label { + color: var(--green); + font-size: 14px; + font-weight: bold; + padding: 4px; + border-radius: 11px; + transition: all 0.1s ease; +} + +#download-label.urgent { + color: var(--shadow); +} + +#network-icon-label.urgent { + color: var(--shadow); +} + +#button-bar.download { + background-color: var(--green); +} + +#button-bar.upload { + background-color: var(--yellow); +} + +#download-icon-label { + color: var(--green); + font-size: 16px; +} + +#download-icon-label.urgent { + color: var(--shadow); +} + +#upload-icon-label { + color: var(--yellow); + font-size: 16px; +} + +#upload-label { + color: var(--yellow); + font-size: 14px; + font-weight: bold; + padding: 4px; + border-radius: 11px; + transition: all 0.1s ease; +} + +#upload-label.urgent { + color: var(--shadow); +} + +#upload-icon-label.urgent { + color: var(--shadow); +} diff --git a/Ax-Shell/styles/notch.css b/Ax-Shell/styles/notch.css new file mode 100644 index 0000000..4c5de3e --- /dev/null +++ b/Ax-Shell/styles/notch.css @@ -0,0 +1,131 @@ +#notch-box { + margin: 0 20px 10px 20px; +} + +#notch-hover-eventbox { + background-color: transparent; + min-height: 4px; + min-width: 260px; +} + +#notch-hover-detection { + background-color: transparent; + min-height: 4px; +} + +#notch-wrap { + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#notch-content { + background-color: var(--shadow); + border-radius: 0 0 20px 20px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#notch-content.open { + border-radius: 0 0 36px 36px; + padding: 2px; +} + +#notch-content.open.invert { + padding: 2px; + padding-top: 0px; + border-radius: 0 0 36px 36px; +} + +#notch-content.invert { + background-color: var(--surface); + border-radius: 0 0 12px 12px; +} + +#notch-compact { + font-weight: bold; +} + +#notch-corner-left { + margin-left: -16px; +} + +#notch-corner-right { + margin-right: -16px; +} + +#compact-mpris-icon-label { + color: var(--primary); +} + +#compact-mpris-icon-label, +#compact-mpris-button-label { + font-size: 20px; +} + +#compact-mpris-icon, +#compact-mpris-button { + margin: 0 10px; +} + +#compact-mpris-icon:hover, +#compact-mpris-button:hover { + background-color: var(--primary); +} + +#compact-mpris-icon:active, +#compact-mpris-button:active { + background-color: var(--primary); +} + +#compact-mpris-icon:hover #compact-mpris-icon-label, +#compact-mpris-button:hover #compact-mpris-button-label { + color: var(--background); +} + +#hyprland-window label { + margin: 0px 8px; +} + +#active-window-box { + margin: 10px; +} + +#app-launcher, +#power-menu, +#toolbox, +#dashboard, +#tmux-manager, +#clip-history, +#overview, +#emoji { + background-color: var(--shadow); + padding: 14px; + border-radius: 0 0 34px 34px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1); +} + +#notch-content.panel #app-launcher, +#notch-content.panel #power-menu, +#notch-content.panel #toolbox, +#notch-content.panel #dashboard, +#notch-content.panel #tmux-manager, +#notch-content.panel #clip-history, +#notch-content.panel #overview, +#notch-content.panel #emoji { + border-radius: 34px; +} + +#notch-content.panel { + border-radius: 36px; +} + +#notch-box.panel { + margin: 6px; + padding: 2px; + background-color: var(--surface); + border-radius: 36px; +} + +#notch-content.open.panel { + background-color: var(--shadow); + padding: 0px; + border-radius: 36px; +} diff --git a/Ax-Shell/styles/notifications.css b/Ax-Shell/styles/notifications.css new file mode 100644 index 0000000..8d7ee14 --- /dev/null +++ b/Ax-Shell/styles/notifications.css @@ -0,0 +1,187 @@ +#action-button { + border-radius: 16px; + background-color: var(--surface); + padding: 8px; +} + +#action-button:hover { + background-color: var(--surface-bright); +} + +#action-button:active { + background-color: var(--primary); +} + +#action-button:active #button-label { + color: var(--shadow); +} + +#notification-image image { + border-radius: 16px; +} + +#notification-summary, +#button-label { + font-weight: bold; +} + +#notification-summary { + font-weight: bold; + color: var(--primary); +} + +#notification-app-name { + color: var(--outline); +} + +#action-button { + margin-top: 8px; +} + +#notification-stack-box { + border-radius: 32px; + padding: 16px; + border: 2px solid var(--surface); + background-color: var(--shadow); + margin: 8px; + margin-bottom: 4px; + min-width: 330px; +} + +#notification-navigation { + padding: 16px; +} + +#notif-close-button { + background-color: var(--surface); + border-radius: 16px; + padding: 8px; +} + +#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: 16px; +} + +#notif-close-button:active #notif-close-label { + color: var(--shadow); +} + +#nav-button { + padding: 8px; + border-radius: 16px; + background: var(--shadow); + border: 2px solid var(--surface); + margin-top: -12px; +} + +#nav-button:hover { + background: var(--surface-bright); +} + +#nav-button:disabled #nav-button-label { + color: var(--surface-bright); +} + +#nav-button-label { + font-size: 16px; +} + +#nav-button-label.close { + color: var(--red-dim); +} + +#nav-button:hover #nav-button-label { + color: var(--primary); +} + +#nav-button:hover #nav-button-label.close { + color: var(--red-dim); +} + +#notification-history scrollbar { + background: transparent; +} + +#notification-history scrollbar.vertical slider { + background: var(--primary); + border-radius: 8px; + min-width: 16px; + min-height: 48px; + margin: 4px; +} + +#notification-history scrollbar.vertical trough { + background: var(--surface); + border: none; + border-radius: 12px; + margin: 4px; +} + +#notification-box-hist { + padding: 8px; + border-radius: 12px; + border: 2px solid var(--surface); +} + +#notification-timestamp { + color: var(--surface-bright); +} + +#notification-history-header { + border-radius: 12px; + border: 2px solid var(--surface); + padding: 4px; + margin-bottom: 4px; +} + +#nhh { + font-weight: bold; +} + +#nhh-button { + border-radius: 8px; + background-color: var(--surface); + padding: 4px; +} + +#nhh-button:hover { + background-color: var(--surface-bright); +} + +#nhh-button-label { + color: var(--red-dim); + font-size: 20px; +} + +#dnd-label { + font-size: 20px; + margin-left: 4px; +} + +#notif-sep { + padding: 2px; + border-radius: 16px; + background-color: var(--surface-bright); + margin: 0 8px; +} + +#notif-date-sep { + padding: 4px; + border-radius: 12px; + background-color: var(--surface); +} + +#notif-date-sep-label { + color: var(--outline); + font-weight: bold; +} diff --git a/Ax-Shell/styles/overview.css b/Ax-Shell/styles/overview.css new file mode 100644 index 0000000..8d8134c --- /dev/null +++ b/Ax-Shell/styles/overview.css @@ -0,0 +1,65 @@ +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +#overview-icon { + background-color: var(--primary); +} + +#overview-client-box, +#overview-workspace-bg, +#overview-frame { + border-radius: 12px; + transition: all 0.1s ease; +} + +#overview.show { + animation: fadeIn 0.25s ease; +} + +#overview-client-box { + background-color: var(--shadow); + border: 3px solid var(--surface); +} + +#overview-client-box:hover { + background-color: var(--surface); +} + +#overview-client-box:focus { + background-color: var(--surface); + border: 3px solid var(--primary); +} + +#overview-client-box:active { + background-color: var(--surface-bright); + border: 3px solid var(--surface-bright); +} + +#overview-workspace-bg { + border-radius: 20px; +} + +#overview-workspace-box { + padding: 4px; + border: 2px solid var(--surface); +} + +#overview-add-label { + font-size: 24px; + color: var(--surface-bright); +} + +#overview-workspace-label { + font-weight: bold; + background-color: var(--surface); + border-radius: 10px; + padding: 4px; + margin-bottom: 4px; +} diff --git a/Ax-Shell/styles/pins.css b/Ax-Shell/styles/pins.css new file mode 100644 index 0000000..239278d --- /dev/null +++ b/Ax-Shell/styles/pins.css @@ -0,0 +1,31 @@ +#pin-add { + font-size: 24px; + color: var(--surface-bright); +} + +#pin-cell-box { + /* background-color: var(--shadow); */ + border-radius: 16px; + padding: 16px; + border: 4px solid var(--surface); + /* min-height: 160px; */ +} + +#pin-file, +#pin-url { + font-weight: bold; + /* margin-top: -16px; */ + /* margin-bottom: 16px; */ +} + +#pin-add:hover { + color: var(--primary); +} + +#pin-text { + font-weight: bold; +} + +#pin-url-icon { + color: var(--primary); +} diff --git a/Ax-Shell/styles/player.css b/Ax-Shell/styles/player.css new file mode 100644 index 0000000..c670bf4 --- /dev/null +++ b/Ax-Shell/styles/player.css @@ -0,0 +1,191 @@ +#player { + min-width: 172px; + border-radius: 20px; + border: 4px solid var(--surface); + /* background-color: var(--shadow); */ + padding: 8px; +} + +#player-progress { + color: var(--surface-bright); + border: 8px solid var(--foreground); +} + +#player-title, +#player-album, +#player-artist { + font-weight: bold; +} + +#player-title { + margin-top: -10px; + font-size: 11pt; +} + +#player-title.vertical { + margin-top: 0; + font-size: 11pt; +} + +#player-album { + /* margin-top: -5px; */ + font-size: 9pt; + color: var(--outline); +} + +#player-artist { + font-size: 10pt; + color: var(--primary); +} + +/* โ€”โ€”โ€”โ€”โ€”โ€” Reemplazo de #player-btn-label โ€”โ€”โ€”โ€”โ€”โ€” */ +#player-btn label { + font-size: 16px; + color: var(--foreground); +} + +#player-btn label.play-pause { + font-size: 24px; + color: var(--shadow); +} + +/* โ€”โ€”โ€”โ€”โ€”โ€” Botรณn โ€”โ€”โ€”โ€”โ€”โ€” */ +#player-btn { + padding: 4px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#player-btn.play-pause.playing { + background-color: var(--primary); + border-radius: 12px; + padding: 8px; +} + +#player-btn.play-pause { + background-color: var(--surface); + border-radius: 20px; + padding: 8px; +} + +#player-btn.play-pause label { + color: var(--outline); +} + +#player-btn.play-pause:hover { + background-color: var(--surface-bright); +} + +#player-btn.play-pause:hover label { + color: var(--outline); +} + +#player-btn.play-pause.playing label { + color: var(--shadow); +} + +#player-btn.play-pause.stop { + background-color: var(--shadow); +} + +#player-btn.play-pause.stop label { + color: var(--foreground); +} + +#player-btn.play-pause.stop:hover label { + color: var(--foreground); +} + +#player-btn:hover { + background-color: var(--foreground); +} + +#player-btn.play-pause.playing:hover { + background-color: var(--foreground); +} + +#player-btn:hover label { + color: var(--shadow); +} + +#player-btn:active { + background-color: var(--primary); +} + +#player-btn:active label { + color: var(--foreground); +} + +#player-time { + font-weight: bold; + color: var(--outline); + font-size: 10pt; +} + +#player-switcher { + margin: 0 24px; +} + +#player-switcher.stack-switcher > *:focus { + background-color: var(--surface-bright); +} + +#player-switcher.stack-switcher button label { + font-size: 16px; + color: var(--surface-bright); +} + +#player-switcher.stack-switcher button:hover label { + font-size: 16px; + color: var(--outline); +} + +#player-switcher.stack-switcher > *:checked label { + font-size: 20px; + color: var(--foreground); +} + +#player-switcher.stack-switcher > *:checked:hover label { + font-size: 20px; + color: var(--foreground); +} + +#player-switcher-vertical { + margin: 0 8px; +} + +#player-switcher-vertical.stack-switcher > *:focus { + background-color: var(--surface-bright); +} + +#player-switcher-vertical.stack-switcher button { + font-size: 0px; + color: var(--surface-bright); + background-color: var(--surface-bright); + min-width: 8px; + min-height: 8px; + margin: 0 -1px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#player-switcher-vertical.stack-switcher button:hover { + font-size: 0px; + color: var(--outline); + background-color: var(--foreground); +} + +#player-switcher-vertical.stack-switcher > *:checked { + font-size: 0px; + color: var(--foreground); + background-color: var(--primary); + min-width: 32px; +} + +#player-switcher-vertical.stack-switcher > *:checked:hover { + font-size: 0px; + color: var(--foreground); + background-color: var(--foreground); +} + +#player-switcher-vertical.stack-switcher button label { + font-size: 0px; +} diff --git a/Ax-Shell/styles/power.css b/Ax-Shell/styles/power.css new file mode 100644 index 0000000..bf1918e --- /dev/null +++ b/Ax-Shell/styles/power.css @@ -0,0 +1,31 @@ +#power-menu-button { + border-radius: 40px; + min-width: 52px; + min-height: 52px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#power-menu-button label { + font-size: 24px; + color: var(--foreground); +} + +#power-menu-button:hover, +#power-menu-button:focus { + border-radius: 20px; + background-color: var(--surface-bright); +} + +#power-menu-button:hover label, +#power-menu-button:focus label { + color: var(--primary); +} + +#power-menu-button:active { + border-radius: 40px; + background-color: var(--primary); +} + +#power-menu-button:active label { + color: var(--shadow); +} diff --git a/Ax-Shell/styles/shadows.css b/Ax-Shell/styles/shadows.css new file mode 100644 index 0000000..7c23a8a --- /dev/null +++ b/Ax-Shell/styles/shadows.css @@ -0,0 +1,32 @@ +#button-bar, +#bar-inner.dense, +#bar-inner.edge, +#bar-inner.edge.vertical, +#bar-inner.edgecenter.vertical, +#workspaces-container, +#notch-content, +#notch-box.panel, +#date-time, +#systray, +#battery, +#control-small, +#notification-stack-box, +#nav-button, +#metrics-small, +#weather, +#language, +#dock { + box-shadow: 0 0 3px alpha(black, 0.7); +} + +#notch-content.panel { + box-shadow: 0 0 3px alpha(black, 0); +} + +#bar-revealer { + padding: 10px; +} + +#boxed-revealer { + margin: -10px; +} diff --git a/Ax-Shell/styles/systemprofiles.css b/Ax-Shell/styles/systemprofiles.css new file mode 100644 index 0000000..650f912 --- /dev/null +++ b/Ax-Shell/styles/systemprofiles.css @@ -0,0 +1,49 @@ +#systemprofiles { + background-color: var(--shadow); + padding: 4px; + border-radius: 16px; +} + +#systemprofiles.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#battery-save, +#battery-balanced, +#battery-performance { + border-radius: 12px; + min-width: 28px; + min-height: 28px; +} + +#battery-save-label, +#battery-balanced-label, +#battery-performance-label { + color: var(--outline); + font-size: 20px; +} + +#battery-save:hover, +#battery-balanced:hover, +#battery-performance:hover { + background-color: var(--surface-bright); +} + +#battery-save:hover #battery-save-label, +#battery-balanced:hover #battery-balanced-label, +#battery-performance:hover #battery-performance-label { + color: var(--primary); +} + +#battery-save.active, +#battery-balanced.active, +#battery-performance.active { + background-color: var(--primary); +} + +#battery-save.active #battery-save-label, +#battery-balanced.active #battery-balanced-label, +#battery-performance.active #battery-performance-label { + color: var(--shadow); +} diff --git a/Ax-Shell/styles/tools.css b/Ax-Shell/styles/tools.css new file mode 100644 index 0000000..6011b3d --- /dev/null +++ b/Ax-Shell/styles/tools.css @@ -0,0 +1,73 @@ +#toolbox-button { + border-radius: 40px; + min-width: 52px; + min-height: 52px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#toolbox-button label { + font-size: 24px; + color: var(--foreground); +} + +#toolbox-button.recording label { + color: var(--red-dim); +} + +#toolbox-button.pomodoro label { + color: var(--yellow-dim); +} + +#toolbox-button:hover, +#toolbox-button:focus { + border-radius: 20px; + background-color: var(--surface-bright); +} + +#toolbox-button:hover label, +#toolbox-button:focus label { + color: var(--primary); +} + +#toolbox-button.recording:hover label, +#toolbox-button.recording:focus label { + color: var(--red-dim); +} + +#toolbox-button.pomodoro:hover label, +#toolbox-button.pomodoro:focus label { + color: var(--yellow-dim); +} + +#toolbox-button:active { + border-radius: 40px; + background-color: var(--primary); +} + +#toolbox-button:active label { + color: var(--shadow); +} + +#toolbox-button.recording:active { + background-color: var(--red-dim); +} + +#toolbox-button.pomodoro:active { + background-color: var(--yellow-dim); +} + +#toolbox-button.recording:active label { + color: var(--shadow); +} + +#toolbox-button.pomodoro:active label { + color: var(--shadow); +} + +#tool-sep { + min-width: 4px; + min-height: 4px; + margin: 0px 4px; + border-radius: 16px; + background-color: var(--surface-bright); +} diff --git a/Ax-Shell/styles/wallpapers.css b/Ax-Shell/styles/wallpapers.css new file mode 100644 index 0000000..75eaca9 --- /dev/null +++ b/Ax-Shell/styles/wallpapers.css @@ -0,0 +1,145 @@ +#wallpaper-icons { + border-radius: 20px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#wallpaper-icons:selected { + background-color: var(--outline); + border-radius: 8px; +} + +#scheme-dropdown { + background-color: var(--surface); + padding: 8px; +} + +#scheme-dropdown > * > * { + font-weight: bold; + color: var(--shadow); +} + +#scheme-dropdown.box button.box.cellview { + background-color: var(--primary); +} + +#scheme-dropdown.box button.box.cellview:selected { + background-color: var(--surface-bright); +} + +#search-entry-walls { + /* min-width: 512px; */ +} + +/* Customize the overall switch appearance */ +#matugen-switcher, +#dnd-switch { + min-width: 40px; + min-height: 20px; + background-color: var(--surface); + border-radius: 15px; + padding: 2px; + transition: background-color 0.3s ease; +} + +/* Style the switch's slider */ +#matugen-switcher slider, +#dnd-switch slider { + background-color: var(--primary); + border-radius: 16px; + min-width: 16px; + min-height: 8px; + transition: + background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25), + transform 0.25s cubic-bezier(0.5, 0.25, 0, 1.25); +} + +/* When the switch is active (checked) */ +#matugen-switcher:checked, +#dnd-switch:checked { + background-color: var(--primary); +} + +/* Optional: additional styling for the slider when active */ +#matugen-switcher:checked slider, +#dnd-switch:checked slider { + background-color: var(--shadow); +} + +#matugen-switcher:checked image, +#dnd-switch:checked image { + color: var(--shadow); +} + +#mat-label { + font-size: 24px; + margin-left: 4px; +} + +#custom-color-selector-box { + background-color: var(--surface); + border-radius: 20px; + padding: 8px; +} + +#hue-slider { + margin: 0 24px; +} + +#hue-slider trough { + background-image: linear-gradient( + to right, + #ff0000, + #ffff00, + #00ff00, + #00ffff, + #0000ff, + #ff00ff, + #ff0000 + ); + border-radius: 8px; + min-height: 8px; +} + +#hue-slider slider { + min-height: 16px; + min-width: 8px; + margin: -10px 0; + background-color: var(--primary); + box-shadow: 0 0 4px alpha(var(--shadow), 0.5); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#hue-slider slider:hover, +#hue-slider slider:active { + background-color: var(--foreground); +} + +#hue-slider slider:active { + min-height: 24px; + min-width: 8px; +} + +#apply-color-button { + background-color: var(--primary); + color: var(--shadow); + border-radius: 20px; + padding: 8px; + box-shadow: 0 0 4px alpha(var(--shadow), 0.5); +} + +#apply-color-button:hover { + background-color: var(--foreground); +} + +#apply-color-button:active { + background-color: var(--shadow); +} + +#apply-color-label { + font-size: 16px; + color: var(--shadow); +} + +#apply-color-button:active #apply-color-label { + color: var(--foreground); +} diff --git a/Ax-Shell/styles/workspaces.css b/Ax-Shell/styles/workspaces.css new file mode 100644 index 0000000..d44840e --- /dev/null +++ b/Ax-Shell/styles/workspaces.css @@ -0,0 +1,102 @@ +#workspaces { + padding: 14px; +} + +#workspaces-num { + padding: 4px; +} + +#workspaces-container { + background-color: var(--shadow); +} + +#workspaces-container.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#workspaces > button { + min-width: 8px; + min-height: 8px; + border-radius: 16px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + background-color: var(--foreground); +} + +#workspaces > button > label { + font-size: 0px; +} + +#workspaces > button.empty:hover { + background-color: var(--foreground); +} + +#workspaces > button.urgent { + background-color: var(--error-dim); +} + +#workspaces > button.active { + min-width: 48px; + min-height: 8px; + background-color: var(--primary); +} + +#workspaces > button.active.vertical { + min-width: 8px; + min-height: 48px; + background-color: var(--primary); +} + +#workspaces > button.empty { + background-color: var(--surface-bright); +} + +#workspaces-num > button > label { + color: var(--foreground); +} + +#workspaces-num { + padding: 8px; +} + +#workspaces-num > button { + border-radius: 20px; + /* transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); */ +} + +#workspaces-num > button > label { + color: var(--foreground); + font-weight: bold; + min-width: 20px; + min-height: 20px; + font-size: 10pt; +} + +#workspaces-num > button:hover { + background-color: var(--surface-bright); +} + +#workspaces-num > button.active { + background-color: var(--primary); + border-radius: 8px; +} + +#workspaces-num > button.active > label { + color: var(--shadow); +} + +#workspaces-num > button.empty > label { + color: var(--surface-bright); +} + +#workspaces-num > button.empty:hover > label { + color: var(--foreground); +} + +#workspaces-num > button.empty:hover { + background-color: transparent; +} + +#workspaces-num > button.urgent > label { + color: var(--error-dim); +} diff --git a/Ax-Shell/test.txt b/Ax-Shell/test.txt deleted file mode 100644 index 9daeafb..0000000 --- a/Ax-Shell/test.txt +++ /dev/null @@ -1 +0,0 @@ -test diff --git a/Ax-Shell/uninstall.sh b/Ax-Shell/uninstall.sh new file mode 100644 index 0000000..32e15b7 --- /dev/null +++ b/Ax-Shell/uninstall.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +echo "This will permanently delete Ax-Shell cache, configuration, and remove its entry from hyprland.conf." +read -p "Are you sure you want to continue? [y/N] " confirm + +if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + echo "Aborted." + exit 1 +fi + +rm -rf ~/.cache/ax-shell +rm -rf ~/.config/Ax-Shell + +conf_file=~/.config/hypr/hyprland.conf +tmp_file=$(mktemp) + +awk ' +BEGIN { found_comment=0 } +{ + if ($0 ~ /# Ax-Shell/) { + found_comment=1 + next + } + if (found_comment && $0 ~ /source[[:space:]]*=[[:space:]]*~\/\.config\/Ax-Shell\/config\/hypr\/ax-shell\.conf/) { + found_comment=0 + next + } + print +}' "$conf_file" > "$tmp_file" && mv "$tmp_file" "$conf_file" + +echo "Ax-Shell data and config removed successfully." diff --git a/Ax-Shell/utils/__init__.py b/Ax-Shell/utils/__init__.py new file mode 100644 index 0000000..8b462ad --- /dev/null +++ b/Ax-Shell/utils/__init__.py @@ -0,0 +1,4 @@ +""" +Ax-Shell utilities package. +Contains helper functions and utility classes. +""" diff --git a/Ax-Shell/utils/animator.py b/Ax-Shell/utils/animator.py new file mode 100644 index 0000000..ed60e9e --- /dev/null +++ b/Ax-Shell/utils/animator.py @@ -0,0 +1,180 @@ +from typing import cast + +import fabric +from fabric import Property, Service, Signal +from gi.repository import GLib, Gtk + + +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/Ax-Shell/utils/async_subprocess.py b/Ax-Shell/utils/async_subprocess.py new file mode 100644 index 0000000..d7f0904 --- /dev/null +++ b/Ax-Shell/utils/async_subprocess.py @@ -0,0 +1,120 @@ +""" +Utility functions for running subprocess operations asynchronously without blocking the UI. +This module provides helper functions to prevent UI freezes when executing external processes. +""" + +import subprocess +from typing import Callable, List, Optional, Union +from gi.repository import GLib + + +def run_async_subprocess( + command: Union[str, List[str]], + on_success: Optional[Callable] = None, + on_error: Optional[Callable[[Exception], None]] = None, + on_complete: Optional[Callable[[], None]] = None, + thread_name: str = "async-subprocess" +) -> None: + """ + Run a subprocess command asynchronously in a background thread. + + Args: + command: Command to execute (string or list of strings) + on_success: Callback function to call on successful completion + on_error: Callback function to call when an error occurs (receives exception) + on_complete: Callback function to call when operation completes (success or error) + thread_name: Name for the background thread + """ + def worker_thread(user_data): + """Background thread worker function""" + try: + if isinstance(command, str): + subprocess.run(command, shell=True, check=True) + else: + subprocess.run(command, check=True) + + # Schedule success callback on main thread + if on_success: + GLib.idle_add(lambda: (on_success(), False)) + + except Exception as e: + # Schedule error callback on main thread + if on_error: + GLib.idle_add(lambda: (on_error(e), False)) + finally: + # Schedule completion callback on main thread + if on_complete: + GLib.idle_add(lambda: (on_complete(), False)) + + GLib.Thread.new(thread_name, worker_thread, None) + + +def check_process_async( + process_name: str, + on_running: Optional[Callable[[], None]] = None, + on_not_running: Optional[Callable[[], None]] = None, + on_error: Optional[Callable[[Exception], None]] = None, + thread_name: str = "check-process" +) -> None: + """ + Check if a process is running asynchronously. + + Args: + process_name: Name of the process to check (used with pgrep) + on_running: Callback function to call if process is running + on_not_running: Callback function to call if process is not running + on_error: Callback function to call when an error occurs + thread_name: Name for the background thread + """ + def worker_thread(user_data): + """Background thread worker function""" + try: + subprocess.check_output(["pgrep", process_name]) + # Process is running + if on_running: + GLib.idle_add(lambda: (on_running(), False)) + except subprocess.CalledProcessError: + # Process is not running + if on_not_running: + GLib.idle_add(lambda: (on_not_running(), False)) + except Exception as e: + # Other error occurred + if on_error: + GLib.idle_add(lambda: (on_error(e), False)) + + GLib.Thread.new(thread_name, worker_thread, None) + + +def run_command_with_output_async( + command: Union[str, List[str]], + on_success: Optional[Callable[[bytes], None]] = None, + on_error: Optional[Callable[[Exception], None]] = None, + thread_name: str = "command-output" +) -> None: + """ + Run a command and capture its output asynchronously. + + Args: + command: Command to execute (string or list of strings) + on_success: Callback function to call with command output on success + on_error: Callback function to call when an error occurs + thread_name: Name for the background thread + """ + def worker_thread(user_data): + """Background thread worker function""" + try: + if isinstance(command, str): + result = subprocess.run(command, shell=True, capture_output=True, check=True) + else: + result = subprocess.run(command, capture_output=True, check=True) + + # Schedule success callback with output on main thread + if on_success: + GLib.idle_add(lambda: (on_success(result.stdout), False)) + + except Exception as e: + # Schedule error callback on main thread + if on_error: + GLib.idle_add(lambda: (on_error(e), False)) + + GLib.Thread.new(thread_name, worker_thread, None) \ No newline at end of file diff --git a/Ax-Shell/utils/colors.py b/Ax-Shell/utils/colors.py new file mode 100644 index 0000000..176d2eb --- /dev/null +++ b/Ax-Shell/utils/colors.py @@ -0,0 +1,14 @@ +class Colors: + """Class to define colors for terminal output""" + + # Reference: https://stackoverflow.com/questions/287871/print-in-terminal-with-colors-using-python + HEADER = "\033[95m" + INFO = "\033[94m" + OKCYAN = "\033[96m" + OKGREEN = "\033[92m" + WARNING = "\033[93m" + ERROR = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + RESET = "\033[0m" diff --git a/Ax-Shell/utils/conversion.py b/Ax-Shell/utils/conversion.py new file mode 100644 index 0000000..d50230b --- /dev/null +++ b/Ax-Shell/utils/conversion.py @@ -0,0 +1,449 @@ +import requests + + +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, + } + + # Ya no usamos currency_converter aquรญ. + + +class Conversion(): + def __init__(self): + self.units = Units() + + def convert(self, value: float, from_type: str, to_type: str): + """ + Generalized conversion function que funciona con todas las categorรญas, + incluyendo moneda via floatrates.com. + """ + # Colecciรณn de todos los charts no-monedas + 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) Revisar si estรก en alguno de los charts (no monedas) + for chart_name, chart in charts.items(): + if from_type in chart and to_type in chart: + # Temperaturas usan 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 + + # Cualquier otro chart numรฉrico + if from_type == to_type: + return value + return value * (chart[from_type] / chart[to_type]) + + # 2) Si ambos son cรณdigos de moneda (p. ej. โ€œUSDโ€, โ€œARSโ€) + # asumimos que estรกn en mayรบsculas y tienen 3 letras. + if len(from_type) == 3 and len(to_type) == 3 and from_type.isalpha() and to_type.isalpha(): + return self._convert_currency_via_floatrates(value, from_type, to_type) + + # 3) Si no cae en ningรบn caso, error. + raise ValueError(f"Unsupported conversion: {from_type} to {to_type}") + + def _convert_currency_via_floatrates(self, value: float, from_code: str, to_code: str) -> float: + """ + Convierte usando el JSON de floatrates.com: + - Hace GET a https://www.floatrates.com/daily/{from_lower}.json + - Toma el rate de la clave to_lower y multiplica. + """ + 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 al obtener datos de floatrates para {from_code}") + + data = resp.json() + if to_lower not in data: + raise ValueError(f"Moneda destino '{to_code}' no encontrada en la respuesta de floatrates para '{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: # valor unidad1 and valor2 unidad2 _ a unidad_destino + parts.remove("and") + if len(parts) != 6: + raise ValueError("Formato invรกlido. Esperado: '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("Formato invรกlido. Esperado: '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: + """ + Si es moneda (3 letras), lo pasa a mayรบsculas. + Si termina en 's' (y no es 'celsius'), le quita la 's' para + las otras unidades. """ + if len(type) == 3 and type.isalpha(): + return type.upper() + if type.endswith("s") and type.lower() != "celsius": + # Para las tablas que tienen singular/plural + singular = type[:-1].lower() + # Si existe en STORAGE_TYPE_CHART, lo usamos; + # si no, devolvemos singular en minรบsculas para otros charts. + if singular in self.units.STORAGE_TYPE_CHART: + return singular + return singular.lower() + return type + + +# Ejemplo rรกpido de uso: +if __name__ == "__main__": + conv = Conversion() + # Convierte 10 USD a ARS: + result, suffix = conv.parse_input_and_convert("10 USD _ ARS") + print(f"{result:.2f} {suffix}") # Ej: "10 USD _ ARS" -> "38754.23 ARS" diff --git a/Ax-Shell/utils/functions.py b/Ax-Shell/utils/functions.py new file mode 100644 index 0000000..7eeb0f9 --- /dev/null +++ b/Ax-Shell/utils/functions.py @@ -0,0 +1,235 @@ +import datetime +import os +import shutil +import subprocess +from typing import Dict, List, Literal + +import gi +import psutil +from fabric.utils import exec_shell_command, exec_shell_command_async, get_relative_path +from gi.repository import Gdk, GLib, Gtk +from loguru import logger + +from .colors import Colors +from .icons import distro_text_icons + +gi.require_version("Gtk", "3.0") + + +class ExecutableNotFoundError(ImportError): + """Raised when an executable is not found.""" + + def __init__(self, executable_name: str): + super().__init__( + f"{Colors.ERROR}Executable {Colors.UNDERLINE}{executable_name}{Colors.RESET} not found. Please install it using your package manager." # noqa: E501 + ) + + +# Function to escape the markup +def parse_markup(text): + return text + + +# support for multiple monitors +def for_monitors(widget): + n = Gdk.Display.get_default().get_n_monitors() if Gdk.Display.get_default() else 1 + return [widget(i) for i in range(n)] + + +# Function to get the system icon theme +def copy_theme(theme: str): + destination_file = get_relative_path("../styles/theme.scss") + source_file = get_relative_path(f"../styles/themes/{theme}.scss") + + if not os.path.exists(source_file): + logger.warning( + f"{Colors.WARNING}Warning: The theme file '{theme}.scss' was not found. Using default theme." # noqa: E501 + ) + source_file = get_relative_path("../styles/themes/catpuccin-mocha.scss") + + try: + with open(source_file, "r") as source_file: + content = source_file.read() + + # Open the destination file in write mode + with open(destination_file, "w") as destination_file: + destination_file.write(content) + logger.info(f"{Colors.INFO}[THEME] '{theme}' applied successfully.") + + except FileNotFoundError: + logger.error( + f"{Colors.ERROR}Error: The theme file '{source_file}' was not found." + ) + exit(1) + + +# Merge the parsed data with the default configuration +def merge_defaults(data: dict, defaults: dict): + return {**defaults, **data} + + +# Validate the widgets +def validate_widgets(parsed_data, default_config): + layout = parsed_data["layout"] + for section in layout: + for widget in layout[section]: + if widget not in default_config: + raise ValueError( + f"Invalid widget {widget} found in section {section}. Please check the widget name." # noqa: E501 + ) + + +# Function to exclude keys from a dictionary ) +def exclude_keys(d: Dict, keys_to_exclude: List[str]) -> Dict: + return {k: v for k, v in d.items() if k not in keys_to_exclude} + + +# Function to format time in hours and minutes +def format_time(secs: int): + mm, _ = divmod(secs, 60) + hh, mm = divmod(mm, 60) + return "%d h %02d min" % (hh, mm) + + +# Function to convert bytes to kilobytes, megabytes, or gigabytes +def convert_bytes(bytes: int, to: Literal["kb", "mb", "gb"], format_spec=".1f"): + multiplier = 1 + + if to == "mb": + multiplier = 2 + elif to == "gb": + multiplier = 3 + + return f"{format(bytes / (1024**multiplier), format_spec)}{to.upper()}" + + +# Function to get the system uptime +def uptime(): + boot_time = psutil.boot_time() + now = datetime.datetime.now() + + diff = now.timestamp() - boot_time + + # Convert the difference in seconds to hours and minutes + hours, remainder = divmod(diff, 3600) + minutes, _ = divmod(remainder, 60) + + return f"{int(hours):02}:{int(minutes):02}" + + +# Function to convert seconds to milliseconds +def convert_seconds_to_milliseconds(seconds: int): + return seconds * 1000 + + +# Function to check if an icon exists, otherwise use a fallback icon +def check_icon_exists(icon_name: str, fallback_icon: str) -> str: + if Gtk.IconTheme.get_default().has_icon(icon_name): + return icon_name + return fallback_icon + + +# Function to execute a shell command asynchronously +def play_sound(file: str): + exec_shell_command_async(f"play {file}", None) + + +# Function to get the distro icon +def get_distro_icon(): + distro_id = GLib.get_os_info("ID") + + # Search for the icon in the list + return distro_text_icons.get(distro_id, "๎œ’") + + +# Function to check if an executable exists +def executable_exists(executable_name): + executable_path = shutil.which(executable_name) + return bool(executable_path) + + +def send_notification( + title: str, + body: str, + urgency: Literal["low", "normal", "critical"], + icon=None, + app_name="Application", + timeout=None, +): + """ + Sends a notification using the notify-send command. + :param title: The title of the notification + :param body: The message body of the notification + :param urgency: The urgency of the notification ('low', 'normal', 'critical') + :param icon: Optional icon for the notification + :param app_name: The application name that is sending the notification + :param timeout: Optional timeout in milliseconds (e.g., 5000 for 5 seconds) + """ + # Base command + command = [ + "notify-send", + "--urgency", + urgency, + "--app-name", + app_name, + title, + body, + ] + + # Add icon if provided + if icon: + command.extend(["--icon", icon]) + + if timeout is not None: + command.extend(["-t", str(timeout)]) + + try: + subprocess.run(command, check=True) + except subprocess.CalledProcessError as e: + print(f"Failed to send notification: {e}") + + +# Function to get the relative time +def get_relative_time(mins: int) -> str: + # Seconds + if mins == 0: + return "now" + + # Minutes + if mins < 60: + return f"{mins} minute{'s' if mins > 1 else ''} ago" + + # Hours + if mins < 1440: + hours = mins // 60 + return f"{hours} hour{'s' if hours > 1 else ''} ago" + + # Days + days = mins // 1440 + return f"{days} day{'s' if days > 1 else ''} ago" + + +# Function to get the percentage of a value +def convert_to_percent( + current: int | float, max: int | float, is_int=True +) -> int | float: + if is_int: + return int((current / max) * 100) + else: + return (current / max) * 100 + + +# Function to ensure the directory exists +def ensure_dir_exists(path: str): + if not os.path.exists(path): + os.makedirs(path) + + +# Function to unique list +def unique_list(lst) -> List: + return list(set(lst)) + + +# Function to check if an app is running +def is_app_running(app_name: str) -> bool: + return len(exec_shell_command(f"pidof {app_name}")) != 0 diff --git a/Ax-Shell/utils/global_keybinds.py b/Ax-Shell/utils/global_keybinds.py new file mode 100644 index 0000000..816c5c8 --- /dev/null +++ b/Ax-Shell/utils/global_keybinds.py @@ -0,0 +1,254 @@ +from typing import Optional + + +class GlobalKeybindHandler: + """ + Handler for global keybinds that redirects commands to the focused monitor. + + This class provides methods to open notch modules, access widgets, and + perform other actions on the currently focused monitor. + """ + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if hasattr(self, '_initialized'): + return + + self._initialized = True + self._monitor_manager = None + + def set_monitor_manager(self, monitor_manager): + """Set the monitor manager reference.""" + self._monitor_manager = monitor_manager + + def open_notch_module(self, module_name: str) -> bool: + """ + Open a notch module on the currently focused monitor. + + Args: + module_name: Name of the module to open + + Returns: + True if successful, False otherwise + """ + if not self._monitor_manager: + return False + + focused_monitor_id = self._monitor_manager.get_focused_monitor_id() + + # Close any open notches on other monitors + self._monitor_manager.close_all_notches_except(focused_monitor_id) + + # Get notch instance for focused monitor + notch = self._monitor_manager.get_focused_instance('notch') + if notch and hasattr(notch, 'open_module'): + try: + notch.open_module(module_name) + self._monitor_manager.set_notch_state(focused_monitor_id, True, module_name) + return True + except Exception as e: + print(f"GlobalKeybindHandler: Error opening module '{module_name}': {e}") + + return False + + def toggle_notch(self) -> bool: + """ + Toggle notch on the currently focused monitor. + + Returns: + True if successful, False otherwise + """ + if not self._monitor_manager: + return False + + focused_monitor_id = self._monitor_manager.get_focused_monitor_id() + is_open = self._monitor_manager.is_notch_open(focused_monitor_id) + + notch = self._monitor_manager.get_focused_instance('notch') + if notch: + try: + if is_open: + if hasattr(notch, 'close'): + notch.close() + self._monitor_manager.set_notch_state(focused_monitor_id, False) + else: + if hasattr(notch, 'open'): + notch.open() + self._monitor_manager.set_notch_state(focused_monitor_id, True) + return True + except Exception as e: + print(f"GlobalKeybindHandler: Error toggling notch: {e}") + + return False + + def get_dashboard_wallpapers_widget(self): + """ + Get the dashboard wallpapers widget from the focused monitor. + + Returns: + Wallpapers widget instance or None + """ + if not self._monitor_manager: + return None + + notch = self._monitor_manager.get_focused_instance('notch') + if notch and hasattr(notch, 'dashboard'): + dashboard = notch.dashboard + if hasattr(dashboard, 'widgets') and hasattr(dashboard.widgets, 'wallpapers'): + return dashboard.widgets.wallpapers + + return None + + def get_dashboard_widget(self, widget_name: str): + """ + Get a specific dashboard widget from the focused monitor. + + Args: + widget_name: Name of the widget to get + + Returns: + Widget instance or None + """ + if not self._monitor_manager: + return None + + notch = self._monitor_manager.get_focused_instance('notch') + if notch and hasattr(notch, 'dashboard'): + dashboard = notch.dashboard + if hasattr(dashboard, 'widgets'): + return getattr(dashboard.widgets, widget_name, None) + + return None + + def open_launcher(self) -> bool: + """Open launcher on focused monitor.""" + return self.open_notch_module('launcher') + + def open_overview(self) -> bool: + """Open overview on focused monitor.""" + return self.open_notch_module('overview') + + def open_dashboard(self) -> bool: + """Open dashboard on focused monitor.""" + return self.open_notch_module('dashboard') + + def open_power_menu(self) -> bool: + """Open power menu on focused monitor.""" + return self.open_notch_module('power') + + def open_toolbox(self) -> bool: + """Open toolbox on focused monitor.""" + return self.open_notch_module('tools') + + def open_emoji_picker(self) -> bool: + """Open emoji picker on focused monitor.""" + return self.open_notch_module('emoji') + + def open_clipboard_history(self) -> bool: + """Open clipboard history on focused monitor.""" + return self.open_notch_module('cliphist') + + def get_focused_monitor_info(self) -> Optional[dict]: + """ + Get information about the currently focused monitor. + + Returns: + Monitor info dict or None + """ + if not self._monitor_manager: + return None + + return self._monitor_manager.get_focused_monitor() + + def get_all_monitors_info(self) -> list: + """ + Get information about all monitors. + + Returns: + List of monitor info dicts + """ + if not self._monitor_manager: + return [] + + return self._monitor_manager.get_monitors() + + def toggle_bar(self) -> bool: + """ + Toggle bar visibility and force notch/dock to occlusion mode. + + Returns: + True if successful, False otherwise + """ + if not self._monitor_manager: + return False + + monitors = self._monitor_manager.get_monitors() + + for monitor in monitors: + bar = self._monitor_manager.get_instance(monitor['id'], 'bar') + notch = self._monitor_manager.get_instance(monitor['id'], 'notch') + + if bar and notch: + try: + current_visibility = bar.get_visible() + bar.set_visible(not current_visibility) + + if not current_visibility: + # Bar is being shown - restore from occlusion + notch.restore_from_occlusion() + # Also restore docks on all monitors + try: + from modules.dock import Dock + for dock_instance in Dock._instances: + if hasattr(dock_instance, 'restore_from_occlusion'): + dock_instance.restore_from_occlusion() + except ImportError: + pass + else: + # Bar is being hidden - force occlusion + notch.force_occlusion() + # Also force occlusion on docks on all monitors + try: + from modules.dock import Dock + for dock_instance in Dock._instances: + if hasattr(dock_instance, 'force_occlusion'): + dock_instance.force_occlusion() + except ImportError: + pass + + except Exception as e: + print(f"GlobalKeybindHandler: Error toggling bar: {e}") + return False + + return True + + +# Singleton accessor +_global_keybind_handler_instance = None + +def get_global_keybind_handler() -> GlobalKeybindHandler: + """Get the global GlobalKeybindHandler instance.""" + global _global_keybind_handler_instance + if _global_keybind_handler_instance is None: + _global_keybind_handler_instance = GlobalKeybindHandler() + return _global_keybind_handler_instance + +def init_global_keybind_objects(): + """Initialize global keybind handler with monitor manager.""" + try: + from utils.monitor_manager import get_monitor_manager + + handler = get_global_keybind_handler() + manager = get_monitor_manager() + handler.set_monitor_manager(manager) + + return handler + except ImportError as e: + print(f"Error initializing global keybind objects: {e}") + return None \ No newline at end of file diff --git a/Ax-Shell/utils/hyprland_monitor.py b/Ax-Shell/utils/hyprland_monitor.py new file mode 100644 index 0000000..c48fecc --- /dev/null +++ b/Ax-Shell/utils/hyprland_monitor.py @@ -0,0 +1,56 @@ +import json +from typing import Dict + +import gi + +import warnings + +from fabric.hyprland import Hyprland + +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk + + +# IDC, Gdk.Screen.get_monitor_plug_name is deprecated +warnings.filterwarnings("ignore", category=DeprecationWarning) + +# Another idea is to use Gdk.Monitor.get_model() however, +# there is no garuntee that this will be unique +# Example: both monitors have the same model number +# (quite common in multi monitor setups) + + +# Also, using Gdk.Display.get_monitor_at_point(x,y) +# does not work correctly on all wayland setups + + +# Annoyingly, Gdk 4.0 has a solution to this with +# Gdk.Monitor.get_description() or Gdk.Monitor.get_connector() +# which both can be used to uniquely identify a monitor + + +class HyprlandWithMonitors(Hyprland): + def __init__(self, commands_only: bool = False, **kwargs): + self.display: Gdk.Display = Gdk.Display.get_default() + super().__init__(commands_only, **kwargs) + + # Add new arguments + 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/Ax-Shell/utils/icon_resolver.py b/Ax-Shell/utils/icon_resolver.py new file mode 100644 index 0000000..a80c8f6 --- /dev/null +++ b/Ax-Shell/utils/icon_resolver.py @@ -0,0 +1,102 @@ +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/Ax-Shell/utils/icons.py b/Ax-Shell/utils/icons.py new file mode 100644 index 0000000..8a9471c --- /dev/null +++ b/Ax-Shell/utils/icons.py @@ -0,0 +1,550 @@ +common_text_icons = { + "playing": "๏‹", + "paused": "๏Œ", + "power": "๏€‘", + "cpu": "๏’ผ", + "memory": "๎ฟ…", + "storage": "๓ฐ‹Š", + "updates": "๓ฑง˜", + "thermometer": "๏‹‰", +} + +distro_text_icons = { + "deepin": "๏Œก", + "fedora": "๏ŒŠ", + "arch": "๏Œƒ", + "nixos": "๏Œ“", + "debian": "๏Œ†", + "opensuse-tumbleweed": "๏Œ”", + "ubuntu": "๏Œœ", + "endeavouros": "๏Œข", + "manjaro": "๏Œ’", + "popos": "๏Œช", + "garuda": "๏Œท", + "zorin": "๏Œฏ", + "mxlinux": "๏Œฟ", + "arcolinux": "๏†", + "gentoo": "๏Œ", + "artix": "๏ŒŸ", + "centos": "๏Œ„", + "hyperbola": "๏Œบ", + "kubuntu": "๏Œณ", + "mandriva": "๏Œ‘", + "xerolinux": "๏Š", + "parabola": "๏€", + "void": "๏Œฎ", + "linuxmint": "๏ŒŽ", + "archlabs": "๏Œž", + "devuan": "๏Œ‡", + "freebsd": "๏ŒŒ", + "openbsd": "๏Œจ", + "slackware": "๏Œ˜", +} + +# sourced from wttr.in +weather_text_icons = { + "113": {"description": "Sunny", "icon": "๓ฐ–™"}, + "116": {"description": "PartlyCloudy", "icon": "๓ฐ–•"}, + "119": {"description": "Cloudy", "icon": "๓ฐ–"}, + "122": {"description": "VeryCloudy", "icon": "๓ฐ–•"}, + "143": {"description": "Fog", "icon": "๓ฐ–‘"}, + "176": {"description": "LightShowers", "icon": "๓ฐผณ"}, + "179": {"description": "LightSleetShowers", "icon": "๓ฐผต"}, + "182": {"description": "LightSleet", "icon": "๓ฐ™ฟ"}, + "185": {"description": "LightSleet", "icon": "๓ฐ™ฟ"}, + "200": {"description": "ThunderyShowers", "icon": "๓ฐ™พ"}, + "227": {"description": "LightSnow", "icon": "๓ฐผด"}, + "230": {"description": "HeavySnow", "icon": "๓ฐผถ"}, + "248": {"description": "Fog", "icon": "๓ฐ–‘"}, + "260": {"description": "Fog", "icon": "๓ฐ–‘"}, + "263": {"description": "LightShowers", "icon": "๓ฐผณ"}, + "266": {"description": "LightRain", "icon": "๓ฐผณ"}, + "281": {"description": "LightSleet", "icon": "๓ฐ™ฟ"}, + "284": {"description": "LightSleet", "icon": "๓ฐ™ฟ"}, + "293": {"description": "LightRain", "icon": "๓ฐผณ"}, + "296": {"description": "LightRain", "icon": "๓ฐผณ"}, + "299": {"description": "HeavyShowers", "icon": "๓ฐ––"}, + "302": {"description": "HeavyRain", "icon": "๓ฐ––"}, + "305": {"description": "HeavyShowers", "icon": "๓ฐ––"}, + "308": {"description": "HeavyRain", "icon": "๓ฐ––"}, + "311": {"description": "LightSleet", "icon": "๓ฐ™ฟ"}, + "314": {"description": "LightSleet", "icon": "๓ฐ™ฟ"}, + "317": {"description": "LightSleet", "icon": "๓ฐ™ฟ"}, + "320": {"description": "LightSnow", "icon": "๓ฐผด"}, + "323": {"description": "LightSnowShowers", "icon": "๓ฐผต"}, + "326": {"description": "LightSnowShowers", "icon": "๓ฐผต"}, + "329": {"description": "HeavySnow", "icon": "๓ฐผถ"}, + "332": {"description": "HeavySnow", "icon": "๓ฐผถ"}, + "335": {"description": "HeavySnowShowers", "icon": "๓ฐผต"}, + "338": {"description": "HeavySnow", "icon": "๓ฐผถ"}, + "350": {"description": "LightSleet", "icon": "๓ฐ™ฟ"}, + "353": {"description": "LightShowers", "icon": "๓ฐผณ"}, + "356": {"description": "HeavyShowers", "icon": "๓ฐ––"}, + "359": {"description": "HeavyRain", "icon": "๓ฐ––"}, + "362": {"description": "LightSleetShowers", "icon": "๓ฐผต"}, + "365": {"description": "LightSleetShowers", "icon": "๓ฐผต"}, + "368": {"description": "LightSnowShowers", "icon": "๓ฐผต"}, + "371": {"description": "HeavySnowShowers", "icon": "๓ฐผต"}, + "374": {"description": "LightSleetShowers", "icon": "๓ฐผต"}, + "377": {"description": "LightSleet", "icon": "๓ฐ™ฟ"}, + "386": {"description": "ThunderyShowers", "icon": "๓ฐ™พ"}, + "389": {"description": "ThunderyHeavyRain", "icon": "๓ฐ™พ"}, + "392": {"description": "ThunderySnowShowers", "icon": "๓ฐผถ"}, + "395": {"description": "HeavySnowShowers", "icon": "๓ฐผต"}, +} + +weather_text_icons_v2 = { + "113": { + "description": "Sunny", + "icon": "๓ฐ–™", + "image": "clear-day", + "icon-night": "๓ฐ–™", + "image-night": "clear-night", + }, + "116": { + "description": "PartlyCloudy", + "icon": "๓ฐ–•", + "image": "cloudy", + "icon-night": "๓ฐ–•", + "image-night": "cloudy", + }, + "119": { + "description": "Cloudy", + "icon": "๓ฐ–", + "image": "cloudy", + "icon-night": "๓ฐ–", + "image-night": "cloudy", + }, + "122": { + "description": "VeryCloudy", + "icon": "๓ฐ–•", + "image": "cloudy", + "icon-night": "๓ฐ–•", + "image-night": "cloudy", + }, + "143": { + "description": "Fog", + "icon": "๓ฐ–‘", + "image": "fog", + "icon-night": "๓ฐ–‘", + "image-night": "fog", + }, + "176": { + "description": "LightShowers", + "icon": "๓ฐผณ", + "image": "rain", + "icon-night": "๓ฐผณ", + "image-night": "rain", + }, + "179": { + "description": "LightSleetShowers", + "icon": "๓ฐผต", + "image": "sleet", + "icon-night": "๓ฐผต", + "image-night": "sleet", + }, + "182": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "185": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "200": { + "description": "ThunderyShowers", + "icon": "๓ฐ™พ", + "image": "thunderstorms", + "icon-night": "๓ฐ™พ", + "image-night": "thunderstorms", + }, + "227": { + "description": "LightSnow", + "icon": "๓ฐผด", + "image": "snow", + "icon-night": "๓ฐผด", + "image-night": "snow", + }, + "230": { + "description": "HeavySnow", + "icon": "๓ฐผถ", + "image": "snow", + "icon-night": "๓ฐผถ", + "image-night": "snow", + }, + "248": { + "description": "Fog", + "icon": "๓ฐ–‘", + "image": "fog", + "icon-night": "๓ฐ–‘", + "image-night": "fog", + }, + "260": { + "description": "Fog", + "icon": "๓ฐ–‘", + "image": "fog", + "icon-night": "๓ฐ–‘", + "image-night": "fog", + }, + "263": { + "description": "LightShowers", + "icon": "๓ฐผณ", + "image": "rain", + "icon-night": "๓ฐผณ", + "image-night": "rain", + }, + "266": { + "description": "LightRain", + "icon": "๓ฐผณ", + "image": "rain", + "icon-night": "๓ฐผณ", + "image-night": "rain", + }, + "281": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "284": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "293": { + "description": "LightRain", + "icon": "๓ฐผณ", + "image": "rain", + "icon-night": "๓ฐผณ", + "image-night": "rain", + }, + "296": { + "description": "LightRain", + "icon": "๓ฐผณ", + "image": "rain", + "icon-night": "๓ฐผณ", + "image-night": "rain", + }, + "299": { + "description": "HeavyShowers", + "icon": "๓ฐ––", + "image": "rain", + "icon-night": "๓ฐ––", + "image-night": "rain", + }, + "302": { + "description": "HeavyRain", + "icon": "๓ฐ––", + "image": "rain", + "icon-night": "๓ฐ––", + "image-night": "rain", + }, + "305": { + "description": "HeavyShowers", + "icon": "๓ฐ––", + "image": "rain", + "icon-night": "๓ฐ––", + "image-night": "rain", + }, + "308": { + "description": "HeavyRain", + "icon": "๓ฐ––", + "image": "rain", + "icon-night": "๓ฐ––", + "image-night": "rain", + }, + "311": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "314": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "317": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "320": { + "description": "LightSnow", + "icon": "๓ฐผด", + "image": "snow", + "icon-night": "๓ฐผด", + "image-night": "snow", + }, + "323": { + "description": "LightSnowShowers", + "icon": "๓ฐผต", + "image": "snow", + "icon-night": "๓ฐผต", + "image-night": "snow", + }, + "326": { + "description": "LightSnowShowers", + "icon": "๓ฐผต", + "image": "snow", + "icon-night": "๓ฐผต", + "image-night": "snow", + }, + "329": { + "description": "HeavySnow", + "icon": "๓ฐผถ", + "image": "snow", + "icon-night": "๓ฐผถ", + "image-night": "snow", + }, + "332": { + "description": "HeavySnow", + "icon": "๓ฐผถ", + "image": "snow", + "icon-night": "๓ฐผถ", + "image-night": "snow", + }, + "335": { + "description": "HeavySnowShowers", + "icon": "๓ฐผต", + "image": "snow", + "icon-night": "๓ฐผต", + "image-night": "snow", + }, + "338": { + "description": "HeavySnow", + "icon": "๓ฐผถ", + "image": "snow", + "icon-night": "๓ฐผถ", + "image-night": "snow", + }, + "350": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "353": { + "description": "LightShowers", + "icon": "๓ฐผณ", + "image": "rain", + "icon-night": "๓ฐผณ", + "image-night": "rain", + }, + "356": { + "description": "HeavyShowers", + "icon": "๓ฐ––", + "image": "rain", + "icon-night": "๓ฐ––", + "image-night": "rain", + }, + "359": { + "description": "HeavyRain", + "icon": "๓ฐ––", + "image": "rain", + "icon-night": "๓ฐ––", + "image-night": "rain", + }, + "362": { + "description": "LightSleetShowers", + "icon": "๓ฐผต", + "image": "sleet", + "icon-night": "๓ฐผต", + "image-night": "sleet", + }, + "365": { + "description": "HeavySleetShowers", + "icon": "๓ฐผต", + "image": "sleet", + "icon-night": "๓ฐผต", + "image-night": "sleet", + }, + "368": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "371": { + "description": "HeavySleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, + "374": { + "description": "HeavySnowShowers", + "icon": "๓ฐผถ", + "image": "snow", + "icon-night": "๓ฐผถ", + "image-night": "snow", + }, + "377": { + "description": "LightSleet", + "icon": "๓ฐ™ฟ", + "image": "sleet", + "icon-night": "๓ฐ™ฟ", + "image-night": "sleet", + }, +} + +volume_text_icons = { + "overamplified": "๓ฐ•พ", + "high": "๓ฐ•พ", + "medium": "๓ฐ–€", + "low": "๓ฐ•ฟ", + "muted": "๓ฐŸ", +} + +volume_text_icons = { + "overamplified": "๓ฐ•พ", + "high": "๓ฐ•พ", + "medium": "๓ฐ–€", + "low": "๓ฐ•ฟ", + "muted": "๓ฐŸ", +} + +brightness_text_icons = { + "off": "๎Ž", # lowest brightness + "low": "๎‘", + "medium": "๎Ž", + "high": "๎Ž›", # highest brightness +} + +icons = { + "missing": "image-missing-symbolic", + "nix": { + "nix": "nix-snowflake-symbolic", + }, + "app": { + "terminal": "terminal-symbolic", + }, + "fallback": { + "executable": "application-x-executable", + "notification": "dialog-information-symbolic", + "video": "video-x-generic-symbolic", + "audio": "audio-x-generic-symbolic", + }, + "ui": { + "close": "window-close-symbolic", + "colorpicker": "color-select-symbolic", + "info": "info-symbolic", + "link": "external-link-symbolic", + "lock": "system-lock-screen-symbolic", + "menu": "open-menu-symbolic", + "refresh": "view-refresh-symbolic", + "search": "system-search-symbolic", + "settings": "emblem-system-symbolic", + "themes": "preferences-desktop-theme-symbolic", + "tick": "object-select-symbolic", + "time": "hourglass-symbolic", + "toolbars": "toolbars-symbolic", + "warning": "dialog-warning-symbolic", + "avatar": "avatar-default-symbolic", + "arrow": { + "right": "pan-end-symbolic", + "left": "pan-start-symbolic", + "down": "pan-down-symbolic", + "up": "pan-up-symbolic", + }, + }, + "audio": { + "mic": { + "muted": "microphone-disabled-symbolic", + "low": "microphone-sensitivity-low-symbolic", + "medium": "microphone-sensitivity-medium-symbolic", + "high": "microphone-sensitivity-high-symbolic", + }, + "volume": { + "muted": "audio-volume-muted-symbolic", + "low": "audio-volume-low-symbolic", + "medium": "audio-volume-medium-symbolic", + "high": "audio-volume-high-symbolic", + "overamplified": "audio-volume-overamplified-symbolic", + }, + "type": { + "headset": "audio-headphones-symbolic", + "speaker": "audio-speakers-symbolic", + "card": "audio-card-symbolic", + }, + "mixer": "mixer-symbolic", + }, + "powerprofile": { + "balanced": "power-profile-balanced-symbolic", + "power-saver": "power-profile-power-saver-symbolic", + "performance": "power-profile-performance-symbolic", + }, + "battery": { + "charging": "battery-flash-symbolic", + "warning": "battery-empty-symbolic", + }, + "bluetooth": { + "enabled": "bluetooth-active-symbolic", + "disabled": "bluetooth-disabled-symbolic", + }, + "brightness": { + "indicator": "display-brightness-symbolic", + "keyboard": "keyboard-brightness-symbolic", + "screen": "display-brightness-symbolic", + }, + "powermenu": { + "sleep": "weather-clear-night-symbolic", + "reboot": "system-reboot-symbolic", + "logout": "system-log-out-symbolic", + "shutdown": "system-shutdown-symbolic", + }, + "recorder": { + "recording": "media-record-symbolic", + "stopped": "media-record-symbolic", + }, + "notifications": { + "noisy": "org.gnome.Settings-notifications-symbolic", + "silent": "notifications-disabled-symbolic", + "message": "chat-bubbles-symbolic", + }, + "trash": { + "full": "user-trash-full-symbolic", + "empty": "user-trash-symbolic", + }, + "mpris": { + "shuffle": { + "enabled": "media-playlist-shuffle-symbolic", + "disabled": "media-playlist-consecutive-symbolic", + }, + "loop": { + "none": "media-playlist-repeat-symbolic", + "track": "media-playlist-repeat-song-symbolic", + "playlist": "media-playlist-repeat-symbolic", + }, + "playing": "media-playback-pause-symbolic", + "paused": "media-playback-start-symbolic", + "stopped": "media-playback-start-symbolic", + "prev": "media-skip-backward-symbolic", + "next": "media-skip-forward-symbolic", + }, + "system": { + "cpu": "org.gnome.SystemMonitor-symbolic", + "ram": "drive-harddisk-solidstate-symbolic", + "temp": "temperature-symbolic", + }, + "color": { + "dark": "dark-mode-symbolic", + "light": "light-mode-symbolic", + }, +} diff --git a/Ax-Shell/utils/monitor_manager.py b/Ax-Shell/utils/monitor_manager.py new file mode 100644 index 0000000..a8f4dab --- /dev/null +++ b/Ax-Shell/utils/monitor_manager.py @@ -0,0 +1,334 @@ +import json +import subprocess +from typing import Dict, List, Optional, Tuple + +import gi + +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk + + +class Signal: + """Simple signal implementation for monitor manager.""" + + def __init__(self): + self._callbacks = [] + + def connect(self, callback): + """Connect a callback to this signal.""" + self._callbacks.append(callback) + + def emit(self, *args, **kwargs): + """Emit the signal to all connected callbacks.""" + for callback in self._callbacks: + try: + callback(*args, **kwargs) + except Exception as e: + print(f"Error in signal callback: {e}") + + +class MonitorManager: + """ + Centralized monitor management for Ax-Shell multi-monitor support. + + Manages monitor detection, workspace paging, notch states, and component instances. + """ + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if hasattr(self, '_initialized'): + return + + self._initialized = True + self._monitors: List[Dict] = [] + self._focused_monitor_id: int = 0 + self._notch_states: Dict[int, bool] = {} + self._current_notch_module: Dict[int, Optional[str]] = {} + self._monitor_instances: Dict[int, Dict] = {} + self._monitor_focus_service = None + + # Signals + self.monitor_changed = Signal() + self.notch_focus_changed = Signal() + + self.refresh_monitors() + + def set_monitor_focus_service(self, service): + """Set the monitor focus service reference.""" + self._monitor_focus_service = service + if service: + service.monitor_focused.connect(self._on_monitor_focused) + + def _get_gtk_monitor_info(self) -> List[Dict]: + """Get monitor information using GTK/GDK including scale factors.""" + gtk_monitors = [] + try: + display = Gdk.Display.get_default() + if display and hasattr(display, 'get_n_monitors'): + n_monitors = display.get_n_monitors() + for i in range(n_monitors): + monitor = display.get_monitor(i) + if monitor: + geometry = monitor.get_geometry() + scale_factor = monitor.get_scale_factor() + model = monitor.get_model() or f'monitor-{i}' + + gtk_monitors.append({ + 'id': i, + 'name': model, + 'width': geometry.width, + 'height': geometry.height, + 'x': geometry.x, + 'y': geometry.y, + 'scale': scale_factor + }) + except Exception as e: + print(f"Error getting GTK monitor info: {e}") + + return gtk_monitors + + def refresh_monitors(self) -> List[Dict]: + """ + Detect monitors using Hyprland API for accurate info, with GTK for scale detection. + + Returns: + List of monitor dictionaries with id, name, width, height, x, y, scale + """ + self._monitors = [] + + try: + # Try Hyprland first for primary info (more accurate) + result = subprocess.run( + ["hyprctl", "monitors", "-j"], + capture_output=True, + text=True, + check=True + ) + hypr_monitors = json.loads(result.stdout) + + for i, monitor in enumerate(hypr_monitors): + monitor_name = monitor.get('name', f'monitor-{i}') + + # Get scale directly from Hyprland (more reliable) + hypr_scale = monitor.get('scale', 1.0) + + self._monitors.append({ + 'id': i, + 'name': monitor_name, + 'width': monitor.get('width', 1920), + 'height': monitor.get('height', 1080), + 'x': monitor.get('x', 0), + 'y': monitor.get('y', 0), + 'focused': monitor.get('focused', False), + 'scale': hypr_scale + }) + + # Initialize states for new monitors + if i not in self._notch_states: + self._notch_states[i] = False + self._current_notch_module[i] = None + + except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError): + # Fallback to GTK only if Hyprland fails + self._fallback_to_gtk() + + # Ensure we have at least one monitor + if not self._monitors: + self._monitors = [{ + 'id': 0, + 'name': 'default', + 'width': 1920, + 'height': 1080, + 'x': 0, + 'y': 0, + 'focused': True, + 'scale': 1.0 + }] + self._notch_states[0] = False + self._current_notch_module[0] = None + + # Update focused monitor + for monitor in self._monitors: + if monitor.get('focused', False): + self._focused_monitor_id = monitor['id'] + break + + self.monitor_changed.emit(self._monitors) + return self._monitors + + def _fallback_to_gtk(self): + """Fallback monitor detection using GTK with scale information.""" + try: + display = Gdk.Display.get_default() + if display and hasattr(display, 'get_n_monitors'): + n_monitors = display.get_n_monitors() + for i in range(n_monitors): + monitor = display.get_monitor(i) + geometry = monitor.get_geometry() + scale_factor = monitor.get_scale_factor() + + self._monitors.append({ + 'id': i, + 'name': monitor.get_model() or f'monitor-{i}', + 'width': geometry.width, + 'height': geometry.height, + 'x': geometry.x, + 'y': geometry.y, + 'focused': i == 0, # Assume first monitor is focused + 'scale': scale_factor + }) + + if i not in self._notch_states: + self._notch_states[i] = False + self._current_notch_module[i] = None + except Exception: + pass + + def get_monitors(self) -> List[Dict]: + """Get list of all monitors.""" + return self._monitors.copy() + + def get_monitor_by_id(self, monitor_id: int) -> Optional[Dict]: + """Get monitor by ID.""" + for monitor in self._monitors: + if monitor['id'] == monitor_id: + return monitor.copy() + return None + + def get_focused_monitor_id(self) -> int: + """Get currently focused monitor ID.""" + return self._focused_monitor_id + + def get_focused_monitor(self) -> Optional[Dict]: + """Get currently focused monitor.""" + return self.get_monitor_by_id(self._focused_monitor_id) + + def get_workspace_range_for_monitor(self, monitor_id: int) -> Tuple[int, int]: + """ + Get workspace range for a monitor (10 workspaces per monitor). + + Args: + monitor_id: Monitor ID + + Returns: + Tuple of (start_workspace, end_workspace) + """ + start = (monitor_id * 10) + 1 + end = start + 9 + return (start, end) + + def get_monitor_for_workspace(self, workspace_id: int) -> int: + """ + Get monitor ID for a workspace. + + Args: + workspace_id: Workspace number + + Returns: + Monitor ID + """ + if workspace_id <= 0: + return 0 + return (workspace_id - 1) // 10 + + def get_monitor_scale(self, monitor_id: int) -> float: + """ + Get scale factor for a monitor. + + Args: + monitor_id: Monitor ID + + Returns: + Scale factor (default 1.0 if not found) + """ + monitor = self.get_monitor_by_id(monitor_id) + return monitor.get('scale', 1.0) if monitor else 1.0 + + def is_notch_open(self, monitor_id: int) -> bool: + """Check if notch is open on a monitor.""" + return self._notch_states.get(monitor_id, False) + + def set_notch_state(self, monitor_id: int, is_open: bool, module: Optional[str] = None): + """Set notch state for a monitor.""" + self._notch_states[monitor_id] = is_open + self._current_notch_module[monitor_id] = module if is_open else None + + def get_current_notch_module(self, monitor_id: int) -> Optional[str]: + """Get current notch module for a monitor.""" + return self._current_notch_module.get(monitor_id) + + def close_all_notches_except(self, except_monitor_id: int): + """Close all notches except on specified monitor.""" + for monitor_id in self._notch_states: + if monitor_id != except_monitor_id and self._notch_states[monitor_id]: + self.set_notch_state(monitor_id, False) + # Get notch instance and close it + instances = self._monitor_instances.get(monitor_id, {}) + notch = instances.get('notch') + if notch and hasattr(notch, 'close_notch'): + notch.close_notch() + + def register_monitor_instances(self, monitor_id: int, instances: Dict): + """ + Register component instances for a monitor. + + Args: + monitor_id: Monitor ID + instances: Dict with 'bar', 'notch', 'dock', 'corners' keys + """ + self._monitor_instances[monitor_id] = instances + + def get_monitor_instances(self, monitor_id: int) -> Dict: + """Get component instances for a monitor.""" + return self._monitor_instances.get(monitor_id, {}) + + def get_instance(self, monitor_id: int, component: str): + """Get specific component instance for a monitor.""" + instances = self._monitor_instances.get(monitor_id, {}) + return instances.get(component) + + def get_focused_instance(self, component: str): + """Get component instance from focused monitor.""" + return self.get_instance(self._focused_monitor_id, component) + + def _on_monitor_focused(self, monitor_name: str, monitor_id: int, workspace_id: int): + """Handle monitor focus change.""" + old_focused = self._focused_monitor_id + self._focused_monitor_id = monitor_id + + # Handle notch focus switching + if old_focused != monitor_id: + self._handle_notch_focus_switch(old_focused, monitor_id) + + def _handle_notch_focus_switch(self, old_monitor: int, new_monitor: int): + """Handle notch switching between monitors.""" + # Close notch on old monitor if open + if self.is_notch_open(old_monitor): + old_module = self.get_current_notch_module(old_monitor) + self.close_all_notches_except(-1) # Close all + + # Open notch on new monitor with same module + if old_module: + new_instances = self.get_monitor_instances(new_monitor) + notch = new_instances.get('notch') + if notch and hasattr(notch, 'open_module'): + notch.open_module(old_module) + + self.notch_focus_changed.emit(old_monitor, new_monitor) + + +# Singleton accessor +_monitor_manager_instance = None + +def get_monitor_manager() -> MonitorManager: + """Get the global MonitorManager instance.""" + global _monitor_manager_instance + if _monitor_manager_instance is None: + _monitor_manager_instance = MonitorManager() + return _monitor_manager_instance \ No newline at end of file diff --git a/Ax-Shell/utils/occlusion.py b/Ax-Shell/utils/occlusion.py new file mode 100644 index 0000000..9c3a9f5 --- /dev/null +++ b/Ax-Shell/utils/occlusion.py @@ -0,0 +1,146 @@ +import subprocess +import json + +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 " + # 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 + + # Get screen dimensions for fullscreen check + screen_width, screen_height = get_screen_dimensions() + + 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 fullscreen windows (size matches screen and positioned at 0,0) + if (width, height) == (screen_width, screen_height) and (x, y) == (0, 0): + # For fullscreen windows, check if occlusion region is the top area + if occ_y == 0 and occ_height > 0: # Top region + return True # Consider fullscreen as occluding the top + + # 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/Ax-Shell/version.json b/Ax-Shell/version.json new file mode 100644 index 0000000..854abd1 --- /dev/null +++ b/Ax-Shell/version.json @@ -0,0 +1,8 @@ +{ + "version": "0.0.62", + "pkg_update": false, + "changelog": [ + "feat: Real-time volume/mic display in notch (#311)", + "fix: Notch behavior on multi-monitor (#312)" + ] +} diff --git a/Ax-Shell/widgets/circle_image.py b/Ax-Shell/widgets/circle_image.py new file mode 100644 index 0000000..acfdc6d --- /dev/null +++ b/Ax-Shell/widgets/circle_image.py @@ -0,0 +1,118 @@ +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 + self._orig_image: GdkPixbuf.Pixbuf | None = None # Original image for reprocessing + 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/Ax-Shell/widgets/image.py b/Ax-Shell/widgets/image.py new file mode 100644 index 0000000..8096a6a --- /dev/null +++ b/Ax-Shell/widgets/image.py @@ -0,0 +1,38 @@ +import math +from typing import cast + +import cairo +from fabric.widgets.image import Image +from gi.repository import Gtk + + +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/Ax-Shell/widgets/shadertoy.py b/Ax-Shell/widgets/shadertoy.py new file mode 100644 index 0000000..af3ac93 --- /dev/null +++ b/Ax-Shell/widgets/shadertoy.py @@ -0,0 +1,367 @@ +from collections.abc import Iterable +from enum import Enum +from typing import Literal, cast, overload + +import gi +import OpenGL.GL as GL +from fabric import Application, Property, Signal +from fabric.widgets.widget import Widget +from OpenGL.GL.shaders import compileProgram, compileShader + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk + + +class ShadertoyUniformType(Enum): + # TODO: add more types + FLOAT = 1 + INTEGER = 2 + VECTOR = 3 + TEXTURE = 4 + + +class ShadertoyCompileError(Exception): ... + + +class Shadertoy(Gtk.GLArea, Widget): + @Signal # pygobject signal + def ready(self) -> None: ... + + @Property(str, "read-write") + def shader_buffer(self) -> str: + return self._shader_buffer + + @shader_buffer.setter + def shader_buffer(self, shader_buffer: str) -> None: + self._shader_buffer = shader_buffer + if not self._ready: + return + self._shader_uniforms.clear() + self.do_realize() + self.queue_draw() + return + + # signatures for building a replica of shadertoy + DEFAULT_VERTEX_SHADER = """ + #version 330 + + in vec2 position; + + void main() { + gl_Position = vec4(position, 0.0, 1.0); + } + """ + + DEFAULT_FRAGMENT_UNIFORMS = """ + #version 330 + + uniform vec3 iResolution; // viewport resolution (in pixels) + uniform float iTime; // shader playback time (in seconds) + uniform float iTimeDelta; // render time (in seconds) + uniform float iFrameRate; // shader frame rate + uniform int iFrame; // shader playback frame + uniform float iChannelTime[4]; // channel playback time (in seconds) + uniform vec3 iChannelResolution[4]; // channel resolution (in pixels) + uniform vec4 iMouse; // mouse pixel coords. xy: current (if MLB down), zw: click + uniform sampler2D iChannel0; // input channel. XX = 2D/Cube + uniform sampler2D iChannel1; + uniform sampler2D iChannel2; + uniform sampler2D iChannel3; + uniform vec4 iDate; // (year, month, day, time in seconds) + uniform float iSampleRate; // sound sample rate (i.e., 44100) + + """ + + FRAGMENT_MAIN_FUNCTION = """ + void main() { + mainImage(gl_FragColor, gl_FragCoord.xy); + } + """ + + def __init__( + self, + shader_buffer: str, + shader_uniforms: list[ + tuple[ + str, + ShadertoyUniformType, + bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf, + ] + ] + | 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, + ): + Gtk.GLArea.__init__( + self # type: ignore + ) + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self._shader_buffer = shader_buffer + self._shader_uniforms = shader_uniforms or [] + + # widget settings + self.set_required_version(3, 3) + self.set_has_depth_buffer(False) + self.set_has_stencil_buffer(False) + + self._ready = False + self._program = None + self._vao = None + self._quad_vbo = None + self._texture_units = {} + + # timer + self._start_time = GLib.get_monotonic_time() / 1e6 + self._frame_time = self._start_time + self._frame_count = 0 + + # to avoid a constant framerate we tell + # gtk to render a frame whenever possible + self._tick_id = self.add_tick_callback(lambda *_: (self.queue_draw(), True)[1]) + + def do_bake_program(self): + try: + vertex_shader = compileShader( + self.DEFAULT_VERTEX_SHADER, GL.GL_VERTEX_SHADER + ) + fragment_shader = compileShader( + self.DEFAULT_FRAGMENT_UNIFORMS + + self._shader_buffer + + self.FRAGMENT_MAIN_FUNCTION, + GL.GL_FRAGMENT_SHADER, + ) + except Exception as e: + raise ShadertoyCompileError( + f"couldn't compile the provided shader, OpenGL error:\n {e}" + ) + + return compileProgram(vertex_shader, fragment_shader) + + def do_realize(self, *_): + Gtk.GLArea.do_realize(self) + if not self._ready: + ctx = self.get_context() + if (err := self.get_error()) or not ctx: + raise RuntimeError( + f"couldn't initialize the drawing context, error: {err or 'context is None'}" + ) + + ctx.make_current() + + if self._program: + GL.glDeleteProgram(self._program) + self._program = None + self._program = self.do_bake_program() + + # NOTE: for this to work (alpha pixels) `self.set_has_alpha(True)` must be done + # this breaks some fragment shaders, for some reason, so i'm leaving it for anyone willing to use + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + + self._quad_vbo = GL.glGenBuffers(1) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._quad_vbo) + + # this is not so good, unless the introduction of numpy, we must do + # a hack to generate an array GL would accept, i've tried using + # the "array" python library but it doesn't seem to be working + + # cast python type into GL type (list[float] -> arraybuf[GLfloat]) + quad_verts = (-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0) + array_type = GL.GLfloat * len(quad_verts) + + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + len(quad_verts) * 4, + array_type(*quad_verts), + GL.GL_STATIC_DRAW, + ) + + self._vao = GL.glGenVertexArrays(1) + GL.glBindVertexArray(self._vao) + + position = GL.glGetAttribLocation(self._program, "position") + GL.glEnableVertexAttribArray(position) + GL.glVertexAttribPointer(position, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + + for uname, utype, uvalue in self._shader_uniforms: + self.set_uniform(uname, utype, uvalue) # type: ignore + + self._ready = True + self.ready() + return + + def do_get_timing(self) -> tuple[float, float, float]: + current_time = GLib.get_monotonic_time() / 1e6 + delta_time = current_time - self._frame_time + return current_time, delta_time, (1.0 / delta_time) if delta_time > 0 else 0.0 + + def do_post_render(self, time: float): + self._frame_time = time + self._frame_count += 1 + return + + def do_render(self, ctx: Gdk.GLContext): + if not self._program: + if self._tick_id: + self.remove_tick_callback(self._tick_id) + self._tick_id = 0 + return False + + GL.glUseProgram(self._program) + + # clear up for next frame + GL.glClear(GL.GL_COLOR_BUFFER_BIT) + + alloc = self.get_allocation() + width: int = alloc.width # type: ignore + height: int = alloc.height # type: ignore + mouse_pos = cast(tuple[int, int], self.get_pointer()) + + current_time, delta_time, frame_rate = self.do_get_timing() + + self.set_uniform( + "iTime", ShadertoyUniformType.FLOAT, current_time - self._start_time + ) + self.set_uniform("iFrame", ShadertoyUniformType.INTEGER, self._frame_count) + self.set_uniform("iTimeDelta", ShadertoyUniformType.FLOAT, delta_time) + self.set_uniform("iFrameRate", ShadertoyUniformType.FLOAT, frame_rate) + self.set_uniform( + "iResolution", ShadertoyUniformType.VECTOR, (width, height, 1.0) + ) + self.set_uniform( + "iMouse", + ShadertoyUniformType.VECTOR, + (mouse_pos[0], height - mouse_pos[1], 0, 0), + ) + + # paint the quad + GL.glBindVertexArray(self._vao) + GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4) + self.do_post_render(current_time) + return True + + def do_resize(self, width: int, height: int): + Gtk.GLArea.do_resize(self, width, height) + GL.glViewport(0, 0, width, height) + return + + @overload + def set_uniform( + self, name: str, type: Literal[ShadertoyUniformType.FLOAT], value: float + ): ... + + @overload + def set_uniform( + self, name: str, type: Literal[ShadertoyUniformType.INTEGER], value: int + ): ... + + @overload + def set_uniform( + self, + name: str, + type: Literal[ShadertoyUniformType.VECTOR], + value: tuple[float, ...], + ): ... + + @overload + def set_uniform( + self, + name: str, + type: Literal[ShadertoyUniformType.TEXTURE], + value: GdkPixbuf.Pixbuf, + ): ... + + def set_uniform( + self, + name: str, + type: ShadertoyUniformType, + value: bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf, + ): + if not self._program: + raise RuntimeError("the shader program is not initialized") + GL.glUseProgram(self._program) + location = GL.glGetUniformLocation(self._program, name) + match type: + case ShadertoyUniformType.VECTOR: + value = cast(tuple[float, ...], value) + ( + GL.glUniform2f + if (vlen := len(value)) == 2 + else GL.glUniform3f + if vlen == 3 + else GL.glUniform4f + )(location, *value) + case ShadertoyUniformType.FLOAT: + GL.glUniform1f(location, value) + case ShadertoyUniformType.INTEGER: + GL.glUniform1i(location, value) + case ShadertoyUniformType.TEXTURE: + # who dislikes boilerplate? + value = cast(GdkPixbuf.Pixbuf, value).flip(False) + format = GL.GL_RGBA if value.get_has_alpha() else GL.GL_RGB + + if name not in self._texture_units: + texture = GL.glGenTextures(1) + self._texture_units[name] = (len(self._texture_units), texture) + else: + texture_unit, texture = self._texture_units[name] + + texture_unit = self._texture_units[name][0] + GL.glActiveTexture(GL.GL_TEXTURE0 + texture_unit) + GL.glBindTexture(GL.GL_TEXTURE_2D, texture) + + GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_REPEAT) + GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_REPEAT) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR + ) + + # "upload" the texture + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + 0, # detail level (woah?) + format, # result format + value.get_width(), + value.get_height(), + 0, # "border" + format, # input format + GL.GL_UNSIGNED_BYTE, + value.get_pixels(), + ) + GL.glGenerateMipmap(GL.GL_TEXTURE_2D) + + # all aboard... + GL.glUniform1i(location, texture_unit) diff --git a/Ax-Shell/widgets/wayland.py b/Ax-Shell/widgets/wayland.py new file mode 100644 index 0000000..0dcbe51 --- /dev/null +++ b/Ax-Shell/widgets/wayland.py @@ -0,0 +1,359 @@ +from gi.repository import Gdk, GObject, Gtk +import re +from collections.abc import Iterable +from enum import Enum +from typing import Literal, cast + +import cairo +import gi +from fabric.core.service import Property +from fabric.utils.helpers import extract_css_values, get_enum_member +from fabric.widgets.window import Window +from loguru import logger + +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], + }