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/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],
+ }