[
  {
    "path": ".gitignore",
    "content": "\n**/__pycache__/\n*.pyc\n*.pyo\n*.pyd\n# config/assets/config.json\nconfig/assets/dock.json\nconfig/assets/accounts.json\nconfig/assets/passwords.json\nconfig/assets/bookmarks.json\n# config/hypr/modus.conf\nconfig/hypr/colors.conf\nfabric/**\nfabric/\n# styles/colors.css\n.aider*\n.venv/*\n.venv/\n\nAGENTS.md\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <img src=\"assets/modus.png\" height=\"200\" alt=\"Logo\">\n</p>\n\n<p align=\"center\">\n  <sub><sup><img src=\"https://raw.githubusercontent.com/Tarikul-Islam-Anik/Telegram-Animated-Emojis/main/Activity/Sparkles.webp\" alt=\"Sparkles\" width=\"25\" height=\"25\"/></sup></sub>\n  <a href=\"https://github.com/hyprwm/Hyprland\">\n    <img src=\"https://img.shields.io/badge/A%20hackable%20shell%20for-Hyprland-0092CD?style=for-the-badge&logo=linux&color=0092CD&logoColor=D9E0EE&labelColor=000000\" alt=\"A hackable shell for Hyprland\">\n  </a>\n  <a href=\"https://github.com/Fabric-Development/fabric/\">\n    <img src=\"https://img.shields.io/badge/Powered%20by-Fabric-FAFAFA?style=for-the-badge&logo=python&color=FAFAFA&logoColor=D9E0EE&labelColor=000000\" alt=\"Powered by Fabric\">\n  <sub><sup><img src=\"https://raw.githubusercontent.com/Tarikul-Islam-Anik/Telegram-Animated-Emojis/main/Activity/Sparkles.webp\" alt=\"Sparkles\" width=\"25\" height=\"25\"/></sup></sub>\n  </a>\n  </p>\n\n<div align=\"center\">\n\n[![GitHub stars](https://img.shields.io/github/stars/S4NKALP/Modus?style=for-the-badge&logo=github&color=FFB686&logoColor=D9E0EE&labelColor=292324)](https://github.com/S4NKALP/Modus/stargazers)\n[![Hyprland](https://img.shields.io/badge/Made%20for-Hyprland-pink?style=for-the-badge&logo=linux&logoColor=D9E0EE&labelColor=292324&color=C6A0F6)](https://hyprland.org/)\n[![Maintained](https://img.shields.io/badge/Maintained-Yes-blue?style=for-the-badge&logo=linux&logoColor=D9E0EE&labelColor=292324&color=3362E1)]()\n[![Discord](https://dcbadge.limes.pink/api/server/https://discord.gg/tRFxkbQ3Zq)](https://discord.gg/tRFxkbQ3Zq)\n\n</div>\n\n<br>\n\n<figure>\n  <h2>Home Screen:</h2>\n  <img src=\"assets/screenshots/home.png\" alt=\"fabric\">\n  <br/>\n  <h2>Lock Screen:</h2>\n    <img src=\"assets/screenshots/lock.png\" alt=\"fabric\">\n</figure>\n<br>\n\n\n## Installation \n\n> [!CAUTION]\n> - You need a working installation of hyprland and knowledge of how it works\n> - There may not be all packages in your system install them accordingly\n\n```bash\ngit clone https://github.com/S4NKALP/Modus ~/.config/Modus\ncd ~/.config/Modus\n./install.sh\n```\n\n> [!TIP]\n> ## Post Installation\n> - Install recommended [Icon theme](https://github.com/vinceliuice/MacTahoe-icon-theme) , [GTK theme](https://github.com/vinceliuice/MacTahoe-gtk-theme) and [Cursor Theme](https://github.com/vinceliuice/MacTahoe-icon-theme/tree/main/cursors) <br>\n> - Check `config/hypr/modus.conf` edit it according to your device and copy it to your hyprland config\n> - For Lock Screen Bind keys to `python lock.py`\n\n<h2><sub><img src=\"https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Travel%20and%20places/Rocket.png\" alt=\"Rocket\" width=\"25\" height=\"25\" /></sub> Todo</h2>\n\n## Manual Installation (WIP)\n\n```bash\nparu -S glace-git gtk-session-lock python-pyotp python-pillow python-ijson python-setproctitle apple-fonts cinnamon-desktop --needed\ngit clone https://github.com/S4NKALP/Modus ~/.config/Modus\ncd ~/.config/Modus\npython -m venv .venv\nsource .venv/bin/activate\npip install -r requirements.txt\npip install --no-deps git+https://github.com/Fabric-Development/fabric.git\n```\n\n- [x] Launcher\n- [x] Lock Screen\n- [x] Dock\n- [x] Notification\n- [x] Control Center\n- [x] Music Player\n- [x] Desktop Widgets\n- [x] New Launcher (like Spotlight)\n- [ ] Settings\n- [x] ~~Magnifier hover effect on Dock~~\n- [x] ~~New Application Switcher~~\n- [x] Panel Widget\n- [x] MacOS like Widget\n- [x] Expandable Notification Centre\n- [ ] Installation Script\n- [ ] Proper Documentation\n- [ ] Pomodoro Timer Widget\n- [x] To-do List Widget\n\n## Bug Fixes (the bug found till now)\n\n- [x] WiFi\n- [x] wifi off button looks bigger\n- [x] Metadata Changes delay in Media Player\n- [x] Active Window Title showing `Unknown` when no active window\n- [x] Notification Escape Char Issue\n\n## Team\n\n- [SANKALP](https://github.com/S4NKALP/)\n- [tr1x_em](https://github.com/tr1xem)\n\n## Special Thanks\n\nA big thank you to the following people for their incredible help with code and creative ideas. Your help made a real difference!\n\n- [darsh](https://github.com/its-darsh): for creating Fabric, which made everything possible.\n- [gummy bear album](https://github.com/muhchaudhary): for sharing fantastic code snippets that saved me time and effort.\n- [axenide](https://github.com/Axenide): for the amazing config that not only inspired parts of mine but also provided some gems I couldn’t resist borrowing.\n- [E3nviction](https://github.com/E3nviction/): for code snippets and ideas that were incredibly helpful.\n\nI truly appreciate your support\n"
  },
  {
    "path": "config/assets/config.json",
    "content": "{\n  \"wallpapers_dir\": \"~/Pictures/Wallpapers/\",\n  \"dock_position\": \"Bottom\",\n  \"terminal_command\": \"kitty -e\",\n  \"dock_enabled\": true,\n  \"dock_auto_hide\": true,\n  \"dock_always_occluded\": false,\n  \"dock_icon_size\": 52,\n  \"window_switcher_items_per_row\": 10,\n  \"hide_special_workspace\": true,\n  \"dock_hide_special_workspace_apps\": true,\n  \"dock_preview_apps\": false,\n  \"notification_timeout\": \"5s\",\n  \"notification_ignored_apps\": [\"Hyprshot\"],\n  \"notification_limited_apps_history\": [\"Spotify\"]\n}\n"
  },
  {
    "path": "config/assets/emoji.json",
    "content": "{\n  \"😀\": {\n    \"name\": \"grinning face\",\n    \"slug\": \"grinning_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😃\": {\n    \"name\": \"grinning face with big eyes\",\n    \"slug\": \"grinning_face_with_big_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😄\": {\n    \"name\": \"grinning face with smiling eyes\",\n    \"slug\": \"grinning_face_with_smiling_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😁\": {\n    \"name\": \"beaming face with smiling eyes\",\n    \"slug\": \"beaming_face_with_smiling_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😆\": {\n    \"name\": \"grinning squinting face\",\n    \"slug\": \"grinning_squinting_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😅\": {\n    \"name\": \"grinning face with sweat\",\n    \"slug\": \"grinning_face_with_sweat\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤣\": {\n    \"name\": \"rolling on the floor laughing\",\n    \"slug\": \"rolling_on_the_floor_laughing\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"😂\": {\n    \"name\": \"face with tears of joy\",\n    \"slug\": \"face_with_tears_of_joy\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🙂\": {\n    \"name\": \"slightly smiling face\",\n    \"slug\": \"slightly_smiling_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🙃\": {\n    \"name\": \"upside-down face\",\n    \"slug\": \"upside_down_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫠\": {\n    \"name\": \"melting face\",\n    \"slug\": \"melting_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"😉\": {\n    \"name\": \"winking face\",\n    \"slug\": \"winking_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😊\": {\n    \"name\": \"smiling face with smiling eyes\",\n    \"slug\": \"smiling_face_with_smiling_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😇\": {\n    \"name\": \"smiling face with halo\",\n    \"slug\": \"smiling_face_with_halo\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥰\": {\n    \"name\": \"smiling face with hearts\",\n    \"slug\": \"smiling_face_with_hearts\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"😍\": {\n    \"name\": \"smiling face with heart-eyes\",\n    \"slug\": \"smiling_face_with_heart_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤩\": {\n    \"name\": \"star-struck\",\n    \"slug\": \"star_struck\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"😘\": {\n    \"name\": \"face blowing a kiss\",\n    \"slug\": \"face_blowing_a_kiss\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😗\": {\n    \"name\": \"kissing face\",\n    \"slug\": \"kissing_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"☺️\": {\n    \"name\": \"smiling face\",\n    \"slug\": \"smiling_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😚\": {\n    \"name\": \"kissing face with closed eyes\",\n    \"slug\": \"kissing_face_with_closed_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😙\": {\n    \"name\": \"kissing face with smiling eyes\",\n    \"slug\": \"kissing_face_with_smiling_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥲\": {\n    \"name\": \"smiling face with tear\",\n    \"slug\": \"smiling_face_with_tear\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"😋\": {\n    \"name\": \"face savoring food\",\n    \"slug\": \"face_savoring_food\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😛\": {\n    \"name\": \"face with tongue\",\n    \"slug\": \"face_with_tongue\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😜\": {\n    \"name\": \"winking face with tongue\",\n    \"slug\": \"winking_face_with_tongue\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤪\": {\n    \"name\": \"zany face\",\n    \"slug\": \"zany_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"😝\": {\n    \"name\": \"squinting face with tongue\",\n    \"slug\": \"squinting_face_with_tongue\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤑\": {\n    \"name\": \"money-mouth face\",\n    \"slug\": \"money_mouth_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤗\": {\n    \"name\": \"smiling face with open hands\",\n    \"slug\": \"smiling_face_with_open_hands\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤭\": {\n    \"name\": \"face with hand over mouth\",\n    \"slug\": \"face_with_hand_over_mouth\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫢\": {\n    \"name\": \"face with open eyes and hand over mouth\",\n    \"slug\": \"face_with_open_eyes_and_hand_over_mouth\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫣\": {\n    \"name\": \"face with peeking eye\",\n    \"slug\": \"face_with_peeking_eye\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤫\": {\n    \"name\": \"shushing face\",\n    \"slug\": \"shushing_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤔\": {\n    \"name\": \"thinking face\",\n    \"slug\": \"thinking_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫡\": {\n    \"name\": \"saluting face\",\n    \"slug\": \"saluting_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤐\": {\n    \"name\": \"zipper-mouth face\",\n    \"slug\": \"zipper_mouth_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤨\": {\n    \"name\": \"face with raised eyebrow\",\n    \"slug\": \"face_with_raised_eyebrow\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"😐\": {\n    \"name\": \"neutral face\",\n    \"slug\": \"neutral_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"😑\": {\n    \"name\": \"expressionless face\",\n    \"slug\": \"expressionless_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😶\": {\n    \"name\": \"face without mouth\",\n    \"slug\": \"face_without_mouth\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫥\": {\n    \"name\": \"dotted line face\",\n    \"slug\": \"dotted_line_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"😶‍🌫️\": {\n    \"name\": \"face in clouds\",\n    \"slug\": \"face_in_clouds\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"13.1\",\n    \"unicode_version\": \"13.1\",\n    \"skin_tone_support\": false\n  },\n  \"😏\": {\n    \"name\": \"smirking face\",\n    \"slug\": \"smirking_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😒\": {\n    \"name\": \"unamused face\",\n    \"slug\": \"unamused_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🙄\": {\n    \"name\": \"face with rolling eyes\",\n    \"slug\": \"face_with_rolling_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😬\": {\n    \"name\": \"grimacing face\",\n    \"slug\": \"grimacing_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😮‍💨\": {\n    \"name\": \"face exhaling\",\n    \"slug\": \"face_exhaling\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"13.1\",\n    \"unicode_version\": \"13.1\",\n    \"skin_tone_support\": false\n  },\n  \"🤥\": {\n    \"name\": \"lying face\",\n    \"slug\": \"lying_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫨\": {\n    \"name\": \"shaking face\",\n    \"slug\": \"shaking_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🙂‍↔️\": {\n    \"name\": \"head shaking horizontally\",\n    \"slug\": \"head_shaking_horizontally\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"🙂‍↕️\": {\n    \"name\": \"head shaking vertically\",\n    \"slug\": \"head_shaking_vertically\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"😌\": {\n    \"name\": \"relieved face\",\n    \"slug\": \"relieved_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😔\": {\n    \"name\": \"pensive face\",\n    \"slug\": \"pensive_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😪\": {\n    \"name\": \"sleepy face\",\n    \"slug\": \"sleepy_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤤\": {\n    \"name\": \"drooling face\",\n    \"slug\": \"drooling_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"😴\": {\n    \"name\": \"sleeping face\",\n    \"slug\": \"sleeping_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😷\": {\n    \"name\": \"face with medical mask\",\n    \"slug\": \"face_with_medical_mask\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤒\": {\n    \"name\": \"face with thermometer\",\n    \"slug\": \"face_with_thermometer\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤕\": {\n    \"name\": \"face with head-bandage\",\n    \"slug\": \"face_with_head_bandage\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤢\": {\n    \"name\": \"nauseated face\",\n    \"slug\": \"nauseated_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤮\": {\n    \"name\": \"face vomiting\",\n    \"slug\": \"face_vomiting\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤧\": {\n    \"name\": \"sneezing face\",\n    \"slug\": \"sneezing_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥵\": {\n    \"name\": \"hot face\",\n    \"slug\": \"hot_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥶\": {\n    \"name\": \"cold face\",\n    \"slug\": \"cold_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥴\": {\n    \"name\": \"woozy face\",\n    \"slug\": \"woozy_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"😵\": {\n    \"name\": \"face with crossed-out eyes\",\n    \"slug\": \"face_with_crossed_out_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😵‍💫\": {\n    \"name\": \"face with spiral eyes\",\n    \"slug\": \"face_with_spiral_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"13.1\",\n    \"unicode_version\": \"13.1\",\n    \"skin_tone_support\": false\n  },\n  \"🤯\": {\n    \"name\": \"exploding head\",\n    \"slug\": \"exploding_head\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤠\": {\n    \"name\": \"cowboy hat face\",\n    \"slug\": \"cowboy_hat_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥳\": {\n    \"name\": \"partying face\",\n    \"slug\": \"partying_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥸\": {\n    \"name\": \"disguised face\",\n    \"slug\": \"disguised_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"😎\": {\n    \"name\": \"smiling face with sunglasses\",\n    \"slug\": \"smiling_face_with_sunglasses\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤓\": {\n    \"name\": \"nerd face\",\n    \"slug\": \"nerd_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧐\": {\n    \"name\": \"face with monocle\",\n    \"slug\": \"face_with_monocle\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"😕\": {\n    \"name\": \"confused face\",\n    \"slug\": \"confused_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫤\": {\n    \"name\": \"face with diagonal mouth\",\n    \"slug\": \"face_with_diagonal_mouth\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"😟\": {\n    \"name\": \"worried face\",\n    \"slug\": \"worried_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🙁\": {\n    \"name\": \"slightly frowning face\",\n    \"slug\": \"slightly_frowning_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"☹️\": {\n    \"name\": \"frowning face\",\n    \"slug\": \"frowning_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"😮\": {\n    \"name\": \"face with open mouth\",\n    \"slug\": \"face_with_open_mouth\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😯\": {\n    \"name\": \"hushed face\",\n    \"slug\": \"hushed_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😲\": {\n    \"name\": \"astonished face\",\n    \"slug\": \"astonished_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😳\": {\n    \"name\": \"flushed face\",\n    \"slug\": \"flushed_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥺\": {\n    \"name\": \"pleading face\",\n    \"slug\": \"pleading_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥹\": {\n    \"name\": \"face holding back tears\",\n    \"slug\": \"face_holding_back_tears\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"😦\": {\n    \"name\": \"frowning face with open mouth\",\n    \"slug\": \"frowning_face_with_open_mouth\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😧\": {\n    \"name\": \"anguished face\",\n    \"slug\": \"anguished_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😨\": {\n    \"name\": \"fearful face\",\n    \"slug\": \"fearful_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😰\": {\n    \"name\": \"anxious face with sweat\",\n    \"slug\": \"anxious_face_with_sweat\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😥\": {\n    \"name\": \"sad but relieved face\",\n    \"slug\": \"sad_but_relieved_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😢\": {\n    \"name\": \"crying face\",\n    \"slug\": \"crying_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😭\": {\n    \"name\": \"loudly crying face\",\n    \"slug\": \"loudly_crying_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😱\": {\n    \"name\": \"face screaming in fear\",\n    \"slug\": \"face_screaming_in_fear\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😖\": {\n    \"name\": \"confounded face\",\n    \"slug\": \"confounded_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😣\": {\n    \"name\": \"persevering face\",\n    \"slug\": \"persevering_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😞\": {\n    \"name\": \"disappointed face\",\n    \"slug\": \"disappointed_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😓\": {\n    \"name\": \"downcast face with sweat\",\n    \"slug\": \"downcast_face_with_sweat\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😩\": {\n    \"name\": \"weary face\",\n    \"slug\": \"weary_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😫\": {\n    \"name\": \"tired face\",\n    \"slug\": \"tired_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥱\": {\n    \"name\": \"yawning face\",\n    \"slug\": \"yawning_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"😤\": {\n    \"name\": \"face with steam from nose\",\n    \"slug\": \"face_with_steam_from_nose\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😡\": {\n    \"name\": \"enraged face\",\n    \"slug\": \"enraged_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😠\": {\n    \"name\": \"angry face\",\n    \"slug\": \"angry_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤬\": {\n    \"name\": \"face with symbols on mouth\",\n    \"slug\": \"face_with_symbols_on_mouth\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"😈\": {\n    \"name\": \"smiling face with horns\",\n    \"slug\": \"smiling_face_with_horns\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"👿\": {\n    \"name\": \"angry face with horns\",\n    \"slug\": \"angry_face_with_horns\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💀\": {\n    \"name\": \"skull\",\n    \"slug\": \"skull\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"☠️\": {\n    \"name\": \"skull and crossbones\",\n    \"slug\": \"skull_and_crossbones\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"💩\": {\n    \"name\": \"pile of poo\",\n    \"slug\": \"pile_of_poo\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤡\": {\n    \"name\": \"clown face\",\n    \"slug\": \"clown_face\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"👹\": {\n    \"name\": \"ogre\",\n    \"slug\": \"ogre\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👺\": {\n    \"name\": \"goblin\",\n    \"slug\": \"goblin\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👻\": {\n    \"name\": \"ghost\",\n    \"slug\": \"ghost\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👽\": {\n    \"name\": \"alien\",\n    \"slug\": \"alien\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👾\": {\n    \"name\": \"alien monster\",\n    \"slug\": \"alien_monster\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤖\": {\n    \"name\": \"robot\",\n    \"slug\": \"robot\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"😺\": {\n    \"name\": \"grinning cat\",\n    \"slug\": \"grinning_cat\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😸\": {\n    \"name\": \"grinning cat with smiling eyes\",\n    \"slug\": \"grinning_cat_with_smiling_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😹\": {\n    \"name\": \"cat with tears of joy\",\n    \"slug\": \"cat_with_tears_of_joy\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😻\": {\n    \"name\": \"smiling cat with heart-eyes\",\n    \"slug\": \"smiling_cat_with_heart_eyes\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😼\": {\n    \"name\": \"cat with wry smile\",\n    \"slug\": \"cat_with_wry_smile\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😽\": {\n    \"name\": \"kissing cat\",\n    \"slug\": \"kissing_cat\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🙀\": {\n    \"name\": \"weary cat\",\n    \"slug\": \"weary_cat\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😿\": {\n    \"name\": \"crying cat\",\n    \"slug\": \"crying_cat\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"😾\": {\n    \"name\": \"pouting cat\",\n    \"slug\": \"pouting_cat\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🙈\": {\n    \"name\": \"see-no-evil monkey\",\n    \"slug\": \"see_no_evil_monkey\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🙉\": {\n    \"name\": \"hear-no-evil monkey\",\n    \"slug\": \"hear_no_evil_monkey\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🙊\": {\n    \"name\": \"speak-no-evil monkey\",\n    \"slug\": \"speak_no_evil_monkey\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💌\": {\n    \"name\": \"love letter\",\n    \"slug\": \"love_letter\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💘\": {\n    \"name\": \"heart with arrow\",\n    \"slug\": \"heart_with_arrow\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💝\": {\n    \"name\": \"heart with ribbon\",\n    \"slug\": \"heart_with_ribbon\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💖\": {\n    \"name\": \"sparkling heart\",\n    \"slug\": \"sparkling_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💗\": {\n    \"name\": \"growing heart\",\n    \"slug\": \"growing_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💓\": {\n    \"name\": \"beating heart\",\n    \"slug\": \"beating_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💞\": {\n    \"name\": \"revolving hearts\",\n    \"slug\": \"revolving_hearts\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💕\": {\n    \"name\": \"two hearts\",\n    \"slug\": \"two_hearts\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💟\": {\n    \"name\": \"heart decoration\",\n    \"slug\": \"heart_decoration\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❣️\": {\n    \"name\": \"heart exclamation\",\n    \"slug\": \"heart_exclamation\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"💔\": {\n    \"name\": \"broken heart\",\n    \"slug\": \"broken_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❤️‍🔥\": {\n    \"name\": \"heart on fire\",\n    \"slug\": \"heart_on_fire\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"13.1\",\n    \"unicode_version\": \"13.1\",\n    \"skin_tone_support\": false\n  },\n  \"❤️‍🩹\": {\n    \"name\": \"mending heart\",\n    \"slug\": \"mending_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"13.1\",\n    \"unicode_version\": \"13.1\",\n    \"skin_tone_support\": false\n  },\n  \"❤️\": {\n    \"name\": \"red heart\",\n    \"slug\": \"red_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🩷\": {\n    \"name\": \"pink heart\",\n    \"slug\": \"pink_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧡\": {\n    \"name\": \"orange heart\",\n    \"slug\": \"orange_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"💛\": {\n    \"name\": \"yellow heart\",\n    \"slug\": \"yellow_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💚\": {\n    \"name\": \"green heart\",\n    \"slug\": \"green_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💙\": {\n    \"name\": \"blue heart\",\n    \"slug\": \"blue_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🩵\": {\n    \"name\": \"light blue heart\",\n    \"slug\": \"light_blue_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"💜\": {\n    \"name\": \"purple heart\",\n    \"slug\": \"purple_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤎\": {\n    \"name\": \"brown heart\",\n    \"slug\": \"brown_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🖤\": {\n    \"name\": \"black heart\",\n    \"slug\": \"black_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🩶\": {\n    \"name\": \"grey heart\",\n    \"slug\": \"grey_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤍\": {\n    \"name\": \"white heart\",\n    \"slug\": \"white_heart\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"💋\": {\n    \"name\": \"kiss mark\",\n    \"slug\": \"kiss_mark\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💯\": {\n    \"name\": \"hundred points\",\n    \"slug\": \"hundred_points\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💢\": {\n    \"name\": \"anger symbol\",\n    \"slug\": \"anger_symbol\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💥\": {\n    \"name\": \"collision\",\n    \"slug\": \"collision\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💫\": {\n    \"name\": \"dizzy\",\n    \"slug\": \"dizzy\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💦\": {\n    \"name\": \"sweat droplets\",\n    \"slug\": \"sweat_droplets\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💨\": {\n    \"name\": \"dashing away\",\n    \"slug\": \"dashing_away\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕳️\": {\n    \"name\": \"hole\",\n    \"slug\": \"hole\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"💬\": {\n    \"name\": \"speech balloon\",\n    \"slug\": \"speech_balloon\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👁️‍🗨️\": {\n    \"name\": \"eye in speech bubble\",\n    \"slug\": \"eye_in_speech_bubble\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🗨️\": {\n    \"name\": \"left speech bubble\",\n    \"slug\": \"left_speech_bubble\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🗯️\": {\n    \"name\": \"right anger bubble\",\n    \"slug\": \"right_anger_bubble\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"💭\": {\n    \"name\": \"thought balloon\",\n    \"slug\": \"thought_balloon\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"💤\": {\n    \"name\": \"ZZZ\",\n    \"slug\": \"zzz\",\n    \"group\": \"Smileys & Emotion\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👋\": {\n    \"name\": \"waving hand\",\n    \"slug\": \"waving_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🤚\": {\n    \"name\": \"raised back of hand\",\n    \"slug\": \"raised_back_of_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🖐️\": {\n    \"name\": \"hand with fingers splayed\",\n    \"slug\": \"hand_with_fingers_splayed\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"✋\": {\n    \"name\": \"raised hand\",\n    \"slug\": \"raised_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🖖\": {\n    \"name\": \"vulcan salute\",\n    \"slug\": \"vulcan_salute\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🫱\": {\n    \"name\": \"rightwards hand\",\n    \"slug\": \"rightwards_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"🫲\": {\n    \"name\": \"leftwards hand\",\n    \"slug\": \"leftwards_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"🫳\": {\n    \"name\": \"palm down hand\",\n    \"slug\": \"palm_down_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"🫴\": {\n    \"name\": \"palm up hand\",\n    \"slug\": \"palm_up_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"🫷\": {\n    \"name\": \"leftwards pushing hand\",\n    \"slug\": \"leftwards_pushing_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.0\"\n  },\n  \"🫸\": {\n    \"name\": \"rightwards pushing hand\",\n    \"slug\": \"rightwards_pushing_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.0\"\n  },\n  \"👌\": {\n    \"name\": \"OK hand\",\n    \"slug\": \"ok_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🤌\": {\n    \"name\": \"pinched fingers\",\n    \"slug\": \"pinched_fingers\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"🤏\": {\n    \"name\": \"pinching hand\",\n    \"slug\": \"pinching_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"✌️\": {\n    \"name\": \"victory hand\",\n    \"slug\": \"victory_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🤞\": {\n    \"name\": \"crossed fingers\",\n    \"slug\": \"crossed_fingers\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🫰\": {\n    \"name\": \"hand with index finger and thumb crossed\",\n    \"slug\": \"hand_with_index_finger_and_thumb_crossed\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"🤟\": {\n    \"name\": \"love-you gesture\",\n    \"slug\": \"love_you_gesture\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🤘\": {\n    \"name\": \"sign of the horns\",\n    \"slug\": \"sign_of_the_horns\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🤙\": {\n    \"name\": \"call me hand\",\n    \"slug\": \"call_me_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"👈\": {\n    \"name\": \"backhand index pointing left\",\n    \"slug\": \"backhand_index_pointing_left\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👉\": {\n    \"name\": \"backhand index pointing right\",\n    \"slug\": \"backhand_index_pointing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👆\": {\n    \"name\": \"backhand index pointing up\",\n    \"slug\": \"backhand_index_pointing_up\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🖕\": {\n    \"name\": \"middle finger\",\n    \"slug\": \"middle_finger\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👇\": {\n    \"name\": \"backhand index pointing down\",\n    \"slug\": \"backhand_index_pointing_down\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"☝️\": {\n    \"name\": \"index pointing up\",\n    \"slug\": \"index_pointing_up\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🫵\": {\n    \"name\": \"index pointing at the viewer\",\n    \"slug\": \"index_pointing_at_the_viewer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"👍\": {\n    \"name\": \"thumbs up\",\n    \"slug\": \"thumbs_up\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👎\": {\n    \"name\": \"thumbs down\",\n    \"slug\": \"thumbs_down\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"✊\": {\n    \"name\": \"raised fist\",\n    \"slug\": \"raised_fist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👊\": {\n    \"name\": \"oncoming fist\",\n    \"slug\": \"oncoming_fist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🤛\": {\n    \"name\": \"left-facing fist\",\n    \"slug\": \"left_facing_fist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🤜\": {\n    \"name\": \"right-facing fist\",\n    \"slug\": \"right_facing_fist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"👏\": {\n    \"name\": \"clapping hands\",\n    \"slug\": \"clapping_hands\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🙌\": {\n    \"name\": \"raising hands\",\n    \"slug\": \"raising_hands\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🫶\": {\n    \"name\": \"heart hands\",\n    \"slug\": \"heart_hands\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"👐\": {\n    \"name\": \"open hands\",\n    \"slug\": \"open_hands\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🤲\": {\n    \"name\": \"palms up together\",\n    \"slug\": \"palms_up_together\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🤝\": {\n    \"name\": \"handshake\",\n    \"slug\": \"handshake\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"🙏\": {\n    \"name\": \"folded hands\",\n    \"slug\": \"folded_hands\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"✍️\": {\n    \"name\": \"writing hand\",\n    \"slug\": \"writing_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"💅\": {\n    \"name\": \"nail polish\",\n    \"slug\": \"nail_polish\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🤳\": {\n    \"name\": \"selfie\",\n    \"slug\": \"selfie\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"💪\": {\n    \"name\": \"flexed biceps\",\n    \"slug\": \"flexed_biceps\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🦾\": {\n    \"name\": \"mechanical arm\",\n    \"slug\": \"mechanical_arm\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦿\": {\n    \"name\": \"mechanical leg\",\n    \"slug\": \"mechanical_leg\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦵\": {\n    \"name\": \"leg\",\n    \"slug\": \"leg\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🦶\": {\n    \"name\": \"foot\",\n    \"slug\": \"foot\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"👂\": {\n    \"name\": \"ear\",\n    \"slug\": \"ear\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🦻\": {\n    \"name\": \"ear with hearing aid\",\n    \"slug\": \"ear_with_hearing_aid\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👃\": {\n    \"name\": \"nose\",\n    \"slug\": \"nose\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🧠\": {\n    \"name\": \"brain\",\n    \"slug\": \"brain\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫀\": {\n    \"name\": \"anatomical heart\",\n    \"slug\": \"anatomical_heart\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫁\": {\n    \"name\": \"lungs\",\n    \"slug\": \"lungs\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦷\": {\n    \"name\": \"tooth\",\n    \"slug\": \"tooth\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦴\": {\n    \"name\": \"bone\",\n    \"slug\": \"bone\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"👀\": {\n    \"name\": \"eyes\",\n    \"slug\": \"eyes\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👁️\": {\n    \"name\": \"eye\",\n    \"slug\": \"eye\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"👅\": {\n    \"name\": \"tongue\",\n    \"slug\": \"tongue\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👄\": {\n    \"name\": \"mouth\",\n    \"slug\": \"mouth\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🫦\": {\n    \"name\": \"biting lip\",\n    \"slug\": \"biting_lip\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"👶\": {\n    \"name\": \"baby\",\n    \"slug\": \"baby\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🧒\": {\n    \"name\": \"child\",\n    \"slug\": \"child\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"👦\": {\n    \"name\": \"boy\",\n    \"slug\": \"boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👧\": {\n    \"name\": \"girl\",\n    \"slug\": \"girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🧑\": {\n    \"name\": \"person\",\n    \"slug\": \"person\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"👱\": {\n    \"name\": \"person blond hair\",\n    \"slug\": \"person_blond_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👨\": {\n    \"name\": \"man\",\n    \"slug\": \"man\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🧔\": {\n    \"name\": \"person beard\",\n    \"slug\": \"person_beard\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧔‍♂️\": {\n    \"name\": \"man beard\",\n    \"slug\": \"man_beard\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.1\",\n    \"unicode_version\": \"13.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"🧔‍♀️\": {\n    \"name\": \"woman beard\",\n    \"slug\": \"woman_beard\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.1\",\n    \"unicode_version\": \"13.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"👨‍🦰\": {\n    \"name\": \"man red hair\",\n    \"slug\": \"man_red_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"👨‍🦱\": {\n    \"name\": \"man curly hair\",\n    \"slug\": \"man_curly_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"👨‍🦳\": {\n    \"name\": \"man white hair\",\n    \"slug\": \"man_white_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"👨‍🦲\": {\n    \"name\": \"man bald\",\n    \"slug\": \"man_bald\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"👩\": {\n    \"name\": \"woman\",\n    \"slug\": \"woman\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👩‍🦰\": {\n    \"name\": \"woman red hair\",\n    \"slug\": \"woman_red_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🧑‍🦰\": {\n    \"name\": \"person red hair\",\n    \"slug\": \"person_red_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👩‍🦱\": {\n    \"name\": \"woman curly hair\",\n    \"slug\": \"woman_curly_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🧑‍🦱\": {\n    \"name\": \"person curly hair\",\n    \"slug\": \"person_curly_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👩‍🦳\": {\n    \"name\": \"woman white hair\",\n    \"slug\": \"woman_white_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🧑‍🦳\": {\n    \"name\": \"person white hair\",\n    \"slug\": \"person_white_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👩‍🦲\": {\n    \"name\": \"woman bald\",\n    \"slug\": \"woman_bald\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🧑‍🦲\": {\n    \"name\": \"person bald\",\n    \"slug\": \"person_bald\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👱‍♀️\": {\n    \"name\": \"woman blond hair\",\n    \"slug\": \"woman_blond_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👱‍♂️\": {\n    \"name\": \"man blond hair\",\n    \"slug\": \"man_blond_hair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧓\": {\n    \"name\": \"older person\",\n    \"slug\": \"older_person\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"👴\": {\n    \"name\": \"old man\",\n    \"slug\": \"old_man\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👵\": {\n    \"name\": \"old woman\",\n    \"slug\": \"old_woman\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🙍\": {\n    \"name\": \"person frowning\",\n    \"slug\": \"person_frowning\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🙍‍♂️\": {\n    \"name\": \"man frowning\",\n    \"slug\": \"man_frowning\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙍‍♀️\": {\n    \"name\": \"woman frowning\",\n    \"slug\": \"woman_frowning\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙎\": {\n    \"name\": \"person pouting\",\n    \"slug\": \"person_pouting\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🙎‍♂️\": {\n    \"name\": \"man pouting\",\n    \"slug\": \"man_pouting\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙎‍♀️\": {\n    \"name\": \"woman pouting\",\n    \"slug\": \"woman_pouting\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙅\": {\n    \"name\": \"person gesturing NO\",\n    \"slug\": \"person_gesturing_no\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🙅‍♂️\": {\n    \"name\": \"man gesturing NO\",\n    \"slug\": \"man_gesturing_no\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙅‍♀️\": {\n    \"name\": \"woman gesturing NO\",\n    \"slug\": \"woman_gesturing_no\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙆\": {\n    \"name\": \"person gesturing OK\",\n    \"slug\": \"person_gesturing_ok\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🙆‍♂️\": {\n    \"name\": \"man gesturing OK\",\n    \"slug\": \"man_gesturing_ok\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙆‍♀️\": {\n    \"name\": \"woman gesturing OK\",\n    \"slug\": \"woman_gesturing_ok\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"💁\": {\n    \"name\": \"person tipping hand\",\n    \"slug\": \"person_tipping_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"💁‍♂️\": {\n    \"name\": \"man tipping hand\",\n    \"slug\": \"man_tipping_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"💁‍♀️\": {\n    \"name\": \"woman tipping hand\",\n    \"slug\": \"woman_tipping_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙋\": {\n    \"name\": \"person raising hand\",\n    \"slug\": \"person_raising_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🙋‍♂️\": {\n    \"name\": \"man raising hand\",\n    \"slug\": \"man_raising_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙋‍♀️\": {\n    \"name\": \"woman raising hand\",\n    \"slug\": \"woman_raising_hand\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧏\": {\n    \"name\": \"deaf person\",\n    \"slug\": \"deaf_person\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"🧏‍♂️\": {\n    \"name\": \"deaf man\",\n    \"slug\": \"deaf_man\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"🧏‍♀️\": {\n    \"name\": \"deaf woman\",\n    \"slug\": \"deaf_woman\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"🙇\": {\n    \"name\": \"person bowing\",\n    \"slug\": \"person_bowing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🙇‍♂️\": {\n    \"name\": \"man bowing\",\n    \"slug\": \"man_bowing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🙇‍♀️\": {\n    \"name\": \"woman bowing\",\n    \"slug\": \"woman_bowing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤦\": {\n    \"name\": \"person facepalming\",\n    \"slug\": \"person_facepalming\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🤦‍♂️\": {\n    \"name\": \"man facepalming\",\n    \"slug\": \"man_facepalming\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤦‍♀️\": {\n    \"name\": \"woman facepalming\",\n    \"slug\": \"woman_facepalming\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤷\": {\n    \"name\": \"person shrugging\",\n    \"slug\": \"person_shrugging\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🤷‍♂️\": {\n    \"name\": \"man shrugging\",\n    \"slug\": \"man_shrugging\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤷‍♀️\": {\n    \"name\": \"woman shrugging\",\n    \"slug\": \"woman_shrugging\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍⚕️\": {\n    \"name\": \"health worker\",\n    \"slug\": \"health_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍⚕️\": {\n    \"name\": \"man health worker\",\n    \"slug\": \"man_health_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍⚕️\": {\n    \"name\": \"woman health worker\",\n    \"slug\": \"woman_health_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🎓\": {\n    \"name\": \"student\",\n    \"slug\": \"student\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🎓\": {\n    \"name\": \"man student\",\n    \"slug\": \"man_student\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🎓\": {\n    \"name\": \"woman student\",\n    \"slug\": \"woman_student\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🏫\": {\n    \"name\": \"teacher\",\n    \"slug\": \"teacher\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🏫\": {\n    \"name\": \"man teacher\",\n    \"slug\": \"man_teacher\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🏫\": {\n    \"name\": \"woman teacher\",\n    \"slug\": \"woman_teacher\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍⚖️\": {\n    \"name\": \"judge\",\n    \"slug\": \"judge\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍⚖️\": {\n    \"name\": \"man judge\",\n    \"slug\": \"man_judge\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍⚖️\": {\n    \"name\": \"woman judge\",\n    \"slug\": \"woman_judge\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🌾\": {\n    \"name\": \"farmer\",\n    \"slug\": \"farmer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🌾\": {\n    \"name\": \"man farmer\",\n    \"slug\": \"man_farmer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🌾\": {\n    \"name\": \"woman farmer\",\n    \"slug\": \"woman_farmer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🍳\": {\n    \"name\": \"cook\",\n    \"slug\": \"cook\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🍳\": {\n    \"name\": \"man cook\",\n    \"slug\": \"man_cook\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🍳\": {\n    \"name\": \"woman cook\",\n    \"slug\": \"woman_cook\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🔧\": {\n    \"name\": \"mechanic\",\n    \"slug\": \"mechanic\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🔧\": {\n    \"name\": \"man mechanic\",\n    \"slug\": \"man_mechanic\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🔧\": {\n    \"name\": \"woman mechanic\",\n    \"slug\": \"woman_mechanic\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🏭\": {\n    \"name\": \"factory worker\",\n    \"slug\": \"factory_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🏭\": {\n    \"name\": \"man factory worker\",\n    \"slug\": \"man_factory_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🏭\": {\n    \"name\": \"woman factory worker\",\n    \"slug\": \"woman_factory_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍💼\": {\n    \"name\": \"office worker\",\n    \"slug\": \"office_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍💼\": {\n    \"name\": \"man office worker\",\n    \"slug\": \"man_office_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍💼\": {\n    \"name\": \"woman office worker\",\n    \"slug\": \"woman_office_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🔬\": {\n    \"name\": \"scientist\",\n    \"slug\": \"scientist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🔬\": {\n    \"name\": \"man scientist\",\n    \"slug\": \"man_scientist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🔬\": {\n    \"name\": \"woman scientist\",\n    \"slug\": \"woman_scientist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍💻\": {\n    \"name\": \"technologist\",\n    \"slug\": \"technologist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍💻\": {\n    \"name\": \"man technologist\",\n    \"slug\": \"man_technologist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍💻\": {\n    \"name\": \"woman technologist\",\n    \"slug\": \"woman_technologist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🎤\": {\n    \"name\": \"singer\",\n    \"slug\": \"singer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🎤\": {\n    \"name\": \"man singer\",\n    \"slug\": \"man_singer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🎤\": {\n    \"name\": \"woman singer\",\n    \"slug\": \"woman_singer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🎨\": {\n    \"name\": \"artist\",\n    \"slug\": \"artist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🎨\": {\n    \"name\": \"man artist\",\n    \"slug\": \"man_artist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🎨\": {\n    \"name\": \"woman artist\",\n    \"slug\": \"woman_artist\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍✈️\": {\n    \"name\": \"pilot\",\n    \"slug\": \"pilot\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍✈️\": {\n    \"name\": \"man pilot\",\n    \"slug\": \"man_pilot\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍✈️\": {\n    \"name\": \"woman pilot\",\n    \"slug\": \"woman_pilot\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🚀\": {\n    \"name\": \"astronaut\",\n    \"slug\": \"astronaut\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🚀\": {\n    \"name\": \"man astronaut\",\n    \"slug\": \"man_astronaut\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🚀\": {\n    \"name\": \"woman astronaut\",\n    \"slug\": \"woman_astronaut\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🚒\": {\n    \"name\": \"firefighter\",\n    \"slug\": \"firefighter\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"👨‍🚒\": {\n    \"name\": \"man firefighter\",\n    \"slug\": \"man_firefighter\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👩‍🚒\": {\n    \"name\": \"woman firefighter\",\n    \"slug\": \"woman_firefighter\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👮\": {\n    \"name\": \"police officer\",\n    \"slug\": \"police_officer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👮‍♂️\": {\n    \"name\": \"man police officer\",\n    \"slug\": \"man_police_officer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👮‍♀️\": {\n    \"name\": \"woman police officer\",\n    \"slug\": \"woman_police_officer\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🕵️\": {\n    \"name\": \"detective\",\n    \"slug\": \"detective\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"2.0\"\n  },\n  \"🕵️‍♂️\": {\n    \"name\": \"man detective\",\n    \"slug\": \"man_detective\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🕵️‍♀️\": {\n    \"name\": \"woman detective\",\n    \"slug\": \"woman_detective\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"💂\": {\n    \"name\": \"guard\",\n    \"slug\": \"guard\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"💂‍♂️\": {\n    \"name\": \"man guard\",\n    \"slug\": \"man_guard\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"💂‍♀️\": {\n    \"name\": \"woman guard\",\n    \"slug\": \"woman_guard\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🥷\": {\n    \"name\": \"ninja\",\n    \"slug\": \"ninja\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"👷\": {\n    \"name\": \"construction worker\",\n    \"slug\": \"construction_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👷‍♂️\": {\n    \"name\": \"man construction worker\",\n    \"slug\": \"man_construction_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👷‍♀️\": {\n    \"name\": \"woman construction worker\",\n    \"slug\": \"woman_construction_worker\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🫅\": {\n    \"name\": \"person with crown\",\n    \"slug\": \"person_with_crown\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"🤴\": {\n    \"name\": \"prince\",\n    \"slug\": \"prince\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"👸\": {\n    \"name\": \"princess\",\n    \"slug\": \"princess\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👳\": {\n    \"name\": \"person wearing turban\",\n    \"slug\": \"person_wearing_turban\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👳‍♂️\": {\n    \"name\": \"man wearing turban\",\n    \"slug\": \"man_wearing_turban\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👳‍♀️\": {\n    \"name\": \"woman wearing turban\",\n    \"slug\": \"woman_wearing_turban\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👲\": {\n    \"name\": \"person with skullcap\",\n    \"slug\": \"person_with_skullcap\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🧕\": {\n    \"name\": \"woman with headscarf\",\n    \"slug\": \"woman_with_headscarf\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🤵\": {\n    \"name\": \"person in tuxedo\",\n    \"slug\": \"person_in_tuxedo\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🤵‍♂️\": {\n    \"name\": \"man in tuxedo\",\n    \"slug\": \"man_in_tuxedo\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"🤵‍♀️\": {\n    \"name\": \"woman in tuxedo\",\n    \"slug\": \"woman_in_tuxedo\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"👰\": {\n    \"name\": \"person with veil\",\n    \"slug\": \"person_with_veil\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"👰‍♂️\": {\n    \"name\": \"man with veil\",\n    \"slug\": \"man_with_veil\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"👰‍♀️\": {\n    \"name\": \"woman with veil\",\n    \"slug\": \"woman_with_veil\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"🤰\": {\n    \"name\": \"pregnant woman\",\n    \"slug\": \"pregnant_woman\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🫃\": {\n    \"name\": \"pregnant man\",\n    \"slug\": \"pregnant_man\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"🫄\": {\n    \"name\": \"pregnant person\",\n    \"slug\": \"pregnant_person\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"14.0\"\n  },\n  \"🤱\": {\n    \"name\": \"breast-feeding\",\n    \"slug\": \"breast_feeding\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"👩‍🍼\": {\n    \"name\": \"woman feeding baby\",\n    \"slug\": \"woman_feeding_baby\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"👨‍🍼\": {\n    \"name\": \"man feeding baby\",\n    \"slug\": \"man_feeding_baby\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"🧑‍🍼\": {\n    \"name\": \"person feeding baby\",\n    \"slug\": \"person_feeding_baby\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"👼\": {\n    \"name\": \"baby angel\",\n    \"slug\": \"baby_angel\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🎅\": {\n    \"name\": \"Santa Claus\",\n    \"slug\": \"santa_claus\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🤶\": {\n    \"name\": \"Mrs. Claus\",\n    \"slug\": \"mrs_claus\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🧑‍🎄\": {\n    \"name\": \"mx claus\",\n    \"slug\": \"mx_claus\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.0\"\n  },\n  \"🦸\": {\n    \"name\": \"superhero\",\n    \"slug\": \"superhero\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🦸‍♂️\": {\n    \"name\": \"man superhero\",\n    \"slug\": \"man_superhero\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🦸‍♀️\": {\n    \"name\": \"woman superhero\",\n    \"slug\": \"woman_superhero\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🦹\": {\n    \"name\": \"supervillain\",\n    \"slug\": \"supervillain\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🦹‍♂️\": {\n    \"name\": \"man supervillain\",\n    \"slug\": \"man_supervillain\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🦹‍♀️\": {\n    \"name\": \"woman supervillain\",\n    \"slug\": \"woman_supervillain\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"11.0\"\n  },\n  \"🧙\": {\n    \"name\": \"mage\",\n    \"slug\": \"mage\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧙‍♂️\": {\n    \"name\": \"man mage\",\n    \"slug\": \"man_mage\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧙‍♀️\": {\n    \"name\": \"woman mage\",\n    \"slug\": \"woman_mage\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧚\": {\n    \"name\": \"fairy\",\n    \"slug\": \"fairy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧚‍♂️\": {\n    \"name\": \"man fairy\",\n    \"slug\": \"man_fairy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧚‍♀️\": {\n    \"name\": \"woman fairy\",\n    \"slug\": \"woman_fairy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧛\": {\n    \"name\": \"vampire\",\n    \"slug\": \"vampire\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧛‍♂️\": {\n    \"name\": \"man vampire\",\n    \"slug\": \"man_vampire\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧛‍♀️\": {\n    \"name\": \"woman vampire\",\n    \"slug\": \"woman_vampire\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧜\": {\n    \"name\": \"merperson\",\n    \"slug\": \"merperson\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧜‍♂️\": {\n    \"name\": \"merman\",\n    \"slug\": \"merman\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧜‍♀️\": {\n    \"name\": \"mermaid\",\n    \"slug\": \"mermaid\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧝\": {\n    \"name\": \"elf\",\n    \"slug\": \"elf\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧝‍♂️\": {\n    \"name\": \"man elf\",\n    \"slug\": \"man_elf\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧝‍♀️\": {\n    \"name\": \"woman elf\",\n    \"slug\": \"woman_elf\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧞\": {\n    \"name\": \"genie\",\n    \"slug\": \"genie\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧞‍♂️\": {\n    \"name\": \"man genie\",\n    \"slug\": \"man_genie\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧞‍♀️\": {\n    \"name\": \"woman genie\",\n    \"slug\": \"woman_genie\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧟\": {\n    \"name\": \"zombie\",\n    \"slug\": \"zombie\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧟‍♂️\": {\n    \"name\": \"man zombie\",\n    \"slug\": \"man_zombie\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧟‍♀️\": {\n    \"name\": \"woman zombie\",\n    \"slug\": \"woman_zombie\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧌\": {\n    \"name\": \"troll\",\n    \"slug\": \"troll\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"💆\": {\n    \"name\": \"person getting massage\",\n    \"slug\": \"person_getting_massage\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"💆‍♂️\": {\n    \"name\": \"man getting massage\",\n    \"slug\": \"man_getting_massage\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"💆‍♀️\": {\n    \"name\": \"woman getting massage\",\n    \"slug\": \"woman_getting_massage\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"💇\": {\n    \"name\": \"person getting haircut\",\n    \"slug\": \"person_getting_haircut\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"💇‍♂️\": {\n    \"name\": \"man getting haircut\",\n    \"slug\": \"man_getting_haircut\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"💇‍♀️\": {\n    \"name\": \"woman getting haircut\",\n    \"slug\": \"woman_getting_haircut\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🚶\": {\n    \"name\": \"person walking\",\n    \"slug\": \"person_walking\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🚶‍♂️\": {\n    \"name\": \"man walking\",\n    \"slug\": \"man_walking\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🚶‍♀️\": {\n    \"name\": \"woman walking\",\n    \"slug\": \"woman_walking\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🚶‍➡️\": {\n    \"name\": \"person walking facing right\",\n    \"slug\": \"person_walking_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🚶‍♀️‍➡️\": {\n    \"name\": \"woman walking facing right\",\n    \"slug\": \"woman_walking_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🚶‍♂️‍➡️\": {\n    \"name\": \"man walking facing right\",\n    \"slug\": \"man_walking_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🧍\": {\n    \"name\": \"person standing\",\n    \"slug\": \"person_standing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"🧍‍♂️\": {\n    \"name\": \"man standing\",\n    \"slug\": \"man_standing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"🧍‍♀️\": {\n    \"name\": \"woman standing\",\n    \"slug\": \"woman_standing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"🧎\": {\n    \"name\": \"person kneeling\",\n    \"slug\": \"person_kneeling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"🧎‍♂️\": {\n    \"name\": \"man kneeling\",\n    \"slug\": \"man_kneeling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"🧎‍♀️\": {\n    \"name\": \"woman kneeling\",\n    \"slug\": \"woman_kneeling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"🧎‍➡️\": {\n    \"name\": \"person kneeling facing right\",\n    \"slug\": \"person_kneeling_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🧎‍♀️‍➡️\": {\n    \"name\": \"woman kneeling facing right\",\n    \"slug\": \"woman_kneeling_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🧎‍♂️‍➡️\": {\n    \"name\": \"man kneeling facing right\",\n    \"slug\": \"man_kneeling_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🧑‍🦯\": {\n    \"name\": \"person with white cane\",\n    \"slug\": \"person_with_white_cane\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"🧑‍🦯‍➡️\": {\n    \"name\": \"person with white cane facing right\",\n    \"slug\": \"person_with_white_cane_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"👨‍🦯\": {\n    \"name\": \"man with white cane\",\n    \"slug\": \"man_with_white_cane\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👨‍🦯‍➡️\": {\n    \"name\": \"man with white cane facing right\",\n    \"slug\": \"man_with_white_cane_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"👩‍🦯\": {\n    \"name\": \"woman with white cane\",\n    \"slug\": \"woman_with_white_cane\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👩‍🦯‍➡️\": {\n    \"name\": \"woman with white cane facing right\",\n    \"slug\": \"woman_with_white_cane_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🧑‍🦼\": {\n    \"name\": \"person in motorized wheelchair\",\n    \"slug\": \"person_in_motorized_wheelchair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"🧑‍🦼‍➡️\": {\n    \"name\": \"person in motorized wheelchair facing right\",\n    \"slug\": \"person_in_motorized_wheelchair_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"👨‍🦼\": {\n    \"name\": \"man in motorized wheelchair\",\n    \"slug\": \"man_in_motorized_wheelchair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👨‍🦼‍➡️\": {\n    \"name\": \"man in motorized wheelchair facing right\",\n    \"slug\": \"man_in_motorized_wheelchair_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"👩‍🦼\": {\n    \"name\": \"woman in motorized wheelchair\",\n    \"slug\": \"woman_in_motorized_wheelchair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👩‍🦼‍➡️\": {\n    \"name\": \"woman in motorized wheelchair facing right\",\n    \"slug\": \"woman_in_motorized_wheelchair_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🧑‍🦽\": {\n    \"name\": \"person in manual wheelchair\",\n    \"slug\": \"person_in_manual_wheelchair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.1\",\n    \"unicode_version\": \"12.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.1\"\n  },\n  \"🧑‍🦽‍➡️\": {\n    \"name\": \"person in manual wheelchair facing right\",\n    \"slug\": \"person_in_manual_wheelchair_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"👨‍🦽\": {\n    \"name\": \"man in manual wheelchair\",\n    \"slug\": \"man_in_manual_wheelchair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👨‍🦽‍➡️\": {\n    \"name\": \"man in manual wheelchair facing right\",\n    \"slug\": \"man_in_manual_wheelchair_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"👩‍🦽\": {\n    \"name\": \"woman in manual wheelchair\",\n    \"slug\": \"woman_in_manual_wheelchair\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👩‍🦽‍➡️\": {\n    \"name\": \"woman in manual wheelchair facing right\",\n    \"slug\": \"woman_in_manual_wheelchair_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🏃\": {\n    \"name\": \"person running\",\n    \"slug\": \"person_running\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🏃‍♂️\": {\n    \"name\": \"man running\",\n    \"slug\": \"man_running\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏃‍♀️\": {\n    \"name\": \"woman running\",\n    \"slug\": \"woman_running\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏃‍➡️\": {\n    \"name\": \"person running facing right\",\n    \"slug\": \"person_running_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🏃‍♀️‍➡️\": {\n    \"name\": \"woman running facing right\",\n    \"slug\": \"woman_running_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"🏃‍♂️‍➡️\": {\n    \"name\": \"man running facing right\",\n    \"slug\": \"man_running_facing_right\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"15.1\"\n  },\n  \"💃\": {\n    \"name\": \"woman dancing\",\n    \"slug\": \"woman_dancing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🕺\": {\n    \"name\": \"man dancing\",\n    \"slug\": \"man_dancing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🕴️\": {\n    \"name\": \"person in suit levitating\",\n    \"slug\": \"person_in_suit_levitating\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"👯\": {\n    \"name\": \"people with bunny ears\",\n    \"slug\": \"people_with_bunny_ears\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👯‍♂️\": {\n    \"name\": \"men with bunny ears\",\n    \"slug\": \"men_with_bunny_ears\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👯‍♀️\": {\n    \"name\": \"women with bunny ears\",\n    \"slug\": \"women_with_bunny_ears\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧖\": {\n    \"name\": \"person in steamy room\",\n    \"slug\": \"person_in_steamy_room\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧖‍♂️\": {\n    \"name\": \"man in steamy room\",\n    \"slug\": \"man_in_steamy_room\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧖‍♀️\": {\n    \"name\": \"woman in steamy room\",\n    \"slug\": \"woman_in_steamy_room\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧗\": {\n    \"name\": \"person climbing\",\n    \"slug\": \"person_climbing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧗‍♂️\": {\n    \"name\": \"man climbing\",\n    \"slug\": \"man_climbing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧗‍♀️\": {\n    \"name\": \"woman climbing\",\n    \"slug\": \"woman_climbing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🤺\": {\n    \"name\": \"person fencing\",\n    \"slug\": \"person_fencing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏇\": {\n    \"name\": \"horse racing\",\n    \"slug\": \"horse_racing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"⛷️\": {\n    \"name\": \"skier\",\n    \"slug\": \"skier\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏂\": {\n    \"name\": \"snowboarder\",\n    \"slug\": \"snowboarder\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🏌️\": {\n    \"name\": \"person golfing\",\n    \"slug\": \"person_golfing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏌️‍♂️\": {\n    \"name\": \"man golfing\",\n    \"slug\": \"man_golfing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏌️‍♀️\": {\n    \"name\": \"woman golfing\",\n    \"slug\": \"woman_golfing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏄\": {\n    \"name\": \"person surfing\",\n    \"slug\": \"person_surfing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🏄‍♂️\": {\n    \"name\": \"man surfing\",\n    \"slug\": \"man_surfing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏄‍♀️\": {\n    \"name\": \"woman surfing\",\n    \"slug\": \"woman_surfing\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🚣\": {\n    \"name\": \"person rowing boat\",\n    \"slug\": \"person_rowing_boat\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🚣‍♂️\": {\n    \"name\": \"man rowing boat\",\n    \"slug\": \"man_rowing_boat\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🚣‍♀️\": {\n    \"name\": \"woman rowing boat\",\n    \"slug\": \"woman_rowing_boat\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏊\": {\n    \"name\": \"person swimming\",\n    \"slug\": \"person_swimming\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🏊‍♂️\": {\n    \"name\": \"man swimming\",\n    \"slug\": \"man_swimming\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏊‍♀️\": {\n    \"name\": \"woman swimming\",\n    \"slug\": \"woman_swimming\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"⛹️\": {\n    \"name\": \"person bouncing ball\",\n    \"slug\": \"person_bouncing_ball\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"2.0\"\n  },\n  \"⛹️‍♂️\": {\n    \"name\": \"man bouncing ball\",\n    \"slug\": \"man_bouncing_ball\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"⛹️‍♀️\": {\n    \"name\": \"woman bouncing ball\",\n    \"slug\": \"woman_bouncing_ball\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏋️\": {\n    \"name\": \"person lifting weights\",\n    \"slug\": \"person_lifting_weights\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"2.0\"\n  },\n  \"🏋️‍♂️\": {\n    \"name\": \"man lifting weights\",\n    \"slug\": \"man_lifting_weights\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🏋️‍♀️\": {\n    \"name\": \"woman lifting weights\",\n    \"slug\": \"woman_lifting_weights\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🚴\": {\n    \"name\": \"person biking\",\n    \"slug\": \"person_biking\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🚴‍♂️\": {\n    \"name\": \"man biking\",\n    \"slug\": \"man_biking\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🚴‍♀️\": {\n    \"name\": \"woman biking\",\n    \"slug\": \"woman_biking\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🚵\": {\n    \"name\": \"person mountain biking\",\n    \"slug\": \"person_mountain_biking\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🚵‍♂️\": {\n    \"name\": \"man mountain biking\",\n    \"slug\": \"man_mountain_biking\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🚵‍♀️\": {\n    \"name\": \"woman mountain biking\",\n    \"slug\": \"woman_mountain_biking\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤸\": {\n    \"name\": \"person cartwheeling\",\n    \"slug\": \"person_cartwheeling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🤸‍♂️\": {\n    \"name\": \"man cartwheeling\",\n    \"slug\": \"man_cartwheeling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤸‍♀️\": {\n    \"name\": \"woman cartwheeling\",\n    \"slug\": \"woman_cartwheeling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤼\": {\n    \"name\": \"people wrestling\",\n    \"slug\": \"people_wrestling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤼‍♂️\": {\n    \"name\": \"men wrestling\",\n    \"slug\": \"men_wrestling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤼‍♀️\": {\n    \"name\": \"women wrestling\",\n    \"slug\": \"women_wrestling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"🤽\": {\n    \"name\": \"person playing water polo\",\n    \"slug\": \"person_playing_water_polo\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🤽‍♂️\": {\n    \"name\": \"man playing water polo\",\n    \"slug\": \"man_playing_water_polo\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤽‍♀️\": {\n    \"name\": \"woman playing water polo\",\n    \"slug\": \"woman_playing_water_polo\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤾\": {\n    \"name\": \"person playing handball\",\n    \"slug\": \"person_playing_handball\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🤾‍♂️\": {\n    \"name\": \"man playing handball\",\n    \"slug\": \"man_playing_handball\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤾‍♀️\": {\n    \"name\": \"woman playing handball\",\n    \"slug\": \"woman_playing_handball\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤹\": {\n    \"name\": \"person juggling\",\n    \"slug\": \"person_juggling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"3.0\"\n  },\n  \"🤹‍♂️\": {\n    \"name\": \"man juggling\",\n    \"slug\": \"man_juggling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🤹‍♀️\": {\n    \"name\": \"woman juggling\",\n    \"slug\": \"woman_juggling\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧘\": {\n    \"name\": \"person in lotus position\",\n    \"slug\": \"person_in_lotus_position\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧘‍♂️\": {\n    \"name\": \"man in lotus position\",\n    \"slug\": \"man_in_lotus_position\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🧘‍♀️\": {\n    \"name\": \"woman in lotus position\",\n    \"slug\": \"woman_in_lotus_position\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"5.0\"\n  },\n  \"🛀\": {\n    \"name\": \"person taking bath\",\n    \"slug\": \"person_taking_bath\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"1.0\"\n  },\n  \"🛌\": {\n    \"name\": \"person in bed\",\n    \"slug\": \"person_in_bed\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"4.0\"\n  },\n  \"🧑‍🤝‍🧑\": {\n    \"name\": \"people holding hands\",\n    \"slug\": \"people_holding_hands\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👭\": {\n    \"name\": \"women holding hands\",\n    \"slug\": \"women_holding_hands\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👫\": {\n    \"name\": \"woman and man holding hands\",\n    \"slug\": \"woman_and_man_holding_hands\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"👬\": {\n    \"name\": \"men holding hands\",\n    \"slug\": \"men_holding_hands\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"12.0\"\n  },\n  \"💏\": {\n    \"name\": \"kiss\",\n    \"slug\": \"kiss\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"👩‍❤️‍💋‍👨\": {\n    \"name\": \"kiss woman, man\",\n    \"slug\": \"kiss_woman_man\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"👨‍❤️‍💋‍👨\": {\n    \"name\": \"kiss man, man\",\n    \"slug\": \"kiss_man_man\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"👩‍❤️‍💋‍👩\": {\n    \"name\": \"kiss woman, woman\",\n    \"slug\": \"kiss_woman_woman\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"💑\": {\n    \"name\": \"couple with heart\",\n    \"slug\": \"couple_with_heart\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"👩‍❤️‍👨\": {\n    \"name\": \"couple with heart woman, man\",\n    \"slug\": \"couple_with_heart_woman_man\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"👨‍❤️‍👨\": {\n    \"name\": \"couple with heart man, man\",\n    \"slug\": \"couple_with_heart_man_man\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"👩‍❤️‍👩\": {\n    \"name\": \"couple with heart woman, woman\",\n    \"slug\": \"couple_with_heart_woman_woman\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": true,\n    \"skin_tone_support_unicode_version\": \"13.1\"\n  },\n  \"👨‍👩‍👦\": {\n    \"name\": \"family man, woman, boy\",\n    \"slug\": \"family_man_woman_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👩‍👧\": {\n    \"name\": \"family man, woman, girl\",\n    \"slug\": \"family_man_woman_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👩‍👧‍👦\": {\n    \"name\": \"family man, woman, girl, boy\",\n    \"slug\": \"family_man_woman_girl_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👩‍👦‍👦\": {\n    \"name\": \"family man, woman, boy, boy\",\n    \"slug\": \"family_man_woman_boy_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👩‍👧‍👧\": {\n    \"name\": \"family man, woman, girl, girl\",\n    \"slug\": \"family_man_woman_girl_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👨‍👦\": {\n    \"name\": \"family man, man, boy\",\n    \"slug\": \"family_man_man_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👨‍👧\": {\n    \"name\": \"family man, man, girl\",\n    \"slug\": \"family_man_man_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👨‍👧‍👦\": {\n    \"name\": \"family man, man, girl, boy\",\n    \"slug\": \"family_man_man_girl_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👨‍👦‍👦\": {\n    \"name\": \"family man, man, boy, boy\",\n    \"slug\": \"family_man_man_boy_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👨‍👧‍👧\": {\n    \"name\": \"family man, man, girl, girl\",\n    \"slug\": \"family_man_man_girl_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👩‍👦\": {\n    \"name\": \"family woman, woman, boy\",\n    \"slug\": \"family_woman_woman_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👩‍👧\": {\n    \"name\": \"family woman, woman, girl\",\n    \"slug\": \"family_woman_woman_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👩‍👧‍👦\": {\n    \"name\": \"family woman, woman, girl, boy\",\n    \"slug\": \"family_woman_woman_girl_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👩‍👦‍👦\": {\n    \"name\": \"family woman, woman, boy, boy\",\n    \"slug\": \"family_woman_woman_boy_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👩‍👧‍👧\": {\n    \"name\": \"family woman, woman, girl, girl\",\n    \"slug\": \"family_woman_woman_girl_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👦\": {\n    \"name\": \"family man, boy\",\n    \"slug\": \"family_man_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👦‍👦\": {\n    \"name\": \"family man, boy, boy\",\n    \"slug\": \"family_man_boy_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👧\": {\n    \"name\": \"family man, girl\",\n    \"slug\": \"family_man_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👧‍👦\": {\n    \"name\": \"family man, girl, boy\",\n    \"slug\": \"family_man_girl_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👨‍👧‍👧\": {\n    \"name\": \"family man, girl, girl\",\n    \"slug\": \"family_man_girl_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👦\": {\n    \"name\": \"family woman, boy\",\n    \"slug\": \"family_woman_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👦‍👦\": {\n    \"name\": \"family woman, boy, boy\",\n    \"slug\": \"family_woman_boy_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👧\": {\n    \"name\": \"family woman, girl\",\n    \"slug\": \"family_woman_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👧‍👦\": {\n    \"name\": \"family woman, girl, boy\",\n    \"slug\": \"family_woman_girl_boy\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"👩‍👧‍👧\": {\n    \"name\": \"family woman, girl, girl\",\n    \"slug\": \"family_woman_girl_girl\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"🗣️\": {\n    \"name\": \"speaking head\",\n    \"slug\": \"speaking_head\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"👤\": {\n    \"name\": \"bust in silhouette\",\n    \"slug\": \"bust_in_silhouette\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👥\": {\n    \"name\": \"busts in silhouette\",\n    \"slug\": \"busts_in_silhouette\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫂\": {\n    \"name\": \"people hugging\",\n    \"slug\": \"people_hugging\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"👪\": {\n    \"name\": \"family\",\n    \"slug\": \"family\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧑‍🧑‍🧒\": {\n    \"name\": \"family adult, adult, child\",\n    \"slug\": \"family_adult_adult_child\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"🧑‍🧑‍🧒‍🧒\": {\n    \"name\": \"family adult, adult, child, child\",\n    \"slug\": \"family_adult_adult_child_child\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"🧑‍🧒\": {\n    \"name\": \"family adult, child\",\n    \"slug\": \"family_adult_child\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"🧑‍🧒‍🧒\": {\n    \"name\": \"family adult, child, child\",\n    \"slug\": \"family_adult_child_child\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"👣\": {\n    \"name\": \"footprints\",\n    \"slug\": \"footprints\",\n    \"group\": \"People & Body\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐵\": {\n    \"name\": \"monkey face\",\n    \"slug\": \"monkey_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐒\": {\n    \"name\": \"monkey\",\n    \"slug\": \"monkey\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦍\": {\n    \"name\": \"gorilla\",\n    \"slug\": \"gorilla\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦧\": {\n    \"name\": \"orangutan\",\n    \"slug\": \"orangutan\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐶\": {\n    \"name\": \"dog face\",\n    \"slug\": \"dog_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐕\": {\n    \"name\": \"dog\",\n    \"slug\": \"dog\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🦮\": {\n    \"name\": \"guide dog\",\n    \"slug\": \"guide_dog\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐕‍🦺\": {\n    \"name\": \"service dog\",\n    \"slug\": \"service_dog\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐩\": {\n    \"name\": \"poodle\",\n    \"slug\": \"poodle\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐺\": {\n    \"name\": \"wolf\",\n    \"slug\": \"wolf\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦊\": {\n    \"name\": \"fox\",\n    \"slug\": \"fox\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦝\": {\n    \"name\": \"raccoon\",\n    \"slug\": \"raccoon\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐱\": {\n    \"name\": \"cat face\",\n    \"slug\": \"cat_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐈\": {\n    \"name\": \"cat\",\n    \"slug\": \"cat\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🐈‍⬛\": {\n    \"name\": \"black cat\",\n    \"slug\": \"black_cat\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦁\": {\n    \"name\": \"lion\",\n    \"slug\": \"lion\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐯\": {\n    \"name\": \"tiger face\",\n    \"slug\": \"tiger_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐅\": {\n    \"name\": \"tiger\",\n    \"slug\": \"tiger\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐆\": {\n    \"name\": \"leopard\",\n    \"slug\": \"leopard\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐴\": {\n    \"name\": \"horse face\",\n    \"slug\": \"horse_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🫎\": {\n    \"name\": \"moose\",\n    \"slug\": \"moose\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫏\": {\n    \"name\": \"donkey\",\n    \"slug\": \"donkey\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐎\": {\n    \"name\": \"horse\",\n    \"slug\": \"horse\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦄\": {\n    \"name\": \"unicorn\",\n    \"slug\": \"unicorn\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦓\": {\n    \"name\": \"zebra\",\n    \"slug\": \"zebra\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦌\": {\n    \"name\": \"deer\",\n    \"slug\": \"deer\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦬\": {\n    \"name\": \"bison\",\n    \"slug\": \"bison\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐮\": {\n    \"name\": \"cow face\",\n    \"slug\": \"cow_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐂\": {\n    \"name\": \"ox\",\n    \"slug\": \"ox\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐃\": {\n    \"name\": \"water buffalo\",\n    \"slug\": \"water_buffalo\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐄\": {\n    \"name\": \"cow\",\n    \"slug\": \"cow\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐷\": {\n    \"name\": \"pig face\",\n    \"slug\": \"pig_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐖\": {\n    \"name\": \"pig\",\n    \"slug\": \"pig\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐗\": {\n    \"name\": \"boar\",\n    \"slug\": \"boar\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐽\": {\n    \"name\": \"pig nose\",\n    \"slug\": \"pig_nose\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐏\": {\n    \"name\": \"ram\",\n    \"slug\": \"ram\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐑\": {\n    \"name\": \"ewe\",\n    \"slug\": \"ewe\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐐\": {\n    \"name\": \"goat\",\n    \"slug\": \"goat\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐪\": {\n    \"name\": \"camel\",\n    \"slug\": \"camel\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐫\": {\n    \"name\": \"two-hump camel\",\n    \"slug\": \"two_hump_camel\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦙\": {\n    \"name\": \"llama\",\n    \"slug\": \"llama\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦒\": {\n    \"name\": \"giraffe\",\n    \"slug\": \"giraffe\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐘\": {\n    \"name\": \"elephant\",\n    \"slug\": \"elephant\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦣\": {\n    \"name\": \"mammoth\",\n    \"slug\": \"mammoth\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦏\": {\n    \"name\": \"rhinoceros\",\n    \"slug\": \"rhinoceros\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦛\": {\n    \"name\": \"hippopotamus\",\n    \"slug\": \"hippopotamus\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐭\": {\n    \"name\": \"mouse face\",\n    \"slug\": \"mouse_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐁\": {\n    \"name\": \"mouse\",\n    \"slug\": \"mouse\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐀\": {\n    \"name\": \"rat\",\n    \"slug\": \"rat\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐹\": {\n    \"name\": \"hamster\",\n    \"slug\": \"hamster\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐰\": {\n    \"name\": \"rabbit face\",\n    \"slug\": \"rabbit_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐇\": {\n    \"name\": \"rabbit\",\n    \"slug\": \"rabbit\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐿️\": {\n    \"name\": \"chipmunk\",\n    \"slug\": \"chipmunk\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🦫\": {\n    \"name\": \"beaver\",\n    \"slug\": \"beaver\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦔\": {\n    \"name\": \"hedgehog\",\n    \"slug\": \"hedgehog\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦇\": {\n    \"name\": \"bat\",\n    \"slug\": \"bat\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐻\": {\n    \"name\": \"bear\",\n    \"slug\": \"bear\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐻‍❄️\": {\n    \"name\": \"polar bear\",\n    \"slug\": \"polar_bear\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐨\": {\n    \"name\": \"koala\",\n    \"slug\": \"koala\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐼\": {\n    \"name\": \"panda\",\n    \"slug\": \"panda\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦥\": {\n    \"name\": \"sloth\",\n    \"slug\": \"sloth\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦦\": {\n    \"name\": \"otter\",\n    \"slug\": \"otter\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦨\": {\n    \"name\": \"skunk\",\n    \"slug\": \"skunk\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦘\": {\n    \"name\": \"kangaroo\",\n    \"slug\": \"kangaroo\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦡\": {\n    \"name\": \"badger\",\n    \"slug\": \"badger\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐾\": {\n    \"name\": \"paw prints\",\n    \"slug\": \"paw_prints\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦃\": {\n    \"name\": \"turkey\",\n    \"slug\": \"turkey\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐔\": {\n    \"name\": \"chicken\",\n    \"slug\": \"chicken\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐓\": {\n    \"name\": \"rooster\",\n    \"slug\": \"rooster\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐣\": {\n    \"name\": \"hatching chick\",\n    \"slug\": \"hatching_chick\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐤\": {\n    \"name\": \"baby chick\",\n    \"slug\": \"baby_chick\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐥\": {\n    \"name\": \"front-facing baby chick\",\n    \"slug\": \"front_facing_baby_chick\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐦\": {\n    \"name\": \"bird\",\n    \"slug\": \"bird\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐧\": {\n    \"name\": \"penguin\",\n    \"slug\": \"penguin\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕊️\": {\n    \"name\": \"dove\",\n    \"slug\": \"dove\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🦅\": {\n    \"name\": \"eagle\",\n    \"slug\": \"eagle\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦆\": {\n    \"name\": \"duck\",\n    \"slug\": \"duck\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦢\": {\n    \"name\": \"swan\",\n    \"slug\": \"swan\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦉\": {\n    \"name\": \"owl\",\n    \"slug\": \"owl\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦤\": {\n    \"name\": \"dodo\",\n    \"slug\": \"dodo\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪶\": {\n    \"name\": \"feather\",\n    \"slug\": \"feather\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦩\": {\n    \"name\": \"flamingo\",\n    \"slug\": \"flamingo\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦚\": {\n    \"name\": \"peacock\",\n    \"slug\": \"peacock\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦜\": {\n    \"name\": \"parrot\",\n    \"slug\": \"parrot\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪽\": {\n    \"name\": \"wing\",\n    \"slug\": \"wing\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐦‍⬛\": {\n    \"name\": \"black bird\",\n    \"slug\": \"black_bird\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪿\": {\n    \"name\": \"goose\",\n    \"slug\": \"goose\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐦‍🔥\": {\n    \"name\": \"phoenix\",\n    \"slug\": \"phoenix\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"🐸\": {\n    \"name\": \"frog\",\n    \"slug\": \"frog\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐊\": {\n    \"name\": \"crocodile\",\n    \"slug\": \"crocodile\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐢\": {\n    \"name\": \"turtle\",\n    \"slug\": \"turtle\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦎\": {\n    \"name\": \"lizard\",\n    \"slug\": \"lizard\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐍\": {\n    \"name\": \"snake\",\n    \"slug\": \"snake\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐲\": {\n    \"name\": \"dragon face\",\n    \"slug\": \"dragon_face\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐉\": {\n    \"name\": \"dragon\",\n    \"slug\": \"dragon\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦕\": {\n    \"name\": \"sauropod\",\n    \"slug\": \"sauropod\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦖\": {\n    \"name\": \"T-Rex\",\n    \"slug\": \"t_rex\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐳\": {\n    \"name\": \"spouting whale\",\n    \"slug\": \"spouting_whale\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐋\": {\n    \"name\": \"whale\",\n    \"slug\": \"whale\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐬\": {\n    \"name\": \"dolphin\",\n    \"slug\": \"dolphin\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦭\": {\n    \"name\": \"seal\",\n    \"slug\": \"seal\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐟\": {\n    \"name\": \"fish\",\n    \"slug\": \"fish\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐠\": {\n    \"name\": \"tropical fish\",\n    \"slug\": \"tropical_fish\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐡\": {\n    \"name\": \"blowfish\",\n    \"slug\": \"blowfish\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦈\": {\n    \"name\": \"shark\",\n    \"slug\": \"shark\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐙\": {\n    \"name\": \"octopus\",\n    \"slug\": \"octopus\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐚\": {\n    \"name\": \"spiral shell\",\n    \"slug\": \"spiral_shell\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪸\": {\n    \"name\": \"coral\",\n    \"slug\": \"coral\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪼\": {\n    \"name\": \"jellyfish\",\n    \"slug\": \"jellyfish\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐌\": {\n    \"name\": \"snail\",\n    \"slug\": \"snail\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦋\": {\n    \"name\": \"butterfly\",\n    \"slug\": \"butterfly\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐛\": {\n    \"name\": \"bug\",\n    \"slug\": \"bug\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐜\": {\n    \"name\": \"ant\",\n    \"slug\": \"ant\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🐝\": {\n    \"name\": \"honeybee\",\n    \"slug\": \"honeybee\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪲\": {\n    \"name\": \"beetle\",\n    \"slug\": \"beetle\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🐞\": {\n    \"name\": \"lady beetle\",\n    \"slug\": \"lady_beetle\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🦗\": {\n    \"name\": \"cricket\",\n    \"slug\": \"cricket\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪳\": {\n    \"name\": \"cockroach\",\n    \"slug\": \"cockroach\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🕷️\": {\n    \"name\": \"spider\",\n    \"slug\": \"spider\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕸️\": {\n    \"name\": \"spider web\",\n    \"slug\": \"spider_web\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🦂\": {\n    \"name\": \"scorpion\",\n    \"slug\": \"scorpion\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦟\": {\n    \"name\": \"mosquito\",\n    \"slug\": \"mosquito\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪰\": {\n    \"name\": \"fly\",\n    \"slug\": \"fly\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪱\": {\n    \"name\": \"worm\",\n    \"slug\": \"worm\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦠\": {\n    \"name\": \"microbe\",\n    \"slug\": \"microbe\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"💐\": {\n    \"name\": \"bouquet\",\n    \"slug\": \"bouquet\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌸\": {\n    \"name\": \"cherry blossom\",\n    \"slug\": \"cherry_blossom\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💮\": {\n    \"name\": \"white flower\",\n    \"slug\": \"white_flower\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪷\": {\n    \"name\": \"lotus\",\n    \"slug\": \"lotus\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏵️\": {\n    \"name\": \"rosette\",\n    \"slug\": \"rosette\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌹\": {\n    \"name\": \"rose\",\n    \"slug\": \"rose\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥀\": {\n    \"name\": \"wilted flower\",\n    \"slug\": \"wilted_flower\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌺\": {\n    \"name\": \"hibiscus\",\n    \"slug\": \"hibiscus\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌻\": {\n    \"name\": \"sunflower\",\n    \"slug\": \"sunflower\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌼\": {\n    \"name\": \"blossom\",\n    \"slug\": \"blossom\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌷\": {\n    \"name\": \"tulip\",\n    \"slug\": \"tulip\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪻\": {\n    \"name\": \"hyacinth\",\n    \"slug\": \"hyacinth\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌱\": {\n    \"name\": \"seedling\",\n    \"slug\": \"seedling\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪴\": {\n    \"name\": \"potted plant\",\n    \"slug\": \"potted_plant\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌲\": {\n    \"name\": \"evergreen tree\",\n    \"slug\": \"evergreen_tree\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌳\": {\n    \"name\": \"deciduous tree\",\n    \"slug\": \"deciduous_tree\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌴\": {\n    \"name\": \"palm tree\",\n    \"slug\": \"palm_tree\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌵\": {\n    \"name\": \"cactus\",\n    \"slug\": \"cactus\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌾\": {\n    \"name\": \"sheaf of rice\",\n    \"slug\": \"sheaf_of_rice\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌿\": {\n    \"name\": \"herb\",\n    \"slug\": \"herb\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"☘️\": {\n    \"name\": \"shamrock\",\n    \"slug\": \"shamrock\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍀\": {\n    \"name\": \"four leaf clover\",\n    \"slug\": \"four_leaf_clover\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍁\": {\n    \"name\": \"maple leaf\",\n    \"slug\": \"maple_leaf\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍂\": {\n    \"name\": \"fallen leaf\",\n    \"slug\": \"fallen_leaf\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍃\": {\n    \"name\": \"leaf fluttering in wind\",\n    \"slug\": \"leaf_fluttering_in_wind\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪹\": {\n    \"name\": \"empty nest\",\n    \"slug\": \"empty_nest\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪺\": {\n    \"name\": \"nest with eggs\",\n    \"slug\": \"nest_with_eggs\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍄\": {\n    \"name\": \"mushroom\",\n    \"slug\": \"mushroom\",\n    \"group\": \"Animals & Nature\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍇\": {\n    \"name\": \"grapes\",\n    \"slug\": \"grapes\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍈\": {\n    \"name\": \"melon\",\n    \"slug\": \"melon\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍉\": {\n    \"name\": \"watermelon\",\n    \"slug\": \"watermelon\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍊\": {\n    \"name\": \"tangerine\",\n    \"slug\": \"tangerine\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍋\": {\n    \"name\": \"lemon\",\n    \"slug\": \"lemon\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍋‍🟩\": {\n    \"name\": \"lime\",\n    \"slug\": \"lime\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"🍌\": {\n    \"name\": \"banana\",\n    \"slug\": \"banana\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍍\": {\n    \"name\": \"pineapple\",\n    \"slug\": \"pineapple\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥭\": {\n    \"name\": \"mango\",\n    \"slug\": \"mango\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍎\": {\n    \"name\": \"red apple\",\n    \"slug\": \"red_apple\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍏\": {\n    \"name\": \"green apple\",\n    \"slug\": \"green_apple\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍐\": {\n    \"name\": \"pear\",\n    \"slug\": \"pear\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍑\": {\n    \"name\": \"peach\",\n    \"slug\": \"peach\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍒\": {\n    \"name\": \"cherries\",\n    \"slug\": \"cherries\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍓\": {\n    \"name\": \"strawberry\",\n    \"slug\": \"strawberry\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🫐\": {\n    \"name\": \"blueberries\",\n    \"slug\": \"blueberries\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥝\": {\n    \"name\": \"kiwi fruit\",\n    \"slug\": \"kiwi_fruit\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍅\": {\n    \"name\": \"tomato\",\n    \"slug\": \"tomato\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🫒\": {\n    \"name\": \"olive\",\n    \"slug\": \"olive\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥥\": {\n    \"name\": \"coconut\",\n    \"slug\": \"coconut\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥑\": {\n    \"name\": \"avocado\",\n    \"slug\": \"avocado\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍆\": {\n    \"name\": \"eggplant\",\n    \"slug\": \"eggplant\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥔\": {\n    \"name\": \"potato\",\n    \"slug\": \"potato\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥕\": {\n    \"name\": \"carrot\",\n    \"slug\": \"carrot\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌽\": {\n    \"name\": \"ear of corn\",\n    \"slug\": \"ear_of_corn\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌶️\": {\n    \"name\": \"hot pepper\",\n    \"slug\": \"hot_pepper\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🫑\": {\n    \"name\": \"bell pepper\",\n    \"slug\": \"bell_pepper\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥒\": {\n    \"name\": \"cucumber\",\n    \"slug\": \"cucumber\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥬\": {\n    \"name\": \"leafy green\",\n    \"slug\": \"leafy_green\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥦\": {\n    \"name\": \"broccoli\",\n    \"slug\": \"broccoli\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧄\": {\n    \"name\": \"garlic\",\n    \"slug\": \"garlic\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧅\": {\n    \"name\": \"onion\",\n    \"slug\": \"onion\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥜\": {\n    \"name\": \"peanuts\",\n    \"slug\": \"peanuts\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫘\": {\n    \"name\": \"beans\",\n    \"slug\": \"beans\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌰\": {\n    \"name\": \"chestnut\",\n    \"slug\": \"chestnut\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🫚\": {\n    \"name\": \"ginger root\",\n    \"slug\": \"ginger_root\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫛\": {\n    \"name\": \"pea pod\",\n    \"slug\": \"pea_pod\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍄‍🟫\": {\n    \"name\": \"brown mushroom\",\n    \"slug\": \"brown_mushroom\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"🍞\": {\n    \"name\": \"bread\",\n    \"slug\": \"bread\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥐\": {\n    \"name\": \"croissant\",\n    \"slug\": \"croissant\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥖\": {\n    \"name\": \"baguette bread\",\n    \"slug\": \"baguette_bread\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫓\": {\n    \"name\": \"flatbread\",\n    \"slug\": \"flatbread\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥨\": {\n    \"name\": \"pretzel\",\n    \"slug\": \"pretzel\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥯\": {\n    \"name\": \"bagel\",\n    \"slug\": \"bagel\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥞\": {\n    \"name\": \"pancakes\",\n    \"slug\": \"pancakes\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧇\": {\n    \"name\": \"waffle\",\n    \"slug\": \"waffle\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧀\": {\n    \"name\": \"cheese wedge\",\n    \"slug\": \"cheese_wedge\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍖\": {\n    \"name\": \"meat on bone\",\n    \"slug\": \"meat_on_bone\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍗\": {\n    \"name\": \"poultry leg\",\n    \"slug\": \"poultry_leg\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥩\": {\n    \"name\": \"cut of meat\",\n    \"slug\": \"cut_of_meat\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥓\": {\n    \"name\": \"bacon\",\n    \"slug\": \"bacon\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍔\": {\n    \"name\": \"hamburger\",\n    \"slug\": \"hamburger\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍟\": {\n    \"name\": \"french fries\",\n    \"slug\": \"french_fries\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍕\": {\n    \"name\": \"pizza\",\n    \"slug\": \"pizza\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌭\": {\n    \"name\": \"hot dog\",\n    \"slug\": \"hot_dog\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥪\": {\n    \"name\": \"sandwich\",\n    \"slug\": \"sandwich\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌮\": {\n    \"name\": \"taco\",\n    \"slug\": \"taco\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌯\": {\n    \"name\": \"burrito\",\n    \"slug\": \"burrito\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫔\": {\n    \"name\": \"tamale\",\n    \"slug\": \"tamale\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥙\": {\n    \"name\": \"stuffed flatbread\",\n    \"slug\": \"stuffed_flatbread\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧆\": {\n    \"name\": \"falafel\",\n    \"slug\": \"falafel\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥚\": {\n    \"name\": \"egg\",\n    \"slug\": \"egg\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍳\": {\n    \"name\": \"cooking\",\n    \"slug\": \"cooking\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥘\": {\n    \"name\": \"shallow pan of food\",\n    \"slug\": \"shallow_pan_of_food\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍲\": {\n    \"name\": \"pot of food\",\n    \"slug\": \"pot_of_food\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🫕\": {\n    \"name\": \"fondue\",\n    \"slug\": \"fondue\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥣\": {\n    \"name\": \"bowl with spoon\",\n    \"slug\": \"bowl_with_spoon\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥗\": {\n    \"name\": \"green salad\",\n    \"slug\": \"green_salad\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍿\": {\n    \"name\": \"popcorn\",\n    \"slug\": \"popcorn\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧈\": {\n    \"name\": \"butter\",\n    \"slug\": \"butter\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧂\": {\n    \"name\": \"salt\",\n    \"slug\": \"salt\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥫\": {\n    \"name\": \"canned food\",\n    \"slug\": \"canned_food\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍱\": {\n    \"name\": \"bento box\",\n    \"slug\": \"bento_box\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍘\": {\n    \"name\": \"rice cracker\",\n    \"slug\": \"rice_cracker\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍙\": {\n    \"name\": \"rice ball\",\n    \"slug\": \"rice_ball\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍚\": {\n    \"name\": \"cooked rice\",\n    \"slug\": \"cooked_rice\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍛\": {\n    \"name\": \"curry rice\",\n    \"slug\": \"curry_rice\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍜\": {\n    \"name\": \"steaming bowl\",\n    \"slug\": \"steaming_bowl\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍝\": {\n    \"name\": \"spaghetti\",\n    \"slug\": \"spaghetti\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍠\": {\n    \"name\": \"roasted sweet potato\",\n    \"slug\": \"roasted_sweet_potato\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍢\": {\n    \"name\": \"oden\",\n    \"slug\": \"oden\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍣\": {\n    \"name\": \"sushi\",\n    \"slug\": \"sushi\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍤\": {\n    \"name\": \"fried shrimp\",\n    \"slug\": \"fried_shrimp\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍥\": {\n    \"name\": \"fish cake with swirl\",\n    \"slug\": \"fish_cake_with_swirl\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥮\": {\n    \"name\": \"moon cake\",\n    \"slug\": \"moon_cake\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍡\": {\n    \"name\": \"dango\",\n    \"slug\": \"dango\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥟\": {\n    \"name\": \"dumpling\",\n    \"slug\": \"dumpling\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥠\": {\n    \"name\": \"fortune cookie\",\n    \"slug\": \"fortune_cookie\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥡\": {\n    \"name\": \"takeout box\",\n    \"slug\": \"takeout_box\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦀\": {\n    \"name\": \"crab\",\n    \"slug\": \"crab\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦞\": {\n    \"name\": \"lobster\",\n    \"slug\": \"lobster\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦐\": {\n    \"name\": \"shrimp\",\n    \"slug\": \"shrimp\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦑\": {\n    \"name\": \"squid\",\n    \"slug\": \"squid\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦪\": {\n    \"name\": \"oyster\",\n    \"slug\": \"oyster\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍦\": {\n    \"name\": \"soft ice cream\",\n    \"slug\": \"soft_ice_cream\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍧\": {\n    \"name\": \"shaved ice\",\n    \"slug\": \"shaved_ice\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍨\": {\n    \"name\": \"ice cream\",\n    \"slug\": \"ice_cream\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍩\": {\n    \"name\": \"doughnut\",\n    \"slug\": \"doughnut\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍪\": {\n    \"name\": \"cookie\",\n    \"slug\": \"cookie\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎂\": {\n    \"name\": \"birthday cake\",\n    \"slug\": \"birthday_cake\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍰\": {\n    \"name\": \"shortcake\",\n    \"slug\": \"shortcake\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧁\": {\n    \"name\": \"cupcake\",\n    \"slug\": \"cupcake\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥧\": {\n    \"name\": \"pie\",\n    \"slug\": \"pie\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍫\": {\n    \"name\": \"chocolate bar\",\n    \"slug\": \"chocolate_bar\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍬\": {\n    \"name\": \"candy\",\n    \"slug\": \"candy\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍭\": {\n    \"name\": \"lollipop\",\n    \"slug\": \"lollipop\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍮\": {\n    \"name\": \"custard\",\n    \"slug\": \"custard\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍯\": {\n    \"name\": \"honey pot\",\n    \"slug\": \"honey_pot\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍼\": {\n    \"name\": \"baby bottle\",\n    \"slug\": \"baby_bottle\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥛\": {\n    \"name\": \"glass of milk\",\n    \"slug\": \"glass_of_milk\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"☕\": {\n    \"name\": \"hot beverage\",\n    \"slug\": \"hot_beverage\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🫖\": {\n    \"name\": \"teapot\",\n    \"slug\": \"teapot\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍵\": {\n    \"name\": \"teacup without handle\",\n    \"slug\": \"teacup_without_handle\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍶\": {\n    \"name\": \"sake\",\n    \"slug\": \"sake\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍾\": {\n    \"name\": \"bottle with popping cork\",\n    \"slug\": \"bottle_with_popping_cork\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍷\": {\n    \"name\": \"wine glass\",\n    \"slug\": \"wine_glass\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍸\": {\n    \"name\": \"cocktail glass\",\n    \"slug\": \"cocktail_glass\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍹\": {\n    \"name\": \"tropical drink\",\n    \"slug\": \"tropical_drink\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍺\": {\n    \"name\": \"beer mug\",\n    \"slug\": \"beer_mug\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🍻\": {\n    \"name\": \"clinking beer mugs\",\n    \"slug\": \"clinking_beer_mugs\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥂\": {\n    \"name\": \"clinking glasses\",\n    \"slug\": \"clinking_glasses\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥃\": {\n    \"name\": \"tumbler glass\",\n    \"slug\": \"tumbler_glass\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫗\": {\n    \"name\": \"pouring liquid\",\n    \"slug\": \"pouring_liquid\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥤\": {\n    \"name\": \"cup with straw\",\n    \"slug\": \"cup_with_straw\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧋\": {\n    \"name\": \"bubble tea\",\n    \"slug\": \"bubble_tea\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧃\": {\n    \"name\": \"beverage box\",\n    \"slug\": \"beverage_box\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧉\": {\n    \"name\": \"mate\",\n    \"slug\": \"mate\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧊\": {\n    \"name\": \"ice\",\n    \"slug\": \"ice\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥢\": {\n    \"name\": \"chopsticks\",\n    \"slug\": \"chopsticks\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🍽️\": {\n    \"name\": \"fork and knife with plate\",\n    \"slug\": \"fork_and_knife_with_plate\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🍴\": {\n    \"name\": \"fork and knife\",\n    \"slug\": \"fork_and_knife\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥄\": {\n    \"name\": \"spoon\",\n    \"slug\": \"spoon\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔪\": {\n    \"name\": \"kitchen knife\",\n    \"slug\": \"kitchen_knife\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🫙\": {\n    \"name\": \"jar\",\n    \"slug\": \"jar\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏺\": {\n    \"name\": \"amphora\",\n    \"slug\": \"amphora\",\n    \"group\": \"Food & Drink\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌍\": {\n    \"name\": \"globe showing Europe-Africa\",\n    \"slug\": \"globe_showing_europe_africa\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌎\": {\n    \"name\": \"globe showing Americas\",\n    \"slug\": \"globe_showing_americas\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌏\": {\n    \"name\": \"globe showing Asia-Australia\",\n    \"slug\": \"globe_showing_asia_australia\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌐\": {\n    \"name\": \"globe with meridians\",\n    \"slug\": \"globe_with_meridians\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🗺️\": {\n    \"name\": \"world map\",\n    \"slug\": \"world_map\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🗾\": {\n    \"name\": \"map of Japan\",\n    \"slug\": \"map_of_japan\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧭\": {\n    \"name\": \"compass\",\n    \"slug\": \"compass\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏔️\": {\n    \"name\": \"snow-capped mountain\",\n    \"slug\": \"snow_capped_mountain\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⛰️\": {\n    \"name\": \"mountain\",\n    \"slug\": \"mountain\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌋\": {\n    \"name\": \"volcano\",\n    \"slug\": \"volcano\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🗻\": {\n    \"name\": \"mount fuji\",\n    \"slug\": \"mount_fuji\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏕️\": {\n    \"name\": \"camping\",\n    \"slug\": \"camping\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏖️\": {\n    \"name\": \"beach with umbrella\",\n    \"slug\": \"beach_with_umbrella\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏜️\": {\n    \"name\": \"desert\",\n    \"slug\": \"desert\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏝️\": {\n    \"name\": \"desert island\",\n    \"slug\": \"desert_island\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏞️\": {\n    \"name\": \"national park\",\n    \"slug\": \"national_park\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏟️\": {\n    \"name\": \"stadium\",\n    \"slug\": \"stadium\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏛️\": {\n    \"name\": \"classical building\",\n    \"slug\": \"classical_building\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏗️\": {\n    \"name\": \"building construction\",\n    \"slug\": \"building_construction\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🧱\": {\n    \"name\": \"brick\",\n    \"slug\": \"brick\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪨\": {\n    \"name\": \"rock\",\n    \"slug\": \"rock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪵\": {\n    \"name\": \"wood\",\n    \"slug\": \"wood\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛖\": {\n    \"name\": \"hut\",\n    \"slug\": \"hut\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏘️\": {\n    \"name\": \"houses\",\n    \"slug\": \"houses\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏚️\": {\n    \"name\": \"derelict house\",\n    \"slug\": \"derelict_house\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏠\": {\n    \"name\": \"house\",\n    \"slug\": \"house\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏡\": {\n    \"name\": \"house with garden\",\n    \"slug\": \"house_with_garden\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏢\": {\n    \"name\": \"office building\",\n    \"slug\": \"office_building\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏣\": {\n    \"name\": \"Japanese post office\",\n    \"slug\": \"japanese_post_office\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏤\": {\n    \"name\": \"post office\",\n    \"slug\": \"post_office\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏥\": {\n    \"name\": \"hospital\",\n    \"slug\": \"hospital\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏦\": {\n    \"name\": \"bank\",\n    \"slug\": \"bank\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏨\": {\n    \"name\": \"hotel\",\n    \"slug\": \"hotel\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏩\": {\n    \"name\": \"love hotel\",\n    \"slug\": \"love_hotel\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏪\": {\n    \"name\": \"convenience store\",\n    \"slug\": \"convenience_store\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏫\": {\n    \"name\": \"school\",\n    \"slug\": \"school\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏬\": {\n    \"name\": \"department store\",\n    \"slug\": \"department_store\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏭\": {\n    \"name\": \"factory\",\n    \"slug\": \"factory\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏯\": {\n    \"name\": \"Japanese castle\",\n    \"slug\": \"japanese_castle\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏰\": {\n    \"name\": \"castle\",\n    \"slug\": \"castle\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💒\": {\n    \"name\": \"wedding\",\n    \"slug\": \"wedding\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🗼\": {\n    \"name\": \"Tokyo tower\",\n    \"slug\": \"tokyo_tower\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🗽\": {\n    \"name\": \"Statue of Liberty\",\n    \"slug\": \"statue_of_liberty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⛪\": {\n    \"name\": \"church\",\n    \"slug\": \"church\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕌\": {\n    \"name\": \"mosque\",\n    \"slug\": \"mosque\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛕\": {\n    \"name\": \"hindu temple\",\n    \"slug\": \"hindu_temple\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🕍\": {\n    \"name\": \"synagogue\",\n    \"slug\": \"synagogue\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"⛩️\": {\n    \"name\": \"shinto shrine\",\n    \"slug\": \"shinto_shrine\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕋\": {\n    \"name\": \"kaaba\",\n    \"slug\": \"kaaba\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"⛲\": {\n    \"name\": \"fountain\",\n    \"slug\": \"fountain\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⛺\": {\n    \"name\": \"tent\",\n    \"slug\": \"tent\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌁\": {\n    \"name\": \"foggy\",\n    \"slug\": \"foggy\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌃\": {\n    \"name\": \"night with stars\",\n    \"slug\": \"night_with_stars\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏙️\": {\n    \"name\": \"cityscape\",\n    \"slug\": \"cityscape\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌄\": {\n    \"name\": \"sunrise over mountains\",\n    \"slug\": \"sunrise_over_mountains\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌅\": {\n    \"name\": \"sunrise\",\n    \"slug\": \"sunrise\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌆\": {\n    \"name\": \"cityscape at dusk\",\n    \"slug\": \"cityscape_at_dusk\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌇\": {\n    \"name\": \"sunset\",\n    \"slug\": \"sunset\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌉\": {\n    \"name\": \"bridge at night\",\n    \"slug\": \"bridge_at_night\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♨️\": {\n    \"name\": \"hot springs\",\n    \"slug\": \"hot_springs\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎠\": {\n    \"name\": \"carousel horse\",\n    \"slug\": \"carousel_horse\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛝\": {\n    \"name\": \"playground slide\",\n    \"slug\": \"playground_slide\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎡\": {\n    \"name\": \"ferris wheel\",\n    \"slug\": \"ferris_wheel\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎢\": {\n    \"name\": \"roller coaster\",\n    \"slug\": \"roller_coaster\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💈\": {\n    \"name\": \"barber pole\",\n    \"slug\": \"barber_pole\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎪\": {\n    \"name\": \"circus tent\",\n    \"slug\": \"circus_tent\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚂\": {\n    \"name\": \"locomotive\",\n    \"slug\": \"locomotive\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚃\": {\n    \"name\": \"railway car\",\n    \"slug\": \"railway_car\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚄\": {\n    \"name\": \"high-speed train\",\n    \"slug\": \"high_speed_train\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚅\": {\n    \"name\": \"bullet train\",\n    \"slug\": \"bullet_train\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚆\": {\n    \"name\": \"train\",\n    \"slug\": \"train\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚇\": {\n    \"name\": \"metro\",\n    \"slug\": \"metro\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚈\": {\n    \"name\": \"light rail\",\n    \"slug\": \"light_rail\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚉\": {\n    \"name\": \"station\",\n    \"slug\": \"station\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚊\": {\n    \"name\": \"tram\",\n    \"slug\": \"tram\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚝\": {\n    \"name\": \"monorail\",\n    \"slug\": \"monorail\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚞\": {\n    \"name\": \"mountain railway\",\n    \"slug\": \"mountain_railway\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚋\": {\n    \"name\": \"tram car\",\n    \"slug\": \"tram_car\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚌\": {\n    \"name\": \"bus\",\n    \"slug\": \"bus\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚍\": {\n    \"name\": \"oncoming bus\",\n    \"slug\": \"oncoming_bus\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🚎\": {\n    \"name\": \"trolleybus\",\n    \"slug\": \"trolleybus\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚐\": {\n    \"name\": \"minibus\",\n    \"slug\": \"minibus\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚑\": {\n    \"name\": \"ambulance\",\n    \"slug\": \"ambulance\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚒\": {\n    \"name\": \"fire engine\",\n    \"slug\": \"fire_engine\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚓\": {\n    \"name\": \"police car\",\n    \"slug\": \"police_car\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚔\": {\n    \"name\": \"oncoming police car\",\n    \"slug\": \"oncoming_police_car\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🚕\": {\n    \"name\": \"taxi\",\n    \"slug\": \"taxi\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚖\": {\n    \"name\": \"oncoming taxi\",\n    \"slug\": \"oncoming_taxi\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚗\": {\n    \"name\": \"automobile\",\n    \"slug\": \"automobile\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚘\": {\n    \"name\": \"oncoming automobile\",\n    \"slug\": \"oncoming_automobile\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🚙\": {\n    \"name\": \"sport utility vehicle\",\n    \"slug\": \"sport_utility_vehicle\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛻\": {\n    \"name\": \"pickup truck\",\n    \"slug\": \"pickup_truck\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚚\": {\n    \"name\": \"delivery truck\",\n    \"slug\": \"delivery_truck\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚛\": {\n    \"name\": \"articulated lorry\",\n    \"slug\": \"articulated_lorry\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚜\": {\n    \"name\": \"tractor\",\n    \"slug\": \"tractor\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏎️\": {\n    \"name\": \"racing car\",\n    \"slug\": \"racing_car\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏍️\": {\n    \"name\": \"motorcycle\",\n    \"slug\": \"motorcycle\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🛵\": {\n    \"name\": \"motor scooter\",\n    \"slug\": \"motor_scooter\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦽\": {\n    \"name\": \"manual wheelchair\",\n    \"slug\": \"manual_wheelchair\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦼\": {\n    \"name\": \"motorized wheelchair\",\n    \"slug\": \"motorized_wheelchair\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛺\": {\n    \"name\": \"auto rickshaw\",\n    \"slug\": \"auto_rickshaw\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚲\": {\n    \"name\": \"bicycle\",\n    \"slug\": \"bicycle\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛴\": {\n    \"name\": \"kick scooter\",\n    \"slug\": \"kick_scooter\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛹\": {\n    \"name\": \"skateboard\",\n    \"slug\": \"skateboard\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛼\": {\n    \"name\": \"roller skate\",\n    \"slug\": \"roller_skate\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚏\": {\n    \"name\": \"bus stop\",\n    \"slug\": \"bus_stop\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛣️\": {\n    \"name\": \"motorway\",\n    \"slug\": \"motorway\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🛤️\": {\n    \"name\": \"railway track\",\n    \"slug\": \"railway_track\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🛢️\": {\n    \"name\": \"oil drum\",\n    \"slug\": \"oil_drum\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⛽\": {\n    \"name\": \"fuel pump\",\n    \"slug\": \"fuel_pump\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛞\": {\n    \"name\": \"wheel\",\n    \"slug\": \"wheel\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚨\": {\n    \"name\": \"police car light\",\n    \"slug\": \"police_car_light\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚥\": {\n    \"name\": \"horizontal traffic light\",\n    \"slug\": \"horizontal_traffic_light\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚦\": {\n    \"name\": \"vertical traffic light\",\n    \"slug\": \"vertical_traffic_light\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛑\": {\n    \"name\": \"stop sign\",\n    \"slug\": \"stop_sign\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚧\": {\n    \"name\": \"construction\",\n    \"slug\": \"construction\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⚓\": {\n    \"name\": \"anchor\",\n    \"slug\": \"anchor\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛟\": {\n    \"name\": \"ring buoy\",\n    \"slug\": \"ring_buoy\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"⛵\": {\n    \"name\": \"sailboat\",\n    \"slug\": \"sailboat\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛶\": {\n    \"name\": \"canoe\",\n    \"slug\": \"canoe\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚤\": {\n    \"name\": \"speedboat\",\n    \"slug\": \"speedboat\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛳️\": {\n    \"name\": \"passenger ship\",\n    \"slug\": \"passenger_ship\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⛴️\": {\n    \"name\": \"ferry\",\n    \"slug\": \"ferry\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🛥️\": {\n    \"name\": \"motor boat\",\n    \"slug\": \"motor_boat\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🚢\": {\n    \"name\": \"ship\",\n    \"slug\": \"ship\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"✈️\": {\n    \"name\": \"airplane\",\n    \"slug\": \"airplane\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛩️\": {\n    \"name\": \"small airplane\",\n    \"slug\": \"small_airplane\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🛫\": {\n    \"name\": \"airplane departure\",\n    \"slug\": \"airplane_departure\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛬\": {\n    \"name\": \"airplane arrival\",\n    \"slug\": \"airplane_arrival\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪂\": {\n    \"name\": \"parachute\",\n    \"slug\": \"parachute\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"💺\": {\n    \"name\": \"seat\",\n    \"slug\": \"seat\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚁\": {\n    \"name\": \"helicopter\",\n    \"slug\": \"helicopter\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚟\": {\n    \"name\": \"suspension railway\",\n    \"slug\": \"suspension_railway\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚠\": {\n    \"name\": \"mountain cableway\",\n    \"slug\": \"mountain_cableway\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚡\": {\n    \"name\": \"aerial tramway\",\n    \"slug\": \"aerial_tramway\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛰️\": {\n    \"name\": \"satellite\",\n    \"slug\": \"satellite\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🚀\": {\n    \"name\": \"rocket\",\n    \"slug\": \"rocket\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛸\": {\n    \"name\": \"flying saucer\",\n    \"slug\": \"flying_saucer\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛎️\": {\n    \"name\": \"bellhop bell\",\n    \"slug\": \"bellhop_bell\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🧳\": {\n    \"name\": \"luggage\",\n    \"slug\": \"luggage\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"⌛\": {\n    \"name\": \"hourglass done\",\n    \"slug\": \"hourglass_done\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏳\": {\n    \"name\": \"hourglass not done\",\n    \"slug\": \"hourglass_not_done\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⌚\": {\n    \"name\": \"watch\",\n    \"slug\": \"watch\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏰\": {\n    \"name\": \"alarm clock\",\n    \"slug\": \"alarm_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏱️\": {\n    \"name\": \"stopwatch\",\n    \"slug\": \"stopwatch\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"⏲️\": {\n    \"name\": \"timer clock\",\n    \"slug\": \"timer_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🕰️\": {\n    \"name\": \"mantelpiece clock\",\n    \"slug\": \"mantelpiece_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕛\": {\n    \"name\": \"twelve o’clock\",\n    \"slug\": \"twelve_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕧\": {\n    \"name\": \"twelve-thirty\",\n    \"slug\": \"twelve_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕐\": {\n    \"name\": \"one o’clock\",\n    \"slug\": \"one_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕜\": {\n    \"name\": \"one-thirty\",\n    \"slug\": \"one_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕑\": {\n    \"name\": \"two o’clock\",\n    \"slug\": \"two_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕝\": {\n    \"name\": \"two-thirty\",\n    \"slug\": \"two_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕒\": {\n    \"name\": \"three o’clock\",\n    \"slug\": \"three_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕞\": {\n    \"name\": \"three-thirty\",\n    \"slug\": \"three_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕓\": {\n    \"name\": \"four o’clock\",\n    \"slug\": \"four_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕟\": {\n    \"name\": \"four-thirty\",\n    \"slug\": \"four_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕔\": {\n    \"name\": \"five o’clock\",\n    \"slug\": \"five_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕠\": {\n    \"name\": \"five-thirty\",\n    \"slug\": \"five_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕕\": {\n    \"name\": \"six o’clock\",\n    \"slug\": \"six_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕡\": {\n    \"name\": \"six-thirty\",\n    \"slug\": \"six_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕖\": {\n    \"name\": \"seven o’clock\",\n    \"slug\": \"seven_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕢\": {\n    \"name\": \"seven-thirty\",\n    \"slug\": \"seven_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕗\": {\n    \"name\": \"eight o’clock\",\n    \"slug\": \"eight_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕣\": {\n    \"name\": \"eight-thirty\",\n    \"slug\": \"eight_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕘\": {\n    \"name\": \"nine o’clock\",\n    \"slug\": \"nine_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕤\": {\n    \"name\": \"nine-thirty\",\n    \"slug\": \"nine_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕙\": {\n    \"name\": \"ten o’clock\",\n    \"slug\": \"ten_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕥\": {\n    \"name\": \"ten-thirty\",\n    \"slug\": \"ten_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🕚\": {\n    \"name\": \"eleven o’clock\",\n    \"slug\": \"eleven_o_clock\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕦\": {\n    \"name\": \"eleven-thirty\",\n    \"slug\": \"eleven_thirty\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌑\": {\n    \"name\": \"new moon\",\n    \"slug\": \"new_moon\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌒\": {\n    \"name\": \"waxing crescent moon\",\n    \"slug\": \"waxing_crescent_moon\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌓\": {\n    \"name\": \"first quarter moon\",\n    \"slug\": \"first_quarter_moon\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌔\": {\n    \"name\": \"waxing gibbous moon\",\n    \"slug\": \"waxing_gibbous_moon\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌕\": {\n    \"name\": \"full moon\",\n    \"slug\": \"full_moon\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌖\": {\n    \"name\": \"waning gibbous moon\",\n    \"slug\": \"waning_gibbous_moon\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌗\": {\n    \"name\": \"last quarter moon\",\n    \"slug\": \"last_quarter_moon\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌘\": {\n    \"name\": \"waning crescent moon\",\n    \"slug\": \"waning_crescent_moon\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌙\": {\n    \"name\": \"crescent moon\",\n    \"slug\": \"crescent_moon\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌚\": {\n    \"name\": \"new moon face\",\n    \"slug\": \"new_moon_face\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌛\": {\n    \"name\": \"first quarter moon face\",\n    \"slug\": \"first_quarter_moon_face\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌜\": {\n    \"name\": \"last quarter moon face\",\n    \"slug\": \"last_quarter_moon_face\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌡️\": {\n    \"name\": \"thermometer\",\n    \"slug\": \"thermometer\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"☀️\": {\n    \"name\": \"sun\",\n    \"slug\": \"sun\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌝\": {\n    \"name\": \"full moon face\",\n    \"slug\": \"full_moon_face\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🌞\": {\n    \"name\": \"sun with face\",\n    \"slug\": \"sun_with_face\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪐\": {\n    \"name\": \"ringed planet\",\n    \"slug\": \"ringed_planet\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"⭐\": {\n    \"name\": \"star\",\n    \"slug\": \"star\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌟\": {\n    \"name\": \"glowing star\",\n    \"slug\": \"glowing_star\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌠\": {\n    \"name\": \"shooting star\",\n    \"slug\": \"shooting_star\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌌\": {\n    \"name\": \"milky way\",\n    \"slug\": \"milky_way\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"☁️\": {\n    \"name\": \"cloud\",\n    \"slug\": \"cloud\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⛅\": {\n    \"name\": \"sun behind cloud\",\n    \"slug\": \"sun_behind_cloud\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⛈️\": {\n    \"name\": \"cloud with lightning and rain\",\n    \"slug\": \"cloud_with_lightning_and_rain\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌤️\": {\n    \"name\": \"sun behind small cloud\",\n    \"slug\": \"sun_behind_small_cloud\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌥️\": {\n    \"name\": \"sun behind large cloud\",\n    \"slug\": \"sun_behind_large_cloud\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌦️\": {\n    \"name\": \"sun behind rain cloud\",\n    \"slug\": \"sun_behind_rain_cloud\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌧️\": {\n    \"name\": \"cloud with rain\",\n    \"slug\": \"cloud_with_rain\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌨️\": {\n    \"name\": \"cloud with snow\",\n    \"slug\": \"cloud_with_snow\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌩️\": {\n    \"name\": \"cloud with lightning\",\n    \"slug\": \"cloud_with_lightning\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌪️\": {\n    \"name\": \"tornado\",\n    \"slug\": \"tornado\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌫️\": {\n    \"name\": \"fog\",\n    \"slug\": \"fog\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌬️\": {\n    \"name\": \"wind face\",\n    \"slug\": \"wind_face\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🌀\": {\n    \"name\": \"cyclone\",\n    \"slug\": \"cyclone\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌈\": {\n    \"name\": \"rainbow\",\n    \"slug\": \"rainbow\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌂\": {\n    \"name\": \"closed umbrella\",\n    \"slug\": \"closed_umbrella\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"☂️\": {\n    \"name\": \"umbrella\",\n    \"slug\": \"umbrella\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"☔\": {\n    \"name\": \"umbrella with rain drops\",\n    \"slug\": \"umbrella_with_rain_drops\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⛱️\": {\n    \"name\": \"umbrella on ground\",\n    \"slug\": \"umbrella_on_ground\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⚡\": {\n    \"name\": \"high voltage\",\n    \"slug\": \"high_voltage\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❄️\": {\n    \"name\": \"snowflake\",\n    \"slug\": \"snowflake\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"☃️\": {\n    \"name\": \"snowman\",\n    \"slug\": \"snowman\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⛄\": {\n    \"name\": \"snowman without snow\",\n    \"slug\": \"snowman_without_snow\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"☄️\": {\n    \"name\": \"comet\",\n    \"slug\": \"comet\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔥\": {\n    \"name\": \"fire\",\n    \"slug\": \"fire\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💧\": {\n    \"name\": \"droplet\",\n    \"slug\": \"droplet\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🌊\": {\n    \"name\": \"water wave\",\n    \"slug\": \"water_wave\",\n    \"group\": \"Travel & Places\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎃\": {\n    \"name\": \"jack-o-lantern\",\n    \"slug\": \"jack_o_lantern\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎄\": {\n    \"name\": \"Christmas tree\",\n    \"slug\": \"christmas_tree\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎆\": {\n    \"name\": \"fireworks\",\n    \"slug\": \"fireworks\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎇\": {\n    \"name\": \"sparkler\",\n    \"slug\": \"sparkler\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧨\": {\n    \"name\": \"firecracker\",\n    \"slug\": \"firecracker\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"✨\": {\n    \"name\": \"sparkles\",\n    \"slug\": \"sparkles\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎈\": {\n    \"name\": \"balloon\",\n    \"slug\": \"balloon\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎉\": {\n    \"name\": \"party popper\",\n    \"slug\": \"party_popper\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎊\": {\n    \"name\": \"confetti ball\",\n    \"slug\": \"confetti_ball\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎋\": {\n    \"name\": \"tanabata tree\",\n    \"slug\": \"tanabata_tree\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎍\": {\n    \"name\": \"pine decoration\",\n    \"slug\": \"pine_decoration\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎎\": {\n    \"name\": \"Japanese dolls\",\n    \"slug\": \"japanese_dolls\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎏\": {\n    \"name\": \"carp streamer\",\n    \"slug\": \"carp_streamer\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎐\": {\n    \"name\": \"wind chime\",\n    \"slug\": \"wind_chime\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎑\": {\n    \"name\": \"moon viewing ceremony\",\n    \"slug\": \"moon_viewing_ceremony\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧧\": {\n    \"name\": \"red envelope\",\n    \"slug\": \"red_envelope\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎀\": {\n    \"name\": \"ribbon\",\n    \"slug\": \"ribbon\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎁\": {\n    \"name\": \"wrapped gift\",\n    \"slug\": \"wrapped_gift\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎗️\": {\n    \"name\": \"reminder ribbon\",\n    \"slug\": \"reminder_ribbon\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎟️\": {\n    \"name\": \"admission tickets\",\n    \"slug\": \"admission_tickets\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎫\": {\n    \"name\": \"ticket\",\n    \"slug\": \"ticket\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎖️\": {\n    \"name\": \"military medal\",\n    \"slug\": \"military_medal\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏆\": {\n    \"name\": \"trophy\",\n    \"slug\": \"trophy\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏅\": {\n    \"name\": \"sports medal\",\n    \"slug\": \"sports_medal\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥇\": {\n    \"name\": \"1st place medal\",\n    \"slug\": \"1st_place_medal\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥈\": {\n    \"name\": \"2nd place medal\",\n    \"slug\": \"2nd_place_medal\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥉\": {\n    \"name\": \"3rd place medal\",\n    \"slug\": \"3rd_place_medal\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"⚽\": {\n    \"name\": \"soccer ball\",\n    \"slug\": \"soccer_ball\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⚾\": {\n    \"name\": \"baseball\",\n    \"slug\": \"baseball\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥎\": {\n    \"name\": \"softball\",\n    \"slug\": \"softball\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏀\": {\n    \"name\": \"basketball\",\n    \"slug\": \"basketball\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏐\": {\n    \"name\": \"volleyball\",\n    \"slug\": \"volleyball\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏈\": {\n    \"name\": \"american football\",\n    \"slug\": \"american_football\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏉\": {\n    \"name\": \"rugby football\",\n    \"slug\": \"rugby_football\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎾\": {\n    \"name\": \"tennis\",\n    \"slug\": \"tennis\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥏\": {\n    \"name\": \"flying disc\",\n    \"slug\": \"flying_disc\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎳\": {\n    \"name\": \"bowling\",\n    \"slug\": \"bowling\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏏\": {\n    \"name\": \"cricket game\",\n    \"slug\": \"cricket_game\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏑\": {\n    \"name\": \"field hockey\",\n    \"slug\": \"field_hockey\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏒\": {\n    \"name\": \"ice hockey\",\n    \"slug\": \"ice_hockey\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥍\": {\n    \"name\": \"lacrosse\",\n    \"slug\": \"lacrosse\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏓\": {\n    \"name\": \"ping pong\",\n    \"slug\": \"ping_pong\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏸\": {\n    \"name\": \"badminton\",\n    \"slug\": \"badminton\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥊\": {\n    \"name\": \"boxing glove\",\n    \"slug\": \"boxing_glove\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥋\": {\n    \"name\": \"martial arts uniform\",\n    \"slug\": \"martial_arts_uniform\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥅\": {\n    \"name\": \"goal net\",\n    \"slug\": \"goal_net\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"⛳\": {\n    \"name\": \"flag in hole\",\n    \"slug\": \"flag_in_hole\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⛸️\": {\n    \"name\": \"ice skate\",\n    \"slug\": \"ice_skate\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎣\": {\n    \"name\": \"fishing pole\",\n    \"slug\": \"fishing_pole\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🤿\": {\n    \"name\": \"diving mask\",\n    \"slug\": \"diving_mask\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎽\": {\n    \"name\": \"running shirt\",\n    \"slug\": \"running_shirt\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎿\": {\n    \"name\": \"skis\",\n    \"slug\": \"skis\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛷\": {\n    \"name\": \"sled\",\n    \"slug\": \"sled\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥌\": {\n    \"name\": \"curling stone\",\n    \"slug\": \"curling_stone\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎯\": {\n    \"name\": \"bullseye\",\n    \"slug\": \"bullseye\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪀\": {\n    \"name\": \"yo-yo\",\n    \"slug\": \"yo_yo\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪁\": {\n    \"name\": \"kite\",\n    \"slug\": \"kite\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔫\": {\n    \"name\": \"water pistol\",\n    \"slug\": \"water_pistol\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎱\": {\n    \"name\": \"pool 8 ball\",\n    \"slug\": \"pool_8_ball\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔮\": {\n    \"name\": \"crystal ball\",\n    \"slug\": \"crystal_ball\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪄\": {\n    \"name\": \"magic wand\",\n    \"slug\": \"magic_wand\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎮\": {\n    \"name\": \"video game\",\n    \"slug\": \"video_game\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕹️\": {\n    \"name\": \"joystick\",\n    \"slug\": \"joystick\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎰\": {\n    \"name\": \"slot machine\",\n    \"slug\": \"slot_machine\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎲\": {\n    \"name\": \"game die\",\n    \"slug\": \"game_die\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧩\": {\n    \"name\": \"puzzle piece\",\n    \"slug\": \"puzzle_piece\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧸\": {\n    \"name\": \"teddy bear\",\n    \"slug\": \"teddy_bear\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪅\": {\n    \"name\": \"piñata\",\n    \"slug\": \"pinata\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪩\": {\n    \"name\": \"mirror ball\",\n    \"slug\": \"mirror_ball\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪆\": {\n    \"name\": \"nesting dolls\",\n    \"slug\": \"nesting_dolls\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"♠️\": {\n    \"name\": \"spade suit\",\n    \"slug\": \"spade_suit\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♥️\": {\n    \"name\": \"heart suit\",\n    \"slug\": \"heart_suit\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♦️\": {\n    \"name\": \"diamond suit\",\n    \"slug\": \"diamond_suit\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♣️\": {\n    \"name\": \"club suit\",\n    \"slug\": \"club_suit\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♟️\": {\n    \"name\": \"chess pawn\",\n    \"slug\": \"chess_pawn\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🃏\": {\n    \"name\": \"joker\",\n    \"slug\": \"joker\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🀄\": {\n    \"name\": \"mahjong red dragon\",\n    \"slug\": \"mahjong_red_dragon\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎴\": {\n    \"name\": \"flower playing cards\",\n    \"slug\": \"flower_playing_cards\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎭\": {\n    \"name\": \"performing arts\",\n    \"slug\": \"performing_arts\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🖼️\": {\n    \"name\": \"framed picture\",\n    \"slug\": \"framed_picture\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎨\": {\n    \"name\": \"artist palette\",\n    \"slug\": \"artist_palette\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧵\": {\n    \"name\": \"thread\",\n    \"slug\": \"thread\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪡\": {\n    \"name\": \"sewing needle\",\n    \"slug\": \"sewing_needle\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧶\": {\n    \"name\": \"yarn\",\n    \"slug\": \"yarn\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪢\": {\n    \"name\": \"knot\",\n    \"slug\": \"knot\",\n    \"group\": \"Activities\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"👓\": {\n    \"name\": \"glasses\",\n    \"slug\": \"glasses\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕶️\": {\n    \"name\": \"sunglasses\",\n    \"slug\": \"sunglasses\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🥽\": {\n    \"name\": \"goggles\",\n    \"slug\": \"goggles\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥼\": {\n    \"name\": \"lab coat\",\n    \"slug\": \"lab_coat\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦺\": {\n    \"name\": \"safety vest\",\n    \"slug\": \"safety_vest\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"👔\": {\n    \"name\": \"necktie\",\n    \"slug\": \"necktie\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👕\": {\n    \"name\": \"t-shirt\",\n    \"slug\": \"t_shirt\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👖\": {\n    \"name\": \"jeans\",\n    \"slug\": \"jeans\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧣\": {\n    \"name\": \"scarf\",\n    \"slug\": \"scarf\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧤\": {\n    \"name\": \"gloves\",\n    \"slug\": \"gloves\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧥\": {\n    \"name\": \"coat\",\n    \"slug\": \"coat\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧦\": {\n    \"name\": \"socks\",\n    \"slug\": \"socks\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"👗\": {\n    \"name\": \"dress\",\n    \"slug\": \"dress\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👘\": {\n    \"name\": \"kimono\",\n    \"slug\": \"kimono\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥻\": {\n    \"name\": \"sari\",\n    \"slug\": \"sari\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🩱\": {\n    \"name\": \"one-piece swimsuit\",\n    \"slug\": \"one_piece_swimsuit\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🩲\": {\n    \"name\": \"briefs\",\n    \"slug\": \"briefs\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🩳\": {\n    \"name\": \"shorts\",\n    \"slug\": \"shorts\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"👙\": {\n    \"name\": \"bikini\",\n    \"slug\": \"bikini\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👚\": {\n    \"name\": \"woman’s clothes\",\n    \"slug\": \"woman_s_clothes\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪭\": {\n    \"name\": \"folding hand fan\",\n    \"slug\": \"folding_hand_fan\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"👛\": {\n    \"name\": \"purse\",\n    \"slug\": \"purse\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👜\": {\n    \"name\": \"handbag\",\n    \"slug\": \"handbag\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👝\": {\n    \"name\": \"clutch bag\",\n    \"slug\": \"clutch_bag\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛍️\": {\n    \"name\": \"shopping bags\",\n    \"slug\": \"shopping_bags\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎒\": {\n    \"name\": \"backpack\",\n    \"slug\": \"backpack\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🩴\": {\n    \"name\": \"thong sandal\",\n    \"slug\": \"thong_sandal\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"👞\": {\n    \"name\": \"man’s shoe\",\n    \"slug\": \"man_s_shoe\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👟\": {\n    \"name\": \"running shoe\",\n    \"slug\": \"running_shoe\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🥾\": {\n    \"name\": \"hiking boot\",\n    \"slug\": \"hiking_boot\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥿\": {\n    \"name\": \"flat shoe\",\n    \"slug\": \"flat_shoe\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"👠\": {\n    \"name\": \"high-heeled shoe\",\n    \"slug\": \"high_heeled_shoe\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👡\": {\n    \"name\": \"woman’s sandal\",\n    \"slug\": \"woman_s_sandal\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🩰\": {\n    \"name\": \"ballet shoes\",\n    \"slug\": \"ballet_shoes\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"👢\": {\n    \"name\": \"woman’s boot\",\n    \"slug\": \"woman_s_boot\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪮\": {\n    \"name\": \"hair pick\",\n    \"slug\": \"hair_pick\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"👑\": {\n    \"name\": \"crown\",\n    \"slug\": \"crown\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"👒\": {\n    \"name\": \"woman’s hat\",\n    \"slug\": \"woman_s_hat\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎩\": {\n    \"name\": \"top hat\",\n    \"slug\": \"top_hat\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎓\": {\n    \"name\": \"graduation cap\",\n    \"slug\": \"graduation_cap\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧢\": {\n    \"name\": \"billed cap\",\n    \"slug\": \"billed_cap\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪖\": {\n    \"name\": \"military helmet\",\n    \"slug\": \"military_helmet\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"⛑️\": {\n    \"name\": \"rescue worker’s helmet\",\n    \"slug\": \"rescue_worker_s_helmet\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"📿\": {\n    \"name\": \"prayer beads\",\n    \"slug\": \"prayer_beads\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"💄\": {\n    \"name\": \"lipstick\",\n    \"slug\": \"lipstick\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💍\": {\n    \"name\": \"ring\",\n    \"slug\": \"ring\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💎\": {\n    \"name\": \"gem stone\",\n    \"slug\": \"gem_stone\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔇\": {\n    \"name\": \"muted speaker\",\n    \"slug\": \"muted_speaker\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔈\": {\n    \"name\": \"speaker low volume\",\n    \"slug\": \"speaker_low_volume\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🔉\": {\n    \"name\": \"speaker medium volume\",\n    \"slug\": \"speaker_medium_volume\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔊\": {\n    \"name\": \"speaker high volume\",\n    \"slug\": \"speaker_high_volume\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📢\": {\n    \"name\": \"loudspeaker\",\n    \"slug\": \"loudspeaker\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📣\": {\n    \"name\": \"megaphone\",\n    \"slug\": \"megaphone\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📯\": {\n    \"name\": \"postal horn\",\n    \"slug\": \"postal_horn\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔔\": {\n    \"name\": \"bell\",\n    \"slug\": \"bell\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔕\": {\n    \"name\": \"bell with slash\",\n    \"slug\": \"bell_with_slash\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎼\": {\n    \"name\": \"musical score\",\n    \"slug\": \"musical_score\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎵\": {\n    \"name\": \"musical note\",\n    \"slug\": \"musical_note\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎶\": {\n    \"name\": \"musical notes\",\n    \"slug\": \"musical_notes\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎙️\": {\n    \"name\": \"studio microphone\",\n    \"slug\": \"studio_microphone\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎚️\": {\n    \"name\": \"level slider\",\n    \"slug\": \"level_slider\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎛️\": {\n    \"name\": \"control knobs\",\n    \"slug\": \"control_knobs\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎤\": {\n    \"name\": \"microphone\",\n    \"slug\": \"microphone\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎧\": {\n    \"name\": \"headphone\",\n    \"slug\": \"headphone\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📻\": {\n    \"name\": \"radio\",\n    \"slug\": \"radio\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎷\": {\n    \"name\": \"saxophone\",\n    \"slug\": \"saxophone\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪗\": {\n    \"name\": \"accordion\",\n    \"slug\": \"accordion\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎸\": {\n    \"name\": \"guitar\",\n    \"slug\": \"guitar\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎹\": {\n    \"name\": \"musical keyboard\",\n    \"slug\": \"musical_keyboard\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎺\": {\n    \"name\": \"trumpet\",\n    \"slug\": \"trumpet\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎻\": {\n    \"name\": \"violin\",\n    \"slug\": \"violin\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪕\": {\n    \"name\": \"banjo\",\n    \"slug\": \"banjo\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🥁\": {\n    \"name\": \"drum\",\n    \"slug\": \"drum\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪘\": {\n    \"name\": \"long drum\",\n    \"slug\": \"long_drum\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪇\": {\n    \"name\": \"maracas\",\n    \"slug\": \"maracas\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪈\": {\n    \"name\": \"flute\",\n    \"slug\": \"flute\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"📱\": {\n    \"name\": \"mobile phone\",\n    \"slug\": \"mobile_phone\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📲\": {\n    \"name\": \"mobile phone with arrow\",\n    \"slug\": \"mobile_phone_with_arrow\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"☎️\": {\n    \"name\": \"telephone\",\n    \"slug\": \"telephone\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📞\": {\n    \"name\": \"telephone receiver\",\n    \"slug\": \"telephone_receiver\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📟\": {\n    \"name\": \"pager\",\n    \"slug\": \"pager\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📠\": {\n    \"name\": \"fax machine\",\n    \"slug\": \"fax_machine\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔋\": {\n    \"name\": \"battery\",\n    \"slug\": \"battery\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪫\": {\n    \"name\": \"low battery\",\n    \"slug\": \"low_battery\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔌\": {\n    \"name\": \"electric plug\",\n    \"slug\": \"electric_plug\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💻\": {\n    \"name\": \"laptop\",\n    \"slug\": \"laptop\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🖥️\": {\n    \"name\": \"desktop computer\",\n    \"slug\": \"desktop_computer\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🖨️\": {\n    \"name\": \"printer\",\n    \"slug\": \"printer\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⌨️\": {\n    \"name\": \"keyboard\",\n    \"slug\": \"keyboard\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🖱️\": {\n    \"name\": \"computer mouse\",\n    \"slug\": \"computer_mouse\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🖲️\": {\n    \"name\": \"trackball\",\n    \"slug\": \"trackball\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"💽\": {\n    \"name\": \"computer disk\",\n    \"slug\": \"computer_disk\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💾\": {\n    \"name\": \"floppy disk\",\n    \"slug\": \"floppy_disk\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💿\": {\n    \"name\": \"optical disk\",\n    \"slug\": \"optical_disk\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📀\": {\n    \"name\": \"dvd\",\n    \"slug\": \"dvd\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧮\": {\n    \"name\": \"abacus\",\n    \"slug\": \"abacus\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎥\": {\n    \"name\": \"movie camera\",\n    \"slug\": \"movie_camera\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎞️\": {\n    \"name\": \"film frames\",\n    \"slug\": \"film_frames\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"📽️\": {\n    \"name\": \"film projector\",\n    \"slug\": \"film_projector\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🎬\": {\n    \"name\": \"clapper board\",\n    \"slug\": \"clapper_board\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📺\": {\n    \"name\": \"television\",\n    \"slug\": \"television\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📷\": {\n    \"name\": \"camera\",\n    \"slug\": \"camera\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📸\": {\n    \"name\": \"camera with flash\",\n    \"slug\": \"camera_with_flash\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"📹\": {\n    \"name\": \"video camera\",\n    \"slug\": \"video_camera\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📼\": {\n    \"name\": \"videocassette\",\n    \"slug\": \"videocassette\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔍\": {\n    \"name\": \"magnifying glass tilted left\",\n    \"slug\": \"magnifying_glass_tilted_left\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔎\": {\n    \"name\": \"magnifying glass tilted right\",\n    \"slug\": \"magnifying_glass_tilted_right\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🕯️\": {\n    \"name\": \"candle\",\n    \"slug\": \"candle\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"💡\": {\n    \"name\": \"light bulb\",\n    \"slug\": \"light_bulb\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔦\": {\n    \"name\": \"flashlight\",\n    \"slug\": \"flashlight\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏮\": {\n    \"name\": \"red paper lantern\",\n    \"slug\": \"red_paper_lantern\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪔\": {\n    \"name\": \"diya lamp\",\n    \"slug\": \"diya_lamp\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"📔\": {\n    \"name\": \"notebook with decorative cover\",\n    \"slug\": \"notebook_with_decorative_cover\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📕\": {\n    \"name\": \"closed book\",\n    \"slug\": \"closed_book\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📖\": {\n    \"name\": \"open book\",\n    \"slug\": \"open_book\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📗\": {\n    \"name\": \"green book\",\n    \"slug\": \"green_book\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📘\": {\n    \"name\": \"blue book\",\n    \"slug\": \"blue_book\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📙\": {\n    \"name\": \"orange book\",\n    \"slug\": \"orange_book\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📚\": {\n    \"name\": \"books\",\n    \"slug\": \"books\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📓\": {\n    \"name\": \"notebook\",\n    \"slug\": \"notebook\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📒\": {\n    \"name\": \"ledger\",\n    \"slug\": \"ledger\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📃\": {\n    \"name\": \"page with curl\",\n    \"slug\": \"page_with_curl\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📜\": {\n    \"name\": \"scroll\",\n    \"slug\": \"scroll\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📄\": {\n    \"name\": \"page facing up\",\n    \"slug\": \"page_facing_up\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📰\": {\n    \"name\": \"newspaper\",\n    \"slug\": \"newspaper\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🗞️\": {\n    \"name\": \"rolled-up newspaper\",\n    \"slug\": \"rolled_up_newspaper\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"📑\": {\n    \"name\": \"bookmark tabs\",\n    \"slug\": \"bookmark_tabs\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔖\": {\n    \"name\": \"bookmark\",\n    \"slug\": \"bookmark\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏷️\": {\n    \"name\": \"label\",\n    \"slug\": \"label\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"💰\": {\n    \"name\": \"money bag\",\n    \"slug\": \"money_bag\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪙\": {\n    \"name\": \"coin\",\n    \"slug\": \"coin\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"💴\": {\n    \"name\": \"yen banknote\",\n    \"slug\": \"yen_banknote\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💵\": {\n    \"name\": \"dollar banknote\",\n    \"slug\": \"dollar_banknote\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💶\": {\n    \"name\": \"euro banknote\",\n    \"slug\": \"euro_banknote\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"💷\": {\n    \"name\": \"pound banknote\",\n    \"slug\": \"pound_banknote\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"💸\": {\n    \"name\": \"money with wings\",\n    \"slug\": \"money_with_wings\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💳\": {\n    \"name\": \"credit card\",\n    \"slug\": \"credit_card\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🧾\": {\n    \"name\": \"receipt\",\n    \"slug\": \"receipt\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"💹\": {\n    \"name\": \"chart increasing with yen\",\n    \"slug\": \"chart_increasing_with_yen\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"✉️\": {\n    \"name\": \"envelope\",\n    \"slug\": \"envelope\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📧\": {\n    \"name\": \"e-mail\",\n    \"slug\": \"e_mail\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📨\": {\n    \"name\": \"incoming envelope\",\n    \"slug\": \"incoming_envelope\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📩\": {\n    \"name\": \"envelope with arrow\",\n    \"slug\": \"envelope_with_arrow\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📤\": {\n    \"name\": \"outbox tray\",\n    \"slug\": \"outbox_tray\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📥\": {\n    \"name\": \"inbox tray\",\n    \"slug\": \"inbox_tray\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📦\": {\n    \"name\": \"package\",\n    \"slug\": \"package\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📫\": {\n    \"name\": \"closed mailbox with raised flag\",\n    \"slug\": \"closed_mailbox_with_raised_flag\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📪\": {\n    \"name\": \"closed mailbox with lowered flag\",\n    \"slug\": \"closed_mailbox_with_lowered_flag\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📬\": {\n    \"name\": \"open mailbox with raised flag\",\n    \"slug\": \"open_mailbox_with_raised_flag\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"📭\": {\n    \"name\": \"open mailbox with lowered flag\",\n    \"slug\": \"open_mailbox_with_lowered_flag\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"📮\": {\n    \"name\": \"postbox\",\n    \"slug\": \"postbox\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🗳️\": {\n    \"name\": \"ballot box with ballot\",\n    \"slug\": \"ballot_box_with_ballot\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"✏️\": {\n    \"name\": \"pencil\",\n    \"slug\": \"pencil\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"✒️\": {\n    \"name\": \"black nib\",\n    \"slug\": \"black_nib\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🖋️\": {\n    \"name\": \"fountain pen\",\n    \"slug\": \"fountain_pen\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🖊️\": {\n    \"name\": \"pen\",\n    \"slug\": \"pen\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🖌️\": {\n    \"name\": \"paintbrush\",\n    \"slug\": \"paintbrush\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🖍️\": {\n    \"name\": \"crayon\",\n    \"slug\": \"crayon\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"📝\": {\n    \"name\": \"memo\",\n    \"slug\": \"memo\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💼\": {\n    \"name\": \"briefcase\",\n    \"slug\": \"briefcase\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📁\": {\n    \"name\": \"file folder\",\n    \"slug\": \"file_folder\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📂\": {\n    \"name\": \"open file folder\",\n    \"slug\": \"open_file_folder\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🗂️\": {\n    \"name\": \"card index dividers\",\n    \"slug\": \"card_index_dividers\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"📅\": {\n    \"name\": \"calendar\",\n    \"slug\": \"calendar\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📆\": {\n    \"name\": \"tear-off calendar\",\n    \"slug\": \"tear_off_calendar\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🗒️\": {\n    \"name\": \"spiral notepad\",\n    \"slug\": \"spiral_notepad\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🗓️\": {\n    \"name\": \"spiral calendar\",\n    \"slug\": \"spiral_calendar\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"📇\": {\n    \"name\": \"card index\",\n    \"slug\": \"card_index\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📈\": {\n    \"name\": \"chart increasing\",\n    \"slug\": \"chart_increasing\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📉\": {\n    \"name\": \"chart decreasing\",\n    \"slug\": \"chart_decreasing\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📊\": {\n    \"name\": \"bar chart\",\n    \"slug\": \"bar_chart\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📋\": {\n    \"name\": \"clipboard\",\n    \"slug\": \"clipboard\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📌\": {\n    \"name\": \"pushpin\",\n    \"slug\": \"pushpin\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📍\": {\n    \"name\": \"round pushpin\",\n    \"slug\": \"round_pushpin\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📎\": {\n    \"name\": \"paperclip\",\n    \"slug\": \"paperclip\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🖇️\": {\n    \"name\": \"linked paperclips\",\n    \"slug\": \"linked_paperclips\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"📏\": {\n    \"name\": \"straight ruler\",\n    \"slug\": \"straight_ruler\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📐\": {\n    \"name\": \"triangular ruler\",\n    \"slug\": \"triangular_ruler\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"✂️\": {\n    \"name\": \"scissors\",\n    \"slug\": \"scissors\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🗃️\": {\n    \"name\": \"card file box\",\n    \"slug\": \"card_file_box\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🗄️\": {\n    \"name\": \"file cabinet\",\n    \"slug\": \"file_cabinet\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🗑️\": {\n    \"name\": \"wastebasket\",\n    \"slug\": \"wastebasket\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🔒\": {\n    \"name\": \"locked\",\n    \"slug\": \"locked\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔓\": {\n    \"name\": \"unlocked\",\n    \"slug\": \"unlocked\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔏\": {\n    \"name\": \"locked with pen\",\n    \"slug\": \"locked_with_pen\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔐\": {\n    \"name\": \"locked with key\",\n    \"slug\": \"locked_with_key\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔑\": {\n    \"name\": \"key\",\n    \"slug\": \"key\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🗝️\": {\n    \"name\": \"old key\",\n    \"slug\": \"old_key\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🔨\": {\n    \"name\": \"hammer\",\n    \"slug\": \"hammer\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪓\": {\n    \"name\": \"axe\",\n    \"slug\": \"axe\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"⛏️\": {\n    \"name\": \"pick\",\n    \"slug\": \"pick\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⚒️\": {\n    \"name\": \"hammer and pick\",\n    \"slug\": \"hammer_and_pick\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛠️\": {\n    \"name\": \"hammer and wrench\",\n    \"slug\": \"hammer_and_wrench\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🗡️\": {\n    \"name\": \"dagger\",\n    \"slug\": \"dagger\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⚔️\": {\n    \"name\": \"crossed swords\",\n    \"slug\": \"crossed_swords\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"💣\": {\n    \"name\": \"bomb\",\n    \"slug\": \"bomb\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪃\": {\n    \"name\": \"boomerang\",\n    \"slug\": \"boomerang\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏹\": {\n    \"name\": \"bow and arrow\",\n    \"slug\": \"bow_and_arrow\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛡️\": {\n    \"name\": \"shield\",\n    \"slug\": \"shield\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🪚\": {\n    \"name\": \"carpentry saw\",\n    \"slug\": \"carpentry_saw\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔧\": {\n    \"name\": \"wrench\",\n    \"slug\": \"wrench\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪛\": {\n    \"name\": \"screwdriver\",\n    \"slug\": \"screwdriver\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔩\": {\n    \"name\": \"nut and bolt\",\n    \"slug\": \"nut_and_bolt\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⚙️\": {\n    \"name\": \"gear\",\n    \"slug\": \"gear\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🗜️\": {\n    \"name\": \"clamp\",\n    \"slug\": \"clamp\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⚖️\": {\n    \"name\": \"balance scale\",\n    \"slug\": \"balance_scale\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🦯\": {\n    \"name\": \"white cane\",\n    \"slug\": \"white_cane\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔗\": {\n    \"name\": \"link\",\n    \"slug\": \"link\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⛓️‍💥\": {\n    \"name\": \"broken chain\",\n    \"slug\": \"broken_chain\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"15.1\",\n    \"unicode_version\": \"15.1\",\n    \"skin_tone_support\": false\n  },\n  \"⛓️\": {\n    \"name\": \"chains\",\n    \"slug\": \"chains\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🪝\": {\n    \"name\": \"hook\",\n    \"slug\": \"hook\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧰\": {\n    \"name\": \"toolbox\",\n    \"slug\": \"toolbox\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧲\": {\n    \"name\": \"magnet\",\n    \"slug\": \"magnet\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪜\": {\n    \"name\": \"ladder\",\n    \"slug\": \"ladder\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"⚗️\": {\n    \"name\": \"alembic\",\n    \"slug\": \"alembic\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧪\": {\n    \"name\": \"test tube\",\n    \"slug\": \"test_tube\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧫\": {\n    \"name\": \"petri dish\",\n    \"slug\": \"petri_dish\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧬\": {\n    \"name\": \"dna\",\n    \"slug\": \"dna\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔬\": {\n    \"name\": \"microscope\",\n    \"slug\": \"microscope\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔭\": {\n    \"name\": \"telescope\",\n    \"slug\": \"telescope\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"📡\": {\n    \"name\": \"satellite antenna\",\n    \"slug\": \"satellite_antenna\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💉\": {\n    \"name\": \"syringe\",\n    \"slug\": \"syringe\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🩸\": {\n    \"name\": \"drop of blood\",\n    \"slug\": \"drop_of_blood\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"💊\": {\n    \"name\": \"pill\",\n    \"slug\": \"pill\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🩹\": {\n    \"name\": \"adhesive bandage\",\n    \"slug\": \"adhesive_bandage\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🩼\": {\n    \"name\": \"crutch\",\n    \"slug\": \"crutch\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🩺\": {\n    \"name\": \"stethoscope\",\n    \"slug\": \"stethoscope\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🩻\": {\n    \"name\": \"x-ray\",\n    \"slug\": \"x_ray\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚪\": {\n    \"name\": \"door\",\n    \"slug\": \"door\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛗\": {\n    \"name\": \"elevator\",\n    \"slug\": \"elevator\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪞\": {\n    \"name\": \"mirror\",\n    \"slug\": \"mirror\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪟\": {\n    \"name\": \"window\",\n    \"slug\": \"window\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛏️\": {\n    \"name\": \"bed\",\n    \"slug\": \"bed\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🛋️\": {\n    \"name\": \"couch and lamp\",\n    \"slug\": \"couch_and_lamp\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🪑\": {\n    \"name\": \"chair\",\n    \"slug\": \"chair\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚽\": {\n    \"name\": \"toilet\",\n    \"slug\": \"toilet\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪠\": {\n    \"name\": \"plunger\",\n    \"slug\": \"plunger\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚿\": {\n    \"name\": \"shower\",\n    \"slug\": \"shower\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛁\": {\n    \"name\": \"bathtub\",\n    \"slug\": \"bathtub\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪤\": {\n    \"name\": \"mouse trap\",\n    \"slug\": \"mouse_trap\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪒\": {\n    \"name\": \"razor\",\n    \"slug\": \"razor\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧴\": {\n    \"name\": \"lotion bottle\",\n    \"slug\": \"lotion_bottle\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧷\": {\n    \"name\": \"safety pin\",\n    \"slug\": \"safety_pin\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧹\": {\n    \"name\": \"broom\",\n    \"slug\": \"broom\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧺\": {\n    \"name\": \"basket\",\n    \"slug\": \"basket\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧻\": {\n    \"name\": \"roll of paper\",\n    \"slug\": \"roll_of_paper\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪣\": {\n    \"name\": \"bucket\",\n    \"slug\": \"bucket\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧼\": {\n    \"name\": \"soap\",\n    \"slug\": \"soap\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🫧\": {\n    \"name\": \"bubbles\",\n    \"slug\": \"bubbles\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪥\": {\n    \"name\": \"toothbrush\",\n    \"slug\": \"toothbrush\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧽\": {\n    \"name\": \"sponge\",\n    \"slug\": \"sponge\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧯\": {\n    \"name\": \"fire extinguisher\",\n    \"slug\": \"fire_extinguisher\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛒\": {\n    \"name\": \"shopping cart\",\n    \"slug\": \"shopping_cart\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"3.0\",\n    \"unicode_version\": \"3.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚬\": {\n    \"name\": \"cigarette\",\n    \"slug\": \"cigarette\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⚰️\": {\n    \"name\": \"coffin\",\n    \"slug\": \"coffin\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪦\": {\n    \"name\": \"headstone\",\n    \"slug\": \"headstone\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"⚱️\": {\n    \"name\": \"funeral urn\",\n    \"slug\": \"funeral_urn\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🧿\": {\n    \"name\": \"nazar amulet\",\n    \"slug\": \"nazar_amulet\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪬\": {\n    \"name\": \"hamsa\",\n    \"slug\": \"hamsa\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🗿\": {\n    \"name\": \"moai\",\n    \"slug\": \"moai\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪧\": {\n    \"name\": \"placard\",\n    \"slug\": \"placard\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🪪\": {\n    \"name\": \"identification card\",\n    \"slug\": \"identification_card\",\n    \"group\": \"Objects\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏧\": {\n    \"name\": \"ATM sign\",\n    \"slug\": \"atm_sign\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚮\": {\n    \"name\": \"litter in bin sign\",\n    \"slug\": \"litter_in_bin_sign\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚰\": {\n    \"name\": \"potable water\",\n    \"slug\": \"potable_water\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"♿\": {\n    \"name\": \"wheelchair symbol\",\n    \"slug\": \"wheelchair_symbol\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚹\": {\n    \"name\": \"men’s room\",\n    \"slug\": \"men_s_room\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚺\": {\n    \"name\": \"women’s room\",\n    \"slug\": \"women_s_room\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚻\": {\n    \"name\": \"restroom\",\n    \"slug\": \"restroom\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚼\": {\n    \"name\": \"baby symbol\",\n    \"slug\": \"baby_symbol\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚾\": {\n    \"name\": \"water closet\",\n    \"slug\": \"water_closet\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛂\": {\n    \"name\": \"passport control\",\n    \"slug\": \"passport_control\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛃\": {\n    \"name\": \"customs\",\n    \"slug\": \"customs\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛄\": {\n    \"name\": \"baggage claim\",\n    \"slug\": \"baggage_claim\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🛅\": {\n    \"name\": \"left luggage\",\n    \"slug\": \"left_luggage\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"⚠️\": {\n    \"name\": \"warning\",\n    \"slug\": \"warning\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚸\": {\n    \"name\": \"children crossing\",\n    \"slug\": \"children_crossing\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"⛔\": {\n    \"name\": \"no entry\",\n    \"slug\": \"no_entry\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚫\": {\n    \"name\": \"prohibited\",\n    \"slug\": \"prohibited\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚳\": {\n    \"name\": \"no bicycles\",\n    \"slug\": \"no_bicycles\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚭\": {\n    \"name\": \"no smoking\",\n    \"slug\": \"no_smoking\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚯\": {\n    \"name\": \"no littering\",\n    \"slug\": \"no_littering\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚱\": {\n    \"name\": \"non-potable water\",\n    \"slug\": \"non_potable_water\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🚷\": {\n    \"name\": \"no pedestrians\",\n    \"slug\": \"no_pedestrians\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"📵\": {\n    \"name\": \"no mobile phones\",\n    \"slug\": \"no_mobile_phones\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔞\": {\n    \"name\": \"no one under eighteen\",\n    \"slug\": \"no_one_under_eighteen\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"☢️\": {\n    \"name\": \"radioactive\",\n    \"slug\": \"radioactive\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"☣️\": {\n    \"name\": \"biohazard\",\n    \"slug\": \"biohazard\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"⬆️\": {\n    \"name\": \"up arrow\",\n    \"slug\": \"up_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"↗️\": {\n    \"name\": \"up-right arrow\",\n    \"slug\": \"up_right_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"➡️\": {\n    \"name\": \"right arrow\",\n    \"slug\": \"right_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"↘️\": {\n    \"name\": \"down-right arrow\",\n    \"slug\": \"down_right_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⬇️\": {\n    \"name\": \"down arrow\",\n    \"slug\": \"down_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"↙️\": {\n    \"name\": \"down-left arrow\",\n    \"slug\": \"down_left_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⬅️\": {\n    \"name\": \"left arrow\",\n    \"slug\": \"left_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"↖️\": {\n    \"name\": \"up-left arrow\",\n    \"slug\": \"up_left_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"↕️\": {\n    \"name\": \"up-down arrow\",\n    \"slug\": \"up_down_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"↔️\": {\n    \"name\": \"left-right arrow\",\n    \"slug\": \"left_right_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"↩️\": {\n    \"name\": \"right arrow curving left\",\n    \"slug\": \"right_arrow_curving_left\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"↪️\": {\n    \"name\": \"left arrow curving right\",\n    \"slug\": \"left_arrow_curving_right\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⤴️\": {\n    \"name\": \"right arrow curving up\",\n    \"slug\": \"right_arrow_curving_up\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⤵️\": {\n    \"name\": \"right arrow curving down\",\n    \"slug\": \"right_arrow_curving_down\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔃\": {\n    \"name\": \"clockwise vertical arrows\",\n    \"slug\": \"clockwise_vertical_arrows\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔄\": {\n    \"name\": \"counterclockwise arrows button\",\n    \"slug\": \"counterclockwise_arrows_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔙\": {\n    \"name\": \"BACK arrow\",\n    \"slug\": \"back_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔚\": {\n    \"name\": \"END arrow\",\n    \"slug\": \"end_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔛\": {\n    \"name\": \"ON! arrow\",\n    \"slug\": \"on_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔜\": {\n    \"name\": \"SOON arrow\",\n    \"slug\": \"soon_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔝\": {\n    \"name\": \"TOP arrow\",\n    \"slug\": \"top_arrow\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛐\": {\n    \"name\": \"place of worship\",\n    \"slug\": \"place_of_worship\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"⚛️\": {\n    \"name\": \"atom symbol\",\n    \"slug\": \"atom_symbol\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🕉️\": {\n    \"name\": \"om\",\n    \"slug\": \"om\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"✡️\": {\n    \"name\": \"star of David\",\n    \"slug\": \"star_of_david\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"☸️\": {\n    \"name\": \"wheel of dharma\",\n    \"slug\": \"wheel_of_dharma\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"☯️\": {\n    \"name\": \"yin yang\",\n    \"slug\": \"yin_yang\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"✝️\": {\n    \"name\": \"latin cross\",\n    \"slug\": \"latin_cross\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"☦️\": {\n    \"name\": \"orthodox cross\",\n    \"slug\": \"orthodox_cross\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"☪️\": {\n    \"name\": \"star and crescent\",\n    \"slug\": \"star_and_crescent\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"☮️\": {\n    \"name\": \"peace symbol\",\n    \"slug\": \"peace_symbol\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🕎\": {\n    \"name\": \"menorah\",\n    \"slug\": \"menorah\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔯\": {\n    \"name\": \"dotted six-pointed star\",\n    \"slug\": \"dotted_six_pointed_star\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🪯\": {\n    \"name\": \"khanda\",\n    \"slug\": \"khanda\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"♈\": {\n    \"name\": \"Aries\",\n    \"slug\": \"aries\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♉\": {\n    \"name\": \"Taurus\",\n    \"slug\": \"taurus\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♊\": {\n    \"name\": \"Gemini\",\n    \"slug\": \"gemini\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♋\": {\n    \"name\": \"Cancer\",\n    \"slug\": \"cancer\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♌\": {\n    \"name\": \"Leo\",\n    \"slug\": \"leo\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♍\": {\n    \"name\": \"Virgo\",\n    \"slug\": \"virgo\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♎\": {\n    \"name\": \"Libra\",\n    \"slug\": \"libra\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♏\": {\n    \"name\": \"Scorpio\",\n    \"slug\": \"scorpio\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♐\": {\n    \"name\": \"Sagittarius\",\n    \"slug\": \"sagittarius\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♑\": {\n    \"name\": \"Capricorn\",\n    \"slug\": \"capricorn\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♒\": {\n    \"name\": \"Aquarius\",\n    \"slug\": \"aquarius\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♓\": {\n    \"name\": \"Pisces\",\n    \"slug\": \"pisces\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⛎\": {\n    \"name\": \"Ophiuchus\",\n    \"slug\": \"ophiuchus\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔀\": {\n    \"name\": \"shuffle tracks button\",\n    \"slug\": \"shuffle_tracks_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔁\": {\n    \"name\": \"repeat button\",\n    \"slug\": \"repeat_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔂\": {\n    \"name\": \"repeat single button\",\n    \"slug\": \"repeat_single_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"▶️\": {\n    \"name\": \"play button\",\n    \"slug\": \"play_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏩\": {\n    \"name\": \"fast-forward button\",\n    \"slug\": \"fast_forward_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏭️\": {\n    \"name\": \"next track button\",\n    \"slug\": \"next_track_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⏯️\": {\n    \"name\": \"play or pause button\",\n    \"slug\": \"play_or_pause_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"◀️\": {\n    \"name\": \"reverse button\",\n    \"slug\": \"reverse_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏪\": {\n    \"name\": \"fast reverse button\",\n    \"slug\": \"fast_reverse_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏮️\": {\n    \"name\": \"last track button\",\n    \"slug\": \"last_track_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🔼\": {\n    \"name\": \"upwards button\",\n    \"slug\": \"upwards_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏫\": {\n    \"name\": \"fast up button\",\n    \"slug\": \"fast_up_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔽\": {\n    \"name\": \"downwards button\",\n    \"slug\": \"downwards_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏬\": {\n    \"name\": \"fast down button\",\n    \"slug\": \"fast_down_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⏸️\": {\n    \"name\": \"pause button\",\n    \"slug\": \"pause_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⏹️\": {\n    \"name\": \"stop button\",\n    \"slug\": \"stop_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⏺️\": {\n    \"name\": \"record button\",\n    \"slug\": \"record_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"⏏️\": {\n    \"name\": \"eject button\",\n    \"slug\": \"eject_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🎦\": {\n    \"name\": \"cinema\",\n    \"slug\": \"cinema\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔅\": {\n    \"name\": \"dim button\",\n    \"slug\": \"dim_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔆\": {\n    \"name\": \"bright button\",\n    \"slug\": \"bright_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"📶\": {\n    \"name\": \"antenna bars\",\n    \"slug\": \"antenna_bars\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🛜\": {\n    \"name\": \"wireless\",\n    \"slug\": \"wireless\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"15.0\",\n    \"unicode_version\": \"15.0\",\n    \"skin_tone_support\": false\n  },\n  \"📳\": {\n    \"name\": \"vibration mode\",\n    \"slug\": \"vibration_mode\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📴\": {\n    \"name\": \"mobile phone off\",\n    \"slug\": \"mobile_phone_off\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"♀️\": {\n    \"name\": \"female sign\",\n    \"slug\": \"female_sign\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"♂️\": {\n    \"name\": \"male sign\",\n    \"slug\": \"male_sign\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"⚧️\": {\n    \"name\": \"transgender symbol\",\n    \"slug\": \"transgender_symbol\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"✖️\": {\n    \"name\": \"multiply\",\n    \"slug\": \"multiply\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"➕\": {\n    \"name\": \"plus\",\n    \"slug\": \"plus\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"➖\": {\n    \"name\": \"minus\",\n    \"slug\": \"minus\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"➗\": {\n    \"name\": \"divide\",\n    \"slug\": \"divide\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🟰\": {\n    \"name\": \"heavy equals sign\",\n    \"slug\": \"heavy_equals_sign\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"14.0\",\n    \"unicode_version\": \"14.0\",\n    \"skin_tone_support\": false\n  },\n  \"♾️\": {\n    \"name\": \"infinity\",\n    \"slug\": \"infinity\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"‼️\": {\n    \"name\": \"double exclamation mark\",\n    \"slug\": \"double_exclamation_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⁉️\": {\n    \"name\": \"exclamation question mark\",\n    \"slug\": \"exclamation_question_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❓\": {\n    \"name\": \"red question mark\",\n    \"slug\": \"red_question_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❔\": {\n    \"name\": \"white question mark\",\n    \"slug\": \"white_question_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❕\": {\n    \"name\": \"white exclamation mark\",\n    \"slug\": \"white_exclamation_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❗\": {\n    \"name\": \"red exclamation mark\",\n    \"slug\": \"red_exclamation_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"〰️\": {\n    \"name\": \"wavy dash\",\n    \"slug\": \"wavy_dash\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💱\": {\n    \"name\": \"currency exchange\",\n    \"slug\": \"currency_exchange\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💲\": {\n    \"name\": \"heavy dollar sign\",\n    \"slug\": \"heavy_dollar_sign\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⚕️\": {\n    \"name\": \"medical symbol\",\n    \"slug\": \"medical_symbol\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"♻️\": {\n    \"name\": \"recycling symbol\",\n    \"slug\": \"recycling_symbol\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⚜️\": {\n    \"name\": \"fleur-de-lis\",\n    \"slug\": \"fleur_de_lis\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔱\": {\n    \"name\": \"trident emblem\",\n    \"slug\": \"trident_emblem\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"📛\": {\n    \"name\": \"name badge\",\n    \"slug\": \"name_badge\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔰\": {\n    \"name\": \"Japanese symbol for beginner\",\n    \"slug\": \"japanese_symbol_for_beginner\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⭕\": {\n    \"name\": \"hollow red circle\",\n    \"slug\": \"hollow_red_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"✅\": {\n    \"name\": \"check mark button\",\n    \"slug\": \"check_mark_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"☑️\": {\n    \"name\": \"check box with check\",\n    \"slug\": \"check_box_with_check\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"✔️\": {\n    \"name\": \"check mark\",\n    \"slug\": \"check_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❌\": {\n    \"name\": \"cross mark\",\n    \"slug\": \"cross_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❎\": {\n    \"name\": \"cross mark button\",\n    \"slug\": \"cross_mark_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"➰\": {\n    \"name\": \"curly loop\",\n    \"slug\": \"curly_loop\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"➿\": {\n    \"name\": \"double curly loop\",\n    \"slug\": \"double_curly_loop\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"〽️\": {\n    \"name\": \"part alternation mark\",\n    \"slug\": \"part_alternation_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"✳️\": {\n    \"name\": \"eight-spoked asterisk\",\n    \"slug\": \"eight_spoked_asterisk\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"✴️\": {\n    \"name\": \"eight-pointed star\",\n    \"slug\": \"eight_pointed_star\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"❇️\": {\n    \"name\": \"sparkle\",\n    \"slug\": \"sparkle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"©️\": {\n    \"name\": \"copyright\",\n    \"slug\": \"copyright\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"®️\": {\n    \"name\": \"registered\",\n    \"slug\": \"registered\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"™️\": {\n    \"name\": \"trade mark\",\n    \"slug\": \"trade_mark\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"#️⃣\": {\n    \"name\": \"keycap #\",\n    \"slug\": \"keycap_number_sign\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"*️⃣\": {\n    \"name\": \"keycap *\",\n    \"slug\": \"keycap_asterisk\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"0️⃣\": {\n    \"name\": \"keycap 0\",\n    \"slug\": \"keycap_0\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"1️⃣\": {\n    \"name\": \"keycap 1\",\n    \"slug\": \"keycap_1\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"2️⃣\": {\n    \"name\": \"keycap 2\",\n    \"slug\": \"keycap_2\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"3️⃣\": {\n    \"name\": \"keycap 3\",\n    \"slug\": \"keycap_3\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"4️⃣\": {\n    \"name\": \"keycap 4\",\n    \"slug\": \"keycap_4\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"5️⃣\": {\n    \"name\": \"keycap 5\",\n    \"slug\": \"keycap_5\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"6️⃣\": {\n    \"name\": \"keycap 6\",\n    \"slug\": \"keycap_6\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"7️⃣\": {\n    \"name\": \"keycap 7\",\n    \"slug\": \"keycap_7\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"8️⃣\": {\n    \"name\": \"keycap 8\",\n    \"slug\": \"keycap_8\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"9️⃣\": {\n    \"name\": \"keycap 9\",\n    \"slug\": \"keycap_9\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔟\": {\n    \"name\": \"keycap 10\",\n    \"slug\": \"keycap_10\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔠\": {\n    \"name\": \"input latin uppercase\",\n    \"slug\": \"input_latin_uppercase\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔡\": {\n    \"name\": \"input latin lowercase\",\n    \"slug\": \"input_latin_lowercase\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔢\": {\n    \"name\": \"input numbers\",\n    \"slug\": \"input_numbers\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔣\": {\n    \"name\": \"input symbols\",\n    \"slug\": \"input_symbols\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔤\": {\n    \"name\": \"input latin letters\",\n    \"slug\": \"input_latin_letters\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🅰️\": {\n    \"name\": \"A button (blood type)\",\n    \"slug\": \"a_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆎\": {\n    \"name\": \"AB button (blood type)\",\n    \"slug\": \"ab_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🅱️\": {\n    \"name\": \"B button (blood type)\",\n    \"slug\": \"b_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆑\": {\n    \"name\": \"CL button\",\n    \"slug\": \"cl_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆒\": {\n    \"name\": \"COOL button\",\n    \"slug\": \"cool_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆓\": {\n    \"name\": \"FREE button\",\n    \"slug\": \"free_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"ℹ️\": {\n    \"name\": \"information\",\n    \"slug\": \"information\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆔\": {\n    \"name\": \"ID button\",\n    \"slug\": \"id_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"Ⓜ️\": {\n    \"name\": \"circled M\",\n    \"slug\": \"circled_m\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆕\": {\n    \"name\": \"NEW button\",\n    \"slug\": \"new_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆖\": {\n    \"name\": \"NG button\",\n    \"slug\": \"ng_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🅾️\": {\n    \"name\": \"O button (blood type)\",\n    \"slug\": \"o_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆗\": {\n    \"name\": \"OK button\",\n    \"slug\": \"ok_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🅿️\": {\n    \"name\": \"P button\",\n    \"slug\": \"p_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆘\": {\n    \"name\": \"SOS button\",\n    \"slug\": \"sos_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆙\": {\n    \"name\": \"UP! button\",\n    \"slug\": \"up_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🆚\": {\n    \"name\": \"VS button\",\n    \"slug\": \"vs_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈁\": {\n    \"name\": \"Japanese “here” button\",\n    \"slug\": \"japanese_here_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈂️\": {\n    \"name\": \"Japanese “service charge” button\",\n    \"slug\": \"japanese_service_charge_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈷️\": {\n    \"name\": \"Japanese “monthly amount” button\",\n    \"slug\": \"japanese_monthly_amount_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈶\": {\n    \"name\": \"Japanese “not free of charge” button\",\n    \"slug\": \"japanese_not_free_of_charge_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈯\": {\n    \"name\": \"Japanese “reserved” button\",\n    \"slug\": \"japanese_reserved_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🉐\": {\n    \"name\": \"Japanese “bargain” button\",\n    \"slug\": \"japanese_bargain_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈹\": {\n    \"name\": \"Japanese “discount” button\",\n    \"slug\": \"japanese_discount_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈚\": {\n    \"name\": \"Japanese “free of charge” button\",\n    \"slug\": \"japanese_free_of_charge_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈲\": {\n    \"name\": \"Japanese “prohibited” button\",\n    \"slug\": \"japanese_prohibited_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🉑\": {\n    \"name\": \"Japanese “acceptable” button\",\n    \"slug\": \"japanese_acceptable_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈸\": {\n    \"name\": \"Japanese “application” button\",\n    \"slug\": \"japanese_application_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈴\": {\n    \"name\": \"Japanese “passing grade” button\",\n    \"slug\": \"japanese_passing_grade_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈳\": {\n    \"name\": \"Japanese “vacancy” button\",\n    \"slug\": \"japanese_vacancy_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"㊗️\": {\n    \"name\": \"Japanese “congratulations” button\",\n    \"slug\": \"japanese_congratulations_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"㊙️\": {\n    \"name\": \"Japanese “secret” button\",\n    \"slug\": \"japanese_secret_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈺\": {\n    \"name\": \"Japanese “open for business” button\",\n    \"slug\": \"japanese_open_for_business_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🈵\": {\n    \"name\": \"Japanese “no vacancy” button\",\n    \"slug\": \"japanese_no_vacancy_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔴\": {\n    \"name\": \"red circle\",\n    \"slug\": \"red_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🟠\": {\n    \"name\": \"orange circle\",\n    \"slug\": \"orange_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🟡\": {\n    \"name\": \"yellow circle\",\n    \"slug\": \"yellow_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🟢\": {\n    \"name\": \"green circle\",\n    \"slug\": \"green_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🔵\": {\n    \"name\": \"blue circle\",\n    \"slug\": \"blue_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🟣\": {\n    \"name\": \"purple circle\",\n    \"slug\": \"purple_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🟤\": {\n    \"name\": \"brown circle\",\n    \"slug\": \"brown_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"⚫\": {\n    \"name\": \"black circle\",\n    \"slug\": \"black_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⚪\": {\n    \"name\": \"white circle\",\n    \"slug\": \"white_circle\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🟥\": {\n    \"name\": \"red square\",\n    \"slug\": \"red_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🟧\": {\n    \"name\": \"orange square\",\n    \"slug\": \"orange_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🟨\": {\n    \"name\": \"yellow square\",\n    \"slug\": \"yellow_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🟩\": {\n    \"name\": \"green square\",\n    \"slug\": \"green_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🟦\": {\n    \"name\": \"blue square\",\n    \"slug\": \"blue_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🟪\": {\n    \"name\": \"purple square\",\n    \"slug\": \"purple_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"🟫\": {\n    \"name\": \"brown square\",\n    \"slug\": \"brown_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"12.0\",\n    \"unicode_version\": \"12.0\",\n    \"skin_tone_support\": false\n  },\n  \"⬛\": {\n    \"name\": \"black large square\",\n    \"slug\": \"black_large_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"⬜\": {\n    \"name\": \"white large square\",\n    \"slug\": \"white_large_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"◼️\": {\n    \"name\": \"black medium square\",\n    \"slug\": \"black_medium_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"◻️\": {\n    \"name\": \"white medium square\",\n    \"slug\": \"white_medium_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"◾\": {\n    \"name\": \"black medium-small square\",\n    \"slug\": \"black_medium_small_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"◽\": {\n    \"name\": \"white medium-small square\",\n    \"slug\": \"white_medium_small_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"▪️\": {\n    \"name\": \"black small square\",\n    \"slug\": \"black_small_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"▫️\": {\n    \"name\": \"white small square\",\n    \"slug\": \"white_small_square\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔶\": {\n    \"name\": \"large orange diamond\",\n    \"slug\": \"large_orange_diamond\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔷\": {\n    \"name\": \"large blue diamond\",\n    \"slug\": \"large_blue_diamond\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔸\": {\n    \"name\": \"small orange diamond\",\n    \"slug\": \"small_orange_diamond\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔹\": {\n    \"name\": \"small blue diamond\",\n    \"slug\": \"small_blue_diamond\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔺\": {\n    \"name\": \"red triangle pointed up\",\n    \"slug\": \"red_triangle_pointed_up\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔻\": {\n    \"name\": \"red triangle pointed down\",\n    \"slug\": \"red_triangle_pointed_down\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"💠\": {\n    \"name\": \"diamond with a dot\",\n    \"slug\": \"diamond_with_a_dot\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔘\": {\n    \"name\": \"radio button\",\n    \"slug\": \"radio_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔳\": {\n    \"name\": \"white square button\",\n    \"slug\": \"white_square_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🔲\": {\n    \"name\": \"black square button\",\n    \"slug\": \"black_square_button\",\n    \"group\": \"Symbols\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏁\": {\n    \"name\": \"chequered flag\",\n    \"slug\": \"chequered_flag\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🚩\": {\n    \"name\": \"triangular flag\",\n    \"slug\": \"triangular_flag\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🎌\": {\n    \"name\": \"crossed flags\",\n    \"slug\": \"crossed_flags\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🏴\": {\n    \"name\": \"black flag\",\n    \"slug\": \"black_flag\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"1.0\",\n    \"unicode_version\": \"1.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏳️\": {\n    \"name\": \"white flag\",\n    \"slug\": \"white_flag\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.7\",\n    \"unicode_version\": \"0.7\",\n    \"skin_tone_support\": false\n  },\n  \"🏳️‍🌈\": {\n    \"name\": \"rainbow flag\",\n    \"slug\": \"rainbow_flag\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏳️‍⚧️\": {\n    \"name\": \"transgender flag\",\n    \"slug\": \"transgender_flag\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"13.0\",\n    \"unicode_version\": \"13.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏴‍☠️\": {\n    \"name\": \"pirate flag\",\n    \"slug\": \"pirate_flag\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"11.0\",\n    \"unicode_version\": \"11.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇨\": {\n    \"name\": \"flag Ascension Island\",\n    \"slug\": \"flag_ascension_island\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇩\": {\n    \"name\": \"flag Andorra\",\n    \"slug\": \"flag_andorra\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇪\": {\n    \"name\": \"flag United Arab Emirates\",\n    \"slug\": \"flag_united_arab_emirates\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇫\": {\n    \"name\": \"flag Afghanistan\",\n    \"slug\": \"flag_afghanistan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇬\": {\n    \"name\": \"flag Antigua & Barbuda\",\n    \"slug\": \"flag_antigua_barbuda\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇮\": {\n    \"name\": \"flag Anguilla\",\n    \"slug\": \"flag_anguilla\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇱\": {\n    \"name\": \"flag Albania\",\n    \"slug\": \"flag_albania\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇲\": {\n    \"name\": \"flag Armenia\",\n    \"slug\": \"flag_armenia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇴\": {\n    \"name\": \"flag Angola\",\n    \"slug\": \"flag_angola\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇶\": {\n    \"name\": \"flag Antarctica\",\n    \"slug\": \"flag_antarctica\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇷\": {\n    \"name\": \"flag Argentina\",\n    \"slug\": \"flag_argentina\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇸\": {\n    \"name\": \"flag American Samoa\",\n    \"slug\": \"flag_american_samoa\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇹\": {\n    \"name\": \"flag Austria\",\n    \"slug\": \"flag_austria\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇺\": {\n    \"name\": \"flag Australia\",\n    \"slug\": \"flag_australia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇼\": {\n    \"name\": \"flag Aruba\",\n    \"slug\": \"flag_aruba\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇽\": {\n    \"name\": \"flag Åland Islands\",\n    \"slug\": \"flag_aland_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇦🇿\": {\n    \"name\": \"flag Azerbaijan\",\n    \"slug\": \"flag_azerbaijan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇦\": {\n    \"name\": \"flag Bosnia & Herzegovina\",\n    \"slug\": \"flag_bosnia_herzegovina\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇧\": {\n    \"name\": \"flag Barbados\",\n    \"slug\": \"flag_barbados\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇩\": {\n    \"name\": \"flag Bangladesh\",\n    \"slug\": \"flag_bangladesh\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇪\": {\n    \"name\": \"flag Belgium\",\n    \"slug\": \"flag_belgium\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇫\": {\n    \"name\": \"flag Burkina Faso\",\n    \"slug\": \"flag_burkina_faso\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇬\": {\n    \"name\": \"flag Bulgaria\",\n    \"slug\": \"flag_bulgaria\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇭\": {\n    \"name\": \"flag Bahrain\",\n    \"slug\": \"flag_bahrain\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇮\": {\n    \"name\": \"flag Burundi\",\n    \"slug\": \"flag_burundi\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇯\": {\n    \"name\": \"flag Benin\",\n    \"slug\": \"flag_benin\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇱\": {\n    \"name\": \"flag St. Barthélemy\",\n    \"slug\": \"flag_st_barthelemy\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇲\": {\n    \"name\": \"flag Bermuda\",\n    \"slug\": \"flag_bermuda\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇳\": {\n    \"name\": \"flag Brunei\",\n    \"slug\": \"flag_brunei\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇴\": {\n    \"name\": \"flag Bolivia\",\n    \"slug\": \"flag_bolivia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇶\": {\n    \"name\": \"flag Caribbean Netherlands\",\n    \"slug\": \"flag_caribbean_netherlands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇷\": {\n    \"name\": \"flag Brazil\",\n    \"slug\": \"flag_brazil\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇸\": {\n    \"name\": \"flag Bahamas\",\n    \"slug\": \"flag_bahamas\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇹\": {\n    \"name\": \"flag Bhutan\",\n    \"slug\": \"flag_bhutan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇻\": {\n    \"name\": \"flag Bouvet Island\",\n    \"slug\": \"flag_bouvet_island\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇼\": {\n    \"name\": \"flag Botswana\",\n    \"slug\": \"flag_botswana\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇾\": {\n    \"name\": \"flag Belarus\",\n    \"slug\": \"flag_belarus\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇧🇿\": {\n    \"name\": \"flag Belize\",\n    \"slug\": \"flag_belize\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇦\": {\n    \"name\": \"flag Canada\",\n    \"slug\": \"flag_canada\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇨\": {\n    \"name\": \"flag Cocos (Keeling) Islands\",\n    \"slug\": \"flag_cocos_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇩\": {\n    \"name\": \"flag Congo - Kinshasa\",\n    \"slug\": \"flag_congo_kinshasa\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇫\": {\n    \"name\": \"flag Central African Republic\",\n    \"slug\": \"flag_central_african_republic\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇬\": {\n    \"name\": \"flag Congo - Brazzaville\",\n    \"slug\": \"flag_congo_brazzaville\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇭\": {\n    \"name\": \"flag Switzerland\",\n    \"slug\": \"flag_switzerland\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇮\": {\n    \"name\": \"flag Côte d’Ivoire\",\n    \"slug\": \"flag_cote_d_ivoire\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇰\": {\n    \"name\": \"flag Cook Islands\",\n    \"slug\": \"flag_cook_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇱\": {\n    \"name\": \"flag Chile\",\n    \"slug\": \"flag_chile\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇲\": {\n    \"name\": \"flag Cameroon\",\n    \"slug\": \"flag_cameroon\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇳\": {\n    \"name\": \"flag China\",\n    \"slug\": \"flag_china\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇴\": {\n    \"name\": \"flag Colombia\",\n    \"slug\": \"flag_colombia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇵\": {\n    \"name\": \"flag Clipperton Island\",\n    \"slug\": \"flag_clipperton_island\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇷\": {\n    \"name\": \"flag Costa Rica\",\n    \"slug\": \"flag_costa_rica\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇺\": {\n    \"name\": \"flag Cuba\",\n    \"slug\": \"flag_cuba\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇻\": {\n    \"name\": \"flag Cape Verde\",\n    \"slug\": \"flag_cape_verde\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇼\": {\n    \"name\": \"flag Curaçao\",\n    \"slug\": \"flag_curacao\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇽\": {\n    \"name\": \"flag Christmas Island\",\n    \"slug\": \"flag_christmas_island\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇾\": {\n    \"name\": \"flag Cyprus\",\n    \"slug\": \"flag_cyprus\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇨🇿\": {\n    \"name\": \"flag Czechia\",\n    \"slug\": \"flag_czechia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇩🇪\": {\n    \"name\": \"flag Germany\",\n    \"slug\": \"flag_germany\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇩🇬\": {\n    \"name\": \"flag Diego Garcia\",\n    \"slug\": \"flag_diego_garcia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇩🇯\": {\n    \"name\": \"flag Djibouti\",\n    \"slug\": \"flag_djibouti\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇩🇰\": {\n    \"name\": \"flag Denmark\",\n    \"slug\": \"flag_denmark\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇩🇲\": {\n    \"name\": \"flag Dominica\",\n    \"slug\": \"flag_dominica\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇩🇴\": {\n    \"name\": \"flag Dominican Republic\",\n    \"slug\": \"flag_dominican_republic\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇩🇿\": {\n    \"name\": \"flag Algeria\",\n    \"slug\": \"flag_algeria\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇪🇦\": {\n    \"name\": \"flag Ceuta & Melilla\",\n    \"slug\": \"flag_ceuta_melilla\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇪🇨\": {\n    \"name\": \"flag Ecuador\",\n    \"slug\": \"flag_ecuador\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇪🇪\": {\n    \"name\": \"flag Estonia\",\n    \"slug\": \"flag_estonia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇪🇬\": {\n    \"name\": \"flag Egypt\",\n    \"slug\": \"flag_egypt\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇪🇭\": {\n    \"name\": \"flag Western Sahara\",\n    \"slug\": \"flag_western_sahara\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇪🇷\": {\n    \"name\": \"flag Eritrea\",\n    \"slug\": \"flag_eritrea\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇪🇸\": {\n    \"name\": \"flag Spain\",\n    \"slug\": \"flag_spain\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇪🇹\": {\n    \"name\": \"flag Ethiopia\",\n    \"slug\": \"flag_ethiopia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇪🇺\": {\n    \"name\": \"flag European Union\",\n    \"slug\": \"flag_european_union\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇫🇮\": {\n    \"name\": \"flag Finland\",\n    \"slug\": \"flag_finland\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇫🇯\": {\n    \"name\": \"flag Fiji\",\n    \"slug\": \"flag_fiji\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇫🇰\": {\n    \"name\": \"flag Falkland Islands\",\n    \"slug\": \"flag_falkland_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇫🇲\": {\n    \"name\": \"flag Micronesia\",\n    \"slug\": \"flag_micronesia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇫🇴\": {\n    \"name\": \"flag Faroe Islands\",\n    \"slug\": \"flag_faroe_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇫🇷\": {\n    \"name\": \"flag France\",\n    \"slug\": \"flag_france\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇦\": {\n    \"name\": \"flag Gabon\",\n    \"slug\": \"flag_gabon\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇧\": {\n    \"name\": \"flag United Kingdom\",\n    \"slug\": \"flag_united_kingdom\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇩\": {\n    \"name\": \"flag Grenada\",\n    \"slug\": \"flag_grenada\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇪\": {\n    \"name\": \"flag Georgia\",\n    \"slug\": \"flag_georgia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇫\": {\n    \"name\": \"flag French Guiana\",\n    \"slug\": \"flag_french_guiana\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇬\": {\n    \"name\": \"flag Guernsey\",\n    \"slug\": \"flag_guernsey\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇭\": {\n    \"name\": \"flag Ghana\",\n    \"slug\": \"flag_ghana\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇮\": {\n    \"name\": \"flag Gibraltar\",\n    \"slug\": \"flag_gibraltar\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇱\": {\n    \"name\": \"flag Greenland\",\n    \"slug\": \"flag_greenland\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇲\": {\n    \"name\": \"flag Gambia\",\n    \"slug\": \"flag_gambia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇳\": {\n    \"name\": \"flag Guinea\",\n    \"slug\": \"flag_guinea\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇵\": {\n    \"name\": \"flag Guadeloupe\",\n    \"slug\": \"flag_guadeloupe\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇶\": {\n    \"name\": \"flag Equatorial Guinea\",\n    \"slug\": \"flag_equatorial_guinea\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇷\": {\n    \"name\": \"flag Greece\",\n    \"slug\": \"flag_greece\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇸\": {\n    \"name\": \"flag South Georgia & South Sandwich Islands\",\n    \"slug\": \"flag_south_georgia_south_sandwich_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇹\": {\n    \"name\": \"flag Guatemala\",\n    \"slug\": \"flag_guatemala\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇺\": {\n    \"name\": \"flag Guam\",\n    \"slug\": \"flag_guam\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇼\": {\n    \"name\": \"flag Guinea-Bissau\",\n    \"slug\": \"flag_guinea_bissau\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇬🇾\": {\n    \"name\": \"flag Guyana\",\n    \"slug\": \"flag_guyana\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇭🇰\": {\n    \"name\": \"flag Hong Kong SAR China\",\n    \"slug\": \"flag_hong_kong_sar_china\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇭🇲\": {\n    \"name\": \"flag Heard & McDonald Islands\",\n    \"slug\": \"flag_heard_mcdonald_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇭🇳\": {\n    \"name\": \"flag Honduras\",\n    \"slug\": \"flag_honduras\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇭🇷\": {\n    \"name\": \"flag Croatia\",\n    \"slug\": \"flag_croatia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇭🇹\": {\n    \"name\": \"flag Haiti\",\n    \"slug\": \"flag_haiti\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇭🇺\": {\n    \"name\": \"flag Hungary\",\n    \"slug\": \"flag_hungary\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇨\": {\n    \"name\": \"flag Canary Islands\",\n    \"slug\": \"flag_canary_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇩\": {\n    \"name\": \"flag Indonesia\",\n    \"slug\": \"flag_indonesia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇪\": {\n    \"name\": \"flag Ireland\",\n    \"slug\": \"flag_ireland\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇱\": {\n    \"name\": \"flag Israel\",\n    \"slug\": \"flag_israel\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇲\": {\n    \"name\": \"flag Isle of Man\",\n    \"slug\": \"flag_isle_of_man\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇳\": {\n    \"name\": \"flag India\",\n    \"slug\": \"flag_india\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇴\": {\n    \"name\": \"flag British Indian Ocean Territory\",\n    \"slug\": \"flag_british_indian_ocean_territory\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇶\": {\n    \"name\": \"flag Iraq\",\n    \"slug\": \"flag_iraq\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇷\": {\n    \"name\": \"flag Iran\",\n    \"slug\": \"flag_iran\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇸\": {\n    \"name\": \"flag Iceland\",\n    \"slug\": \"flag_iceland\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇮🇹\": {\n    \"name\": \"flag Italy\",\n    \"slug\": \"flag_italy\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇯🇪\": {\n    \"name\": \"flag Jersey\",\n    \"slug\": \"flag_jersey\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇯🇲\": {\n    \"name\": \"flag Jamaica\",\n    \"slug\": \"flag_jamaica\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇯🇴\": {\n    \"name\": \"flag Jordan\",\n    \"slug\": \"flag_jordan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇯🇵\": {\n    \"name\": \"flag Japan\",\n    \"slug\": \"flag_japan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇪\": {\n    \"name\": \"flag Kenya\",\n    \"slug\": \"flag_kenya\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇬\": {\n    \"name\": \"flag Kyrgyzstan\",\n    \"slug\": \"flag_kyrgyzstan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇭\": {\n    \"name\": \"flag Cambodia\",\n    \"slug\": \"flag_cambodia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇮\": {\n    \"name\": \"flag Kiribati\",\n    \"slug\": \"flag_kiribati\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇲\": {\n    \"name\": \"flag Comoros\",\n    \"slug\": \"flag_comoros\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇳\": {\n    \"name\": \"flag St. Kitts & Nevis\",\n    \"slug\": \"flag_st_kitts_nevis\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇵\": {\n    \"name\": \"flag North Korea\",\n    \"slug\": \"flag_north_korea\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇷\": {\n    \"name\": \"flag South Korea\",\n    \"slug\": \"flag_south_korea\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇼\": {\n    \"name\": \"flag Kuwait\",\n    \"slug\": \"flag_kuwait\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇾\": {\n    \"name\": \"flag Cayman Islands\",\n    \"slug\": \"flag_cayman_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇰🇿\": {\n    \"name\": \"flag Kazakhstan\",\n    \"slug\": \"flag_kazakhstan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇦\": {\n    \"name\": \"flag Laos\",\n    \"slug\": \"flag_laos\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇧\": {\n    \"name\": \"flag Lebanon\",\n    \"slug\": \"flag_lebanon\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇨\": {\n    \"name\": \"flag St. Lucia\",\n    \"slug\": \"flag_st_lucia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇮\": {\n    \"name\": \"flag Liechtenstein\",\n    \"slug\": \"flag_liechtenstein\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇰\": {\n    \"name\": \"flag Sri Lanka\",\n    \"slug\": \"flag_sri_lanka\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇷\": {\n    \"name\": \"flag Liberia\",\n    \"slug\": \"flag_liberia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇸\": {\n    \"name\": \"flag Lesotho\",\n    \"slug\": \"flag_lesotho\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇹\": {\n    \"name\": \"flag Lithuania\",\n    \"slug\": \"flag_lithuania\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇺\": {\n    \"name\": \"flag Luxembourg\",\n    \"slug\": \"flag_luxembourg\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇻\": {\n    \"name\": \"flag Latvia\",\n    \"slug\": \"flag_latvia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇱🇾\": {\n    \"name\": \"flag Libya\",\n    \"slug\": \"flag_libya\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇦\": {\n    \"name\": \"flag Morocco\",\n    \"slug\": \"flag_morocco\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇨\": {\n    \"name\": \"flag Monaco\",\n    \"slug\": \"flag_monaco\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇩\": {\n    \"name\": \"flag Moldova\",\n    \"slug\": \"flag_moldova\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇪\": {\n    \"name\": \"flag Montenegro\",\n    \"slug\": \"flag_montenegro\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇫\": {\n    \"name\": \"flag St. Martin\",\n    \"slug\": \"flag_st_martin\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇬\": {\n    \"name\": \"flag Madagascar\",\n    \"slug\": \"flag_madagascar\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇭\": {\n    \"name\": \"flag Marshall Islands\",\n    \"slug\": \"flag_marshall_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇰\": {\n    \"name\": \"flag North Macedonia\",\n    \"slug\": \"flag_north_macedonia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇱\": {\n    \"name\": \"flag Mali\",\n    \"slug\": \"flag_mali\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇲\": {\n    \"name\": \"flag Myanmar (Burma)\",\n    \"slug\": \"flag_myanmar\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇳\": {\n    \"name\": \"flag Mongolia\",\n    \"slug\": \"flag_mongolia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇴\": {\n    \"name\": \"flag Macao SAR China\",\n    \"slug\": \"flag_macao_sar_china\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇵\": {\n    \"name\": \"flag Northern Mariana Islands\",\n    \"slug\": \"flag_northern_mariana_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇶\": {\n    \"name\": \"flag Martinique\",\n    \"slug\": \"flag_martinique\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇷\": {\n    \"name\": \"flag Mauritania\",\n    \"slug\": \"flag_mauritania\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇸\": {\n    \"name\": \"flag Montserrat\",\n    \"slug\": \"flag_montserrat\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇹\": {\n    \"name\": \"flag Malta\",\n    \"slug\": \"flag_malta\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇺\": {\n    \"name\": \"flag Mauritius\",\n    \"slug\": \"flag_mauritius\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇻\": {\n    \"name\": \"flag Maldives\",\n    \"slug\": \"flag_maldives\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇼\": {\n    \"name\": \"flag Malawi\",\n    \"slug\": \"flag_malawi\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇽\": {\n    \"name\": \"flag Mexico\",\n    \"slug\": \"flag_mexico\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇾\": {\n    \"name\": \"flag Malaysia\",\n    \"slug\": \"flag_malaysia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇲🇿\": {\n    \"name\": \"flag Mozambique\",\n    \"slug\": \"flag_mozambique\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇦\": {\n    \"name\": \"flag Namibia\",\n    \"slug\": \"flag_namibia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇨\": {\n    \"name\": \"flag New Caledonia\",\n    \"slug\": \"flag_new_caledonia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇪\": {\n    \"name\": \"flag Niger\",\n    \"slug\": \"flag_niger\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇫\": {\n    \"name\": \"flag Norfolk Island\",\n    \"slug\": \"flag_norfolk_island\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇬\": {\n    \"name\": \"flag Nigeria\",\n    \"slug\": \"flag_nigeria\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇮\": {\n    \"name\": \"flag Nicaragua\",\n    \"slug\": \"flag_nicaragua\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇱\": {\n    \"name\": \"flag Netherlands\",\n    \"slug\": \"flag_netherlands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇴\": {\n    \"name\": \"flag Norway\",\n    \"slug\": \"flag_norway\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇵\": {\n    \"name\": \"flag Nepal\",\n    \"slug\": \"flag_nepal\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇷\": {\n    \"name\": \"flag Nauru\",\n    \"slug\": \"flag_nauru\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇺\": {\n    \"name\": \"flag Niue\",\n    \"slug\": \"flag_niue\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇳🇿\": {\n    \"name\": \"flag New Zealand\",\n    \"slug\": \"flag_new_zealand\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇴🇲\": {\n    \"name\": \"flag Oman\",\n    \"slug\": \"flag_oman\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇦\": {\n    \"name\": \"flag Panama\",\n    \"slug\": \"flag_panama\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇪\": {\n    \"name\": \"flag Peru\",\n    \"slug\": \"flag_peru\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇫\": {\n    \"name\": \"flag French Polynesia\",\n    \"slug\": \"flag_french_polynesia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇬\": {\n    \"name\": \"flag Papua New Guinea\",\n    \"slug\": \"flag_papua_new_guinea\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇭\": {\n    \"name\": \"flag Philippines\",\n    \"slug\": \"flag_philippines\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇰\": {\n    \"name\": \"flag Pakistan\",\n    \"slug\": \"flag_pakistan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇱\": {\n    \"name\": \"flag Poland\",\n    \"slug\": \"flag_poland\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇲\": {\n    \"name\": \"flag St. Pierre & Miquelon\",\n    \"slug\": \"flag_st_pierre_miquelon\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇳\": {\n    \"name\": \"flag Pitcairn Islands\",\n    \"slug\": \"flag_pitcairn_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇷\": {\n    \"name\": \"flag Puerto Rico\",\n    \"slug\": \"flag_puerto_rico\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇸\": {\n    \"name\": \"flag Palestinian Territories\",\n    \"slug\": \"flag_palestinian_territories\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇹\": {\n    \"name\": \"flag Portugal\",\n    \"slug\": \"flag_portugal\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇼\": {\n    \"name\": \"flag Palau\",\n    \"slug\": \"flag_palau\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇵🇾\": {\n    \"name\": \"flag Paraguay\",\n    \"slug\": \"flag_paraguay\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇶🇦\": {\n    \"name\": \"flag Qatar\",\n    \"slug\": \"flag_qatar\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇷🇪\": {\n    \"name\": \"flag Réunion\",\n    \"slug\": \"flag_reunion\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇷🇴\": {\n    \"name\": \"flag Romania\",\n    \"slug\": \"flag_romania\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇷🇸\": {\n    \"name\": \"flag Serbia\",\n    \"slug\": \"flag_serbia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇷🇺\": {\n    \"name\": \"flag Russia\",\n    \"slug\": \"flag_russia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇷🇼\": {\n    \"name\": \"flag Rwanda\",\n    \"slug\": \"flag_rwanda\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇦\": {\n    \"name\": \"flag Saudi Arabia\",\n    \"slug\": \"flag_saudi_arabia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇧\": {\n    \"name\": \"flag Solomon Islands\",\n    \"slug\": \"flag_solomon_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇨\": {\n    \"name\": \"flag Seychelles\",\n    \"slug\": \"flag_seychelles\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇩\": {\n    \"name\": \"flag Sudan\",\n    \"slug\": \"flag_sudan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇪\": {\n    \"name\": \"flag Sweden\",\n    \"slug\": \"flag_sweden\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇬\": {\n    \"name\": \"flag Singapore\",\n    \"slug\": \"flag_singapore\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇭\": {\n    \"name\": \"flag St. Helena\",\n    \"slug\": \"flag_st_helena\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇮\": {\n    \"name\": \"flag Slovenia\",\n    \"slug\": \"flag_slovenia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇯\": {\n    \"name\": \"flag Svalbard & Jan Mayen\",\n    \"slug\": \"flag_svalbard_jan_mayen\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇰\": {\n    \"name\": \"flag Slovakia\",\n    \"slug\": \"flag_slovakia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇱\": {\n    \"name\": \"flag Sierra Leone\",\n    \"slug\": \"flag_sierra_leone\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇲\": {\n    \"name\": \"flag San Marino\",\n    \"slug\": \"flag_san_marino\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇳\": {\n    \"name\": \"flag Senegal\",\n    \"slug\": \"flag_senegal\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇴\": {\n    \"name\": \"flag Somalia\",\n    \"slug\": \"flag_somalia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇷\": {\n    \"name\": \"flag Suriname\",\n    \"slug\": \"flag_suriname\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇸\": {\n    \"name\": \"flag South Sudan\",\n    \"slug\": \"flag_south_sudan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇹\": {\n    \"name\": \"flag São Tomé & Príncipe\",\n    \"slug\": \"flag_sao_tome_principe\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇻\": {\n    \"name\": \"flag El Salvador\",\n    \"slug\": \"flag_el_salvador\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇽\": {\n    \"name\": \"flag Sint Maarten\",\n    \"slug\": \"flag_sint_maarten\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇾\": {\n    \"name\": \"flag Syria\",\n    \"slug\": \"flag_syria\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇸🇿\": {\n    \"name\": \"flag Eswatini\",\n    \"slug\": \"flag_eswatini\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇦\": {\n    \"name\": \"flag Tristan da Cunha\",\n    \"slug\": \"flag_tristan_da_cunha\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇨\": {\n    \"name\": \"flag Turks & Caicos Islands\",\n    \"slug\": \"flag_turks_caicos_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇩\": {\n    \"name\": \"flag Chad\",\n    \"slug\": \"flag_chad\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇫\": {\n    \"name\": \"flag French Southern Territories\",\n    \"slug\": \"flag_french_southern_territories\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇬\": {\n    \"name\": \"flag Togo\",\n    \"slug\": \"flag_togo\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇭\": {\n    \"name\": \"flag Thailand\",\n    \"slug\": \"flag_thailand\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇯\": {\n    \"name\": \"flag Tajikistan\",\n    \"slug\": \"flag_tajikistan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇰\": {\n    \"name\": \"flag Tokelau\",\n    \"slug\": \"flag_tokelau\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇱\": {\n    \"name\": \"flag Timor-Leste\",\n    \"slug\": \"flag_timor_leste\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇲\": {\n    \"name\": \"flag Turkmenistan\",\n    \"slug\": \"flag_turkmenistan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇳\": {\n    \"name\": \"flag Tunisia\",\n    \"slug\": \"flag_tunisia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇴\": {\n    \"name\": \"flag Tonga\",\n    \"slug\": \"flag_tonga\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇷\": {\n    \"name\": \"flag Türkiye\",\n    \"slug\": \"flag_turkiye\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇹\": {\n    \"name\": \"flag Trinidad & Tobago\",\n    \"slug\": \"flag_trinidad_tobago\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇻\": {\n    \"name\": \"flag Tuvalu\",\n    \"slug\": \"flag_tuvalu\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇼\": {\n    \"name\": \"flag Taiwan\",\n    \"slug\": \"flag_taiwan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇹🇿\": {\n    \"name\": \"flag Tanzania\",\n    \"slug\": \"flag_tanzania\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇺🇦\": {\n    \"name\": \"flag Ukraine\",\n    \"slug\": \"flag_ukraine\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇺🇬\": {\n    \"name\": \"flag Uganda\",\n    \"slug\": \"flag_uganda\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇺🇲\": {\n    \"name\": \"flag U.S. Outlying Islands\",\n    \"slug\": \"flag_u_s_outlying_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇺🇳\": {\n    \"name\": \"flag United Nations\",\n    \"slug\": \"flag_united_nations\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"4.0\",\n    \"unicode_version\": \"4.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇺🇸\": {\n    \"name\": \"flag United States\",\n    \"slug\": \"flag_united_states\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"0.6\",\n    \"unicode_version\": \"0.6\",\n    \"skin_tone_support\": false\n  },\n  \"🇺🇾\": {\n    \"name\": \"flag Uruguay\",\n    \"slug\": \"flag_uruguay\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇺🇿\": {\n    \"name\": \"flag Uzbekistan\",\n    \"slug\": \"flag_uzbekistan\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇻🇦\": {\n    \"name\": \"flag Vatican City\",\n    \"slug\": \"flag_vatican_city\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇻🇨\": {\n    \"name\": \"flag St. Vincent & Grenadines\",\n    \"slug\": \"flag_st_vincent_grenadines\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇻🇪\": {\n    \"name\": \"flag Venezuela\",\n    \"slug\": \"flag_venezuela\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇻🇬\": {\n    \"name\": \"flag British Virgin Islands\",\n    \"slug\": \"flag_british_virgin_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇻🇮\": {\n    \"name\": \"flag U.S. Virgin Islands\",\n    \"slug\": \"flag_u_s_virgin_islands\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇻🇳\": {\n    \"name\": \"flag Vietnam\",\n    \"slug\": \"flag_vietnam\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇻🇺\": {\n    \"name\": \"flag Vanuatu\",\n    \"slug\": \"flag_vanuatu\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇼🇫\": {\n    \"name\": \"flag Wallis & Futuna\",\n    \"slug\": \"flag_wallis_futuna\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇼🇸\": {\n    \"name\": \"flag Samoa\",\n    \"slug\": \"flag_samoa\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇽🇰\": {\n    \"name\": \"flag Kosovo\",\n    \"slug\": \"flag_kosovo\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇾🇪\": {\n    \"name\": \"flag Yemen\",\n    \"slug\": \"flag_yemen\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇾🇹\": {\n    \"name\": \"flag Mayotte\",\n    \"slug\": \"flag_mayotte\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇿🇦\": {\n    \"name\": \"flag South Africa\",\n    \"slug\": \"flag_south_africa\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇿🇲\": {\n    \"name\": \"flag Zambia\",\n    \"slug\": \"flag_zambia\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🇿🇼\": {\n    \"name\": \"flag Zimbabwe\",\n    \"slug\": \"flag_zimbabwe\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"2.0\",\n    \"unicode_version\": \"2.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏴󠁧󠁢󠁥󠁮󠁧󠁿\": {\n    \"name\": \"flag England\",\n    \"slug\": \"flag_england\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏴󠁧󠁢󠁳󠁣󠁴󠁿\": {\n    \"name\": \"flag Scotland\",\n    \"slug\": \"flag_scotland\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  },\n  \"🏴󠁧󠁢󠁷󠁬󠁳󠁿\": {\n    \"name\": \"flag Wales\",\n    \"slug\": \"flag_wales\",\n    \"group\": \"Flags\",\n    \"emoji_version\": \"5.0\",\n    \"unicode_version\": \"5.0\",\n    \"skin_tone_support\": false\n  }\n}\n"
  },
  {
    "path": "config/assets/launcher.json",
    "content": "{\n\t\"launcher_config\": {\n\t\t\"em\": {\n\t\t\t\"icon\": \"emoji-people-symbolic\",\n\t\t\t\"description\": \"Emoji - Search and copy emojis\"\n\t\t},\n\t\t\"clip\": {\n\t\t\t\"icon\": \"app.getclipboard.Clipboard\",\n\t\t\t\"description\": \"Clipboard - Search and manage clipboard history\"\n\t\t},\n\t\t\"=\": {\n\t\t\t\"examples\": [\n\t\t\t\t\"= 10*5\",\n\t\t\t\t\"= 2^8\",\n\t\t\t\t\"= sin(30)\"\n\t\t\t],\n\t\t\t\"icon\": \"calculator\",\n\t\t\t\"description\": \"Quick Math - Fast mathematical expressions\"\n\t\t},\n\t\t\"app\": {\n\t\t\t\"icon\": \"apps\",\n\t\t\t\"description\": \"Applications - Launch installed applications\"\n\t\t},\n\t\t\"bin\": {\n\t\t\t\"icon\": \"terminal\",\n\t\t\t\"description\": \"Bins - Search and run executable binaries\"\n\t\t},\n\t\t\"power\": {\n\t\t\t\"icon\": \"shutdown\",\n\t\t\t\"description\": \"Power - System power management and session control\"\n\t\t},\n\t\t\"caffeine\": {\n\t\t\t\"examples\": [\n\t\t\t\t\"caffeine 30m\",\n\t\t\t\t\"caffeine 1h\",\n\t\t\t\t\"caffeine 2h\",\n\t\t\t\t\"caffeine on\"\n\t\t\t],\n\t\t\t\"icon\": \"caffeine\",\n\t\t\t\"description\": \"Caffeine - Prevent system from going idle\"\n\t\t},\n\t\t\"sc\": {\n\t\t\t\"icon\": \"cs-screen\",\n\t\t\t\"description\": \"Screencapture - Record screen and audio\"\n\t\t},\n\t\t\"wall\": {\n\t\t\t\"icon\": \"daily-wallpaper\",\n\t\t\t\"description\": \"Wallpapers - Set wallpapers, and tons of features\"\n\t\t},\n\t\t\"?\": {\n\t\t\t\"icon\": \"search-symbolic\",\n\t\t\t\"description\": \"Quick Web Search - Fast web search with question mark\",\n\t\t\t\"examples\": [\n\t\t\t\t\"? cats\",\n\t\t\t\t\"? google cats\",\n\t\t\t\t\"? youtube music\"\n\t\t\t]\n\t\t},\n\t\t\"remind\": {\n\t\t\t\"icon\": \"alarm-timer\",\n\t\t\t\"description\": \"Reminders - Set time-based reminders with notifications\"\n\t\t},\n\t\t\"otp\": {\n\t\t\t\"icon\": \"auth-otp-symbolic\",\n\t\t\t\"description\": \"Manage TOTP codes and 2FA authentication\"\n\t\t},\n\t\t\"pass\": {\n\t\t\t\"icon\": \"nextcloud-password-client\",\n\t\t\t\"description\": \"Password Manager - Search and manage passwords\"\n\t\t},\n\t\t\"bm\": {\n\t\t\t\"icon\": \"bookmark-add-symbolic\",\n\t\t\t\"description\": \"Bookmarks - Search and manage bookmarks\"\n\t\t},\n\t\t\"script\": {\n\t\t\t\"icon\": \"terminal\",\n\t\t\t\"description\": \"Bash Scripts - Manage and execute bash scripts\"\n\t\t},\n\t\t\"bash\": {\n\t\t\t\"icon\": \"terminal\",\n\t\t\t\"description\": \"Bash Scripts - Manage and execute bash scripts\"\n\t\t},\n\t\t\"sh\": {\n\t\t\t\"icon\": \"terminal\",\n\t\t\t\"description\": \"Bash Scripts - Manage and execute bash scripts\"\n\t\t},\n\t\t\"tmux\": {\n\t\t\t\"icon\": \"terminal\",\n\t\t\t\"description\": \"Tmux Manager - Create, attach, rename, and kill tmux sessions\"\n\t\t}\n\t},\n\t\"settings\": {\n\t\t\"max_examples_shown\": 2,\n\t\t\"default_icon\": \"apps\",\n\t\t\"fallback_example_template\": \"{trigger} <search>\",\n\t\t\"config_version\": \"1.0\"\n\t}\n}\n"
  },
  {
    "path": "config/data.py",
    "content": "import json\nimport os\n\nimport gi\nfrom fabric.utils.helpers import get_relative_path\nfrom gi.repository import Gdk, GLib\n\ngi.require_version(\"Gtk\", \"3.0\")\n\nAPP_NAME = \"modus\"\nAPP_NAME_CAP = \"Modus\"\n\n\ndef parse_timeout_string(timeout_str):\n    \"\"\"\n    Parse timeout string in format like '5s', '10m', '30s' etc.\n    Returns timeout in milliseconds.\n    \"\"\"\n    if not timeout_str or not isinstance(timeout_str, str):\n        return 5000\n\n    timeout_str = timeout_str.strip().lower()\n\n    if timeout_str.endswith(\"s\"):\n        try:\n            seconds = int(timeout_str[:-1])\n            return seconds * 1000\n        except ValueError:\n            return 5000\n    elif timeout_str.endswith(\"m\"):\n        try:\n            minutes = int(timeout_str[:-1])\n            return minutes * 60 * 1000\n        except ValueError:\n            return 5000\n    else:\n        try:\n            seconds = int(timeout_str)\n            return seconds * 1000\n        except ValueError:\n            return 5000\n\n\nCACHE_DIR = str(GLib.get_user_cache_dir()) + f\"/{APP_NAME}\"\n\nUSERNAME = os.getlogin()\nHOSTNAME = os.uname().nodename\nHOME_DIR = os.path.expanduser(\"~\")\n\nCONFIG_DIR = os.path.expanduser(f\"~/.config/{APP_NAME}\")\n\nscreen = Gdk.Screen.get_default()\nCURRENT_WIDTH = screen.get_width()\nCURRENT_HEIGHT = screen.get_height()\n\n\nWALLPAPERS_DIR_DEFAULT = get_relative_path(\"../assets/wallpapers_example/\")\nCONFIG_FILE = get_relative_path(\"../config/assets/config.json\")\nMATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, \"matugen\")\n\n\ndef load_config():\n    \"\"\"Load the configuration from config.json\"\"\"\n    config = {}\n\n    if os.path.exists(CONFIG_FILE):\n        try:\n            with open(CONFIG_FILE, \"r\") as f:\n                config = json.load(f)\n        except Exception as e:\n            print(f\"Error loading config: {e}\")\n\n    return config\n\n\nif os.path.exists(CONFIG_FILE):\n    with open(CONFIG_FILE, \"r\") as f:\n        config = json.load(f)\n    wallpapers_dir_from_config = config.get(\"wallpapers_dir\", WALLPAPERS_DIR_DEFAULT)\n    WALLPAPERS_DIR = os.path.expanduser(wallpapers_dir_from_config)\n    DOCK_POSITION = config.get(\"dock_position\", \"Bottom\")\n    TERMINAL_COMMAND = config.get(\"terminal_command\", \"kitty -e\")\n    DOCK_ENABLED = config.get(\"dock_enabled\", True)\n    DOCK_AUTO_HIDE = config.get(\"dock_auto_hide\", True)\n    DOCK_ALWAYS_OCCLUDED = config.get(\"dock_always_occluded\", False)\n    DOCK_ICON_SIZE = config.get(\"dock_icon_size\", 60)\n    WINDOW_SWITCHER_ITEMS_PER_ROW = config.get(\"window_switcher_items_per_row\", 10)\n    HIDE_SPECIAL_WORKSPACE = config.get(\"hide_special_workspace\", True)\n    DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get(\n        \"dock_hide_special_workspace_apps\", True\n    )\n\n    NOTIFICATION_TIMEOUT_STR = config.get(\"notification_timeout\", \"5s\")\n    NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)\n    NOTIFICATION_IGNORED_APPS_HISTORY = config.get(\n        \"notification_ignored_apps_history\", [\"Hyprshot\"]\n    )\n    NOTIFICATION_LIMITED_APPS_HISTORY = config.get(\n        \"notification_limited_apps_history\", [\"Spotify\"]\n    )\n\nelse:\n    WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT\n    DOCK_POSITION = \"Bottom\"\n    DOCK_ENABLED = True\n    DOCK_ALWAYS_OCCLUDED = False\n    DOCK_AUTO_HIDE = True\n    TERMINAL_COMMAND = \"kitty -e\"\n    DOCK_THEME = \"Pills\"\n    DOCK_ICON_SIZE = 60\n    WINDOW_SWITCHER_ITEMS_PER_ROW = 10\n    HIDE_SPECIAL_WORKSPACE = True\n    DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True\n\n    NOTIFICATION_TIMEOUT_STR = \"5s\"\n    NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)\n    NOTIFICATION_IGNORED_APPS_HISTORY = [\"Hyprshot\"]\n    NOTIFICATION_LIMITED_APPS_HISTORY = [\"Spotify\"]\n"
  },
  {
    "path": "config/hypr/modus.conf",
    "content": "#  exec-once\nexec-once = uwsm app -- python ~/.config/Modus/main.py\nexec = pgrep -x \"hypridle\" > /dev/null || uwsm app -- hypridle\nexec-once = uwsm app -- swww-daemon\nexec-once =  wl-paste --type text --watch cliphist store\nexec-once =  wl-paste --type image --watch cliphist store\n\n\n# LAYER RULES FOR BLURS\nlayerrule = blur on, xray 0, blur_popups on, ignore_alpha 0, no_anim on, match:namespace ^modus-notifications$\nlayerrule = animation popin, match:namespace ^lockscreen$\nlayerrule = blur on, xray 0, blur_popups on, ignore_alpha 0, animation popin, match:namespace ^modus-launcher$\nlayerrule = blur on, ignore_alpha 0, xray 0, blur_popups on, match:namespace ^fabric$\nlayerrule = blur on, xray 0, blur_popups on, ignore_alpha 0, match:namespace ^modus$\nlayerrule = animation slide right, match:namespace ^notification-center$\n\n# KEYBINDS\n\n$fabricSend = fabric-cli exec modus\n# Reload Modus\nbind = SUPER ALT, B, exec, killall modus; uwsm-app $(python $HOME/.config/Modus/main.py)\n# Message\nbind = SUPER SHIFT, y, exec, $fabricSend 'app.set_css()' # Reload CSS\n# # Application Switcher\nbind = ALT, TAB, exec, $fabricSend 'switcher.show_switcher()'\n# App Launcher\nbind = SUPER, SPACE, exec, $fabricSend \"launcher.show_launcher()\"\n# Clipboard History\nbind = SUPER, V, exec, $fabricSend \"launcher.show_launcher('clip')\"\n# Wallpapers\nbind = SUPER, W, exec, $fabricSend \"launcher.show_launcher('wall')\"\n# Random Wallpaper\nbind = ALT SHIFT, W, exec, $fabricSend \"launcher.show_launcher('wall random', external=True)\"\n# Emoji Picker\nbind = SUPER, Period, exec, $fabricSend \"launcher.show_launcher('em')\"\n# Power Menu\nbind = SUPER, ESCAPE, exec, $fabricSend \"launcher.show_launcher('power')\"\n# Toggle Caffeine\nbind = SUPER SHIFT, M, exec, $fabricSend \"launcher.show_launcher('caffeine on', external=True)\"\n\n"
  },
  {
    "path": "config/matugen/templates/hyprland-colors.conf",
    "content": "$wallpaper = {{image}}\n$background = {{colors.background.default.hex_stripped}}\n$foreground = {{colors.on_background.default.hex_stripped}}\n\n$primary = {{colors.primary.default.hex_stripped}}\n$secondary = {{colors.secondary.default.hex_stripped}}\n$tertiary = {{colors.tertiary.default.hex_stripped}}\n$surface = {{colors.surface.default.hex_stripped}}\n$surface_bright = {{colors.surface_bright.default.hex_stripped}}\n$outline = {{colors.outline.default.hex_stripped}}\n$error = {{colors.error.default.hex_stripped | set_lightness: -20.0}}\n\n$shadow = {{colors.shadow.default.hex_stripped}}\n\n$red = {{colors.red.default.hex_stripped}}\n$green = {{colors.green.default.hex_stripped}}\n$yellow = {{colors.yellow.default.hex_stripped}}\n$blue = {{colors.blue.default.hex_stripped}}\n$magenta = {{colors.magenta.default.hex_stripped}}\n$cyan = {{colors.cyan.default.hex_stripped}}\n$white = {{colors.white.default.hex_stripped}}\n$red_dim = {{colors.red.default.hex_stripped | set_lightness: -20.0}}\n$green_dim = {{colors.green.default.hex_stripped | set_lightness: -20.0}}\n$yellow_dim = {{colors.yellow.default.hex_stripped | set_lightness: -20.0}}\n$blue_dim = {{colors.blue.default.hex_stripped | set_lightness: -20.0}}\n$magenta_dim = {{colors.magenta.default.hex_stripped | set_lightness: -20.0}}\n$cyan_dim = {{colors.cyan.default.hex_stripped | set_lightness: -20.0}}\n"
  },
  {
    "path": "config/matugen/templates/macos.css",
    "content": ":vars {\n  --foreground: {{colors.on_background.default.hex}};\n  --background: {{colors.background.default.hex}};\n  --cursor: {{colors.on_background.default.hex}};\n  --primary: {{colors.primary.default.hex}};\n  --on-primary: {{colors.on_primary.default.hex}};\n  --secondary: {{colors.secondary.default.hex}};\n  --on-secondary: {{colors.on_secondary.default.hex}};\n  --tertiary: {{colors.tertiary.default.hex}};\n  --on-tertiary: {{colors.on_tertiary.default.hex}};\n  --surface: {{colors.surface.default.hex}};\n  --surface-bright: {{colors.surface_bright.default.hex}};\n  --error: {{colors.error.default.hex}};\n  --error-dim: {{colors.error.default.hex | set_lightness: -10.0}};\n  --on-error: {{colors.on_error.default.hex}};\n  --error-container: {{colors.error_container.default.hex}};\n  --outline: {{colors.outline.default.hex}};\n  --shadow: {{colors.shadow.default.hex}};\n  --red: {{colors.red.default.hex}};\n  --red-dim: {{colors.red.default.hex | set_lightness: -10.0}};\n  --green: {{colors.green.default.hex}};\n  --green-dim: {{colors.green.default.hex | set_lightness: -10.0}};\n  --yellow: {{colors.yellow.default.hex}};\n  --yellow-dim: {{colors.yellow.default.hex | set_lightness: -10.0}};\n  --blue: {{colors.blue.default.hex}};\n  --blue-dim: {{colors.blue.default.hex | set_lightness: -10.0}};\n  --magenta: {{colors.magenta.default.hex}};\n  --magenta-dim: {{colors.magenta.default.hex | set_lightness: -10.0}};\n  --cyan: {{colors.cyan.default.hex}};\n  --cyan-dim: {{colors.cyan.default.hex | set_lightness: -10.0}};\n  --white: {{colors.white.default.hex}};\n}\n"
  },
  {
    "path": "debug_memory.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nReal-time memory monitor for debugging expanded player memory leaks.\nThis module provides functions to track memory usage in real-time.\n\"\"\"\n\nimport psutil\nimport os\nimport gc\nimport threading\nimport time\nfrom loguru import logger\n\n\nclass MemoryMonitor:\n    \"\"\"Real-time memory monitoring for debugging memory leaks.\"\"\"\n\n    def __init__(self):\n        self.process = psutil.Process(os.getpid())\n        self.baseline_memory = None\n        self.last_memory = None\n        self.monitoring = False\n        self.monitor_thread = None\n\n    def get_memory_usage(self):\n        \"\"\"Get current memory usage in MB.\"\"\"\n        return self.process.memory_info().rss / 1024 / 1024\n\n    def get_memory_details(self):\n        \"\"\"Get detailed memory information.\"\"\"\n        memory_info = self.process.memory_info()\n        memory_percent = self.process.memory_percent()\n\n        return {\n            \"rss_mb\": memory_info.rss / 1024 / 1024,\n            \"vms_mb\": memory_info.vms / 1024 / 1024,\n            \"percent\": memory_percent,\n            \"num_threads\": self.process.num_threads(),\n        }\n\n    def set_baseline(self, label=\"Baseline\"):\n        \"\"\"Set the baseline memory usage.\"\"\"\n        self.baseline_memory = self.get_memory_usage()\n        logger.info(f\"🎯 {label} memory: {self.baseline_memory:.1f} MB\")\n        return self.baseline_memory\n\n    def log_memory_change(self, label=\"Memory Check\", force_gc=True):\n        \"\"\"Log current memory usage and change from baseline.\"\"\"\n        if force_gc:\n            gc.collect()\n\n        current_memory = self.get_memory_usage()\n        details = self.get_memory_details()\n\n        if self.baseline_memory:\n            delta = current_memory - self.baseline_memory\n            logger.info(\n                f\"📊 {label}: {current_memory:.1f} MB (Δ: {delta:+.1f} MB) | Threads: {\n                    details['num_threads']\n                }\"\n            )\n        else:\n            logger.info(\n                f\"📊 {label}: {current_memory:.1f} MB | Threads: {\n                    details['num_threads']\n                }\"\n            )\n\n        self.last_memory = current_memory\n        return current_memory\n\n    def log_memory_spike(self, threshold_mb=10):\n        \"\"\"Log if there's a significant memory increase.\"\"\"\n        if self.last_memory:\n            current = self.get_memory_usage()\n            increase = current - self.last_memory\n            if increase > threshold_mb:\n                logger.warning(\n                    f\"🚨 MEMORY SPIKE: +{increase:.1f} MB (from {self.last_memory:.1f} to {current:.1f} MB)\"\n                )\n                return True\n        return False\n\n    def start_continuous_monitoring(self, interval_seconds=2):\n        \"\"\"Start continuous background monitoring.\"\"\"\n        if self.monitoring:\n            return\n\n        self.monitoring = True\n\n        def monitor_loop():\n            while self.monitoring:\n                try:\n                    self.log_memory_change(\"Continuous Monitor\", force_gc=False)\n                    time.sleep(interval_seconds)\n                except Exception as e:\n                    logger.error(f\"Memory monitor error: {e}\")\n                    break\n\n        self.monitor_thread = threading.Thread(target=monitor_loop, daemon=True)\n        self.monitor_thread.start()\n        logger.info(\n            f\"🔍 Started continuous memory monitoring (every {interval_seconds}s)\"\n        )\n\n    def stop_continuous_monitoring(self):\n        \"\"\"Stop continuous monitoring.\"\"\"\n        if self.monitoring:\n            self.monitoring = False\n            logger.info(\"🛑 Stopped continuous memory monitoring\")\n\n\n# Global memory monitor instance\nmemory_monitor = MemoryMonitor()\n\n# Convenience functions for easy use\n\n\ndef set_memory_baseline(label=\"Baseline\"):\n    \"\"\"Set memory baseline.\"\"\"\n    return memory_monitor.set_baseline(label)\n\n\ndef log_memory(label=\"Memory Check\"):\n    \"\"\"Log current memory usage.\"\"\"\n    return memory_monitor.log_memory_change(label)\n\n\ndef start_memory_monitoring():\n    \"\"\"Start continuous memory monitoring.\"\"\"\n    memory_monitor.start_continuous_monitoring()\n\n\ndef stop_memory_monitoring():\n    \"\"\"Stop continuous memory monitoring.\"\"\"\n    memory_monitor.stop_continuous_monitoring()\n\n\ndef check_memory_spike():\n    \"\"\"Check for memory spike.\"\"\"\n    return memory_monitor.log_memory_spike()\n\n\n# Test function\n\n\ndef test_memory_monitor():\n    \"\"\"Test the memory monitor.\"\"\"\n    print(\"Testing Memory Monitor...\")\n    monitor = MemoryMonitor()\n\n    monitor.set_baseline(\"Test Start\")\n\n    # Simulate some memory usage\n    big_list = [i for i in range(100000)]\n    monitor.log_memory_change(\"After creating big list\")\n\n    del big_list\n    monitor.log_memory_change(\"After deleting big list\")\n\n    print(\"Memory monitor test complete!\")\n\n\nif __name__ == \"__main__\":\n    test_memory_monitor()\n"
  },
  {
    "path": "install.sh",
    "content": "#!/bin/bash\n\n#  ███╗   ███╗ ██████╗ ██████╗ ██╗   ██╗███████╗\n#  ████╗ ████║██╔═══██╗██╔══██╗██║   ██║██╔════╝\n#  ██╔████╔██║██║   ██║██║  ██║██║   ██║███████╗\n#  ██║╚██╔╝██║██║   ██║██║  ██║██║   ██║╚════██║\n#  ██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████║\n#  ╚═╝     ╚═╝ ╚═════╝ ╚═════╝  ╚═════╝ ╚══════╝\n#\n#  A hackable shell for Hyprland\n#  Installation Script for Arch Linux\n#\n#  Repository: https://github.com/S4NKALP/Modus --branch macos\n#  License: GPLv3\n\nset -e\nset -u\nset -o pipefail\n\nREPO_URL=\"https://github.com/S4NKALP/Modus.git\"\nINSTALL_DIR=\"$HOME/.config/Modus\"\n\nPACKAGES=(\n    python-fabric-git\n    fabric-cli-git\n    glace-git\n    cliphist\n    gnome-bluetooth-3.0\n    gobject-introspection\n    slurp\n    ffmpeg\n    hypridle\n    hyprsunset\n    hyprpicker\n    imagemagick\n    libnotify\n    matugen-bin\n    playerctl\n    python-gobject\n    python-pillow\n    python-setproctitle\n    python-toml\n    python-requests\n    python-numpy\n    python-pywayland\n    python-pyxdg\n    python-ijson\n    python-watchdog\n    python-pyotp\n    pyzbar\n    python-psutil\n    python-pydbus\n    python-thefuzz\n    python-pam\n    gtk-session-lock\n    swww\n    apple-fonts\n    swappy\n    wl-clipboard\n    webp-pixbuf-loader\n    wf-recorder\n    acpi\n    brightnessctl\n    power-profiles-daemon\n    uwsm\n    cinnamon-desktop\n)\n\n# Colors and formatting\nif [ -t 1 ]; then\n    GREEN=$(tput setaf 2)\n    YELLOW=$(tput setaf 3)\n    RED=$(tput setaf 1)\n    CYAN=$(tput setaf 6)\n    BLUE=$(tput setaf 4)\n    BOLD=$(tput bold)\n    DIM=$(tput dim)\n    RESET=$(tput sgr0)\nelse\n    GREEN=\"\" YELLOW=\"\" RED=\"\" CYAN=\"\" BLUE=\"\" BOLD=\"\" DIM=\"\" RESET=\"\"\nfi\n\n# Status symbols\nARROW=\"→\"\nCHECK=\"✔\"\nCROSS=\"✖\"\nINFO=\"ℹ\"\nWARN=\"⚠\"\n\n# Progress tracking\nTOTAL_STEPS=7\nCURRENT_STEP=0\n\n# Function for progress indicator\nprogress() {\n    CURRENT_STEP=$((CURRENT_STEP + 1))\n    echo -e \"\\n${BOLD}${BLUE}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$1${RESET}\"\n}\n\nstep() {\n    echo -e \"  ${CYAN}${ARROW}${RESET} $1\"\n}\n\nsuccess() {\n    echo -e \"  ${GREEN}${CHECK}${RESET} ${GREEN}$1${RESET}\"\n}\n\nwarn() {\n    echo -e \"  ${YELLOW}${WARN}${RESET} ${YELLOW}$1${RESET}\"\n}\n\nerror() {\n    echo -e \"\\n${RED}${CROSS}${RESET} ${RED}${BOLD}ERROR:${RESET} ${RED}$1${RESET}\\n\" >&2\n}\n\ninfo() {\n    echo -e \"  ${BLUE}${INFO}${RESET} ${DIM}$1${RESET}\"\n}\n\nspinner() {\n    local pid=$1\n    local message=$2\n    local spin=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')\n    local i=0\n\n    while kill -0 $pid 2>/dev/null; do\n        printf \"\\r  ${CYAN}${spin[i]}${RESET} $message\"\n        i=$(((i + 1) % 10))\n        sleep 0.1\n    done\n    printf \"\\r\"\n}\n\n# Cleanup handler\ncleanup() {\n    if [ -n \"${SUDO_KEEPER_PID:-}\" ]; then\n        kill $SUDO_KEEPER_PID 2>/dev/null || true\n    fi\n}\ntrap cleanup EXIT INT TERM\n\n# Header\nclear\necho -e \"${BOLD}${CYAN}\"\ncat << \"EOF\"\n  ███╗   ███╗ ██████╗ ██████╗ ██╗   ██╗███████╗\n  ████╗ ████║██╔═══██╗██╔══██╗██║   ██║██╔════╝\n  ██╔████╔██║██║   ██║██║  ██║██║   ██║███████╗\n  ██║╚██╔╝██║██║   ██║██║  ██║██║   ██║╚════██║\n  ██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████║\n  ╚═╝     ╚═╝ ╚═════╝ ╚═════╝  ╚═════╝ ╚══════╝\nEOF\necho -e \"${RESET}\"\necho -e \"${BOLD}  A hackable shell for Hyprland${RESET}\"\necho -e \"${DIM}  Installation Script v1.0${RESET}\\n\"\n\n# Pre-flight checks\nprogress \"Pre-flight checks\"\n\nstep \"Checking operating system...\"\nif ! grep -qi \"arch\" /etc/os-release; then\n    error \"This script requires Arch Linux or an Arch-based distribution\"\n    exit 1\nfi\nsuccess \"Arch Linux detected\"\n\nstep \"Checking user permissions...\"\nif [ \"$(id -u)\" -eq 0 ]; then\n    error \"Please run this script as a regular user, not as root\"\n    exit 1\nfi\nsuccess \"Running as regular user\"\n\nstep \"Checking system requirements...\"\nif ! command -v git &>/dev/null; then\n    error \"git is not installed. Please install it first: sudo pacman -S git\"\n    exit 1\nfi\nsuccess \"All requirements met\"\n\n# Sudo authentication\nprogress \"Requesting permissions\"\n\ninfo \"Some packages require root privileges for installation\"\necho \"\"\nif ! sudo -v; then\n    error \"Sudo authentication failed\"\n    exit 1\nfi\nsuccess \"Permissions granted\"\n\n# Keep sudo alive\nwhile true; do\n    sudo -n true\n    sleep 60\n    kill -0 \"$$\" || exit\ndone 2>/dev/null &\nSUDO_KEEPER_PID=$!\n\n# Show package list\nprogress \"Package information\"\n\ninfo \"Total packages to install: ${BOLD}${#PACKAGES[@]}${RESET}\"\necho \"\"\nread -rp \"  ${YELLOW}${INFO}${RESET} View full package list? (y/N): \" view_packages\nif [[ \"$view_packages\" =~ ^[Yy]$ ]]; then\n    echo \"\"\n    printf \"  ${DIM}• %s${RESET}\\n\" \"${PACKAGES[@]}\"\n    echo \"\"\nfi\n\n# Confirmation\nread -rp \"  ${BOLD}Proceed with installation? (y/N):${RESET} \" confirm\nif [[ ! \"$confirm\" =~ ^[Yy]$ ]]; then\n    warn \"Installation cancelled by user\"\n    exit 0\nfi\n\n# AUR helper setup\nprogress \"Setting up AUR helper\"\n\naur_helper=\"yay\"\nif command -v paru &>/dev/null; then\n    aur_helper=\"paru\"\n    success \"Found paru\"\nelif command -v yay &>/dev/null; then\n    success \"Found yay\"\nelse\n    step \"Installing yay-bin...\"\n    tmpdir=$(mktemp -d)\n    (\n        git clone --quiet --depth=1 https://aur.archlinux.org/yay-bin.git \"$tmpdir/yay-bin\" 2>&1 | grep -v \"Cloning into\" || true\n        cd \"$tmpdir/yay-bin\"\n        makepkg -si --noconfirm >/dev/null 2>&1\n    ) &\n    spinner $! \"Building yay-bin...\"\n    wait $! || {\n        error \"Failed to install yay-bin\"\n        rm -rf \"$tmpdir\"\n        exit 1\n    }\n    rm -rf \"$tmpdir\"\n    success \"yay-bin installed successfully\"\nfi\n\n# Repository setup\nprogress \"Setting up Modus repository\"\n\nif [ -d \"$INSTALL_DIR\" ]; then\n    step \"Updating existing repository...\"\n    git -C \"$INSTALL_DIR\" pull --quiet 2>&1 | grep -v \"Already up to date\" || true\n    success \"Repository updated\"\nelse\n    step \"Cloning repository...\"\n    git clone --quiet \"$REPO_URL\" \"$INSTALL_DIR\" 2>&1 | grep -v \"Cloning into\" || true\n    success \"Repository cloned\"\nfi\ninfo \"Location: ${INSTALL_DIR}\"\n\n# Package installation\nprogress \"Installing packages\"\n\nstep \"Syncing package databases...\"\n$aur_helper -Syy --noconfirm >/dev/null 2>&1 || true\nsuccess \"Database synced\"\n\nstep \"Installing required packages (this may take a while)...\"\ninstalled=0\nfailed=0\nfor pkg in \"${PACKAGES[@]}\"; do\n    if $aur_helper -S --needed --noconfirm \"$pkg\" >/dev/null 2>&1; then\n        installed=$((installed + 1))\n    else\n        failed=$((failed + 1))\n        warn \"Failed to install: $pkg\"\n    fi\n    printf \"\\r  ${CYAN}${ARROW}${RESET} Progress: ${installed}/${#PACKAGES[@]} packages\"\ndone\necho \"\"\n\nif [ $failed -eq 0 ]; then\n    success \"All packages installed successfully\"\nelse\n    warn \"$failed package(s) failed to install\"\nfi\n\n# Update check\nstep \"Checking for package updates...\"\noutdated=$($aur_helper -Qu 2>/dev/null | awk '{print $1}' || true)\nto_update=()\nfor pkg in \"${PACKAGES[@]}\"; do\n    if echo \"$outdated\" | grep -q \"^$pkg\\$\"; then\n        to_update+=(\"$pkg\")\n    fi\ndone\n\nif [ ${#to_update[@]} -gt 0 ]; then\n    step \"Updating ${#to_update[@]} outdated package(s)...\"\n    $aur_helper -S --noconfirm \"${to_update[@]}\" >/dev/null 2>&1 || true\n    success \"Packages updated\"\nelse\n    success \"All packages are up-to-date\"\nfi\n\n# Configuration\n# progress \"Running configuration\"\n# if [ -f \"$INSTALL_DIR/config/config.py\" ]; then\n#     step \"Initializing Modus configuration...\"\n#     if python \"$INSTALL_DIR/config/config.py\" 2>/dev/null; then\n#         success \"Configuration completed\"\n#     else\n#         warn \"Configuration step failed or was skipped\"\n#     fi\n# else\n#     info \"No configuration file found, skipping\"\n# fi\n\n# Hyprland configuration\nprogress \"Configuring Hyprland\"\n\nHYPR_CONFIG=\"$HOME/.config/hypr/hyprland.conf\"\nMODUS_CONF_LINE=\"source= ~/.config/Modus/config/hypr/modus.conf\"\n\nif [ -f \"$HYPR_CONFIG\" ]; then\n    step \"Checking Hyprland configuration...\"\n    if grep -qF \"$MODUS_CONF_LINE\" \"$HYPR_CONFIG\"; then\n        success \"Modus configuration already sourced\"\n    else\n        step \"Adding Modus configuration to Hyprland...\"\n        echo \"\" >> \"$HYPR_CONFIG\"\n        echo \"# Modus configuration\" >> \"$HYPR_CONFIG\"\n        echo \"$MODUS_CONF_LINE\" >> \"$HYPR_CONFIG\"\n        success \"Modus configuration added to Hyprland\"\n    fi\nelse\n    warn \"Hyprland config not found at $HYPR_CONFIG\"\n    info \"You may need to manually add: $MODUS_CONF_LINE\"\nfi\n\n# Launch Modus\nprogress \"Launching Modus\"\n\nstep \"Stopping existing instances...\"\nif killall modus 2>/dev/null; then\n    success \"Stopped running instance\"\n    sleep 1\nelse\n    info \"No existing instance found\"\nfi\n\nstep \"Starting Modus...\"\nif uwsm app -- python \"$INSTALL_DIR/main.py\" >/dev/null 2>&1 & then\n    disown\n    sleep 1\n    if pgrep -f \"python.*main.py\" >/dev/null; then\n        success \"Modus is now running\"\n    else\n        warn \"Modus may not have started correctly\"\n    fi\nelse\n    error \"Failed to start Modus\"\n    exit 1\nfi\n\n# Completion\necho \"\"\necho -e \"${GREEN}${BOLD}╔════════════════════════════════════════╗${RESET}\"\necho -e \"${GREEN}${BOLD}║                                        ║${RESET}\"\necho -e \"${GREEN}${BOLD}║     Installation completed! 🎉         ║${RESET}\"\necho -e \"${GREEN}${BOLD}║                                        ║${RESET}\"\necho -e \"${GREEN}${BOLD}╚════════════════════════════════════════╝${RESET}\"\necho \"\"\ninfo \"Modus is running in the background\"\ninfo \"Config location: ${INSTALL_DIR}\"\ninfo \"Repository: ${REPO_URL}\"\necho \"\"\n"
  },
  {
    "path": "lock.py",
    "content": "from widgets.circle_image import CircleImage as Image\nfrom modules.panel.components.indicators import (\n    BatteryIndicator,\n    BluetoothIndicator,\n    NetworkIndicator,\n)\nfrom gi.repository import (\n    Gdk,  # pyright: ignore[reportMissingModuleSource]\n    GLib,\n    GtkSessionLock,  # pyright: ignore[reportAttributeAccessIssue]\n)\nfrom fabric.widgets.window import Window\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.entry import Entry\nfrom fabric.widgets.datetime import DateTime\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.box import Box\nfrom fabric.utils import get_relative_path\nfrom fabric import Application\nimport os\nimport getpass\nimport setproctitle\nimport gi\nimport pam\n\ngi.require_version(\"Gdk\", \"3.0\")\ngi.require_version(\"Gtk\", \"3.0\")\n\n\ngi.require_version(\"GtkSessionLock\", \"0.1\")\n# from fabric.widgets.image import Image\n\n# from widgets.wayland import WaylandWindow as Window\n\n\nclass IndicatorBox(Box):\n    def __init__(self, *args, **kwargs):\n        super().__init__(\n            h_align=\"end\",\n            name=\"indicator-box\",\n            spacing=5,\n            h_expand=True,\n            children=[\n                BatteryIndicator(show_window=False),\n                BluetoothIndicator(show_window=False),\n                NetworkIndicator(show_window=False),\n            ],\n        )\n\n\nclass ContentBox(CenterBox):\n    def __init__(self, on_activate, *args, **kwargs):\n        self.password_entry = Entry(\n            placeholder=\"Enter Password\",\n            name=\"password-entry\",\n            h_align=\"center\",\n            v_align=\"center\",\n            visible=False,\n            password=True,\n            on_activate=on_activate,\n        )\n\n        face_icon = os.path.expanduser(\"~/.face.icon\")\n        if not os.path.exists(face_icon):\n            face_icon = get_relative_path(\"./assets/default.png\")\n        self.password_entry.set_property(\"xalign\", 0.5)\n        self.username_label = Label(\n            label=f\"{getpass.getuser().title()}\",\n            name=\"username\",\n            visible=True,\n            h_align=\"center\",\n            v_align=\"center\",\n        )\n        self.unlock_text = Label(\n            label=\"Touch ID or Enter Password\",\n            name=\"unlock-text\",\n        )\n        super().__init__(\n            name=\"content-box\",\n            h_expand=True,\n            orientation=\"vertical\",\n            v_expand=True,\n            start_children=[\n                IndicatorBox(),\n                DateTime(\n                    formatters=[\"%A,%B %-d\"],\n                    interval=10000,\n                    h_expand=False,\n                    v_align=\"start\",\n                    v_expand=False,\n                    name=\"lock-date\",\n                ),\n                DateTime(formatters=[\"%I:%M\"], name=\"lock-clock\"),\n            ],\n            end_children=[\n                Box(\n                    name=\"profile-box\",\n                    h_align=\"center\",\n                    h_expand=True,\n                    v_expand=True,\n                    v_align=\"end\",\n                    children=[\n                        Image(\n                            name=\"face-icon\",\n                            image_file=face_icon,\n                            size=64,\n                        ),\n                    ],\n                ),\n                Box(\n                    name=\"container-box\",\n                    orientation=\"v\",\n                    v_align=\"end\",\n                    h_align=\"center\",\n                    h_expand=True,\n                    v_expand=True,\n                    children=[\n                        self.username_label,\n                        self.password_entry,\n                    ],\n                ),\n                Box(\n                    name=\"unlock-box\",\n                    v_align=\"end\",\n                    h_align=\"center\",\n                    h_expand=True,\n                    v_expand=True,\n                    children=[\n                        self.unlock_text,\n                    ],\n                ),\n            ],\n            **kwargs,\n        )\n\n\nclass LockScreen(Window):\n    def __init__(self, lock: GtkSessionLock.Lock):\n        self._hide_timeout_id = None  # prevent AttributeError\n        self.lock = lock\n        self.content = ContentBox(self.on_activate)\n        super().__init__(\n            title=\"lock\",\n            visible=False,\n            all_visible=False,\n            name=\"lockscreen-bg\",\n            anchor=\"center\",\n            child=self.content,\n        )\n\n        self.content.password_entry.set_visible(False)\n        self.connect(\"key-press-event\", self._on_keypress)\n\n        bg = os.path.expanduser(\"~/.current.wall\")\n        if not os.path.exists(bg):\n            bg = get_relative_path(\"./assets/wallpapers_example/example-1.png\")\n        self.set_style(f\"background-image: url('{bg}');\")\n\n    def _on_keypress(self, widget, event):\n        keyval = event.keyval\n\n        # ESC pressed → hide entry immediately\n        if keyval == Gdk.KEY_Escape and self.content.password_entry.get_visible():\n            self._hide_entry()\n            return\n\n        # Show entry if hidden\n        if not self.content.password_entry.get_visible():\n            self.content.username_label.set_visible(False)\n            self.content.password_entry.set_visible(True)\n            self.content.password_entry.grab_focus()\n            self._start_hide_timer()\n        else:\n            # Reset timer if already visible\n            self._restart_hide_timer()\n\n    def _start_hide_timer(self):\n        self._stop_hide_timer()  # just in case\n        self._hide_timeout_id = GLib.timeout_add_seconds(5, self._hide_entry)\n        # 10 seconds of inactivity before hiding\n\n    def _restart_hide_timer(self):\n        self._start_hide_timer()\n\n    def _stop_hide_timer(self):\n        if self._hide_timeout_id:\n            GLib.source_remove(self._hide_timeout_id)\n            self._hide_timeout_id = None\n\n    def _hide_entry(self):\n        self._stop_hide_timer()\n        self.content.password_entry.set_visible(False)\n        self.content.username_label.set_visible(True)\n        return False  # stop timeout\n\n    def on_activate(self, entry: Entry, *args):\n        if not pam.authenticate(getpass.getuser(), (entry.get_text() or \"\").strip()):\n            entry.set_text(\"\")\n            entry.set_placeholder_text(\"Wrong Password\")\n            return\n        self.lock.unlock_and_destroy()\n        self.destroy()\n        GLib.idle_add(app.quit)  # schedules quit after unlock\n\n\ndef initialize():\n    lock = GtkSessionLock.prepare_lock()\n    lock.lock_lock()\n    lockscreen = LockScreen(lock)\n    lock.new_surface(\n        lockscreen,\n        Gdk.Display.get_default().get_monitor(  # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess]\n            0\n        ),\n    )\n    lockscreen.show()\n\n\nif __name__ == \"__main__\":\n    setproctitle.setproctitle(\"lockscreen\")\n    initialize()\n    lockscreen = LockScreen(GtkSessionLock.Lock())\n\n    app = Application(\"lock\", lockscreen)\n\n    def set_css():\n        app.set_stylesheet_from_file(\n            get_relative_path(\"main.css\"),\n        )\n\n    app.set_css = set_css  # pyright: ignore[reportAttributeAccessIssue]\n\n    app.set_css()  # pyright: ignore[reportAttributeAccessIssue]\n    app.run()\n"
  },
  {
    "path": "main.css",
    "content": "@import url(\"./styles/colors.css\");\n@import url(\"./styles/panel.css\");\n@import url(\"./styles/dock.css\");\n@import url(\"./styles/switcher.css\");\n@import url(\"./styles/launcher.css\");\n@import url(\"./styles/osd.css\");\n@import url(\"./styles/controlcenter.css\");\n@import url(\"./styles/notification.css\");\n@import url(\"./styles/dropdown.css\");\n@import url(\"./styles/about.css\");\n@import url(\"./styles/notification-center.css\");\n@import url(\"./styles/player.css\");\n@import url(\"./styles/widgets.css\");\n@import url(\"./styles/tray.css\");\n@import url(\"./styles/battery-widget.css\");\n@import url(\"./styles/lock.css\");\n@import url(\"./styles/todo.css\");\n\n* {\n  all: unset;\n  color: var(--foreground);\n  font-size: unset;\n  font-family: \"SF Pro Rounded\";\n}\n\n#corner {\n  background-color: var(--shadow);\n  border-radius: 0;\n}\n\n#corner-container {\n  min-width: 20px;\n  min-height: 20px;\n}\n"
  },
  {
    "path": "main.py",
    "content": "import setproctitle\nfrom fabric import Application\nfrom fabric.utils import get_relative_path, monitor_file\nfrom loguru import logger\n\nfrom config.data import APP_NAME\nfrom modules.dock import Dock\nfrom modules.launcher.main import Launcher\nfrom modules.notification.notification import ModusNoti\nfrom modules.osd import OSD\nfrom modules.panel.main import Panel\nfrom modules.switcher import ApplicationSwitcher\nfrom modules.widget import Deskwidgets\n\n# from modules.corners import Corners\n\nfor log in [\n    \"fabric\",\n    \"services\",\n    \"utils\",\n    # \"modules\",\n]:\n    logger.disable(log)\n\n\nif __name__ == \"__main__\":\n    setproctitle.setproctitle(APP_NAME)\n\n    # Load configuration\n    from config.data import load_config\n\n    # About().toggle(None)\n    config = load_config()\n\n    panel = Panel()\n    # corners = Corners()\n    dock = Dock()\n    modusnoti = ModusNoti()\n    switcher = ApplicationSwitcher()\n    launcher = Launcher()\n    panel.launcher = launcher\n    osd = OSD()\n\n    widgets = Deskwidgets()\n    # Set corners visibility based on config\n    # corners_visible = config.get(\"corners_visible\", True)\n    # corners.set_visible(corners_visible)\n\n    # Monitor CSS files for changes\n    css_file = monitor_file(get_relative_path(\"styles\"))\n    _ = css_file.connect(\"changed\", lambda *_: set_css())\n\n    # Make sure corners is added to the app\n    app = Application(\n        f\"{APP_NAME}\", panel, dock, switcher, launcher, modusnoti, osd, widgets\n    )\n\n    def set_css():\n        app.set_stylesheet_from_file(\n            get_relative_path(\"main.css\"),\n        )\n\n    app.set_css = set_css\n\n    app.set_css()\n\n    app.run()\n"
  },
  {
    "path": "modules/about.py",
    "content": "import re\nimport subprocess\nimport os\n\nimport gi\n\ngi.require_version(\"GdkPixbuf\", \"2.0\")\ngi.require_version(\"Gtk\", \"3.0\")\nfrom gi.repository import GdkPixbuf, Gtk  # type: ignore\n\nfrom fabric.utils.helpers import get_relative_path\nfrom utils.roam import modus_service\nfrom utils.icon_resolver import IconResolver\n\n\ndef read_dmi(field):\n    try:\n        with open(f\"/sys/class/dmi/id/{field}\") as f:\n            return f.read().strip()\n    except Exception:\n        return \"Unknown\"\n\n\ndef get_gpu_name():\n    try:\n        output = (\n            subprocess.check_output(\n                \"lspci -nn | grep -i 'VGA compatible controller'\", shell=True, text=True\n            )\n            .strip()\n            .split(\"\\n\")\n        )\n\n        def clean(line):\n            matches = re.findall(r\"\\[(.*?)\\]\", line)\n            if len(matches) >= 2:\n                return matches[1].strip()\n            desc = line.split(\":\", 2)[-1]\n            return desc.replace(\"Corporation\", \"\").strip()\n\n        for line in output:\n            if any(vendor in line.lower() for vendor in [\"nvidia\", \"amd\", \"radeon\"]):\n                return clean(line)\n\n        if output:\n            return clean(output[0])\n\n        return \"Unknown GPU\"\n    except Exception:\n        return \"Unknown GPU\"\n\n\ndef get_executable_path(exec_string):\n    \"\"\"Extract and find the actual executable path from Exec field\"\"\"\n    if not exec_string:\n        return None\n\n    # Remove common exec modifiers and arguments\n    exec_parts = exec_string.split()\n    if not exec_parts:\n        return None\n\n    executable = exec_parts[0]\n\n    # Remove common prefixes\n    prefixes_to_remove = [\"env\", \"bash\", \"sh\", \"/usr/bin/env\"]\n    while executable in prefixes_to_remove and len(exec_parts) > 1:\n        exec_parts.pop(0)\n        executable = exec_parts[0]\n\n    # If it's already an absolute path, check if it exists\n    if executable.startswith(\"/\"):\n        if os.path.exists(executable):\n            return executable\n        return None\n\n    # Search in PATH\n    try:\n        result = subprocess.run([\"which\", executable], capture_output=True, text=True)\n        if result.returncode == 0:\n            return result.stdout.strip()\n    except Exception:\n        pass\n\n    return None\n\n\ndef get_app_info(wmclass):\n    \"\"\"Get comprehensive application information from .desktop file\"\"\"\n    if not wmclass:\n        return {\n            \"name\": \"Desktop\",\n            \"version\": \"\",\n            \"comment\": \"Desktop Environment\",\n            \"icon\": \"desktop\",\n            \"exec\": \"\",\n            \"location\": \"\",\n            \"categories\": \"\",\n            \"desktop_file\": \"\",\n        }\n\n    desktop_paths = [\n        \"/usr/share/applications\",\n        \"/var/lib/flatpak/exports/share/applications\",\n        os.path.expanduser(\"~/.local/share/applications\"),\n        \"/usr/local/share/applications\",\n    ]\n\n    for path in desktop_paths:\n        if not os.path.exists(path):\n            continue\n\n        # Try exact match first\n        exact_matches = [\n            f for f in os.listdir(path) if f.lower() == f\"{wmclass.lower()}.desktop\"\n        ]\n\n        # Then try starts with\n        startswith_matches = [\n            f\n            for f in os.listdir(path)\n            if f.startswith(wmclass.lower()) and f.endswith(\".desktop\")\n        ]\n\n        # Finally try contains\n        contains_matches = [\n            f\n            for f in os.listdir(path)\n            if wmclass.lower() in f.lower() and f.endswith(\".desktop\")\n        ]\n\n        # Process matches in order of preference\n        for matches in [exact_matches, startswith_matches, contains_matches]:\n            for filename in matches:\n                desktop_file = os.path.join(path, filename)\n                try:\n                    with open(desktop_file, \"r\", encoding=\"utf-8\") as f:\n                        content = f.read()\n\n                    name = wmclass.title()\n                    version = \"\"\n                    comment = \"\"\n                    icon = wmclass.lower()\n                    exec_cmd = \"\"\n                    categories = \"\"\n\n                    # Parse desktop file\n                    in_desktop_entry = False\n                    for line in content.split(\"\\n\"):\n                        line = line.strip()\n                        if line == \"[Desktop Entry]\":\n                            in_desktop_entry = True\n                            continue\n                        elif line.startswith(\"[\") and line.endswith(\"]\"):\n                            in_desktop_entry = False\n                            continue\n\n                        if not in_desktop_entry or \"=\" not in line:\n                            continue\n\n                        key, value = line.split(\"=\", 1)\n                        if key == \"Name\":\n                            name = value\n                        elif key == \"Version\":\n                            version = value\n                        elif key == \"Comment\":\n                            comment = value\n                        elif key == \"GenericName\" and not comment:\n                            # Use GenericName as fallback description\n                            comment = value\n                        elif key == \"Icon\":\n                            icon = value\n                        elif key == \"Exec\":\n                            exec_cmd = value\n                        elif key == \"Categories\":\n                            categories = value\n\n                    # Get executable location\n                    location = get_executable_path(exec_cmd)\n\n                    return {\n                        \"name\": name,\n                        \"version\": version,\n                        \"comment\": comment,\n                        \"icon\": icon,\n                        \"exec\": exec_cmd,\n                        \"location\": location or \"\",\n                        \"categories\": categories,\n                        \"desktop_file\": desktop_file,\n                    }\n                except Exception:\n                    continue\n\n    # Fallback: try to find executable in PATH\n    location = \"\"\n    try:\n        result = subprocess.run(\n            [\"which\", wmclass.lower()], capture_output=True, text=True\n        )\n        if result.returncode == 0:\n            location = result.stdout.strip()\n    except Exception:\n        pass\n\n    return {\n        \"name\": wmclass.title() if wmclass else \"Unknown Application\",\n        \"version\": \"\",\n        \"comment\": \"\",\n        \"icon\": wmclass.lower() if wmclass else \"application-x-executable\",\n        \"exec\": \"\",\n        \"location\": location,\n        \"categories\": \"\",\n        \"desktop_file\": \"\",\n    }\n\n\nclass AboutApp(Gtk.Window):\n    def __init__(self, app_name=\"Unknown Application\", wmclass=\"\"):\n        super().__init__(title=f\"About {app_name}\")\n        self.app_name = app_name\n        self.wmclass = wmclass\n        self.icon_resolver = IconResolver()\n\n        self.set_default_size(300, 500)\n        self.set_size_request(300, 480)\n        self.set_resizable(False)\n        self.set_title(f\"About {app_name}\")\n        self.set_name(\"about-app\")\n        self.set_visible(False)\n\n        self.setup_ui()\n\n    def setup_ui(self):\n        app_info = get_app_info(self.wmclass)\n\n        main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)\n        main_box.set_margin_top(20)\n        main_box.set_margin_bottom(20)\n        main_box.set_margin_start(15)\n        main_box.set_margin_end(15)\n\n        # App Icon\n        logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)\n        try:\n            # Use IconResolver's get_icon_pixbuf method like other parts of the project\n            icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_info[\"icon\"], 128)\n            if icon_pixbuf:\n                logo = Gtk.Image.new_from_pixbuf(icon_pixbuf)\n            else:\n                raise Exception(\"Icon pixbuf not found\")\n        except Exception:\n            # Fallback: try direct file path if it's an absolute path\n            try:\n                if app_info[\"icon\"].startswith(\"/\") and os.path.exists(\n                    app_info[\"icon\"]\n                ):\n                    pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(\n                        app_info[\"icon\"], 128, 128, preserve_aspect_ratio=True\n                    )\n                    logo = Gtk.Image.new_from_pixbuf(pixbuf)\n                else:\n                    raise Exception(\"Direct path failed\")\n            except Exception:\n                # Final fallback: emoji\n                logo = Gtk.Label()\n                logo.set_markup(\"<span size='72000'>📱</span>\")\n\n        logo_box.pack_start(logo, False, False, 0)\n        logo_box.set_margin_bottom(15)\n\n        # App Name\n        app_name_label = Gtk.Label()\n        app_name_label.set_markup(\n            f\"<b><span size='18000'>{app_info['name']}</span></b>\"\n        )\n        app_name_label.set_halign(Gtk.Align.CENTER)\n        app_name_label.set_margin_bottom(5)\n\n        # Version (if available)\n        if app_info[\"version\"]:\n            version_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)\n            version_label = Gtk.Label(\n                label=f\"Version {app_info.get('version', 'Unknown')}\"\n            )\n            version_label.set_name(\"version-label\")\n            version_label.set_halign(Gtk.Align.CENTER)\n            version_box.pack_start(version_label, False, False, 0)\n            version_box.set_margin_bottom(5)\n\n        # Description/Comment - Make it more prominent\n        description_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)\n        if app_info[\"comment\"]:\n            # Create a frame for the description to make it stand out\n            desc_frame = Gtk.Frame()\n            desc_frame.set_shadow_type(Gtk.ShadowType.IN)\n\n            description_label = Gtk.Label()\n            description_label.set_markup(f\"<i><b>{app_info['comment']}</b></i>\")\n            description_label.set_justify(Gtk.Justification.CENTER)\n            description_label.set_halign(Gtk.Align.CENTER)\n            description_label.set_line_wrap(True)\n            description_label.set_max_width_chars(45)\n            description_label.set_margin_top(8)\n            description_label.set_margin_bottom(8)\n            description_label.set_margin_start(12)\n            description_label.set_margin_end(12)\n\n            desc_frame.add(description_label)\n            description_box.pack_start(desc_frame, False, False, 0)\n        else:\n            # Show a placeholder if no description is available\n            placeholder_label = Gtk.Label()\n            placeholder_label.set_markup(\n                \"<i><span foreground='#888888'>No description available</span></i>\"\n            )\n            placeholder_label.set_halign(Gtk.Align.CENTER)\n            description_box.pack_start(placeholder_label, False, False, 0)\n\n        description_box.set_margin_bottom(15)\n\n        # Information Grid\n        # Create a label with markup\n        info_frame = Gtk.Frame()\n        label = Gtk.Label()\n        label.set_markup(\"<b><u>Application Information</u></b>\")\n        label.set_margin_bottom(5)\n        info_frame.set_label_widget(label)\n\n        info_frame.set_label_align(0.5, 0.5)\n\n        info_grid = Gtk.Grid()\n        info_grid.set_row_spacing(8)\n        info_grid.set_column_spacing(15)\n        info_grid.set_margin_top(10)\n        info_grid.set_margin_bottom(10)\n        info_grid.set_margin_start(15)\n        info_grid.set_margin_end(15)\n        info_grid.set_valign(Gtk.Align.CENTER)\n        info_grid.set_halign(Gtk.Align.CENTER)\n\n        def make_info_row(label_text, value_text, row):\n            \"\"\"Create a row in the info grid\"\"\"\n            label = Gtk.Label(label=f\"{label_text}:\")\n            label.set_halign(Gtk.Align.END)\n            label.set_markup(f\"<b>{label_text}:</b>\")\n\n            value = Gtk.Label()\n            value.set_markup(f'<span foreground=\"#727272\">{value_text}</span>')\n            value.set_halign(Gtk.Align.START)\n            value.set_line_wrap(True)\n            value.set_max_width_chars(30)\n            value.set_name(\"info-value-label\")\n\n            info_grid.attach(label, 0, row, 1, 1)\n            info_grid.attach(value, 1, row, 1, 1)\n\n        row = 0\n\n        # Application Name\n        make_info_row(\"Name\", app_info[\"name\"], row)\n        row += 1\n\n        # Version\n        if app_info[\"version\"]:\n            make_info_row(\"Version\", app_info[\"version\"], row)\n            row += 1\n\n        # Executable Location\n        if app_info[\"location\"]:\n            make_info_row(\"Location\", app_info[\"location\"], row)\n            row += 1\n\n        # Window Class\n        if self.wmclass:\n            make_info_row(\"Window Class\", self.wmclass, row)\n            row += 1\n\n        # Categories\n        if app_info[\"categories\"]:\n            categories = app_info[\"categories\"].replace(\";\", \", \").strip(\", \")\n            make_info_row(\"Categories\", categories, row)\n            row += 1\n\n        # Desktop File\n        if app_info[\"desktop_file\"]:\n            desktop_file_name = os.path.basename(app_info[\"desktop_file\"])\n            make_info_row(\"Desktop File\", desktop_file_name, row)\n            row += 1\n\n        info_frame.add(info_grid)\n\n        # Layout\n        main_box.pack_start(logo_box, False, False, 0)\n        main_box.pack_start(app_name_label, False, False, 0)\n        if app_info[\"version\"]:\n            main_box.pack_start(version_box, False, False, 0)\n        main_box.pack_start(description_box, False, False, 0)\n        main_box.pack_start(info_frame, False, False, 0)\n\n        self.add(main_box)\n\n    def toggle(self, b):\n        if self.get_visible():\n            self.hide()\n        else:\n            self.show_all()\n\n\nclass About(Gtk.Window):\n    def __init__(self):\n        super().__init__(title=\"About Menu\")\n        self.set_default_size(300, 550)\n        self.set_size_request(300, 500)\n        self.set_resizable(False)\n        self.set_title(\"About PC\")\n        self.set_name(\"about-menu\")\n        self.set_visible(False)\n\n        main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)\n        main_box.set_margin_top(10)\n        main_box.set_margin_bottom(20)\n        main_box.set_margin_start(20)\n        main_box.set_margin_end(20)\n\n        # Logo\n        logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)\n        pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(\n            get_relative_path(\"../config/assets/icons/misc/imac.svg\"),\n            158,\n            108,\n            preserve_aspect_ratio=True,\n        )\n        logo = Gtk.Image.new_from_pixbuf(pixbuf)\n        logo_box.pack_start(logo, False, False, 0)\n        logo_box.set_margin_top(60)\n\n        # Product Name & Vendor\n        name_label = Gtk.Label()\n        name_label.set_margin_top(30)\n        name_label.set_markup(\n            f\"<b><span size='16000'>{read_dmi('product_name')}</span></b>\"\n        )\n\n        vendor_label = Gtk.Label(label=read_dmi(\"sys_vendor\"))\n        vendor_label.set_name(\"vendor-label\")\n        vendor_label.set_halign(Gtk.Align.CENTER)\n\n        # Info Grid\n        info_grid = Gtk.Grid()\n        info_grid.set_row_spacing(6)\n        info_grid.set_column_spacing(10)\n        info_grid.set_valign(Gtk.Align.CENTER)\n        info_grid.set_halign(Gtk.Align.FILL)\n\n        def make_label(text, align_end=False, name=None):\n            label = Gtk.Label(label=text)\n            label.set_halign(Gtk.Align.END if align_end else Gtk.Align.START)\n            if name:\n                label.set_name(name)\n            return label\n\n        # Info values\n        labels = [\n            (\n                \"Kernel\",\n                subprocess.run(\n                    \"uname -r\", shell=True, capture_output=True, text=True\n                ).stdout.strip(),\n            ),\n            (\n                \"CPU\",\n                subprocess.run(\n                    \"lscpu | grep 'Model name:' | cut -d ':' -f2-\",\n                    shell=True,\n                    capture_output=True,\n                    text=True,\n                ).stdout.strip(),\n            ),\n            (\n                \"Memory\",\n                subprocess.run(\n                    \"free -h --giga | grep Mem | tr -s ' ' | cut -d ' ' -f 2\",\n                    shell=True,\n                    capture_output=True,\n                    text=True,\n                ).stdout.strip(),\n            ),\n            (\"GPU\", get_gpu_name()),\n            (\n                \"Uptime\",\n                subprocess.run(\n                    \"uptime -p\", shell=True, capture_output=True, text=True\n                ).stdout.strip(),\n            ),\n        ]\n\n        for i, (title, value) in enumerate(labels):\n            title_label = make_label(title, align_end=True)\n            value_label = make_label(value, align_end=False, name=\"info-label\")\n            info_grid.attach(title_label, 0, i, 1, 1)\n            info_grid.attach(value_label, 1, i, 1, 1)\n\n        # Button\n        button_box = Gtk.Box(halign=Gtk.Align.CENTER)\n        button_box.set_margin_top(20)\n        more_info_button = Gtk.Button(label=\"More Info...\", name=\"more-info-button\")\n        more_info_button.connect(\"clicked\", self.open_more_info)\n        button_box.pack_start(more_info_button, False, False, 0)\n\n        # Info Footer\n        info = Gtk.Label(\n            label=\"™ and © 2025 Linux Inc.\\nAll rights reserved.\\n\\n\",\n            justify=Gtk.Justification.CENTER,\n            halign=Gtk.Align.CENTER,\n        )\n        info.set_name(\"info-label\")\n        info.set_margin_top(10)\n\n        # Layout Order\n        main_box.pack_start(logo_box, False, False, 0)\n        main_box.pack_start(name_label, False, False, 0)\n        main_box.pack_start(vendor_label, False, False, 0)\n        main_box.pack_start(info_grid, False, False, 0)\n        main_box.pack_start(button_box, False, False, 0)\n        main_box.pack_start(info, False, False, 0)\n\n        self.add(main_box)\n\n    def open_more_info(self, button):\n        # TODO: Implement the logic to open more information\n        pass\n\n    def toggle(self, b):\n        if self.get_visible():\n            self.hide()\n        else:\n            self.show_all()\n"
  },
  {
    "path": "modules/controlcenter/battery.py",
    "content": "import subprocess\n\nfrom fabric.utils import get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.separator import Separator\nfrom fabric.widgets.svg import Svg\nfrom gi.repository import GLib\n\nfrom services.battery import Battery\n\n\nclass EnergyModeButton(Box):\n    def __init__(\n        self,\n        profile_name: str,\n        display_name: str,\n        icon_name: str,\n        battery_service: Battery,\n        parent,\n        **kwargs,\n    ):\n        super().__init__(name=\"energy-mode-button\", **kwargs)\n        self.profile_name = profile_name\n        self.battery_service = battery_service\n        self.parent = parent\n\n        self.mode_icon_svg = Svg(\n            # icon_name=f\"battery-{icon_name}-symbolic\",\n            svg_file=get_relative_path(\n                f\"../../config/assets/icons/power_modes/battery-{icon_name}.svg\"\n            ),\n            size=24,\n        )\n        self.mode_icon = Box(\n            children=[self.mode_icon_svg],\n            name=\"energy-mode-icon\",\n            style_classes=\"battery-profile-icon\",\n        )\n\n        self.mode_label = Label(\n            label=display_name,\n            style_classes=\"battery-power-mode\",\n            h_align=\"start\",\n            h_expand=True,\n        )\n\n        start_box = Box(\n            orientation=\"horizontal\",\n            spacing=4,\n            children=[self.mode_icon, self.mode_label],\n        )\n\n        self.button = Button(\n            child=start_box,\n            h_expand=True,\n            name=\"energy-mode-button-clickable\",\n            on_clicked=self.on_clicked,\n            style_classes=\"battery-profile-button\",\n        )\n\n        self.children = [self.button]\n        self.update_state()\n\n    def on_clicked(self, *args):\n        success = self.battery_service.change_power_profile(self.profile_name)\n        if success:\n            # Update all profile buttons in parent\n            self.parent.update_energy_mode_buttons()\n\n        # Reset icon state after short delay\n        GLib.timeout_add(300, lambda: self._reset_icon_state())\n\n    def _reset_icon_state(self):\n        return False  # Remove timeout\n\n    def update_state(self):\n        is_active = self.battery_service.power_profile == self.profile_name\n        if is_active:\n            self.mode_icon.add_style_class(\"connected\")\n        else:\n            self.mode_icon.remove_style_class(\"connected\")\n\n\nclass GameModeButton(Box):\n    def __init__(self, parent, **kwargs):\n        super().__init__(name=\"energy-mode-button\", h_expand=True, **kwargs)\n        self.parent = parent\n\n        self.game_icon = Image(\n            icon_name=\"applications-games-symbolic\",\n            size=16,\n            name=\"game-mode-icon\",\n            style_classes=\"battery-gamemode-icon\",\n        )\n\n        self.game_label = Label(\n            label=\"Game Mode\",\n            style_classes=\"gamemode-button\",\n            h_align=\"start\",\n            h_expand=True,\n        )\n\n        start_box = Box(\n            orientation=\"horizontal\",\n            spacing=3,\n            children=[self.game_icon, self.game_label],\n        )\n\n        self.button = Button(\n            child=start_box,\n            name=\"game-mode-button-clickable\",\n            on_clicked=self.on_clicked,\n            h_expand=True,\n            style_classes=\"battery-gamemode-button\",\n        )\n\n        self.children = [self.button]\n        self.update_state()\n\n    def on_clicked(self, *args):\n        try:\n            script_path = get_relative_path(\"../../scripts/gamemode.sh\")\n            subprocess.run([script_path], check=False)\n\n            GLib.timeout_add(500, lambda: self.update_state())\n        except Exception as e:\n            print(f\"Failed to toggle game mode: {e}\")\n\n        GLib.timeout_add(300, lambda: self._reset_icon_state())\n\n    def _reset_icon_state(self):\n        return False  # Remove timeout\n\n    def update_state(self):\n        try:\n            script_path = get_relative_path(\"../../scripts/gamemode.sh\")\n            result = subprocess.run(\n                [script_path, \"check\"], capture_output=True, text=True, check=False\n            )\n            is_active = result.stdout.strip() == \"t\"\n\n            if is_active:\n                self.game_icon.add_style_class(\"connected\")\n            else:\n                self.game_icon.remove_style_class(\"connected\")\n        except Exception as e:\n            print(f\"Failed to check game mode status: {e}\")\n            # Default to inactive state on error\n            self.game_icon.remove_style_class(\"connected\")\n\n        return False  # Remove timeout if called from GLib.timeout_add\n\n\nclass BatteryControl(Box):\n    def __init__(self, parent, **kwargs):\n        super().__init__(\n            spacing=12,\n            orientation=\"vertical\",\n            name=\"control-center-widgets\",\n            **kwargs,\n        )\n        self.set_size_request(354, -1)\n\n        self.parent = parent\n        self.battery_service = Battery()\n        self.energy_mode_buttons = []\n\n        self.battery_widget = Box(\n            name=\"battery-widget\",\n            orientation=\"vertical\",\n            style_classes=\"battery-status-section\",\n            h_expand=True,\n            spacing=8,\n        )\n\n        self.battery_title = Label(\n            label=\"Battery\", style_classes=\"battery-main-title\", h_align=\"start\"\n        )\n\n        self.battery_percentage_label = Label(\n            label=\"80%\", style_classes=\"battery-percentage\", h_align=\"end\"\n        )\n\n        self.battery_header = CenterBox(\n            start_children=self.battery_title,\n            end_children=self.battery_percentage_label,\n            name=\"battery-header\",\n        )\n\n        self.power_source_label = Label(\n            label=\"Power Source: Power Adapter\",\n            style_classes=\"battery-power-source\",\n            h_align=\"start\",\n        )\n\n        self.charging_time_label = Label(\n            label=\"1h 4m until fully charged\",\n            style_classes=\"battery-power-source\",\n            h_align=\"start\",\n        )\n\n        self.energy_mode_section = Box(\n            orientation=\"vertical\", spacing=8, name=\"energy-mode-section\"\n        )\n\n        self.energy_mode_title = Label(\n            label=\"Energy Mode\", style_classes=\"battery-section-title\", h_align=\"start\"\n        )\n\n        self.energy_modes_container = Box(\n            orientation=\"vertical\", spacing=4, name=\"energy-modes-container\"\n        )\n\n        self.game_mode_section = Box(\n            orientation=\"vertical\", spacing=8, name=\"game-mode-section\"\n        )\n\n        self.game_mode_title = Label(\n            label=\"Game Mode\", style_classes=\"battery-section-title\", h_align=\"start\"\n        )\n\n        self.game_mode_container = Box(\n            orientation=\"vertical\", spacing=4, name=\"game-mode-container\"\n        )\n\n        self.battery_settings_button = Button(\n            v_align=\"center\",\n            child=Label(\n                label=\"Battery Settings\",\n                h_align=\"start\",\n            ),\n            style_classes=\"battery-settings-button\",\n            on_clicked=self.open_battery_settings,\n        )\n\n        self.battery_widget.add(self.battery_header)\n        self.battery_widget.add(self.power_source_label)\n        self.battery_widget.add(self.charging_time_label)\n\n        separator1 = Separator(orientation=\"h\", name=\"separator\")\n        self.battery_widget.add(separator1)\n\n        self.energy_mode_section.add(self.energy_mode_title)\n        self.energy_mode_section.add(self.energy_modes_container)\n        self.battery_widget.add(self.energy_mode_section)\n\n        separator2 = Separator(orientation=\"h\", name=\"separator\")\n        self.battery_widget.add(separator2)\n\n        self.game_mode_section.add(self.game_mode_title)\n        self.game_mode_section.add(self.game_mode_container)\n        self.battery_widget.add(self.game_mode_section)\n\n        separator3 = Separator(orientation=\"h\", name=\"separator\")\n        self.battery_widget.add(separator3)\n\n        self.battery_widget.add(self.battery_settings_button)\n\n        self.add(self.battery_widget)\n\n        self.battery_service.connect(\"changed\", self.on_battery_changed)\n        self.battery_service.connect(\"profile_changed\", self.on_profile_changed)\n\n        # Initialize display\n        self.update_battery_info()\n        self.create_energy_mode_buttons()\n        self.create_game_mode_button()\n\n    def open_battery_settings(self, *args):\n        # TODO: Implement to open Battery Settings\n        pass\n\n    def create_energy_mode_buttons(self):\n        # Clear existing buttons\n        for button in self.energy_mode_buttons:\n            button.destroy()\n        self.energy_mode_buttons.clear()\n\n        # Get available profiles\n        available_profiles = self.battery_service.available_profiles\n\n        if not available_profiles:\n            no_profiles_label = Label(\n                label=\"No energy modes available\",\n                style_classes=\"battery-no-profiles\",\n                h_align=\"start\",\n            )\n            self.energy_modes_container.add(no_profiles_label)\n            return\n\n        # Define energy mode mappings with proper icon names\n        energy_mode_config = {\n            \"balanced\": {\"display\": \"Automatic\", \"icon\": \"balanced\"},\n            \"power-saver\": {\"display\": \"Low Power\", \"icon\": \"power\"},\n            \"powersave\": {\"display\": \"Low Power\", \"icon\": \"power\"},\n            \"performance\": {\"display\": \"High Power\", \"icon\": \"performance\"},\n        }\n\n        # Define the desired order for energy modes\n        desired_order = [\"balanced\", \"power-saver\", \"powersave\", \"performance\"]\n\n        # Create ordered list of available profiles\n        ordered_profiles = []\n        for profile_name in desired_order:\n            if profile_name in available_profiles:\n                ordered_profiles.append(profile_name)\n\n        # Add any remaining profiles not in the desired order\n        for profile in available_profiles:\n            if profile not in ordered_profiles:\n                ordered_profiles.append(profile)\n\n        # Create button for each available profile in the specified order\n        for profile in ordered_profiles:\n            config = energy_mode_config.get(\n                profile, {\"display\": profile.title(), \"icon\": \"good\"}\n            )\n\n            button = EnergyModeButton(\n                profile_name=profile,\n                display_name=config[\"display\"],\n                icon_name=config[\"icon\"],\n                battery_service=self.battery_service,\n                parent=self,\n            )\n            self.energy_mode_buttons.append(button)\n            self.energy_modes_container.add(button)\n\n    def update_energy_mode_buttons(self):\n        for button in self.energy_mode_buttons:\n            button.update_state()\n\n    def create_game_mode_button(self):\n        # Clear existing game mode button if any\n        for child in list(self.game_mode_container.get_children()):\n            child.destroy()\n\n        # Create game mode button\n        self.game_mode_button = GameModeButton(parent=self)\n        self.game_mode_container.add(self.game_mode_button)\n\n    def update_battery_info(self):\n        if not self.battery_service.is_present:\n            self.battery_percentage_label.set_label(\"No Battery\")\n            self.power_source_label.set_label(\"Power Source: Not Present\")\n            self.charging_time_label.set_label(\"\")\n            return\n\n        # Update percentage in header\n        percentage = self.battery_service.percentage\n        self.battery_percentage_label.set_label(f\"{percentage}%\")\n\n        # Update power source and charging info\n        state = self.battery_service.state\n\n        if state in [\"CHARGING\", \"PENDING_CHARGE\"]:\n            self.power_source_label.set_label(\"Power Source: Power Adapter\")\n            time_to_full = self.battery_service.time_to_full\n            if time_to_full != \"N/A\" and time_to_full != \"0m\":\n                self.charging_time_label.set_label(\n                    f\"{time_to_full} until fully charged\"\n                )\n            else:\n                self.charging_time_label.set_label(\"Charging...\")\n        elif state == \"FULLY_CHARGED\":\n            self.power_source_label.set_label(\"Power Source: Power Adapter\")\n            self.charging_time_label.set_label(\"Fully Charged\")\n        elif state in [\"DISCHARGING\", \"PENDING_DISCHARGE\"]:\n            self.power_source_label.set_label(\"Power Source: Battery\")\n            time_to_empty = self.battery_service.time_to_empty\n            if time_to_empty != \"N/A\" and not time_to_empty.startswith(\n                \"4553h\"\n            ):  # Filter out unrealistic times\n                self.charging_time_label.set_label(f\"{time_to_empty} remaining\")\n            else:\n                self.charging_time_label.set_label(\"On Battery Power\")\n        elif state == \"EMPTY\":\n            self.power_source_label.set_label(\"Power Source: Battery\")\n            self.charging_time_label.set_label(\"Battery Empty\")\n        else:\n            self.power_source_label.set_label(\"Power Source: Unknown\")\n            self.charging_time_label.set_label(\"\")\n\n    def on_battery_changed(self, *args):\n        self.update_battery_info()\n\n    def on_profile_changed(self, service, new_profile):\n        self.update_energy_mode_buttons()\n"
  },
  {
    "path": "modules/controlcenter/bluetooth.py",
    "content": "import subprocess\n\nimport gi\nfrom fabric.bluetooth import BluetoothClient, BluetoothDevice\nfrom fabric.utils import get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.revealer import Revealer\nfrom fabric.widgets.scrolledwindow import ScrolledWindow\nfrom fabric.widgets.separator import Separator\nfrom fabric.widgets.svg import Svg\nfrom gi.repository import Gdk, GLib, Gtk\nfrom loguru import logger\n\nfrom services.battery import Battery\n\ngi.require_version(\"Gtk\", \"3.0\")\ngi.require_version(\"Gdk\", \"3.0\")\n\n\ndef set_bluetooth_enabled_with_fallback(client, enabled: bool):\n    try:\n        # Try fabric bluetooth first\n        client.set_enabled(enabled)\n    except Exception as e:\n        logger.warning(f\"Fabric bluetooth set_enabled({enabled}) failed: {e}\")\n        # Fallback to rfkill to unblock/block bluetooth\n        if enabled:\n            command = [\"rfkill\", \"unblock\", \"bluetooth\"]\n        else:\n            command = [\"rfkill\", \"block\", \"bluetooth\"]\n\n        try:\n            # Execute the rfkill command\n            subprocess.run(\n                command, capture_output=True, text=True, timeout=10, check=True\n            )\n        except Exception as subprocess_error:\n            logger.error(f\"rfkill fallback failed with exception: {subprocess_error}\")\n\n\nclass BluetoothDeviceSlot(CenterBox):\n    def __init__(self, device: BluetoothDevice, **kwargs):\n        super().__init__(h_expand=True, name=\"device-button\", **kwargs)\n        self.device = device\n        self.device.connect(\"changed\", self.on_changed)\n        self.device.connect(\n            \"notify::closed\", lambda *_: self.device.closed and self.destroy()\n        )\n\n        self.styles = [\n            \"connected\" if self.device.connected else \"\",\n            \"paired\" if self.device.paired else \"\",\n        ]\n\n        self.dimage = Image(\n            icon_name=device.icon_name + \"-symbolic\",  # type: ignore\n            size=5,\n            name=\"device-icon\",\n            style_classes=\" \".join(self.styles),\n        )\n\n        self.device_button = Button(\n            on_clicked=lambda *_: self.toggle_connecting(),\n            child=Box(\n                orientation=\"h\",\n                h_expand=True,\n                children=[self.dimage, Label(label=device.name)],\n            ),  # type: ignore\n        )\n        self.start_children = [self.device_button]\n\n        # Add battery info if available\n        if hasattr(device, \"battery_percentage\") and device.battery_percentage > 0:\n            battery_box = Box(orientation=\"h\", spacing=4)\n\n            # Create battery icon\n            battery_icon = Svg(\n                size=16,\n                svg_file=get_relative_path(\n                    Battery.get_battery_icon_file(\n                        device.battery_percentage,\n                        False,  # Not charging for bluetooth devices\n                        \"../../config/assets/icons/\",\n                    )\n                ),\n                name=\"battery-icon\",\n            )\n\n            # Create battery percentage label\n            battery_label = Label(\n                label=f\"{device.battery_percentage:.0f}%\", name=\"battery-label\"\n            )\n\n            battery_box.children = [battery_icon, battery_label]\n            self.end_children = [battery_box]\n\n        self.device_button.connect(\"enter-notify-event\", self.on_button_enter)\n        self.device_button.connect(\"leave-notify-event\", self.on_button_leave)\n        self.device.emit(\"changed\")  # to update display status\n\n    def on_button_enter(self, widget, event):\n        self.add_style_class(\"button-hovered\")\n\n    def on_button_leave(self, widget, event):\n        self.remove_style_class(\"button-hovered\")\n\n    def toggle_connecting(self):\n        self.device.emit(\"changed\")\n        self.device.set_connecting(not self.device.connected)\n\n    def on_changed(self, *_):\n        try:\n            # Update connection and pairing status\n            new_styles = [\n                \"connected\" if self.device.connected else \"\",\n                \"paired\" if self.device.paired else \"\",\n            ]\n\n            self.styles = new_styles\n            self.dimage.set_property(\"style-classes\", \" \".join(self.styles))\n        except Exception:\n            return\n\n        # Update battery info if available\n        if (\n            hasattr(self.device, \"battery_percentage\")\n            and self.device.battery_percentage > 0\n        ):\n            if not self.end_children:  # Add battery display if not already present\n                battery_box = Box(orientation=\"h\", spacing=4)\n\n                # Create battery icon\n                battery_icon = Svg(\n                    size=16,\n                    svg_file=get_relative_path(\n                        Battery.get_battery_icon_file(\n                            self.device.battery_percentage,\n                            False,  # Not charging for bluetooth devices\n                            \"../../config/assets/icons/\",\n                        )\n                    ),\n                    name=\"battery-icon\",\n                )\n\n                # Create battery percentage label\n                battery_label = Label(\n                    label=f\"{self.device.battery_percentage:.0f}%\", name=\"battery-label\"\n                )\n\n                battery_box.children = [battery_icon, battery_label]\n                self.end_children = [battery_box]\n            else:  # Update existing battery display\n                battery_box = self.end_children[0]\n                if hasattr(battery_box, \"children\") and len(battery_box.children) >= 2:\n                    battery_icon = battery_box.children[0]\n                    battery_label = battery_box.children[1]\n\n                    # Update battery icon\n                    battery_icon.set_from_file(\n                        get_relative_path(\n                            Battery.get_battery_icon_file(\n                                self.device.battery_percentage,\n                                False,  # Not charging for bluetooth devices\n                                \"../../config/assets/icons/\",\n                            )\n                        )\n                    )\n\n                    # Update battery percentage\n                    battery_label.set_label(f\"{self.device.battery_percentage:.0f}%\")\n        elif self.end_children:  # Remove battery display if no longer available\n            self.end_children = []\n\n        return\n\n\nclass BluetoothConnections(Box):\n    def __init__(\n        self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs\n    ):\n        super().__init__(\n            spacing=8,\n            orientation=\"vertical\",\n            name=\"bluetooth-connections\",\n            **kwargs,\n        )\n\n        self.parent = parent\n        self.show_hidden_devices = show_hidden_devices\n        self.is_scanning = False  # Track scanning state\n        self.refresh_timer = None  # Timer for periodic device refresh\n        self._update_in_progress = False  # Prevent concurrent updates\n        self._destroyed = False  # Track if widget is destroyed\n\n        self.client = BluetoothClient(on_device_added=self.on_device_added)\n\n        # Create pull-to-refresh indicator\n        self.refresh_indicator = Label(\n            name=\"bluetooth-refresh-indicator\",\n            label=\"↓ Pull to scan for devices\",\n            h_align=\"center\",\n            visible=False,\n            style=\"color: #fff; font-size: 12px; padding: 5px;\",\n        )\n\n        # Create title with optional back button\n        title_children = []\n        if show_back_button:\n            title_children.append(\n                Button(\n                    image=Image(icon_name=\"back\", size=10),\n                    on_clicked=lambda *_: self.parent.close_bluetooth(),\n                )\n            )\n        title_children.append(Label(\"Bluetooth\", name=\"bluetooth-title\"))\n\n        self.title = Box(\n            orientation=\"h\",\n            children=title_children,\n        )\n\n        self.toggle_button = Gtk.Switch(visible=True, name=\"toggle-button\")\n\n        # Safely set initial state\n        self.toggle_button.set_active(self.client.enabled)\n        self.toggle_button.connect(\n            \"notify::active\",\n            lambda *_: set_bluetooth_enabled_with_fallback(\n                self.client, self.toggle_button.get_active()\n            ),\n        )\n\n        # Connect client signals\n        self.client.connect(\n            \"notify::enabled\",\n            lambda *_: self.toggle_button.set_active(self.client.enabled),\n        )\n        self.client.connect(\"notify::scanning\", lambda *_: self.update_scan_label())\n\n        # Connect to device changes\n        self.client.connect(\"device-added\", self.update_devices)\n        self.client.connect(\"device-removed\", self.update_devices)\n\n        # Connect to additional signals for better real-time monitoring\n        self.client.connect(\"changed\", self.on_client_changed)\n\n        # Create Devices section\n        self.paired_devices_label = Label(\n            label=\"Devices\", h_align=\"start\", name=\"networks-title\"\n        )\n        self.paired_devices = Box(\n            spacing=4, orientation=\"vertical\", name=\"known-networks\"\n        )\n\n        # Create \"No devices available\" message\n        self.no_devices_label = Label(\n            label=\"No devices available\",\n            h_align=\"center\",\n            name=\"no-networks-label\",\n            visible=False,\n        )\n\n        # Create Other Devices section with clickable title\n        self.other_devices_button = Button(\n            child=Label(\"Other Devices\", h_align=\"start\"),\n            name=\"wifi-other-button\",\n            on_clicked=self.toggle_other_devices,\n        )\n        self.other_devices = Box(spacing=4, orientation=\"vertical\")\n\n        # Create scrolled window for other devices\n        self.other_devices_scrolled = ScrolledWindow(\n            min_content_size=(303, 150),\n            child=self.other_devices,\n            overlay_scroll=True,\n        )\n\n        # Add pull-to-refresh functionality to scrolled window\n        self.setup_pull_to_refresh()\n\n        # Create revealer for Other Devices section\n        self.other_devices_revealer = Revealer(\n            child=self.other_devices_scrolled,\n            transition_type=\"slide-down\",\n            transition_duration=100,\n            child_revealed=False,\n        )\n\n        # Create More Settings button (same style as Other Devices button)\n        self.more_settings_button = Button(\n            child=Label(\"More Settings\", h_align=\"start\"),\n            name=\"wifi-other-button\",\n            on_clicked=self.open_bluetooth_settings,\n        )\n\n        self.children = [\n            CenterBox(\n                start_children=self.title,\n                end_children=self.toggle_button,\n                name=\"bluetooth-widget-top\",\n            ),\n            self.refresh_indicator,\n            Separator(orientation=\"h\", name=\"separator\"),\n            self.paired_devices_label,\n            self.paired_devices,\n            self.no_devices_label,\n            Separator(orientation=\"h\", name=\"separator\"),\n            self.other_devices_button,\n            self.other_devices_revealer,\n            Separator(orientation=\"h\", name=\"separator\"),\n            self.more_settings_button,\n        ]\n\n        # Connect cleanup on destroy\n        self.connect(\"destroy\", self.on_destroy)\n\n        self.client.notify(\"scanning\")\n        self.client.notify(\"enabled\")\n\n        # Initial device update\n        self.update_devices()\n\n        # Start periodic device monitoring for real-time updates\n        self.start_device_monitoring()\n\n    def toggle_other_devices(self, *_):\n        \"\"\"Toggle the visibility of other devices section\"\"\"\n        current_state = self.other_devices_revealer.child_revealed\n        self.other_devices_revealer.child_revealed = not current_state\n\n        # Handle scanning based on section visibility\n        if self.client:\n            if self.other_devices_revealer.child_revealed:\n                # Start scanning when revealing other devices section\n                if not self.client.scanning:\n                    self.client.toggle_scan()\n                # Also force an immediate device refresh to catch any missed connections\n                self.force_device_refresh()\n            else:\n                # Stop scanning when hiding other devices section\n                if self.client.scanning:\n                    self.client.toggle_scan()\n\n    def open_bluetooth_settings(self, *_):\n        \"\"\"Open Blueman bluetooth manager\"\"\"\n        try:\n            subprocess.Popen([\"blueman-manager\"], start_new_session=True)\n            if self.parent and hasattr(self.parent, \"hide_controlcenter\"):\n                self.parent.hide_controlcenter()\n        except FileNotFoundError:\n            pass\n        except Exception:\n            pass\n\n    def update_scan_label(self):\n        \"\"\"Update scanning state appearance\"\"\"\n        if self.client.scanning:\n            # Show scanning feedback in refresh indicator\n            self.refresh_indicator.set_label(\"Scanning for devices...\")\n            self.refresh_indicator.set_visible(True)\n            self.refresh_indicator.add_style_class(\"scanning\")\n        else:\n            # Hide scanning feedback\n            self.refresh_indicator.set_visible(False)\n            self.refresh_indicator.remove_style_class(\"scanning\")\n\n    def update_devices(self, *_):\n        \"\"\"Update the list of available devices\"\"\"\n        # Prevent concurrent updates and check if destroyed\n        if self._update_in_progress or self._destroyed or not self.client:\n            return\n\n        self._update_in_progress = True\n\n        try:\n            # Store current device addresses to detect changes\n            current_paired_addresses = {\n                child.device.address\n                for child in self.paired_devices.get_children()\n                if hasattr(child, \"device\")\n            }\n            current_other_addresses = {\n                child.device.address\n                for child in self.other_devices.get_children()\n                if hasattr(child, \"device\")\n            }\n\n            # Get current devices safely\n            devices = self.client.devices\n            paired_devices = []\n            other_devices = []\n            new_paired_addresses = set()\n            new_other_addresses = set()\n\n            for device in devices:\n                try:\n                    if device.name and device.name != \"Unknown\":\n                        # Categorize devices: paired devices go to \"Devices (Paired)\"\n                        # All others go to \"Other Devices\"\n                        if device.paired:\n                            paired_devices.append(device)\n                            new_paired_addresses.add(device.address)\n                        else:\n                            other_devices.append(device)\n                            new_other_addresses.add(device.address)\n                except Exception:\n                    continue\n\n            # Check if we need to update (devices added/removed)\n            paired_changed = current_paired_addresses != new_paired_addresses\n            other_changed = current_other_addresses != new_other_addresses\n\n            # Only rebuild if something actually changed\n            if paired_changed or other_changed:\n                # Clear existing devices safely\n                for child in list(self.paired_devices.get_children()):\n                    if not self._destroyed:\n                        child.destroy()\n                for child in list(self.other_devices.get_children()):\n                    if not self._destroyed:\n                        child.destroy()\n\n                # Add paired devices\n                for device in paired_devices:\n                    if not self._destroyed:\n                        device_slot = BluetoothDeviceSlot(device)\n                        self.paired_devices.add(device_slot)\n\n                # Add other devices\n                for device in other_devices:\n                    if not self._destroyed:\n                        device_slot = BluetoothDeviceSlot(device)\n                        self.other_devices.add(device_slot)\n\n            # Show/hide sections based on available devices\n            if not self._destroyed:\n                has_paired_devices = len(paired_devices) > 0\n                has_other_devices = len(other_devices) > 0\n                has_any_devices = has_paired_devices or has_other_devices\n\n                # Show paired devices section only if there are paired devices\n                self.paired_devices_label.set_visible(has_paired_devices)\n                self.paired_devices.set_visible(has_paired_devices)\n\n                # Show \"No devices available\" message if no devices at all\n                self.no_devices_label.set_visible(not has_any_devices)\n\n                # Always show the other devices button, regardless of available devices\n                self.other_devices_button.set_visible(True)  # Always visible\n\n        except Exception:\n            pass\n        finally:\n            self._update_in_progress = False\n\n    def start_device_monitoring(self):\n        \"\"\"Start periodic monitoring for device changes\"\"\"\n        # Monitor for device changes every 5 seconds (less aggressive)\n        # This helps catch devices that connect from external sources\n        self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh)\n\n    def stop_device_monitoring(self):\n        \"\"\"Stop periodic monitoring\"\"\"\n        if self.refresh_timer:\n            GLib.source_remove(self.refresh_timer)\n            self.refresh_timer = None\n\n    def periodic_device_refresh(self):\n        \"\"\"Periodically refresh device list to catch external connections\"\"\"\n        # Skip if update in progress, destroyed, or client not available\n        if (\n            self._update_in_progress\n            or self._destroyed\n            or not self.client\n            or not self.client.enabled\n        ):\n            return True  # Continue monitoring\n\n        try:\n            # Simple check - just trigger update_devices which has its own safety checks\n            # Don't force signal emissions as that can cause race conditions\n            self.update_devices()\n\n        except Exception:\n            pass\n\n        return True  # Continue monitoring\n\n    def force_device_refresh(self):\n        \"\"\"Force an immediate refresh of the device list\"\"\"\n        if self._update_in_progress or self._destroyed:\n            return\n\n        try:\n            # Simply trigger update_devices which has its own safety checks\n            # Avoid forcing signal emissions to prevent race conditions\n            self.update_devices()\n        except Exception:\n            pass\n\n    def on_client_changed(self, *_):\n        \"\"\"Handle when the bluetooth client state changes\"\"\"\n        # Update devices when client state changes\n        self.update_devices()\n\n    def on_destroy(self, widget):\n        \"\"\"Cleanup when widget is destroyed\"\"\"\n        # Mark as destroyed to prevent further updates\n        self._destroyed = True\n        # Stop monitoring\n        self.stop_device_monitoring()\n        # Make sure other devices revealer is collapsed when closing\n        try:\n            self.other_devices_revealer.child_revealed = False\n        except:\n            pass  # Widget might already be destroyed\n\n    def close_bluetooth(self):\n        \"\"\"Called when Bluetooth panel is being closed\"\"\"\n        # Collapse the other devices section when closing\n        self.other_devices_revealer.child_revealed = False\n\n    def setup_pull_to_refresh(self):\n        \"\"\"Setup pull-to-refresh gesture for the scrolled window\"\"\"\n        # Get the scrolled window's vertical adjustment\n        self.vadjustment = self.other_devices_scrolled.get_vadjustment()\n\n        # Track gesture state\n        self.pull_start_y = 0\n        self.is_pulling = False\n        self.pull_threshold = 50  # pixels to trigger refresh\n\n        # Connect to scroll events\n        self.other_devices_scrolled.connect(\"scroll-event\", self.on_scroll_event)\n        self.other_devices_scrolled.connect(\"button-press-event\", self.on_button_press)\n        self.other_devices_scrolled.connect(\n            \"button-release-event\", self.on_button_release\n        )\n        self.other_devices_scrolled.connect(\n            \"motion-notify-event\", self.on_motion_notify\n        )\n\n        # Enable events\n        self.other_devices_scrolled.set_events(\n            Gdk.EventMask.SCROLL_MASK\n            | Gdk.EventMask.BUTTON_PRESS_MASK\n            | Gdk.EventMask.BUTTON_RELEASE_MASK\n            | Gdk.EventMask.POINTER_MOTION_MASK\n        )\n\n    def on_scroll_event(self, widget, event):\n        \"\"\"Handle scroll events for pull-to-refresh\"\"\"\n        # Only handle pull-to-refresh when at the top\n        if self.vadjustment.get_value() <= 0:\n            if event.direction == Gdk.ScrollDirection.UP:\n                # Scrolling up at the top - toggle scan and force refresh\n                self.client.toggle_scan()\n                self.force_device_refresh()\n                return True  # Consume the event\n        return False  # Let normal scrolling continue\n\n    def on_button_press(self, widget, event):\n        \"\"\"Handle button press for touch/drag gestures\"\"\"\n        if self.vadjustment.get_value() <= 0:\n            self.pull_start_y = event.y\n            self.is_pulling = True\n        return False\n\n    def on_button_release(self, widget, event):\n        \"\"\"Handle button release for touch/drag gestures\"\"\"\n        if self.is_pulling:\n            pull_distance = event.y - self.pull_start_y\n            if pull_distance > self.pull_threshold:\n                # Toggle scan and force refresh\n                self.client.toggle_scan()\n                self.force_device_refresh()\n            # Hide refresh indicator\n            self.refresh_indicator.set_visible(False)\n            self.refresh_indicator.remove_style_class(\"ready-to-refresh\")\n            self.is_pulling = False\n        return False\n\n    def on_motion_notify(self, widget, event):\n        \"\"\"Handle motion events for visual feedback during pull\"\"\"\n        if self.is_pulling and self.vadjustment.get_value() <= 0:\n            pull_distance = event.y - self.pull_start_y\n            if pull_distance > 0:\n                # Show refresh indicator when pulling down\n                self.refresh_indicator.set_visible(True)\n                if pull_distance >= self.pull_threshold:\n                    if self.client.scanning:\n                        self.refresh_indicator.set_label(\"↑ Release to stop scanning\")\n                    else:\n                        self.refresh_indicator.set_label(\"↑ Release to scan\")\n                    self.refresh_indicator.add_style_class(\"ready-to-refresh\")\n                else:\n                    if self.client.scanning:\n                        self.refresh_indicator.set_label(\"↓ Pull to stop scanning\")\n                    else:\n                        self.refresh_indicator.set_label(\"↓ Pull to scan for devices\")\n                    self.refresh_indicator.remove_style_class(\"ready-to-refresh\")\n            else:\n                self.refresh_indicator.set_visible(False)\n        return False\n\n    def on_device_added(self, client: BluetoothClient, address: str):\n        \"\"\"Handle when a new device is added\"\"\"\n        # Update the device list when devices are added\n        self.update_devices()\n"
  },
  {
    "path": "modules/controlcenter/expanded_player.py",
    "content": "# Standard library imports\nimport os\nimport re\nimport tempfile\nimport urllib.parse\nimport urllib.request\nimport threading\nimport weakref\nfrom typing import List, Optional, Dict, Set\nimport gc\n\n# Fabric imports\nfrom fabric.widgets.scale import Scale\nfrom widgets.wayland import WaylandWindow as Window\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.box import Box\nfrom fabric.utils import bulk_connect, invoke_repeater, cooldown\nfrom fabric.utils.helpers import get_relative_path\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.overlay import Overlay\nfrom fabric.widgets.stack import Stack\nfrom fabric.widgets.svg import Svg\nfrom gi.repository import GLib, GObject\nfrom loguru import logger\n\n# Local imports\nfrom services.mpris import MprisPlayer, MprisPlayerManager\nimport config.data as data\n\nCACHE_DIR = f\"{data.CACHE_DIR}/media\"\n\n# Global memory management\n_shared_mpris_manager = None\n_widget_cache = weakref.WeakValueDictionary()\n_artwork_cache = {}\n_max_artwork_cache_size = 10  # Limit artwork cache to prevent memory bloat\n\n\ndef get_shared_mpris_manager():\n    \"\"\"Get shared MPRIS manager instance to reduce memory usage.\"\"\"\n    global _shared_mpris_manager\n    if _shared_mpris_manager is None:\n        _shared_mpris_manager = MprisPlayerManager()\n    return _shared_mpris_manager\n\n\ndef cleanup_artwork_cache():\n    \"\"\"Clean up artwork cache to prevent memory leaks.\"\"\"\n    global _artwork_cache\n    if len(_artwork_cache) > _max_artwork_cache_size:\n        # Keep only the most recent items\n        items = list(_artwork_cache.items())\n        _artwork_cache = dict(items[-_max_artwork_cache_size:])\n        # Force garbage collection\n        gc.collect()\n\n\ndef cleanup_old_cache_files():\n    \"\"\"Clean up old artwork cache files aggressively to save memory.\"\"\"\n    try:\n        if not os.path.exists(CACHE_DIR):\n            return\n\n        import time\n\n        current_time = time.time()\n        # Clean files older than 2 hours (more aggressive)\n        two_hours_ago = current_time - (2 * 60 * 60)\n\n        for filename in os.listdir(CACHE_DIR):\n            filepath = os.path.join(CACHE_DIR, filename)\n            try:\n                if os.path.isfile(filepath):\n                    file_mod_time = os.path.getmtime(filepath)\n                    if file_mod_time < two_hours_ago:\n                        os.remove(filepath)\n                        logger.debug(f\"Cleaned up old cache file: {filename}\")\n            except Exception as e:\n                logger.warning(f\"Failed to clean up cache file {filename}: {e}\")\n\n    except Exception as e:\n        logger.error(f\"Error during cache cleanup: {e}\")\n\n\ndef get_artwork_cached(url: str) -> Optional[str]:\n    \"\"\"Get artwork with memory-efficient caching.\"\"\"\n    if not url:\n        return None\n\n    # Check memory cache first\n    if url in _artwork_cache:\n        return _artwork_cache[url]\n\n    # Clean cache if too large\n    cleanup_artwork_cache()\n\n    try:\n        # Create cache directory if it doesn't exist\n        os.makedirs(CACHE_DIR, exist_ok=True)\n\n        # Generate cache filename\n        safe_filename = re.sub(\n            r\"[^\\w\\-_.]\", \"_\", urllib.parse.urlparse(url).path.split(\"/\")[-1]\n        )\n        if not safe_filename:\n            safe_filename = f\"artwork_{hash(url)}\"\n\n        cache_file = os.path.join(CACHE_DIR, safe_filename)\n\n        # Check if cached file exists and is recent (within 2 hours)\n        if os.path.exists(cache_file):\n            import time\n\n            if time.time() - os.path.getmtime(cache_file) < 7200:  # 2 hours\n                _artwork_cache[url] = cache_file\n                return cache_file\n\n        # Download and cache\n        urllib.request.urlretrieve(url, cache_file)\n        _artwork_cache[url] = cache_file\n        return cache_file\n\n    except Exception as e:\n        logger.warning(f\"Failed to cache artwork from {url}: {e}\")\n        return None\n\n        for filename in os.listdir(CACHE_DIR):\n            filepath = os.path.join(CACHE_DIR, filename)\n            try:\n                if os.path.isfile(filepath):\n                    file_mtime = os.path.getmtime(filepath)\n                    if file_mtime < six_hours_ago:\n                        os.unlink(filepath)\n            except Exception:\n                pass  # Ignore individual file errors\n    except Exception:\n        pass  # Ignore all errors in cleanup\n\n\nclass EmbeddedExpandedPlayer(Box):\n    \"\"\"Embedded expanded player widget for use inside control center.\"\"\"\n\n    def __init__(self, control_center, **kwargs):\n        super().__init__(\n            orientation=\"vertical\",\n            h_expand=True,\n            name=\"embedded-expanded-player\",\n            **kwargs,\n        )\n\n        self.control_center = control_center\n        self.mpris_manager = get_shared_mpris_manager()\n\n        # Create back button (hidden in header)\n        self.back_button = Button(\n            name=\"back-button\",\n            child=Label(label=\"← Back\"),\n            on_clicked=self._on_back_clicked,\n        )\n\n        # Create expanded player content\n        self.player_content = PlayerBoxStack(self.mpris_manager)\n\n        # Add escape key binding for navigation back\n        self._keybinding_added = False\n        try:\n            if hasattr(self.control_center, \"add_keybinding\"):\n                self.control_center.add_keybinding(\"Escape\", self._on_back_clicked)\n                self._keybinding_added = True\n        except Exception:\n            pass  # Ignore if keybinding fails\n\n        self.children = [\n            Box(\n                orientation=\"horizontal\",\n                h_expand=True,\n                style_classes=\"menu\",\n                visible=False,  # Hide header to remove title and back button\n                children=[\n                    self.back_button,\n                    Box(h_expand=True),  # Spacer\n                    Label(label=\"Now Playing\", style_classes=\"title\"),\n                    Box(h_expand=True),  # Spacer\n                ],\n            ),\n            self.player_content,\n        ]\n\n    def _on_back_clicked(self, *_):\n        \"\"\"Handle back button click\"\"\"\n        if self.control_center and hasattr(\n            self.control_center, \"close_expanded_player\"\n        ):\n            self.control_center.close_expanded_player()\n\n    def refresh(self):\n        \"\"\"Refresh the player content\"\"\"\n        # This will automatically update as MPRIS players change\n        pass\n\n    def destroy(self):\n        \"\"\"Clean up resources and prevent memory leaks\"\"\"\n        logger.debug(\"🗑️ EmbeddedExpandedPlayer cleanup starting\")\n\n        try:\n            # Destroy player content (PlayerBoxStack)\n            if hasattr(self, \"player_content\") and hasattr(\n                self.player_content, \"destroy\"\n            ):\n                self.player_content.destroy()\n\n            # Clean up back button\n            if hasattr(self, \"back_button\") and hasattr(self.back_button, \"destroy\"):\n                self.back_button.destroy()\n\n            # Clean up any other widgets we might have\n            for child in list(self.get_children()):\n                try:\n                    child.destroy()\n                except Exception as e:\n                    logger.warning(f\"Failed to destroy child widget: {e}\")\n\n            # Clean up keybinding if it was added\n            if hasattr(self, \"_keybinding_added\") and self._keybinding_added:\n                try:\n                    if hasattr(self.control_center, \"remove_keybinding\"):\n                        self.control_center.remove_keybinding(\"Escape\")\n                except Exception as e:\n                    logger.warning(f\"Failed to remove keybinding: {e}\")\n\n            # Aggressively clean global caches\n            global _widget_cache, _artwork_cache\n            _widget_cache.clear()\n            _artwork_cache.clear()\n            logger.debug(\"🗑️ Cleared global widget and artwork caches\")\n\n            # Clear references\n            if hasattr(self, \"control_center\"):\n                self.control_center = None\n            if hasattr(self, \"mpris_manager\"):\n                self.mpris_manager = None\n\n            # Force garbage collection\n            gc.collect()\n            logger.debug(\"🗑️ EmbeddedExpandedPlayer cleanup completed\")\n\n        except Exception as e:\n            logger.error(f\"Error during EmbeddedExpandedPlayer cleanup: {e}\")\n        finally:\n            super().destroy()\n\n    def _periodic_cleanup(self):\n        \"\"\"Light cleanup for EmbeddedExpandedPlayer reuse\"\"\"\n        try:\n            logger.debug(\"🧹 EmbeddedExpandedPlayer light cleanup starting\")\n\n            # Only clean up the player content lightly\n            if hasattr(self, \"player_content\") and hasattr(\n                self.player_content, \"_periodic_cleanup\"\n            ):\n                self.player_content._periodic_cleanup()\n\n            # Clean global caches but don't destroy core functionality\n            global _artwork_cache\n            _artwork_cache.clear()\n\n            # Light garbage collection\n            gc.collect()\n\n            logger.debug(\"🧹 EmbeddedExpandedPlayer light cleanup completed\")\n\n        except Exception as e:\n            logger.warning(f\"Error during EmbeddedExpandedPlayer light cleanup: {e}\")\n\n\nclass PlayerBoxStack(Box):\n    \"\"\"Memory-optimized widget that displays current player information.\"\"\"\n\n    def __init__(self, mpris_manager: MprisPlayerManager, **kwargs):\n        # Clean up old cache files on startup\n        cleanup_old_cache_files()\n\n        # The player stack with memory-efficient settings\n        self.player_stack = Stack(\n            name=\"player-stack\",\n            # Disable transitions to reduce memory usage\n            transition_type=\"none\",\n        )\n        self.current_stack_pos = 0\n\n        # Store player buttons - cleaned up via explicit removal\n        self.player_buttons: list[Button] = []\n        self._player_widgets: Dict[str, Box] = weakref.WeakValueDictionary()\n\n        # Track signal connections for cleanup using weak references\n        self._signal_connections = []\n\n        # Create a lightweight \"No media playing\" placeholder\n        self.no_media_box = self._create_no_media_box()\n\n        super().__init__(orientation=\"v\", name=\"media\", children=[self.player_stack])\n\n        # Show the no media box initially\n        self.player_stack.children = [self.no_media_box]\n        self.set_visible(True)\n\n        self.mpris_manager = mpris_manager\n\n        # Track connections for cleanup\n        connections = bulk_connect(\n            self.mpris_manager,\n            {\n                \"player-appeared\": self.on_new_player,\n                \"player-vanished\": self.on_lost_player,\n            },\n        )\n        for handler_id in connections:\n            self._signal_connections.append((self.mpris_manager, handler_id))\n\n        # Process existing players\n        for player in self.mpris_manager.players:  # type: ignore\n            logger.info(\n                f\"[PLAYER MANAGER] player found: {player.get_property('player-name')}\"\n            )\n            self.on_new_player(self.mpris_manager, player)\n\n        # Schedule periodic memory cleanup and store source ID\n        self._cleanup_source_id = GLib.timeout_add_seconds(\n            300, self._periodic_cleanup\n        )  # Every 5 minutes\n\n    def _periodic_cleanup(self):\n        \"\"\"Light cleanup for widget reuse - preserve functionality.\"\"\"\n        try:\n            logger.debug(\"Starting light expanded player cleanup for reuse\")\n\n            # Clean artwork cache to reduce memory\n            cleanup_artwork_cache()\n\n            # Reset visual state but preserve connections and core functionality\n            # Only clear the player widgets that can be recreated\n            for widget in list(self._player_widgets.values()):\n                try:\n                    if widget and hasattr(widget, \"get_parent\") and widget.get_parent():\n                        # Only remove from parent, don't destroy (let GTK handle it)\n                        widget.get_parent().remove(widget)\n                except Exception:\n                    pass\n            # Don't clear the _player_widgets dict - just let them be recreated\n\n            # Reset stack to show no_media_box without destroying children\n            try:\n                if hasattr(self, \"player_stack\") and hasattr(self, \"no_media_box\"):\n                    self.player_stack.set_visible_child(self.no_media_box)\n                    self.current_stack_pos = 0\n            except Exception:\n                pass\n\n            # Light garbage collection\n            gc.collect()\n\n            logger.debug(\"Light expanded player cleanup completed\")\n\n        except Exception as e:\n            logger.warning(f\"Error during light cleanup: {e}\")\n\n        return True  # Continue timer\n\n    def destroy(self):\n        \"\"\"Clean up resources when the widget is destroyed.\"\"\"\n        try:\n            # Cancel any pending cleanup timer\n            if hasattr(self, \"_cleanup_source_id\") and self._cleanup_source_id:\n                try:\n                    GLib.source_remove(self._cleanup_source_id)\n                except Exception:\n                    pass  # Timer may have already been removed\n\n            # Disconnect all signal connections\n            for obj, handler_id in self._signal_connections:\n                try:\n                    if obj and hasattr(obj, \"disconnect\"):\n                        obj.disconnect(handler_id)\n                except Exception as e:\n                    logger.warning(f\"Failed to disconnect signal: {e}\")\n            self._signal_connections.clear()\n\n            # Clean up player widgets\n            for widget in list(self._player_widgets.values()):\n                try:\n                    if widget and hasattr(widget, \"destroy\"):\n                        widget.destroy()\n                except Exception:\n                    pass\n            self._player_widgets.clear()\n\n            # Clean up player buttons explicitly\n            for button in list(self.player_buttons):\n                try:\n                    if button and hasattr(button, \"destroy\"):\n                        button.destroy()\n                except Exception:\n                    pass\n\n            # Clean up stack children\n            for child in self.player_stack.get_children():\n                if hasattr(child, \"destroy\") and child != self.no_media_box:\n                    try:\n                        child.destroy()\n                    except Exception:\n                        pass\n\n            # Force final garbage collection\n            gc.collect()\n\n        except Exception as e:\n            logger.error(f\"Error during PlayerBoxStack cleanup: {e}\")\n        finally:\n            super().destroy()\n\n    def _create_no_media_box(self):\n        \"\"\"Create a placeholder box for when no media is playing.\"\"\"\n        fallback_cover_path = f\"{data.HOME_DIR}/.current.wall\"\n\n        # Album cover with fallback image using Image widget\n\n        album_cover = Box(\n            name=\"macos-album-image-no\",\n        )\n        album_cover.set_style(f\"background-image:url('{fallback_cover_path}')\")\n\n        image_stack = Box(h_align=\"start\", v_align=\"center\", name=\"player-image-stack\")\n        image_stack.children = [album_cover]\n\n        # Track info showing \"No media playing\"\n        track_title = Label(\n            label=\"No media playing\",\n            name=\"player-title-no\",\n            justification=\"left\",\n            max_chars_width=30,\n            ellipsization=\"end\",\n            h_align=\"start\",\n        )\n\n        track_artist = Label(\n            label=\"\",\n            name=\"player-artist\",\n            justification=\"left\",\n            max_chars_width=12,\n            ellipsization=\"end\",\n            h_align=\"start\",\n            visible=False,  # Hide artist and album when no media\n        )\n\n        track_album = Label(\n            label=\"\",\n            name=\"player-album\",\n            justification=\"left\",\n            max_chars_width=12,\n            ellipsization=\"end\",\n            h_align=\"start\",\n            visible=False,  # Hide artist and album when no media\n        )\n\n        track_info = Box(\n            name=\"track-info\",\n            spacing=5,\n            orientation=\"v\",\n            v_align=\"start\",\n            h_align=\"start\",\n            children=[track_title, track_artist, track_album],\n        )\n\n        # No control buttons for no media state - just an empty box\n        controls_box = Box(\n            name=\"player-controls\",\n            visible=False,  # Hide controls when no media\n        )\n\n        player_info_box = Box(\n            name=\"player-info-box\",\n            v_align=\"center\",\n            h_align=\"start\",\n            orientation=\"v\",\n            h_expand=True,\n            children=[track_info, controls_box],\n        )\n\n        inner_box = Box(\n            name=\"inner-player-box\",\n            v_align=\"center\",\n            h_align=\"start\",\n        )\n\n        outer_box = Box(\n            name=\"outer-player-box\",\n            h_align=\"start\",\n        )\n\n        overlay_box = Overlay(\n            child=outer_box,\n            overlays=[\n                inner_box,\n                player_info_box,\n                image_stack,\n            ],\n        )\n\n        no_media_box = Box(\n            h_align=\"center\",\n            name=\"player-box\",\n            h_expand=True,\n            children=[overlay_box],\n        )\n\n        return no_media_box\n\n    def on_player_clicked(self, type):\n        # unset active from prev active button\n        if self.player_buttons and self.current_stack_pos < len(self.player_buttons):\n            self.player_buttons[self.current_stack_pos].remove_style_class(\"active\")\n\n        if type == \"next\":\n            self.current_stack_pos = (\n                self.current_stack_pos + 1\n                if self.current_stack_pos != len(self.player_stack.get_children()) - 1\n                else 0\n            )\n        elif type == \"prev\":\n            self.current_stack_pos = (\n                self.current_stack_pos - 1\n                if self.current_stack_pos != 0\n                else len(self.player_stack.get_children()) - 1\n            )\n\n        # set new active button\n        if self.player_buttons and self.current_stack_pos < len(self.player_buttons):\n            self.player_buttons[self.current_stack_pos].add_style_class(\"active\")\n            self.player_stack.set_visible_child(\n                self.player_stack.get_children()[self.current_stack_pos],\n            )\n\n    def on_player_clicked_by_index(self, index):\n        \"\"\"Switch to player at given index\"\"\"\n        if 0 <= index < len(self.player_buttons):\n            # unset active from prev active button\n            if self.player_buttons and self.current_stack_pos < len(\n                self.player_buttons\n            ):\n                self.player_buttons[self.current_stack_pos].remove_style_class(\"active\")\n            # set new position\n            self.current_stack_pos = index\n            # set new active button\n            if self.player_buttons and self.current_stack_pos < len(\n                self.player_buttons\n            ):\n                self.player_buttons[self.current_stack_pos].add_style_class(\"active\")\n                self.player_stack.set_visible_child(\n                    self.player_stack.get_children()[self.current_stack_pos],\n                )\n            # Update all player boxes with new button state\n            self._update_all_player_buttons()\n\n    def on_new_player(self, mpris_manager, player):\n        # if player_name in self.config.get(\"ignore\", []):\n        #     return\n\n        # Remove the no media box if it's the only child\n        if (\n            len(self.player_stack.get_children()) == 1\n            and self.player_stack.get_children()[0] == self.no_media_box\n        ):\n            self.player_stack.children = []\n            self.current_stack_pos = 0\n\n        self.set_visible(True)\n\n        new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self)\n        self.player_stack.children = [\n            *self.player_stack.children,\n            new_player_box,\n        ]\n\n        self.make_new_player_button(self.player_stack.get_children()[-1])\n        logger.info(\n            f\"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}\",\n        )\n        if self.player_buttons and self.current_stack_pos < len(self.player_buttons):\n            self.player_buttons[self.current_stack_pos].set_style_classes([\"active\"])\n\n        # Update all player boxes with current button state\n        self._update_all_player_buttons()\n\n    def on_lost_player(self, mpris_manager, player_name):\n        # the playerBox is automatically removed from mprisbox children on being removed\n        logger.info(f\"[PLAYER_MANAGER] Player Removed {player_name}\")\n        players: List[PlayerBox] = self.player_stack.get_children()\n\n        # Find and properly destroy the player box\n        player_box_to_remove = None\n        for player_box in players:\n            if (\n                hasattr(player_box, \"player\")\n                and player_box.player.player_name == player_name\n            ):\n                player_box_to_remove = player_box\n                break\n\n        if player_box_to_remove:\n            try:\n                player_box_to_remove.destroy()\n            except Exception as e:\n                logger.warning(f\"Failed to destroy player box: {e}\")\n\n        # Check if this was the last player\n        remaining_players = [\n            p for p in self.player_stack.get_children() if p != player_box_to_remove\n        ]\n        if len(remaining_players) == 0:\n            # Show the no media box instead of hiding\n            self.player_stack.children = [self.no_media_box]\n            self.current_stack_pos = 0\n            self.player_buttons = []  # Clear player buttons\n            return\n\n        # Adjust current position if needed\n        if self.current_stack_pos >= len(self.player_stack.get_children()):\n            self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)\n\n        # Set active button if we have buttons and a valid position\n        if self.player_buttons and self.current_stack_pos < len(self.player_buttons):\n            self.player_buttons[self.current_stack_pos].set_style_classes([\"active\"])\n            if self.player_stack.get_children():\n                self.player_stack.set_visible_child(\n                    self.player_stack.get_children()[self.current_stack_pos],\n                )\n\n        # Update all player boxes with current button state\n        self._update_all_player_buttons()\n\n    def make_new_player_button(self, player_box):\n        new_button = Button(name=\"player-stack-button\")\n\n        def on_player_button_click(button: Button):\n            if self.player_buttons and self.current_stack_pos < len(\n                self.player_buttons\n            ):\n                self.player_buttons[self.current_stack_pos].remove_style_class(\"active\")\n            if button in self.player_buttons:\n                self.current_stack_pos = self.player_buttons.index(button)\n                button.add_style_class(\"active\")\n                self.player_stack.set_visible_child(player_box)\n\n        new_button.connect(\n            \"clicked\",\n            on_player_button_click,\n        )\n        self.player_buttons.append(new_button)\n\n        # This will automatically destroy our used button\n        def cleanup_button(*_):\n            try:\n                if new_button in self.player_buttons:\n                    self.player_buttons.remove(new_button)\n                new_button.destroy()\n            except Exception as e:\n                logger.warning(f\"Failed to cleanup button: {e}\")\n\n        player_box.connect(\"destroy\", cleanup_button)\n\n    def _update_all_player_buttons(self):\n        \"\"\"Update all player boxes with the current button state\"\"\"\n        players: List[PlayerBox] = self.player_stack.get_children()\n        logger.info(\n            f\"[PlayerBoxStack] Updating buttons for {len(players)} players, {\n                len(self.player_buttons)\n            } buttons\"\n        )\n        for player_box in players:\n            if hasattr(player_box, \"update_buttons\"):\n                player_box.update_buttons(self.player_buttons, len(players) > 1)\n            else:\n                logger.warning(\n                    \"[PlayerBoxStack] PlayerBox missing update_buttons method\"\n                )\n\n\nclass PlayerBox(Box):\n    \"\"\"A widget that displays the current player information.\"\"\"\n\n    def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):\n        super().__init__(\n            h_align=\"center\",\n            name=\"player-box\",\n            **kwargs,\n            h_expand=True,\n        )\n        # Setup\n        self.player: MprisPlayer = player\n        self.player_stack = player_stack\n        self.fallback_cover_path = f\"{data.HOME_DIR}/.current.wall\"\n\n        self.icon_size = 15\n\n        # State\n        self.exit = False\n        self.skipped = False\n        self._user_seeking = False  # Flag to prevent choppy seeking\n        self._seekbar_timer_id = None  # Track timer ID for cleanup\n\n        # Memory management\n        self.temp_artwork_files = []  # Track temp files for cleanup\n        self.current_download_thread = None  # Track current download thread\n        self._download_cancelled = False  # Flag to cancel downloads\n        self._signal_connections = []  # Track signal connections\n\n        # Use same CSS background approach as small player for consistency\n        self.album_cover = Box(\n            name=\"macos-album-image\",\n        )\n        self.album_cover.set_style(\n            f\"background-image:url('{self.fallback_cover_path}')\"\n        )\n        self.album_cover.set_size_request(70, 70)\n\n        self.image_stack = Box(\n            h_align=\"start\", v_align=\"center\", name=\"player-image-stack\"\n        )\n        self.image_stack.children = [*self.image_stack.children, self.album_cover]\n\n        # Track Info\n        self.track_title = Label(\n            label=\"No Title\",\n            name=\"macos-player-title\",\n            justification=\"left\",\n            max_chars_width=30,\n            ellipsization=\"end\",\n            h_align=\"start\",\n            h_expand=True,\n        )\n\n        self.track_artist = Label(\n            label=\"No Artist\",\n            name=\"macos-player-artist\",\n            justification=\"left\",\n            max_chars_width=25,\n            ellipsization=\"end\",\n            h_align=\"start\",\n            h_expand=True,\n            visible=True,\n        )\n        self.track_album = Label(\n            label=\"No Album\",\n            name=\"macos-player-album\",\n            justification=\"left\",\n            max_chars_width=25,\n            ellipsization=\"end\",\n            h_align=\"start\",\n            visible=True,  # Hide artist and album when no media\n        )\n\n        self.app_icon = Box(\n            children=Image(\n                icon_name=self.player.player_name, name=\"player-app-icon\", icon_size=20\n            ),\n            h_align=\"end\",\n            v_align=\"end\",\n            tooltip_text=self.player.player_name,  # type: ignore\n        )\n        self.image = Overlay(\n            child=self.image_stack,\n            overlays=[\n                self.app_icon,\n            ],\n        )\n        # Seek bar should not update automatically during user interaction\n        self._user_seeking = False\n\n        self.seek_bar = Scale(\n            value=0,\n            min_value=0,\n            max_value=100,\n            increments=(1, 1),\n            name=\"expanded-seek-bar\",\n            size=1,\n            h_expand=True,\n        )\n        self.seek_bar.connect(\"value-changed\", self._on_scale_value_changed)\n        self.seek_bar.connect(\"button-press-event\", self._on_seek_start)\n        self.seek_bar.connect(\"button-release-event\", self._on_seek_end)\n        self.player.bind(\"can-seek\", \"sensitive\", self.seek_bar)\n\n        # Position and length labels for seek bar\n        self.position_label = Label(\n            label=\"0:00\",\n            name=\"macos-position-label\",\n            justification=\"left\",\n            h_align=\"start\",\n        )\n\n        self.length_label = Label(\n            label=\"0:00\",\n            name=\"macos-length-label\",\n            justification=\"right\",\n            h_align=\"end\",\n        )\n\n        # Labels box for position and length below seek bar\n        self.labels_box = Box(\n            name=\"macos-labels-box\",\n            orientation=\"h\",\n            children=[\n                self.position_label,\n                Box(h_expand=True),  # Spacer to push labels to ends\n                self.length_label,\n            ],\n        )\n\n        # Seek bar with position labels below\n        self.seek_box = Box(\n            name=\"macos-seek-box\",\n            orientation=\"v\",\n            spacing=2,\n            children=[\n                self.seek_bar,\n                self.labels_box,\n            ],\n        )\n\n        # Define buttons first\n        self.button_box = Box(\n            name=\"macos-button-box\",\n            h_align=\"center\",\n            h_expand=True,\n            spacing=10,  # Reduced spacing for macOS look\n        )\n\n        # Define controls_box BEFORE using it in track_info\n        self.controls_box = Box(\n            name=\"macos-player-controls\",\n            orientation=\"v\",\n            h_expand=True,\n            spacing=6,\n            h_align=\"center\",\n            children=[self.button_box],\n        )\n\n        # Track info with inline controls - expands to fill available space\n        self.track_info = Box(\n            name=\"macos-track-info\",\n            spacing=4,  # Reduced spacing\n            orientation=\"v\",\n            v_align=\"start\",\n            h_align=\"fill\",  # Fill all available horizontal space\n            h_expand=True,  # Expand horizontally to take maximum space\n            v_expand=True,  # Also expand vertically\n            children=[\n                self.track_title,\n                self.track_artist,\n                self.track_album,\n            ],\n        )\n\n        # Bind player properties\n        self.player.bind_property(\n            \"title\",\n            self.track_title,\n            \"label\",\n            GObject.BindingFlags.DEFAULT,\n            lambda _, x: (\n                re.sub(r\"\\r?\\n\", \" \", x) if x != \"\" and x is not None else \"No Title\"\n            ),  # type: ignore\n        )\n        self.player.bind_property(\n            \"artist\",\n            self.track_artist,\n            \"label\",\n            GObject.BindingFlags.DEFAULT,\n            lambda _, x: (\n                re.sub(r\"\\r?\\n\", \" \", x) if x != \"\" and x is not None else \"No Artist\"\n            ),  # type: ignore\n        )\n        self.player.bind_property(\n            \"album\",\n            self.track_album,\n            \"label\",\n            GObject.BindingFlags.DEFAULT,\n            lambda _, x: (\n                re.sub(r\"\\r?\\n\", \" \", x) if x != \"\" and x is not None else \"No Album\"\n            ),  # type: ignore\n        )\n\n        # Player switcher buttons box (compact, minimal space)\n        self.stack_buttons_box = Box(\n            h_expand=False,  # Fixed width, don't expand\n            v_expand=True,\n            name=\"macos-stack-buttons-box\",\n            spacing=4,  # Reduced spacing\n            orientation=\"h\",  # Vertical layout for compactness\n            h_align=\"center\",\n            v_align=\"end\",\n        )\n        self.stack_buttons_box.hide()  # Initially hidden\n\n        # Create SVG icons from player directory\n        self.skip_next_icon = Svg(\n            name=\"btn\",\n            style_classes=[\"control-buttons\"],\n            svg_file=get_relative_path(\"../../config/assets/icons/player/fwd.svg\"),\n        )\n        self.skip_prev_icon = Svg(\n            name=\"btn\",\n            style_classes=[\"control-buttons\"],\n            svg_file=get_relative_path(\"../../config/assets/icons/player/Rewind.svg\"),\n        )\n        self.play_pause_icon = Svg(\n            name=\"btn\",\n            style_classes=[\"control-buttons\"],\n            svg_file=get_relative_path(\"../../config/assets/icons/player/Pause.svg\"),\n        )\n\n        self.play_pause_button = Button(\n            style_classes=[\"control-buttons\"],\n            name=\"macos-play-button\",\n            child=self.play_pause_icon,\n            on_clicked=self.player.play_pause,\n        )\n\n        self.player.bind_property(\"can_pause\", self.play_pause_button, \"sensitive\")\n\n        self.next_button = Button(\n            style_classes=[\"control-buttons\"],\n            name=\"macos-control-button\",\n            child=self.skip_next_icon,\n            on_clicked=self._on_player_next,\n        )\n        self.player.bind_property(\"can_go_next\", self.next_button, \"sensitive\")\n\n        self.prev_button = Button(\n            name=\"macos-control-button\",\n            child=self.skip_prev_icon,\n            style_classes=[\"control-buttons\"],\n            on_clicked=self._on_player_prev,\n        )\n        self.button_box.children = (\n            self.prev_button,\n            self.play_pause_button,\n            self.next_button,\n        )\n\n        self.box = Box(\n            orientation=\"horizontal\",\n            children=[\n                self.image,  # Album art on left (fixed width)\n                # Contains title, artist, seek bar AND controls (expands)\n                self.track_info,\n            ],\n        )\n        self.inner_box = Box(\n            orientation=\"v\",\n            h_expand=True,\n            h_align=\"fill\",  # Fill available space\n            children=[\n                self.box,  # Track info and album art\n                self.seek_box,  # Seek bar with position labels\n                self.controls_box,  # Controls now inline with track info\n            ],\n        )\n        # Compact macOS layout: album art on left, expanded track info+controls, minimal switcher\n        self.outer_box = Box(\n            name=\"macos-outer-player-box\",\n            orientation=\"v\",\n            spacing=10,  # Reduced spacing between elements\n            h_expand=True,\n            v_expand=True,\n            v_align=\"center\",\n            h_align=\"fill\",  # Fill available space\n            children=[\n                self.inner_box,  # Track info and controls\n                # Compact switcher in corner (fixed width)\n                self.stack_buttons_box,\n            ],\n        )\n\n        self.children = [*self.children, self.outer_box]\n\n        # Track signal connections for cleanup - store (object, handler_id) tuples\n        connections = bulk_connect(\n            self.player,\n            {\n                \"exit\": self._on_player_exit,\n                \"notify::playback-status\": self._on_playback_change,\n                \"notify::metadata\": self._on_metadata,\n            },\n        )\n        # Store as (object, handler_id) tuples\n        for handler_id in connections:\n            self._signal_connections.append((self.player, handler_id))\n\n    def destroy(self):\n        \"\"\"Clean up all resources when the widget is destroyed.\"\"\"\n        # Set exit flag FIRST to stop any running timers\n        self.exit = True\n\n        # Cancel any ongoing downloads immediately\n        self._download_cancelled = True\n\n        # Cancel seek bar timer\n        if self._seekbar_timer_id:\n            try:\n                from gi.repository import GLib\n\n                GLib.source_remove(self._seekbar_timer_id)\n            except:\n                pass\n            self._seekbar_timer_id = None\n\n        # Wait for download thread to finish (with timeout)\n        if self.current_download_thread and self.current_download_thread.is_alive():\n            try:\n                self.current_download_thread.join(timeout=1.0)  # 1 second timeout\n            except Exception:\n                pass\n\n        # Disconnect all signal connections\n        for obj, handler_id in self._signal_connections:\n            try:\n                obj.disconnect(handler_id)\n            except Exception as e:\n                logger.warning(f\"Failed to disconnect signal: {e}\")\n        self._signal_connections.clear()\n\n        # Clean up temp files aggressively\n        self._cleanup_temp_files()\n\n        # Clear image references\n        if hasattr(self, \"album_cover_image\"):\n            try:\n                self.album_cover_image.set_from_pixbuf(None)\n            except Exception:\n                pass\n\n        super().destroy()\n\n    def __del__(self):\n        \"\"\"Ensure cleanup happens even if player exits unexpectedly.\"\"\"\n        try:\n            self._cleanup_temp_files()\n        except Exception:\n            pass  # Ignore errors during cleanup in destructor\n\n    def update_buttons(self, player_buttons, show_buttons):\n        \"\"\"Update the stack switcher buttons in this player box\"\"\"\n        logger.info(\n            f\"[PlayerBox] update_buttons called: show_buttons={\n                show_buttons\n            }, num_buttons={len(player_buttons)}\"\n        )\n\n        # Clear existing buttons\n        for child in self.stack_buttons_box.get_children():\n            try:\n                child.destroy()\n            except Exception:\n                pass\n\n        if show_buttons and len(player_buttons) > 1:\n            logger.info(f\"[PlayerBox] Creating {len(player_buttons)} stack buttons\")\n            # Create macOS-style dot indicators for each player\n            for i, button in enumerate(player_buttons):\n                # Create a macOS-style dot button\n                dot_button = Button(\n                    name=\"macos-player-switcher-dot\",\n                    style_classes=[\"macos-switcher-dot\"],\n                )\n\n                # Set active state based on original button\n                if button.get_style_context().has_class(\"active\"):\n                    dot_button.add_style_class(\"active\")\n\n                # Connect click handler to switch to this player\n                def make_click_handler(index):\n                    return lambda *_: self.player_stack.on_player_clicked_by_index(\n                        index\n                    )\n\n                dot_button.connect(\"clicked\", make_click_handler(i))\n                self.stack_buttons_box.children = [\n                    *self.stack_buttons_box.children,\n                    dot_button,\n                ]\n                logger.info(f\"[PlayerBox] Added dot button {i}\")\n\n            self.stack_buttons_box.show_all()\n            logger.info(\"[PlayerBox] Stack buttons box shown\")\n        else:\n            self.stack_buttons_box.hide()\n            logger.info(\"[PlayerBox] Stack buttons box hidden\")\n\n    def length_str(self, length):\n        \"\"\"Convert length in microseconds to MM:SS or H:MM:SS format like real media players.\"\"\"\n        if length is None or length <= 0:\n            return \"0:00\"\n\n        # Convert microseconds to seconds\n        length_seconds = length / 1000000\n\n        hours = int(length_seconds // 3600)\n        minutes = int((length_seconds % 3600) // 60)\n        seconds = int(length_seconds % 60)\n\n        if hours > 0:\n            return f\"{hours}:{minutes:02d}:{seconds:02d}\"\n        else:\n            return f\"{minutes}:{seconds:02d}\"\n\n    def _on_metadata(self, *_):\n        self._set_image()\n        duration = self.player.length\n\n        if duration:\n            self.length_label.set_label(self.length_str(duration))\n            # Clamp duration to avoid 32-bit integer overflow in the scale widget\n            max_int32 = 2147483647  # 2^31 - 1\n            safe_duration = min(max_int32, duration)\n            self.seek_bar.set_range(0, safe_duration)\n\n        # Cancel existing timer before starting a new one\n        if self._seekbar_timer_id:\n            try:\n                from gi.repository import GLib\n\n                GLib.source_remove(self._seekbar_timer_id)\n            except:\n                pass\n            self._seekbar_timer_id = None\n\n        # Start new timer and store its ID\n        self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)\n\n    def _cleanup_temp_files(self):\n        \"\"\"Clean up temporary artwork files.\"\"\"\n        for temp_file in self.temp_artwork_files:\n            try:\n                if os.path.exists(temp_file):\n                    os.unlink(temp_file)\n            except Exception as e:\n                logger.warning(f\"Failed to cleanup temp file {temp_file}: {e}\")\n        self.temp_artwork_files.clear()\n\n    def _on_player_exit(self, _, value):\n        self.exit = value\n        self._cleanup_temp_files()  # Clean up temp files before destroying\n        self.destroy()\n\n    def _on_player_next(self, *_):\n        self.player.next()\n\n    def _on_player_prev(self, *_):\n        self.player.previous()\n\n    def _on_playback_change(self, player, status):\n        status = player.get_property(\"playback-status\")\n\n        if status == \"paused\":\n            self.play_pause_icon.set_from_file(\n                get_relative_path(\"../../config/assets/icons/player/play.svg\")\n            )\n\n        if status == \"playing\":\n            self.play_pause_icon.set_from_file(\n                get_relative_path(\"../../config/assets/icons/player/Pause.svg\")\n            )\n\n    def _update_image(self, image_path):\n        if image_path and os.path.isfile(image_path):\n            self.album_cover.set_style(f\"background-image:url('{image_path}')\")\n        else:\n            self.album_cover.set_style(\n                f\"background-image:url('{self.fallback_cover_path}')\"\n            )\n\n    def _set_image(self, *_):\n        art_url = self.player.arturl\n\n        # If no art URL or empty/None, use fallback\n        if not art_url:\n            self._update_image(None)\n            return\n\n        parsed = urllib.parse.urlparse(art_url)\n        if parsed.scheme == \"file\":\n            local_arturl = urllib.parse.unquote(parsed.path)\n            self._update_image(local_arturl)\n        elif parsed.scheme in (\"http\", \"https\"):\n            # Cancel any existing download to prevent memory buildup\n            self._download_cancelled = True\n\n            # Use threading.Thread instead of GLib.Thread for better control\n            if self.current_download_thread and self.current_download_thread.is_alive():\n                # Thread will check _download_cancelled flag and exit early\n                pass\n\n            self._download_cancelled = False\n            self.current_download_thread = threading.Thread(\n                target=self._download_and_set_artwork,\n                args=(art_url,),\n                daemon=True,  # Dies with main thread\n            )\n            self.current_download_thread.start()\n        else:\n            print(art_url)\n            self._update_image(art_url)\n\n    def _download_and_set_artwork(self, arturl):\n        \"\"\"\n        Download the artwork from the given URL asynchronously and update the cover\n        using GLib.idle_add to ensure UI updates occur on the main thread.\n        \"\"\"\n        local_arturl = self.fallback_cover_path\n        temp_file_path = None\n\n        try:\n            # Check if download was cancelled\n            if self._download_cancelled:\n                return\n\n            # Clean up old temp files first (keep only last 1 to reduce memory)\n            if len(self.temp_artwork_files) > 1:\n                old_files = self.temp_artwork_files[:-1]\n                for old_file in old_files:\n                    try:\n                        if os.path.exists(old_file):\n                            os.unlink(old_file)\n                    except Exception:\n                        pass\n                self.temp_artwork_files = self.temp_artwork_files[-1:]\n\n            # Check again if cancelled\n            if self._download_cancelled:\n                return\n\n            # Download artwork\n            parsed = urllib.parse.urlparse(arturl)\n            suffix = os.path.splitext(parsed.path)[1] or \".png\"\n\n            with urllib.request.urlopen(arturl, timeout=10) as response:  # Add timeout\n                if self._download_cancelled:\n                    return\n                data = response.read()\n\n            # Check one more time if cancelled\n            if self._download_cancelled:\n                return\n\n            # Create temp file in cache directory instead of system temp\n            os.makedirs(CACHE_DIR, exist_ok=True)\n            with tempfile.NamedTemporaryFile(\n                delete=False, suffix=suffix, dir=CACHE_DIR\n            ) as temp_file:\n                temp_file.write(data)\n                temp_file_path = temp_file.name\n                local_arturl = temp_file_path\n\n            # Track temp file for cleanup\n            if temp_file_path and not self._download_cancelled:\n                self.temp_artwork_files.append(temp_file_path)\n\n        except Exception as e:\n            if not self._download_cancelled:\n                logger.warning(f\"Failed to download artwork from {arturl}: {e}\")\n            # Clean up failed temp file\n            if temp_file_path and os.path.exists(temp_file_path):\n                try:\n                    os.unlink(temp_file_path)\n                except Exception:\n                    pass\n            return\n\n        # Only update UI if not cancelled\n        if not self._download_cancelled:\n            GLib.idle_add(self._update_image, local_arturl)\n        return None\n\n    def _move_seekbar(self, *_):\n        if self.player is None or self.exit or self._user_seeking:\n            return True  # Continue the timer but don't update while user is seeking\n\n        # Additional safety checks to prevent GTK errors\n        if not hasattr(self, \"seek_bar\") or self.seek_bar is None:\n            return False  # Stop the timer\n\n        try:\n            position = self.player.position\n            self.position_label.set_label(self.length_str(position))\n\n            # Only update seek bar if user is not currently seeking\n            if not self._user_seeking:\n                # Clamp position to avoid 32-bit integer overflow\n                max_int32 = 2147483647  # 2^31 - 1\n                safe_position = min(max_int32, position) if position else 0\n                self.seek_bar.set_value(safe_position)\n\n        except Exception as e:\n            # If any error occurs (widget destroyed, etc), stop the timer\n            logger.warning(f\"Seek bar update failed, stopping timer: {e}\")\n            return False\n\n        return True\n\n    def _on_seek_start(self, widget, event):\n        \"\"\"User started seeking - disable automatic updates\"\"\"\n        self._user_seeking = True\n        return False\n\n    def _on_seek_end(self, widget, event):\n        \"\"\"User finished seeking - re-enable automatic updates\"\"\"\n        self._user_seeking = False\n        return False\n\n    def _on_scale_value_changed(self, scale: Scale):\n        \"\"\"Handle seek bar value changes - only when user is seeking\"\"\"\n        if self.player and not self.exit and self._user_seeking:\n            try:\n                new_position = int(scale.get_value())\n                # Clamp to 32-bit signed integer range to avoid overflow\n                max_int32 = 2147483647  # 2^31 - 1\n                min_int32 = -2147483648  # -2^31\n                new_position = max(min_int32, min(max_int32, new_position))\n                self.player.position = new_position\n                self.position_label.set_label(self.length_str(new_position))\n            except Exception as e:\n                # If setting position fails, just update the label\n                try:\n                    self.position_label.set_label(self.length_str(new_position))\n                except Exception:\n                    logger.warning(f\"Failed to update position label: {e}\")\n\n    @cooldown(0.1)\n    def _on_scale_move(self, scale: Scale, event, pos: int):\n        try:\n            if not self.exit and self.player:\n                self.player.position = pos\n                self.position_label.set_label(self.length_str(pos))\n                self.seek_bar.set_value(pos)\n        except Exception as e:\n            logger.warning(f\"Failed to update seek position: {e}\")\n\n\nclass Thing(Box):\n    def __init__(self, **kwargs):\n        super().__init__(\n            name=\"thing\",\n            size=(480, 160),\n            orientation=\"vertical\",\n            spacing=0,\n            children=[\n                Label(\n                    name=\"thing-label\",\n                    label=\"This is a thing\",\n                    style=\"font-size: 16px; padding: 10px;\",\n                ),\n            ],\n            **kwargs,\n        )\n\n\nclass ExpandedPlayer(Window):\n    def __init__(self, **kwargs):\n        super().__init__(\n            name=\"expanded-player\",\n            title=\"modus\",\n            anchor=\"top right\",\n            layer=\"top\",\n            exclusivity=\"auto\",\n            child=PlayerBoxStack(get_shared_mpris_manager()),\n            visible=False,\n        )\n        self.add_keybinding(\"Escape\", self.set_child_visible(False))\n\n    def destroy(self):\n        \"\"\"Clean up resources when the window is destroyed.\"\"\"\n        # Clean up the child PlayerBoxStack\n        if hasattr(self, \"child\") and hasattr(self.child, \"destroy\"):\n            try:\n                self.child.destroy()\n            except Exception as e:\n                logger.warning(f\"Failed to destroy child PlayerBoxStack: {e}\")\n\n        super().destroy()\n\n    def _init_mousecapture(self, mousecapture):\n        self._mousecapture_parent = mousecapture\n\n    def hide_controlcenter(self, *_):\n        # self._mousecapture_parent.toggle_mousecapture()\n        self.set_visible(False)\n"
  },
  {
    "path": "modules/controlcenter/main.py",
    "content": "import subprocess\n\nfrom fabric.utils import idle_add\nfrom fabric.utils.helpers import (\n    get_relative_path,\n)\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.scale import Scale\nfrom fabric.widgets.svg import Svg\nfrom gi.repository import Gdk, GLib\nfrom loguru import logger\n\nfrom modules.controlcenter.bluetooth import (\n    BluetoothConnections,\n    set_bluetooth_enabled_with_fallback,\n)\nfrom modules.controlcenter.expanded_player import EmbeddedExpandedPlayer\nfrom modules.controlcenter.nightlight import create_night_light_widget\nfrom modules.controlcenter.per_app_volume import PerAppVolumeControl\nfrom modules.controlcenter.player import PlayerBoxStack\nfrom modules.controlcenter.wifi import WifiConnections\nfrom services.brightness import Brightness\nfrom services.mpris import MprisPlayerManager\nfrom services.network import NetworkClient\nfrom utils.roam import audio_service, modus_service\nfrom widgets.wayland import WaylandWindow as Window\n\nbrightness_service = Brightness.get_initial()\n\n\nclass ModusControlCenter(Window):\n    def __init__(self, **kwargs):\n        super().__init__(\n            layer=\"top\",\n            title=\"modus\",\n            anchor=\"top right\",\n            margin=\"2px 10px 0px 0px\",\n            exclusivity=\"auto\",\n            keyboard_mode=\"on-demand\",\n            name=\"control-center-menu\",\n            visible=False,\n            **kwargs,\n        )\n        self.focus_mode = modus_service.dont_disturb\n        self._updating_brightness = False\n        self._updating_volume = False\n\n        # Flight mode and caffeine states\n        self.flight_mode = False\n        self.caffeine_mode = False\n        self._caffeine_process = None\n\n        # Lazy loading flags\n        self._music_initialized = False\n        self._per_app_volume_initialized = False\n        self._expanded_player_initialized = False\n\n        # Store references for cleanup - initialize all as None\n        self._signal_connections = []\n        self._music_widget_content = None\n        self._per_app_volume_widget = None\n        self._expanded_player_widget = None\n        self._mpris_manager = None  # Shared MPRIS manager instance\n\n        # Initialize network service for WiFi toggle\n        self.network_service = NetworkClient()\n        self.wifi_service = None\n\n        # Initialize flight mode and caffeine states\n        self._check_initial_states()\n\n        # Wait for network service to be ready\n\n        self.add_keybinding(\"Escape\", self.hide_controlcenter)\n\n        volume = 100\n        wlan = modus_service.sc(\"wlan-changed\", self.wlan_changed)\n        bluetooth = modus_service.sc(\"bluetooth-changed\", self.bluetooth_changed)\n        music = modus_service.sc(\"music-changed\", self.audio_changed)\n\n        self.network_service.connect(\"wifi-device-added\", self.on_network_ready)\n        # Store signal connections for cleanup\n        self._signal_connections.extend(\n            [\n                audio_service.connect(\"changed\", self.audio_changed),\n                audio_service.connect(\"changed\", self.volume_changed),\n                modus_service.connect(\"dont-disturb-changed\", self.dnd_changed),\n            ]\n        )\n\n        print(wlan)\n        self.wlan_label = Label(\n            label=wlan,\n            name=\"wifi-widget-label\",\n            max_chars_width=15,\n            h_align=\"start\",\n            ellipsization=\"end\",\n        )\n        if bluetooth != \"disabled\":\n            if bluetooth.startswith(\"connected:\"):\n                parts = bluetooth.split(\":\")\n                bluetooth_display = parts[1] if len(parts) >= 2 else \"Connected\"\n            else:\n                bluetooth_display = \"On\"\n        else:\n            bluetooth_display = \"Off\"\n\n        self.bluetooth_label = Label(\n            label=bluetooth_display,\n            name=\"bluetooth-widget-label\",\n            max_chars_width=15,\n            ellipsization=\"end\",\n            h_align=\"start\",\n        )\n        self.volume_scale = Scale(\n            value=volume,\n            min_value=0,\n            max_value=100,\n            increments=(5, 5),\n            name=\"volume-widget-slider\",\n            size=30,\n            h_expand=True,\n        )\n        self.volume_scale.connect(\"change-value\", self.set_volume)\n        self.volume_scale.connect(\"scroll-event\", self.on_volume_scroll)\n\n        current_brightness = brightness_service.screen_brightness\n        brightness_percentage = (\n            int((current_brightness / brightness_service.max_screen) * 100)\n            if brightness_service.max_screen > 0\n            else 50\n        )\n\n        self.brightness_scale = Scale(\n            value=brightness_percentage,\n            min_value=0,\n            max_value=100,\n            increments=(5, 5),\n            name=\"brightness-widget-slider\",\n            size=30,\n            h_expand=True,\n        )\n\n        # Only connect brightness controls if brightness service is available\n        if brightness_service.max_screen > 0:\n            self.brightness_scale.connect(\"change-value\", self.set_brightness)\n            self.brightness_scale.connect(\"scroll-event\", self.on_brightness_scroll)\n            self._signal_connections.append(\n                brightness_service.connect(\"screen\", self.brightness_changed)\n            )\n        else:\n            # Disable brightness scale if no backlight device available\n            self.brightness_scale.set_sensitive(False)\n\n        # Create placeholder music widget - lazy load content when needed\n        self.music_widget = Box(\n            name=\"music-widget\",\n            h_align=\"start\",\n            children=[],  # Empty initially\n        )\n\n        self.has_bluetooth_open = False\n        self.has_wifi_open = False\n        self.has_per_app_volume_open = False\n        self.has_expanded_player_open = False\n\n        self.bluetooth_svg = Svg(\n            name=\"bluetooth-icon\",\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/applets/bluetooth.svg\"\n                if bluetooth != \"disabled\"\n                else \"../../config/assets/icons/applets/bluetooth-off.svg\"\n            ),\n            size=42,\n        )\n        self.wifi_svg = Svg(\n            name=\"wifi-icon\",\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/applets/wifi.svg\"\n                if wlan != \"No Connection\"\n                else \"../../config/assets/icons/applets/wifi-off.svg\"\n            ),\n            size=42,\n        )\n\n        self.bluetooth_widget = Box(\n            name=\"bluetooth-widget\",\n            orientation=\"h\",\n            children=[\n                Button(\n                    name=\"bluetooth-icon-button\",\n                    child=self.bluetooth_svg,\n                    on_clicked=self.toggle_bluetooth,\n                ),\n                Button(\n                    name=\"bluetooth-info-button\",\n                    child=Box(\n                        name=\"bluetooth-widget-info\",\n                        orientation=\"vertical\",\n                        children=[\n                            Label(\n                                name=\"bluetooth-widget-name\",\n                                label=\"Bluetooth\",\n                                style_classes=\"ct\",\n                                h_align=\"start\",\n                            ),\n                            self.bluetooth_label,\n                        ],\n                    ),\n                    on_clicked=self.open_bluetooth,\n                ),\n            ],\n        )\n\n        self.wlan_widget = Box(\n            name=\"wifi-widget\",\n            orientation=\"h\",\n            children=[\n                Button(\n                    name=\"wifi-icon-button\",\n                    child=self.wifi_svg,\n                    on_clicked=self.toggle_wifi,\n                ),\n                Button(\n                    name=\"wifi-info-button\",\n                    child=Box(\n                        name=\"wifi-widget-info\",\n                        orientation=\"vertical\",\n                        children=[\n                            Label(\n                                name=\"wifi-widget-name\",\n                                label=\"Wi-Fi\",\n                                style_classes=\"ct\",\n                                h_align=\"start\",\n                            ),\n                            self.wlan_label,\n                        ],\n                    ),\n                    on_clicked=self.open_wifi,\n                ),\n            ],\n        )\n\n        self.focus_icon = Svg(\n            name=\"focus-icon\",\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/applets/dnd.svg\"\n                if self.focus_mode\n                else \"../../config/assets/icons/applets/dnd-off.svg\"\n            ),\n            size=42,\n        )\n\n        self.focus_status_label = Label(\n            label=\"On\" if self.focus_mode else \"Off\",\n            style_classes=\"status-label\",\n            h_align=\"start\",\n        )\n\n        self.focus_widget = Button(\n            name=\"focus-widget\",\n            child=Box(\n                spacing=4,\n                orientation=\"h\",\n                children=[\n                    self.focus_icon,\n                    Box(\n                        v_expand=True,\n                        h_expand=True,\n                        v_align=\"center\",\n                        orientation=\"v\",\n                        h_align=\"start\",\n                        children=[\n                            Label(\n                                label=\"Focus\",\n                                style_classes=\"title-widget\",\n                                h_align=\"start\",\n                            ),\n                            self.focus_status_label,\n                        ],\n                    ),\n                ],\n            ),\n            on_clicked=self.set_dont_disturb,\n        )\n\n        self.flight_icon = Svg(\n            name=\"flight-icon\",\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/applets/flight-on.svg\"\n                if self.flight_mode\n                else \"../../config/assets/icons/applets/flight-off.svg\"\n            ),\n            size=42,\n        )\n\n        self.flight_widget = Button(\n            name=\"flight-widget\",\n            child=Box(\n                orientation=\"v\",\n                h_expand=True,\n                v_expand=True,\n                spacing=8,\n                h_align=\"center\",\n                v_align=\"center\",\n                children=[\n                    self.flight_icon,\n                    Label(\n                        label=\"Flight\",\n                        style_classes=\"title-widget\",\n                        h_align=\"center\",\n                    ),\n                ],\n            ),\n            on_clicked=self.toggle_flight_mode,\n        )\n\n        self.caffeine_icon = Svg(\n            name=\"caffeine-icon\",\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/applets/caffeine-on.svg\"\n                if self.caffeine_mode\n                else \"../../config/assets/icons/applets/caffeine-off.svg\"\n            ),\n            size=42,\n        )\n\n        self.caffeine_status_label = Label(\n            label=\"On\" if self.caffeine_mode else \"Off\",\n            style_classes=\"status-label\",\n            h_align=\"start\",\n        )\n\n        self.caffeine_widget = Button(\n            name=\"caffeine-widget\",\n            child=Box(\n                orientation=\"v\",\n                spacing=8,\n                h_expand=True,\n                v_expand=True,\n                h_align=\"center\",\n                v_align=\"center\",\n                children=[\n                    self.caffeine_icon,\n                    Label(\n                        label=\"Caffeine\",\n                        style_classes=\"title-widget\",\n                        h_align=\"center\",\n                    ),\n                    # Box(\n                    #     orientation=\"vertical\",\n                    #     v_expand=True,\n                    #     v_align=\"center\",\n                    #     h_align=\"center\",\n                    #     h_expand=True,\n                    #     children=[\n                    #         # self.caffeine_status_label,\n                    #     ],\n                    # ),\n                ],\n            ),\n            on_clicked=self.toggle_caffeine,\n        )\n\n        # Create night light widget\n        self.night_light_widget = create_night_light_widget(self)\n\n        # Create main widgets directly without XML\n        self.widgets = Box(\n            orientation=\"vertical\",\n            h_expand=True,\n            name=\"control-center-widgets\",\n            children=[\n                # Top section: Wi-Fi + Bluetooth on left, DND + Flight Mode + Caffeine on right\n                Box(\n                    orientation=\"horizontal\",\n                    h_expand=True,\n                    name=\"top-widget\",\n                    children=[\n                        # Left side: Wi-Fi and Bluetooth\n                        Box(\n                            orientation=\"vertical\",\n                            name=\"wb-widget\",\n                            style_classes=\"menu\",\n                            spacing=5,\n                            children=[\n                                self.wlan_widget,\n                                self.bluetooth_widget,\n                                self.night_light_widget,\n                            ],\n                        ),\n                        # Right side: DND, Flight Mode, and Caffeine\n                        Box(\n                            orientation=\"vertical\",\n                            h_expand=True,\n                            name=\"right-side-widget\",\n                            children=[\n                                # DND widget\n                                Box(\n                                    orientation=\"vertical\",\n                                    name=\"dnd-widget\",\n                                    style_classes=\"menu\",\n                                    children=[\n                                        self.focus_widget,\n                                    ],\n                                ),\n                                # Flight Mode and Caffeine row\n                                Box(\n                                    orientation=\"horizontal\",\n                                    name=\"flight-caffeine-row\",\n                                    children=[\n                                        Box(\n                                            orientation=\"vertical\",\n                                            name=\"flight-widget-container\",\n                                            style_classes=\"menu\",\n                                            h_expand=True,\n                                            v_expand=True,\n                                            children=[\n                                                self.flight_widget,\n                                            ],\n                                        ),\n                                        Box(\n                                            orientation=\"vertical\",\n                                            name=\"caffeine-widget-container\",\n                                            h_expand=True,\n                                            v_expand=True,\n                                            style_classes=\"menu\",\n                                            children=[\n                                                self.caffeine_widget,\n                                            ],\n                                        ),\n                                    ],\n                                ),\n                            ],\n                        ),\n                    ],\n                ),\n                Box(\n                    orientation=\"vertical\",\n                    name=\"brightness-widget\",\n                    style_classes=\"menu\",\n                    h_expand=True,\n                    children=[\n                        Label(label=\"Display\", style_classes=\"title\", h_align=\"start\"),\n                        self.brightness_scale,\n                        Label(\n                            label=\"󰖨 \", name=\"brightness-widget-icon\", h_align=\"start\"\n                        ),\n                    ],\n                ),\n                Box(\n                    orientation=\"vertical\",\n                    name=\"volume-widget\",\n                    style_classes=\"menu\",\n                    h_expand=True,\n                    children=[\n                        Label(label=\"Sound\", style_classes=\"title\", h_align=\"start\"),\n                        Box(\n                            name=\"vol-box\",\n                            orientation=\"horizontal\",\n                            spacing=12,\n                            v_expand=True,\n                            children=[\n                                Box(\n                                    name=\"vol-slider-box\",\n                                    h_expand=True,\n                                    v_align=\"center\",\n                                    v_expand=False,\n                                    children=[\n                                        self.volume_scale,\n                                    ],\n                                ),\n                                Button(\n                                    name=\"per-app-volume-button\",\n                                    size=(36, 36),\n                                    child=Svg(\n                                        svg_file=get_relative_path(\n                                            \"../../config/assets/icons/player/audio-switcher.svg\"\n                                        ),\n                                        name=\"per-app-volume-icon\",\n                                        sidze=32,\n                                    ),\n                                    on_clicked=self.open_per_app_volume,\n                                ),\n                            ],\n                        ),\n                        Label(label=\" \", name=\"volume-widget-icon\", h_align=\"start\"),\n                    ],\n                ),\n                self.music_widget,\n            ],\n        )\n\n        # Initialize managers directly like working version\n        self.wifi_man = WifiConnections(self)\n        self.bluetooth_man = BluetoothConnections(self)\n\n        self.has_bluetooth_open = False\n        self.has_wifi_open = False\n\n        # Lazy-loaded widgets - create placeholders\n        self.bluetooth_widgets = None\n        self.wifi_widgets = None\n        self.per_app_volume_widgets = None\n        self.expanded_player_widgets = None\n\n        # Create main content boxes\n        self.center_box = CenterBox(start_children=[self.widgets])\n        self.bluetooth_center_box = None\n        self.wifi_center_box = None\n        self.per_app_volume_center_box = None\n        self.expanded_player_center_box = None\n\n        # Create revealers for crossfade transitions\n\n        self.widgets.set_size_request(300, -1)\n\n        self.children = self.center_box\n\n        # Track current state for smooth transitions\n        self.current_view = \"main\"  # main, expanded_player\n\n        # Connect to visibility changes for cleanup\n        self.connect(\"notify::visible\", self._on_visibility_changed)\n\n    def _on_visibility_changed(self, widget, param):\n        \"\"\"Handle visibility changes for memory management\"\"\"\n        if not self.get_visible():\n            self._cleanup_when_hidden()\n\n    def _cleanup_when_hidden(self):\n        \"\"\"Aggressively clean up resources when widget is hidden to reduce memory usage\"\"\"\n        try:\n            # Clean up music widget content if it exists\n            if self._music_widget_content:\n                # Remove from the parent container\n                current_children = list(self.music_widget.children)\n                if self._music_widget_content in current_children:\n                    current_children.remove(self._music_widget_content)\n                    self.music_widget.children = current_children\n\n                # Trigger periodic cleanup before destroying\n                if hasattr(self._music_widget_content, \"_periodic_cleanup\"):\n                    self._music_widget_content._periodic_cleanup()\n\n                # Properly destroy the music widget content\n                try:\n                    self._music_widget_content.destroy()\n                except Exception as e:\n                    logger.warning(f\"Failed to destroy music widget content: {e}\")\n                self._music_widget_content = None\n\n            # Clean up shared MPRIS manager when hidden to free memory\n            if self._mpris_manager:\n                try:\n                    self._mpris_manager.destroy()\n                except Exception as e:\n                    logger.warning(\n                        f\"Failed to destroy MPRIS manager during cleanup: {e}\"\n                    )\n                self._mpris_manager = None\n\n            # Reset initialization flags to force recreation next time\n            self._music_initialized = False\n\n            # Force garbage collection\n            import gc\n\n            gc.collect()\n\n            logger.debug(\"Control center aggressive cleanup completed\")\n\n        except Exception as e:\n            logger.warning(f\"Control center cleanup failed: {e}\")\n\n    def _ensure_music_widget(self):\n        \"\"\"Lazy load music widget content - reuse MPRIS manager\"\"\"\n        if not self._music_initialized:\n            # Create shared MPRIS manager if it doesn't exist\n            if self._mpris_manager is None:\n                self._mpris_manager = MprisPlayerManager()\n\n            self._music_widget_content = PlayerBoxStack(\n                self._mpris_manager, control_center=self\n            )\n            # Add to the music widget's children list\n            current_children = list(self.music_widget.children)\n            current_children.append(self._music_widget_content)\n            self.music_widget.children = current_children\n            self._music_initialized = True\n\n    def _ensure_bluetooth_widgets(self):\n        \"\"\"Lazy load bluetooth widgets\"\"\"\n        if self.bluetooth_widgets is None:\n            self.bluetooth_widgets = Box(\n                orientation=\"vertical\",\n                h_expand=True,\n                v_expand=True,\n                children=[\n                    self.bluetooth_man,\n                    # Box(\n                    #     orientation=\"horizontal\",\n                    #     name=\"top-widget\",\n                    #     h_expand=True,\n                    #     children=[\n                    #         Box(\n                    #             orientation=\"vertical\",\n                    #             name=\"wb-widget\",\n                    #             style_classes=\"menu\",\n                    #             spacing=5,\n                    #             children=[\n                    #             ],\n                    #         ),\n                    #     ],\n                    # ),\n                ],\n            )\n            self.bluetooth_center_box = Box(\n                h_expand=True, v_expand=True, children=[self.bluetooth_widgets]\n            )\n            self.bluetooth_center_box.set_size_request(350, -1)\n\n    def _ensure_wifi_widgets(self):\n        \"\"\"Lazy load wifi widgets\"\"\"\n        if self.wifi_widgets is None:\n            self.wifi_widgets = Box(\n                orientation=\"vertical\",\n                h_expand=True,\n                v_expand=True,\n                children=[\n                    self.wifi_man,\n                ],\n            )\n            self.wifi_center_box = Box(\n                h_expand=True, v_expand=True, children=[self.wifi_widgets]\n            )\n            self.wifi_center_box.set_size_request(350, -1)\n\n    def _ensure_per_app_volume_widgets(self):\n        \"\"\"Lazy load per-app volume widgets\"\"\"\n        if self.per_app_volume_widgets is None:\n            if self._per_app_volume_widget is None:\n                self._per_app_volume_widget = PerAppVolumeControl(self)\n\n            self.per_app_volume_widgets = Box(\n                orientation=\"vertical\",\n                h_expand=True,\n                name=\"control-center-widgets\",\n                children=[\n                    self._per_app_volume_widget,\n                ],\n            )\n            self.per_app_volume_center_box = CenterBox(\n                start_children=[self.per_app_volume_widgets]\n            )\n            self.per_app_volume_center_box.set_size_request(300, -1)\n\n    def _ensure_expanded_player_widgets(self):\n        \"\"\"Lazy load expanded player widgets\"\"\"\n        if self.expanded_player_widgets is None:\n            if self._expanded_player_widget is None:\n                self._expanded_player_widget = EmbeddedExpandedPlayer(self)\n\n            self.expanded_player_widgets = Box(\n                orientation=\"vertical\",\n                h_expand=True,\n                name=\"control-center-widgets\",\n                children=[\n                    self._expanded_player_widget,\n                ],\n            )\n            self.expanded_player_center_box = CenterBox(\n                start_children=[self.expanded_player_widgets]\n            )\n            self.expanded_player_center_box.set_size_request(300, -1)\n\n    def _check_initial_states(self):\n        # Check if caffeine is already running\n        try:\n            result = subprocess.run(\n                [\"pgrep\", \"-f\", \"modus-inhibit\"], capture_output=True, text=True\n            )\n            self.caffeine_mode = bool(result.stdout.strip())\n        except Exception:\n            self.caffeine_mode = False\n\n        # Flight mode starts as False (normal mode)\n        self.flight_mode = False\n\n        # Update initial labels (will be set after widgets are created)\n        GLib.timeout_add(100, self._update_initial_labels)\n\n    def _update_initial_labels(self):\n        return False\n\n    def set_dont_disturb(self, *_):\n        self.focus_mode = not self.focus_mode\n        modus_service.dont_disturb = self.focus_mode\n        self.focus_icon.set_from_file(\n            get_relative_path(\n                \"../../config/assets/icons/applets/dnd.svg\"\n                if self.focus_mode\n                else \"../../config/assets/icons/applets/dnd-off.svg\"\n            )\n        )\n        self.focus_status_label.set_label(\"On\" if self.focus_mode else \"Off\")\n\n    def toggle_flight_mode(self, *_):\n        try:\n            self.flight_mode = not self.flight_mode\n\n            if self.flight_mode:\n                # Turn off WiFi and Bluetooth\n                if self.wifi_service:\n                    self.wifi_service.wireless_enabled = False\n                if hasattr(self, \"bluetooth_man\") and hasattr(\n                    self.bluetooth_man, \"client\"\n                ):\n                    self.bluetooth_man.client.set_enabled(False)\n            else:\n                # Turn on WiFi and Bluetooth\n                if self.wifi_service:\n                    self.wifi_service.wireless_enabled = True\n                if hasattr(self, \"bluetooth_man\") and hasattr(\n                    self.bluetooth_man, \"client\"\n                ):\n                    self.bluetooth_man.client.set_enabled(True)\n\n            # Update icon\n            self.flight_icon.set_from_file(\n                get_relative_path(\n                    \"../../config/assets/icons/applets/flight-on.svg\"\n                    if self.flight_mode\n                    else \"../../config/assets/icons/applets/flight-off.svg\"\n                )\n            )\n\n        except Exception as e:\n            logger.warning(f\"Failed to toggle flight mode: {e}\")\n\n    def toggle_caffeine(self, *_):\n        try:\n            if self.caffeine_mode:\n                # Turn off caffeine\n                inhibit_script = get_relative_path(\"../../utils/inhibit.py\")\n                subprocess.run([\"python3\", inhibit_script, \"off\"], check=False)\n                self.caffeine_mode = False\n                if self._caffeine_process:\n                    try:\n                        self._caffeine_process.terminate()\n                    except:\n                        pass\n                    self._caffeine_process = None\n            else:\n                # Turn on caffeine\n                inhibit_script = get_relative_path(\"../../utils/inhibit.py\")\n                self._caffeine_process = subprocess.Popen(\n                    [\"python3\", inhibit_script, \"on\"], start_new_session=True\n                )\n                self.caffeine_mode = True\n\n            self.caffeine_icon.set_from_file(\n                get_relative_path(\n                    \"../../config/assets/icons/applets/caffeine-on.svg\"\n                    if self.caffeine_mode\n                    else \"../../config/assets/icons/applets/caffeine-off.svg\"\n                )\n            )\n            self.caffeine_status_label.set_label(\"On\" if self.caffeine_mode else \"Off\")\n\n        except Exception as e:\n            logger.warning(f\"Failed to toggle caffeine: {e}\")\n\n    def set_volume(self, _, __, volume):\n        self._updating_volume = True\n        audio_service.speaker.volume = round(volume)\n        self._updating_volume = False\n\n    def set_brightness(self, _, __, brightness):\n        self._updating_brightness = True\n        brightness_value = int((brightness / 100) * brightness_service.max_screen)\n        brightness_service.screen_brightness = brightness_value\n        self._updating_brightness = False\n\n    def brightness_changed(self, _, brightness_value):\n        if self._updating_brightness:\n            return\n\n        if brightness_service.max_screen > 0:\n            brightness_percentage = int(\n                (brightness_value / brightness_service.max_screen) * 100\n            )\n\n            GLib.idle_add(\n                lambda: self.brightness_scale.set_value(brightness_percentage)\n            )\n\n    def on_volume_scroll(self, widget, event):\n        current_value = self.volume_scale.get_value()\n        scroll_step = 5\n        if event.direction == Gdk.ScrollDirection.UP:\n            new_value = min(100, current_value + scroll_step)\n        elif event.direction == Gdk.ScrollDirection.DOWN:\n            new_value = max(0, current_value - scroll_step)\n        else:\n            return False\n\n        self.volume_scale.set_value(new_value)\n        return True\n\n    def on_brightness_scroll(self, widget, event):\n        current_value = self.brightness_scale.get_value()\n        scroll_step = 5\n        if event.direction == Gdk.ScrollDirection.UP:\n            new_value = min(100, current_value + scroll_step)\n        elif event.direction == Gdk.ScrollDirection.DOWN:\n            new_value = max(0, current_value - scroll_step)\n        else:\n            return False\n\n        self.brightness_scale.set_value(new_value)\n        return True\n\n    def toggle_bluetooth(self, *_):\n        try:\n            # Access the bluetooth client from the bluetooth manager\n            if hasattr(self, \"bluetooth_man\") and hasattr(self.bluetooth_man, \"client\"):\n                current_state = self.bluetooth_man.client.enabled\n                set_bluetooth_enabled_with_fallback(\n                    self.bluetooth_man.client, not current_state\n                )\n            else:\n                logger.warning(\"Bluetooth client not available for toggling\")\n        except Exception as e:\n            logger.warning(f\"Failed to toggle bluetooth: {e}\")\n\n    def on_network_ready(self, *_):\n        \"\"\"Called when network service is ready\"\"\"\n        self.wifi_service = self.network_service.wifi_device\n        if self.wifi_service:\n            # Connect to WiFi state changes to update icon\n            self.wifi_service.connect(\"notify::wireless-enabled\", self.update_wifi_icon)\n\n    def update_wifi_icon(self, *_):\n        \"\"\"Update WiFi icon based on current state\"\"\"\n        try:\n            if self.wifi_service and hasattr(self, \"wifi_svg\"):\n                is_enabled = self.wifi_service.wireless_enabled\n                icon_file = (\n                    \"../../config/assets/icons/applets/wifi.svg\"\n                    if is_enabled\n                    else \"../../config/assets/icons/applets/wifi-off.svg\"\n                )\n                self.wifi_svg.set_from_file(get_relative_path(icon_file))\n        except Exception as e:\n            logger.warning(f\"Failed to update WiFi icon: {e}\")\n\n    def toggle_wifi(self, *_):\n        \"\"\"Toggle wifi on/off\"\"\"\n        try:\n            if self.wifi_service:\n                self.wifi_service.toggle_wifi()\n                # Update icon immediately after toggle\n                GLib.timeout_add(100, self.update_wifi_icon)\n            else:\n                logger.warning(\"WiFi device not available for toggling\")\n        except Exception as e:\n            logger.warning(f\"Failed to toggle wifi: {e}\")\n\n    def set_children(self, children):\n        self.children = children\n\n    def open_bluetooth(self, *_):\n        self._ensure_bluetooth_widgets()\n        idle_add(lambda *_: self.set_children(self.bluetooth_center_box))\n        self.has_bluetooth_open = True\n\n    def open_wifi(self, *_):\n        self._ensure_wifi_widgets()\n        idle_add(lambda *_: self.set_children(self.wifi_center_box))\n        self.has_wifi_open = True\n\n    def close_bluetooth(self, *_):\n        if self.current_view == \"expanded_player\":\n            self._crossfade_to_view(\"main\")\n        else:\n            idle_add(lambda *_: self.set_children(self.center_box))\n        self.has_bluetooth_open = False\n\n    def close_wifi(self, *_):\n        if self.current_view == \"expanded_player\":\n            self._crossfade_to_view(\"main\")\n        else:\n            idle_add(lambda *_: self.set_children(self.center_box))\n        self.has_wifi_open = False\n\n    def open_per_app_volume(self, *_):\n        self._ensure_per_app_volume_widgets()\n        if self.current_view == \"expanded_player\":\n            # If coming from expanded player, use crossfade\n            self._crossfade_to_view(\"main\")\n            GLib.timeout_add(\n                250,\n                lambda: idle_add(\n                    lambda *_: self.set_children(self.per_app_volume_center_box)\n                ),\n            )\n        else:\n            idle_add(lambda *_: self.set_children(self.per_app_volume_center_box))\n        self.has_per_app_volume_open = True\n        # Refresh the app list when opening\n        if self._per_app_volume_widget:\n            self._per_app_volume_widget.refresh()\n\n    def close_per_app_volume(self, *_):\n        if self.current_view == \"expanded_player\":\n            self._crossfade_to_view(\"main\")\n        else:\n            idle_add(lambda *_: self.set_children(self.center_box))\n        self.has_per_app_volume_open = False\n\n    def open_expanded_player(self, *_):\n        self._ensure_expanded_player_widgets()\n        self._crossfade_to_view(\"expanded_player\")\n        self.has_expanded_player_open = True\n        # Refresh the player when opening\n        if self._expanded_player_widget:\n            self._expanded_player_widget.refresh()\n\n    def close_expanded_player(self, *_):\n        self._crossfade_to_view(\"main\")\n        self.has_expanded_player_open = False\n\n    def _crossfade_to_view(self, view_name):\n        \"\"\"Handle transitions between views\"\"\"\n        if view_name == \"expanded_player\":\n            # Show expanded player\n            self._ensure_expanded_player_widgets()\n            idle_add(lambda *_: self.set_children(self.expanded_player_center_box))\n            self.current_view = \"expanded_player\"\n        elif view_name == \"main\":\n            # Show main view\n            idle_add(lambda *_: self.set_children(self.center_box))\n            self.current_view = \"main\"\n\n    def _set_mousecapture(self, visible: bool):\n        if visible:\n            # Lazy load music widget when becoming visible\n            self._ensure_music_widget()\n\n        self.set_visible(visible)\n        if not visible:\n            self.close_bluetooth()\n            self.close_wifi()\n            self.close_per_app_volume()\n            self.close_expanded_player()\n\n    def volume_changed(\n        self,\n        _,\n    ):\n        if self._updating_volume:\n            return\n\n        GLib.idle_add(\n            lambda: self.volume_scale.set_value(int(audio_service.speaker.volume))\n        )\n\n    def wlan_changed(self, _, wlan):\n        self.wifi_svg.set_from_file(\n            get_relative_path(\n                \"../../config/assets/icons/applets/wifi.svg\"\n                if wlan != \"No Connection\"\n                else \"../../config/assets/icons/applets/wifi-off.svg\"\n            )\n        )\n        if wlan != \"No Connection\":\n            if wlan.startswith(\"connected:\"):\n                parts = wlan.split(\":\")\n                if len(parts) >= 2:\n                    wifi_name = parts[1]\n                    GLib.idle_add(\n                        lambda: self.wlan_label.set_property(\"label\", wifi_name)\n                    )\n                else:\n                    GLib.idle_add(\n                        lambda: self.wlan_label.set_property(\"label\", \"Connected\")\n                    )\n            else:\n                GLib.idle_add(lambda: self.wlan_label.set_property(\"label\", wlan))\n        else:\n            GLib.idle_add(lambda: self.wlan_label.set_property(\"label\", wlan))\n\n    def bluetooth_changed(self, _, bluetooth):\n        self.bluetooth_svg.set_from_file(\n            get_relative_path(\n                \"../../config/assets/icons/applets/bluetooth.svg\"\n                if bluetooth != \"disabled\"\n                else \"../../config/assets/icons/applets/bluetooth-off.svg\"\n            )\n        )\n        if bluetooth != \"disabled\":\n            if bluetooth.startswith(\"connected:\"):\n                parts = bluetooth.split(\":\")\n                if len(parts) >= 2:\n                    device_name = parts[1]\n                    GLib.idle_add(lambda: self.bluetooth_label.set_label(device_name))\n                else:\n                    GLib.idle_add(lambda: self.bluetooth_label.set_label(\"Connected\"))\n            elif bluetooth == \"enabled\":\n                GLib.idle_add(lambda: self.bluetooth_label.set_label(\"On\"))\n            else:\n                GLib.idle_add(lambda: self.bluetooth_label.set_label(\"On\"))\n        else:\n            GLib.idle_add(lambda: self.bluetooth_label.set_label(\"Off\"))\n\n    def audio_changed(self, *_):\n        pass\n\n    def dnd_changed(self, _, dnd_state):\n        self.focus_mode = dnd_state\n        self.focus_icon.set_from_file(\n            get_relative_path(\n                \"../../config/assets/icons/applets/dnd.svg\"\n                if self.focus_mode\n                else \"../../config/assets/icons/applets/dnd-off.svg\"\n            )\n        )\n        self.focus_status_label.set_label(\"On\" if self.focus_mode else \"Off\")\n\n    def _init_mousecapture(self, mousecapture):\n        self._mousecapture_parent = mousecapture\n\n    def hide_controlcenter(self, *_):\n        self._mousecapture_parent.toggle_mousecapture()\n        self.set_visible(False)\n\n    def destroy(self):\n        \"\"\"Clean up resources when widget is destroyed\"\"\"\n        # Disconnect all signal connections\n        for connection in self._signal_connections:\n            try:\n                connection.disconnect()\n            except:\n                pass\n\n        # Clean up caffeine process\n        if self._caffeine_process:\n            try:\n                self._caffeine_process.terminate()\n            except:\n                pass\n            self._caffeine_process = None\n\n        # Clean up heavy components\n        if hasattr(self, \"wifi_man\") and self.wifi_man:\n            self.wifi_man.destroy()\n        if hasattr(self, \"bluetooth_man\") and self.bluetooth_man:\n            self.bluetooth_man.destroy()\n        if self._music_widget_content:\n            self._music_widget_content.destroy()\n        if self._per_app_volume_widget:\n            self._per_app_volume_widget.destroy()\n        if self._expanded_player_widget:\n            self._expanded_player_widget.destroy()\n\n        # Clean up shared MPRIS manager\n        if self._mpris_manager:\n            try:\n                self._mpris_manager.destroy()\n            except Exception as e:\n                logger.warning(f\"Failed to destroy MPRIS manager: {e}\")\n            self._mpris_manager = None\n\n        super().destroy()\n"
  },
  {
    "path": "modules/controlcenter/nightlight.py",
    "content": "# Standard library imports\nimport subprocess\n\n# Fabric imports\nfrom fabric.utils.helpers import get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.svg import Svg\n\n# Local imports\nfrom loguru import logger\n\n\nclass NightLightControl:\n    \"\"\"Control night light using hyprsunset\"\"\"\n\n    def __init__(self):\n        self.is_active = False\n        self._check_initial_state()\n\n    def _check_initial_state(self):\n        \"\"\"Check if hyprsunset is currently running\"\"\"\n        try:\n            result = subprocess.run(\n                [\"pgrep\", \"-f\", \"hyprsunset\"], capture_output=True, text=True\n            )\n            self.is_active = bool(result.stdout.strip())\n        except Exception as e:\n            logger.warning(f\"Failed to check hyprsunset status: {e}\")\n            self.is_active = False\n\n    def toggle(self):\n        \"\"\"Toggle night light on/off\"\"\"\n        try:\n            if self.is_active:\n                # Turn off night light by killing hyprsunset\n                subprocess.run([\"pkill\", \"hyprsunset\"], check=False)\n                self.is_active = False\n                logger.debug(\"Night light turned off\")\n            else:\n                # Turn on night light with default temperature (3000K)\n                subprocess.Popen([\"hyprsunset\", \"-t\", \"4500\"], start_new_session=True)\n                self.is_active = True\n                logger.debug(\"Night light turned on\")\n            return True\n        except Exception as e:\n            logger.warning(f\"Failed to toggle night light: {e}\")\n            return False\n\n    def set_temperature(self, temperature: int):\n        \"\"\"Set night light temperature (1000-6500K)\"\"\"\n        try:\n            # Kill existing process\n            subprocess.run([\"pkill\", \"hyprsunset\"], check=False)\n\n            # Start with new temperature\n            subprocess.Popen(\n                [\"hyprsunset\", \"-t\", str(temperature)], start_new_session=True\n            )\n            self.is_active = True\n            logger.debug(f\"Night light temperature set to {temperature}K\")\n            return True\n        except Exception as e:\n            logger.warning(f\"Failed to set night light temperature: {e}\")\n            return False\n\n\ndef create_night_light_widget(control_center):\n    \"\"\"Create night light widget for control center\"\"\"\n\n    # Initialize night light control\n    night_light = NightLightControl()\n\n    # Create icon\n    night_light_icon = Svg(\n        name=\"nightlight-icon\",\n        svg_file=get_relative_path(\n            \"../../config/assets/icons/applets/redshift-status-on.svg\"\n            if night_light.is_active\n            else \"../../config/assets/icons/applets/redshift-status-off.svg\"\n        ),\n        size=42,\n    )\n\n    # Create status label\n    night_light_status_label = Label(\n        label=\"On\" if night_light.is_active else \"Off\",\n        name=\"nightlight-widget-label\",\n        style_classes=\"status-label\",\n        max_chars_width=15,\n        ellipsization=\"end\",\n        h_align=\"start\",\n    )\n\n    def toggle_night_light(*_):\n        \"\"\"Toggle night light and update UI\"\"\"\n        if night_light.toggle():\n            # Update icon\n            night_light_icon.set_from_file(\n                get_relative_path(\n                    \"../../config/assets/icons/applets/redshift-status-on.svg\"\n                    if night_light.is_active\n                    else \"../../config/assets/icons/applets/redshift-status-off.svg\"\n                )\n            )\n            # Update status label\n            night_light_status_label.set_label(\"On\" if night_light.is_active else \"Off\")\n\n    # Create widget box (similar to bluetooth_widget structure)\n    night_light_widget = Box(\n        name=\"nightlight-widget\",\n        orientation=\"h\",\n        h_expand=True,\n        children=[\n            Button(\n                name=\"nightlight-icon-button\",\n                child=night_light_icon,\n                on_clicked=toggle_night_light,\n            ),\n            Button(\n                name=\"nightlight-info-button\",\n                child=Box(\n                    name=\"nightlight-widget-info\",\n                    h_expand=True,\n                    v_expand=True,\n                    v_align=\"center\",\n                    h_align=\"start\",\n                    orientation=\"vertical\",\n                    children=[\n                        Label(\n                            name=\"nightlight-widget-name\",\n                            label=\"Night Light\",\n                            style_classes=\"ct\",\n                            h_align=\"start\",\n                        ),\n                        night_light_status_label,\n                    ],\n                ),\n                on_clicked=toggle_night_light,\n            ),\n        ],\n    )\n\n    return night_light_widget\n\n"
  },
  {
    "path": "modules/controlcenter/per_app_volume.py",
    "content": "# Standard library imports\nfrom gi.repository import GLib\n\n# Fabric imports\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.scale import Scale\nfrom fabric.widgets.scrolledwindow import ScrolledWindow\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.separator import Separator\n\n# Local imports\nfrom utils.roam import audio_service\n\n\nclass PerAppVolumeControl(Box):\n    \"\"\"Per-application volume control widget\"\"\"\n\n    def __init__(self, control_center, **kwargs):\n        super().__init__(\n            orientation=\"vertical\",\n            name=\"per-app-volume-control\",\n            spacing=5,\n            **kwargs,\n        )\n\n        self.control_center = control_center\n        self._updating_volumes = set()\n        self._app_widgets = {}\n        self._signal_connections = []\n\n        # Header with back button\n        self.header = Box(\n            orientation=\"horizontal\",\n            name=\"per-app-volume-header\",\n            style_classes=\"menu-header\",\n            children=[\n                Button(\n                    image=Image(icon_name=\"back\", size=10),\n                    on_clicked=lambda *_: self.control_center.close_per_app_volume(),\n                ),\n                Label(\"App Volume\", name=\"app-volume-header\"),\n            ],\n        )\n\n        # Scrollable container for app volume controls\n        self.apps_container = Box(\n            orientation=\"vertical\",\n            name=\"apps-scrolled-container\",\n            spacing=2,\n        )\n\n        self.scrolled_window = ScrolledWindow(\n            name=\"apps-scrolled-window\",\n            child=self.apps_container,\n            size=(300, 500),\n        )\n\n        # Add escape key binding for navigation back\n        try:\n            if hasattr(self.control_center, \"add_keybinding\"):\n                self.control_center.add_keybinding(\"Escape\", self._go_back)\n        except Exception:\n            pass  # Ignore if keybinding fails\n\n        self.children = [self.header, self.scrolled_window]\n\n        # Connect to audio service changes\n        if audio_service:\n            self._signal_connections.append(\n                audio_service.connect(\"stream-added\", self._on_stream_changed)\n            )\n            self._signal_connections.append(\n                audio_service.connect(\"stream-removed\", self._on_stream_changed)\n            )\n\n        # Initial population\n        self._populate_apps()\n\n        # Set up auto-refresh timer for audio streams\n        self._refresh_timer = GLib.timeout_add_seconds(2, self._auto_refresh)\n\n    def _auto_refresh(self):\n        \"\"\"Auto-refresh the application list every 2 seconds\"\"\"\n        self._populate_apps()\n        return True  # Continue the timer\n\n    def _go_back(self, *_):\n        \"\"\"Return to main control center view\"\"\"\n        self.control_center.close_per_app_volume()\n\n    def _get_app_icon(self, app):\n        \"\"\"Get icon for application\"\"\"\n        # Don't use fabric's generic audio icon, use description/name for better detection\n        fabric_icon = getattr(app, \"icon_name\", \"\")\n        if fabric_icon and fabric_icon != \"audio\":\n            return fabric_icon\n\n        # Use description first (more accurate), then name\n        app_description = getattr(app, \"description\", \"\").lower()\n        app_name = app.name.lower()\n\n        # Check description first as it's more reliable\n        search_text = app_description if app_description else app_name\n\n        icon_mapping = {\n            \"spotify\": \"spotify\",\n            \"firefox\": \"firefox\",\n            \"chromium\": \"chromium-browser\",\n            \"chrome\": \"google-chrome\",\n            \"vlc\": \"vlc\",\n            \"discord\": \"discord\",\n            \"steam\": \"steam\",\n            \"zen\": \"zen-browser\",\n            \"code\": \"vscode\",\n            \"visual studio code\": \"vscode\",\n            \"telegram\": \"telegram-desktop\",\n            \"pulse\": \"audio-card\",\n            \"zen\": \"zen-browser\",\n            \"pipewire\": \"audio-card\",\n            \"alsa\": \"audio-card\",\n            \"sink\": \"audio-speakers\",\n            \"source\": \"audio-input-microphone\",\n            \"youtube\": \"youtube\",\n            \"music\": \"rhythmbox\",\n            \"media\": \"multimedia-player\",\n        }\n\n        # Check if any key in mapping is contained in search text\n        for key, icon in icon_mapping.items():\n            if key in search_text:\n                return icon\n\n        # Also check app name as fallback\n        if search_text != app_name:\n            for key, icon in icon_mapping.items():\n                if key in app_name:\n                    return icon\n\n        # Default audio icon for unknown apps\n        return \"audio-volume-high\"\n\n    def _format_app_name(self, name):\n        \"\"\"Format application name with proper capitalization\"\"\"\n        if not name:\n            return \"Unknown\"\n\n        # Handle common app names specially\n        special_names = {\n            \"spotify\": \"Spotify\",\n            \"firefox\": \"Firefox\",\n            \"chromium\": \"Chromium\",\n            \"chrome\": \"Chrome\",\n            \"vlc\": \"VLC\",\n            \"discord\": \"Discord\",\n            \"steam\": \"Steam\",\n            \"zen\": \"Zen Browser\",\n            \"code\": \"VS Code\",\n            \"telegram\": \"Telegram\",\n        }\n\n        name_lower = name.lower()\n        if name_lower in special_names:\n            return special_names[name_lower]\n\n        # Default: capitalize first letter\n        return name.capitalize()\n\n    def _populate_apps(self):\n        \"\"\"Populate the widget with current audio applications\"\"\"\n        # Clear existing widgets\n        self.apps_container.children = []\n        self._app_widgets.clear()\n\n        # Use fabric audio service for applications\n        if not audio_service:\n            self._show_no_apps_message()\n            return\n\n        applications = getattr(audio_service, \"applications\", [])\n\n        if applications:\n            for i, app in enumerate(applications):\n                if hasattr(app, \"name\") and hasattr(app, \"volume\"):\n                    app_widget = self._create_app_control(app)\n                    self.apps_container.children = list(\n                        self.apps_container.children\n                    ) + [app_widget]\n\n                    # Add separator between apps (except for last one)\n                    if i < len(applications) - 1:\n                        separator = Separator(\n                            orientation=\"horizontal\",\n                            style_classes=\"app-volume-separator\",\n                        )\n                        self.apps_container.children = list(\n                            self.apps_container.children\n                        ) + [separator]\n\n                    self._app_widgets[app.name] = (app_widget, app)\n        else:\n            self._show_no_apps_message()\n\n    def _show_no_apps_message(self):\n        \"\"\"Show message when no apps are playing audio\"\"\"\n        message = Label(\n            label=\"No applications currently using audio\",\n            style_classes=\"subtitle\",\n            h_align=\"center\",\n            v_align=\"center\",\n        )\n        self.apps_container.children = [message]\n\n    def _create_app_control(self, app):\n        \"\"\"Create compact volume control for a single application\"\"\"\n        # Format app name (shorter for compact layout)\n        app_name = self._format_app_name(app.name)\n        # Get current volume from fabric audio service\n        current_volume = getattr(app, \"volume\", 0.0)\n        max_vol = getattr(audio_service, \"max_volume\", 100) if audio_service else 100\n\n        # Ensure volume is in valid range\n        volume_percent = max(0, min(current_volume, max_vol))\n\n        # App icon - use the same approach as expanded player\n        icon_name = self._get_app_icon(app)\n        app_icon = Image(\n            icon_name=icon_name,\n            name=\"app-icon\",\n            icon_size=16,\n            style_classes=\"app-icon\",\n        )\n\n        # App name label\n        name_label = Label(\n            label=app_name,\n            style_classes=\"app-name-compact\",\n            justification=\"left\",\n            h_align=\"start\",\n            max_chars_width=10,\n            ellipsization=\"end\",\n        )\n\n        # Volume scale (smaller and horizontal)\n        volume_scale = Scale(\n            value=volume_percent,\n            min_value=0,\n            max_value=max_vol,\n            increments=(1, 5),\n            name=\"compact-volume-slider\",\n            size=20,\n            h_expand=True,\n        )\n\n        # Connect volume change handler\n        volume_scale.connect(\n            \"change-value\",\n            lambda scale, scroll_type, value, app=app: self._set_app_volume(app, value),\n        )\n\n        # Create horizontal compact layout\n        app_control = Box(\n            orientation=\"horizontal\",\n            spacing=5,\n            h_expand=True,\n            style_classes=\"compact-app-volume-item\",\n            children=[\n                Box(\n                    orientation=\"h\",\n                    spacing=3,\n                    v_expand=True,\n                    v_align=\"center\",\n                    name=\"app-control-box\",\n                    children=[\n                        app_icon,\n                        name_label,\n                    ],\n                ),\n                volume_scale,\n            ],\n        )\n        # Add separator after the control\n\n        return app_control\n\n    def _set_app_volume(self, app, volume_value):\n        \"\"\"Set volume for a specific application using fabric audio service\"\"\"\n        if app.name in self._updating_volumes:\n            return\n\n        self._updating_volumes.add(app.name)\n\n        try:\n            # Get max volume from audio service\n            max_vol = (\n                getattr(audio_service, \"max_volume\", 100) if audio_service else 100\n            )\n\n            # Ensure volume is within bounds\n            volume_value = max(0, min(volume_value, max_vol))\n\n            # Set volume directly - fabric expects the actual volume value, not percentage\n            app.volume = volume_value\n\n        except Exception as e:\n            print(f\"Error setting volume for {app.name}: {e}\")\n        finally:\n            GLib.timeout_add(100, lambda: self._updating_volumes.discard(app.name))\n\n    def _on_stream_changed(self, *_):\n        \"\"\"Handle when audio streams are added or removed\"\"\"\n        GLib.idle_add(self._populate_apps)\n\n    def refresh(self):\n        \"\"\"Manually refresh the application list\"\"\"\n        self._populate_apps()\n\n    def destroy(self):\n        \"\"\"Clean up resources\"\"\"\n        if hasattr(self, \"_refresh_timer\"):\n            GLib.source_remove(self._refresh_timer)\n\n        # Disconnect fabric audio service signals\n        if audio_service:\n            for connection in self._signal_connections:\n                try:\n                    audio_service.disconnect(connection)\n                except:\n                    pass\n\n        self._signal_connections.clear()\n        self._app_widgets.clear()\n        self._updating_volumes.clear()\n\n        super().destroy()\n"
  },
  {
    "path": "modules/controlcenter/player.py",
    "content": "import os\nimport re\nimport tempfile\nimport urllib.parse\nimport urllib.request\nfrom typing import List\nimport threading\n\nfrom fabric.utils import (\n    bulk_connect,\n)\nfrom fabric.utils.helpers import get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.overlay import Overlay\nfrom fabric.widgets.stack import Stack\nfrom fabric.widgets.svg import Svg\nfrom gi.repository import GLib, GObject\nfrom fabric.widgets.centerbox import CenterBox\nfrom loguru import logger\n\nfrom services.mpris import MprisPlayer, MprisPlayerManager\nimport config.data as data\n\nCACHE_DIR = f\"{data.CACHE_DIR}/media\"\n\n\ndef cleanup_old_cache_files():\n    \"\"\"Clean up old artwork cache files (older than 1 day) and limit total cache size.\"\"\"\n    try:\n        if not os.path.exists(CACHE_DIR):\n            return\n\n        import time\n\n        current_time = time.time()\n        one_day_ago = current_time - (24 * 60 * 60)  # 24 hours\n        cache_files = []\n\n        # Collect all cache files with their modification times\n        for filename in os.listdir(CACHE_DIR):\n            filepath = os.path.join(CACHE_DIR, filename)\n            try:\n                if os.path.isfile(filepath):\n                    file_mtime = os.path.getmtime(filepath)\n                    file_size = os.path.getsize(filepath)\n                    cache_files.append((filepath, file_mtime, file_size))\n            except Exception:\n                pass  # Ignore individual file errors\n\n        # Remove files older than 1 day\n        total_size = 0\n        recent_files = []\n        for filepath, file_mtime, file_size in cache_files:\n            if file_mtime < one_day_ago:\n                try:\n                    os.unlink(filepath)\n                except Exception:\n                    pass\n            else:\n                recent_files.append((filepath, file_mtime, file_size))\n                total_size += file_size\n\n        # If cache is still too large (>50MB), remove oldest files\n        MAX_CACHE_SIZE = 50 * 1024 * 1024  # 50MB\n        if total_size > MAX_CACHE_SIZE:\n            # Sort by modification time (oldest first)\n            recent_files.sort(key=lambda x: x[1])\n            for filepath, _, file_size in recent_files:\n                if total_size <= MAX_CACHE_SIZE:\n                    break\n                try:\n                    os.unlink(filepath)\n                    total_size -= file_size\n                except Exception:\n                    pass\n\n    except Exception:\n        pass  # Ignore all errors in cleanup\n\n\nclass PlayerBoxStack(Box):\n    \"\"\"A widget that displays the current player information.\"\"\"\n\n    def __init__(\n        self, mpris_manager: MprisPlayerManager, control_center=None, **kwargs\n    ):\n        # Clean up old cache files on startup\n        cleanup_old_cache_files()\n\n        # The player stack\n        self.player_stack = Stack(\n            # transition_type=\"slide-left-right\",\n            # transition_duration=500,\n            name=\"player-stack\",\n        )\n        self.current_stack_pos = 0\n        self.control_center = control_center\n\n        # List to store player buttons\n        self.player_buttons: list[Button] = []\n\n        # Track signal connections for cleanup\n        self._signal_connections = []\n\n        # Create a \"No media playing\" placeholder\n        self.no_media_box = self._create_no_media_box()\n\n        super().__init__(orientation=\"v\", name=\"media\", children=[self.player_stack])\n\n        # Show the no media box initially\n        self.player_stack.children = [self.no_media_box]\n        self.set_visible(True)\n\n        self.mpris_manager = mpris_manager\n\n        # Track connections for cleanup - store (object, handler_id) tuples\n        connections = bulk_connect(\n            self.mpris_manager,\n            {\n                \"player-appeared\": self.on_new_player,\n                \"player-vanished\": self.on_lost_player,\n            },\n        )\n        # Store as (object, handler_id) tuples\n        for handler_id in connections:\n            self._signal_connections.append((self.mpris_manager, handler_id))\n\n        for player in self.mpris_manager.players:  # type: ignore\n            logger.info(\n                f\"[PLAYER MANAGER] player found: {player.get_property('player-name')}\",\n            )\n            self.on_new_player(self.mpris_manager, player)\n\n    def destroy(self):\n        \"\"\"Clean up resources when the widget is destroyed.\"\"\"\n        # Disconnect all signal connections\n        for obj, handler_id in self._signal_connections:\n            try:\n                obj.disconnect(handler_id)\n            except Exception as e:\n                logger.warning(f\"Failed to disconnect signal: {e}\")\n        self._signal_connections.clear()\n\n        # Clean up player buttons\n        for button in self.player_buttons:\n            try:\n                button.destroy()\n            except Exception:\n                pass\n        self.player_buttons.clear()\n\n        # Clean up player boxes\n        for child in self.player_stack.get_children():\n            if hasattr(child, \"destroy\") and child != self.no_media_box:\n                try:\n                    child.destroy()\n                except Exception:\n                    pass\n\n        super().destroy()\n\n    def _periodic_cleanup(self):\n        \"\"\"Enhanced cleanup for reuse - clean internal state and free memory\"\"\"\n        try:\n            # Destroy all player boxes properly to free their resources\n            current_children = list(self.player_stack.get_children())\n            for child in current_children:\n                if hasattr(child, \"destroy\") and child != self.no_media_box:\n                    try:\n                        child.destroy()\n                    except Exception as e:\n                        logger.warning(f\"Failed to destroy player child: {e}\")\n\n            # Reset to no media state\n            self.player_stack.children = [self.no_media_box]\n\n            # Clear player buttons\n            for button in self.player_buttons:\n                try:\n                    button.destroy()\n                except Exception:\n                    pass\n            self.player_buttons.clear()\n\n            # Reset stack position\n            self.current_stack_pos = 0\n\n            # Clean up old cache files more aggressively\n            cleanup_old_cache_files()\n\n            # Force garbage collection\n            import gc\n\n            gc.collect()\n\n            logger.debug(\"PlayerBoxStack enhanced cleanup completed\")\n        except Exception as e:\n            logger.warning(f\"PlayerBoxStack enhanced cleanup failed: {e}\")\n\n    def _create_no_media_box(self):\n        \"\"\"Create a placeholder box for when no media is playing.\"\"\"\n        fallback_cover_path = f\"{data.HOME_DIR}/.current.wall\"\n\n        # Album cover with fallback image\n        album_cover = Box(style_classes=\"album-image-c\")\n        album_cover.set_style(f\"background-image:url('{fallback_cover_path}')\")\n\n        image_stack = Box(h_align=\"start\", v_align=\"center\", name=\"player-image-stack\")\n        image_stack.children = [album_cover]\n\n        # Track info showing \"No media playing\"\n        track_title = Label(\n            label=\"No media playing\",\n            name=\"player-title-c\",\n            justification=\"left\",\n            max_chars_width=25,\n            ellipsization=\"end\",\n            h_align=\"start\",\n        )\n\n        track_artist = Label(\n            label=\"\",\n            name=\"player-artist-c\",\n            justification=\"left\",\n            max_chars_width=15,\n            ellipsization=\"end\",\n            h_align=\"start\",\n            visible=False,  # Hide artist and album when no media\n        )\n\n        track_info = Box(\n            name=\"track-info\",\n            # spacing=5,\n            h_expand=True,\n            orientation=\"v\",\n            v_align=\"start\",\n            h_align=\"start\",\n            children=[track_title, track_artist],\n        )\n\n        # No control buttons for no media state - just an empty box\n        controls_box = Box(\n            name=\"player-controls\",\n            visible=False,  # Hide controls when no media\n        )\n\n        player_info_box = Box(\n            name=\"player-info-box-c\",\n            h_expand=True,\n            v_align=\"center\",\n            h_align=\"center\",\n            orientation=\"v\",\n            children=[track_info, controls_box],\n        )\n\n        inner_box = CenterBox(\n            name=\"inner-player-box\",\n            start_children=[\n                image_stack,\n            ],\n            center_children=[\n                player_info_box,\n            ],\n        )\n        # resize the inner box\n        outer_box = Box(\n            spacing=5,\n            name=\"outer-no-player-box-c\",\n            h_expand=True,\n            h_align=\"fill\",\n            # children=[\n            v_expand=True,\n            children=inner_box,\n            # inner_box,\n            # player_info_box,\n            # image,\n            # ],\n        )\n\n        box = Box(\n            name=\"box-c\",\n            orientation=\"h\",\n            v_expand=True,\n            h_align=\"fill\",\n            h_expand=True,\n            children=[\n                outer_box,\n            ],\n        )\n        no_media_box = Box(\n            h_align=\"center\",\n            name=\"player-box\",\n            h_expand=True,\n            children=[box],\n        )\n\n        return no_media_box\n\n    def _find_playing_player_index(self):\n        \"\"\"Find the index of the currently playing player.\"\"\"\n        players: List[PlayerBox] = self.player_stack.get_children()\n        for i, player_box in enumerate(players):\n            if (\n                hasattr(player_box, \"player\")\n                and player_box.player.playback_status == \"playing\"\n            ):\n                return i\n        return None\n\n    def _switch_to_playing_player(self):\n        \"\"\"Switch to the currently playing player if one exists.\"\"\"\n        playing_index = self._find_playing_player_index()\n        if playing_index is not None and playing_index != self.current_stack_pos:\n            logger.info(\n                f\"[PlayerBoxStack] Auto-switching to playing player at index {\n                    playing_index\n                }\"\n            )\n            self.on_player_clicked_by_index(playing_index)\n\n    def on_player_playback_changed(self, player_box, status):\n        \"\"\"Called when a player's playback status changes.\"\"\"\n        if status == \"playing\":\n            # Find this player's index and switch to it\n            players: List[PlayerBox] = self.player_stack.get_children()\n            for i, pb in enumerate(players):\n                if pb == player_box:\n                    if i != self.current_stack_pos:\n                        logger.info(\n                            f\"[PlayerBoxStack] Switching to playing player: {\n                                player_box.player.player_name\n                            }\"\n                        )\n                        self.on_player_clicked_by_index(i)\n                    break\n\n    def on_player_clicked(self, type):\n        # unset active from prev active button\n        if self.player_buttons and self.current_stack_pos < len(self.player_buttons):\n            self.player_buttons[self.current_stack_pos].remove_style_class(\"active\")\n\n        if type == \"next\":\n            self.current_stack_pos = (\n                self.current_stack_pos + 1\n                if self.current_stack_pos != len(self.player_stack.get_children()) - 1\n                else 0\n            )\n        elif type == \"prev\":\n            self.current_stack_pos = (\n                self.current_stack_pos - 1\n                if self.current_stack_pos != 0\n                else len(self.player_stack.get_children()) - 1\n            )\n\n        # set new active button\n        if self.player_buttons and self.current_stack_pos < len(self.player_buttons):\n            print(\n                f\"[PlayerBoxStack] Switching to player at index {\n                    self.current_stack_pos\n                }\"\n            )\n            self.player_buttons[self.current_stack_pos].add_style_class(\"active\")\n            self.player_stack.set_visible_child(\n                self.player_stack.get_children()[self.current_stack_pos],\n            )\n\n    def on_player_clicked_by_index(self, index):\n        \"\"\"Switch to player at given index\"\"\"\n        if 0 <= index < len(self.player_buttons):\n            # unset active from prev active button\n            if self.player_buttons and self.current_stack_pos < len(\n                self.player_buttons\n            ):\n                self.player_buttons[self.current_stack_pos].remove_style_class(\"active\")\n            # set new position\n            self.current_stack_pos = index\n            # set new active button\n            if self.player_buttons and self.current_stack_pos < len(\n                self.player_buttons\n            ):\n                self.player_buttons[self.current_stack_pos].add_style_class(\"active\")\n                self.player_stack.set_visible_child(\n                    self.player_stack.get_children()[self.current_stack_pos],\n                )\n            # Update all player boxes with new button state\n            self._update_all_player_buttons()\n\n    def on_new_player(self, mpris_manager, player):\n        player_name = player.props.player_name\n\n        # if player_name in self.config.get(\"ignore\", []):\n        #     return\n\n        # Remove the no media box if it's the only child\n        if (\n            len(self.player_stack.get_children()) == 1\n            and self.player_stack.get_children()[0] == self.no_media_box\n        ):\n            self.player_stack.children = []\n            self.current_stack_pos = 0\n\n        self.set_visible(True)\n\n        new_player_box = PlayerBox(\n            player=MprisPlayer(player),\n            player_stack=self,\n            control_center=self.control_center,\n        )\n        self.player_stack.children = [\n            *self.player_stack.children,\n            new_player_box,\n        ]\n\n        self.make_new_player_button(self.player_stack.get_children()[-1])\n        logger.info(\n            f\"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}\",\n        )\n        if self.player_buttons and self.current_stack_pos < len(self.player_buttons):\n            self.player_buttons[self.current_stack_pos].set_style_classes([\"active\"])\n\n        # Update all player boxes with current button state\n        self._update_all_player_buttons()\n\n        # Check if this new player is playing and switch to it\n        self._switch_to_playing_player()\n\n    def on_lost_player(self, mpris_manager, player_name):\n        # the playerBox is automatically removed from mprisbox children on being removed\n        logger.info(f\"[PLAYER_MANAGER] Player Removed {player_name}\")\n        players: List[PlayerBox] = self.player_stack.get_children()\n\n        # Find and properly destroy the player box\n        player_box_to_remove = None\n        for player_box in players:\n            if (\n                hasattr(player_box, \"player\")\n                and player_box.player.player_name == player_name\n            ):\n                player_box_to_remove = player_box\n                break\n\n        if player_box_to_remove:\n            try:\n                player_box_to_remove.destroy()\n            except Exception as e:\n                logger.warning(f\"Failed to destroy player box: {e}\")\n\n        # Check if this was the last player\n        remaining_players = [\n            p for p in self.player_stack.get_children() if p != player_box_to_remove\n        ]\n        if len(remaining_players) == 0:\n            # Show the no media box instead of hiding\n            self.player_stack.children = [self.no_media_box]\n            self.current_stack_pos = 0\n            self.player_buttons = []  # Clear player buttons\n            return\n\n        # Adjust current position if needed\n        if self.current_stack_pos >= len(self.player_stack.get_children()):\n            self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)\n\n        # Set active button if we have buttons and a valid position\n        if self.player_buttons and self.current_stack_pos < len(self.player_buttons):\n            self.player_buttons[self.current_stack_pos].set_style_classes([\"active\"])\n            if self.player_stack.get_children():\n                self.player_stack.set_visible_child(\n                    self.player_stack.get_children()[self.current_stack_pos],\n                )\n\n        # Update all player boxes with current button state\n        self._update_all_player_buttons()\n\n        # After a player is removed, check if we should switch to a playing player\n        self._switch_to_playing_player()\n\n    def make_new_player_button(self, player_box):\n        new_button = Button(name=\"player-stack-button\")\n\n        def on_player_button_click(button: Button):\n            if self.player_buttons and self.current_stack_pos < len(\n                self.player_buttons\n            ):\n                self.player_buttons[self.current_stack_pos].remove_style_class(\"active\")\n            if button in self.player_buttons:\n                self.current_stack_pos = self.player_buttons.index(button)\n                button.add_style_class(\"active\")\n                self.player_stack.set_visible_child(player_box)\n\n        new_button.connect(\n            \"clicked\",\n            on_player_button_click,\n        )\n        self.player_buttons.append(new_button)\n\n        # This will automatically destroy our used button\n        def cleanup_button(*_):\n            try:\n                if new_button in self.player_buttons:\n                    self.player_buttons.remove(new_button)\n                new_button.destroy()\n            except Exception as e:\n                logger.warning(f\"Failed to cleanup button: {e}\")\n\n        player_box.connect(\"destroy\", cleanup_button)\n\n    def _update_all_player_buttons(self):\n        \"\"\"Update all player boxes with the current button state\"\"\"\n        players: List[PlayerBox] = self.player_stack.get_children()\n        logger.info(\n            f\"[PlayerBoxStack] Updating buttons for {len(players)} players, {\n                len(self.player_buttons)\n            } buttons\"\n        )\n        for player_box in players:\n            if hasattr(player_box, \"update_buttons\"):\n                player_box.update_buttons(self.player_buttons, len(players) > 1)\n            else:\n                logger.warning(\n                    \"[PlayerBoxStack] PlayerBox missing update_buttons method\"\n                )\n\n\nclass PlayerBox(Box):\n    \"\"\"A widget that displays the current player information.\"\"\"\n\n    def __init__(\n        self, player: MprisPlayer, player_stack=None, control_center=None, **kwargs\n    ):\n        super().__init__(\n            h_align=\"center\",\n            name=\"player-box\",\n            **kwargs,\n            h_expand=True,\n        )\n        # Setup\n        self.player: MprisPlayer = player\n        self.player_stack = player_stack\n        self.control_center = control_center\n        self.fallback_cover_path = f\"{data.HOME_DIR}/.current.wall\"\n\n        # Add controls_box attribute early for compatibility\n        # Temporary placeholder\n        self.controls_box = Box(name=\"temp-controls-box\")\n\n        self.image_size = 50\n        self.icon_size = 15\n\n        # State\n        self.exit = False\n        self.skipped = False\n\n        # Memory management\n        self.temp_artwork_files = []  # Track temp files for cleanup\n        self.current_download_thread = None  # Track current download thread\n        self._download_cancelled = False  # Flag to cancel downloads\n        self._signal_connections = []  # Track signal connections\n\n        self.album_cover = Box(style_classes=\"album-image-c\")\n        self.album_cover.set_style(\n            f\"background-image:url('{self.fallback_cover_path}')\"\n        )\n\n        self.image_stack = Box(\n            h_align=\"start\",\n            v_align=\"center\",\n            name=\"player-image-stack\",\n        )\n        self.image_stack.children = [*self.image_stack.children, self.album_cover]\n\n        self.app_icon = Box(\n            children=Image(\n                icon_name=self.player.player_name, name=\"player-app-icon\", icon_size=20\n            ),\n            h_align=\"end\",\n            v_align=\"end\",\n            tooltip_text=self.player.player_name,  # type: ignore\n        )\n        self.image = Overlay(\n            child=self.image_stack,\n            overlays=[\n                self.app_icon,\n            ],\n        )\n        # Track Info\n\n        self.track_title = Label(\n            label=\"No Title\",\n            name=\"player-title-c\",\n            justification=\"left\",\n            max_chars_width=25,\n            ellipsization=\"end\",\n            h_align=\"start\",\n        )\n\n        self.track_artist = Label(\n            label=\"No Artist\",\n            name=\"player-artist-c\",\n            justification=\"left\",\n            max_chars_width=23,\n            ellipsization=\"end\",\n            h_align=\"start\",\n            visible=True,\n        )\n\n        self.player.bind_property(\n            \"title\",\n            self.track_title,\n            \"label\",\n            GObject.BindingFlags.DEFAULT,\n            lambda _, x: (\n                re.sub(r\"\\r?\\n\", \" \", x) if x != \"\" and x is not None else \"No Title\"\n            ),  # type: ignore\n        )\n        self.player.bind_property(\n            \"artist\",\n            self.track_artist,\n            \"label\",\n            GObject.BindingFlags.DEFAULT,\n            lambda _, x: (\n                re.sub(r\"\\r?\\n\", \" \", x) if x != \"\" and x is not None else \"No Artist\"\n            ),  # type: ignore\n        )\n\n        self.track_info = Box(\n            name=\"track-info\",\n            spacing=5,\n            orientation=\"v\",\n            v_align=\"start\",\n            h_align=\"start\",\n            children=[\n                self.track_title,\n                self.track_artist,\n            ],\n        )\n\n        # Buttons with fixed sizing for layout stability\n        self.button_box = Box(\n            name=\"button-box-c\",\n            h_expand=False,\n            spacing=2,\n        )\n\n        # Create SVG icons with consistent sizing\n        self.skip_next_icon = Svg(\n            name=\"control-buttons\",\n            size=(22, 22),\n            svg_file=get_relative_path(\"../../config/assets/icons/player/fwd.svg\"),\n        )\n        self.play_pause_icon = Svg(\n            name=\"control-buttons\",\n            size=(22, 22),\n            svg_file=get_relative_path(\"../../config/assets/icons/player/Pause.svg\"),\n        )\n\n        # Fixed size buttons to prevent layout shifts\n        self.play_pause_button = Button(\n            name=\"player-button\",\n            child=self.play_pause_icon,\n            on_clicked=self.player.play_pause,\n        )\n        # Set consistent button size\n\n        self.player.bind_property(\"can_pause\", self.play_pause_button, \"sensitive\")\n\n        self.next_button = Button(\n            name=\"player-button\",\n            child=self.skip_next_icon,\n            on_clicked=self._on_player_next,\n        )\n        # Set consistent button size\n        # self.next_button.set_size_request(32, 32)\n        self.player.bind_property(\"can_go_next\", self.next_button, \"sensitive\")\n\n        self.button_box.children = (\n            self.play_pause_button,\n            self.next_button,\n        )\n\n        # Assign button_box to controls_box for compatibility\n        self.controls_box = self.button_box\n\n        self.player_info_box = Box(\n            name=\"player-info-box-c\",\n            v_align=\"center\",\n            h_expand=True,\n            h_align=\"start\",\n            orientation=\"v\",\n            children=[\n                self.track_info,\n            ],\n        )\n\n        self.inner_box = Box(\n            name=\"inner-player-box\",\n            h_expand=True,\n            v_align=\"center\",\n            h_align=\"start\",\n            children=[\n                self.image,\n                self.player_info_box,\n            ],\n        )\n        # resize the inner box\n        self.outer_box = Button(\n            spacing=5,\n            name=\"outer-player-box-c\",\n            h_expand=True,\n            # style=\"background-color:#fff\",\n            on_clicked=self._on_outer_box_clicked,\n            h_align=\"start\",\n            # children=[\n            child=self.inner_box,\n            # self.inner_box,\n            # self.player_info_box,\n            # self.image,\n            # ],\n        )\n\n        self.box = CenterBox(\n            name=\"box-c\",\n            orientation=\"h\",\n            h_align=\"center\",\n            start_children=[\n                self.outer_box,\n                # self.stack_buttons_box,\n            ],\n            end_children=[\n                self.button_box,\n            ],\n        )\n\n        self.children = [\n            *self.children,\n            self.box,\n        ]\n\n        # Track signal connections for cleanup - store (object, handler_id) tuples\n        connections = bulk_connect(\n            self.player,\n            {\n                \"exit\": self._on_player_exit,\n                \"notify::playback-status\": self._on_playback_change,\n                \"notify::metadata\": self._on_metadata,\n            },\n        )\n        # Store as (object, handler_id) tuples\n        for handler_id in connections:\n            self._signal_connections.append((self.player, handler_id))\n\n    def destroy(self):\n        \"\"\"Clean up all resources when the widget is destroyed.\"\"\"\n        # Cancel any ongoing downloads\n        self._download_cancelled = True\n\n        # Disconnect all signal connections\n        for obj, handler_id in self._signal_connections:\n            try:\n                obj.disconnect(handler_id)\n            except Exception as e:\n                logger.warning(f\"Failed to disconnect signal: {e}\")\n        self._signal_connections.clear()\n\n        # Clean up temp files\n        self._cleanup_temp_files()\n\n        super().destroy()\n\n    def __del__(self):\n        \"\"\"Ensure cleanup happens even if player exits unexpectedly.\"\"\"\n        try:\n            self._cleanup_temp_files()\n        except Exception:\n            pass  # Ignore errors during cleanup in destructor\n\n    def _on_prev_button_click(self, *_):\n        \"\"\"Handle prev button click: open expanded player in control center\"\"\"\n        try:\n            # Open expanded player in control center instead of new window\n            if self.control_center and hasattr(\n                self.control_center, \"open_expanded_player\"\n            ):\n                self.control_center.open_expanded_player()\n        except Exception as e:\n            logger.warning(f\"Failed to handle prev button click: {e}\")\n\n    def _on_outer_box_clicked(self, *_):\n        \"\"\"Handle outer box click with proper error handling.\"\"\"\n        try:\n            # Open expanded player in control center instead of new window\n            if self.control_center and hasattr(\n                self.control_center, \"open_expanded_player\"\n            ):\n                self.control_center.open_expanded_player()\n        except Exception as e:\n            logger.warning(f\"Failed to handle outer box click: {e}\")\n            import traceback\n\n            logger.error(f\"Full traceback: {traceback.format_exc()}\")\n\n    def update_buttons(self, player_buttons, show_buttons):\n        # \"\"\"Update the stack switcher buttons in this player box\"\"\"\n        pass\n\n    def _on_metadata(self, *_):\n        self._set_image()\n\n    def _cleanup_temp_files(self):\n        \"\"\"Clean up temporary artwork files.\"\"\"\n        for temp_file in self.temp_artwork_files:\n            try:\n                if os.path.exists(temp_file):\n                    os.unlink(temp_file)\n            except Exception as e:\n                logger.warning(f\"Failed to cleanup temp file {temp_file}: {e}\")\n        self.temp_artwork_files.clear()\n\n    def _on_player_exit(self, _, value):\n        self.exit = value\n        self._cleanup_temp_files()  # Clean up temp files before destroying\n        self.destroy()\n\n    def _on_player_next(self, *_):\n        self.player.next()\n\n    def _on_player_prev(self, *_):\n        self.player.previous()\n\n    def _on_playback_change(self, player, status):\n        status = player.get_property(\"playback-status\")\n\n        if status == \"paused\":\n            self.play_pause_icon.set_from_file(\n                get_relative_path(\"../../config/assets/icons/player/play.svg\")\n            )\n\n        if status == \"playing\":\n            self.play_pause_icon.set_from_file(\n                get_relative_path(\"../../config/assets/icons/player/Pause.svg\")\n            )\n            # Notify the player stack that this player started playing\n            if self.player_stack and hasattr(\n                self.player_stack, \"on_player_playback_changed\"\n            ):\n                self.player_stack.on_player_playback_changed(self, status)\n\n    def _update_image(self, image_path):\n        if image_path and os.path.isfile(image_path):\n            self.album_cover.set_style(f\"background-image:url('{image_path}')\")\n        else:\n            self.album_cover.set_style(\n                f\"background-image:url('{self.fallback_cover_path}')\"\n            )\n\n    def _set_image(self, *_):\n        art_url = self.player.arturl\n\n        parsed = urllib.parse.urlparse(art_url)\n        if parsed.scheme == \"file\":\n            local_arturl = urllib.parse.unquote(parsed.path)\n            self._update_image(local_arturl)\n        elif parsed.scheme in (\"http\", \"https\"):\n            # Cancel any existing download to prevent memory buildup\n            self._download_cancelled = True\n\n            # Use threading.Thread instead of GLib.Thread for better control\n            if self.current_download_thread and self.current_download_thread.is_alive():\n                # Thread will check _download_cancelled flag and exit early\n                pass\n\n            self._download_cancelled = False\n            self.current_download_thread = threading.Thread(\n                target=self._download_and_set_artwork,\n                args=(art_url,),\n                daemon=True,  # Dies with main thread\n            )\n            self.current_download_thread.start()\n        else:\n            self._update_image(art_url)\n\n    def _download_and_set_artwork(self, arturl):\n        \"\"\"\n        Download the artwork from the given URL asynchronously and update the cover\n        using GLib.idle_add to ensure UI updates occur on the main thread.\n        \"\"\"\n        local_arturl = self.fallback_cover_path\n        temp_file_path = None\n\n        try:\n            # Check if download was cancelled\n            if self._download_cancelled:\n                return\n\n            # Clean up old temp files first (keep only last 1 to reduce memory)\n            if len(self.temp_artwork_files) > 1:\n                old_files = self.temp_artwork_files[:-1]\n                for old_file in old_files:\n                    try:\n                        if os.path.exists(old_file):\n                            os.unlink(old_file)\n                    except Exception:\n                        pass\n                self.temp_artwork_files = self.temp_artwork_files[-1:]\n\n            # Check again if cancelled\n            if self._download_cancelled:\n                return\n\n            # Download artwork\n            parsed = urllib.parse.urlparse(arturl)\n            suffix = os.path.splitext(parsed.path)[1] or \".png\"\n\n            with urllib.request.urlopen(arturl, timeout=10) as response:  # Add timeout\n                if self._download_cancelled:\n                    return\n                data = response.read()\n\n            # Check one more time if cancelled\n            if self._download_cancelled:\n                return\n\n            # Create temp file in cache directory instead of system temp\n            os.makedirs(CACHE_DIR, exist_ok=True)\n            with tempfile.NamedTemporaryFile(\n                delete=False, suffix=suffix, dir=CACHE_DIR\n            ) as temp_file:\n                temp_file.write(data)\n                temp_file_path = temp_file.name\n                local_arturl = temp_file_path\n\n            # Track temp file for cleanup\n            if temp_file_path and not self._download_cancelled:\n                self.temp_artwork_files.append(temp_file_path)\n\n        except Exception as e:\n            if not self._download_cancelled:\n                logger.warning(f\"Failed to download artwork from {arturl}: {e}\")\n            # Clean up failed temp file\n            if temp_file_path and os.path.exists(temp_file_path):\n                try:\n                    os.unlink(temp_file_path)\n                except Exception:\n                    pass\n            return\n\n        # Only update UI if not cancelled\n        if not self._download_cancelled:\n            GLib.idle_add(self._update_image, local_arturl)\n\n    def close_bluetooth(self, *args):\n        \"\"\"Placeholder method for compatibility\"\"\"\n        pass\n"
  },
  {
    "path": "modules/controlcenter/wifi.py",
    "content": "from widgets.wifi_password_dialog import WiFiPasswordDialog\nfrom services.network import NetworkClient\nfrom fabric.widgets.scrolledwindow import ScrolledWindow\nfrom fabric.widgets.revealer import Revealer\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.svg import Svg\nfrom fabric.utils import get_relative_path\nfrom gi.repository import Gdk, GLib, Gtk\nfrom fabric.widgets.separator import Separator\nfrom utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon\nimport gi\nimport subprocess\n\ngi.require_version(\"Gtk\", \"3.0\")\ngi.require_version(\"Gdk\", \"3.0\")\n\n\nclass WifiNetworkSlot(Box):\n    def __init__(self, access_point, wifi_service, parent=None, **kwargs):\n        super().__init__(name=\"wifi-network-slot\", **kwargs)\n        self.access_point = access_point\n        self.wifi_service = wifi_service\n        self.parent = parent  # Reference to control center\n\n        # Get network info from AccessPoint object\n        self.ssid = access_point.ssid\n        self.bssid = access_point.bssid\n        self.strength = access_point.strength\n        self.icon_name = access_point.icon\n\n        # Check if this network is currently connected\n        self.is_connected = access_point.is_active\n\n        # Initialize styles based on connection state\n        self.styles = [\n            \"connected\" if self.is_connected else \"\",\n        ]\n\n        # Create connection status indicator using dynamic WiFi icon based on signal strength\n        wifi_icon_path = get_wifi_icon_for_strength(self.strength)\n        self.dimage = Svg(\n            svg_file=wifi_icon_path,\n            size=28,\n            name=\"device-icon\",\n        )\n        self.wifi_icon_box = Box(\n            children=[self.dimage],\n            style_classes=[\"wifi-icon-box\"],\n        )\n        # if self.is_connected:\n        #     self.wifi_icon_box.remove_style_class(\"wifi-icon-box\")\n        #     self.wifi_icon_box.add_style_class(\"wifi-icon-box-connected\")\n        #\n        # Set initial style classes\n        if self.is_connected:\n            self.dimage.add_style_class(\"connected\")\n\n        self.network_label = Label(\n            label=self.ssid, name=\"wifi-network-name\", h_align=\"start\", h_expand=True\n        )\n\n        # Create lock icon for secured networks\n        self.lock_icon = None\n        if self.access_point.requires_password:\n            self.lock_icon = Image(\n                icon_name=\"changes-prevent-symbolic\",\n                size=12,\n                name=\"wifi-lock-icon\",\n            )\n\n        # Initialize password dialog\n        self.password_dialog = None\n\n        # Create the start section with WiFi icon and network name\n        start_box = Box(\n            orientation=\"h\",\n            spacing=8,\n        )\n        start_box.children = [self.wifi_icon_box, self.network_label]\n        if self.lock_icon:\n            start_box.children.append(self.lock_icon)\n        # Create end section with lock icon if needed\n\n        self.children = [\n            Button(\n                child=start_box,\n                h_expand=True,\n                name=\"wifi-network-button\",\n                on_clicked=lambda *_: self.toggle_connecting(),\n            )\n        ]\n\n        # Emit initial change to update display\n        self.on_changed()\n\n    def toggle_connecting(self):\n        # Check if this network is currently connected\n        is_currently_connected = self.access_point.is_active\n\n        if is_currently_connected:\n            # Show disconnecting state using connecting icon\n            connecting_icon = get_wifi_connecting_icon()\n            self.dimage.set_from_file(connecting_icon)\n            self.dimage.add_style_class(\"disconnecting\")\n\n            # Disconnect from network\n            self.wifi_service.disconnect_wifi()\n            self.is_connected = False\n            # Remove disconnecting state after a short delay to show feedback\n            GLib.timeout_add(500, lambda: self._reset_disconnect_state())\n        else:\n            # Try to connect - check if password is required\n            if self.access_point.requires_password:\n                # Show password dialog immediately\n                self._show_password_dialog()\n            else:\n                # Try to connect without password (for open networks)\n                connecting_icon = get_wifi_connecting_icon()\n                self.dimage.set_from_file(connecting_icon)\n                self.dimage.add_style_class(\"connecting\")\n\n                def on_open_connection_result(success, message):\n                    \"\"\"Handle the connection result for open networks\"\"\"\n                    if success:\n                        self.is_connected = True\n                        # Remove connecting state after a short delay\n                        GLib.timeout_add(500, lambda: self._reset_connect_state())\n                    else:\n                        # Connection failed\n                        self._reset_connect_state()\n\n                    # Update display after connection attempt\n                    self.on_changed()\n\n                try:\n                    self.wifi_service.connect_to_wifi(\n                        self.access_point, callback=on_open_connection_result\n                    )\n                except Exception:\n                    # Handle any connection errors gracefully\n                    self._reset_connect_state()\n                    self.on_changed()\n\n        # Update display after connection attempt\n        self.on_changed()\n\n    def _reset_disconnect_state(self):\n        \"\"\"Reset visual state after disconnect operation\"\"\"\n        self.dimage.remove_style_class(\"disconnecting\")\n        wifi_icon_path = get_wifi_icon_for_strength(self.strength)\n        self.dimage.set_from_file(wifi_icon_path)\n        self.on_changed()\n        return False  # Remove timeout\n\n    def _reset_connect_state(self):\n        \"\"\"Reset visual state after connect operation\"\"\"\n        self.dimage.remove_style_class(\"connecting\")\n        wifi_icon_path = get_wifi_icon_for_strength(self.strength)\n        self.dimage.set_from_file(wifi_icon_path)\n        self.on_changed()\n        return False  # Remove timeout\n\n    def on_changed(self, *_):\n        # Check if this network is currently connected using the access point's is_active property\n        self.is_connected = self.access_point.is_active\n        self.styles = [\n            \"connected\" if self.is_connected else \"\",\n        ]\n        # Update style classes for SVG widget\n        if self.is_connected:\n            self.wifi_icon_box.add_style_class(\"wifi-icon-box-connected\")\n        else:\n            self.wifi_icon_box.remove_style_class(\"wifi-icon-box-connected\")\n        return\n\n    def _show_password_dialog(self):\n        \"\"\"Show the WiFi password dialog\"\"\"\n        # Close the control center first\n        if self.parent and hasattr(self.parent, \"hide_controlcenter\"):\n            self.parent.hide_controlcenter()\n\n        # Create a new dialog each time to ensure clean state\n        if self.password_dialog:\n            self.password_dialog.destroy_dialog()\n\n        self.password_dialog = WiFiPasswordDialog(\n            ssid=self.ssid,\n            on_connect_callback=self._on_password_connect,\n            on_cancel_callback=self._on_password_cancel,\n        )\n\n        self.password_dialog.show_dialog()\n\n    def _on_password_connect(self, ssid, password):\n        \"\"\"Handle password dialog connect action\"\"\"\n        if password.strip():\n            # Show connecting state using connecting icon\n            connecting_icon = get_wifi_connecting_icon()\n            self.dimage.set_from_file(connecting_icon)\n            self.dimage.add_style_class(\"connecting\")\n\n            # Try to connect with password using callback\n            def on_connection_result(success, message):\n                \"\"\"Handle the connection result\"\"\"\n                if success:\n                    self.is_connected = True\n                    # Remove connecting state after a short delay\n                    from gi.repository import GLib\n\n                    GLib.timeout_add(500, lambda: self._reset_connect_state())\n\n                    # Clear any timeout in the password dialog\n                    if (\n                        self.password_dialog\n                        and self.password_dialog.connection_timeout_id\n                    ):\n                        GLib.source_remove(self.password_dialog.connection_timeout_id)\n                        self.password_dialog.connection_timeout_id = None\n                        self.password_dialog.is_connecting = False\n                else:\n                    # Connection failed - show error in dialog\n                    self._reset_connect_state()\n                    if self.password_dialog:\n                        self._show_connection_error(message)\n\n                # Update display after connection attempt\n                self.on_changed()\n\n            try:\n                self.wifi_service.connect_to_wifi(\n                    self.access_point, password, callback=on_connection_result\n                )\n            except Exception:\n                # Handle any connection errors gracefully\n                self._reset_connect_state()\n                if self.password_dialog:\n                    self._show_connection_error(\"Connection failed. Please try again.\")\n                self.on_changed()\n\n    def _show_connection_error(self, message=\"Incorrect password. Please try again.\"):\n        \"\"\"Show connection error in a separate thread to prevent UI blocking\"\"\"\n        if self.password_dialog:\n            self.password_dialog.show_error(message)\n        return False  # Don't repeat if called from GLib.timeout_add\n\n    def _on_password_cancel(self):\n        \"\"\"Handle password dialog cancel action\"\"\"\n        # Reset any connecting state\n        self._reset_connect_state()\n\n\nclass WifiConnections(Box):\n    def __init__(self, parent, show_back_button=True, **kwargs):\n        super().__init__(\n            spacing=8,\n            orientation=\"vertical\",\n            name=\"wifi-connections\",\n            **kwargs,\n        )\n\n        self.parent = parent\n        self.network_service = NetworkClient()\n        self.wifi_service = None\n        self.is_scanning = False  # Track scanning state\n        self.refresh_timer = None  # Timer for periodic network refresh\n        self._update_in_progress = False  # Prevent concurrent updates\n        self._destroyed = False  # Track if widget is destroyed\n\n        # Wait for network service to be ready\n        self.network_service.connect(\"wifi-device-added\", self.on_network_ready)\n\n        # Create pull-to-refresh indicator\n        self.refresh_indicator = Label(\n            name=\"wifi-refresh-indicator\",\n            label=\"↓ Pull to scan for networks\",\n            h_align=\"center\",\n            visible=False,\n            style=\"color: #fff; font-size: 12px; padding: 5px;\",\n        )\n\n        # Create title with optional back button\n        title_children = []\n        if show_back_button:\n            title_children.append(\n                Button(\n                    image=Image(icon_name=\"back\", size=10),\n                    on_clicked=lambda *_: self.parent.close_wifi(),\n                )\n            )\n        title_children.append(Label(\"Wi-Fi\", name=\"wifi-title\"))\n\n        self.title = Box(\n            orientation=\"h\",\n            children=title_children,\n        )\n\n        self.toggle_button = Gtk.Switch(visible=True, name=\"toggle-button\")\n\n        # Create Known Network section\n        self.known_networks_label = Label(\n            label=\"Known Network\", h_align=\"start\", name=\"networks-title\"\n        )\n        self.known_networks = Box(\n            spacing=4, orientation=\"vertical\", name=\"known-networks\"\n        )\n\n        # Create \"No networks available\" message\n        self.no_networks_label = Label(\n            label=\"No networks available\",\n            h_align=\"center\",\n            name=\"no-networks-label\",\n            visible=False,\n        )\n\n        # Create Other Networks section with clickable title\n        self.other_networks_button = Button(\n            child=Label(\"Other Networks\", h_align=\"start\"),\n            name=\"wifi-other-button\",\n            on_clicked=self.toggle_other_networks,\n        )\n        self.other_networks = Box(spacing=4, orientation=\"vertical\")\n\n        # Create scrolled window for other networks\n        self.other_networks_scrolled = ScrolledWindow(\n            min_content_size=(303, 150),\n            child=self.other_networks,\n            overlay_scroll=True,\n        )\n\n        # Add pull-to-refresh functionality to scrolled window\n        self.setup_pull_to_refresh()\n\n        # Create revealer for Other Networks section\n        self.other_networks_revealer = Revealer(\n            child=self.other_networks_scrolled,\n            transition_type=\"slide-down\",\n            transition_duration=100,\n            child_revealed=False,\n        )\n\n        # Create More Settings button (same style as Other Networks button)\n        self.more_settings_button = Button(\n            child=Label(\"More Settings\", h_align=\"start\"),\n            name=\"wifi-other-button\",\n            on_clicked=self.open_network_settings,\n        )\n\n        self.children = [\n            CenterBox(\n                start_children=self.title,\n                end_children=self.toggle_button,\n                name=\"wifi-widget-top\",\n            ),\n            self.refresh_indicator,\n            Separator(orientation=\"h\", name=\"separator\"),\n            self.known_networks_label,\n            self.known_networks,\n            self.no_networks_label,\n            Separator(orientation=\"h\", name=\"separator\"),\n            self.other_networks_button,\n            self.other_networks_revealer,\n            Separator(orientation=\"h\", name=\"separator\"),\n            self.more_settings_button,\n        ]\n\n        # Connect cleanup on destroy\n        self.connect(\"destroy\", self.on_destroy)\n\n        # Start periodic network monitoring for real-time updates\n        self.start_network_monitoring()\n\n    def toggle_other_networks(self, *_):\n        \"\"\"Toggle the visibility of other networks section\"\"\"\n        current_state = self.other_networks_revealer.child_revealed\n        self.other_networks_revealer.child_revealed = not current_state\n\n        # Update button text based on state\n        if self.other_networks_revealer.child_revealed:\n            # Trigger a scan when revealing other networks and force refresh\n            if self.wifi_service:\n                self.wifi_service.scan()\n                # Also force an immediate network refresh to catch any missed connections\n                self.force_network_refresh()\n\n    def on_network_ready(self, *_):\n        \"\"\"Called when network service is ready\"\"\"\n        self.wifi_service = self.network_service.wifi_device\n        if self.wifi_service:\n            # Set up WiFi toggle\n            self.toggle_button.set_active(self.wifi_service.wireless_enabled)\n            self.toggle_button.connect(\"notify::active\", self.on_toggle_changed)\n\n            # Connect to WiFi service signals\n            self.wifi_service.connect(\n                \"notify::wireless-enabled\", self.on_wifi_enabled_changed\n            )\n            self.wifi_service.connect(\"changed\", self.update_networks)\n            self.wifi_service.connect(\"ap-added\", self.update_networks)\n            self.wifi_service.connect(\"ap-removed\", self.update_networks)\n\n            # Initial network update\n            self.update_networks()\n\n    def on_toggle_changed(self, toggle_button, *_):\n        \"\"\"Handle WiFi toggle button changes\"\"\"\n        if self.wifi_service:\n            new_state = toggle_button.get_active()\n            self.wifi_service.wireless_enabled = new_state\n\n    def on_wifi_enabled_changed(self, *_):\n        \"\"\"Handle WiFi enabled state changes\"\"\"\n        if self.wifi_service:\n            self.toggle_button.set_active(self.wifi_service.wireless_enabled)\n\n    def open_network_settings(self, *_):\n        \"\"\"Open NetworkManager connection editor\"\"\"\n        try:\n            subprocess.Popen([\"nm-connection-editor\"], start_new_session=True)\n            if self.parent and hasattr(self.parent, \"hide_controlcenter\"):\n                self.parent.hide_controlcenter()\n        except FileNotFoundError:\n            pass\n        except Exception:\n            pass\n\n    def update_networks(self, *_):\n        \"\"\"Update the list of available networks\"\"\"\n        # Prevent concurrent updates and check if destroyed\n        if self._update_in_progress or self._destroyed or not self.wifi_service:\n            return\n\n        self._update_in_progress = True\n\n        try:\n            # Store current network SSIDs to detect changes\n            current_known_ssids = {\n                child.ssid\n                for child in self.known_networks.get_children()\n                if hasattr(child, \"ssid\")\n            }\n            current_other_ssids = {\n                child.ssid\n                for child in self.other_networks.get_children()\n                if hasattr(child, \"ssid\")\n            }\n\n            # Get current networks\n            access_points = self.wifi_service.access_points\n            known_networks = []\n            other_networks = []\n            new_known_ssids = set()\n            new_other_ssids = set()\n\n            for access_point in access_points:\n                try:\n                    if access_point.ssid and access_point.ssid != \"Unknown\":\n                        # Categorize networks: connected or saved networks go to \"Known Network\"\n                        # All others go to \"Other Networks\"\n                        if access_point.is_active or self._is_saved_network(\n                            access_point\n                        ):\n                            known_networks.append(access_point)\n                            new_known_ssids.add(access_point.ssid)\n                        else:\n                            other_networks.append(access_point)\n                            new_other_ssids.add(access_point.ssid)\n                except Exception:\n                    continue\n\n            # Check if we need to update (networks added/removed)\n            known_changed = current_known_ssids != new_known_ssids\n            other_changed = current_other_ssids != new_other_ssids\n\n            # Only rebuild if something actually changed\n            if known_changed or other_changed:\n                # Clear existing networks safely\n                for child in list(self.known_networks.get_children()):\n                    if not self._destroyed:\n                        child.destroy()\n                for child in list(self.other_networks.get_children()):\n                    if not self._destroyed:\n                        child.destroy()\n\n                # Add known networks\n                for access_point in known_networks:\n                    if not self._destroyed:\n                        network_slot = WifiNetworkSlot(\n                            access_point, self.wifi_service, parent=self.parent\n                        )\n                        self.known_networks.add(network_slot)\n\n                # Add other networks\n                for access_point in other_networks:\n                    if not self._destroyed:\n                        network_slot = WifiNetworkSlot(\n                            access_point, self.wifi_service, parent=self.parent\n                        )\n                        self.other_networks.add(network_slot)\n\n            # Show/hide sections based on available networks\n            if not self._destroyed:\n                has_known_networks = len(known_networks) > 0\n                has_other_networks = len(other_networks) > 0\n                has_any_networks = has_known_networks or has_other_networks\n\n                # Show known networks section only if there are known networks\n                self.known_networks_label.set_visible(has_known_networks)\n                self.known_networks.set_visible(has_known_networks)\n\n                # Show \"No networks available\" message if no networks at all\n                self.no_networks_label.set_visible(not has_any_networks)\n\n                # Always show the other networks button, regardless of available networks\n                self.other_networks_button.set_visible(True)  # Always visible\n\n                # Update all network connection states\n                self.refresh_network_states()\n\n        except Exception:\n            pass\n        finally:\n            self._update_in_progress = False\n\n    def _is_saved_network(self, access_point):\n        \"\"\"Check if a network is saved/known using NetworkManager connections\"\"\"\n        if not self.network_service or not self.network_service._client:\n            return False\n\n        try:\n            ssid = access_point.ssid\n            if not ssid or ssid == \"Unknown\":\n                return False\n\n            # Get all saved connections from NetworkManager\n            connections = self.network_service._client.get_connections()\n\n            for connection in connections:\n                # Check if this is a WiFi connection\n                if connection.get_connection_type() != \"802-11-wireless\":\n                    continue\n\n                # Get the wireless setting\n                wifi_setting = connection.get_setting_wireless()\n                if not wifi_setting:\n                    continue\n\n                # Compare SSIDs\n                connection_ssid_bytes = wifi_setting.get_ssid()\n                if connection_ssid_bytes:\n                    from gi.repository import NM\n\n                    connection_ssid = NM.utils_ssid_to_utf8(\n                        connection_ssid_bytes.get_data()\n                    )\n                    if connection_ssid == ssid:\n                        return True\n\n        except Exception:\n            pass\n\n        return False\n\n    def refresh_network_states(self, *_):\n        \"\"\"Refresh connection states for all network slots\"\"\"\n        # Refresh known networks\n        for child in self.known_networks.get_children():\n            if hasattr(child, \"on_changed\"):\n                child.on_changed()\n\n        # Refresh other networks\n        for child in self.other_networks.get_children():\n            if hasattr(child, \"on_changed\"):\n                child.on_changed()\n\n    def start_network_monitoring(self):\n        \"\"\"Start periodic monitoring for network changes\"\"\"\n        # Monitor for network changes every 5 seconds\n        # This helps catch networks that connect from external sources\n        self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh)\n\n    def stop_network_monitoring(self):\n        \"\"\"Stop periodic monitoring\"\"\"\n        if self.refresh_timer:\n            GLib.source_remove(self.refresh_timer)\n            self.refresh_timer = None\n\n    def periodic_network_refresh(self):\n        \"\"\"Periodically refresh network list to catch external connections\"\"\"\n        # Skip if update in progress, destroyed, or wifi service not available\n        if (\n            self._update_in_progress\n            or self._destroyed\n            or not self.wifi_service\n            or not self.wifi_service.wireless_enabled\n        ):\n            return True  # Continue monitoring\n\n        try:\n            # Simple check - just trigger update_networks which has its own safety checks\n            self.update_networks()\n        except Exception:\n            pass\n\n        return True  # Continue monitoring\n\n    def force_network_refresh(self):\n        \"\"\"Force an immediate refresh of the network list\"\"\"\n        if self._update_in_progress or self._destroyed:\n            return\n\n        try:\n            # Simply trigger update_networks which has its own safety checks\n            self.update_networks()\n        except Exception:\n            pass\n\n    def setup_pull_to_refresh(self):\n        \"\"\"Setup pull-to-refresh gesture for the scrolled window\"\"\"\n        # Get the scrolled window's vertical adjustment\n        self.vadjustment = self.other_networks_scrolled.get_vadjustment()\n\n        # Track gesture state\n        self.pull_start_y = 0\n        self.is_pulling = False\n        self.pull_threshold = 50  # pixels to trigger refresh\n\n        # Connect to scroll events\n        self.other_networks_scrolled.connect(\"scroll-event\", self.on_scroll_event)\n        self.other_networks_scrolled.connect(\"button-press-event\", self.on_button_press)\n        self.other_networks_scrolled.connect(\n            \"button-release-event\", self.on_button_release\n        )\n        self.other_networks_scrolled.connect(\n            \"motion-notify-event\", self.on_motion_notify\n        )\n\n        # Enable events\n        self.other_networks_scrolled.set_events(\n            Gdk.EventMask.SCROLL_MASK\n            | Gdk.EventMask.BUTTON_PRESS_MASK\n            | Gdk.EventMask.BUTTON_RELEASE_MASK\n            | Gdk.EventMask.POINTER_MOTION_MASK\n        )\n\n    def on_scroll_event(self, widget, event):\n        \"\"\"Handle scroll events for pull-to-refresh\"\"\"\n        # Only handle pull-to-refresh when at the top\n        if self.vadjustment.get_value() <= 0:\n            if event.direction == Gdk.ScrollDirection.UP:\n                # Scrolling up at the top - trigger scan and force refresh\n                if self.wifi_service:\n                    self.wifi_service.scan()\n                    self.force_network_refresh()\n                return True  # Consume the event\n        return False  # Let normal scrolling continue\n\n    def on_button_press(self, widget, event):\n        \"\"\"Handle button press for touch/drag gestures\"\"\"\n        if self.vadjustment.get_value() <= 0:\n            self.pull_start_y = event.y\n            self.is_pulling = True\n        return False\n\n    def on_button_release(self, widget, event):\n        \"\"\"Handle button release for touch/drag gestures\"\"\"\n        if self.is_pulling:\n            pull_distance = event.y - self.pull_start_y\n            if pull_distance > self.pull_threshold:\n                # Trigger scan and force refresh\n                if self.wifi_service:\n                    self.wifi_service.scan()\n                    self.force_network_refresh()\n            # Hide refresh indicator\n            self.refresh_indicator.set_visible(False)\n            self.refresh_indicator.remove_style_class(\"ready-to-refresh\")\n            self.is_pulling = False\n        return False\n\n    def on_motion_notify(self, widget, event):\n        \"\"\"Handle motion events for visual feedback during pull\"\"\"\n        if self.is_pulling and self.vadjustment.get_value() <= 0:\n            pull_distance = event.y - self.pull_start_y\n            if pull_distance > 0:\n                # Show refresh indicator when pulling down\n                self.refresh_indicator.set_visible(True)\n                if pull_distance >= self.pull_threshold:\n                    self.refresh_indicator.set_label(\"↑ Release to scan\")\n                    self.refresh_indicator.add_style_class(\"ready-to-refresh\")\n                else:\n                    self.refresh_indicator.set_label(\"↓ Pull to scan for networks\")\n                    self.refresh_indicator.remove_style_class(\"ready-to-refresh\")\n            else:\n                self.refresh_indicator.set_visible(False)\n        return False\n\n    def on_destroy(self, widget):\n        \"\"\"Cleanup when widget is destroyed\"\"\"\n        # Mark as destroyed to prevent further updates\n        self._destroyed = True\n        # Stop monitoring\n        self.stop_network_monitoring()\n        # Make sure other networks revealer is collapsed when closing\n        try:\n            self.other_networks_revealer.child_revealed = False\n        except:\n            pass  # Widget might already be destroyed\n\n    def close_wifi(self):\n        \"\"\"Called when WiFi panel is being closed\"\"\"\n        # Collapse the other networks section when closing\n        self.other_networks_revealer.child_revealed = False\n"
  },
  {
    "path": "modules/corners.py",
    "content": "from fabric.widgets.box import Box\nfrom fabric.widgets.shapes import Corner\nfrom widgets.wayland import WaylandWindow as Window\n\n\nclass MyCorner(Box):\n    def __init__(self, corner):\n        super().__init__(\n            name=\"corner-container\",\n            children=Corner(\n                name=\"corner\",\n                orientation=corner,\n                h_expand=False,\n                v_expand=False,\n                h_align=\"center\",\n                v_align=\"center\",\n                size=20,\n            ),\n        )\n\n\nclass Corners(Window):\n    def __init__(self):\n        super().__init__(\n            name=\"corners\",\n            layer=\"bottom\",\n            anchor=\"top bottom left right\",\n            exclusivity=\"normal\",\n            # pass_through=True,\n            visible=False,\n            all_visible=False,\n        )\n\n        self.all_corners = Box(\n            name=\"all-corners\",\n            orientation=\"v\",\n            h_expand=True,\n            v_expand=True,\n            h_align=\"fill\",\n            v_align=\"fill\",\n            children=[\n                Box(\n                    name=\"top-corners\",\n                    orientation=\"h\",\n                    h_align=\"fill\",\n                    children=[\n                        MyCorner(\"top-left\"),\n                        Box(h_expand=True),\n                        MyCorner(\"top-right\"),\n                    ],\n                ),\n                Box(v_expand=True),\n                Box(\n                    name=\"bottom-corners\",\n                    orientation=\"h\",\n                    h_align=\"fill\",\n                    children=[\n                        MyCorner(\"bottom-left\"),\n                        Box(h_expand=True),\n                        MyCorner(\"bottom-right\"),\n                    ],\n                ),\n            ],\n        )\n\n        self.add(self.all_corners)\n\n        self.show_all()\n"
  },
  {
    "path": "modules/dock.py",
    "content": "import json\nimport os\nimport re\nimport subprocess\n\nfrom fabric.utils.helpers import get_desktop_applications, get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.eventbox import EventBox\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.overlay import Overlay\nfrom fabric.widgets.revealer import Revealer\nfrom gi.repository import GLib, Gtk\nfrom loguru import logger\n\nimport config.data as data\nfrom services.modus import modus_service\nfrom utils.functions import read_json_file, write_json_file, is_special_workspace_id\nfrom utils.icon_resolver import IconResolver\nfrom utils.occlusion import check_occlusion\nfrom widgets.wayland import WaylandWindow as Window\n\n# Pinned apps file\nPINNED_APPS_FILE = get_relative_path(\"../config/assets/dock.json\")\n\n\nclass AppBar(Box):\n    def __init__(self, parent: Window):\n        self.client_buttons = {}  # For running app instances\n        self.pinned_buttons = {}  # For pinned apps\n        # Position tracking for hover effects\n        self.running_items_pos = []\n        self.pinned_items_pos = []\n        self._parent = parent\n\n        # Set orientation based on dock position\n        orientation = (\n            \"vertical\" if data.DOCK_POSITION in [\"Left\", \"Right\"] else \"horizontal\"\n        )\n\n        super().__init__(\n            spacing=0,\n            name=\"dock\",\n            orientation=orientation,\n            children=[],\n        )\n        self.icon_resolver = IconResolver()\n        self._hyprland_connection = modus_service._hyprland_connection\n\n        # Initialize GTK menu\n        self.menu = Gtk.Menu()\n\n        self.pinned_apps = read_json_file(PINNED_APPS_FILE) or []\n        self.pinned_apps_container = Box()\n        self.add(self.pinned_apps_container)\n\n        self.separator = Box(\n            v_align=\"center\", style_classes=[\"hidden\", \"dock_separator\"]\n        )\n        self.add(self.separator)\n\n        self.running_apps_container = Box(name=\"dock_container\")\n        self.add(self.running_apps_container)\n\n        self._populate_pinned_apps()\n        self.setup_app_monitoring()\n\n    def setup_app_monitoring(self):\n        def update_running_apps():\n            try:\n                self.update_dock_apps()\n            except Exception as e:\n                logger.error(f\"[AppBar] Error updating apps: {e}\")\n            return True\n\n        GLib.timeout_add(250, update_running_apps)\n        GLib.idle_add(self.update_dock_apps)\n\n    def _populate_pinned_apps(self):\n        for child in self.pinned_apps_container.get_children():\n            self.pinned_apps_container.remove(child)\n\n        self.pinned_buttons = {}\n        self.pinned_items_pos = []\n\n        try:\n            desktop_apps = get_desktop_applications(include_hidden=False)\n        except Exception:\n            desktop_apps = []\n\n        for app_data in self.pinned_apps:\n            self._create_pinned_button(app_data, desktop_apps)\n\n        # Add trash icon at the end of pinned apps\n        self._create_trash_button()\n\n    def _create_pinned_button(self, app_data, desktop_apps):\n        if isinstance(app_data, dict):\n            app_identifier = app_data.get(\"name\", \"\") or app_data.get(\n                \"window_class\", \"\"\n            )\n            display_name = app_data.get(\"display_name\", app_identifier)\n            app = self._find_desktop_app_from_data(app_data, desktop_apps)\n\n            if app:\n                icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE)\n            else:\n                icon_name = app_data.get(\"window_class\", \"\") or app_data.get(\"name\", \"\")\n                icon_pixbuf = self.icon_resolver.get_icon_pixbuf(\n                    icon_name, data.DOCK_ICON_SIZE\n                )\n        else:\n            app_identifier = app_data\n            app = self._find_desktop_app_by_id(app_data, desktop_apps)\n            if not app:\n                return\n\n            display_name = app.display_name or app.name\n            icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE)\n\n        pinned_image = Image(name=\"dock_item_icon\")\n        pinned_image.set_from_pixbuf(icon_pixbuf)\n\n        main_container = Box(\n            name=\"dock_item_main_container\",\n            orientation=\"v\",\n            children=[pinned_image],\n        )\n\n        pinned_button = Button(\n            name=\"dock_item\",\n            child=main_container,\n            tooltip_text=display_name,\n            on_button_press_event=lambda _, event: self._handle_pinned_app_click(\n                event, app_data\n            ),\n            on_enter_notify_event=lambda *_: self._handle_item_hovered(\n                pinned_button, True\n            ),\n            on_leave_notify_event=lambda *_: self._handle_item_unhovered(\n                pinned_button, True\n            ),\n        )\n\n        pinned_button.add_style_class(\"shown\")\n\n        self.pinned_buttons[app_identifier] = pinned_button\n        self.pinned_apps_container.add(pinned_button)\n        self.pinned_items_pos.append(pinned_button)\n\n    def _create_trash_button(self):\n        \"\"\"Create a trash button that opens the trash in file manager\"\"\"\n        # Get trash icon\n        trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf(\n            \"user-trash\", data.DOCK_ICON_SIZE\n        )\n\n        trash_image = Image(name=\"dock_item_icon\")\n        trash_image.set_from_pixbuf(trash_icon_pixbuf)\n\n        main_container = Box(\n            name=\"dock_item_main_container\",\n            orientation=\"v\",\n            children=[trash_image],\n        )\n\n        trash_button = Button(\n            name=\"dock_item\",\n            child=main_container,\n            tooltip_text=\"Trash\",\n            on_button_press_event=lambda _, event: self._handle_trash_click(event),\n            on_enter_notify_event=lambda *_: self._handle_item_hovered(\n                trash_button, True\n            ),\n            on_leave_notify_event=lambda *_: self._handle_item_unhovered(\n                trash_button, True\n            ),\n        )\n\n        trash_button.add_style_class(\"shown\")\n        trash_button.is_trash = True\n\n        self.pinned_buttons[\"trash\"] = trash_button\n        self.pinned_apps_container.add(trash_button)\n        self.pinned_items_pos.append(trash_button)\n\n    def _find_desktop_app_from_data(self, app_data: dict, desktop_apps):\n        for app in desktop_apps:\n            if (\n                (\n                    app_data.get(\"name\")\n                    and app.name\n                    and app.name.lower() == app_data[\"name\"].lower()\n                )\n                or (\n                    app_data.get(\"window_class\")\n                    and hasattr(app, \"window_class\")\n                    and app.window_class\n                    and app.window_class.lower() == app_data[\"window_class\"].lower()\n                )\n                or (\n                    app_data.get(\"executable\")\n                    and app.executable\n                    and (\n                        app.executable.lower() == app_data[\"executable\"].lower()\n                        or os.path.basename(app.executable).lower()\n                        == os.path.basename(app_data[\"executable\"]).lower()\n                    )\n                )\n            ):\n                return app\n        return None\n\n    def _find_desktop_app_by_id(self, app_id: str, desktop_apps):\n        for app in desktop_apps:\n            if (\n                (app.name and app.name.lower() == app_id.lower())\n                or (app.display_name and app.display_name.lower() == app_id.lower())\n                or (\n                    hasattr(app, \"window_class\")\n                    and app.window_class\n                    and app.window_class.lower() == app_id.lower()\n                )\n                or (\n                    app.executable\n                    and (\n                        app.executable.lower() == app_id.lower()\n                        or os.path.basename(app.executable).lower() == app_id.lower()\n                    )\n                )\n            ):\n                return app\n        return None\n\n    def show_menu(self, app_id: str, client=None, instance_address=None):\n        for item in self.menu.get_children():\n            self.menu.remove(item)\n            item.destroy()\n\n        if client or instance_address:\n            close_item = Gtk.MenuItem(label=\"Close\")\n            if instance_address:\n                close_item.connect(\n                    \"activate\", lambda *_: self._close_running_app(instance_address)\n                )\n            self.menu.add(close_item)\n\n            if app_id:\n                separator = Gtk.SeparatorMenuItem()\n                self.menu.add(separator)\n\n        if app_id:\n            is_pinned = self._is_app_pinned(app_id)\n            pin_item = Gtk.MenuItem(label=\"Unpin\" if is_pinned else \"Pin\")\n\n            if is_pinned:\n                pin_item.connect(\"activate\", lambda *_: self._unpin_app(app_id))\n            else:\n                pin_item.connect(\"activate\", lambda *_: self._pin_app(app_id))\n\n            self.menu.add(pin_item)\n\n        self.menu.show_all()\n\n    def _close_running_app(self, instance_address):\n        try:\n            self._hyprland_connection.send_command(\n                f\"dispatch closewindow address:{instance_address}\"\n            )\n        except Exception as e:\n            logger.error(f\"[AppBar] Error closing window: {e}\")\n\n    def _handle_pinned_app_click(self, event, app_data):\n        if event.button == 1:  # Left click - launch app\n            self._launch_app_data(app_data)\n        elif event.button == 2:  # Middle click - unpin app\n            app_identifier = self._get_app_identifier(app_data)\n            self._unpin_app(app_identifier)\n        elif event.button == 3:  # Right click - show context menu\n            app_identifier = self._get_app_identifier(app_data)\n            self.show_menu(app_identifier)\n            self.menu.popup_at_pointer(event)\n\n    def _handle_trash_click(self, event):\n        \"\"\"Handle trash button click to open trash in file manager\"\"\"\n        if event.button == 1:  # Left click\n            try:\n                trash_path = os.path.expanduser(\"~/.local/share/Trash/files\")\n                file_managers = [\n                    \"nautilus\",\n                    \"dolphin\",\n                    \"thunar\",\n                    \"nemo\",\n                    \"caja\",\n                    \"pcmanfm\",\n                ]\n\n                for fm in file_managers:\n                    try:\n                        result = subprocess.run(\n                            [\"which\", fm], capture_output=True, text=True\n                        )\n                        if result.returncode == 0:\n                            subprocess.Popen([fm, trash_path])\n                            return\n                    except Exception:\n                        continue\n            except Exception as e:\n                logger.error(f\"[AppBar] Error opening trash: {e}\")\n\n    def _handle_item_hovered(self, item, pinned=False):\n        if pinned:\n            try:\n                index = self.pinned_items_pos.index(item)\n                if index > 0:\n                    self.pinned_items_pos[index - 1].add_style_class(\"semi_hovered\")\n                if index < len(self.pinned_items_pos) - 1:\n                    self.pinned_items_pos[index + 1].add_style_class(\"semi_hovered\")\n            except ValueError:\n                pass\n        else:\n            try:\n                index = self.running_items_pos.index(item)\n                if index > 0:\n                    self.running_items_pos[index - 1].add_style_class(\"semi_hovered\")\n                if index < len(self.running_items_pos) - 1:\n                    self.running_items_pos[index + 1].add_style_class(\"semi_hovered\")\n            except ValueError:\n                pass\n\n    def _handle_item_unhovered(self, item, pinned=False):\n        if pinned:\n            try:\n                index = self.pinned_items_pos.index(item)\n                if index > 0:\n                    self.pinned_items_pos[index - 1].remove_style_class(\"semi_hovered\")\n                if index < len(self.pinned_items_pos) - 1:\n                    self.pinned_items_pos[index + 1].remove_style_class(\"semi_hovered\")\n            except ValueError:\n                pass\n        else:\n            try:\n                index = self.running_items_pos.index(item)\n                if index > 0:\n                    self.running_items_pos[index - 1].remove_style_class(\"semi_hovered\")\n                if index < len(self.running_items_pos) - 1:\n                    self.running_items_pos[index + 1].remove_style_class(\"semi_hovered\")\n            except ValueError:\n                pass\n\n    def _get_app_identifier(self, app_data):\n        if isinstance(app_data, dict):\n            return app_data.get(\"name\", \"\") or app_data.get(\"window_class\", \"\")\n        return app_data\n\n    def _launch_app_data(self, app_data):\n        try:\n            desktop_apps = get_desktop_applications(include_hidden=False)\n\n            if isinstance(app_data, dict):\n                app = self._find_desktop_app_from_data(app_data, desktop_apps)\n                if app:\n                    self._launch_app(app)\n                else:\n                    self._launch_app_from_data(app_data)\n            else:\n                app = self._find_desktop_app_by_id(app_data, desktop_apps)\n                if app:\n                    self._launch_app(app)\n        except Exception as e:\n            logger.error(f\"[AppBar] Failed to launch app: {e}\")\n\n    def _launch_app(self, app):\n        try:\n            cleaned_command = re.sub(r\"%\\w+\", \"\", app.command_line).strip()\n            final_command = f\"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'\"\n            subprocess.Popen(final_command, shell=True)\n        except Exception:\n            try:\n                app.launch()\n            except Exception as fallback_error:\n                logger.error(f\"[AppBar] Failed to launch app: {fallback_error}\")\n\n    def _launch_app_from_data(self, app_data):\n        try:\n            command_line = app_data.get(\"command_line\", \"\")\n            if command_line:\n                cleaned_command = re.sub(r\"%\\w+\", \"\", command_line).strip()\n                final_command = f\"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'\"\n                subprocess.Popen(final_command, shell=True)\n            elif app_data.get(\"executable\"):\n                final_command = (\n                    f\"hyprctl dispatch exec 'uwsm app -- {app_data['executable']}'\"\n                )\n                subprocess.Popen(final_command, shell=True)\n            else:\n                logger.error(\n                    f\"[AppBar] No command or executable found for app: {app_data}\"\n                )\n        except Exception as e:\n            logger.error(f\"[AppBar] Failed to launch app from data: {e}\")\n\n    def _pin_app(self, app_class: str):\n        if self._is_app_pinned(app_class):\n            return False\n\n        try:\n            desktop_apps = get_desktop_applications(include_hidden=False)\n            app = self._find_desktop_app_by_id(app_class, desktop_apps)\n\n            if app:\n                app_data = {\n                    \"name\": app.name,\n                    \"display_name\": app.display_name or app.name,\n                    \"window_class\": getattr(app, \"window_class\", None) or app_class,\n                    \"executable\": app.executable,\n                    \"command_line\": app.command_line,\n                }\n            else:\n                app_data = {\n                    \"name\": app_class,\n                    \"display_name\": app_class,\n                    \"window_class\": app_class,\n                    \"executable\": app_class,\n                    \"command_line\": app_class,\n                }\n\n            self.pinned_apps.append(app_data)\n        except Exception:\n            self.pinned_apps.append(app_class)\n\n        write_json_file(self.pinned_apps, PINNED_APPS_FILE)\n        self._populate_pinned_apps()\n        return True\n\n    def _unpin_app(self, app_identifier: str):\n        apps_to_remove = []\n\n        for i, pinned_app in enumerate(self.pinned_apps):\n            if self._matches_app_identifier(pinned_app, app_identifier):\n                apps_to_remove.append(i)\n\n        for i in reversed(apps_to_remove):\n            self.pinned_apps.pop(i)\n\n        if apps_to_remove:\n            write_json_file(self.pinned_apps, PINNED_APPS_FILE)\n            self._populate_pinned_apps()\n            return True\n        return False\n\n    def _matches_app_identifier(self, pinned_app, app_identifier):\n        if not app_identifier:\n            return False\n\n        if isinstance(pinned_app, dict):\n            window_class = pinned_app.get(\"window_class\") or \"\"\n            name = pinned_app.get(\"name\") or \"\"\n            return (\n                window_class.lower() == app_identifier.lower()\n                or name.lower() == app_identifier.lower()\n            )\n        return (\n            isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower()\n        )\n\n    def get_clients(self):\n        try:\n            clients_data = self._hyprland_connection.send_command(\"j/clients\").reply\n            if not clients_data:\n                return []\n            return json.loads(clients_data.decode(\"utf-8\"))\n        except Exception as e:\n            logger.error(f\"[AppBar] Error getting clients: {e}\")\n            return []\n\n    def get_focused_window(self):\n        try:\n            active_data = self._hyprland_connection.send_command(\"j/activewindow\").reply\n            if not active_data:\n                return None\n            return json.loads(active_data.decode(\"utf-8\"))\n        except Exception as e:\n            logger.error(f\"[AppBar] Error getting focused window: {e}\")\n            return None\n\n    def update_dock_apps(self):\n        try:\n            clients = self.get_clients()\n            focused_window = self.get_focused_window()\n            focused_address = focused_window.get(\"address\", \"\") if focused_window else \"\"\n\n            current_instance_ids = set()\n\n            for client in clients:\n                if client.get(\"hidden\", False) or not self._should_show_app_instance(client):\n                    continue\n\n                instance_address = client.get(\"address\", \"\")\n                app_class = client.get(\"class\", \"\") or client.get(\"title\", \"\")\n                if not instance_address or not app_class:\n                    continue\n\n                current_instance_ids.add(instance_address)\n\n                if instance_address not in self.client_buttons:\n                    self.create_instance_button(instance_address, client, app_class)\n                else:\n                    self.update_instance_button(instance_address, client, app_class)\n\n                button = self.client_buttons[instance_address]\n                if instance_address == focused_address:\n                    button.add_style_class(\"activated\")\n                else:\n                    button.remove_style_class(\"activated\")\n\n            self._update_pinned_apps_state(clients)\n            self._update_separator_visibility()\n\n            self._cleanup_removed_instances(current_instance_ids)\n            \n        except Exception as e:\n            logger.error(f\"[AppBar] Error in update_dock_apps: {e}\")\n\n    def _update_pinned_apps_state(self, clients):\n        running_app_classes = {\n            client.get(\"class\", \"\").lower() or client.get(\"title\", \"\").lower()\n            for client in clients\n            if not client.get(\"hidden\", False)\n            and (client.get(\"class\") or client.get(\"title\"))\n            and self._should_show_app_instance(client)\n        }\n\n        for app_identifier, button in self.pinned_buttons.items():\n            # Skip trash button as it's not a regular app\n            if app_identifier == \"trash\" or hasattr(button, \"is_trash\"):\n                continue\n\n            if app_identifier.lower() in running_app_classes:\n                button.add_style_class(\"instance\")\n            else:\n                button.remove_style_class(\"instance\")\n\n    def _cleanup_removed_instances(self, current_instance_ids):\n        buttons_to_remove = [\n            instance_id\n            for instance_id in self.client_buttons.keys()\n            if instance_id not in current_instance_ids\n        ]\n\n        # Clean up removed and orphaned buttons\n        for instance_id in buttons_to_remove + [k for k, v in self.client_buttons.items() \n                                              if not hasattr(v, 'instance_address') or not v.get_parent()]:\n            if instance_id in self.client_buttons:\n                button = self.client_buttons.pop(instance_id)\n                try:\n                    if button in self.running_items_pos:\n                        self.running_items_pos.remove(button)\n                    button.remove_style_class(\"shown\")\n                    button.remove_style_class(\"activated\")\n                    if button.get_parent():\n                        button.get_parent().remove(button)\n                    button.destroy()\n                except Exception as e:\n                    logger.warning(f\"[AppBar] Error during cleanup: {e}\")\n\n    def create_instance_button(self, instance_address, client, app_class):\n        try:\n            client_image = Image(name=\"dock_item_icon\")\n\n            try:\n                desktop_apps = get_desktop_applications(include_hidden=False)\n                desktop_app = self._find_desktop_app_by_id(app_class, desktop_apps)\n\n                if desktop_app:\n                    pixbuf = desktop_app.get_icon_pixbuf(data.DOCK_ICON_SIZE)\n                else:\n                    pixbuf = self.icon_resolver.get_icon_pixbuf(\n                        app_class, data.DOCK_ICON_SIZE\n                    )\n\n                client_image.set_from_pixbuf(pixbuf)\n            except Exception as e:\n                logger.warning(f\"[AppBar] Could not load icon for {app_class}: {e}\")\n\n            workspace_id = self._get_workspace_id(client)\n            workspace_label = None\n            if workspace_id is not None:\n                workspace_label = Label(\n                    label=str(workspace_id),\n                    name=\"workspace-indicator\",\n                    h_align=\"end\",\n                    v_align=\"end\",\n                )\n\n            image_overlay = Overlay(name=\"dock-image-overlay\", child=client_image)\n            if workspace_label:\n                image_overlay.add_overlay(workspace_label)\n\n            indicator = Box(name=\"dock_item_indicator\", h_align=\"center\")\n            main_container = Box(\n                name=\"dock_item_main_container\",\n                orientation=\"v\",\n                children=[image_overlay, indicator],\n            )\n\n            tooltip_text = client.get(\"title\", app_class)\n            if tooltip_text != app_class:\n                tooltip_text = f\"{app_class}: {tooltip_text}\"\n\n            client_button = Button(\n                name=\"dock_item\",\n                child=main_container,\n                tooltip_text=tooltip_text,\n                on_button_press_event=lambda widget, event: self.handle_instance_click(\n                    widget, event\n                ),\n                on_enter_notify_event=lambda *_: self._handle_item_hovered(\n                    client_button, False\n                ),\n                on_leave_notify_event=lambda *_: self._handle_item_unhovered(\n                    client_button, False\n                ),\n            )\n\n            client_button.instance_address = instance_address\n            client_button.client_data = client\n            client_button.app_class = app_class\n            client_button.workspace_label = workspace_label\n            client_button.add_style_class(\"shown\")\n\n            self.client_buttons[instance_address] = client_button\n            self.running_apps_container.add(client_button)\n            self.running_items_pos.append(client_button)\n            \n        except Exception as e:\n            logger.error(f\"[AppBar] Error creating instance button for {app_class}: {e}\")\n\n    def _get_workspace_id(self, client):\n        workspace_data = client.get(\"workspace\", {})\n        if isinstance(workspace_data, dict):\n            return workspace_data.get(\"id\")\n        elif isinstance(workspace_data, (int, str)):\n            return workspace_data\n        return None\n\n    def _is_special_workspace_id(self, ws_id):\n        return is_special_workspace_id(ws_id)\n\n    def _should_show_app_instance(self, client):\n        if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS:\n            return True\n\n        workspace_id = self._get_workspace_id(client)\n        if workspace_id is None:\n            return True\n\n        return not self._is_special_workspace_id(workspace_id)\n\n    def update_instance_button(self, instance_address, client, app_class):\n        if instance_address not in self.client_buttons:\n            return\n\n        button = self.client_buttons[instance_address]\n        button.client_data = client\n        button.app_class = app_class\n\n        tooltip_text = client.get(\"title\", app_class)\n        if tooltip_text != app_class:\n            tooltip_text = f\"{app_class}: {tooltip_text}\"\n        button.set_tooltip_text(tooltip_text)\n\n        workspace_id = self._get_workspace_id(client)\n        existing_label = getattr(button, \"workspace_label\", None)\n\n        container = button.get_child()\n        if hasattr(container, \"get_children\"):\n            children = container.get_children()\n            if children:\n                image_overlay = children[0]\n                if isinstance(image_overlay, Overlay):\n                    # Remove existing workspace label\n                    if existing_label and existing_label.get_parent():\n                        image_overlay.remove_overlay(existing_label)\n\n                    # Add new workspace label if needed\n                    if workspace_id is not None:\n                        new_label = Label(\n                            label=str(workspace_id),\n                            name=\"workspace-indicator\",\n                            h_align=\"end\",\n                            v_align=\"end\",\n                        )\n                        image_overlay.add_overlay(new_label)\n                        button.workspace_label = new_label\n                    else:\n                        button.workspace_label = None\n\n    def handle_instance_click(self, button_widget, event):\n        instance_address = getattr(button_widget, \"instance_address\", None)\n        app_class = getattr(button_widget, \"app_class\", None)\n\n        if event.button == 1:  # Left click - focus window\n            if instance_address:\n                try:\n                    self._hyprland_connection.send_command(\n                        f\"dispatch focuswindow address:{instance_address}\"\n                    )\n                except Exception as e:\n                    logger.error(f\"[AppBar] Error focusing window: {e}\")\n\n        elif event.button == 2:  # Middle click - pin/unpin app\n            if app_class and not self._is_app_pinned(app_class):\n                self._pin_app(app_class)\n\n        elif event.button == 3:  # Right click - context menu\n            if app_class:\n                self.show_menu(app_class, instance_address=instance_address)\n                self.menu.popup_at_pointer(event)\n\n    def _is_app_pinned(self, app_class: str) -> bool:\n        return any(\n            self._matches_app_identifier(pinned_app, app_class)\n            for pinned_app in self.pinned_apps\n        )\n\n    def _update_separator_visibility(self):\n        has_pinned_apps = len(self.pinned_items_pos) > 0\n        has_running_apps = len(self.running_items_pos) > 0\n        if has_pinned_apps and has_running_apps:\n            self.separator.remove_style_class(\"hidden\")\n        else:\n            self.separator.add_style_class(\"hidden\")\n\n\nclass Dock(Window):\n    def __init__(self):\n        if not data.DOCK_ENABLED:\n            anchor = self._get_anchor_from_position()\n            super().__init__(layer=\"top\", title=\"dock\", anchor=anchor)\n            self.children = Box()  # Empty dock if disabled\n            return\n\n        anchor = self._get_anchor_from_position()\n        super().__init__(layer=\"top\", anchor=anchor)\n\n        self.app_bar = AppBar(self)\n\n        transition_type = self._get_transition_type()\n\n        self.revealer = Revealer(\n            child=Box(children=[self.app_bar], style=\"padding: 20px 50px 5px 50px;\"),\n            transition_duration=200,\n            transition_type=transition_type,\n        )\n\n        self.children = EventBox(\n            events=[\"enter-notify\", \"leave-notify\"],\n            child=Box(style=\"min-height: 1px\", children=self.revealer),\n            on_enter_notify_event=lambda *_: self.on_hover_enter(),\n            on_leave_notify_event=lambda *_: self.on_hover_leave(),\n        )\n\n        self.revealer.set_reveal_child(True)\n        self.app_bar.add_style_class(\"shown\")\n\n        self.dock_height = 100\n        self.is_hovered = False\n        self.hide_timeout_id = None\n\n        # Only setup occlusion monitoring if auto-hide is enabled\n        if data.DOCK_AUTO_HIDE:\n            self.setup_occlusion_monitoring()\n\n    def on_hover_enter(self):\n        self.is_hovered = True\n        if self.hide_timeout_id:\n            GLib.source_remove(self.hide_timeout_id)\n            self.hide_timeout_id = None\n        self.revealer.set_reveal_child(True)\n        self.app_bar.add_style_class(\"shown\")\n\n    def on_hover_leave(self):\n        self.is_hovered = False\n        # Add small delay before potential hiding to prevent rapid show/hide cycles\n        if self.hide_timeout_id:\n            GLib.source_remove(self.hide_timeout_id)\n        self.hide_timeout_id = GLib.timeout_add(100, lambda: None)\n\n    def _get_anchor_from_position(self):\n        if data.DOCK_POSITION == \"Left\":\n            return \"left center\"\n        elif data.DOCK_POSITION == \"Right\":\n            return \"right center\"\n        else:  # Bottom (default)\n            return \"bottom center\"\n\n    def _get_transition_type(self):\n        if data.DOCK_POSITION == \"Left\":\n            return \"slide-right\"\n        elif data.DOCK_POSITION == \"Right\":\n            return \"slide-left\"\n        else:  # Bottom (default)\n            return \"slide-up\"\n\n    def _get_occlusion_position(self):\n        if data.DOCK_POSITION == \"Left\":\n            return (\"left\", self.dock_height)\n        elif data.DOCK_POSITION == \"Right\":\n            return (\"right\", self.dock_height)\n        else:  # Bottom (default)\n            return (\"bottom\", self.dock_height)\n\n    def setup_occlusion_monitoring(self):\n        def check_dock_occlusion():\n            try:\n                if data.DOCK_ALWAYS_OCCLUDED:\n                    is_occluded = True\n                else:\n                    occlusion_position = self._get_occlusion_position()\n                    is_occluded = check_occlusion(occlusion_position)\n\n                if (\n                    is_occluded\n                    and not self.is_hovered\n                    and self.revealer.get_reveal_child()\n                ):\n                    self.revealer.set_reveal_child(False)\n                    self.app_bar.remove_style_class(\"shown\")\n                elif not is_occluded and not self.revealer.get_reveal_child():\n                    self.revealer.set_reveal_child(True)\n                    self.app_bar.add_style_class(\"shown\")\n                elif is_occluded and self.is_hovered:\n                    if not self.revealer.get_reveal_child():\n                        self.revealer.set_reveal_child(True)\n                    self.app_bar.add_style_class(\"shown\")\n            except Exception as e:\n                logger.error(f\"[Dock] Occlusion check error: {e}\")\n\n            return True\n\n        GLib.timeout_add(300, check_dock_occlusion)\n"
  },
  {
    "path": "modules/launcher/__init__.py",
    "content": "\"\"\"\nPlugin-based launcher module for Fabric.\nSimilar to Albert Launcher with extensible plugin system.\n\"\"\"\n\nfrom .main import Launcher\n\n__all__ = [\"Launcher\"]\n"
  },
  {
    "path": "modules/launcher/main.py",
    "content": "from typing import List, Optional, Tuple\n\nfrom gi.repository import Gdk, GLib\n\nfrom fabric.core.service import Property\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.entry import Entry\nfrom modules.launcher.plugin_manager import PluginManager\nfrom modules.launcher.result import Result\nfrom modules.launcher.result_item import ResultItem\nfrom modules.launcher.trigger_config import TriggerConfig\nfrom fabric.widgets.scrolledwindow import ScrolledWindow\nfrom widgets.wayland import WaylandWindow as Window\n\n# Constants\nSEARCH_DEBOUNCE_MS = 50\nTRIGGER_SEARCH_DEBOUNCE_MS = 50\nCURSOR_POSITION_DELAY_MS = 10\nSCROLL_PADDING = 10\nDEFAULT_ITEM_HEIGHT = 68\nPAGE_NAVIGATION_STEP = 5\nLAUNCHER_WIDTH = 640\nLAUNCHER_HEIGHT = 400\n\n\nclass Launcher(Window):\n    \"\"\"\n    Main launcher window with search functionality and plugin system.\n    Spotlight-style interface.\n    \"\"\"\n\n    # Properties\n    query = Property(str, flags=\"read-write\", default_value=\"\")\n    visible = Property(bool, flags=\"read-write\", default_value=False)\n    active_trigger = Property(str, flags=\"read-write\", default_value=\"\")\n\n    def __init__(self, **kwargs):\n        super().__init__(\n            name=\"launcher-window\",\n            title=\"modus-launcher\",\n            layer=\"top\",\n            anchor=\"center\",\n            exclusivity=\"none\",\n            keyboard_mode=\"exclusive\",\n            visible=False,  # Start hidden until explicitly shown\n            **kwargs,\n        )\n        self._initializing = True\n        self._auto_adding_space = False\n        self._processing_backspace = False\n        self.plugin_manager = PluginManager()\n        self.trigger_config = TriggerConfig()\n\n        self.results: List[Result] = []\n        self.selected_index = 0\n        self.max_results = 5  # Show 4 applications by default instead of list\n\n        # Trigger system\n        self.triggered_plugin = None  # Currently active triggered plugin\n        self.active_trigger = \"\"  # Currently active trigger keyword\n        self.query = \"\"  # Current search query\n        self.visible = False  # Launcher visibility state\n        self.opened_with_trigger = False  # Whether launcher was opened with a trigger\n\n        # Focus management\n        self.focus_mode = \"search\"  # \"search\", \"results\"\n\n        # Mouse activity tracking\n        self._mouse_active = False\n        self._last_mouse_activity = 0\n        self._mouse_activity_timeout = 1000  # 1 second timeout\n        self._keyboard_used_recently = False\n        self._last_keyboard_activity = 0\n        self._keyboard_activity_timeout = 2000  # 2 second timeout\n        self._launcher_just_opened = False\n        self._mouse_interaction_delay = 500  # 500ms delay after opening\n\n        # Setup UI\n        main_box = Box(\n            name=\"launcher\",\n            orientation=\"v\",\n            spacing=0,\n            h_align=\"center\",\n            v_align=\"center\",\n        )\n        self.add(main_box)\n\n        # Search entry with Spotlight-style large text\n        self.search_entry = Entry(\n            name=\"launcher-search\",\n            placeholder=\"Spotlight Search\",\n            h_expand=True,\n            h_align=\"fill\",\n            notify_text=lambda entry, *_: self._on_search_changed(entry),\n        )\n        self.search_entry.connect(\"changed\", self._on_search_changed)\n        self.search_entry.connect(\"activate\", self._on_entry_activate)\n\n        self.header_box = Box(\n            name=\"header_box\",\n            spacing=10,\n            orientation=\"h\",\n            children=[\n                self.search_entry,\n            ],\n        )\n\n        main_box.add(self.header_box)\n\n        self.results_scroll = ScrolledWindow(\n            name=\"launcher-results-scroll\",\n            h_scrollbar_policy=\"never\",\n            min_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),\n            max_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),\n            propagate_width=False,\n            propagate_height=False,\n        )\n\n        self.results_box = Box(\n            name=\"launcher-results\",\n            orientation=\"v\",\n            spacing=0,\n        )\n        self.results_scroll.add(self.results_box)\n        main_box.add(self.results_scroll)\n\n        # Keep the results container visible initially\n\n        self.connect(\"key-press-event\", self._on_key_press)\n\n        # Connect mouse events to track activity\n        self.results_scroll.connect(\"button-press-event\", self._on_mouse_activity)\n        self.results_scroll.connect(\"motion-notify-event\", self._on_mouse_activity)\n        self.results_scroll.connect(\"scroll-event\", self._on_mouse_activity)\n\n        # Also track mouse activity on the main window for better coverage\n        self.connect(\"button-press-event\", self._on_mouse_activity)\n        self.connect(\"motion-notify-event\", self._on_mouse_activity)\n\n        self.hide()\n\n        # Mark initialization as complete\n        self._initializing = False\n\n        # Hide trigger suggestions at startup\n        self._clear_results()\n\n    def show_launcher(self, trigger_keyword: str = None, external: bool = False):\n        \"\"\"Show the launcher and focus the search entry, or execute command externally.\n\n        Args:\n            trigger_keyword: Optional trigger keyword to activate immediately (e.g., \"google\", \"calc\", \"app\")\n            external: If True, execute the command without showing the launcher UI\n        \"\"\"\n        # Reset mouse activity tracking when launcher is shown\n        self._mouse_active = False\n        self._last_mouse_activity = 0\n        self._keyboard_used_recently = False\n        self._last_keyboard_activity = 0\n        self._launcher_just_opened = True\n\n        # Schedule to allow mouse interactions after a delay\n        def enable_mouse_interactions():\n            self._launcher_just_opened = False\n            return False\n\n        GLib.timeout_add(self._mouse_interaction_delay, enable_mouse_interactions)\n\n        if external and trigger_keyword:\n            # Execute command externally without showing launcher\n            return self._execute_external_command(trigger_keyword)\n\n        self.show_all()\n\n        if trigger_keyword:\n            # Set flag to track that launcher was opened with a trigger keyword\n            self.opened_with_trigger = True\n\n            # Set the trigger keyword with a space and activate trigger mode\n            trigger_text = f\"{trigger_keyword} \"\n            self.search_entry.set_text(trigger_text)\n\n            # Detect and activate the trigger\n            triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)\n            if triggered_plugin:\n                self.triggered_plugin = triggered_plugin\n                self.active_trigger = detected_trigger\n\n                # Query the plugin with empty string to show default options\n                try:\n                    results = triggered_plugin.query(\"\")\n                    self.results = results\n                    self.selected_index = 0\n                    self._update_results_display()\n                except Exception as e:\n                    print(\n                        f\"Error querying triggered plugin {triggered_plugin.name}: {e}\"\n                    )\n                    self._clear_results()\n            else:\n                # Trigger not found, clear and show error or fallback\n                self.search_entry.set_text(\"\")\n                self._clear_results()\n        else:\n            # Normal launcher opening - show applications\n            self.opened_with_trigger = False\n            self.search_entry.set_text(\"\")\n            # Trigger initial search to show applications immediately\n            self._perform_search(\"\")\n\n        # Reset focus mode to search\n        self.focus_mode = \"search\"\n\n        # Focus search entry without selecting text\n        if trigger_keyword:\n            # For trigger keywords, we want the cursor at the end\n            self.search_entry.grab_focus()\n\n            def position_cursor():\n                if hasattr(self.search_entry, \"set_position\"):\n                    self.search_entry.set_position(-1)  # Move caret to end\n                return False  # Only run once\n\n            GLib.idle_add(position_cursor)\n        else:\n            # For normal opening, use our method that prevents text selection\n            self._focus_search_entry_without_selection()\n\n        self.visible = True\n\n    def _position_cursor_at_end(self, text_length: Optional[int] = None) -> None:\n        \"\"\"Position cursor at the end of search entry text.\"\"\"\n        if text_length is None:\n            text_length = len(self.search_entry.get_text())\n\n        def position_cursor():\n            if hasattr(self.search_entry, \"set_position\"):\n                self.search_entry.set_position(-1)  # Move caret to end\n            if hasattr(self.search_entry, \"select_region\"):\n                self.search_entry.select_region(\n                    text_length, text_length\n                )  # No selection\n            return False  # Only run once\n\n        GLib.idle_add(position_cursor)\n\n    def _add_space_to_trigger(self, trigger_word: str) -> None:\n        \"\"\"Add space after trigger keyword and position cursor.\"\"\"\n        trigger_text_with_space = f\"{trigger_word} \"\n\n        # Temporarily disable search change handling to prevent recursion\n        self._auto_adding_space = True\n        self.search_entry.set_text(trigger_text_with_space)\n\n        # Position cursor at the end\n        def position_cursor():\n            if hasattr(self.search_entry, \"set_position\"):\n                self.search_entry.set_position(-1)  # Move caret to end\n            if hasattr(self.search_entry, \"select_region\"):\n                self.search_entry.select_region(\n                    len(trigger_text_with_space), len(trigger_text_with_space)\n                )  # No selection\n            self._auto_adding_space = False\n            return False  # Only run once\n\n        GLib.idle_add(position_cursor)\n\n        # Update query\n        self.query = trigger_text_with_space\n        return trigger_text_with_space\n\n    def close_launcher(self):\n        \"\"\"Hide the launcher and clear search.\"\"\"\n        self.hide()\n        self.search_entry.set_text(\"\")\n        self._clear_results()\n        self.triggered_plugin = None\n        self.active_trigger = \"\"\n        self.visible = False\n        self.opened_with_trigger = False\n\n    def _on_search_changed(self, entry):\n        \"\"\"Handle search text changes.\"\"\"\n        # Skip if still initializing\n        if getattr(self, \"_initializing\", True):\n            return\n\n        # Skip if we're automatically adding a space to prevent recursion\n        if getattr(self, \"_auto_adding_space\", False):\n            return\n\n        # Skip if we're processing a backspace to prevent interference\n        if getattr(self, \"_processing_backspace\", False):\n            return\n\n        # Reset focus to search when user types\n        if self.focus_mode != \"search\":\n            self.focus_mode = \"search\"\n\n        query = entry.get_text().strip()\n        self.query = query\n\n        # If query is exactly ':', show all triggers\n        if query == \":\":\n            self._show_available_triggers()\n        # If query matches a trigger exactly (with or without space), handle trigger activation\n        elif any(\n            query == trig or query == f\"{trig} \"\n            for trig in [\n                t.strip()\n                for p in self.plugin_manager.get_active_plugins()\n                for t in p.get_triggers()\n            ]\n        ):\n            # Check if we need to add space immediately for exact trigger matches\n            if not query.endswith(\" \"):\n                # This is an exact trigger match without space - add space immediately\n                trigger_text_with_space = self._add_space_to_trigger(query)\n                GLib.timeout_add(\n                    TRIGGER_SEARCH_DEBOUNCE_MS,\n                    self._perform_search,\n                    trigger_text_with_space,\n                )\n            else:\n                # Already has space, proceed with normal search\n                GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)\n        elif query:\n            # Debounce search to avoid too many queries\n            GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)\n        else:\n            # Show applications when query is empty\n            self._perform_search(\"\")\n\n    def _perform_search(self, query: str) -> bool:\n        \"\"\"Perform search across all plugins.\"\"\"\n        # Only search if query hasn't changed\n        if query != self.query:\n            return False\n\n        if not query:\n            # Empty query - show popular applications\n            self.triggered_plugin = None\n            self.active_trigger = \"\"\n\n            # Get applications plugin and show popular apps\n            applications_plugin = self._get_applications_plugin()\n            if applications_plugin:\n                try:\n                    # Query with empty string to get popular/frequently used applications\n                    all_results = applications_plugin.query(\"\")\n                    # Limit to max_results for empty query\n                    self.results = all_results[: self.max_results]\n                    self.selected_index = 0\n                    self._update_results_display()\n                except Exception as e:\n                    print(f\"Error getting applications: {e}\")\n                    self._clear_results()\n            else:\n                self._clear_results()\n            return False\n\n        # Check if we're already in trigger mode\n        if self.triggered_plugin and self.active_trigger:\n            # We're in trigger mode - search within the triggered plugin\n            try:\n                # Extract the search query after the trigger\n                remaining_query = self._extract_query_after_trigger(\n                    query, self.active_trigger\n                )\n                all_results = self.triggered_plugin.query(remaining_query)\n            except Exception as e:\n                print(f\"Error in triggered plugin {self.triggered_plugin.name}: {e}\")\n                all_results = []\n        else:\n            # Check for trigger activation\n            triggered_plugin, trigger = self._detect_trigger(query)\n\n            if triggered_plugin:\n                # New trigger detected - check if we need to add space automatically\n                trigger_word = trigger.strip()\n                current_text = self.search_entry.get_text()\n\n                # If the current text is exactly the trigger word (no space), add space automatically\n                if (\n                    current_text.strip() == trigger_word\n                    and not current_text.endswith(\" \")\n                    and not getattr(self, \"_auto_adding_space\", False)\n                ):\n                    query = self._add_space_to_trigger(trigger_word)\n\n                # Enter trigger mode\n                self.triggered_plugin = triggered_plugin\n                self.active_trigger = trigger\n\n                # Extract search query after trigger\n                remaining_query = self._extract_query_after_trigger(query, trigger)\n\n                # Always call the plugin's query method, even with empty remaining query\n                # This allows plugins to show default options when just the trigger is typed\n                try:\n                    all_results = triggered_plugin.query(remaining_query)\n                except Exception as e:\n                    print(f\"Error in triggered plugin {triggered_plugin.name}: {e}\")\n                    all_results = []\n            else:\n                # No trigger detected - search applications and show trigger suggestions\n                self.triggered_plugin = None\n                self.active_trigger = \"\"\n\n                all_results = []\n\n                # Search applications directly without trigger\n                applications_plugin = self._get_applications_plugin()\n                if applications_plugin:\n                    try:\n                        app_results = applications_plugin.query(query)\n                        all_results.extend(app_results)\n                    except Exception as e:\n                        print(f\"Error searching applications: {e}\")\n\n                # Also show trigger suggestions if query matches trigger prefixes\n                trigger_suggestions = self._get_trigger_suggestions(query)\n                all_results.extend(trigger_suggestions)\n\n        # Sort results by relevance score\n        all_results.sort(key=lambda r: r.relevance, reverse=True)\n\n        # Check if any results have bypass_max_results flag\n        has_bypass = any(\n            hasattr(r, \"data\") and r.data and r.data.get(\"bypass_max_results\")\n            for r in all_results\n        )\n\n        # Don't limit results for triggered plugin queries, only for global searches and trigger suggestions\n        if self.triggered_plugin and self.active_trigger:\n            # In trigger mode - show all results from the triggered plugin\n            self.results = all_results\n        elif not has_bypass:\n            # Global search - check if it's an application search\n            applications_plugin = self._get_applications_plugin()\n            is_application_search = applications_plugin and any(\n                hasattr(r, \"plugin_name\") and r.plugin_name == \"Applications\"\n                for r in all_results\n            )\n\n            if is_application_search:\n                # Don't limit application search results\n                self.results = all_results\n            else:\n                # Apply max_results limit for other global searches and trigger suggestions\n                self.results = all_results[: self.max_results]\n        else:\n            # Has bypass flag - show all results\n            self.results = all_results\n        self.selected_index = 0\n\n        # Update UI\n        self._update_results_display()\n\n        return False  # Don't repeat timeout\n\n    def _extract_query_after_trigger(self, query: str, trigger: str) -> str:\n        \"\"\"\n        Extract the search query after removing the trigger.\n\n        Args:\n            query: The full query string\n            trigger: The trigger keyword\n\n        Returns:\n            The remaining query after the trigger\n        \"\"\"\n        if not query or not trigger:\n            return \"\"\n\n        query_lower = query.lower()\n        trigger_lower = trigger.lower()\n\n        # Handle trigger with space (e.g., \"app \")\n        if trigger_lower.endswith(\" \") and query_lower.startswith(trigger_lower):\n            return query[len(trigger) :].strip()\n\n        # Handle trigger without space (e.g., \"app\")\n        trigger_word = trigger.strip().lower()\n        if query_lower.startswith(trigger_word):\n            # Check if it's followed by space or end of string\n            if len(query) == len(trigger_word):\n                return \"\"\n            elif len(query) > len(trigger_word) and query[len(trigger_word)] == \" \":\n                return query[len(trigger_word) + 1 :].strip()\n            elif len(query) > len(trigger_word):\n                # No space after trigger word, extract rest\n                return query[len(trigger_word) :].strip()\n\n        return \"\"\n\n    def _show_available_triggers(self):\n        \"\"\"Show available triggers when launcher is first opened.\"\"\"\n        trigger_suggestions = self._get_trigger_suggestions(\"\")\n        self.results = trigger_suggestions  # Show all available triggers without limit\n        self.selected_index = 0\n        self._update_results_display()\n\n    def _detect_trigger(self, query: str) -> Tuple[Optional[object], str]:\n        \"\"\"\n        Detect if query starts with a trigger keyword.\n\n        Args:\n            query: The search query\n\n        Returns:\n            Tuple of (plugin, trigger) if triggered, (None, \"\") otherwise\n        \"\"\"\n        if not query.strip():\n            return None, \"\"\n\n        # Check all plugins for triggers\n        for plugin in self.plugin_manager.get_active_plugins():\n            trigger = plugin.get_active_trigger(query)\n            if trigger:\n                return plugin, trigger\n\n        return None, \"\"\n\n    def _get_applications_plugin(self):\n        \"\"\"Get the applications plugin instance.\"\"\"\n        for plugin in self.plugin_manager.get_active_plugins():\n            if (\n                hasattr(plugin, \"display_name\")\n                and plugin.display_name == \"Applications\"\n            ):\n                return plugin\n        return None\n\n    def _get_trigger_suggestions(self, query: str) -> List[Result]:\n        \"\"\"\n        Get trigger suggestions based on the current query.\n\n        Args:\n            query: The search query\n\n        Returns:\n            List of Result objects showing available triggers\n        \"\"\"\n        suggestions = []\n        query_lower = query.lower().strip()\n\n        # Get all available triggers from plugins\n        all_triggers = {}\n        for plugin in self.plugin_manager.get_active_plugins():\n            triggers = plugin.get_triggers()\n            for trigger in triggers:\n                trigger_clean = trigger.strip()\n                if trigger_clean not in all_triggers:\n                    all_triggers[trigger_clean] = {\n                        \"plugin\": plugin,\n                        \"trigger\": trigger,\n                        \"examples\": [],\n                    }\n\n        # Get max examples to show from configuration\n        max_examples = self.trigger_config.settings.get(\"max_examples_shown\", 2)\n\n        # Show trigger suggestions based on query\n        if query_lower:\n            # Show triggers that match the query\n            for trigger_clean, _ in all_triggers.items():\n                if trigger_clean.lower().startswith(query_lower):\n                    result = self._create_trigger_result(trigger_clean, max_examples)\n                    suggestions.append(result)\n        else:\n            # Empty query - show all available triggers\n            for trigger_clean, _ in all_triggers.items():\n                result = self._create_trigger_result(trigger_clean, max_examples)\n                suggestions.append(result)\n\n        return suggestions  # Return all trigger suggestions without limit\n\n    def _create_trigger_result(self, trigger_clean: str, max_examples: int) -> Result:\n        \"\"\"Create a Result object for a trigger suggestion.\"\"\"\n        examples = self.trigger_config.get_trigger_examples(trigger_clean)\n        icon_name = self.trigger_config.get_trigger_icon(trigger_clean)\n        description = self.trigger_config.get_trigger_description(trigger_clean)\n\n        return Result(\n            title=f\"{trigger_clean}\",\n            subtitle=f\"{description} - {', '.join(examples[:max_examples])}\",\n            icon_name=icon_name,\n            action=lambda t=trigger_clean: self._activate_trigger(t),\n            # Shorter triggers get higher relevance\n            relevance=100 - len(trigger_clean),\n            data={\"type\": \"trigger_suggestion\", \"trigger\": trigger_clean},\n        )\n\n    def _activate_trigger(self, trigger: str):\n        \"\"\"\n        Activate a trigger by setting it in the search entry.\n\n        Args:\n            trigger: The trigger keyword to activate\n        \"\"\"\n        # Set the trigger text in the search entry\n        trigger_text = f\"{trigger} \"\n        self.search_entry.set_text(trigger_text)\n        self.search_entry.grab_focus()\n\n        def clear_selection():\n            if hasattr(self.search_entry, \"set_position\"):\n                self.search_entry.set_position(-1)  # Move caret to end\n            if hasattr(self.search_entry, \"select_region\"):\n                self.search_entry.select_region(\n                    len(trigger_text), len(trigger_text)\n                )  # No selection\n            return False  # Only run once\n\n        GLib.idle_add(clear_selection)\n\n        # Manually set the trigger mode to avoid search processing issues\n        triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)\n        if triggered_plugin:\n            self.triggered_plugin = triggered_plugin\n            self.active_trigger = detected_trigger\n\n            # Clear results and show trigger ready state\n            self.results = []\n            self.selected_index = 0\n            self._update_results_display()\n\n        # Focus back to the search entry for immediate typing\n        self.search_entry.grab_focus()\n\n        # Don't hide the launcher - user should be able to continue typing\n\n    def _execute_external_command(self, command_string: str):\n        \"\"\"Execute a command externally without showing the launcher UI.\n\n        Args:\n            command_string: Full command string (e.g., \"wall random\", \"calc 2+2\")\n\n        Returns:\n            Result of the command execution or None if failed\n        \"\"\"\n        try:\n            # Parse the command to extract trigger and query\n            parts = command_string.strip().split(\" \", 1)\n            if not parts:\n                return None\n\n            trigger_part = parts[0]\n            query_part = parts[1] if len(parts) > 1 else \"\"\n\n            # Find the plugin that handles this trigger\n            triggered_plugin = None\n\n            for plugin in self.plugin_manager.get_active_plugins():\n                trigger = plugin.get_active_trigger(f\"{trigger_part} \")\n                if trigger:\n                    triggered_plugin = plugin\n                    break\n\n            if not triggered_plugin:\n                print(f\"No plugin found for trigger: {trigger_part}\")\n                return None\n\n            # Query the plugin with the remaining query\n            try:\n                results = triggered_plugin.query(query_part)\n                if not results:\n                    print(f\"No results found for query: {query_part}\")\n                    return None\n\n                # Find the first result that matches the query exactly or has highest relevance\n                best_result = None\n                for result in results:\n                    # For exact matches like \"random\", execute immediately\n                    if (\n                        hasattr(result, \"data\")\n                        and result.data\n                        and result.data.get(\"action\") == query_part.strip()\n                    ):\n                        best_result = result\n                        break\n                    # For partial matches, take the first high-relevance result\n                    elif not best_result and result.relevance >= 0.9:\n                        best_result = result\n\n                # If no exact match, take the first result\n                if not best_result and results:\n                    best_result = results[0]\n\n                if best_result:\n                    # Execute the result action\n                    try:\n                        result_value = best_result.activate()\n                        print(f\"External command executed: {command_string}\")\n                        return result_value\n                    except Exception as e:\n                        print(f\"Error executing result action: {e}\")\n                        return None\n                else:\n                    print(f\"No suitable result found for: {command_string}\")\n                    return None\n\n            except Exception as e:\n                print(f\"Error querying plugin {triggered_plugin.name}: {e}\")\n                return None\n\n        except Exception as e:\n            print(f\"Error executing external command '{command_string}': {e}\")\n            return None\n\n    def _update_results_display(self):\n        \"\"\"Update the results display.\"\"\"\n        # Skip if still initializing or results_box not ready\n        if getattr(self, \"_initializing\", True) or not hasattr(self, \"results_box\"):\n            return\n\n        # Update input field with trigger indication (Spotlight-style)\n        self._update_input_action_text()\n\n        # Clear existing results\n        for child in self.results_box.get_children():\n            self.results_box.remove(child)\n\n        # Add new results\n        for i, result in enumerate(self.results):\n            # Check if this result has a custom widget\n            if result.custom_widget:\n                # Ensure the widget is not already parented\n                parent = result.custom_widget.get_parent()\n                if parent:\n                    parent.remove(result.custom_widget)\n\n                result.custom_widget.show_all()  # Ensure widget is visible\n                self.results_box.add(result.custom_widget)\n            else:\n                # Create normal result item\n                result_item = ResultItem(\n                    result=result, selected=(i == self.selected_index), index=i\n                )\n                result_item.clicked.connect(\n                    lambda _, idx=i: self._on_result_clicked(result_item, idx)\n                )\n                result_item.hovered.connect(\n                    lambda _, idx=i: self._on_result_hovered(idx)\n                )\n                self.results_box.add(result_item)\n\n        self.results_box.show_all()\n\n        self.results_scroll.show()\n\n    def _update_input_action_text(self):\n        \"\"\"Update the input field with action text (Spotlight-style).\"\"\"\n        # Check if search_entry is initialized\n        if not hasattr(self, \"search_entry\") or self.search_entry is None:\n            return\n\n        # Get the current input text\n        current_text = self.search_entry.get_text()\n\n        # Spotlight-style placeholder text\n        if self.triggered_plugin:\n            if self.results:\n                # Show more minimal placeholder for triggered mode\n                self.search_entry.set_placeholder_text(\n                    f\"Search {self.active_trigger.strip()}...\"\n                )\n            else:\n                # In trigger mode but no results yet\n                if current_text == self.active_trigger.strip():\n                    # Just the trigger keyword\n                    self.search_entry.set_placeholder_text(\n                        f\"Search {self.active_trigger.strip()}...\"\n                    )\n                else:\n                    # Searching within trigger\n                    self.search_entry.set_placeholder_text(\n                        f\"Searching {self.active_trigger.strip()}...\"\n                    )\n        else:\n            # Not in trigger mode - show Spotlight-style help\n            if current_text == \":\":\n                self.search_entry.set_placeholder_text(\"Available search triggers\")\n            else:\n                self.search_entry.set_placeholder_text(\"Spotlight Search\")\n\n    def _clear_results(self):\n        \"\"\"Clear all results.\"\"\"\n        self.results = []\n        self.selected_index = 0\n        for child in self.results_box.get_children():\n            self.results_box.remove(child)\n        # Keep the results scroll visible even when empty\n\n    def _handle_escape_key(self) -> bool:\n        \"\"\"Handle escape key press.\"\"\"\n        # First check if there's a password entry widget that should handle Escape\n        password_entry_widget = self._find_password_entry_widget()\n        if password_entry_widget:\n            # Cancel the password entry\n            password_entry_widget.cancel_password_entry()\n            return True\n        elif self.opened_with_trigger:\n            # If launcher was opened with a trigger keyword, close directly\n            self.close_launcher()\n            return True\n        else:\n            # Hide launcher\n            self.close_launcher()\n            return True\n\n    def _handle_backspace_key(self) -> bool:\n        \"\"\"Handle backspace key press in trigger mode.\"\"\"\n        if self.triggered_plugin:\n            trigger_text = self.active_trigger.strip()\n            current_text = self.search_entry.get_text()\n\n            # Set flag to prevent search change handler from interfering\n            self._processing_backspace = True\n\n            # Allow normal backspace behavior first, then check if we need to exit trigger mode\n            # Don't intercept the backspace - let GTK handle it normally\n\n            # Schedule a check after the backspace is processed\n            def check_trigger_after_backspace():\n                try:\n                    # Get the text after backspace has been processed\n                    new_text = self.search_entry.get_text()\n\n                    # Check if we should exit trigger mode\n                    # We need to check if the text still matches the trigger pattern\n                    should_exit_trigger = False\n\n                    # If the active trigger ends with a space (like \"calc \"),\n                    # we should exit if the text doesn't contain that space anymore\n                    if self.active_trigger.endswith(\" \"):\n                        # For triggers like \"calc \", exit if text is just \"calc\" or doesn't start with \"calc \"\n                        trigger_with_space = self.active_trigger.lower()\n                        if not new_text.lower().startswith(trigger_with_space):\n                            should_exit_trigger = True\n                    else:\n                        # For triggers without space, exit if text doesn't start with trigger\n                        if not new_text.lower().startswith(trigger_text.lower()):\n                            should_exit_trigger = True\n\n                    if should_exit_trigger:\n                        self.triggered_plugin = None\n                        self.active_trigger = \"\"\n                        self._clear_results()\n                        # Don't clear the text - let the user's edit stand\n\n                    # If we're still in trigger mode but the text changed, update the search\n                    elif self.triggered_plugin and new_text != current_text:\n                        # Trigger a search with the new text\n                        self.query = new_text\n                        GLib.timeout_add(50, self._perform_search, new_text)\n\n                finally:\n                    # Clear the backspace processing flag\n                    self._processing_backspace = False\n\n                return False  # Don't repeat\n\n            # Use idle_add to check after the backspace is processed\n            GLib.idle_add(check_trigger_after_backspace)\n\n            # Allow the normal backspace to proceed\n            return False\n\n        # Let normal backspace behavior continue for other cases\n        return False\n\n    def _on_key_press(self, _widget, event):\n        \"\"\"Handle key press events.\"\"\"\n        # Track keyboard activity\n        import time\n\n        current_time = int(time.time() * 1000)\n        self._last_keyboard_activity = current_time\n        self._keyboard_used_recently = True\n\n        keyval = event.keyval\n\n        # Escape - handle password entry, exit trigger mode, or hide launcher\n        if keyval == Gdk.KEY_Escape:\n            return self._handle_escape_key()\n\n        # Backspace - handle trigger mode backspace behavior\n        if keyval == Gdk.KEY_BackSpace:\n            return self._handle_backspace_key()\n\n        # Up/Down - navigate results (alternative to Tab)\n        if keyval == Gdk.KEY_Up:\n            if self.focus_mode == \"results\" and self.results:\n                if self.selected_index > 0:\n                    # Move to previous result\n                    self.selected_index -= 1\n                    self._update_selection()\n                else:\n                    # At first result, go back to search entry\n                    self.focus_mode = \"search\"\n                    self._focus_search_entry_without_selection()\n            elif self.results:\n                # If not in results mode but have results, enter results mode at last item\n                self.focus_mode = \"results\"\n                self.selected_index = len(self.results) - 1\n                self._update_selection()\n            return True\n\n        if keyval == Gdk.KEY_Down:\n            if self.focus_mode == \"results\" and self.results:\n                if self.selected_index < len(self.results) - 1:\n                    # Move to next result\n                    self.selected_index += 1\n                    self._update_selection()\n                else:\n                    # At last result, wrap around to first result\n                    self.selected_index = 0\n                    self._update_selection()\n            elif self.results:\n                # If not in results mode but have results, enter results mode at first item\n                self.focus_mode = \"results\"\n                self.selected_index = 0\n                self._update_selection()\n            return True\n\n        # Enter - activate selected result\n        if keyval == Gdk.KEY_Return:\n            # Check for Shift+Enter for alternative actions\n            if event.state & Gdk.ModifierType.SHIFT_MASK:\n                if self.results and 0 <= self.selected_index < len(self.results):\n                    result = self.results[self.selected_index]\n                    if result.data:\n                        # Check for generic alternative action first\n                        if result.data.get(\"alt_action\"):\n                            result.data[\"alt_action\"]()\n                            return True\n                        # Fallback to pin_action for backward compatibility\n                        elif result.data.get(\"pin_action\"):\n                            result.data[\"pin_action\"]()\n                            return True\n\n            # Check if the selected result has a custom widget with Entry fields\n            if self.results and 0 <= self.selected_index < len(self.results):\n                result = self.results[self.selected_index]\n                if result.custom_widget:\n                    # Check if the custom widget contains Entry widgets that should handle Enter\n                    if self._custom_widget_has_entry(result.custom_widget):\n                        # Let the custom widget handle the Enter key\n                        # Find the focused Entry widget and trigger its activate signal\n                        focused_entry = self._find_focused_entry_in_widget(\n                            result.custom_widget\n                        )\n                        if focused_entry:\n                            focused_entry.emit(\"activate\")\n                            return True\n\n            # Normal Enter behavior\n            self._activate_selected()\n            return True\n\n        # Tab - cycle through focus areas and results\n        if keyval == Gdk.KEY_Tab:\n            if event.state & Gdk.ModifierType.SHIFT_MASK:\n                # Shift+Tab - reverse direction\n                if self.focus_mode == \"results\":\n                    # Navigate through results in reverse\n                    self._navigate_results_backward()\n                else:\n                    self._cycle_focus_backward()\n            else:\n                # Tab - forward direction\n                if self.focus_mode == \"results\":\n                    # Navigate through results forward\n                    self._navigate_results_forward()\n                else:\n                    self._cycle_focus_forward()\n            return True\n\n        # Page Up/Page Down - navigate results faster\n        if keyval == Gdk.KEY_Page_Up:\n            if self.results:\n                self.selected_index = max(0, self.selected_index - PAGE_NAVIGATION_STEP)\n                self._update_selection()\n            return True\n\n        if keyval == Gdk.KEY_Page_Down:\n            if self.results:\n                self.selected_index = min(\n                    len(self.results) - 1, self.selected_index + PAGE_NAVIGATION_STEP\n                )\n                self._update_selection()\n            return True\n\n        # Home/End - go to first/last result\n        if keyval == Gdk.KEY_Home:\n            if self.results:\n                self.selected_index = 0\n                self._update_selection()\n            return True\n\n        if keyval == Gdk.KEY_End:\n            if self.results:\n                self.selected_index = len(self.results) - 1\n                self._update_selection()\n            return True\n\n        # Forward other keys to custom widgets if they can handle them\n        if self.results and 0 <= self.selected_index < len(self.results):\n            result = self.results[self.selected_index]\n            if result.custom_widget and hasattr(result.custom_widget, \"on_key_press\"):\n                # Try to forward the key event to the custom widget\n                if result.custom_widget.on_key_press(result.custom_widget, event):\n                    return True\n\n        return False\n\n    def _custom_widget_has_entry(self, widget):\n        \"\"\"Check if a custom widget contains Entry widgets.\"\"\"\n\n        if isinstance(widget, Entry):\n            return True\n\n        # Check children recursively\n        if hasattr(widget, \"get_children\"):\n            for child in widget.get_children():\n                if self._custom_widget_has_entry(child):\n                    return True\n\n        return False\n\n    def _find_focused_entry_in_widget(self, widget):\n        \"\"\"Find the focused Entry widget within a custom widget.\"\"\"\n\n        if isinstance(widget, Entry):\n            # Try multiple ways to check if this Entry is focused\n            try:\n                if widget.has_focus() or widget.is_focus():\n                    return widget\n                # Also check if this is the only Entry in the widget (likely to be the target)\n                return widget\n            except:\n                # If focus checking fails, assume this Entry should handle the event\n                return widget\n\n        # Check children recursively\n        if hasattr(widget, \"get_children\"):\n            for child in widget.get_children():\n                focused_entry = self._find_focused_entry_in_widget(child)\n                if focused_entry:\n                    return focused_entry\n\n        return None\n\n    def _find_password_entry_widget(self):\n        \"\"\"Find a NetworkPasswordEntry widget in the current results.\"\"\"\n        for result in self.results:\n            if result.custom_widget:\n                # Check if this is a NetworkPasswordEntry widget\n                if (\n                    hasattr(result.custom_widget, \"__class__\")\n                    and result.custom_widget.__class__.__name__\n                    == \"NetworkPasswordEntry\"\n                ):\n                    return result.custom_widget\n                # Also check if it has the cancel_password_entry method (duck typing)\n                elif hasattr(result.custom_widget, \"cancel_password_entry\"):\n                    return result.custom_widget\n        return None\n\n    def _on_entry_activate(self, _entry):\n        \"\"\"Handle entry activation (Enter key).\"\"\"\n        self._activate_selected()\n\n    def _on_result_clicked(self, _result_item, index):\n        \"\"\"Handle result item click.\"\"\"\n        # Only allow clicks when mouse is active or keyboard hasn't been used recently\n        if self._should_allow_mouse_interaction():\n            self.selected_index = index\n            self._activate_selected()\n\n    def _on_result_hovered(self, index):\n        \"\"\"Handle result item hover.\"\"\"\n        # Only allow hover selection when mouse is active and keyboard hasn't been used recently\n        if self._should_allow_mouse_interaction():\n            if 0 <= index < len(self.results):\n                self.selected_index = index\n                self.focus_mode = \"results\"  # Switch to results focus mode\n                self._update_selection_visual_only()\n\n    def _on_mouse_activity(self, widget, event):\n        \"\"\"Track mouse activity to determine when mouse interactions should be enabled.\"\"\"\n        import time\n\n        current_time = int(time.time() * 1000)\n        self._last_mouse_activity = current_time\n        self._mouse_active = True\n\n        # Schedule a check to disable mouse activity after timeout\n        def check_mouse_timeout():\n            if current_time - self._last_mouse_activity >= self._mouse_activity_timeout:\n                self._mouse_active = False\n            return False\n\n        GLib.timeout_add(self._mouse_activity_timeout, check_mouse_timeout)\n        return False\n\n    def _should_allow_mouse_interaction(self):\n        \"\"\"Determine if mouse interactions should be allowed.\"\"\"\n        import time\n\n        current_time = int(time.time() * 1000)\n\n        # Don't allow mouse interactions immediately after launcher opens\n        if self._launcher_just_opened:\n            return False\n\n        # Allow mouse interaction if:\n        # 1. Mouse is currently active (recent activity)\n        # 2. OR keyboard hasn't been used recently (2 seconds)\n        mouse_recently_active = (\n            current_time - self._last_mouse_activity\n        ) < self._mouse_activity_timeout\n        keyboard_not_recently_used = (\n            current_time - self._last_keyboard_activity\n        ) > self._keyboard_activity_timeout\n\n        return mouse_recently_active or keyboard_not_recently_used\n\n    def _is_mouse_over_results(self):\n        \"\"\"Check if mouse is over the results area.\"\"\"\n        # Simple check - if we have results visible, assume user might be interacting\n        return len(self.results) > 0 and self.results_scroll.get_visible()\n\n    def _update_selection(self):\n        \"\"\"Update the visual selection of results with scrolling (for keyboard navigation).\"\"\"\n        children = self.results_box.get_children()\n        selected_widget = None\n\n        for i, child in enumerate(children):\n            if isinstance(child, ResultItem):\n                is_selected = i == self.selected_index\n                child.set_selected(is_selected)\n                if is_selected:\n                    selected_widget = child\n            # For custom widgets, we don't need to handle selection visually\n            # since they manage their own interaction\n\n        # Focus custom widgets when selected for keyboard interaction\n        if self.results and 0 <= self.selected_index < len(self.results):\n            result = self.results[self.selected_index]\n            if result.custom_widget and result.custom_widget.get_can_focus():\n                # Give focus to the custom widget for keyboard interaction\n                result.custom_widget.grab_focus()\n\n        # Scroll to make the selected item visible\n        if selected_widget and self.results_scroll.get_visible():\n            # Use immediate scrolling for better responsiveness\n            self._scroll_to_widget(selected_widget)\n            # Also schedule a more accurate scroll after layout is complete\n            GLib.idle_add(self._ensure_selected_visible)\n\n    def _update_selection_visual_only(self):\n        \"\"\"Update the visual selection of results without scrolling (for mouse hover).\"\"\"\n        children = self.results_box.get_children()\n\n        for i, child in enumerate(children):\n            if isinstance(child, ResultItem):\n                is_selected = i == self.selected_index\n                child.set_selected(is_selected)\n            # For custom widgets, we don't need to handle selection visually\n            # since they manage their own interaction\n\n        # Focus custom widgets when selected for keyboard interaction\n        if self.results and 0 <= self.selected_index < len(self.results):\n            result = self.results[self.selected_index]\n            if result.custom_widget and result.custom_widget.get_can_focus():\n                # Give focus to the custom widget for keyboard interaction\n                result.custom_widget.grab_focus()\n\n    def _scroll_to_widget(self, widget):\n        \"\"\"Scroll the results container to make the widget visible.\"\"\"\n        if not widget or not self.results_scroll.get_visible():\n            return\n\n        # Debug output (can be removed in production)\n        # print(f\"Scrolling to selected index: {self.selected_index}\")\n\n        # Get the scrolled window's vertical adjustment\n        vadjustment = self.results_scroll.get_vadjustment()\n        if not vadjustment:\n            return\n\n        # Use a simpler approach: scroll to the selected item index\n        if self.results and 0 <= self.selected_index < len(self.results):\n            # Get all children to work with actual widgets\n            children = self.results_box.get_children()\n            if not children or self.selected_index >= len(children):\n                return\n\n            # Get the selected child widget\n            selected_child = children[self.selected_index]\n\n            # Try to get actual allocation, fallback to estimation\n            try:\n                allocation = selected_child.get_allocation()\n                item_height = allocation.height if allocation.height > 0 else 68\n                item_y = allocation.y\n            except:\n                # Fallback to estimation\n                item_height = DEFAULT_ITEM_HEIGHT\n                item_y = self.selected_index * item_height\n\n            # Get current scroll info\n            current_scroll = vadjustment.get_value()\n            page_size = vadjustment.get_page_size()\n            max_scroll = vadjustment.get_upper() - page_size\n\n            # Calculate visible area\n            visible_top = current_scroll\n            visible_bottom = current_scroll + page_size\n\n            # Check if selected item is visible\n            item_top = item_y\n            item_bottom = item_y + item_height\n\n            # Add some padding for better visibility\n            # Scroll if needed\n            if item_top < visible_top + SCROLL_PADDING:\n                # Item is above visible area - scroll up\n                new_scroll = max(0, item_top - SCROLL_PADDING)\n                vadjustment.set_value(new_scroll)\n            elif item_bottom > visible_bottom - SCROLL_PADDING:\n                # Item is below visible area - scroll down\n                new_scroll = min(max_scroll, item_bottom - page_size + SCROLL_PADDING)\n                vadjustment.set_value(new_scroll)\n\n    def _ensure_selected_visible(self):\n        \"\"\"Alternative method to ensure selected item is visible using GTK methods.\"\"\"\n        if (\n            not self.results\n            or self.selected_index < 0\n            or self.selected_index >= len(self.results)\n        ):\n            return False\n\n        children = self.results_box.get_children()\n        if self.selected_index < len(children):\n            selected_child = children[self.selected_index]\n\n            # Try to use widget's allocation for more accurate scrolling\n            try:\n                allocation = selected_child.get_allocation()\n                if allocation.height > 0:\n                    vadjustment = self.results_scroll.get_vadjustment()\n                    if vadjustment:\n                        # Calculate the position to center the selected item\n                        page_size = vadjustment.get_page_size()\n                        target_pos = (\n                            allocation.y - (page_size / 2) + (allocation.height / 2)\n                        )\n                        target_pos = max(\n                            0, min(target_pos, vadjustment.get_upper() - page_size)\n                        )\n                        vadjustment.set_value(target_pos)\n            except Exception as e:\n                print(f\"Error in _ensure_selected_visible: {e}\")\n\n        return False  # Don't repeat the idle callback\n\n    def _cycle_focus_forward(self):\n        \"\"\"Cycle focus forward: search -> results\"\"\"\n        if self.focus_mode == \"search\":\n            if self.results:\n                self.focus_mode = \"results\"\n                self._update_selection()\n\n    def _cycle_focus_backward(self):\n        \"\"\"Cycle focus backward: results -> search\"\"\"\n        if self.focus_mode == \"results\":\n            self.focus_mode = \"search\"\n            self._focus_search_entry_without_selection()\n\n    def _focus_search_entry_without_selection(self):\n        \"\"\"Focus search entry and position cursor at end without selecting text.\"\"\"\n        # First grab focus\n        self.search_entry.grab_focus()\n\n        # Use multiple approaches to prevent text selection\n        def clear_selection():\n            try:\n                text_length = len(self.search_entry.get_text())\n\n                # Method 1: Set position and clear selection\n                if hasattr(self.search_entry, \"set_position\"):\n                    self.search_entry.set_position(text_length)\n                if hasattr(self.search_entry, \"select_region\"):\n                    self.search_entry.select_region(text_length, text_length)\n\n                # Method 2: Try to access underlying GTK widget\n                try:\n                    # For fabric Entry widgets, try to get the actual GTK Entry\n                    if hasattr(self.search_entry, \"_entry\"):\n                        gtk_entry = self.search_entry._entry\n                    elif (\n                        hasattr(self.search_entry, \"get_children\")\n                        and self.search_entry.get_children()\n                    ):\n                        gtk_entry = self.search_entry.get_children()[0]\n                    else:\n                        gtk_entry = self.search_entry\n\n                    if hasattr(gtk_entry, \"set_position\"):\n                        gtk_entry.set_position(text_length)\n                    if hasattr(gtk_entry, \"select_region\"):\n                        gtk_entry.select_region(text_length, text_length)\n\n                except Exception:\n                    pass\n\n            except Exception as e:\n                print(f\"Could not clear selection: {e}\")\n            return False\n\n        # Schedule clearing selection after focus is established\n        GLib.idle_add(clear_selection)\n        # Also try with a small delay as backup\n        GLib.timeout_add(CURSOR_POSITION_DELAY_MS, clear_selection)\n\n    def _navigate_results_forward(self):\n        \"\"\"Navigate to next result or wrap around to first result.\"\"\"\n        if self.results and self.selected_index < len(self.results) - 1:\n            # Move to next result\n            self.selected_index += 1\n            self._update_selection()\n        else:\n            # At last result, wrap around to first result\n            self.selected_index = 0\n            self._update_selection()\n\n    def _navigate_results_backward(self):\n        \"\"\"Navigate to previous result or exit results mode.\"\"\"\n        if self.results and self.selected_index > 0:\n            # Move to previous result\n            self.selected_index -= 1\n            self._update_selection()\n        else:\n            # Exit results mode and go to search\n            self.focus_mode = \"search\"\n            self._focus_search_entry_without_selection()\n\n    def _activate_selected(self):\n        \"\"\"Activate the currently selected result.\"\"\"\n        if self.results and 0 <= self.selected_index < len(self.results):\n            result = self.results[self.selected_index]\n            try:\n                # Check if this result has a custom widget\n                if result.custom_widget:\n                    # For custom widgets, we don't activate them since they're already displayed\n                    # The widget handles its own interactions\n                    return\n\n                # Check if this is a trigger suggestion or should keep launcher open\n                is_trigger_suggestion = (\n                    result.data and result.data.get(\"type\") == \"trigger_suggestion\"\n                )\n                keep_launcher_open = result.data and result.data.get(\n                    \"keep_launcher_open\", False\n                )\n\n                # Activate the result\n                result.activate()\n\n                # Only hide launcher if it's not a trigger suggestion and doesn't have keep_launcher_open flag\n                if not is_trigger_suggestion and not keep_launcher_open:\n                    self.close_launcher()\n                # For trigger suggestions and keep_launcher_open actions, the launcher stays open\n            except Exception as e:\n                print(f\"Error activating result: {e}\")\n"
  },
  {
    "path": "modules/launcher/plugin_base.py",
    "content": "from abc import ABC, abstractmethod\nfrom typing import List\n\nfrom modules.launcher.result import Result\n\n\nclass PluginBase(ABC):\n    \"\"\"\n    Abstract base class for launcher plugins.\n    All plugins must inherit from this class.\n    \"\"\"\n\n    def __init__(self):\n        self.name = self.__class__.__name__.lower()\n        self.display_name = self.__class__.__name__\n        self.description = \"A launcher plugin\"\n        self.version = \"1.0.0\"\n        self.enabled = True\n        self._triggers = []  # List of trigger keywords\n\n    @abstractmethod\n    def initialize(self):\n        \"\"\"\n        Initialize the plugin.\n        Called when the plugin is activated.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def cleanup(self):\n        \"\"\"\n        Cleanup the plugin.\n        Called when the plugin is deactivated.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"\n        Process a search query and return results.\n\n        Args:\n            query_string: The search query from the user\n\n        Returns:\n            List of Result objects\n        \"\"\"\n        pass\n\n    def get_triggers(self) -> List[str]:\n        \"\"\"\n        Get list of trigger keywords for this plugin.\n        If the query starts with any of these, this plugin gets priority.\n\n        Returns:\n            List of trigger strings (e.g., [\"calc\", \"=\", \"math\"])\n        \"\"\"\n        return self._triggers\n\n    def set_triggers(self, triggers: List[str]):\n        \"\"\"\n        Set the trigger keywords for this plugin.\n\n        Args:\n            triggers: List of trigger keywords\n        \"\"\"\n        self._triggers = triggers\n\n    def handles_query(self, query_string: str) -> bool:\n        \"\"\"\n        Check if this plugin should handle the given query.\n\n        Args:\n            query_string: The search query\n\n        Returns:\n            True if this plugin should process the query\n        \"\"\"\n        if not self.enabled:\n            return False\n\n        # Check triggers\n        triggers = self.get_triggers()\n        if triggers:\n            query_lower = query_string.lower().strip()\n            return any(query_lower.startswith(trigger.lower()) for trigger in triggers)\n\n        # Default: handle all queries\n        return True\n\n    def get_active_trigger(self, query_string: str) -> str:\n        \"\"\"\n        Get the active trigger for the given query.\n\n        Args:\n            query_string: The search query\n\n        Returns:\n            The trigger keyword if found, empty string otherwise\n        \"\"\"\n        if not self.enabled:\n            return \"\"\n\n        triggers = self.get_triggers()\n        if triggers:\n            query_lower = query_string.lower().strip()\n\n            # Sort triggers by length (longest first) to match more specific triggers first\n            sorted_triggers = sorted(triggers, key=len, reverse=True)\n\n            for trigger in sorted_triggers:\n                trigger_lower = trigger.lower()\n\n                # Exact match with trigger (including space if present)\n                if query_lower.startswith(trigger_lower):\n                    return trigger\n\n                # Match trigger word followed by space\n                trigger_word = trigger.strip().lower()\n                if (\n                    query_lower.startswith(trigger_word + \" \")\n                    or query_lower == trigger_word\n                ):\n                    return trigger\n\n        return \"\"\n\n    def query_triggered(self, query_string: str, trigger: str) -> List[Result]:\n        \"\"\"\n        Process a triggered query (when plugin is in sticky mode).\n        Default implementation removes trigger and calls query().\n\n        Args:\n            query_string: The full query string including trigger\n            trigger: The trigger that activated this plugin\n\n        Returns:\n            List of Result objects\n        \"\"\"\n        # Remove trigger from query and process remaining text\n        remaining_query = query_string[len(trigger) :].strip()\n        return self.query(remaining_query)\n\n    def get_config(self) -> dict:\n        \"\"\"\n        Get plugin configuration.\n\n        Returns:\n            Dictionary of configuration options\n        \"\"\"\n        return {\n            \"name\": self.name,\n            \"display_name\": self.display_name,\n            \"description\": self.description,\n            \"version\": self.version,\n            \"enabled\": self.enabled,\n        }\n\n    def set_config(self, config: dict):\n        \"\"\"\n        Set plugin configuration.\n\n        Args:\n            config: Dictionary of configuration options\n        \"\"\"\n        self.enabled = config.get(\"enabled\", self.enabled)\n\n    def __str__(self):\n        return f\"Plugin({self.name})\"\n\n    def __repr__(self):\n        return self.__str__()\n"
  },
  {
    "path": "modules/launcher/plugin_manager.py",
    "content": "import importlib\nimport importlib.util\nimport os\nfrom typing import Dict, List, Type\n\nfrom modules.launcher.plugin_base import PluginBase\n\n\nclass PluginManager:\n    \"\"\"\n    Manages launcher plugins.\n    \"\"\"\n\n    def __init__(self):\n        self.plugins: Dict[str, PluginBase] = {}\n        self.plugin_classes: Dict[str, Type[PluginBase]] = {}\n        self.active_plugins: List[str] = []\n\n        # Load built-in plugins\n        self._load_builtin_plugins()\n\n        # Load external plugins\n        self._load_external_plugins()\n\n        # Activate default plugins\n        self._activate_default_plugins()\n\n    def _load_builtin_plugins(self):\n        \"\"\"Load built-in plugins from the plugins directory.\"\"\"\n        plugins_dir = os.path.join(os.path.dirname(__file__), \"plugins\")\n\n        if not os.path.exists(plugins_dir):\n            return\n\n        for filename in os.listdir(plugins_dir):\n            if filename.endswith(\".py\") and not filename.startswith(\"_\"):\n                plugin_name = filename[:-3]  # Remove .py extension\n                self._load_plugin_from_file(plugins_dir, plugin_name)\n\n    def _load_external_plugins(self):\n        \"\"\"Load external plugins from user directory.\"\"\"\n        # Could be implemented to load from ~/.config/launcher/plugins/\n        pass\n\n    def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str):\n        \"\"\"Load a plugin from a Python file.\"\"\"\n        try:\n            plugin_path = os.path.join(plugins_dir, f\"{plugin_name}.py\")\n            spec = importlib.util.spec_from_file_location(\n                f\"modules.launcher.plugins.{plugin_name}\", plugin_path\n            )\n\n            if spec and spec.loader:\n                module = importlib.util.module_from_spec(spec)\n\n                # Add the module to sys.modules to support relative imports\n                import sys\n\n                sys.modules[f\"modules.launcher.plugins.{plugin_name}\"] = module\n\n                spec.loader.exec_module(module)\n\n                # Look for plugin class\n                for attr_name in dir(module):\n                    attr = getattr(module, attr_name)\n                    if (\n                        isinstance(attr, type)\n                        and issubclass(attr, PluginBase)\n                        and attr != PluginBase\n                    ):\n                        self.plugin_classes[plugin_name] = attr\n                        break\n\n        except Exception as e:\n            print(f\"Failed to load plugin {plugin_name}: {e}\")\n\n    def _activate_default_plugins(self):\n        \"\"\"Activate default plugins.\"\"\"\n        default_plugins = [\n            \"applications\",\n            \"calculator\",\n            \"system\",\n            \"clipboard\",\n            \"power\",\n            \"caffeine\",\n            \"screencapture\",\n            \"emoji\",\n            \"wallpaper\",\n            \"websearch\",\n            \"reminders\",\n            \"otp\",\n            \"password\",\n            \"bookmarks\",\n            \"bash_scripts\",\n            \"tmux\",\n        ]\n\n        for plugin_name in default_plugins:\n            self.activate_plugin(plugin_name)\n\n    def activate_plugin(self, plugin_name: str) -> bool:\n        \"\"\"Activate a plugin by name.\"\"\"\n        if plugin_name in self.plugins:\n            # Already activated\n            return True\n\n        if plugin_name not in self.plugin_classes:\n            return False\n\n        try:\n            # Instantiate plugin\n            plugin_class = self.plugin_classes[plugin_name]\n            plugin_instance = plugin_class()\n\n            # Initialize plugin\n            plugin_instance.initialize()\n\n            # Store plugin\n            self.plugins[plugin_name] = plugin_instance\n            self.active_plugins.append(plugin_name)\n\n            return True\n\n        except Exception as e:\n            print(f\"Failed to activate plugin {plugin_name}: {e}\")\n            return False\n\n    def deactivate_plugin(self, plugin_name: str) -> bool:\n        \"\"\"Deactivate a plugin by name.\"\"\"\n        if plugin_name not in self.plugins:\n            return False\n\n        try:\n            # Cleanup plugin\n            plugin = self.plugins[plugin_name]\n            plugin.cleanup()\n\n            # Remove from active plugins\n            del self.plugins[plugin_name]\n            if plugin_name in self.active_plugins:\n                self.active_plugins.remove(plugin_name)\n\n            return True\n\n        except Exception as e:\n            print(f\"Failed to deactivate plugin {plugin_name}: {e}\")\n            return False\n\n    def get_active_plugins(self) -> List[PluginBase]:\n        \"\"\"Get list of active plugin instances.\"\"\"\n        return [\n            self.plugins[name] for name in self.active_plugins if name in self.plugins\n        ]\n\n    def get_plugin_names(self) -> List[str]:\n        \"\"\"Get list of available plugin names.\"\"\"\n        return list(self.plugin_classes.keys())\n\n    def get_active_plugin_names(self) -> List[str]:\n        \"\"\"Get list of active plugin names.\"\"\"\n        return self.active_plugins.copy()\n\n    def reload_plugin(self, plugin_name: str) -> bool:\n        \"\"\"Reload a plugin.\"\"\"\n        was_active = plugin_name in self.active_plugins\n\n        # Deactivate if active\n        if was_active:\n            self.deactivate_plugin(plugin_name)\n\n        # Remove from classes\n        if plugin_name in self.plugin_classes:\n            del self.plugin_classes[plugin_name]\n\n        # Reload from file\n        plugins_dir = os.path.join(os.path.dirname(__file__), \"plugins\")\n        self._load_plugin_from_file(plugins_dir, plugin_name)\n\n        # Reactivate if it was active\n        if was_active and plugin_name in self.plugin_classes:\n            return self.activate_plugin(plugin_name)\n\n        return plugin_name in self.plugin_classes\n"
  },
  {
    "path": "modules/launcher/plugins/__init__.py",
    "content": "\"\"\"\nBuilt-in plugins for the launcher.\n\"\"\"\n"
  },
  {
    "path": "modules/launcher/plugins/applications.py",
    "content": "import json\nimport re\nfrom typing import List\nimport subprocess\n\nfrom fabric.utils import DesktopApp\nfrom fabric.utils.helpers import get_desktop_applications, get_relative_path\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\nfrom utils.roam import modus_service\n\n\nclass ApplicationsPlugin(PluginBase):\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Applications\"\n        self.description = \"Search and launch desktop applications\"\n\n    def initialize(self):\n        pass\n\n    def cleanup(self):\n        pass\n\n    def _pin_application(self, app):\n        \"\"\"Pin an application to the dock.\"\"\"\n        config_path = get_relative_path(\"../../../config/assets/dock.json\")\n        try:\n            with open(config_path, \"r\") as file:\n                pinned_apps = json.load(file)\n        except (FileNotFoundError, json.JSONDecodeError):\n            pinned_apps = []\n\n        # Handle legacy format (dict with \"pinned_apps\" key) and convert to new format (simple list)\n        if isinstance(pinned_apps, dict):\n            pinned_apps = pinned_apps.get(\"pinned_apps\", [])\n\n        # Check if app is already pinned (by name/app_id)\n        app_id = app.name  # Use app.name as the identifier\n        if app_id not in pinned_apps:\n            pinned_apps.append(app_id)\n\n            # Save the updated list\n            with open(config_path, \"w\") as file:\n                json.dump(pinned_apps, file, indent=4)\n\n            # Notify dock about the change via modus_service\n            if modus_service:\n                try:\n                    dock_apps_json = json.dumps(pinned_apps)\n                    modus_service.dock_apps = dock_apps_json\n                except Exception as e:\n                    print(f\"Failed to notify dock about pinned app change: {e}\")\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Search applications based on query.\"\"\"\n        if not query_string.strip():\n            return self._get_all_applications()\n\n        try:\n            applications = get_desktop_applications(include_hidden=False)\n        except Exception as e:\n            print(f\"Failed to load applications: {e}\")\n            applications = []\n\n        query = query_string.lower().strip()\n        results = []\n\n        for app in applications:\n            relevance = self._calculate_relevance(app, query)\n            if relevance > 0:\n                description = app.description or app.generic_name or \"\"\n                if len(description) > 80:\n                    description = description[:70] + \"...\"\n\n                result = Result(\n                    title=app.display_name or app.name,\n                    subtitle=description,\n                    icon=app.get_icon_pixbuf(size=48),\n                    action=lambda a=app: self._launch_application(a),\n                    relevance=relevance,\n                    plugin_name=self.display_name,\n                    data={\n                        \"app\": app,\n                        \"pin_action\": lambda a=app: self._pin_application(a),\n                    },\n                )\n                results.append(result)\n\n        return results\n\n    def _calculate_relevance(self, app, query: str) -> float:\n        \"\"\"Calculate relevance score for an application.\"\"\"\n        if not query:\n            return 0.0\n\n        # Get searchable text\n        name = (app.name or \"\").lower()\n        display_name = (app.display_name or \"\").lower()\n        description = (app.description or \"\").lower()\n        generic_name = (app.generic_name or \"\").lower()\n        executable = (app.executable or \"\").lower()\n\n        # Exact matches get highest score\n        if query == name or query == display_name:\n            return 1.0\n\n        # Starts with matches get high score\n        if (\n            name.startswith(query)\n            or display_name.startswith(query)\n            or generic_name.startswith(query)\n        ):\n            return 0.9\n\n        # Contains matches get medium score\n        if (\n            query in name\n            or query in display_name\n            or query in description\n            or query in generic_name\n        ):\n            return 0.7\n\n        # Fuzzy matching for partial matches\n        if self._fuzzy_match(query, name) or self._fuzzy_match(query, display_name):\n            return 0.5\n\n        # Executable name matching\n        if executable and query in executable:\n            return 0.4\n\n        return 0.0\n\n    def _fuzzy_match(self, query: str, text: str) -> bool:\n        \"\"\"Simple fuzzy matching algorithm.\"\"\"\n        if not query or not text:\n            return False\n\n        # Create regex pattern for fuzzy matching\n        pattern = \".*\".join(re.escape(char) for char in query)\n        return bool(re.search(pattern, text, re.IGNORECASE))\n\n    def _launch_application(self, app: DesktopApp):\n\n        # Remove ALL % codes (e.g., %u, %U, %f, %F, %i, %c, etc.)\n        cleaned_command = re.sub(r\"%\\w+\", \"\", app.command_line).strip()\n\n        # Final command with hyprctl dispatch\n        final_command = f\"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'\"\n        subprocess.Popen(final_command, shell=True)\n\n        # app.launch()\n\n    def _get_all_applications(self) -> List[Result]:\n        \"\"\"Get a list of all available applications.\"\"\"\n        try:\n            applications = get_desktop_applications(include_hidden=False)\n        except Exception as e:\n            print(f\"Failed to load applications: {e}\")\n            return []\n\n        results = []\n\n        for app in applications:\n            # Truncate description\n            description = app.description or app.generic_name or \"\"\n            if len(description) > 80:\n                description = description[:70] + \"...\"\n\n            result = Result(\n                title=app.display_name or app.name,\n                subtitle=description,\n                icon=app.get_icon_pixbuf(size=48),\n                action=lambda a=app: self._launch_application(a),\n                relevance=0.5,  # Default relevance for all apps\n                plugin_name=self.display_name,\n                data={\n                    \"app\": app,\n                    \"pin_action\": lambda a=app: self._pin_application(a),\n                },\n            )\n            results.append(result)\n\n        return results\n"
  },
  {
    "path": "modules/launcher/plugins/bash_scripts.py",
    "content": "import json\nimport os\nimport threading\nimport time\nfrom typing import Dict, List\n\nimport config.data as data\nfrom fabric.utils import exec_shell_command_async\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass BashScriptsPlugin(PluginBase):\n    \"\"\"\n    Plugin for managing and executing bash scripts.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Bash Scripts\"\n        self.description = \"Manage and execute bash scripts\"\n\n        # Configuration\n        self.scripts_cache_file = os.path.join(data.CACHE_DIR, \"bash_scripts.json\")\n\n        # Default script directory to scan (only Modus scripts)\n        self.modus_scripts_dir = os.path.expanduser(\"~/.config/Modus/scripts\")\n\n        # Scripts to exclude from discovery\n        self.excluded_scripts = {\n            \"screen-capture.sh\"  # Exclude screen-capture.sh as it's handled by screencapture plugin\n        }\n\n        # In-memory cache\n        self._scripts_cache: Dict[str, Dict] = {}\n        self._last_cache_update = 0\n        self._cache_update_interval = 300  # 5 minutes\n\n        # Background cache building\n        self._cache_building = False\n        self._cache_thread = None\n\n    def initialize(self):\n        \"\"\"Initialize the bash scripts plugin.\"\"\"\n        self.set_triggers([\"sh\"])\n        self._load_scripts_cache()\n        self._start_background_cache_update()\n\n    def cleanup(self):\n        \"\"\"Cleanup the bash scripts plugin.\"\"\"\n        self._scripts_cache.clear()\n        if self._cache_thread and self._cache_thread.is_alive():\n            # Note: We don't join the thread to avoid blocking cleanup\n            pass\n\n    def _load_scripts_cache(self):\n        \"\"\"Load scripts cache from JSON file.\"\"\"\n        try:\n            if os.path.exists(self.scripts_cache_file):\n                with open(self.scripts_cache_file, \"r\", encoding=\"utf-8\") as f:\n                    cache_data = json.load(f)\n                    self._scripts_cache = cache_data.get(\"scripts\", {})\n                    self._last_cache_update = cache_data.get(\"last_update\", 0)\n            else:\n                print(\n                    \"BashScriptsPlugin: No cache file found, will build cache in background\"\n                )\n        except Exception as e:\n            print(f\"BashScriptsPlugin: Error loading scripts cache: {e}\")\n            self._scripts_cache = {}\n            self._last_cache_update = 0\n\n    def _save_scripts_cache(self):\n        \"\"\"Save scripts cache to JSON file.\"\"\"\n        try:\n            os.makedirs(data.CACHE_DIR, exist_ok=True)\n            cache_data = {\n                \"scripts\": self._scripts_cache,\n                \"last_update\": self._last_cache_update,\n            }\n            with open(self.scripts_cache_file, \"w\", encoding=\"utf-8\") as f:\n                json.dump(cache_data, f, indent=2)\n        except Exception as e:\n            print(f\"BashScriptsPlugin: Error saving scripts cache: {e}\")\n\n    def _start_background_cache_update(self):\n        \"\"\"Start background thread to update scripts cache.\"\"\"\n        current_time = time.time()\n\n        # Check if cache needs updating\n        if (\n            current_time - self._last_cache_update > self._cache_update_interval\n            or not self._scripts_cache\n        ):\n            if not self._cache_building:\n                self._cache_building = True\n                self._cache_thread = threading.Thread(\n                    target=self._build_scripts_cache_background, daemon=True\n                )\n                self._cache_thread.start()\n\n    def _build_scripts_cache_background(self):\n        \"\"\"Build scripts cache in background thread.\"\"\"\n        try:\n            new_cache = {}\n\n            # Scan Modus scripts directory for discovered scripts\n            if os.path.exists(self.modus_scripts_dir) and os.path.isdir(\n                self.modus_scripts_dir\n            ):\n                try:\n                    self._scan_directory_for_scripts(self.modus_scripts_dir, new_cache)\n                except (PermissionError, FileNotFoundError, OSError) as e:\n                    print(\n                        f\"BashScriptsPlugin: Error scanning Modus scripts directory: {\n                            e\n                        }\"\n                    )\n\n            # Update cache atomically\n            self._scripts_cache = new_cache\n            self._last_cache_update = time.time()\n\n            # Save to disk\n            self._save_scripts_cache()\n\n        except Exception as e:\n            print(f\"BashScriptsPlugin: Error building scripts cache: {e}\")\n        finally:\n            self._cache_building = False\n\n    def _scan_directory_for_scripts(self, directory: str, cache: Dict):\n        \"\"\"Scan a directory for bash scripts and add them to cache.\"\"\"\n        try:\n            with os.scandir(directory) as entries:\n                for entry in entries:\n                    if entry.is_file(follow_symlinks=False):\n                        script_path = entry.path\n                        script_name = entry.name\n\n                        # Skip excluded scripts\n                        if script_name in self.excluded_scripts:\n                            continue\n\n                        # Check if it's a script file\n                        if self._is_script_file(script_path):\n                            cache[script_name] = {\n                                \"path\": script_path,\n                                \"name\": script_name,\n                                \"description\": self._get_script_description(\n                                    script_path\n                                ),\n                                \"type\": \"discovered\",\n                                \"executable\": os.access(script_path, os.X_OK),\n                                \"args\": [],\n                                \"category\": os.path.basename(directory),\n                            }\n\n        except (PermissionError, FileNotFoundError, OSError) as e:\n            print(f\"BashScriptsPlugin: Error scanning directory {directory}: {e}\")\n        except Exception as e:\n            print(f\"BashScriptsPlugin: Unexpected error scanning {directory}: {e}\")\n\n    def _is_script_file(self, file_path: str) -> bool:\n        \"\"\"Check if a file is a bash script.\"\"\"\n        try:\n            # Check file extension first (most common case)\n            if file_path.endswith((\".sh\", \".bash\")):\n                return True\n\n            # For files without extension, check shebang\n            try:\n                with open(file_path, \"rb\") as f:\n                    first_line = f.readline(100).decode(\"utf-8\", errors=\"ignore\")\n                    if first_line.startswith(\"#!\") and (\n                        \"bash\" in first_line or \"sh\" in first_line\n                    ):\n                        return True\n            except (PermissionError, FileNotFoundError, UnicodeDecodeError):\n                pass\n\n            return False\n        except Exception:\n            return False\n\n    def _get_script_description(self, script_path: str) -> str:\n        \"\"\"Extract description from script comments.\"\"\"\n        try:\n            with open(script_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n                lines = f.readlines()\n\n                # Look for description in first few comment lines\n                for line in lines[:10]:\n                    line = line.strip()\n                    if line.startswith(\"#\") and not line.startswith(\"#!\"):\n                        # Remove leading # and whitespace\n                        desc = line[1:].strip()\n                        if desc and len(desc) > 5:  # Meaningful description\n                            return desc\n\n                return f\"Script: {os.path.basename(script_path)}\"\n        except (PermissionError, FileNotFoundError, UnicodeDecodeError):\n            return f\"Script: {os.path.basename(script_path)}\"\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Search for bash scripts matching the query.\"\"\"\n        query = query_string.strip()\n\n        # Start background update if needed (non-blocking)\n        if not self._scripts_cache or (\n            time.time() - self._last_cache_update > self._cache_update_interval\n        ):\n            self._start_background_cache_update()\n\n        results = []\n\n        # Handle special commands\n        if not query:\n            # Show all scripts when no query\n            results.extend(self._list_all_scripts())\n        else:\n            # Search for scripts\n            results.extend(self._search_scripts(query))\n\n        return results\n\n    def _list_all_scripts(self) -> List[Result]:\n        \"\"\"List all available scripts.\"\"\"\n        results = []\n        max_results = 20\n\n        # Sort scripts by name for consistent ordering\n        sorted_scripts = sorted(\n            self._scripts_cache.items(), key=lambda x: x[1].get(\"name\", \"\")\n        )\n\n        # Add scripts (limit to max_results)\n        for script_name, script_info in sorted_scripts:\n            script_results = self._create_script_results_with_args(\n                script_name, script_info, 0.8\n            )\n            for script_result in script_results:\n                if len(results) < max_results:\n                    results.append(script_result)\n                else:\n                    break\n            if len(results) >= max_results:\n                break\n\n        return results\n\n    def _search_scripts(self, query: str) -> List[Result]:\n        \"\"\"Search for scripts matching the query.\"\"\"\n        results = []\n        query_lower = query.lower()\n        max_results = 15\n\n        # Categorize matches for better sorting\n        exact_matches = []\n        prefix_matches = []\n        partial_matches = []\n        description_matches = []\n\n        for script_name, script_info in self._scripts_cache.items():\n            script_name_lower = script_name.lower()\n            description_lower = script_info.get(\"description\", \"\").lower()\n\n            # Skip if no match at all\n            if (\n                query_lower not in script_name_lower\n                and query_lower not in description_lower\n            ):\n                continue\n\n            # Categorize matches\n            if script_name_lower == query_lower:\n                exact_matches.append((script_name, script_info, 1.0))\n            elif script_name_lower.startswith(query_lower):\n                prefix_matches.append((script_name, script_info, 0.9))\n            elif query_lower in script_name_lower:\n                partial_matches.append((script_name, script_info, 0.7))\n            elif query_lower in description_lower:\n                description_matches.append((script_name, script_info, 0.5))\n\n        # Combine results in priority order\n        all_matches = (\n            exact_matches + prefix_matches + partial_matches + description_matches\n        )\n\n        # Convert to Result objects\n        for script_name, script_info, relevance in all_matches:\n            script_results = self._create_script_results_with_args(\n                script_name, script_info, relevance\n            )\n            for script_result in script_results:\n                if len(results) < max_results:\n                    results.append(script_result)\n                else:\n                    break\n            if len(results) >= max_results:\n                break\n\n        return results\n\n    def _create_script_result(\n        self, script_name: str, script_info: Dict, relevance: float\n    ) -> Result:\n        \"\"\"Create a Result object for a script.\"\"\"\n        script_path = script_info.get(\"path\", \"\")\n        description = script_info.get(\"description\", \"\")\n        script_type = script_info.get(\"type\", \"discovered\")\n        executable = script_info.get(\"executable\", False)\n        category = script_info.get(\"category\", \"\")\n\n        # Create subtitle with additional info\n        subtitle_parts = []\n        if description:\n            subtitle_parts.append(description)\n        if category:\n            subtitle_parts.append(f\"[{category}]\")\n        if not executable:\n            subtitle_parts.append(\"(not executable)\")\n\n        subtitle = (\n            \" | \".join(subtitle_parts) if subtitle_parts else f\"Execute: {script_name}\"\n        )\n\n        # Choose icon based on script type and status\n        if not executable:\n            icon_name = \"gtk-file\"\n        elif script_type == \"custom\":\n            icon_name = \"folder-script-symbolic\"\n        else:\n            icon_name = \"terminalc\"\n\n        return Result(\n            title=script_name,\n            subtitle=subtitle,\n            icon_name=icon_name,\n            action=self._create_script_action(script_name, script_info),\n            relevance=relevance,\n            plugin_name=self.display_name,\n            data={\n                \"script_name\": script_name,\n                \"script_path\": script_path,\n                \"type\": script_type,\n            },\n        )\n\n    def _create_script_results_with_args(\n        self, script_name: str, script_info: Dict, relevance: float\n    ) -> List[Result]:\n        \"\"\"Create multiple Result objects for scripts that support arguments.\"\"\"\n        results = []\n\n        # Check for special scripts that need argument variants\n        if script_name == \"hyprpicker.sh\":\n            # For hyprpicker, only show the argument variants (skip basic version)\n            variants = [\n                (\"-rgb\", \"Pick RGB color\"),\n                (\"-hex\", \"Pick HEX color\"),\n                (\"-hsv\", \"Pick HSV color\"),\n            ]\n\n            for arg, desc in variants:\n                variant_result = Result(\n                    title=f\"{script_name} {arg}\",\n                    subtitle=f\"{desc} | [scripts]\",\n                    icon_name=\"terminal-symbolic\",\n                    action=self._create_script_action_with_args(\n                        script_name, script_info, [arg]\n                    ),\n                    relevance=relevance\n                    + 0.1,  # Slightly higher relevance for specific variants\n                    plugin_name=self.display_name,\n                    data={\n                        \"script_name\": script_name,\n                        \"script_path\": script_info.get(\"path\", \"\"),\n                        \"type\": script_info.get(\"type\", \"discovered\"),\n                        \"args\": [arg],\n                    },\n                )\n                results.append(variant_result)\n        else:\n            # For other scripts, create the basic result\n            basic_result = self._create_script_result(\n                script_name, script_info, relevance\n            )\n            results.append(basic_result)\n\n        return results\n\n    def _create_script_action(self, script_name: str, script_info: Dict):\n        \"\"\"Create an action function for executing a script.\"\"\"\n\n        def action():\n            self._execute_script(script_name, script_info)\n\n        return action\n\n    def _create_script_action_with_args(\n        self, script_name: str, script_info: Dict, args: List[str]\n    ):\n        \"\"\"Create an action function for executing a script with specific arguments.\"\"\"\n\n        def action():\n            # Create a modified script_info with the specific arguments\n            modified_script_info = script_info.copy()\n            modified_script_info[\"args\"] = args\n            self._execute_script(script_name, modified_script_info)\n\n        return action\n\n    def _execute_script(self, script_name: str, script_info: Dict):\n        \"\"\"Execute a bash script.\"\"\"\n        try:\n            script_path = script_info.get(\"path\", \"\")\n            script_args = script_info.get(\"args\", [])\n\n            if not os.path.exists(script_path):\n                return\n\n            if not script_info.get(\"executable\", False):\n                return\n\n            # Build command\n            command = [script_path] + script_args\n            exec_shell_command_async(command)\n\n        except Exception as e:\n            print(f\"BashScriptsPlugin: Error executing script '{script_name}': {e}\")\n"
  },
  {
    "path": "modules/launcher/plugins/bookmarks.py",
    "content": "import json\nimport subprocess\nimport threading\nimport time\nfrom pathlib import Path\nfrom typing import Dict, List, Optional\nfrom urllib.parse import urlparse\n\nfrom thefuzz import fuzz\n\nfrom fabric.utils.helpers import get_relative_path\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass BookmarkManager:\n    \"\"\"Manages user's custom bookmarks.\"\"\"\n\n    def __init__(self, storage_file: Path):\n        self.storage_file = storage_file\n        self.bookmarks: List[Dict] = []\n        self.cache_lock = threading.Lock()\n        self.last_loaded = 0\n        self.cache_ttl = 30  # Cache for 30 seconds\n        self._load_bookmarks()\n\n    def _get_favicon_url(self, url: str) -> str:\n        \"\"\"Generate favicon URL for a given website URL.\"\"\"\n        try:\n            parsed = urlparse(url)\n            return f\"{parsed.scheme}://{parsed.netloc}/favicon.ico\"\n        except:\n            return \"\"\n\n    def _extract_domain(self, url: str) -> str:\n        \"\"\"Extract domain from URL.\"\"\"\n        try:\n            parsed = urlparse(url)\n            domain = parsed.netloc\n            # Remove www. prefix\n            if domain.startswith(\"www.\"):\n                domain = domain[4:]\n            return domain\n        except:\n            return url\n\n    def _normalize_url(self, url: str) -> str:\n        \"\"\"Normalize URL by adding protocol if missing.\"\"\"\n        url = url.strip()\n        if not url.startswith((\"http://\", \"https://\")):\n            if url.startswith(\"www.\"):\n                url = \"https://\" + url\n            else:\n                url = \"https://\" + url\n        return url\n\n    def _load_bookmarks(self):\n        \"\"\"Load bookmarks from JSON file with caching.\"\"\"\n        with self.cache_lock:\n            current_time = time.time()\n\n            # Check if cache is still valid\n            if (\n                current_time - self.last_loaded\n            ) < self.cache_ttl and self.last_loaded > 0:\n                return\n\n            try:\n                if self.storage_file.exists():\n                    with open(self.storage_file, \"r\", encoding=\"utf-8\") as f:\n                        data = json.load(f)\n                        self.bookmarks = data.get(\"bookmarks\", [])\n                else:\n                    # File doesn't exist, start with empty list but don't save yet\n                    self.bookmarks = []\n\n                self.last_loaded = current_time\n            except Exception as e:\n                print(f\"Error loading bookmarks: {e}\")\n                self.bookmarks = []\n\n    def get_bookmarks(self) -> List[Dict]:\n        \"\"\"Get bookmarks, loading from file if needed.\"\"\"\n        self._load_bookmarks()\n        return self.bookmarks\n\n    def _save_bookmarks_unlocked(self):\n        \"\"\"Save bookmarks to JSON file without acquiring lock.\"\"\"\n        try:\n            self.storage_file.parent.mkdir(parents=True, exist_ok=True)\n            data = {\n                \"bookmarks\": self.bookmarks,\n                \"last_updated\": time.time(),\n            }\n            with open(self.storage_file, \"w\", encoding=\"utf-8\") as f:\n                json.dump(data, f, indent=2, ensure_ascii=False)\n\n            # Update cache timestamp\n            self.last_loaded = time.time()\n        except Exception as e:\n            print(f\"Error saving bookmarks: {e}\")\n\n    def _save_bookmarks(self):\n        \"\"\"Save bookmarks to JSON file.\"\"\"\n        with self.cache_lock:\n            self._save_bookmarks_unlocked()\n\n    def add_bookmark(\n        self, title: str, url: str, description: str = \"\", tags: List[str] = None\n    ) -> bool:\n        \"\"\"Add a new bookmark.\"\"\"\n        try:\n            url = self._normalize_url(url)\n\n            # Check if bookmark already exists\n            for bookmark in self.bookmarks:\n                if bookmark[\"url\"] == url:\n                    return False  # Already exists\n\n            new_bookmark = {\n                \"title\": title.strip(),\n                \"url\": url,\n                \"description\": description.strip(),\n                \"tags\": tags or [],\n                \"created\": time.time(),\n                \"accessed\": 0,\n            }\n\n            self.bookmarks.append(new_bookmark)\n            self._save_bookmarks()\n\n            # Clear cache to force reload\n            self.last_loaded = 0\n\n            return True\n\n        except Exception as e:\n            print(f\"Error adding bookmark: {e}\")\n            return False\n\n    def remove_bookmark(self, identifier: str) -> bool:\n        \"\"\"Remove a bookmark by title or URL.\"\"\"\n        try:\n            identifier = identifier.lower().strip()\n\n            for i, bookmark in enumerate(self.bookmarks):\n                if (\n                    bookmark[\"title\"].lower() == identifier\n                    or bookmark[\"url\"].lower() == identifier\n                    or self._extract_domain(bookmark[\"url\"]).lower() == identifier\n                ):\n                    self.bookmarks.pop(i)\n                    self._save_bookmarks()\n\n                    # Clear cache to force reload\n                    self.last_loaded = 0\n\n                    return True\n\n            return False\n\n        except Exception as e:\n            print(f\"Error removing bookmark: {e}\")\n            return False\n\n    def update_access_time(self, url: str):\n        \"\"\"Update the last accessed time for a bookmark.\"\"\"\n        try:\n            for bookmark in self.bookmarks:\n                if bookmark[\"url\"] == url:\n                    bookmark[\"accessed\"] = time.time()\n                    self._save_bookmarks()\n                    break\n        except Exception as e:\n            print(f\"Error updating access time: {e}\")\n\n    def get_bookmark_count(self) -> int:\n        \"\"\"Get total number of bookmarks.\"\"\"\n        return len(self.get_bookmarks())\n\n\nclass BookmarksPlugin(PluginBase):\n    \"\"\"\n    User bookmarks plugin for the launcher.\n    Allows users to add, remove, and search their own bookmarks.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Bookmarks\"\n        self.description = \"Manage and search your personal bookmarks\"\n\n        # Initialize bookmark manager with storage file\n        self.bookmark_file = Path(\n            get_relative_path(\"../../../config/assets/bookmarks.json\")\n        )\n        self.bookmark_manager = BookmarkManager(self.bookmark_file)\n        self.max_results = 15\n\n        # Cache for results\n        self._results_cache = {}\n        self._cache_timestamps = {}\n        self._cache_ttl = 30  # 30 seconds\n\n        # Launcher instance for refreshing\n        self._launcher_instance = None\n        self._original_close_launcher = None\n\n    def initialize(self):\n        \"\"\"Initialize the bookmarks plugin.\"\"\"\n        self.set_triggers([\"bm\"])\n        self._setup_launcher_hooks()\n\n    def cleanup(self):\n        \"\"\"Cleanup the bookmarks plugin.\"\"\"\n        self._results_cache.clear()\n        self._cache_timestamps.clear()\n        self._cleanup_launcher_hooks()\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Process bookmark queries with caching.\"\"\"\n        query_key = query_string.strip()\n        current_time = time.time()\n\n        # Check cache first (except for add/remove commands which should always execute)\n        if (\n            not query_key.startswith((\"add \", \"remove \", \"delete \", \"rm \"))\n            and query_key in self._results_cache\n            and (current_time - self._cache_timestamps.get(query_key, 0))\n            < self._cache_ttl\n        ):\n            return self._results_cache[query_key]\n\n        query = query_key.lower()\n        results = []\n\n        if not query:\n            # Show recent/popular bookmarks when no query\n            results = self._get_recent_bookmarks()\n        elif query.startswith(\"add \"):\n            # Add new bookmark (don't cache)\n            results = self._handle_add_command(query[4:].strip())\n        elif query.startswith((\"remove \", \"delete \", \"rm \")):\n            # Remove bookmark (don't cache)\n            command_parts = query_key.split(\" \", 1)\n            if len(command_parts) > 1:\n                results = self._handle_remove_command(command_parts[1].strip())\n            else:\n                results = self._show_remove_help()\n        else:\n            # Search bookmarks\n            results = self._search_bookmarks(query)\n\n        # Cache results (except for add/remove commands)\n        if not query.startswith((\"add \", \"remove \", \"delete \", \"rm \")):\n            self._results_cache[query_key] = results\n            self._cache_timestamps[query_key] = current_time\n\n        return results\n\n    def _search_bookmarks(self, query: str) -> List[Result]:\n        \"\"\"Search through bookmarks.\"\"\"\n        bookmarks = self.bookmark_manager.get_bookmarks()\n\n        if not bookmarks:\n            return [\n                Result(\n                    title=\"No Bookmarks Found\",\n                    subtitle=\"Use 'add <title> <url>' to add first bookmark\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"info\", \"keep_launcher_open\": True},\n                )\n            ]\n\n        # Search bookmarks\n        results = []\n        for bookmark in bookmarks:\n            relevance = self._calculate_relevance(bookmark, query)\n            if relevance > 0.3:  # Only show relevant results\n                result = self._create_bookmark_result(bookmark, relevance)\n                if result:\n                    results.append(result)\n\n        # Sort by relevance and limit results\n        results.sort(key=lambda r: r.relevance, reverse=True)\n        return results[: self.max_results]\n\n    def _get_recent_bookmarks(self) -> List[Result]:\n        \"\"\"Get recent/popular bookmarks when no query is provided.\"\"\"\n        bookmarks = self.bookmark_manager.get_bookmarks()\n\n        if not bookmarks:\n            return [\n                Result(\n                    title=\"No Bookmarks Yet\",\n                    subtitle=\"Use 'add <title> <url>' to add first bookmark\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"help\", \"keep_launcher_open\": True},\n                ),\n                Result(\n                    title=\"Example: Add Google\",\n                    subtitle=\"add Google https://google.com\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=0.9,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"example\", \"keep_launcher_open\": True},\n                ),\n            ]\n\n        # Sort by access time (most recent first) and show top 10\n        sorted_bookmarks = sorted(\n            bookmarks, key=lambda b: b.get(\"accessed\", 0), reverse=True\n        )\n        results = []\n        for bookmark in sorted_bookmarks[:10]:\n            result = self._create_bookmark_result(bookmark, 0.8)\n            if result:\n                results.append(result)\n\n        return results\n\n    def _handle_add_command(self, args: str) -> List[Result]:\n        \"\"\"Handle add bookmark command.\"\"\"\n        if not args:\n            return [\n                Result(\n                    title=\"Add Bookmark\",\n                    subtitle=\"Usage: add <title> <url> [description]\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"help\", \"keep_launcher_open\": True},\n                ),\n                Result(\n                    title=\"Example\",\n                    subtitle=\"add Google https://google.com Search engine\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=0.9,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"example\", \"keep_launcher_open\": True},\n                ),\n            ]\n\n        # Parse arguments: title url [description]\n        parts = args.split()\n        if len(parts) < 2:\n            return [\n                Result(\n                    title=\"Invalid Format\",\n                    subtitle=\"Usage: add <title> <url> [description]\",\n                    icon_name=\"alert\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            ]\n\n        title = parts[0]\n        url = parts[1]\n        description = \" \".join(parts[2:]) if len(parts) > 2 else \"\"\n\n        # Check if bookmark already exists\n        normalized_url = self.bookmark_manager._normalize_url(url)\n        existing_bookmarks = self.bookmark_manager.get_bookmarks()\n        already_exists = any(\n            bookmark[\"url\"] == normalized_url for bookmark in existing_bookmarks\n        )\n\n        if already_exists:\n            # Truncate URL for display to prevent launcher resize\n            display_url = normalized_url\n            if len(display_url) > 35:\n                display_url = display_url[:32] + \"...\"\n\n            return [\n                Result(\n                    title=\"Bookmark Already Exists\",\n                    subtitle=f\"URL '{display_url}' already exists\",\n                    icon_name=\"alert\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            ]\n\n        # Show add action - will execute on Enter\n        domain = self.bookmark_manager._extract_domain(normalized_url)\n\n        # Truncate domain if too long\n        if len(domain) > 25:\n            domain = domain[:22] + \"...\"\n\n        subtitle = f\"Click to add: {domain}\"\n        if description:\n            # Truncate description to prevent launcher resize\n            max_desc_len = 35 - len(domain)  # Account for domain + separator\n            if len(description) > max_desc_len:\n                description = description[: max_desc_len - 3] + \"...\"\n            subtitle += f\" • {description}\"\n\n        # Truncate title for display\n        display_title = title\n        if len(display_title) > 25:\n            display_title = display_title[:22] + \"...\"\n\n        return [\n            Result(\n                title=f\"Add bookmark '{display_title}'\",\n                subtitle=subtitle,\n                icon_name=\"plus\",\n                action=lambda: self._add_bookmark_action(title, url, description),\n                relevance=1.0,\n                plugin_name=self.display_name,\n                data={\"type\": \"add\", \"name\": title, \"keep_launcher_open\": True},\n            )\n        ]\n\n    def _handle_remove_command(self, identifier: str) -> List[Result]:\n        \"\"\"Handle remove bookmark command.\"\"\"\n        if not identifier:\n            return self._show_remove_help()\n\n        # Find matching bookmarks\n        bookmarks = self.bookmark_manager.get_bookmarks()\n        identifier_lower = identifier.lower().strip()\n\n        matching_bookmarks = []\n        for bookmark in bookmarks:\n            if (\n                bookmark[\"title\"].lower() == identifier_lower\n                or bookmark[\"url\"].lower() == identifier_lower\n                or self.bookmark_manager._extract_domain(bookmark[\"url\"]).lower()\n                == identifier_lower\n            ):\n                matching_bookmarks.append(bookmark)\n\n        if not matching_bookmarks:\n            return [\n                Result(\n                    title=\"Bookmark Not Found\",\n                    subtitle=f\"No bookmark found matching '{identifier}'\",\n                    icon_name=\"alert\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            ]\n\n        # Show remove action - will execute on Enter\n        bookmark = matching_bookmarks[0]  # Take first match\n        title = bookmark.get(\"title\", \"Untitled\")\n        domain = self.bookmark_manager._extract_domain(bookmark.get(\"url\", \"\"))\n\n        # Truncate title and domain for display\n        display_title = title\n        if len(display_title) > 25:\n            display_title = display_title[:22] + \"...\"\n\n        display_domain = domain\n        if len(display_domain) > 30:\n            display_domain = display_domain[:27] + \"...\"\n\n        return [\n            Result(\n                title=f\"Remove '{display_title}'?\",\n                subtitle=f\"Click to confirm: {display_domain}\",\n                icon_name=\"trash\",\n                action=lambda: self._remove_bookmark_action(identifier),\n                relevance=1.0,\n                plugin_name=self.display_name,\n                data={\"type\": \"remove\", \"name\": title, \"keep_launcher_open\": True},\n            )\n        ]\n\n    def _show_remove_help(self) -> List[Result]:\n        \"\"\"Show help for remove command.\"\"\"\n        bookmarks = self.bookmark_manager.get_bookmarks()\n        results = [\n            Result(\n                title=\"Remove Bookmark\",\n                subtitle=\"Usage: remove <title|url|domain>\",\n                icon_name=\"info\",\n                action=lambda: None,\n                relevance=1.0,\n                plugin_name=self.display_name,\n                data={\"type\": \"help\", \"keep_launcher_open\": True},\n            )\n        ]\n\n        # Show available bookmarks to remove\n        if bookmarks:\n            results.append(\n                Result(\n                    title=\"Available Bookmarks:\",\n                    subtitle=f\"{len(bookmarks)} bookmarks available to remove\",\n                    icon_name=\"bookmarks-organize\",\n                    action=lambda: None,\n                    relevance=0.9,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"info\", \"keep_launcher_open\": True},\n                )\n            )\n\n            # Show first few bookmarks as examples\n            for bookmark in bookmarks[:3]:\n                title = bookmark.get(\"title\", \"Untitled\")\n                domain = self._extract_domain(bookmark.get(\"url\", \"\"))\n\n                # Truncate title and domain for consistent display\n                display_title = title\n                if len(display_title) > 20:\n                    display_title = display_title[:17] + \"...\"\n\n                display_domain = domain\n                if len(display_domain) > 20:\n                    display_domain = display_domain[:17] + \"...\"\n\n                results.append(\n                    Result(\n                        title=f\"remove {display_title}\",\n                        subtitle=f\"Click to remove: {display_title} ({display_domain})\",\n                        icon_name=\"trash\",\n                        action=lambda t=title: self._remove_bookmark_action(t),\n                        relevance=0.8,\n                        plugin_name=self.display_name,\n                        data={\n                            \"type\": \"remove_option\",\n                            \"bookmark\": bookmark,\n                            \"keep_launcher_open\": True,\n                        },\n                    )\n                )\n\n        return results\n\n    def _add_bookmark_action(self, title: str, url: str, description: str = \"\"):\n        \"\"\"Execute the add bookmark action.\"\"\"\n        success = self.bookmark_manager.add_bookmark(title, url, description)\n        if success:\n            print(f\"✓ Added bookmark '{title}' - {url}\")\n            # Clear cache to force refresh\n            self._results_cache.clear()\n            self._cache_timestamps.clear()\n            # Reset to trigger word and refresh\n            self._reset_to_trigger()\n        else:\n            print(f\"✗ Failed to add bookmark '{title}' - already exists\")\n\n    def _remove_bookmark_action(self, identifier: str):\n        \"\"\"Execute the remove bookmark action.\"\"\"\n        success = self.bookmark_manager.remove_bookmark(identifier)\n        if success:\n            print(f\"✓ Removed bookmark '{identifier}'\")\n            # Clear cache to force refresh\n            self._results_cache.clear()\n            self._cache_timestamps.clear()\n            # Reset to trigger word and refresh\n            self._reset_to_trigger()\n        else:\n            print(f\"✗ Failed to remove bookmark '{identifier}' - not found\")\n\n    def _remove_bookmark_with_reset(self, identifier: str):\n        \"\"\"Execute the remove bookmark action via alt_action (Shift+Enter) and reset to trigger.\"\"\"\n        success = self.bookmark_manager.remove_bookmark(identifier)\n        if success:\n            print(f\"✓ Removed bookmark '{identifier}'\")\n            # Clear cache to force refresh\n            self._results_cache.clear()\n            self._cache_timestamps.clear()\n            # Reset to trigger word and refresh\n            self._reset_to_trigger()\n        else:\n            print(f\"✗ Failed to remove bookmark '{identifier}' - not found\")\n\n    def _calculate_relevance(self, bookmark: Dict, query: str) -> float:\n        \"\"\"Calculate relevance score for a bookmark.\"\"\"\n        title = bookmark.get(\"title\", \"\").lower()\n        url = bookmark.get(\"url\", \"\").lower()\n        description = bookmark.get(\"description\", \"\").lower()\n\n        # Exact title match\n        if query == title:\n            return 1.0\n\n        # Title starts with query\n        if title.startswith(query):\n            return 0.95\n\n        # Query in title\n        if query in title:\n            position = title.index(query)\n            position_score = 1.0 - (position / len(title))\n            return 0.8 + (position_score * 0.1)\n\n        # Query in URL\n        if query in url:\n            return 0.7\n\n        # Query in description\n        if query in description:\n            return 0.6\n\n        # Fuzzy matching for title\n        if len(query) >= 3:\n            fuzzy_score = fuzz.partial_ratio(query, title) / 100.0\n            if fuzzy_score >= 0.7:\n                return fuzzy_score * 0.6\n\n        return 0.0\n\n    def _create_bookmark_result(\n        self, bookmark: Dict, relevance: float\n    ) -> Optional[Result]:\n        \"\"\"Create a Result object for a bookmark.\"\"\"\n        try:\n            title = bookmark.get(\"title\", \"Untitled\")\n            url = bookmark.get(\"url\", \"\")\n            description = bookmark.get(\"description\", \"\")\n\n            # Truncate long titles to prevent launcher resize\n            if len(title) > 45:\n                title = title[:42] + \"...\"\n\n            # Create subtitle with domain and description\n            domain = self._extract_domain(url)\n\n            # Truncate domain if too long\n            if len(domain) > 30:\n                domain = domain[:27] + \"...\"\n\n            if description:\n                # Truncate description to prevent launcher resize\n                # Account for domain + separator\n                max_desc_len = 50 - len(domain)\n                if len(description) > max_desc_len:\n                    description = description[: max_desc_len - 3] + \"...\"\n                subtitle = f\"{domain} • {description}\"\n            else:\n                subtitle = domain\n\n            # Final subtitle length check to ensure consistent launcher size\n            if len(subtitle) > 60:\n                subtitle = subtitle[:57] + \"...\"\n\n            return Result(\n                title=title,\n                subtitle=subtitle,\n                icon_name=\"bookmark-organize\",\n                action=lambda u=url: self._open_bookmark(u),\n                relevance=relevance,\n                plugin_name=self.display_name,\n                data={\n                    \"type\": \"bookmark\",\n                    \"url\": url,\n                    \"domain\": domain,\n                    \"description\": description,\n                    \"keep_launcher_open\": False,\n                    \"alt_action\": lambda t=title: self._remove_bookmark_with_reset(t),\n                },\n            )\n        except Exception as e:\n            print(f\"Error creating bookmark result: {e}\")\n            return None\n\n    def _extract_domain(self, url: str) -> str:\n        \"\"\"Extract domain from URL.\"\"\"\n        try:\n            parsed = urlparse(url)\n            domain = parsed.netloc\n            # Remove www. prefix\n            if domain.startswith(\"www.\"):\n                domain = domain[4:]\n            return domain\n        except:\n            return url\n\n    def _open_bookmark(self, url: str):\n        \"\"\"Open bookmark URL in default browser and update access time.\"\"\"\n        try:\n            # Update access time\n            self.bookmark_manager.update_access_time(url)\n\n            # Open URL\n            subprocess.Popen(\n                [\"xdg-open\", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL\n            )\n        except Exception as e:\n            print(f\"Failed to open bookmark: {e}\")\n\n    def _setup_launcher_hooks(self):\n        \"\"\"Setup hooks to monitor launcher state.\"\"\"\n        try:\n            # Try to find the launcher instance\n            import gc\n\n            for obj in gc.get_objects():\n                if (\n                    hasattr(obj, \"__class__\")\n                    and obj.__class__.__name__ == \"Launcher\"\n                    and hasattr(obj, \"close_launcher\")\n                ):\n                    self._launcher_instance = obj\n                    break\n        except Exception as e:\n            print(f\"Warning: Could not setup launcher hooks: {e}\")\n\n    def _cleanup_launcher_hooks(self):\n        \"\"\"Cleanup launcher hooks.\"\"\"\n        try:\n            self._launcher_instance = None\n        except Exception as e:\n            print(f\"Warning: Could not cleanup launcher hooks: {e}\")\n\n    def _reset_to_trigger(self):\n        \"\"\"Reset launcher to trigger word and refresh.\"\"\"\n        try:\n            if self._launcher_instance and hasattr(\n                self._launcher_instance, \"search_entry\"\n            ):\n                # Get the current trigger (bookmark or bm)\n                current_text = self._launcher_instance.search_entry.get_text()\n                trigger = \"bookmark \"\n\n                # Determine which trigger was used\n                if current_text.lower().startswith(\"bm \"):\n                    trigger = \"bm \"\n\n                # Reset to trigger word with space\n                try:\n                    from gi.repository import GLib\n\n                    def reset_and_refresh():\n                        # Set text to trigger word\n                        self._launcher_instance.search_entry.set_text(trigger)\n                        # Position cursor at end\n                        self._launcher_instance.search_entry.set_position(-1)\n                        # Trigger search to show default bookmarks\n                        self._launcher_instance._perform_search(trigger)\n                        return False\n\n                    GLib.timeout_add(50, reset_and_refresh)\n                except ImportError:\n                    # Fallback: direct call if GLib not available\n                    self._launcher_instance.search_entry.set_text(trigger)\n                    self._launcher_instance.search_entry.set_position(-1)\n                    self._launcher_instance._perform_search(trigger)\n        except Exception as e:\n            print(f\"Could not reset to trigger: {e}\")\n\n    def _force_launcher_refresh(self):\n        \"\"\"Force the launcher to refresh and show updated results.\"\"\"\n        try:\n            if self._launcher_instance and hasattr(\n                self._launcher_instance, \"_perform_search\"\n            ):\n                # Get current search text\n                current_text = \"\"\n                if hasattr(self._launcher_instance, \"search_entry\"):\n                    current_text = self._launcher_instance.search_entry.get_text()\n\n                # Trigger a search to refresh results\n                try:\n                    from gi.repository import GLib\n\n                    def refresh():\n                        self._launcher_instance._perform_search(current_text)\n                        return False\n\n                    GLib.timeout_add(50, refresh)\n                except ImportError:\n                    # Fallback: direct call if GLib not available\n                    self._launcher_instance._perform_search(current_text)\n        except Exception as e:\n            print(f\"Could not force launcher refresh: {e}\")\n"
  },
  {
    "path": "modules/launcher/plugins/caffeine.py",
    "content": "import subprocess\nfrom threading import Timer\nfrom typing import List\n\nimport gi\n\nimport config.data as data\nfrom fabric.utils import get_relative_path\nfrom fabric.utils.helpers import exec_shell_command_async\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\ngi.require_version(\"Gtk\", \"3.0\")\n\n\nclass CaffeinePlugin(PluginBase):\n    \"\"\"\n    Plugin for preventing system idle using the inhibit script.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Caffeine\"\n        self.description = \"Prevent system from going idle\"\n        self.inhibit_script = get_relative_path(\"../../../utils/inhibit.py\")\n\n        # Predefined durations\n        self.durations = {\n            \"30m\": \"30 minutes\",\n            \"1h\": \"1 hour\",\n            \"2h\": \"2 hours\",\n            \"4h\": \"4 hours\",\n            \"8h\": \"8 hours\",\n            \"on\": \"On\",\n            \"off\": \"Off\",\n        }\n\n    def initialize(self):\n        \"\"\"Initialize the caffeine plugin.\"\"\"\n        self.set_triggers([\"caffeine\"])\n\n    def cleanup(self):\n        \"\"\"Cleanup the caffeine plugin.\"\"\"\n        pass\n\n    def _create_inhibit_action(self, duration: str):\n        \"\"\"Create an inhibit action that properly captures the duration.\"\"\"\n\n        def action():\n            try:\n                # Run the script in the background without waiting\n                subprocess.Popen(\n                    [\"python3\", self.inhibit_script, duration],\n                    stdout=subprocess.DEVNULL,\n                    stderr=subprocess.DEVNULL,\n                    start_new_session=True,  # Run in a new session to prevent hanging\n                )\n\n                # Send notification based on duration\n                if duration.lower() == \"off\":\n                    # Deactivation notification\n                    exec_shell_command_async(\n                        f\"notify-send '☕ Caffeine' 'Deactivated' -a '{\n                            data.APP_NAME_CAP\n                        }' -e\"\n                    )\n                else:\n                    # Activation notification with duration\n                    duration_text = self.durations.get(duration, duration)\n                    exec_shell_command_async(\n                        f\"notify-send '☕ Caffeine' 'Activated for {\n                            duration_text\n                        }' -a '{data.APP_NAME_CAP}' -e\"\n                    )\n\n                    # Schedule expiration notification for timed durations\n                    if duration.lower() not in [\"on\", \"off\"]:\n                        self._schedule_expiration_notification(duration, duration_text)\n\n            except Exception as e:\n                print(f\"Error starting inhibit script: {e}\")\n\n        return action\n\n    def _parse_duration_to_seconds(self, duration_str: str) -> int:\n        \"\"\"Parse duration string into seconds. Same logic as inhibit.py\"\"\"\n        try:\n            if duration_str.lower() in [\"on\", \"off\"]:\n                return 0\n            elif duration_str.endswith(\"h\"):\n                return int(float(duration_str[:-1]) * 3600)\n            elif duration_str.endswith(\"m\"):\n                return int(float(duration_str[:-1]) * 60)\n            elif duration_str.endswith(\"s\"):\n                return int(float(duration_str[:-1]))\n            else:\n                return int(duration_str)\n        except ValueError:\n            return 0\n\n    def _schedule_expiration_notification(self, duration: str, duration_text: str):\n        \"\"\"Schedule a notification for when the caffeine effect expires.\"\"\"\n        seconds = self._parse_duration_to_seconds(duration)\n        if seconds > 0:\n\n            def send_expiration_notification():\n                exec_shell_command_async(\n                    f\"notify-send '☕ Caffeine' 'Expired after {duration_text}' -a '{\n                        data.APP_NAME_CAP\n                    }' -e\"\n                )\n\n            # Schedule the notification\n            timer = Timer(seconds, send_expiration_notification)\n            timer.daemon = True  # Don't prevent program exit\n            timer.start()\n\n    def _is_valid_duration(self, query: str) -> bool:\n        \"\"\"Check if the query is a valid duration format.\"\"\"\n        if query in self.durations:\n            return True\n        if query.isdigit():\n            return True\n        if query.endswith((\"h\", \"m\", \"s\")) and query[:-1].replace(\".\", \"\").isdigit():\n            return True\n        return False\n\n    def _get_default_action(self, query: str):\n        \"\"\"Get the default action for a direct duration input.\"\"\"\n        if self._is_valid_duration(query):\n            return self._create_inhibit_action(query)\n        return None\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Search caffeine durations based on query.\"\"\"\n        # For empty queries, show all available durations\n        if not query_string.strip():\n            query_string = \"\"  # Will match all durations in the loop below\n\n        query = query_string.lower().strip()\n        results = []\n\n        # Handle direct search entry (e.g., \"caffeine 30m\" or just \"30m\")\n        if query.startswith(\"caffeine \"):\n            query = query[9:].strip()  # Remove \"caffeine \" prefix\n            # If query becomes empty after removing prefix, show all durations\n            if not query:\n                query = \"\"  # Will match all durations in the loop below\n        elif query == \"caffeine\":\n            # Handle just \"caffeine\" without space - show all durations\n            query = \"\"\n        elif not query:\n            # Handle empty query - show all durations\n            pass\n        elif (\n            query in self.durations\n            or query.isdigit()\n            or (\n                query.endswith((\"h\", \"m\", \"s\"))\n                and query[:-1].replace(\".\", \"\").isdigit()\n            )\n        ):\n            # If query is a valid duration without prefix, use it directly\n            pass\n        else:\n            return []\n\n        # Add custom duration option\n        if query.isdigit() or (\n            query.endswith((\"h\", \"m\", \"s\")) and query[:-1].replace(\".\", \"\").isdigit()\n        ):\n            result = Result(\n                title=f\"Custom: {query}\",\n                subtitle=\"Set custom duration\",\n                icon_name=\"caffeine\",\n                action=self._create_inhibit_action(query),\n                relevance=1.0,\n                plugin_name=self.display_name,\n                data={\"duration\": query},\n            )\n            results.append(result)\n\n        # Add predefined durations\n        for duration, description in self.durations.items():\n            # If query is empty, show all durations; otherwise filter by query\n            if not query or query in duration or query in description.lower():\n                # Special handling for \"off\" - it should stop inhibition\n                if duration.lower() == \"off\":\n                    subtitle = \"Stop idle inhibition\"\n                else:\n                    subtitle = f\"Prevent idle for {description.lower()}\"\n\n                result = Result(\n                    title=description,\n                    subtitle=subtitle,\n                    icon_name=\"caffeine\",\n                    action=self._create_inhibit_action(duration),\n                    relevance=0.9 if query == duration else 0.7,\n                    plugin_name=self.display_name,\n                    data={\"duration\": duration},\n                )\n                results.append(result)\n\n        # Set default action for direct duration input\n        if results:\n            results[0].default_action = self._get_default_action(query)\n\n        return results\n"
  },
  {
    "path": "modules/launcher/plugins/calculator.py",
    "content": "import math\nimport re\nimport subprocess\nimport time\nfrom typing import List\n\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\nfrom utils.conversion import Conversion\n\n\nclass CalculatorPlugin(PluginBase):\n    \"\"\"\n    Plugin for calculating mathematical expressions and converting units.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Calculator\"\n        self.description = \"Evaluate mathematical expressions and convert units\"\n\n        # Safe functions for evaluation\n        self.safe_functions = {\n            \"abs\": abs,\n            \"round\": round,\n            \"min\": min,\n            \"max\": max,\n            \"sum\": sum,\n            \"pow\": pow,\n            \"sqrt\": math.sqrt,\n            \"sin\": math.sin,\n            \"cos\": math.cos,\n            \"tan\": math.tan,\n            \"asin\": math.asin,\n            \"acos\": math.acos,\n            \"atan\": math.atan,\n            \"log\": math.log,\n            \"log10\": math.log10,\n            \"exp\": math.exp,\n            \"pi\": math.pi,\n            \"e\": math.e,\n        }\n\n        # Initialize conversion utility\n        self.converter = Conversion()\n\n        # Pre-compiled regex patterns\n        self.expression_pattern = re.compile(r\"[\\d+\\-*/^()=]\")\n        self.number_pattern = re.compile(r\"\\d\")\n        self.conversion_pattern = re.compile(\n            r\"(\\d+(?:\\.\\d+)?)\\s*([a-zA-Z]+)\\s*(?:to|in|=)\\s*([a-zA-Z]+)\"\n        )\n\n        # Cache for conversion results\n        self._conversion_cache = {}\n        self._last_cache_cleanup = time.time()\n        self._cache_cleanup_interval = 300  # 5 minutes\n\n    def initialize(self):\n        \"\"\"Initialize the files plugin.\"\"\"\n        self.set_triggers([\"=\"])\n\n    def _cleanup_cache(self):\n        \"\"\"Clean up old cache entries.\"\"\"\n        current_time = time.time()\n        if current_time - self._last_cache_cleanup > self._cache_cleanup_interval:\n            self._conversion_cache.clear()\n            self._last_cache_cleanup = current_time\n\n    def query(self, query: str) -> List[Result]:\n        \"\"\"Process a query and return results.\"\"\"\n        if not query:\n            return []\n\n        # Clean up cache periodically\n        self._cleanup_cache()\n\n        # Check if it's a conversion query\n        conversion_match = self.conversion_pattern.match(query)\n        if conversion_match:\n            try:\n                value, from_unit, to_unit = conversion_match.groups()\n                value = float(value)\n\n                # Check cache first\n                cache_key = f\"{value}_{from_unit}_{to_unit}\"\n                if cache_key in self._conversion_cache:\n                    result = self._conversion_cache[cache_key]\n                    subtitle = f\"{value} {from_unit} = {result:.6g} {to_unit}\"\n                else:\n                    # Use the conversion utility\n                    result = self.converter.convert(value, from_unit, to_unit)\n                    # Cache the result\n                    self._conversion_cache[cache_key] = result\n                    subtitle = f\"{value} {from_unit} = {result:.6g} {to_unit}\"\n\n                return [\n                    Result(\n                        title=f\"{result:.6g} {to_unit}\",\n                        subtitle=subtitle,\n                        icon_name=\"calculator-symbolic\",\n                        action=lambda r=f\"{result:.6g}\": self._copy_to_clipboard(r),\n                        relevance=1.0,\n                        plugin_name=self.display_name,\n                        data={\"from\": (value, from_unit), \"to\": (result, to_unit)},\n                    )\n                ]\n            except ValueError as e:\n                return [\n                    Result(\n                        title=\"Invalid conversion\",\n                        subtitle=str(e),\n                        icon_name=\"calculator-symbolic\",\n                        relevance=0.0,\n                        plugin_name=self.display_name,\n                    )\n                ]\n\n        # Check if it's a math expression\n        if self.expression_pattern.search(query):\n            try:\n                # Evaluate the expression\n                result = eval(query, {\"__builtins__\": {}}, self.safe_functions)\n                if isinstance(result, (int, float)):\n                    return [\n                        Result(\n                            title=f\"{result:.6g}\",\n                            subtitle=f\"{query} = {result:.6g}\",\n                            icon_name=\"calculator-symbolic\",\n                            action=lambda r=f\"{result:.6g}\": self._copy_to_clipboard(r),\n                            relevance=1.0,\n                            plugin_name=self.display_name,\n                        )\n                    ]\n            except Exception:\n                pass\n\n        return []\n\n    def _format_cache_age(self, age_seconds: float) -> str:\n        \"\"\"Format cache age for display.\"\"\"\n        if age_seconds < 60:\n            return f\"{int(age_seconds)}s ago\"\n        elif age_seconds < 3600:\n            return f\"{int(age_seconds // 60)}m ago\"\n        else:\n            return f\"{int(age_seconds // 3600)}h ago\"\n\n    def _copy_to_clipboard(self, text: str):\n        \"\"\"Copy text to clipboard using cliphist.\"\"\"\n        try:\n            # First copy to clipboard\n            subprocess.run([\"wl-copy\"], input=text.encode(), check=True)\n            # Then store in cliphist\n            subprocess.run([\"cliphist\", \"store\"], input=text.encode(), check=True)\n        except subprocess.CalledProcessError:\n            # If cliphist fails, at least we have the text in clipboard\n            pass\n\n    def cleanup(self):\n        \"\"\"Clean up resources.\"\"\"\n        self._conversion_cache.clear()\n        self.converter.cleanup()\n"
  },
  {
    "path": "modules/launcher/plugins/clipboard.py",
    "content": "import os\nimport subprocess\nimport sys\nimport tempfile\nimport threading\nimport time\nfrom concurrent.futures import ThreadPoolExecutor\nfrom typing import Dict, List, Optional\n\nfrom gi.repository import GdkPixbuf, GLib\n\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass ClipboardPlugin(PluginBase):\n    def __init__(self):\n        super().__init__()\n        self.name = \"clipboard\"\n        self.display_name = \"Clipboard History\"\n        self.description = \"Search and manage clipboard history using cliphist\"\n\n        # Performance settings - show more history like example_cliphist.py\n        self.max_results = 50\n        # Cache clipboard items for 5 seconds (more responsive)\n        self.cache_ttl = 5\n\n        # Initialize cache and temp directory\n        self.tmp_dir = tempfile.mkdtemp(prefix=\"cliphist-\")\n        # Cache images forever like example_cliphist.py\n        self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {}\n        self.clipboard_items_cache: List[str] = []\n        self.cache_timestamp = 0\n\n        # Threading\n        self.executor = ThreadPoolExecutor(\n            max_workers=2, thread_name_prefix=\"clipboard\"\n        )\n        self.cache_lock = threading.Lock()\n\n        # State tracking\n        self._loading = False\n        self._pending_updates = False\n\n    def initialize(self):\n        \"\"\"Initialize the plugin.\"\"\"\n        self.set_triggers([\"clip\"])\n        try:\n            subprocess.run(\n                [\"cliphist\", \"list\"], capture_output=True, check=True, timeout=5\n            )\n        except (subprocess.SubprocessError, FileNotFoundError):\n            raise RuntimeError(\"cliphist is not installed or not working properly\")\n\n        # Pre-warm cache in background\n        self.executor.submit(self._load_clipboard_items_cached)\n\n    def cleanup(self):\n        \"\"\"Cleanup the plugin.\"\"\"\n        try:\n            # Shutdown executor\n            if hasattr(self, \"executor\"):\n                self.executor.shutdown(wait=False)\n\n            # Clean up temp files\n            if os.path.exists(self.tmp_dir):\n                import shutil\n\n                shutil.rmtree(self.tmp_dir)\n\n            # Clear caches\n            with self.cache_lock:\n                self.image_cache.clear()\n                self.clipboard_items_cache.clear()\n        except Exception as e:\n            print(f\"Error cleaning up temporary files: {e}\", file=sys.stderr)\n\n    def invalidate_cache(self):\n        \"\"\"Force invalidation of the clipboard cache.\"\"\"\n        with self.cache_lock:\n            self.clipboard_items_cache.clear()\n            self.cache_timestamp = 0\n            # Keep image cache forever like example_cliphist.py\n\n    def _force_launcher_refresh(self):\n        \"\"\"Force the launcher to refresh its results.\"\"\"\n        try:\n            from gi.repository import GLib\n\n            def trigger_refresh():\n                try:\n                    # Try to access the launcher through the fabric Application\n                    from fabric import Application\n\n                    app = Application.get_default()\n\n                    if app and hasattr(app, \"launcher\"):\n                        launcher = app.launcher\n                        if (\n                            launcher\n                            and hasattr(launcher, \"search_entry\")\n                            and hasattr(launcher, \"_perform_search\")\n                        ):\n                            # Get current search text to preserve the query\n                            current_text = launcher.search_entry.get_text()\n                            # Trigger the search to refresh results\n                            launcher._perform_search(current_text)\n                            return False\n\n                    # Fallback: try to find launcher instance through other means\n                    import gc\n\n                    for obj in gc.get_objects():\n                        if (\n                            hasattr(obj, \"__class__\")\n                            and obj.__class__.__name__ == \"Launcher\"\n                        ):\n                            if hasattr(obj, \"search_entry\") and hasattr(\n                                obj, \"_perform_search\"\n                            ):\n                                current_text = obj.search_entry.get_text()\n                                obj._perform_search(current_text)\n                                return False\n\n                except Exception as e:\n                    print(f\"Error forcing launcher refresh: {e}\")\n\n                return False  # Don't repeat\n\n            # Use immediate refresh\n            GLib.timeout_add(10, trigger_refresh)\n\n        except Exception as e:\n            print(f\"Could not trigger refresh: {e}\")\n\n    def _load_clipboard_items_cached(self) -> List[str]:\n        \"\"\"Load clipboard items from cliphist with caching and change detection.\"\"\"\n        current_time = time.time()\n\n        # Always load fresh data to check for changes\n        try:\n            result = subprocess.run(\n                [\"cliphist\", \"list\"], capture_output=True, check=True, timeout=5\n            )\n            stdout_str = result.stdout.decode(\"utf-8\", errors=\"replace\")\n            if stdout_str.strip():\n                lines = stdout_str.strip().split(\"\\n\")\n                items = [\n                    line for line in lines if line and \"<meta http-equiv\" not in line\n                ]\n            else:\n                items = []\n\n            # Check if data has changed\n            with self.cache_lock:\n                data_changed = (\n                    not self.clipboard_items_cache\n                    or len(items) != len(self.clipboard_items_cache)\n                    or items != self.clipboard_items_cache\n                )\n\n                # If cache is still valid and data hasn't changed, return cached data\n                if (\n                    not data_changed\n                    and self.clipboard_items_cache\n                    and current_time - self.cache_timestamp < self.cache_ttl\n                ):\n                    return self.clipboard_items_cache.copy()\n\n                # Update cache with fresh data\n                self.clipboard_items_cache = items\n                self.cache_timestamp = current_time\n\n                # Keep image cache forever like example_cliphist.py - don't clear on data changes\n\n            return items\n        except subprocess.CalledProcessError as e:\n            print(f\"Error loading clipboard history: {e}\", file=sys.stderr)\n            return []\n        except Exception as e:\n            print(f\"Unexpected error: {e}\", file=sys.stderr)\n            return []\n\n    def _load_clipboard_items(self) -> List[str]:\n        \"\"\"Load clipboard items (legacy method for compatibility).\"\"\"\n        return self._load_clipboard_items_cached()\n\n    def _create_pixbuf_from_bytes(\n        self, image_data: bytes, max_size: int = 100\n    ) -> GdkPixbuf.Pixbuf:\n        \"\"\"Create a GdkPixbuf from image bytes with size limit.\"\"\"\n        try:\n            loader = GdkPixbuf.PixbufLoader()\n            loader.write(image_data)\n            loader.close()\n            pixbuf = loader.get_pixbuf()\n\n            # Scale image if needed\n            width, height = pixbuf.get_width(), pixbuf.get_height()\n            if width > height:\n                new_width = max_size\n                new_height = int(height * (max_size / width))\n            else:\n                new_height = max_size\n                new_width = int(width * (max_size / height))\n\n            return pixbuf.scale_simple(\n                new_width, new_height, GdkPixbuf.InterpType.BILINEAR\n            )\n        except GLib.Error:\n            return None\n\n    def _is_image_data(self, content: str) -> bool:\n        \"\"\"Determine if clipboard content is likely an image (like example_cliphist.py).\"\"\"\n        return (\n            content.startswith(\"data:image/\")\n            or content.startswith(\"\\x89PNG\")\n            or content.startswith(\"GIF8\")\n            or content.startswith(\"\\xff\\xd8\\xff\")\n            or \"binary\" in content.lower()\n            and any(\n                ext in content.lower() for ext in [\"jpg\", \"jpeg\", \"png\", \"bmp\", \"gif\"]\n            )\n        )\n\n    def _get_text_preview(self, content: str) -> str:\n        \"\"\"Get a text preview of the content.\"\"\"\n        if len(content) > 50:\n            return content[:37] + \"...\"\n        return content\n\n    def _load_image_preview_cached(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]:\n        \"\"\"Load image preview with forever caching (like example_cliphist.py).\"\"\"\n        # Check cache first - cache forever like example_cliphist.py\n        with self.cache_lock:\n            if item_id in self.image_cache:\n                return self.image_cache[item_id]\n\n        try:\n            result = subprocess.run(\n                [\"cliphist\", \"decode\", item_id],\n                capture_output=True,\n                check=True,\n                timeout=3,\n            )\n            pixbuf = self._create_pixbuf_from_bytes(result.stdout)\n            if pixbuf:\n                with self.cache_lock:\n                    self.image_cache[item_id] = pixbuf\n\n            return pixbuf\n        except Exception as e:\n            print(f\"Error loading image preview: {e}\", file=sys.stderr)\n            return None\n\n    def _load_image_preview_async(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]:\n        \"\"\"Load image preview (legacy method for compatibility).\"\"\"\n        return self._load_image_preview_cached(item_id)\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Search clipboard history using cliphist with optimized performance.\"\"\"\n        results = []\n\n        # Handle query string\n        if query_string.lower() == \"clip\":\n            query_string = \"\"  # Show all items\n\n        try:\n            # Load clipboard items from cache\n            clipboard_items = self._load_clipboard_items_cached()\n\n            # Early exit if no items\n            if not clipboard_items:\n                return results\n\n            # Filter items based on query - search through ALL items first\n            filtered_items = []\n            query_lower = query_string.lower() if query_string else \"\"\n\n            for item in clipboard_items:\n                parts = item.split(\"\\t\", 1)\n                content = parts[1] if len(parts) > 1 else item\n                content_lower = content.lower()\n\n                # Fast filtering - search through all items, don't limit here\n                if not query_lower or query_lower in content_lower:\n                    # Calculate relevance score for better sorting\n                    relevance = 1.0\n                    if query_lower:\n                        if content_lower.startswith(query_lower):\n                            relevance = 1.0  # Exact start match\n                        elif query_lower in content_lower:\n                            # Position-based scoring: earlier matches get higher scores\n                            position = content_lower.find(query_lower)\n                            relevance = max(\n                                0.5, 1.0 - (position / len(content_lower)) * 0.4\n                            )\n\n                    filtered_items.append((item, relevance))\n\n            # Sort by relevance (highest first) and then limit results\n            filtered_items.sort(key=lambda x: x[1], reverse=True)\n            filtered_items = filtered_items[: self.max_results]\n\n            # Process items with lazy image loading\n            for i, (item, relevance) in enumerate(filtered_items):\n                parts = item.split(\"\\t\", 1)\n                item_id = parts[0] if len(parts) > 1 else str(i)\n                content = parts[1] if len(parts) > 1 else item\n\n                # Handle image content like example_cliphist.py\n                if self._is_image_data(content):\n                    # Check if image is already cached (forever cache like example_cliphist.py)\n                    cached_pixbuf = None\n                    with self.cache_lock:\n                        cached_pixbuf = self.image_cache.get(item_id)\n\n                    if cached_pixbuf:\n                        # Use cached image immediately\n                        result = Result(\n                            title=\"Image from clipboard\",\n                            subtitle=\"Click to copy image to clipboard\",\n                            description=\"Image content\",\n                            icon=cached_pixbuf,\n                            relevance=relevance,\n                            plugin_name=self.name,\n                            action=lambda id=item_id: self._copy_to_clipboard(id),\n                            data={\"bypass_max_results\": True},\n                        )\n                    else:\n                        # Try to load image immediately like example_cliphist.py\n                        try:\n                            immediate_pixbuf = self._load_image_preview_cached(item_id)\n                            if immediate_pixbuf:\n                                # Successfully loaded immediately\n                                result = Result(\n                                    title=\"Image from clipboard\",\n                                    subtitle=\"Click to copy image to clipboard\",\n                                    description=\"Image content\",\n                                    icon=immediate_pixbuf,\n                                    relevance=relevance,\n                                    plugin_name=self.name,\n                                    action=lambda id=item_id: self._copy_to_clipboard(\n                                        id\n                                    ),\n                                    data={\"bypass_max_results\": True},\n                                )\n                            else:\n                                # Show placeholder if loading failed\n                                result = Result(\n                                    title=\"Image from clipboard\",\n                                    subtitle=\"Click to copy image to clipboard\",\n                                    description=\"Image content\",\n                                    icon_name=\"image-x-generic\",\n                                    relevance=relevance,\n                                    plugin_name=self.name,\n                                    action=lambda id=item_id: self._copy_to_clipboard(\n                                        id\n                                    ),\n                                    data={\"bypass_max_results\": True},\n                                )\n                        except Exception:\n                            # Fallback to placeholder\n                            result = Result(\n                                title=\"Image from clipboard\",\n                                subtitle=\"Click to copy image to clipboard\",\n                                description=\"Image content\",\n                                icon_name=\"image-x-generic\",\n                                relevance=relevance,\n                                plugin_name=self.name,\n                                action=lambda id=item_id: self._copy_to_clipboard(id),\n                                data={\"bypass_max_results\": True},\n                            )\n\n                    results.append(result)\n                    continue\n\n                # Handle text content\n                display_text = self._get_text_preview(content)\n                result = Result(\n                    title=display_text,\n                    subtitle=\"Text from clipboard\",\n                    description=(\n                        content if len(content) <= 100 else content[:97] + \"...\"\n                    ),\n                    icon_name=\"edit-paste\",\n                    relevance=relevance,\n                    plugin_name=self.name,\n                    action=lambda id=item_id: self._copy_to_clipboard(id),\n                    data={\"bypass_max_results\": True},\n                )\n                results.append(result)\n\n        except Exception as e:\n            # Handle errors gracefully\n            results.append(\n                Result(\n                    title=\"Error accessing clipboard history\",\n                    subtitle=str(e),\n                    icon_name=\"dialog-error\",\n                    relevance=0.0,\n                    plugin_name=self.name,\n                    data={\"bypass_max_results\": True},\n                )\n            )\n\n        return results\n\n    def _copy_to_clipboard(self, entry_id: str):\n        \"\"\"Copy entry to clipboard using cliphist with timeout.\"\"\"\n        try:\n            result = subprocess.run(\n                [\"cliphist\", \"decode\", entry_id],\n                capture_output=True,\n                check=True,\n                timeout=5,\n            )\n            # Use wl-copy for Wayland or xclip for X11\n            try:\n                subprocess.run([\"wl-copy\"], input=result.stdout, check=True, timeout=3)\n            except subprocess.SubprocessError:\n                subprocess.run(\n                    [\"xclip\", \"-selection\", \"clipboard\"],\n                    input=result.stdout,\n                    check=True,\n                    timeout=3,\n                )\n\n            # Don't invalidate image cache - keep images cached forever like example_cliphist.py\n            # Only invalidate clipboard items cache\n            with self.cache_lock:\n                self.clipboard_items_cache.clear()\n                self.cache_timestamp = 0\n\n        except subprocess.SubprocessError as e:\n            print(f\"Error copying to clipboard: {e}\", file=sys.stderr)\n        except subprocess.TimeoutExpired as e:\n            print(f\"Timeout copying to clipboard: {e}\", file=sys.stderr)\n"
  },
  {
    "path": "modules/launcher/plugins/emoji.py",
    "content": "import json\nimport os\nimport subprocess\nimport time\nfrom collections import OrderedDict\nfrom typing import Dict, List\n\nimport config.data as data\nfrom fabric.utils import get_relative_path\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass EmojiPlugin(PluginBase):\n    \"\"\"\n    Plugin for searching and copying emojis.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.name = \"emoji\"\n        self.display_name = \"Emoji\"\n        self.description = \"Search and copy emojis\"\n        self.emoji_data = {}\n        self.emoji_path = get_relative_path(\"../../../config/assets/emoji.json\")\n\n        # Use cache directory for recent emojis (save directly in cache dir)\n        self.recent_emoji_path = os.path.join(data.CACHE_DIR, \"recent_emoji.json\")\n        self.recent_emojis = OrderedDict()\n        self.max_recent_emojis = 20  # Maximum number of recent emojis to track\n\n    def initialize(self):\n        \"\"\"Initialize the emoji plugin.\"\"\"\n        self.set_triggers([\"em\"])\n        self._load_emoji_data()\n        self._load_recent_emojis()\n\n    def cleanup(self):\n        \"\"\"Cleanup the emoji plugin.\"\"\"\n        pass\n\n    def _load_emoji_data(self):\n        \"\"\"Load emoji data from JSON file.\"\"\"\n        try:\n            if os.path.exists(self.emoji_path):\n                with open(self.emoji_path, \"r\", encoding=\"utf-8\") as f:\n                    self.emoji_data = json.load(f)\n            else:\n                print(f\"Emoji file not found: {self.emoji_path}\")\n        except Exception as e:\n            print(f\"Error loading emoji data: {e}\")\n\n    def _load_recent_emojis(self):\n        \"\"\"Load recently used emojis from JSON file.\"\"\"\n        try:\n            if os.path.exists(self.recent_emoji_path):\n                with open(self.recent_emoji_path, \"r\", encoding=\"utf-8\") as f:\n                    recent_data = json.load(f)\n                    # Convert to OrderedDict to maintain order\n                    self.recent_emojis = OrderedDict(recent_data)\n            else:\n                # Create empty recent emojis file\n                self.recent_emojis = OrderedDict()\n                self._save_recent_emojis()\n        except Exception as e:\n            print(f\"Error loading recent emoji data: {e}\")\n            self.recent_emojis = OrderedDict()\n\n    def _save_recent_emojis(self):\n        \"\"\"Save recently used emojis to JSON file.\"\"\"\n        try:\n            # Ensure the cache directory exists\n            os.makedirs(data.CACHE_DIR, exist_ok=True)\n\n            with open(self.recent_emoji_path, \"w\", encoding=\"utf-8\") as f:\n                json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2)\n        except Exception as e:\n            print(f\"Error saving recent emoji data: {e}\")\n\n    def _add_to_recent(self, emoji: str):\n        \"\"\"Add an emoji to the recent list.\"\"\"\n        # Remove if already exists (to move it to front)\n        if emoji in self.recent_emojis:\n            del self.recent_emojis[emoji]\n\n        # Add to front with current timestamp\n        self.recent_emojis[emoji] = time.time()\n\n        # Keep only the most recent emojis\n        while len(self.recent_emojis) > self.max_recent_emojis:\n            # Remove the oldest item\n            self.recent_emojis.popitem(last=False)\n\n        # Save to file\n        self._save_recent_emojis()\n\n    def _copy_to_clipboard(self, emoji: str):\n        \"\"\"Copy emoji to clipboard and track usage.\"\"\"\n        try:\n            # Try Wayland first\n            try:\n                subprocess.run([\"wl-copy\"], input=emoji.encode(), check=True)\n            except subprocess.SubprocessError:\n                # Fall back to X11\n                subprocess.run(\n                    [\"xclip\", \"-selection\", \"clipboard\"],\n                    input=emoji.encode(),\n                    check=True,\n                )\n\n            # Track this emoji as recently used\n            self._add_to_recent(emoji)\n\n        except Exception as e:\n            print(f\"Failed to copy to clipboard: {e}\")\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Search emojis based on query.\"\"\"\n        results = []\n        query = query_string.lower().strip()\n\n        # If no query, show recently used emojis\n        if not query:\n            if self.recent_emojis:\n                # Show recent emojis in reverse order (most recent first)\n                for emoji in reversed(list(self.recent_emojis.keys())):\n                    if emoji in self.emoji_data:\n                        emoji_info = self.emoji_data[emoji]\n                        results.append(\n                            self._create_emoji_result(emoji, emoji_info, 1.0)\n                        )\n            else:\n                # If no recent emojis, show some popular ones as fallback\n                popular_emojis = [\"😀\", \"👍\", \"❤️\", \"🎉\", \"🔥\", \"✨\", \"🚀\", \"🌈\"]\n                for emoji in popular_emojis:\n                    if emoji in self.emoji_data:\n                        emoji_info = self.emoji_data[emoji]\n                        results.append(\n                            self._create_emoji_result(emoji, emoji_info, 1.0)\n                        )\n            return results\n\n        # Search by name, group, or the emoji itself\n        for emoji, info in self.emoji_data.items():\n            relevance = 0\n            name = info.get(\"name\", \"\").lower()\n            group = info.get(\"group\", \"\").lower()\n            slug = info.get(\"slug\", \"\").lower()\n\n            # Exact match with emoji\n            if query == emoji:\n                relevance = 1.0\n            # Name contains query\n            elif query in name:\n                relevance = 0.9\n            # Slug contains query\n            elif query in slug:\n                relevance = 0.8\n            # Group contains query\n            elif query in group:\n                relevance = 0.7\n\n            if relevance > 0:\n                results.append(self._create_emoji_result(emoji, info, relevance))\n\n        # Sort by relevance\n        results.sort(key=lambda x: x.relevance, reverse=True)\n        return results[:20]  # Limit to 20 results\n\n    def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result:\n        \"\"\"Create a Result object for an emoji.\"\"\"\n        name = info.get(\"name\", \"\")\n        group = info.get(\"group\", \"\")\n\n        # Check if this is a recently used emoji\n        is_recent = emoji in self.recent_emojis\n        subtitle = f\"{group}\" + (\" • Recently used\" if is_recent else \"\")\n\n        return Result(\n            title=name,  # Show only the name, not the emoji\n            subtitle=subtitle,\n            icon_markup=emoji,  # Use the emoji itself as the icon\n            action=lambda e=emoji: self._copy_to_clipboard(e),\n            relevance=relevance,\n            plugin_name=self.display_name,\n            data={\"emoji\": emoji, \"name\": name, \"group\": group, \"recent\": is_recent},\n        )\n"
  },
  {
    "path": "modules/launcher/plugins/otp.py",
    "content": "import json\nimport subprocess\nimport threading\nimport time\nfrom pathlib import Path\nfrom typing import Dict, List, Optional\n\nfrom fabric.utils import get_relative_path\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\nfrom services.auth import (\n    generate_totp,\n    get_time_remaining_with_blink,\n    parse_otpauth_uri,\n    scan_qr_and_add_account,\n    validate_base32_secret,\n)\n\n\nclass OTPPlugin(PluginBase):\n    \"\"\"Plugin for managing TOTP (Time-based One-Time Password) codes.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"OTP Manager\"\n        self.description = \"Manage TOTP codes and 2FA authentication\"\n\n        self.secrets_file = Path(\n            get_relative_path(\"../../../config/assets/accounts.json\")\n        )\n        self.secrets: Dict[str, Dict] = {}\n        self.last_update = 0\n\n        # Threading for auto-refresh\n        self.refresh_thread = None\n        self.stop_refresh = threading.Event()\n\n    def initialize(self):\n        \"\"\"Initialize the OTP plugin.\"\"\"\n        self.set_triggers([\"otp\"])\n        self._load_secrets()\n        self._ensure_config_file()\n        self._start_refresh_thread()\n\n    def cleanup(self):\n        \"\"\"Cleanup the OTP plugin.\"\"\"\n        if self.refresh_thread and self.refresh_thread.is_alive():\n            self.stop_refresh.set()\n            self.refresh_thread.join(timeout=1)\n\n    def _load_secrets(self):\n        \"\"\"Load secrets from JSON file.\"\"\"\n        try:\n            if self.secrets_file.exists():\n                with open(self.secrets_file, \"r\", encoding=\"utf-8\") as f:\n                    self.secrets = json.load(f)\n            else:\n                self.secrets = {}\n        except Exception as e:\n            print(f\"Error loading OTP secrets: {e}\")\n            self.secrets = {}\n\n    def _save_secrets(self):\n        \"\"\"Save secrets to JSON file.\"\"\"\n        try:\n            self.secrets_file.parent.mkdir(parents=True, exist_ok=True)\n            with open(self.secrets_file, \"w\", encoding=\"utf-8\") as f:\n                json.dump(self.secrets, f, indent=2)\n        except Exception as e:\n            print(f\"Error saving OTP secrets: {e}\")\n\n    def _ensure_config_file(self):\n        \"\"\"Ensure the config file exists.\"\"\"\n        if not self.secrets_file.exists():\n            self.secrets_file.parent.mkdir(parents=True, exist_ok=True)\n            with open(self.secrets_file, \"w\", encoding=\"utf-8\") as f:\n                json.dump({}, f, indent=2)\n\n    def _start_refresh_thread(self):\n        \"\"\"Start background thread for auto-refreshing tokens.\"\"\"\n\n        def refresh_loop():\n            while not self.stop_refresh.wait(5):\n                current_time = time.time()\n                if current_time - self.last_update >= 5:\n                    self.last_update = current_time\n                    # Only refresh if we have secrets and launcher is likely active\n                    if self.secrets:\n                        try:\n                            self._selective_force_refresh()\n                        except Exception:\n                            pass\n\n        self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True)\n        self.refresh_thread.start()\n\n    def _selective_force_refresh(self):\n        \"\"\"Update time display in existing OTP result items.\"\"\"\n        try:\n            import gc\n\n            from gi.repository import GLib\n\n            def do_update():\n                try:\n                    for obj in gc.get_objects():\n                        if (\n                            hasattr(obj, \"__class__\")\n                            and obj.__class__.__name__ == \"Launcher\"\n                            and hasattr(obj, \"results_box\")\n                            and hasattr(obj, \"visible\")\n                            and obj.visible\n                            and hasattr(obj, \"results\")\n                            and obj.results\n                        ):\n                            has_otp_results = any(\n                                result.data and result.data.get(\"type\") == \"totp\"\n                                for result in obj.results\n                                if hasattr(result, \"data\") and result.data\n                            )\n\n                            if has_otp_results:\n                                self._update_existing_result_labels(obj.results_box)\n                                return False\n                except Exception:\n                    pass\n                return False\n\n            GLib.idle_add(do_update)\n        except Exception:\n            pass\n\n    def _update_existing_result_labels(self, results_box):\n        \"\"\"Update subtitle labels in existing ResultItem widgets.\"\"\"\n        try:\n            time_display = self._get_time_remaining_with_blink()\n            for child in results_box.get_children():\n                if (\n                    hasattr(child, \"__class__\")\n                    and child.__class__.__name__ == \"ResultItem\"\n                    and hasattr(child, \"result\")\n                    and hasattr(child.result, \"data\")\n                    and child.result.data\n                    and child.result.data.get(\"type\") == \"totp\"\n                ):\n                    self._update_result_item_content(child, time_display)\n        except Exception as e:\n            print(f\"Error updating result labels: {e}\")\n\n    def _update_result_item_content(self, result_item, time_display):\n        \"\"\"Update both the title (OTP code) and subtitle (time display) of a specific ResultItem.\"\"\"\n        try:\n            account_name = result_item.result.data.get(\"account\", \"\")\n            if not account_name or account_name not in self.secrets:\n                return\n\n            account_data = self.secrets[account_name]\n            secret = account_data.get(\"secret\", \"\")\n            issuer = account_data.get(\"issuer\", \"\")\n            display_name = f\"{issuer} - {account_name}\" if issuer else account_name\n\n            current_totp_code = self._generate_totp(secret)\n            if not current_totp_code:\n                return\n\n            old_code = result_item.result.data.get(\"code\", \"\")\n            if current_totp_code != old_code:\n                result_item.result.data[\"code\"] = current_totp_code\n                self._find_and_update_title_label(result_item, current_totp_code)\n                result_item.result.action = (\n                    lambda code=current_totp_code: self._copy_to_clipboard(code)\n                )\n\n            new_subtitle_markup = f\"{display_name} • {time_display} remaining\"\n            self._find_and_update_subtitle_label(result_item, new_subtitle_markup)\n        except Exception as e:\n            print(f\"Error updating result item: {e}\")\n\n    def _find_and_update_title_label(self, result_item, new_title):\n        \"\"\"Find the title label widget and update its text.\"\"\"\n\n        def find_title_label(widget):\n            if hasattr(widget, \"get_name\") and widget.get_name() == \"result-item-title\":\n                return widget\n            if hasattr(widget, \"get_children\"):\n                for child in widget.get_children():\n                    found = find_title_label(child)\n                    if found:\n                        return found\n            return None\n\n        title_label = find_title_label(result_item)\n        if title_label and hasattr(title_label, \"set_label\"):\n            title_label.set_label(new_title)\n\n    def _find_and_update_subtitle_label(self, result_item, new_markup):\n        \"\"\"Find the subtitle label widget and update its markup.\"\"\"\n\n        def find_subtitle_label(widget):\n            if (\n                hasattr(widget, \"get_name\")\n                and widget.get_name() == \"result-item-subtitle\"\n            ):\n                return widget\n            if hasattr(widget, \"get_children\"):\n                for child in widget.get_children():\n                    found = find_subtitle_label(child)\n                    if found:\n                        return found\n            return None\n\n        subtitle_label = find_subtitle_label(result_item)\n        if subtitle_label and hasattr(subtitle_label, \"set_markup\"):\n            subtitle_label.set_markup(new_markup)\n\n    def _copy_to_clipboard(self, text: str):\n        \"\"\"Copy text to clipboard.\"\"\"\n        try:\n            try:\n                subprocess.run([\"wl-copy\"], input=text.encode(), check=True)\n            except subprocess.SubprocessError:\n                subprocess.run(\n                    [\"xclip\", \"-selection\", \"clipboard\"],\n                    input=text.encode(),\n                    check=True,\n                )\n        except Exception as e:\n            print(f\"Failed to copy to clipboard: {e}\")\n\n    def _trigger_refresh(self):\n        \"\"\"Trigger launcher refresh to return to default OTP view.\"\"\"\n        try:\n            from gi.repository import GLib\n\n            # Use a small delay to ensure the action completes first\n            def trigger_refresh():\n                try:\n                    # Try to access the launcher through the fabric Application\n                    from fabric import Application\n\n                    app = Application.get_default()\n\n                    if app and hasattr(app, \"launcher\"):\n                        launcher = app.launcher\n                        if launcher and hasattr(launcher, \"search_entry\"):\n                            # Clear the search entry and set it to just \"otp \"\n                            launcher.search_entry.set_text(\"otp \")\n                            # Position cursor at the end\n                            launcher.search_entry.set_position(-1)\n                            # Trigger the search to show default OTP view\n                            if hasattr(launcher, \"_perform_search\"):\n                                launcher._perform_search(\"otp \")\n                            return False\n\n                    # Fallback: try to find launcher instance through other means\n                    import gc\n\n                    for obj in gc.get_objects():\n                        if (\n                            hasattr(obj, \"__class__\")\n                            and obj.__class__.__name__ == \"Launcher\"\n                        ):\n                            if hasattr(obj, \"search_entry\") and hasattr(\n                                obj, \"_perform_search\"\n                            ):\n                                obj.search_entry.set_text(\"otp \")\n                                obj.search_entry.set_position(-1)\n                                obj._perform_search(\"otp \")\n                                return False\n\n                except Exception as e:\n                    print(f\"Error forcing launcher refresh: {e}\")\n\n                return False  # Don't repeat\n\n            # Use a small delay to ensure the action completes first\n            GLib.timeout_add(50, trigger_refresh)\n\n        except Exception as e:\n            print(f\"Could not trigger refresh: {e}\")\n\n    def _remove_account_and_refresh(self, account_name: str):\n        \"\"\"Remove an account and trigger refresh to return to default OTP view.\"\"\"\n        try:\n            if account_name in self.secrets:\n                # Remove the account\n                del self.secrets[account_name]\n                self._save_secrets()\n\n                # Trigger refresh to return to default OTP view\n                self._trigger_refresh()\n        except Exception as e:\n            print(f\"Error removing account {account_name}: {e}\")\n\n    def _generate_totp(self, secret: str) -> Optional[str]:\n        \"\"\"Generate TOTP code from secret.\"\"\"\n        return generate_totp(secret)\n\n    def _get_time_remaining_with_blink(self) -> str:\n        \"\"\"Get time remaining with blinking effect.\"\"\"\n        return get_time_remaining_with_blink()\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Process OTP queries.\"\"\"\n        query = query_string.strip()\n\n        if not query:\n            return self._list_otp_codes()\n\n        query_lower = query.lower()\n        if query_lower.startswith(\"add \"):\n            add_content = query[4:].strip()\n\n            # Handle both formats: with ``` and without ```\n            if \"```\" in add_content:\n                # Old format: add account```secret```\n                parts = add_content.split(\"```\", 1)\n                if len(parts) == 2:\n                    account_name = parts[0].strip()\n                    secret_or_uri = parts[1].strip()\n                    return self._handle_direct_add(account_name, secret_or_uri)\n            elif \" \" in add_content:\n                # New format: add account secret\n                parts = add_content.split(\" \", 1)\n                if len(parts) == 2:\n                    account_name = parts[0].strip()\n                    secret_or_uri = parts[1].strip()\n                    return self._handle_direct_add(account_name, secret_or_uri)\n\n            return self._handle_add_command(add_content)\n        elif query_lower == \"remove\" or query_lower.startswith(\"remove \"):\n            # Handle both \"remove\" and \"remove accountname\"\n            if query_lower == \"remove\":\n                remove_content = \"\"\n            else:\n                remove_content = query[7:].strip()\n            return self._handle_remove_command(remove_content)\n        elif query_lower == \"qr\" or query_lower.startswith(\"qr \"):\n            # Handle QR scanning command\n            if query_lower == \"qr\":\n                qr_content = \"\"\n            else:\n                qr_content = query[3:].strip()\n            return self._handle_qr_command(qr_content)\n        else:\n            return self._search_accounts(query)\n\n    def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]:\n        \"\"\"Handle direct addition of OTP account.\"\"\"\n        if not account_name or not secret_or_uri:\n            return [\n                Result(\n                    title=\"Invalid format\",\n                    subtitle=\"Usage: add <account_name> <secret> or add <account_name>```<secret>```\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"help\", \"keep_launcher_open\": True},\n                )\n            ]\n\n        try:\n            if secret_or_uri.startswith(\"otpauth://\"):\n                return self._handle_otpauth_uri(account_name, secret_or_uri)\n            else:\n                return self._handle_base32_secret(account_name, secret_or_uri)\n        except Exception as e:\n            print(f\"OTP Debug: Error in _handle_direct_add: {e}\")\n            return [\n                Result(\n                    title=\"Error adding account\",\n                    subtitle=f\"Debug: {str(e)}\",\n                    icon_name=\"cancel\",\n                    action=lambda: None,\n                    relevance=0.5,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            ]\n\n    def _list_otp_codes(self) -> List[Result]:\n        \"\"\"List all OTP codes with current tokens.\"\"\"\n        results = []\n\n        if not self.secrets:\n            results.append(\n                Result(\n                    title=\"No OTP accounts configured\",\n                    subtitle=\"Use 'add <account> <secret>' to add your first account\",\n                    icon_name=\"gtk-authentication-symbolic\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"empty\", \"keep_launcher_open\": True},\n                )\n            )\n            results.append(\n                Result(\n                    title=\"Available commands:\",\n                    subtitle=\"add <account> <secret> | remove <account> | qr <account>\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=0.9,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"help\", \"keep_launcher_open\": True},\n                )\n            )\n            return results\n\n        time_display = self._get_time_remaining_with_blink()\n\n        for account_name, account_data in self.secrets.items():\n            secret = account_data.get(\"secret\", \"\")\n            issuer = account_data.get(\"issuer\", \"\")\n            totp_code = self._generate_totp(secret)\n\n            if totp_code:\n                display_name = f\"{issuer} - {account_name}\" if issuer else account_name\n                results.append(\n                    Result(\n                        title=f\"{totp_code}\",\n                        subtitle_markup=f\"{display_name} • {\n                            time_display\n                        } remaining • Shift+Enter: remove\",\n                        icon_name=\"gtk-authentication-symbolic\",\n                        action=lambda code=totp_code: self._copy_to_clipboard(code),\n                        relevance=1.0,\n                        plugin_name=self.display_name,\n                        data={\n                            \"type\": \"totp\",\n                            \"account\": account_name,\n                            \"code\": totp_code,\n                            \"alt_action\": lambda acc=account_name: self._remove_account_and_refresh(\n                                acc\n                            ),\n                        },\n                    )\n                )\n            else:\n                results.append(\n                    Result(\n                        title=f\"Error: {account_name}\",\n                        subtitle=\"Invalid secret or configuration\",\n                        icon_name=\"dialog-cancel-symbolic\",\n                        action=lambda: None,\n                        relevance=0.5,\n                        plugin_name=self.display_name,\n                        data={\n                            \"type\": \"error\",\n                            \"account\": account_name,\n                            \"keep_launcher_open\": True,\n                        },\n                    )\n                )\n\n        return results\n\n    def _search_accounts(self, query: str) -> List[Result]:\n        \"\"\"Search accounts by name or issuer.\"\"\"\n        results = []\n        query_lower = query.lower()\n\n        for account_name, account_data in self.secrets.items():\n            issuer = account_data.get(\"issuer\", \"\").lower()\n            account_lower = account_name.lower()\n\n            if query_lower in account_lower or query_lower in issuer:\n                secret = account_data.get(\"secret\", \"\")\n                totp_code = self._generate_totp(secret)\n\n                if totp_code:\n                    display_name = (\n                        f\"{account_data.get('issuer', '')} - {account_name}\"\n                        if account_data.get(\"issuer\")\n                        else account_name\n                    )\n                    time_display = self._get_time_remaining_with_blink()\n\n                    results.append(\n                        Result(\n                            title=f\"{totp_code}\",\n                            subtitle_markup=f\"{display_name} • {\n                                time_display\n                            } remaining • Shift+Enter: remove\",\n                            icon_name=\"gtk-authentication-symbolic\",\n                            action=lambda code=totp_code: self._copy_to_clipboard(code),\n                            relevance=1.0,\n                            plugin_name=self.display_name,\n                            data={\n                                \"type\": \"totp\",\n                                \"account\": account_name,\n                                \"code\": totp_code,\n                                \"alt_action\": lambda acc=account_name: self._remove_account_and_refresh(\n                                    acc\n                                ),\n                            },\n                        )\n                    )\n\n        if not results:\n            results.append(\n                Result(\n                    title=f\"No accounts found for '{query}'\",\n                    subtitle=\"Use 'add <account> <secret>' to create new account\",\n                    icon_name=\"edit-find-symbolic\",\n                    action=lambda: None,\n                    relevance=0.5,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"no_results\", \"keep_launcher_open\": True},\n                )\n            )\n            results.append(\n                Result(\n                    title=\"Available commands:\",\n                    subtitle=\"add <account> <secret> | remove <account> | qr <account>\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=0.4,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"help\", \"keep_launcher_open\": True},\n                )\n            )\n\n        return results\n\n    def _handle_add_command(self, account_name: str) -> List[Result]:\n        \"\"\"Handle manual addition of OTP secret.\"\"\"\n        if not account_name:\n            return [\n                Result(\n                    title=\"Enter account name\",\n                    subtitle=\"Usage: add <account_name> <secret>\",\n                    icon_name=\"dialog-question-symbolic\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"help\", \"keep_launcher_open\": True},\n                )\n            ]\n\n        return [\n            Result(\n                title=f\"To add '{account_name}':\",\n                subtitle=f\"Type: add {account_name} <secret>\",\n                icon_name=\"info\",\n                action=lambda: None,\n                relevance=1.0,\n                plugin_name=self.display_name,\n                data={\n                    \"type\": \"instruction\",\n                    \"account\": account_name,\n                    \"keep_launcher_open\": True,\n                },\n            ),\n            Result(\n                title=\"Base32 Secret Format:\",\n                subtitle=\"Example: add gmail JBSWY3DPEHPK3PXP\",\n                icon_name=\"info\",\n                action=lambda: None,\n                relevance=0.9,\n                plugin_name=self.display_name,\n                data={\"type\": \"help\", \"keep_launcher_open\": True},\n            ),\n            Result(\n                title=\"otpauth URI Format:\",\n                subtitle=\"Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP\",\n                icon_name=\"info\",\n                action=lambda: None,\n                relevance=0.9,\n                plugin_name=self.display_name,\n                data={\"type\": \"help\", \"keep_launcher_open\": True},\n            ),\n            Result(\n                title=\"Remove Account:\",\n                subtitle=\"Example: remove gmail\",\n                icon_name=\"trash\",\n                action=lambda: None,\n                relevance=0.8,\n                plugin_name=self.display_name,\n                data={\"type\": \"help\", \"keep_launcher_open\": True},\n            ),\n        ]\n\n    def _handle_qr_command(self, account_name: str) -> List[Result]:\n        \"\"\"Handle QR scanning command.\"\"\"\n        if not account_name:\n            return [\n                Result(\n                    title=\"Scan QR Code\",\n                    subtitle=\"Click to scan QR code from screen\",\n                    icon_name=\"view-barcode-qr-symbolic\",\n                    action=lambda: self._scan_qr_and_add_account(\"\"),\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"qr_scan\"},\n                ),\n                Result(\n                    title=\"QR Scan Instructions:\",\n                    subtitle=\"Use 'qr <account_name>' to specify account name\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=0.9,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"help\", \"keep_launcher_open\": True},\n                ),\n            ]\n        else:\n            return [\n                Result(\n                    title=f\"Scan QR Code for '{account_name}'\",\n                    subtitle=\"Click to scan QR code from screen\",\n                    icon_name=\"view-barcode-qr-symbolic\",\n                    action=lambda name=account_name: self._scan_qr_and_add_account(\n                        name\n                    ),\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"qr_scan\"},\n                ),\n            ]\n\n    def _scan_qr_and_add_account(self, account_name: str):\n        \"\"\"Scan QR code and add OTP account.\"\"\"\n        print(f\"QR scan action called for account: '{account_name}'\")\n        print(\"Starting QR scan process asynchronously...\")\n\n        # Run QR scanning asynchronously so launcher closes immediately\n        import threading\n\n        thread = threading.Thread(target=self._scan_qr_async, args=(account_name,))\n        thread.daemon = True\n        thread.start()\n\n    def _scan_qr_async(self, account_name: str):\n        \"\"\"Async QR scanning process.\"\"\"\n        result = scan_qr_and_add_account(account_name, str(self.secrets_file))\n\n        if result[\"success\"]:\n            print(result[\"message\"])\n            # Reload secrets from file\n            self._load_secrets()\n            # Trigger refresh to show the new account\n            self._trigger_refresh()\n        else:\n            print(f\"QR scan failed: {result['error']}\")\n\n    def _handle_remove_command(self, account_name: str) -> List[Result]:\n        \"\"\"Handle removal of OTP account.\"\"\"\n        if not account_name:\n            # Show all available accounts for removal\n            results = []\n\n            if not self.secrets:\n                results.append(\n                    Result(\n                        title=\"No OTP accounts to remove\",\n                        subtitle=\"Use 'add <account> <secret>' to add accounts first\",\n                        icon_name=\"info\",\n                        action=lambda: None,\n                        relevance=1.0,\n                        plugin_name=self.display_name,\n                        data={\"type\": \"empty\", \"keep_launcher_open\": True},\n                    )\n                )\n            else:\n                results.append(\n                    Result(\n                        title=\"Select account to remove:\",\n                        subtitle=\"Type: remove <account_name> to remove an account\",\n                        icon_name=\"user-trash-symbolic\",\n                        action=lambda: None,\n                        relevance=1.0,\n                        plugin_name=self.display_name,\n                        data={\"type\": \"help\", \"keep_launcher_open\": True},\n                    )\n                )\n\n                # Get time display for consistency with main OTP view\n                time_display = self._get_time_remaining_with_blink()\n\n                # Show all accounts with their current OTP codes and remove actions\n                for acc_name, account_data in self.secrets.items():\n                    secret = account_data.get(\"secret\", \"\")\n                    issuer = account_data.get(\"issuer\", \"\")\n                    display_name = f\"{issuer} - {acc_name}\" if issuer else acc_name\n\n                    # Generate current TOTP code\n                    totp_code = self._generate_totp(secret)\n\n                    if totp_code:\n                        results.append(\n                            Result(\n                                title=f\"{totp_code}\",\n                                subtitle_markup=f\"Press Enter to remove • {\n                                    time_display\n                                } remaining\",\n                                icon_name=\"user-trash-symbolic\",\n                                action=lambda acc=acc_name: self._remove_account_and_refresh(\n                                    acc\n                                ),\n                                relevance=0.9,\n                                plugin_name=self.display_name,\n                                data={\n                                    \"type\": \"remove_instruction\",\n                                    \"account\": acc_name,\n                                    \"code\": totp_code,\n                                    \"keep_launcher_open\": True,\n                                },\n                            )\n                        )\n                    else:\n                        results.append(\n                            Result(\n                                title=f\"Error: {acc_name}\",\n                                subtitle=\"Press Enter to remove (Invalid secret)\",\n                                icon_name=\"user-trash-symbolic\",\n                                action=lambda acc=acc_name: self._remove_account_and_refresh(\n                                    acc\n                                ),\n                                relevance=0.8,\n                                plugin_name=self.display_name,\n                                data={\n                                    \"type\": \"remove_instruction\",\n                                    \"account\": acc_name,\n                                    \"keep_launcher_open\": True,\n                                },\n                            )\n                        )\n\n            return results\n\n        # Check if account exists\n        if account_name not in self.secrets:\n            return [\n                Result(\n                    title=f\"Account '{account_name}' not found\",\n                    subtitle=\"Use 'remove' to see all available accounts\",\n                    icon_name=\"dialog-cancel-symbolic\",\n                    action=lambda: None,\n                    relevance=0.5,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            ]\n\n        # Get account info for confirmation\n        account_data = self.secrets[account_name]\n        issuer = account_data.get(\"issuer\", \"\")\n        display_name = f\"{issuer} - {account_name}\" if issuer else account_name\n\n        # Show confirmation for removal\n        return [\n            Result(\n                title=f\"Remove '{display_name}'?\",\n                subtitle=\"Press Enter to confirm removal\",\n                icon_name=\"user-trash-symbolic\",\n                action=lambda acc=account_name: self._remove_account_and_refresh(acc),\n                relevance=1.0,\n                plugin_name=self.display_name,\n                data={\n                    \"type\": \"remove_confirm\",\n                    \"account\": account_name,\n                    \"keep_launcher_open\": True,\n                },\n            )\n        ]\n\n    def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]:\n        \"\"\"Handle raw Base32 secret.\"\"\"\n        result = validate_base32_secret(secret)\n\n        if not result[\"success\"]:\n            return [\n                Result(\n                    title=\"Invalid Base32 secret\",\n                    subtitle=result[\"error\"],\n                    icon_name=\"dialog-cancel-symbolic\",\n                    action=lambda: None,\n                    relevance=0.5,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            ]\n\n        self.secrets[account_name] = {\n            \"secret\": result[\"secret\"],\n            \"issuer\": \"\",\n            \"algorithm\": \"SHA1\",\n            \"digits\": 6,\n            \"period\": 30,\n        }\n        self._save_secrets()\n\n        return [\n            Result(\n                title=f\"✓ Added '{account_name}'\",\n                subtitle=f\"OTP account added successfully (secret: {\n                    result['secret'][:4]\n                }...)\",\n                icon_name=\"emblem-ok-symbolic\",\n                action=lambda: self._trigger_refresh(),\n                relevance=1.0,\n                plugin_name=self.display_name,\n                data={\"type\": \"success\", \"keep_launcher_open\": True},\n            )\n        ]\n\n    def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]:\n        \"\"\"Handle otpauth:// URI.\"\"\"\n        result = parse_otpauth_uri(uri, account_name)\n\n        if not result[\"success\"]:\n            return [\n                Result(\n                    title=\"Error parsing otpauth URI\",\n                    subtitle=result[\"error\"],\n                    icon_name=\"dialog-cancel-symbolic\",\n                    action=lambda: None,\n                    relevance=0.5,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            ]\n\n        self.secrets[result[\"account_name\"]] = {\n            \"secret\": result[\"secret\"],\n            \"issuer\": result[\"issuer\"],\n            \"algorithm\": result[\"algorithm\"],\n            \"digits\": result[\"digits\"],\n            \"period\": result[\"period\"],\n        }\n        self._save_secrets()\n\n        display_name = (\n            f\"{result['issuer']} - {result['account_name']}\"\n            if result[\"issuer\"]\n            else result[\"account_name\"]\n        )\n        return [\n            Result(\n                title=f\"✓ Added '{display_name}'\",\n                subtitle=\"OTP account added from URI\",\n                icon_name=\"emblem-ok-symbolic\",\n                action=lambda: self._trigger_refresh(),\n                relevance=1.0,\n                plugin_name=self.display_name,\n                data={\"type\": \"success\", \"keep_launcher_open\": True},\n            )\n        ]\n"
  },
  {
    "path": "modules/launcher/plugins/password.py",
    "content": "import base64\nimport json\nimport subprocess\nimport threading\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Dict, List, Optional\n\nfrom fabric.utils import get_relative_path\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass PasswordManager:\n    \"\"\"Simple password manager with basic encryption and caching.\"\"\"\n\n    def __init__(self, storage_file: Path):\n        self.storage_file = storage_file\n        self.passwords: Dict[str, Dict] = {}\n        self._cache_lock = threading.Lock()\n        self._last_loaded = 0\n        self._cache_ttl = 30  # Cache for 30 seconds\n        self._load_passwords()\n\n    def _simple_encrypt(self, text: str, key: str = \"modus_pass\") -> str:\n        \"\"\"Simple encryption using XOR with base64 encoding.\"\"\"\n        key_bytes = key.encode(\"utf-8\")\n        text_bytes = text.encode(\"utf-8\")\n\n        # XOR encryption\n        encrypted = bytearray()\n        for i, byte in enumerate(text_bytes):\n            encrypted.append(byte ^ key_bytes[i % len(key_bytes)])\n\n        # Base64 encode\n        return base64.b64encode(encrypted).decode(\"utf-8\")\n\n    def _simple_decrypt(self, encrypted_text: str, key: str = \"modus_pass\") -> str:\n        \"\"\"Simple decryption using XOR with base64 decoding.\"\"\"\n        try:\n            key_bytes = key.encode(\"utf-8\")\n\n            # Base64 decode\n            encrypted_bytes = base64.b64decode(encrypted_text.encode(\"utf-8\"))\n\n            # XOR decryption\n            decrypted = bytearray()\n            for i, byte in enumerate(encrypted_bytes):\n                decrypted.append(byte ^ key_bytes[i % len(key_bytes)])\n\n            return decrypted.decode(\"utf-8\")\n        except Exception:\n            return encrypted_text  # Return as-is if decryption fails\n\n    def _load_passwords(self):\n        \"\"\"Load passwords from JSON file with caching.\"\"\"\n        with self._cache_lock:\n            current_time = time.time()\n\n            # Check if cache is still valid\n            if (current_time - self._last_loaded) < self._cache_ttl and self.passwords:\n                return\n\n            try:\n                if self.storage_file.exists():\n                    with open(self.storage_file, \"r\", encoding=\"utf-8\") as f:\n                        data = json.load(f)\n                        self.passwords = data.get(\"passwords\", {})\n                else:\n                    self.passwords = {}\n\n                self._last_loaded = current_time\n            except Exception as e:\n                print(f\"Error loading passwords: {e}\")\n                self.passwords = {}\n\n    def _save_passwords(self):\n        \"\"\"Save passwords to JSON file.\"\"\"\n        with self._cache_lock:\n            try:\n                self.storage_file.parent.mkdir(parents=True, exist_ok=True)\n                data = {\n                    \"passwords\": self.passwords,\n                    \"last_modified\": datetime.now().isoformat(),\n                }\n                with open(self.storage_file, \"w\", encoding=\"utf-8\") as f:\n                    json.dump(data, f, indent=2)\n\n                # Update cache timestamp\n                self._last_loaded = time.time()\n            except Exception as e:\n                print(f\"Error saving passwords: {e}\")\n\n    def add_password(self, name: str, password: str, description: str = \"\") -> bool:\n        \"\"\"Add a new password entry.\"\"\"\n        try:\n            encrypted_password = self._simple_encrypt(password)\n            self.passwords[name] = {\n                \"password\": encrypted_password,\n                \"description\": description,\n                \"created\": datetime.now().isoformat(),\n                \"last_accessed\": None,\n            }\n            self._save_passwords()\n            return True\n        except Exception as e:\n            print(f\"Error adding password: {e}\")\n            return False\n\n    def get_password(self, name: str, update_access_time: bool = True) -> Optional[str]:\n        \"\"\"Get decrypted password by name.\"\"\"\n        # Ensure we have fresh data\n        self._load_passwords()\n\n        if name in self.passwords:\n            try:\n                encrypted = self.passwords[name][\"password\"]\n                decrypted = self._simple_decrypt(encrypted)\n\n                # Update last accessed time only if requested (to avoid frequent saves)\n                if update_access_time:\n                    self.passwords[name][\"last_accessed\"] = datetime.now().isoformat()\n                    # Don't save immediately - batch saves for better performance\n\n                return decrypted\n            except Exception as e:\n                print(f\"Error decrypting password: {e}\")\n                return None\n        return None\n\n    def remove_password(self, name: str) -> bool:\n        \"\"\"Remove a password entry.\"\"\"\n        if name in self.passwords:\n            del self.passwords[name]\n            self._save_passwords()\n            return True\n        return False\n\n    def list_passwords(self) -> List[str]:\n        \"\"\"Get list of all password names.\"\"\"\n        # Ensure we have fresh data\n        self._load_passwords()\n        return list(self.passwords.keys())\n\n    def get_password_info(self, name: str) -> Optional[Dict]:\n        \"\"\"Get password metadata without decrypting.\"\"\"\n        if name in self.passwords:\n            info = self.passwords[name].copy()\n            info.pop(\"password\", None)  # Remove encrypted password\n            return info\n        return None\n\n\nclass PasswordPlugin(PluginBase):\n    \"\"\"\n    Password manager plugin for the launcher.\n    Stores passwords securely and allows easy access.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Password Manager\"\n        self.description = \"Secure password storage and management\"\n\n        # Initialize password manager\n        self.password_file = Path(\n            get_relative_path(\"../../../config/assets/passwords.json\")\n        )\n        self.password_manager = PasswordManager(self.password_file)\n\n        # State for password visibility\n        self.revealed_passwords: Dict[str, str] = {}\n\n        # Cache for results to avoid repeated queries\n        self._results_cache: Dict[str, List[Result]] = {}\n        self._cache_timestamps: Dict[str, float] = {}\n        self._cache_ttl = 5  # Cache results for 5 seconds\n\n        # Track launcher state for auto-hiding passwords\n        self._launcher_instance = None\n\n    def initialize(self):\n        \"\"\"Initialize the password plugin.\"\"\"\n        self.set_triggers([\"pass\"])\n        self._setup_launcher_hooks()\n\n    def cleanup(self):\n        \"\"\"Cleanup the password plugin.\"\"\"\n        self.revealed_passwords.clear()\n        self._results_cache.clear()\n        self._cache_timestamps.clear()\n        self._cleanup_launcher_hooks()\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Process password manager queries with caching.\"\"\"\n        query_key = query_string.strip()\n        current_time = time.time()\n\n        # Check cache first (except for add/remove commands which should always execute)\n        if (\n            not query_key.startswith((\"add \", \"remove \", \"delete \"))\n            and query_key in self._results_cache\n            and (current_time - self._cache_timestamps.get(query_key, 0))\n            < self._cache_ttl\n        ):\n            return self._results_cache[query_key]\n\n        results = []\n        query = query_key.lower()\n\n        # Handle different commands\n        if not query:\n            # Show all passwords\n            results.extend(self._list_all_passwords())\n        elif query.startswith(\"add \"):\n            # Add new password (don't cache)\n            results.extend(self._handle_add_command(query_string))\n        elif query.startswith(\"remove \") or query.startswith(\"delete \"):\n            # Remove password (don't cache)\n            results.extend(self._handle_remove_command(query_string))\n        else:\n            # Search for specific password\n            results.extend(self._search_passwords(query))\n\n        # Cache results (except for add/remove commands)\n        if not query.startswith((\"add \", \"remove \", \"delete \")):\n            self._results_cache[query_key] = results\n            self._cache_timestamps[query_key] = current_time\n\n        return results\n\n    def _list_all_passwords(self) -> List[Result]:\n        \"\"\"List all stored passwords.\"\"\"\n        results = []\n        password_names = self.password_manager.list_passwords()\n\n        if not password_names:\n            results.append(\n                Result(\n                    title=\"No passwords stored\",\n                    subtitle=\"Use 'pass add <name> <password>' to add your first password\",\n                    icon_name=\"password-symbolic\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"empty\", \"keep_launcher_open\": True},\n                )\n            )\n            results.append(\n                Result(\n                    title=\"Available commands:\",\n                    subtitle=\"add <name> <password> | remove <name> | <name> (to search)\",\n                    icon_name=\"info\",\n                    action=lambda: None,\n                    relevance=0.9,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"help\", \"keep_launcher_open\": True},\n                )\n            )\n            return results\n\n        # Sort passwords alphabetically\n        password_names.sort()\n\n        for name in password_names:\n            info = self.password_manager.get_password_info(name)\n            description = info.get(\"description\", \"\") if info else \"\"\n\n            # Check if password is revealed\n            if name in self.revealed_passwords:\n                title = f\"{name}: {self.revealed_passwords[name]}\"\n                subtitle = \"Password revealed - Enter: copy | Shift+Enter: hide\"\n            else:\n                title = f\"{name}: {'*' * 8}\"\n                subtitle = \"Enter: copy | Shift+Enter: reveal password\"\n\n            if description:\n                subtitle += f\" | {description}\"\n\n            results.append(\n                Result(\n                    title=title,\n                    subtitle=subtitle,\n                    icon_name=\"key\",\n                    action=lambda n=name: self._copy_password_to_clipboard(n),\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\n                        \"type\": \"password\",\n                        \"name\": name,\n                        \"keep_launcher_open\": False,\n                        \"alt_action\": lambda n=name: self._toggle_password_visibility(\n                            n\n                        ),\n                    },\n                )\n            )\n\n        return results\n\n    def _handle_add_command(self, query_string: str) -> List[Result]:\n        \"\"\"Handle add password command.\"\"\"\n        results = []\n        parts = query_string.strip().split(\" \", 3)\n\n        if len(parts) < 3:\n            results.append(\n                Result(\n                    title=\"Add Password - Invalid format\",\n                    subtitle=\"Usage: add <name> <password> [description]\",\n                    icon_name=\"cancel\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            )\n            return results\n\n        name = parts[1]\n        password = parts[2]\n        description = parts[3] if len(parts) > 3 else \"\"\n\n        # Check if password already exists\n        if name in self.password_manager.list_passwords():\n            results.append(\n                Result(\n                    title=f\"Update password for '{name}'?\",\n                    subtitle=\"Password already exists. Click to update it.\",\n                    icon_name=\"key\",\n                    action=lambda: self._add_password_action(\n                        name, password, description, update=True\n                    ),\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"update\", \"name\": name, \"keep_launcher_open\": False},\n                )\n            )\n        else:\n            results.append(\n                Result(\n                    title=f\"Add password for '{name}'\",\n                    subtitle=\"Click to save password\"\n                    + (f\" | {description}\" if description else \"\"),\n                    icon_name=\"plus\",\n                    action=lambda: self._add_password_action(\n                        name, password, description\n                    ),\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"add\", \"name\": name, \"keep_launcher_open\": False},\n                )\n            )\n\n        return results\n\n    def _handle_remove_command(self, query_string: str) -> List[Result]:\n        \"\"\"Handle remove password command.\"\"\"\n        results = []\n        parts = query_string.strip().split(\" \", 1)\n\n        if len(parts) < 2:\n            results.append(\n                Result(\n                    title=\"Remove Password - Invalid format\",\n                    subtitle=\"Usage: remove <name>\",\n                    icon_name=\"cancel\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            )\n            return results\n\n        name = parts[1]\n\n        if name not in self.password_manager.list_passwords():\n            results.append(\n                Result(\n                    title=f\"Password '{name}' not found\",\n                    subtitle=\"Check the name and try again\",\n                    icon_name=\"cancel\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"error\", \"keep_launcher_open\": True},\n                )\n            )\n        else:\n            results.append(\n                Result(\n                    title=f\"Remove password '{name}'?\",\n                    subtitle=\"Click to confirm deletion (this cannot be undone)\",\n                    icon_name=\"trash\",\n                    action=lambda: self._remove_password_action(name),\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"remove\", \"name\": name, \"keep_launcher_open\": False},\n                )\n            )\n\n        return results\n\n    def _search_passwords(self, query: str) -> List[Result]:\n        \"\"\"Search for passwords by name.\"\"\"\n        results = []\n        password_names = self.password_manager.list_passwords()\n\n        # Filter passwords that match the query\n        matching_passwords = [\n            name for name in password_names if query.lower() in name.lower()\n        ]\n\n        if not matching_passwords:\n            results.append(\n                Result(\n                    title=f\"No passwords found matching '{query}'\",\n                    subtitle=\"Try a different search term or use 'pass' to see all passwords\",\n                    icon_name=\"magnifier\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"no_results\", \"keep_launcher_open\": True},\n                )\n            )\n            return results\n\n        # Sort by relevance (exact match first, then starts with, then contains)\n        def get_relevance(name: str) -> float:\n            name_lower = name.lower()\n            query_lower = query.lower()\n\n            if name_lower == query_lower:\n                return 1.0\n            elif name_lower.startswith(query_lower):\n                return 0.9\n            else:\n                return 0.7\n\n        matching_passwords.sort(key=get_relevance, reverse=True)\n\n        for name in matching_passwords:\n            info = self.password_manager.get_password_info(name)\n            description = info.get(\"description\", \"\") if info else \"\"\n\n            # Check if password is revealed\n            if name in self.revealed_passwords:\n                title = f\"{name}: {self.revealed_passwords[name]}\"\n                subtitle = \"Password revealed - Enter: copy | Shift+Enter: hide\"\n            else:\n                title = f\"{name}: {'*' * 8}\"\n                subtitle = \"Enter: copy | Shift+Enter: reveal password\"\n\n            if description:\n                subtitle += f\" | {description}\"\n\n            results.append(\n                Result(\n                    title=title,\n                    subtitle=subtitle,\n                    icon_name=\"key\",\n                    action=lambda n=name: self._copy_password_to_clipboard(n),\n                    relevance=get_relevance(name),\n                    plugin_name=self.display_name,\n                    data={\n                        \"type\": \"password\",\n                        \"name\": name,\n                        \"keep_launcher_open\": False,\n                        \"alt_action\": lambda n=name: self._toggle_password_visibility(\n                            n\n                        ),\n                    },\n                )\n            )\n\n        return results\n\n    def _add_password_action(\n        self, name: str, password: str, description: str = \"\", update: bool = False\n    ):\n        \"\"\"Action to add/update a password.\"\"\"\n        try:\n            success = self.password_manager.add_password(name, password, description)\n            if success:\n                action_word = \"updated\" if update else \"added\"\n                # Clear cache to force refresh\n                self._results_cache.clear()\n\n                # Send notification if available (non-blocking)\n                try:\n                    subprocess.Popen(\n                        [\n                            \"notify-send\",\n                            \"Password Manager\",\n                            f\"Password '{name}' {action_word} successfully\",\n                        ]\n                    )\n                except:\n                    pass\n            else:\n                print(f\"Failed to add password '{name}'\")\n        except Exception as e:\n            print(f\"Error adding password: {e}\")\n\n    def _remove_password_action(self, name: str):\n        \"\"\"Action to remove a password.\"\"\"\n        try:\n            success = self.password_manager.remove_password(name)\n            if success:\n                # Remove from revealed passwords if present\n                self.revealed_passwords.pop(name, None)\n\n                # Clear cache to force refresh\n                self._results_cache.clear()\n\n                # Send notification if available (non-blocking)\n                try:\n                    subprocess.Popen(\n                        [\n                            \"notify-send\",\n                            \"Password Manager\",\n                            f\"Password '{name}' removed successfully\",\n                        ]\n                    )\n                except:\n                    pass\n            else:\n                print(f\"Failed to remove password '{name}'\")\n        except Exception as e:\n            print(f\"Error removing password: {e}\")\n\n    def _copy_password_to_clipboard(self, name: str):\n        \"\"\"Copy password to clipboard and reveal it temporarily.\"\"\"\n        try:\n            password = self.password_manager.get_password(\n                name, update_access_time=False\n            )\n            if password:\n                # Copy to clipboard (use timeout to avoid hanging)\n                try:\n                    subprocess.run(\n                        [\"wl-copy\"], input=password.encode(), check=True, timeout=2\n                    )\n                except subprocess.SubprocessError:\n                    # Fall back to X11\n                    subprocess.run(\n                        [\"xclip\", \"-selection\", \"clipboard\"],\n                        input=password.encode(),\n                        check=True,\n                        timeout=2,\n                    )\n\n                # Reveal password temporarily\n                self.revealed_passwords[name] = password\n\n                # Send notification if available (non-blocking)\n                try:\n                    subprocess.Popen(\n                        [\n                            \"notify-send\",\n                            \"Password Manager\",\n                            f\"Password for '{name}' copied to clipboard\",\n                        ]\n                    )\n                except:\n                    pass\n\n                # Clear cache to force refresh\n                self._results_cache.clear()\n\n            else:\n                print(f\"Failed to retrieve password for '{name}'\")\n        except Exception as e:\n            print(f\"Error copying password: {e}\")\n\n    def _reveal_password(self, name: str):\n        \"\"\"Reveal password without copying to clipboard.\"\"\"\n        try:\n            password = self.password_manager.get_password(\n                name, update_access_time=False\n            )\n            if password:\n                self.revealed_passwords[name] = password\n                # Clear cache to force refresh with revealed password\n                self._results_cache.clear()\n            else:\n                print(f\"Failed to retrieve password for '{name}'\")\n        except Exception as e:\n            print(f\"Error revealing password: {e}\")\n\n    def _hide_password(self, name: str):\n        \"\"\"Hide revealed password.\"\"\"\n        self.revealed_passwords.pop(name, None)\n\n    def _hide_all_passwords(self):\n        \"\"\"Hide all revealed passwords.\"\"\"\n        if self.revealed_passwords:\n            self.revealed_passwords.clear()\n            # Clear cache to force refresh with hidden passwords\n            self._results_cache.clear()\n\n    def _setup_launcher_hooks(self):\n        \"\"\"Setup hooks to monitor launcher state.\"\"\"\n        try:\n            # Try to find the launcher instance\n            import gc\n\n            for obj in gc.get_objects():\n                if (\n                    hasattr(obj, \"__class__\")\n                    and obj.__class__.__name__ == \"Launcher\"\n                    and hasattr(obj, \"close_launcher\")\n                ):\n                    self._launcher_instance = obj\n                    # Store original close_launcher method\n                    self._original_close_launcher = obj.close_launcher\n                    # Replace with our wrapper\n                    obj.close_launcher = self._wrapped_close_launcher\n                    break\n        except Exception as e:\n            print(f\"Warning: Could not setup launcher hooks: {e}\")\n\n    def _cleanup_launcher_hooks(self):\n        \"\"\"Cleanup launcher hooks.\"\"\"\n        try:\n            if self._launcher_instance and hasattr(self, \"_original_close_launcher\"):\n                # Restore original close_launcher method\n                self._launcher_instance.close_launcher = self._original_close_launcher\n                self._launcher_instance = None\n        except Exception as e:\n            print(f\"Warning: Could not cleanup launcher hooks: {e}\")\n\n    def _wrapped_close_launcher(self):\n        \"\"\"Wrapper for launcher close that hides passwords.\"\"\"\n        # Hide all passwords when launcher closes\n        self._hide_all_passwords()\n        # Call original close_launcher method\n        if hasattr(self, \"_original_close_launcher\"):\n            self._original_close_launcher()\n\n    def _toggle_password_visibility(self, name: str):\n        \"\"\"Toggle password visibility when Shift+Enter is pressed.\"\"\"\n        if name in self.revealed_passwords:\n            # Hide password\n            self.revealed_passwords.pop(name, None)\n            self._results_cache.clear()\n        else:\n            # Reveal password\n            try:\n                password = self.password_manager.get_password(\n                    name, update_access_time=False\n                )\n                if password:\n                    self.revealed_passwords[name] = password\n                    self._results_cache.clear()\n                else:\n                    print(f\"Failed to retrieve password for '{name}'\")\n            except Exception as e:\n                print(f\"Error revealing password: {e}\")\n\n        # Force refresh of the launcher to show updated state\n        self._force_launcher_refresh()\n\n    def _force_launcher_refresh(self):\n        \"\"\"Force the launcher to refresh and show updated results.\"\"\"\n        try:\n            if self._launcher_instance and hasattr(\n                self._launcher_instance, \"_perform_search\"\n            ):\n                # Get current search text\n                current_text = \"\"\n                if hasattr(self._launcher_instance, \"search_entry\"):\n                    current_text = self._launcher_instance.search_entry.get_text()\n\n                # Trigger a search to refresh results\n                try:\n                    from gi.repository import GLib\n\n                    def refresh():\n                        self._launcher_instance._perform_search(current_text)\n                        return False\n\n                    GLib.timeout_add(50, refresh)\n                except ImportError:\n                    # Fallback: direct call if GLib not available\n                    self._launcher_instance._perform_search(current_text)\n        except Exception as e:\n            print(f\"Could not force launcher refresh: {e}\")\n"
  },
  {
    "path": "modules/launcher/plugins/power.py",
    "content": "from typing import List\n\nfrom fabric.utils import exec_shell_command_async\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass PowerPlugin(PluginBase):\n    \"\"\"\n    Plugin for system power management operations.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Power\"\n        self.description = \"System power management and control\"\n        self.commands = {\n            \"shutdown\": {\n                \"description\": \"Shutdown the system\",\n                \"icon\": \"system-shutdown-symbolic\",\n                \"action\": self.shutdown,\n            },\n            \"restart\": {\n                \"description\": \"Restart the system\",\n                \"icon\": \"system-reboot-symbolic\",\n                \"action\": self.restart,\n            },\n            \"lock\": {\n                \"description\": \"Lock the screen\",\n                \"icon\": \"system-lock-screen-symbolic\",\n                \"action\": self.lock,\n            },\n            \"suspend\": {\n                \"description\": \"Suspend the system\",\n                \"icon\": \"system-suspend-symbolic\",\n                \"action\": self.suspend,\n            },\n            \"logout\": {\n                \"description\": \"Logout from current session\",\n                \"icon\": \"system-log-out-symbolic\",\n                \"action\": self.logout,\n            },\n        }\n\n    def initialize(self):\n        \"\"\"Initialize the power plugin.\"\"\"\n        self.set_triggers([\"power\"])\n\n    def cleanup(self):\n        \"\"\"Cleanup the power plugin.\"\"\"\n        pass\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Search power commands based on query.\"\"\"\n        query = query_string.lower().strip()\n        results = []\n\n        # If no query, show all power commands\n        if not query:\n            for cmd, info in self.commands.items():\n                result = Result(\n                    title=cmd.capitalize(),\n                    subtitle=info[\"description\"],\n                    icon_name=info[\"icon\"],\n                    action=info[\"action\"],\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"command\": cmd},\n                )\n                results.append(result)\n        else:\n            # Filter commands based on query\n            for cmd, info in self.commands.items():\n                if query in cmd.lower() or query in info[\"description\"].lower():\n                    result = Result(\n                        title=cmd.capitalize(),\n                        subtitle=info[\"description\"],\n                        icon_name=info[\"icon\"],\n                        action=info[\"action\"],\n                        relevance=1.0 if query == cmd else 0.7,\n                        plugin_name=self.display_name,\n                        data={\"command\": cmd},\n                    )\n                    results.append(result)\n\n        return results\n\n    def shutdown(self, *args) -> None:\n        exec_shell_command_async(\"systemctl poweroff\")\n\n    def restart(self, *args) -> None:\n        exec_shell_command_async(\"systemctl reboot\")\n\n    def lock(self, *args) -> None:\n        exec_shell_command_async(\"loginctl lock-session\")\n\n    def suspend(self, *args) -> None:\n        exec_shell_command_async(\"systemctl suspend\")\n\n    def logout(self, *args) -> None:\n        exec_shell_command_async(\"hyprctl dispatch exit\")\n"
  },
  {
    "path": "modules/launcher/plugins/reminders.py",
    "content": "import re\nimport subprocess\nimport threading\nfrom datetime import datetime, timedelta\nfrom typing import Dict, List, Optional\n\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass Reminder:\n    \"\"\"\n    Represents a single reminder with its timer and metadata.\n    \"\"\"\n\n    def __init__(\n        self,\n        reminder_id: int,\n        message: str,\n        target_time: datetime,\n        timer: threading.Timer,\n    ):\n        self.id = reminder_id\n        self.message = message\n        self.target_time = target_time\n        self.timer = timer\n        self.created_time = datetime.now()\n\n    def cancel(self):\n        \"\"\"Cancel this reminder.\"\"\"\n        if self.timer:\n            self.timer.cancel()\n\n    def get_time_remaining(self) -> str:\n        \"\"\"Get formatted time remaining until reminder.\"\"\"\n        now = datetime.now()\n        if self.target_time <= now:\n            return \"Overdue\"\n\n        delta = self.target_time - now\n        total_seconds = int(delta.total_seconds())\n\n        if total_seconds < 60:\n            return f\"{total_seconds}s\"\n        elif total_seconds < 3600:\n            minutes = total_seconds // 60\n            seconds = total_seconds % 60\n            return f\"{minutes}m {seconds}s\" if seconds > 0 else f\"{minutes}m\"\n        else:\n            hours = total_seconds // 3600\n            minutes = (total_seconds % 3600) // 60\n            return f\"{hours}h {minutes}m\" if minutes > 0 else f\"{hours}h\"\n\n    def get_target_time_str(self) -> str:\n        \"\"\"Get formatted target time.\"\"\"\n        return self.target_time.strftime(\"%H:%M\")\n\n\nclass RemindersPlugin(PluginBase):\n    \"\"\"\n    Time-based reminders plugin for the launcher.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Reminders\"\n        self.description = \"Set time-based reminders with notifications\"\n        self.reminders: Dict[int, Reminder] = {}\n        self.next_id = 1\n\n        # Regex patterns for time parsing\n        self.time_patterns = {\n            \"relative_time\": re.compile(r\"^(\\d+)([smhd])$\"),  # 5m, 30s, 2h, 1d\n            \"absolute_time\": re.compile(r\"^(\\d{1,2}):(\\d{2})$\"),  # 14:30, 9:15\n            \"relative_with_unit\": re.compile(\n                r\"^(\\d+)\\s*(min|mins|minute|minutes|hour|hours|sec|seconds|day|days)$\",\n                re.IGNORECASE,\n            ),\n        }\n\n    def initialize(self):\n        \"\"\"Initialize the reminders plugin.\"\"\"\n        self.set_triggers([\"remind\"])\n\n    def cleanup(self):\n        \"\"\"Cleanup the reminders plugin.\"\"\"\n        # Cancel all active reminders\n        for reminder in self.reminders.values():\n            reminder.cancel()\n        self.reminders.clear()\n\n    def _send_notification(self, title: str, message: str):\n        \"\"\"Send a desktop notification using notify-send.\"\"\"\n        try:\n            subprocess.run(\n                [\"notify-send\", \"-a\", \"Reminders\", \"-i\", \"alarm-clock\", title, message],\n                check=False,\n            )\n        except Exception as e:\n            print(f\"Failed to send notification: {e}\")\n\n    def _parse_time_input(self, time_str: str) -> Optional[datetime]:\n        \"\"\"\n        Parse various time input formats and return target datetime.\n\n        Supported formats:\n        - 5m, 30s, 2h, 1d (relative time)\n        - 14:30, 9:15 (absolute time today)\n        - 5 minutes, 2 hours (relative with full unit names)\n        \"\"\"\n        time_str = time_str.strip().lower()\n\n        # Try relative time format (5m, 30s, 2h, 1d)\n        match = self.time_patterns[\"relative_time\"].match(time_str)\n        if match:\n            value, unit = match.groups()\n            value = int(value)\n\n            if unit == \"s\":\n                delta = timedelta(seconds=value)\n            elif unit == \"m\":\n                delta = timedelta(minutes=value)\n            elif unit == \"h\":\n                delta = timedelta(hours=value)\n            elif unit == \"d\":\n                delta = timedelta(days=value)\n            else:\n                return None\n\n            return datetime.now() + delta\n\n        # Try absolute time format (14:30, 9:15)\n        match = self.time_patterns[\"absolute_time\"].match(time_str)\n        if match:\n            hour, minute = map(int, match.groups())\n            if 0 <= hour <= 23 and 0 <= minute <= 59:\n                target = datetime.now().replace(\n                    hour=hour, minute=minute, second=0, microsecond=0\n                )\n                # If the time has already passed today, schedule for tomorrow\n                if target <= datetime.now():\n                    target += timedelta(days=1)\n                return target\n\n        # Try relative time with full unit names\n        match = self.time_patterns[\"relative_with_unit\"].match(time_str)\n        if match:\n            value, unit = match.groups()\n            value = int(value)\n            unit = unit.lower()\n\n            if unit in [\"sec\", \"seconds\"]:\n                delta = timedelta(seconds=value)\n            elif unit in [\"min\", \"mins\", \"minute\", \"minutes\"]:\n                delta = timedelta(minutes=value)\n            elif unit in [\"hour\", \"hours\"]:\n                delta = timedelta(hours=value)\n            elif unit in [\"day\", \"days\"]:\n                delta = timedelta(days=value)\n            else:\n                return None\n\n            return datetime.now() + delta\n\n        return None\n\n    def _create_reminder(self, time_str: str, message: str) -> Optional[Reminder]:\n        \"\"\"Create a new reminder with the given time and message.\"\"\"\n        target_time = self._parse_time_input(time_str)\n        if not target_time:\n            return None\n\n        # Calculate delay in seconds\n        delay = (target_time - datetime.now()).total_seconds()\n        if delay <= 0:\n            return None\n\n        # Create timer that will trigger the notification\n        timer = threading.Timer(delay, self._trigger_reminder, [self.next_id, message])\n\n        # Create reminder object\n        reminder = Reminder(self.next_id, message, target_time, timer)\n\n        # Store reminder and start timer\n        self.reminders[self.next_id] = reminder\n        timer.start()\n\n        # Increment ID for next reminder\n        self.next_id += 1\n\n        return reminder\n\n    def _trigger_reminder(self, reminder_id: int, message: str):\n        \"\"\"Trigger a reminder notification and remove it from active reminders.\"\"\"\n        # Send notification\n        self._send_notification(\"⏰ Reminder\", message)\n\n        # Remove from active reminders\n        if reminder_id in self.reminders:\n            del self.reminders[reminder_id]\n\n    def _cancel_reminder(self, reminder_id: Optional[int] = None) -> int:\n        \"\"\"Cancel a specific reminder or all reminders. Returns number of cancelled reminders.\"\"\"\n        if reminder_id is not None:\n            if reminder_id in self.reminders:\n                self.reminders[reminder_id].cancel()\n                del self.reminders[reminder_id]\n                return 1\n            return 0\n        else:\n            # Cancel all reminders\n            count = len(self.reminders)\n            for reminder in self.reminders.values():\n                reminder.cancel()\n            self.reminders.clear()\n            return count\n\n    def _format_time_remaining(self, total_seconds: float) -> str:\n        \"\"\"Format time remaining in a human-readable way.\"\"\"\n        total_seconds = int(total_seconds)\n\n        if total_seconds < 60:\n            return f\"{total_seconds}s\"\n        elif total_seconds < 3600:\n            minutes = total_seconds // 60\n            seconds = total_seconds % 60\n            return f\"{minutes}m {seconds}s\" if seconds > 0 else f\"{minutes}m\"\n        else:\n            hours = total_seconds // 3600\n            minutes = (total_seconds % 3600) // 60\n            return f\"{hours}h {minutes}m\" if minutes > 0 else f\"{hours}h\"\n\n    def _create_and_confirm_reminder(self, time_str: str, message: str):\n        \"\"\"Actually create the reminder when the user presses Enter.\"\"\"\n        reminder = self._create_reminder(time_str, message)\n        if reminder:\n            time_remaining = reminder.get_time_remaining()\n            self._send_notification(\n                \"✅ Reminder Created\", f\"Reminder set for {time_remaining}: {message}\"\n            )\n        else:\n            self._send_notification(\n                \"❌ Failed to Create Reminder\", \"The specified time may be in the past\"\n            )\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Process reminder queries.\"\"\"\n        results = []\n        query = query_string.strip()\n\n        if not query:\n            # Show help and active reminders count\n            active_count = len(self.reminders)\n            results.append(\n                Result(\n                    title=\"Reminders Help\",\n                    subtitle=f\"Active reminders: {\n                        active_count\n                    } | Usage: remind 5m Take a break\",\n                    icon_name=\"alarm-timer\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"type\": \"help\"},\n                )\n            )\n\n            # Show quick examples\n            examples = [\n                (\"remind 5m Take a break\", \"Set 5 minute reminder\"),\n                (\"remind 14:30 Meeting\", \"Set reminder for 2:30 PM\"),\n                (\"remind list\", \"List active reminders\"),\n                (\"remind cancel\", \"Cancel all reminders\"),\n            ]\n\n            for example, desc in examples:\n                results.append(\n                    Result(\n                        title=example,\n                        subtitle=desc,\n                        icon_name=\"alarm-timer\",\n                        action=lambda: None,\n                        relevance=0.8,\n                        plugin_name=self.display_name,\n                        data={\"type\": \"example\"},\n                    )\n                )\n\n            return results\n\n        # Handle list command\n        if query.lower() in [\"list\", \"ls\", \"show\"]:\n            if not self.reminders:\n                results.append(\n                    Result(\n                        title=\"No Active Reminders\",\n                        subtitle=\"Use 'remind 5m message' to set a reminder\",\n                        icon_name=\"timer-off\",\n                        action=lambda: None,\n                        relevance=1.0,\n                        plugin_name=self.display_name,\n                        data={\"type\": \"empty_list\"},\n                    )\n                )\n            else:\n                for reminder in sorted(\n                    self.reminders.values(), key=lambda r: r.target_time\n                ):\n                    time_remaining = reminder.get_time_remaining()\n                    target_time = reminder.get_target_time_str()\n\n                    results.append(\n                        Result(\n                            title=f\"#{reminder.id}: {reminder.message}\",\n                            subtitle=f\"In {time_remaining} (at {target_time})\",\n                            icon_name=\"alarm-timer\",\n                            action=lambda rid=reminder.id: self._cancel_reminder(rid),\n                            relevance=1.0,\n                            plugin_name=self.display_name,\n                            data={\"type\": \"active_reminder\", \"id\": reminder.id},\n                        )\n                    )\n\n            return results\n\n        # Handle cancel command\n        if query.lower().startswith(\"cancel\") or query.lower().startswith(\"stop\"):\n            parts = query.split()\n            if len(parts) == 1:\n                # Cancel all reminders\n                count = self._cancel_reminder()\n                results.append(\n                    Result(\n                        title=f\"Cancelled {count} Reminders\",\n                        subtitle=\"All active reminders have been cancelled\",\n                        icon_name=\"timer-off\",\n                        action=lambda: None,\n                        relevance=1.0,\n                        plugin_name=self.display_name,\n                        data={\"type\": \"cancel_all\"},\n                    )\n                )\n            else:\n                # Try to cancel specific reminder by ID\n                try:\n                    reminder_id = int(parts[1])\n                    count = self._cancel_reminder(reminder_id)\n                    if count > 0:\n                        results.append(\n                            Result(\n                                title=f\"Cancelled Reminder #{reminder_id}\",\n                                subtitle=\"Reminder has been cancelled\",\n                                icon_name=\"timer-off\",\n                                action=lambda: None,\n                                relevance=1.0,\n                                plugin_name=self.display_name,\n                                data={\"type\": \"cancel_specific\"},\n                            )\n                        )\n                    else:\n                        results.append(\n                            Result(\n                                title=\"Reminder Not Found\",\n                                subtitle=f\"No reminder with ID #{reminder_id}\",\n                                icon_name=\"alert\",\n                                action=lambda: None,\n                                relevance=0.5,\n                                plugin_name=self.display_name,\n                                data={\"type\": \"error\"},\n                            )\n                        )\n                except ValueError:\n                    results.append(\n                        Result(\n                            title=\"Invalid Reminder ID\",\n                            subtitle=\"Please provide a valid reminder ID number\",\n                            icon_name=\"alert\",\n                            action=lambda: None,\n                            relevance=0.5,\n                            plugin_name=self.display_name,\n                            data={\"type\": \"error\"},\n                        )\n                    )\n\n            return results\n\n        # Handle setting new reminders\n        parts = query.split(None, 1)\n        if len(parts) >= 1:\n            time_str = parts[0]\n            message = parts[1] if len(parts) > 1 else \"Reminder\"\n\n            # Try to parse the time (but don't create the reminder yet!)\n            target_time = self._parse_time_input(time_str)\n            if target_time:\n                # Calculate delay and check if it's valid\n                delay = (target_time - datetime.now()).total_seconds()\n                if delay > 0:\n                    # Show what would happen, but don't create the reminder yet\n                    time_remaining = self._format_time_remaining(delay)\n                    target_time_str = target_time.strftime(\"%H:%M\")\n\n                    results.append(\n                        Result(\n                            title=f\"Set Reminder: {message}\",\n                            subtitle=f\"Will remind in {time_remaining} (at {\n                                target_time_str\n                            })\",\n                            icon_name=\"alarm-timer\",\n                            action=lambda ts=time_str, msg=message: self._create_and_confirm_reminder(\n                                ts, msg\n                            ),\n                            relevance=1.0,\n                            plugin_name=self.display_name,\n                            data={\n                                \"type\": \"preview\",\n                                \"time_str\": time_str,\n                                \"message\": message,\n                            },\n                        )\n                    )\n                else:\n                    results.append(\n                        Result(\n                            title=\"Time is in the Past\",\n                            subtitle=\"Please specify a future time\",\n                            icon_name=\"alert\",\n                            action=lambda: None,\n                            relevance=0.5,\n                            plugin_name=self.display_name,\n                            data={\"type\": \"error\"},\n                        )\n                    )\n            else:\n                # Invalid time format\n                results.append(\n                    Result(\n                        title=\"Invalid Time Format\",\n                        subtitle=\"Use formats like: 5m, 30s, 2h, 14:30, or '5 minutes'\",\n                        icon_name=\"alert\",\n                        action=lambda: None,\n                        relevance=0.5,\n                        plugin_name=self.display_name,\n                        data={\"type\": \"error\"},\n                    )\n                )\n\n        return results\n"
  },
  {
    "path": "modules/launcher/plugins/screencapture.py",
    "content": "import subprocess\nfrom typing import List\n\nfrom fabric.utils import get_relative_path\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass ScreencapturePlugin(PluginBase):\n    \"\"\"\n    Plugin for taking screenshots and screen recordings using screen-capture.sh script.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Screencapture\"\n        self.description = \"Take screenshots and screen recordings\"\n        self.script_path = get_relative_path(\"../../../scripts/screen-capture.sh\")\n\n    def initialize(self):\n        \"\"\"Initialize the screencapture plugin.\"\"\"\n        self.set_triggers([\"sc\"])\n\n    def cleanup(self):\n        \"\"\"Cleanup the screencapture plugin.\"\"\"\n        pass\n\n    def get_commands(self):\n        \"\"\"Return available commands for this plugin.\"\"\"\n        return {\n            # Screenshot commands\n            \"screenshot\": \"Take a screenshot of the main display\",\n            \"ss\": \"Take a screenshot of the main display\",\n            \"screenshot-region\": \"Take a screenshot of selected region\",\n            \"ss-region\": \"Take a screenshot of selected region\",\n            \"screenshot-both\": \"Take a screenshot of both displays\",\n            \"ss-both\": \"Take a screenshot of both displays\",\n            \"screenshot-hdmi\": \"Take a screenshot of HDMI display\",\n            \"ss-hdmi\": \"Take a screenshot of HDMI display\",\n            # Recording commands (with audio)\n            \"record\": \"Start recording main display with audio\",\n            \"rec\": \"Start recording main display with audio\",\n            \"record-region\": \"Start recording selected region with audio\",\n            \"rec-region\": \"Start recording selected region with audio\",\n            \"record-hdmi\": \"Start recording HDMI display with audio\",\n            \"rec-hdmi\": \"Start recording HDMI display with audio\",\n            # Recording commands (no audio)\n            \"record-noaudio\": \"Start recording main display without audio\",\n            \"rec-noaudio\": \"Start recording main display without audio\",\n            \"record-noaudio-region\": \"Start recording selected region without audio\",\n            \"rec-noaudio-region\": \"Start recording selected region without audio\",\n            \"record-noaudio-hdmi\": \"Start recording HDMI display without audio\",\n            \"rec-noaudio-hdmi\": \"Start recording HDMI display without audio\",\n            # High-quality recording commands\n            \"record-hq\": \"Start high-quality recording of main display\",\n            \"rec-hq\": \"Start high-quality recording of main display\",\n            \"record-hq-region\": \"Start high-quality recording of selected region\",\n            \"rec-hq-region\": \"Start high-quality recording of selected region\",\n            \"record-hq-hdmi\": \"Start high-quality recording of HDMI display\",\n            \"rec-hq-hdmi\": \"Start high-quality recording of HDMI display\",\n            # GIF recording commands\n            \"record-gif\": \"Start GIF recording of main display\",\n            \"rec-gif\": \"Start GIF recording of main display\",\n            \"record-gif-region\": \"Start GIF recording of selected region\",\n            \"rec-gif-region\": \"Start GIF recording of selected region\",\n            # Control commands\n            \"stop\": \"Stop current recording\",\n            # Conversion commands\n            \"convert-webm\": \"Convert latest MKV recording to WebM format\",\n            \"conv-webm\": \"Convert latest MKV recording to WebM format\",\n            \"convert-iphone\": \"Convert latest MKV recording for iPhone compatibility\",\n            \"conv-iphone\": \"Convert latest MKV recording for iPhone compatibility\",\n            \"convert-youtube\": \"Convert latest recording for YouTube upload\",\n            \"conv-youtube\": \"Convert latest recording for YouTube upload\",\n            \"convert-gif\": \"Convert latest recording to GIF format\",\n            \"conv-gif\": \"Convert latest recording to GIF format\",\n            # Conversion commands with file input\n            \"convert-webm-file\": \"Convert specific MKV file to WebM format\",\n            \"conv-webm-file\": \"Convert specific MKV file to WebM format\",\n            \"convert-iphone-file\": \"Convert specific MKV file for iPhone compatibility\",\n            \"conv-iphone-file\": \"Convert specific MKV file for iPhone compatibility\",\n            \"convert-youtube-file\": \"Convert specific video file for YouTube upload\",\n            \"conv-youtube-file\": \"Convert specific video file for YouTube upload\",\n            \"convert-gif-file\": \"Convert specific video file to GIF format\",\n            \"conv-gif-file\": \"Convert specific video file to GIF format\",\n        }\n\n    def _run_script(self, *args):\n        \"\"\"Execute the screen-capture script with given arguments.\"\"\"\n        try:\n            subprocess.Popen([self.script_path] + list(args))\n        except Exception as e:\n            print(f\"Error running screen-capture script: {e}\")\n\n    def _run_script_with_file(self, format_type: str, file_path: str):\n        \"\"\"Execute the screen-capture script with file parameter.\"\"\"\n        try:\n            subprocess.Popen([self.script_path, \"convert\", format_type, file_path])\n        except Exception as e:\n            print(f\"Error running screen-capture script with file: {e}\")\n\n    def _is_recording(self):\n        \"\"\"Check if recording is currently active.\"\"\"\n        try:\n            result = subprocess.run(\n                [self.script_path, \"status\"], capture_output=True, text=True\n            )\n            return result.stdout.strip() == \"true\"\n        except Exception:\n            return False\n\n    def _get_command_result(self, command: str) -> Result:\n        \"\"\"Get a Result object for a specific command.\"\"\"\n        # Import here to avoid circular imports\n\n        command_info = {\n            # Screenshot commands\n            \"screenshot\": (\n                \"Take Screenshot (eDP-1)\",\n                \"Capture the main display\",\n                \"camera-photo-symbolic\",\n                lambda: self._run_script(\"screenshot\", \"eDP-1\"),\n            ),\n            \"ss\": (\n                \"Take Screenshot (eDP-1)\",\n                \"Capture the main display\",\n                \"camera-photo-symbolic\",\n                lambda: self._run_script(\"screenshot\", \"eDP-1\"),\n            ),\n            \"screenshot-region\": (\n                \"Take Region Screenshot\",\n                \"Capture a selected region\",\n                \"camera-photo-symbolic\",\n                lambda: self._run_script(\"screenshot\", \"selection\"),\n            ),\n            \"ss-region\": (\n                \"Take Region Screenshot\",\n                \"Capture a selected region\",\n                \"camera-photo-symbolic\",\n                lambda: self._run_script(\"screenshot\", \"selection\"),\n            ),\n            \"screenshot-both\": (\n                \"Take Screenshot (Both Displays)\",\n                \"Capture both displays combined\",\n                \"video-joined-displays-symbolic\",\n                lambda: self._run_script(\"screenshot\", \"both\"),\n            ),\n            \"ss-both\": (\n                \"Take Screenshot (Both Displays)\",\n                \"Capture both displays combined\",\n                \"video-joined-displays-symbolic\",\n                lambda: self._run_script(\"screenshot\", \"both\"),\n            ),\n            \"screenshot-hdmi\": (\n                \"Take Screenshot (HDMI-A-1)\",\n                \"Capture HDMI display\",\n                \"video-display-symbolic\",\n                lambda: self._run_script(\"screenshot\", \"HDMI-A-1\"),\n            ),\n            \"ss-hdmi\": (\n                \"Take Screenshot (HDMI-A-1)\",\n                \"Capture HDMI display\",\n                \"video-display-symbolic\",\n                lambda: self._run_script(\"screenshot\", \"HDMI-A-1\"),\n            ),\n            # Recording commands (with audio)\n            \"record\": (\n                \"Start Recording (eDP-1)\",\n                \"Record the main display with audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record\", \"eDP-1\"),\n            ),\n            \"rec\": (\n                \"Start Recording (eDP-1)\",\n                \"Record the main display with audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record\", \"eDP-1\"),\n            ),\n            \"record-region\": (\n                \"Start Region Recording\",\n                \"Record a selected region with audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record\", \"selection\"),\n            ),\n            \"rec-region\": (\n                \"Start Region Recording\",\n                \"Record a selected region with audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record\", \"selection\"),\n            ),\n            \"record-hdmi\": (\n                \"Start Recording (HDMI-A-1)\",\n                \"Record HDMI display with audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record\", \"HDMI-A-1\"),\n            ),\n            \"rec-hdmi\": (\n                \"Start Recording (HDMI-A-1)\",\n                \"Record HDMI display with audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record\", \"HDMI-A-1\"),\n            ),\n            # Recording commands (no audio)\n            \"record-noaudio\": (\n                \"Start Recording No Audio (eDP-1)\",\n                \"Record the main display without audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-noaudio\", \"eDP-1\"),\n            ),\n            \"rec-noaudio\": (\n                \"Start Recording No Audio (eDP-1)\",\n                \"Record the main display without audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-noaudio\", \"eDP-1\"),\n            ),\n            \"record-noaudio-region\": (\n                \"Start Region Recording No Audio\",\n                \"Record a selected region without audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-noaudio\", \"selection\"),\n            ),\n            \"rec-noaudio-region\": (\n                \"Start Region Recording No Audio\",\n                \"Record a selected region without audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-noaudio\", \"selection\"),\n            ),\n            \"record-noaudio-hdmi\": (\n                \"Start Recording No Audio (HDMI-A-1)\",\n                \"Record HDMI display without audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-noaudio\", \"HDMI-A-1\"),\n            ),\n            \"rec-noaudio-hdmi\": (\n                \"Start Recording No Audio (HDMI-A-1)\",\n                \"Record HDMI display without audio\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-noaudio\", \"HDMI-A-1\"),\n            ),\n            # High-quality recording commands\n            \"record-hq\": (\n                \"Start HQ Recording (eDP-1)\",\n                \"High-quality recording for YouTube\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-hq\", \"eDP-1\"),\n            ),\n            \"rec-hq\": (\n                \"Start HQ Recording (eDP-1)\",\n                \"High-quality recording for YouTube\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-hq\", \"eDP-1\"),\n            ),\n            \"record-hq-region\": (\n                \"Start HQ Region Recording\",\n                \"High-quality region recording\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-hq\", \"selection\"),\n            ),\n            \"rec-hq-region\": (\n                \"Start HQ Region Recording\",\n                \"High-quality region recording\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-hq\", \"selection\"),\n            ),\n            \"record-hq-hdmi\": (\n                \"Start HQ Recording (HDMI-A-1)\",\n                \"High-quality HDMI recording\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-hq\", \"HDMI-A-1\"),\n            ),\n            \"rec-hq-hdmi\": (\n                \"Start HQ Recording (HDMI-A-1)\",\n                \"High-quality HDMI recording\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-hq\", \"HDMI-A-1\"),\n            ),\n            # GIF recording commands\n            \"record-gif\": (\n                \"Start GIF Recording (eDP-1)\",\n                \"Record as optimized GIF\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-gif\", \"eDP-1\"),\n            ),\n            \"rec-gif\": (\n                \"Start GIF Recording (eDP-1)\",\n                \"Record as optimized GIF\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-gif\", \"eDP-1\"),\n            ),\n            \"record-gif-region\": (\n                \"Start GIF Region Recording\",\n                \"Record selected region as GIF\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-gif\", \"selection\"),\n            ),\n            \"rec-gif-region\": (\n                \"Start GIF Region Recording\",\n                \"Record selected region as GIF\",\n                \"media-record-symbolic\",\n                lambda: self._run_script(\"record-gif\", \"selection\"),\n            ),\n            # Control commands\n            \"stop\": (\n                \"Stop Recording\",\n                \"Stop the current screen recording\",\n                \"media-playback-stop-symbolic\",\n                lambda: self._run_script(\"record\", \"stop\"),\n            ),\n            # Conversion commands\n            \"convert-webm\": (\n                \"Convert Latest to WebM\",\n                \"Convert latest MKV recording to WebM format\",\n                \"video-x-generic-symbolic\",\n                lambda: self._run_script(\"convert\", \"webm\"),\n            ),\n            \"conv-webm\": (\n                \"Convert Latest to WebM\",\n                \"Convert latest MKV recording to WebM format\",\n                \"video-x-generic-symbolic\",\n                lambda: self._run_script(\"convert\", \"webm\"),\n            ),\n            \"convert-iphone\": (\n                \"Convert Latest for iPhone\",\n                \"Convert latest MKV recording for iPhone compatibility\",\n                \"video-x-generic-symbolic\",\n                lambda: self._run_script(\"convert\", \"iphone\"),\n            ),\n            \"conv-iphone\": (\n                \"Convert Latest for iPhone\",\n                \"Convert latest MKV recording for iPhone compatibility\",\n                \"video-x-generic-symbolic\",\n                lambda: self._run_script(\"convert\", \"iphone\"),\n            ),\n            \"convert-youtube\": (\n                \"Convert Latest for YouTube\",\n                \"Convert latest recording for YouTube upload\",\n                \"video-x-generic-symbolic\",\n                lambda: self._run_script(\"convert\", \"youtube\"),\n            ),\n            \"conv-youtube\": (\n                \"Convert Latest for YouTube\",\n                \"Convert latest recording for YouTube upload\",\n                \"video-x-generic-symbolic\",\n                lambda: self._run_script(\"convert\", \"youtube\"),\n            ),\n            \"convert-gif\": (\n                \"Convert Latest to GIF\",\n                \"Convert latest recording to GIF format\",\n                \"image-x-generic-symbolic\",\n                lambda: self._run_script(\"convert\", \"gif\"),\n            ),\n            \"conv-gif\": (\n                \"Convert Latest to GIF\",\n                \"Convert latest recording to GIF format\",\n                \"image-x-generic-symbolic\",\n                lambda: self._run_script(\"convert\", \"gif\"),\n            ),\n            # File-based conversion commands (these will be handled specially)\n            \"convert-webm-file\": (\n                \"Convert File to WebM\",\n                \"Type filename after command (e.g., convert-webm-file video.mkv)\",\n                \"video-x-generic-symbolic\",\n                None,  # Will be handled in query method\n            ),\n            \"conv-webm-file\": (\n                \"Convert File to WebM\",\n                \"Type filename after command (e.g., conv-webm-file video.mkv)\",\n                \"video-x-generic-symbolic\",\n                None,  # Will be handled in query method\n            ),\n            \"convert-iphone-file\": (\n                \"Convert File for iPhone\",\n                \"Type filename after command (e.g., convert-iphone-file video.mkv)\",\n                \"video-x-generic-symbolic\",\n                None,  # Will be handled in query method\n            ),\n            \"conv-iphone-file\": (\n                \"Convert File for iPhone\",\n                \"Type filename after command (e.g., conv-iphone-file video.mkv)\",\n                \"video-x-generic-symbolic\",\n                None,  # Will be handled in query method\n            ),\n            \"convert-youtube-file\": (\n                \"Convert File for YouTube\",\n                \"Type filename after command (e.g., convert-youtube-file video.mkv)\",\n                \"video-x-generic-symbolic\",\n                None,  # Will be handled in query method\n            ),\n            \"conv-youtube-file\": (\n                \"Convert File for YouTube\",\n                \"Type filename after command (e.g., conv-youtube-file video.mkv)\",\n                \"video-x-generic-symbolic\",\n                None,  # Will be handled in query method\n            ),\n            \"convert-gif-file\": (\n                \"Convert File to GIF\",\n                \"Type filename after command (e.g., convert-gif-file video.mkv)\",\n                \"image-x-generic-symbolic\",\n                None,  # Will be handled in query method\n            ),\n            \"conv-gif-file\": (\n                \"Convert File to GIF\",\n                \"Type filename after command (e.g., conv-gif-file video.mkv)\",\n                \"image-x-generic-symbolic\",\n                None,  # Will be handled in query method\n            ),\n        }\n\n        if command in command_info:\n            title, subtitle, icon, action = command_info[command]\n            if action is not None:  # Regular command\n                return Result(\n                    title=title,\n                    subtitle=subtitle,\n                    icon_name=icon,\n                    action=action,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                )\n            else:  # File-based command, show instruction\n                return Result(\n                    title=title,\n                    subtitle=subtitle,\n                    icon_name=icon,\n                    action=lambda: None,  # No action for instruction\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                )\n\n        return None\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Search for screencapture actions based on query.\"\"\"\n        # Import here to avoid circular imports\n\n        # Clean the query string\n        query = query_string.strip().lower()\n\n        results = []\n\n        # Check for file-based conversion commands with parameters\n        file_conversion_commands = {\n            \"convert-webm-file\": \"webm\",\n            \"conv-webm-file\": \"webm\",\n            \"convert-iphone-file\": \"iphone\",\n            \"conv-iphone-file\": \"iphone\",\n            \"convert-youtube-file\": \"youtube\",\n            \"conv-youtube-file\": \"youtube\",\n            \"convert-gif-file\": \"gif\",\n            \"conv-gif-file\": \"gif\",\n        }\n\n        # Parse query for file-based commands\n        query_parts = query.split()\n        if len(query_parts) >= 2:\n            command = query_parts[0]\n            file_param = \" \".join(query_parts[1:])\n\n            if command in file_conversion_commands:\n                format_type = file_conversion_commands[command]\n                return [\n                    Result(\n                        title=f\"Convert {file_param} to {format_type.upper()}\",\n                        subtitle=f\"Convert specified file to {format_type} format\",\n                        icon_name=(\n                            \"video-x-generic-symbolic\"\n                            if format_type != \"gif\"\n                            else \"image-x-generic-symbolic\"\n                        ),\n                        action=lambda fp=file_param, ft=format_type: self._run_script_with_file(\n                            ft, fp\n                        ),\n                        relevance=1.0,\n                        plugin_name=self.display_name,\n                    )\n                ]\n\n        # Check if query matches a command and return it as a result\n        command_result = self._get_command_result(query)\n        if command_result:\n            return [command_result]\n\n        # Check recording status\n        is_recording = self._is_recording()\n\n        # If recording is active, show stop button first with highest relevance\n        if is_recording:\n            results.append(\n                Result(\n                    title=\"Stop Recording\",\n                    subtitle=\"Stop the current screen recording\",\n                    icon_name=\"media-playback-stop-symbolic\",\n                    action=lambda: self._run_script(\"record\", \"stop\"),\n                    relevance=2.0,  # Highest relevance to appear at top\n                    plugin_name=self.display_name,\n                )\n            )\n\n        # Screenshot actions\n        results.extend(\n            [\n                Result(\n                    title=\"Take Screenshot\",\n                    subtitle=\"Capture the entire screen (eDP-1)\",\n                    icon_name=\"camera-photo-symbolic\",\n                    action=lambda: self._run_script(\"screenshot\", \"eDP-1\"),\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Take Region Screenshot\",\n                    subtitle=\"Capture a selected region\",\n                    icon_name=\"camera-photo-symbolic\",\n                    action=lambda: self._run_script(\"screenshot\", \"selection\"),\n                    relevance=0.9,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Take Screenshot (Both Displays)\",\n                    subtitle=\"Capture both displays combined\",\n                    icon_name=\"video-joined-displays-symbolic\",\n                    action=lambda: self._run_script(\"screenshot\", \"both\"),\n                    relevance=0.8,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Take Screenshot (HDMI-A-1)\",\n                    subtitle=\"Capture HDMI display\",\n                    icon_name=\"video-display-symbolic\",\n                    action=lambda: self._run_script(\"screenshot\", \"HDMI-A-1\"),\n                    relevance=0.7,\n                    plugin_name=self.display_name,\n                ),\n            ]\n        )\n\n        # Standard recording actions\n        results.extend(\n            [\n                Result(\n                    title=\"Start Recording (eDP-1)\",\n                    subtitle=\"Record the main display with audio\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record\", \"eDP-1\"),\n                    relevance=0.7,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Start Region Recording\",\n                    subtitle=\"Record a selected region\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record\", \"selection\"),\n                    relevance=0.6,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Start Recording (HDMI-A-1)\",\n                    subtitle=\"Record HDMI display with audio\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record\", \"HDMI-A-1\"),\n                    relevance=0.5,\n                    plugin_name=self.display_name,\n                ),\n            ]\n        )\n\n        # No-audio recording actions\n        results.extend(\n            [\n                Result(\n                    title=\"Start Recording No Audio (eDP-1)\",\n                    subtitle=\"Record the main display without audio\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record-noaudio\", \"eDP-1\"),\n                    relevance=0.65,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Start Region Recording No Audio\",\n                    subtitle=\"Record a selected region without audio\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record-noaudio\", \"selection\"),\n                    relevance=0.55,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Start Recording No Audio (HDMI-A-1)\",\n                    subtitle=\"Record HDMI display without audio\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record-noaudio\", \"HDMI-A-1\"),\n                    relevance=0.45,\n                    plugin_name=self.display_name,\n                ),\n            ]\n        )\n\n        # High-quality recording actions\n        results.extend(\n            [\n                Result(\n                    title=\"Start HQ Recording (eDP-1)\",\n                    subtitle=\"High-quality recording for YouTube\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record-hq\", \"eDP-1\"),\n                    relevance=0.4,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Start HQ Region Recording\",\n                    subtitle=\"High-quality region recording\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record-hq\", \"selection\"),\n                    relevance=0.3,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Start HQ Recording (HDMI-A-1)\",\n                    subtitle=\"High-quality HDMI recording\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record-hq\", \"HDMI-A-1\"),\n                    relevance=0.2,\n                    plugin_name=self.display_name,\n                ),\n            ]\n        )\n\n        # GIF recording actions\n        results.extend(\n            [\n                Result(\n                    title=\"Start GIF Recording (eDP-1)\",\n                    subtitle=\"Record as optimized GIF\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record-gif\", \"eDP-1\"),\n                    relevance=0.1,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Start GIF Region Recording\",\n                    subtitle=\"Record selected region as GIF\",\n                    icon_name=\"media-record-symbolic\",\n                    action=lambda: self._run_script(\"record-gif\", \"selection\"),\n                    relevance=0.05,\n                    plugin_name=self.display_name,\n                ),\n            ]\n        )\n\n        # Conversion actions\n        results.extend(\n            [\n                Result(\n                    title=\"Convert Latest to WebM\",\n                    subtitle=\"Convert latest MKV recording to WebM format\",\n                    icon_name=\"video-x-generic-symbolic\",\n                    action=lambda: self._run_script(\"convert\", \"webm\"),\n                    relevance=0.01,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Convert Latest for iPhone\",\n                    subtitle=\"Convert latest MKV recording for iPhone compatibility\",\n                    icon_name=\"video-x-generic-symbolic\",\n                    action=lambda: self._run_script(\"convert\", \"iphone\"),\n                    relevance=0.01,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Convert Latest for YouTube\",\n                    subtitle=\"Convert latest recording for YouTube upload\",\n                    icon_name=\"video-x-generic-symbolic\",\n                    action=lambda: self._run_script(\"convert\", \"youtube\"),\n                    relevance=0.01,\n                    plugin_name=self.display_name,\n                ),\n                Result(\n                    title=\"Convert Latest to GIF\",\n                    subtitle=\"Convert latest recording to GIF format\",\n                    icon_name=\"image-x-generic-symbolic\",\n                    action=lambda: self._run_script(\"convert\", \"gif\"),\n                    relevance=0.01,\n                    plugin_name=self.display_name,\n                ),\n            ]\n        )\n\n        return results\n"
  },
  {
    "path": "modules/launcher/plugins/system.py",
    "content": "import json\nimport os\nimport shlex\nimport threading\nimport time\nfrom typing import List, Set, Union\n\nimport config.data as data\nfrom fabric.utils import exec_shell_command_async\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass SystemPlugin(PluginBase):\n    \"\"\"\n    Plugin for system commands and actions.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"System\"\n        self.description = \"System commands and actions\"\n\n        # JSON cache file for system binaries\n        self.bin_cache_file = os.path.join(data.CACHE_DIR, \"system_binaries.json\")\n\n        # In-memory cache for system binaries\n        self._bin_cache: Set[str] = set()\n        self._last_bin_update = 0\n        self._bin_update_interval = 300  # 5 minutes\n\n        # Background cache building\n        self._cache_building = False\n        self._cache_thread = None\n\n    def initialize(self):\n        \"\"\"Initialize the system plugin.\"\"\"\n        self.set_triggers([\"bin\"])\n        self._load_bin_cache()\n        self._start_background_cache_update()\n\n    def cleanup(self):\n        \"\"\"Cleanup the system plugin.\"\"\"\n        self._bin_cache.clear()\n        if self._cache_thread and self._cache_thread.is_alive():\n            # Note: We don't join the thread to avoid blocking cleanup\n            pass\n\n    def _load_bin_cache(self):\n        \"\"\"Load binary cache from JSON file.\"\"\"\n        try:\n            if os.path.exists(self.bin_cache_file):\n                with open(self.bin_cache_file, \"r\", encoding=\"utf-8\") as f:\n                    cache_data = json.load(f)\n                    self._bin_cache = set(cache_data.get(\"binaries\", []))\n                    self._last_bin_update = cache_data.get(\"last_update\", 0)\n            else:\n                print(\n                    \"SystemPlugin: No cache file found, will build cache in background\"\n                )\n        except Exception as e:\n            print(f\"SystemPlugin: Error loading binary cache: {e}\")\n            self._bin_cache = set()\n            self._last_bin_update = 0\n\n    def _save_bin_cache(self):\n        \"\"\"Save binary cache to JSON file.\"\"\"\n        try:\n            # Ensure the cache directory exists\n            os.makedirs(data.CACHE_DIR, exist_ok=True)\n\n            cache_data = {\n                \"binaries\": sorted(list(self._bin_cache)),\n                \"last_update\": self._last_bin_update,\n                \"cache_version\": \"1.0\",\n            }\n\n            with open(self.bin_cache_file, \"w\", encoding=\"utf-8\") as f:\n                json.dump(cache_data, f, indent=2)\n        except Exception as e:\n            print(f\"SystemPlugin: Error saving binary cache: {e}\")\n\n    def _start_background_cache_update(self):\n        \"\"\"Start background thread to update binary cache.\"\"\"\n        current_time = time.time()\n\n        # Check if cache needs updating\n        if (\n            current_time - self._last_bin_update > self._bin_update_interval\n            or not self._bin_cache\n        ):\n            if not self._cache_building:\n                self._cache_building = True\n                self._cache_thread = threading.Thread(\n                    target=self._build_bin_cache_background, daemon=True\n                )\n                self._cache_thread.start()\n\n    def _build_bin_cache_background(self):\n        \"\"\"Build binary cache in background thread.\"\"\"\n        try:\n            new_cache = set()\n            processed_paths = set()  # Avoid duplicate paths\n\n            for path in os.environ[\"PATH\"].split(\":\"):\n                # Skip empty paths and duplicates\n                if not path or path in processed_paths:\n                    continue\n                processed_paths.add(path)\n\n                if os.path.exists(path) and os.path.isdir(path):\n                    try:\n                        # Use os.scandir for better performance than os.listdir\n                        with os.scandir(path) as entries:\n                            for entry in entries:\n                                if entry.is_file(follow_symlinks=False) and os.access(\n                                    entry.path, os.X_OK\n                                ):\n                                    new_cache.add(entry.name)\n                    except (PermissionError, FileNotFoundError, OSError):\n                        continue\n\n            # Update cache atomically\n            self._bin_cache = new_cache\n            self._last_bin_update = time.time()\n\n            # Save to disk\n            self._save_bin_cache()\n\n        except Exception as e:\n            print(f\"SystemPlugin: Error building binary cache: {e}\")\n        finally:\n            self._cache_building = False\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Search for system commands matching the query.\"\"\"\n        query = query_string.strip()\n\n        if not query:\n            return []\n\n        results = []\n\n        # Parse the query to extract binary name and arguments\n        query_parts = query.split()\n        if not query_parts:\n            return []\n\n        binary_query = query_parts[0].lower()\n        full_command = query  # Keep the original case and spacing\n\n        # Check system binaries\n        # Start background update if needed (non-blocking) - but only if cache is empty or very old\n        if not self._bin_cache or (\n            time.time() - self._last_bin_update > self._bin_update_interval\n        ):\n            self._start_background_cache_update()\n\n        # Optimize search with early termination and result limiting\n        exact_matches = []\n        prefix_matches = []\n        partial_matches = []\n        max_results = 20  # Limit total results for performance\n\n        for binary in self._bin_cache:\n            # Pre-compute lowercase once\n            binary_lower = binary.lower()\n\n            # Skip if no match at all\n            if binary_query not in binary_lower:\n                continue\n\n            # Categorize matches for better sorting\n            if binary_lower == binary_query:\n                # Exact match - highest priority\n                display_command = full_command\n                command_to_execute = full_command\n                relevance = 1.0\n                exact_matches.append(\n                    (binary, display_command, command_to_execute, relevance)\n                )\n            elif binary_lower.startswith(binary_query):\n                # Prefix match - high priority\n                display_command = binary\n                command_to_execute = binary\n                relevance = 0.9\n                prefix_matches.append(\n                    (binary, display_command, command_to_execute, relevance)\n                )\n            else:\n                # Partial match - lower priority\n                display_command = binary\n                command_to_execute = binary\n                relevance = 0.7\n                partial_matches.append(\n                    (binary, display_command, command_to_execute, relevance)\n                )\n\n            # Early termination if we have enough good matches\n            if len(exact_matches) + len(prefix_matches) >= max_results:\n                break\n\n        # Combine results in priority order\n        all_matches = exact_matches + prefix_matches + partial_matches\n\n        # Convert to Result objects (limit to max_results)\n        for binary, display_command, command_to_execute, relevance in all_matches[\n            :max_results\n        ]:\n            result = Result(\n                title=display_command,\n                subtitle=f\"Execute: {display_command}\",\n                icon_name=\"terminal\",\n                action=self._create_action(command_to_execute),\n                relevance=relevance,\n                plugin_name=self.display_name,\n                data={\"command\": command_to_execute, \"id\": binary},\n            )\n            results.append(result)\n\n        return results  # Already sorted by priority\n\n    def _create_action(self, command: Union[str, List[str]]):\n        \"\"\"Create an action function for the given command.\"\"\"\n\n        def action():\n            self._execute_command(command)\n\n        return action\n\n    def _execute_command(self, command: Union[str, List[str]]):\n        \"\"\"Execute a system command.\"\"\"\n        try:\n            if isinstance(command, str):\n                # Handle string commands with arguments - split into list for proper execution\n                command_list = shlex.split(command)\n                exec_shell_command_async(command_list)\n            else:\n                # Handle list commands (backward compatibility)\n                exec_shell_command_async(command)\n        except Exception as e:\n            print(f\"SystemPlugin: Error executing command '{command}': {e}\")\n"
  },
  {
    "path": "modules/launcher/plugins/tmux.py",
    "content": "import subprocess\nimport threading\nimport time\nfrom typing import List\n\nimport config.data as data\nfrom fabric.utils import exec_shell_command_async\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass TmuxPlugin(PluginBase):\n    \"\"\"\n    Plugin for managing tmux sessions through the launcher.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Tmux Manager\"\n        self.description = \"Manage tmux sessions - create, attach, rename, and kill\"\n\n        # Cache for sessions to avoid repeated subprocess calls\n        self._sessions_cache = []\n        self._last_cache_update = 0\n        # Cache sessions for 10 seconds (increased from 2)\n        self._cache_ttl = 10\n\n        # Threading for auto-refresh - only when actively used\n        self.refresh_thread = None\n        self.stop_refresh = threading.Event()\n        self._last_query_time = 0\n        # Stop refreshing after 30 seconds of inactivity\n        self._active_refresh_timeout = 30\n\n    def initialize(self):\n        \"\"\"Initialize the tmux plugin.\"\"\"\n        self.set_triggers([\"tmux\"])\n        # Don't start refresh thread immediately - start it when first used\n\n    def cleanup(self):\n        \"\"\"Cleanup the tmux plugin.\"\"\"\n        self.stop_refresh.set()\n        if self.refresh_thread:\n            self.refresh_thread.join(timeout=1)\n        self._sessions_cache.clear()\n\n    def _start_refresh_thread(self):\n        \"\"\"Start background thread to refresh session cache.\"\"\"\n        if not self.refresh_thread or not self.refresh_thread.is_alive():\n            self.refresh_thread = threading.Thread(\n                target=self._refresh_sessions_background, daemon=True\n            )\n            self.refresh_thread.start()\n\n    def _refresh_sessions_background(self):\n        \"\"\"Background thread to refresh sessions cache only when actively used.\"\"\"\n        while not self.stop_refresh.is_set():\n            try:\n                current_time = time.time()\n\n                # Stop refreshing if plugin hasn't been used recently\n                if current_time - self._last_query_time > self._active_refresh_timeout:\n                    print(\"TmuxPlugin: Stopping background refresh due to inactivity\")\n                    break\n\n                if current_time - self._last_cache_update > self._cache_ttl:\n                    self._sessions_cache = self._get_tmux_sessions()\n                    self._last_cache_update = current_time\n\n                self.stop_refresh.wait(5)\n            except Exception as e:\n                print(f\"TmuxPlugin: Error in refresh thread: {e}\")\n                self.stop_refresh.wait(10)  # Wait longer on error\n\n    def _get_tmux_sessions(self):\n        \"\"\"Get list of tmux sessions.\"\"\"\n        try:\n            result = subprocess.run(\n                [\"tmux\", \"list-sessions\", \"-F\", \"#{session_name}\"],\n                capture_output=True,\n                text=True,\n                timeout=5,\n            )\n            if result.returncode == 0:\n                return [\n                    s.strip() for s in result.stdout.strip().split(\"\\n\") if s.strip()\n                ]\n            return []\n        except (\n            subprocess.TimeoutExpired,\n            subprocess.CalledProcessError,\n            FileNotFoundError,\n        ) as e:\n            print(f\"TmuxPlugin: Error getting tmux sessions: {e}\")\n            return []\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Process tmux queries.\"\"\"\n        query = query_string.strip().lower()\n        results = []\n\n        # Track usage and start refresh thread if needed\n        current_time = time.time()\n        self._last_query_time = current_time\n\n        # Start refresh thread if not running and plugin is being used\n        if not self.refresh_thread or not self.refresh_thread.is_alive():\n            self._start_refresh_thread()\n\n        # Get current sessions (use cache if recent)\n        if current_time - self._last_cache_update > self._cache_ttl:\n            self._sessions_cache = self._get_tmux_sessions()\n            self._last_cache_update = current_time\n\n        sessions = self._sessions_cache\n\n        # Handle specific commands\n        if query.startswith(\"new \") or query.startswith(\"create \"):\n            session_name = query.split(\" \", 1)[1].strip() if \" \" in query else \"\"\n            results.append(self._create_new_session_result(session_name))\n\n        elif query.startswith(\"kill \") or query.startswith(\"delete \"):\n            session_name = query.split(\" \", 1)[1].strip() if \" \" in query else \"\"\n            if session_name:\n                matching_sessions = [\n                    s for s in sessions if session_name.lower() in s.lower()\n                ]\n                for session in matching_sessions:\n                    results.append(self._create_kill_session_result(session))\n\n        elif query.startswith(\"rename \"):\n            parts = query.split(\" \", 2)\n            if len(parts) >= 3:\n                old_name, new_name = parts[1], parts[2]\n                if old_name in sessions:\n                    results.append(\n                        self._create_rename_session_result(old_name, new_name)\n                    )\n\n        else:\n            # Show existing sessions for attachment\n            if sessions:\n                # Filter sessions based on query\n                if query:\n                    filtered_sessions = [s for s in sessions if query in s.lower()]\n                else:\n                    filtered_sessions = sessions\n\n                for session in filtered_sessions:\n                    results.append(self._create_attach_session_result(session))\n\n            # Always show option to create new session\n            if not query or \"new\" in query or \"create\" in query:\n                results.append(\n                    self._create_new_session_result(\n                        query\n                        if query and not any(cmd in query for cmd in [\"new\", \"create\"])\n                        else \"\"\n                    )\n                )\n\n        return results\n\n    def _create_attach_session_result(self, session_name: str) -> Result:\n        \"\"\"Create result for attaching to a session.\"\"\"\n        return Result(\n            title=f\"Attach to '{session_name}'\",\n            subtitle=f\"Connect to tmux session: {session_name}\",\n            icon_name=\"terminal\",\n            action=lambda: self._attach_to_session(session_name),\n            relevance=0.9,\n            data={\"type\": \"attach\", \"session\": session_name},\n        )\n\n    def _create_new_session_result(self, session_name: str = \"\") -> Result:\n        \"\"\"Create result for creating a new session.\"\"\"\n        display_name = session_name if session_name else \"new session\"\n        return Result(\n            title=f\"Create '{display_name}'\",\n            subtitle=f\"Create new tmux session{\n                f': {session_name}' if session_name else ''\n            }\",\n            icon_name=\"plus\",\n            action=lambda: self._create_session(session_name),\n            relevance=0.8,\n            data={\"type\": \"create\", \"session\": session_name},\n        )\n\n    def _create_kill_session_result(self, session_name: str) -> Result:\n        \"\"\"Create result for killing a session.\"\"\"\n        return Result(\n            title=f\"Kill '{session_name}'\",\n            subtitle=f\"Terminate tmux session: {session_name}\",\n            icon_name=\"trash\",\n            action=lambda: self._kill_session(session_name),\n            relevance=0.7,\n            data={\"type\": \"kill\", \"session\": session_name},\n        )\n\n    def _create_rename_session_result(self, old_name: str, new_name: str) -> Result:\n        \"\"\"Create result for renaming a session.\"\"\"\n        return Result(\n            title=f\"Rename '{old_name}' to '{new_name}'\",\n            subtitle=f\"Rename tmux session from {old_name} to {new_name}\",\n            icon_name=\"config\",\n            action=lambda: self._rename_session(old_name, new_name),\n            relevance=0.6,\n            data={\"type\": \"rename\", \"old_session\": old_name, \"new_session\": new_name},\n        )\n\n    def _attach_to_session(self, session_name: str):\n        \"\"\"Attach to an existing tmux session.\"\"\"\n        try:\n            terminal_cmd = self._get_terminal_command(\n                f\"tmux attach-session -t '{session_name}'\"\n            )\n            exec_shell_command_async(terminal_cmd)\n            print(f\"TmuxPlugin: Attaching to session '{session_name}'\")\n        except Exception as e:\n            print(f\"TmuxPlugin: Error attaching to session '{session_name}': {e}\")\n\n    def _create_session(self, session_name: str = \"\"):\n        \"\"\"Create a new tmux session.\"\"\"\n        try:\n            if not session_name:\n                # Generate a default name\n                existing_sessions = self._get_tmux_sessions()\n                counter = 0\n                while str(counter) in existing_sessions:\n                    counter += 1\n                session_name = str(counter)\n\n            # Clean the session name\n            clean_name = session_name.strip().replace(\" \", \"_\")\n\n            # Create session\n            subprocess.run(\n                [\"tmux\", \"new-session\", \"-d\", \"-s\", clean_name], check=True, timeout=10\n            )\n\n            # Launch terminal and attach\n            terminal_cmd = self._get_terminal_command(\n                f\"tmux attach-session -t '{clean_name}'\"\n            )\n            exec_shell_command_async(terminal_cmd)\n\n            # Refresh cache\n            self._sessions_cache = self._get_tmux_sessions()\n            self._last_cache_update = time.time()\n\n            print(f\"TmuxPlugin: Created and attached to session '{clean_name}'\")\n        except Exception as e:\n            print(f\"TmuxPlugin: Error creating session '{session_name}': {e}\")\n\n    def _kill_session(self, session_name: str):\n        \"\"\"Kill a tmux session.\"\"\"\n        try:\n            subprocess.run(\n                [\"tmux\", \"kill-session\", \"-t\", session_name], check=True, timeout=10\n            )\n\n            # Refresh cache\n            self._sessions_cache = self._get_tmux_sessions()\n            self._last_cache_update = time.time()\n\n            print(f\"TmuxPlugin: Killed session '{session_name}'\")\n        except Exception as e:\n            print(f\"TmuxPlugin: Error killing session '{session_name}': {e}\")\n\n    def _rename_session(self, old_name: str, new_name: str):\n        \"\"\"Rename a tmux session.\"\"\"\n        try:\n            clean_name = new_name.strip().replace(\" \", \"_\")\n            subprocess.run(\n                [\"tmux\", \"rename-session\", \"-t\", old_name, clean_name],\n                check=True,\n                timeout=10,\n            )\n\n            # Refresh cache\n            self._sessions_cache = self._get_tmux_sessions()\n            self._last_cache_update = time.time()\n\n            print(f\"TmuxPlugin: Renamed session '{old_name}' to '{clean_name}'\")\n        except Exception as e:\n            print(\n                f\"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}\"\n            )\n\n    def _get_terminal_command(self, cmd: str) -> str:\n        \"\"\"Get terminal command based on configured terminal or available terminals.\"\"\"\n        # First try to use the configured terminal command\n        if hasattr(data, \"TERMINAL_COMMAND\") and data.TERMINAL_COMMAND:\n            parts = data.TERMINAL_COMMAND.split()\n            terminal = parts[0]\n\n            try:\n                # Check if the configured terminal is available\n                subprocess.run(\n                    [\"which\", terminal],\n                    check=True,\n                    stdout=subprocess.PIPE,\n                    stderr=subprocess.PIPE,\n                )\n                return f\"{data.TERMINAL_COMMAND} {cmd}\"\n            except subprocess.CalledProcessError:\n                # If configured terminal is not available, fall back to defaults\n                pass\n\n        # Fallback to checking available terminals\n        terminals = [\n            (\"kitty\", f\"kitty -e {cmd}\"),\n            (\"alacritty\", f\"alacritty -e {cmd}\"),\n            (\"foot\", f\"foot {cmd}\"),\n            (\"gnome-terminal\", f\"gnome-terminal -- {cmd}\"),\n            (\"konsole\", f\"konsole -e {cmd}\"),\n            (\"xfce4-terminal\", f\"xfce4-terminal -e '{cmd}'\"),\n        ]\n\n        for term, term_cmd in terminals:\n            try:\n                # Check if terminal is available\n                subprocess.run(\n                    [\"which\", term],\n                    check=True,\n                    stdout=subprocess.PIPE,\n                    stderr=subprocess.PIPE,\n                )\n                return term_cmd\n            except subprocess.CalledProcessError:\n                continue\n\n        # Default fallback\n        return f\"kitty -e {cmd}\"\n"
  },
  {
    "path": "modules/launcher/plugins/wallpaper.py",
    "content": "import colorsys\nimport hashlib\nimport json\nimport os\nimport random\nimport re\nimport threading\nimport time\nfrom typing import Dict, List, Optional\nfrom loguru import logger\n\nfrom gi.repository import GdkPixbuf\nfrom PIL import Image\n\nimport config.data as data\nfrom fabric.utils.helpers import exec_shell_command_async\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass WallpaperPlugin(PluginBase):\n    \"\"\"\n    Plugin for wallpaper management with search, random selection, and matugen integration.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Wallpaper\"\n        self.wallpapers = []\n        self.cache_dir = f\"{data.CACHE_DIR}/thumbs\"\n        self.thumbnail_cache: Dict[str, Optional[GdkPixbuf.Pixbuf]] = {}\n        self.thumbnail_loading = set()  # Track which thumbnails are being loaded\n        self.schemes = {\n            \"scheme-tonal-spot\": \"Tonal Spot\",\n            \"scheme-content\": \"Content\",\n            \"scheme-expressive\": \"Expressive\",\n            \"scheme-fidelity\": \"Fidelity\",\n            \"scheme-fruit-salad\": \"Fruit Salad\",\n            \"scheme-monochrome\": \"Monochrome\",\n            \"scheme-neutral\": \"Neutral\",\n            \"scheme-rainbow\": \"Rainbow\",\n        }\n\n    def initialize(self):\n        \"\"\"Initialize the wallpaper plugin.\"\"\"\n        self.set_triggers([\"wall\"])\n        self._load_wallpapers()\n        os.makedirs(self.cache_dir, exist_ok=True)\n        # Start background thumbnail creation\n        self._start_background_thumbnail_creation()\n\n    def cleanup(self):\n        \"\"\"Cleanup the wallpaper plugin.\"\"\"\n        pass\n\n    def _load_wallpapers(self):\n        \"\"\"Load available wallpapers from the wallpapers directory.\"\"\"\n        try:\n            if os.path.exists(data.WALLPAPERS_DIR):\n                self.wallpapers = sorted(\n                    [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)]\n                )\n            else:\n                logger.error(f\"Wallpapers directory not found: {data.WALLPAPERS_DIR}\")\n        except Exception as e:\n            logger.error(f\"Error loading wallpapers: {e}\")\n\n    def _is_image(self, filename: str) -> bool:\n        \"\"\"Check if file is a supported image format.\"\"\"\n        return filename.lower().endswith(\n            (\".png\", \".jpg\", \".jpeg\", \".bmp\", \".gif\", \".webp\")\n        )\n\n    def _get_matugen_state(self) -> bool:\n        \"\"\"Get current matugen state from config.json.\"\"\"\n        self.matugen_enabled = True  # Default to True\n        try:\n            with open(data.CONFIG_FILE, \"r\") as f:\n                config = json.load(f)\n                self.matugen_enabled = config.get(\"matugen_enabled\", True)\n        except FileNotFoundError:\n            # File doesn't exist, keep default True\n            pass\n        except Exception as e:\n            logger.error(f\"Error reading config file: {e}\")\n            # Keep default True on error\n\n        return self.matugen_enabled\n\n    def _set_matugen_state(self, enabled: bool):\n        \"\"\"Set matugen state and save to config.json.\"\"\"\n        self.matugen_enabled = enabled\n\n        # Save the state to config.json\n        try:\n            # Read current config\n            config = {}\n            if os.path.exists(data.CONFIG_FILE):\n                with open(data.CONFIG_FILE, \"r\") as f:\n                    config = json.load(f)\n\n            # Update matugen state\n            config[\"matugen_enabled\"] = enabled\n\n            # Write back to config file\n            with open(data.CONFIG_FILE, \"w\") as f:\n                json.dump(config, f, indent=4)\n\n        except Exception as e:\n            logger.error(f\"Error writing matugen state to config: {e}\")\n\n        # Clear the search query after toggling matugen\n        self._clear_launcher_query()\n\n        # # Send notification\n        # status = \"enabled\" if enabled else \"disabled\"\n        # exec_shell_command_async(\n        #     f\"notify-send '🎨 Matugen' 'Dynamic colors {status}' -a '{\n        #         data.APP_NAME_CAP\n        #     }' -e\"\n        # )\n\n    def _clear_launcher_query(self):\n        \"\"\"Clear the launcher search query and reset to trigger.\"\"\"\n        try:\n            # Try to access the launcher through the fabric Application\n            from gi.repository import GLib\n\n            from fabric import Application\n\n            app = Application.get_default()\n\n            if app and hasattr(app, \"launcher\"):\n                launcher = app.launcher\n                if launcher and hasattr(launcher, \"search_entry\"):\n\n                    def clear_and_refresh():\n                        # Clear the search entry to just the trigger\n                        launcher.search_entry.set_text(\"wall \")\n                        # Position cursor at the end\n                        launcher.search_entry.set_position(-1)\n                        # Trigger the search to show default wallpaper view\n                        if hasattr(launcher, \"_perform_search\"):\n                            launcher._perform_search(\"wall \")\n                        return False\n\n                    # Use a small delay to ensure the action completes first\n                    GLib.timeout_add(50, clear_and_refresh)\n                    return\n\n            # Fallback: try to find launcher instance through other means\n            import gc\n\n            for obj in gc.get_objects():\n                if hasattr(obj, \"__class__\") and obj.__class__.__name__ == \"Launcher\":\n                    if hasattr(obj, \"search_entry\") and hasattr(obj, \"_perform_search\"):\n\n                        def clear_and_refresh():\n                            obj.search_entry.set_text(\"wall \")\n                            obj.search_entry.set_position(-1)\n                            obj._perform_search(\"wall \")\n                            return False\n\n                        GLib.timeout_add(50, clear_and_refresh)\n                        return\n\n        except Exception as e:\n            logger.error(f\"Could not clear launcher query: {e}\")\n\n    def _get_cache_path(self, filename: str) -> str:\n        \"\"\"Get cache path for wallpaper thumbnail.\"\"\"\n        file_hash = hashlib.md5(filename.encode(\"utf-8\")).hexdigest()\n        return os.path.join(self.cache_dir, f\"{file_hash}.png\")\n\n    def _create_thumbnail(self, filename: str) -> str:\n        \"\"\"Create thumbnail for wallpaper if it doesn't exist.\"\"\"\n        full_path = os.path.join(data.WALLPAPERS_DIR, filename)\n        cache_path = self._get_cache_path(filename)\n\n        if not os.path.exists(cache_path):\n            try:\n                with Image.open(full_path) as img:\n                    # Use faster thumbnail creation with smaller size for better performance\n                    img.thumbnail((32, 32), Image.Resampling.LANCZOS)\n                    img.save(cache_path, \"PNG\", optimize=True)\n            except Exception as e:\n                logger.error(f\"Error creating thumbnail for {filename}: {e}\")\n                return None\n\n        return cache_path\n\n    def _start_background_thumbnail_creation(self):\n        \"\"\"Start background thread to create thumbnails for all wallpapers.\"\"\"\n\n        def create_thumbnails():\n            for wallpaper in self.wallpapers:\n                if wallpaper not in self.thumbnail_loading:\n                    self.thumbnail_loading.add(wallpaper)\n                    try:\n                        cache_path = self._create_thumbnail(wallpaper)\n                        if cache_path and os.path.exists(cache_path):\n                            # Load thumbnail into memory cache\n                            try:\n                                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(\n                                    cache_path, 32, 32, True\n                                )\n                                self.thumbnail_cache[wallpaper] = pixbuf\n                            except Exception as e:\n                                logger.error(\n                                    f\"Error loading thumbnail for {wallpaper}: {e}\"\n                                )\n                                self.thumbnail_cache[wallpaper] = None\n                        else:\n                            self.thumbnail_cache[wallpaper] = None\n                    except Exception as e:\n                        logger.error(f\"Error processing thumbnail for {wallpaper}: {e}\")\n                        self.thumbnail_cache[wallpaper] = None\n                    finally:\n                        self.thumbnail_loading.discard(wallpaper)\n\n                    # Small delay to prevent overwhelming the system\n                    time.sleep(0.01)\n\n        # Start background thread\n        thread = threading.Thread(target=create_thumbnails, daemon=True)\n        thread.start()\n\n    def _get_thumbnail_fast(self, filename: str) -> Optional[GdkPixbuf.Pixbuf]:\n        \"\"\"Get thumbnail quickly from cache or return None if not ready.\"\"\"\n        # Return cached thumbnail if available\n        if filename in self.thumbnail_cache:\n            return self.thumbnail_cache[filename]\n\n        # Check if thumbnail file exists and load it immediately\n        cache_path = self._get_cache_path(filename)\n        if os.path.exists(cache_path):\n            try:\n                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(\n                    cache_path, 32, 32, True\n                )\n                self.thumbnail_cache[filename] = pixbuf\n                return pixbuf\n            except Exception as e:\n                logger.error(f\"Error loading thumbnail for {filename}: {e}\")\n                self.thumbnail_cache[filename] = None\n                return None\n\n        # If not in cache and file doesn't exist, trigger background creation\n        if filename not in self.thumbnail_loading:\n            self.thumbnail_loading.add(filename)\n\n            def create_async():\n                try:\n                    cache_path = self._create_thumbnail(filename)\n                    if cache_path and os.path.exists(cache_path):\n                        try:\n                            pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(\n                                cache_path, 32, 32, True\n                            )\n                            self.thumbnail_cache[filename] = pixbuf\n                        except Exception as e:\n                            print(f\"Error loading thumbnail for {filename}: {e}\")\n                            self.thumbnail_cache[filename] = None\n                    else:\n                        self.thumbnail_cache[filename] = None\n                except Exception as e:\n                    print(f\"Error creating thumbnail for {filename}: {e}\")\n                    self.thumbnail_cache[filename] = None\n                finally:\n                    self.thumbnail_loading.discard(filename)\n\n            thread = threading.Thread(target=create_async, daemon=True)\n            thread.start()\n\n        return None\n\n    def _set_wallpaper(self, filename: str, scheme: str = None):\n        \"\"\"Set wallpaper and apply matugen color scheme if enabled.\"\"\"\n        full_path = os.path.join(data.WALLPAPERS_DIR, filename)\n        current_wall = os.path.expanduser(\"~/.current.wall\")\n\n        if scheme is None:\n            scheme = self._get_current_scheme()\n\n        # Update current wallpaper symlink\n        if os.path.isfile(current_wall) or os.path.islink(current_wall):\n            os.remove(current_wall)\n        os.symlink(full_path, current_wall)\n\n        # Always set the wallpaper image\n        exec_shell_command_async(\n            f'swww img \"{\n                full_path\n            }\" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'\n        )\n\n        # If Matugen is enabled, also apply the color scheme\n        matugen_enabled = self._get_matugen_state()\n        if matugen_enabled:\n            exec_shell_command_async(f'matugen image \"{full_path}\" -t {scheme}')\n\n    def _set_random_wallpaper(self):\n        \"\"\"Set a random wallpaper.\"\"\"\n        if not self.wallpapers:\n            return\n\n        filename = random.choice(self.wallpapers)\n        self._set_wallpaper(filename)\n        # Show notification for immediate feedback\n        exec_shell_command_async(\n            f\"notify-send '🎲 Random Wallpaper' 'Applied: {filename}' -a '{\n                data.APP_NAME_CAP\n            }' -e\"\n        )\n        return filename\n\n    def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str:\n        \"\"\"Convert HSL color value to RGB HEX string.\"\"\"\n        # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0\n        hue = h / 360.0\n        r, g, b = colorsys.hls_to_rgb(hue, l, s)  # Note the order: H, L, S\n        r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255)\n        return f\"#{r_int:02X}{g_int:02X}{b_int:02X}\"\n\n    def _is_valid_hex_color(self, hex_color: str) -> bool:\n        \"\"\"Check if string is a valid hex color.\"\"\"\n        if not hex_color.startswith(\"#\"):\n            hex_color = \"#\" + hex_color\n        return bool(re.match(r\"^#[0-9A-Fa-f]{6}$\", hex_color))\n\n    def _get_current_scheme(self) -> str:\n        \"\"\"Get current color scheme from config (default to tonal-spot).\"\"\"\n        try:\n            with open(data.CONFIG_FILE, \"r\") as f:\n                config = json.load(f)\n                return config.get(\"current_scheme\", \"scheme-tonal-spot\")\n        except FileNotFoundError:\n            # File doesn't exist, return default\n            return \"scheme-tonal-spot\"\n        except Exception as e:\n            print(f\"Error reading current scheme from config: {e}\")\n            return \"scheme-tonal-spot\"\n\n    def _set_current_scheme(self, scheme: str):\n        \"\"\"Set current color scheme and apply it.\"\"\"\n        scheme_name = self.schemes.get(scheme, scheme)\n        matugen_enabled = self._get_matugen_state()\n\n        # Save the scheme to config\n        try:\n            # Read current config\n            config = {}\n            if os.path.exists(data.CONFIG_FILE):\n                with open(data.CONFIG_FILE, \"r\") as f:\n                    config = json.load(f)\n\n            # Update current scheme\n            config[\"current_scheme\"] = scheme\n\n            # Write back to config file\n            with open(data.CONFIG_FILE, \"w\") as f:\n                json.dump(config, f, indent=4)\n\n        except Exception as e:\n            print(f\"Error saving current scheme to config: {e}\")\n            return\n\n        # Apply the scheme to current wallpaper if matugen is enabled\n        if matugen_enabled:\n            current_wall = os.path.expanduser(\"~/.current.wall\")\n            if os.path.exists(current_wall) and os.path.islink(current_wall):\n                # Get the current wallpaper path\n                wallpaper_path = os.readlink(current_wall)\n                if os.path.exists(wallpaper_path):\n                    # Apply the new scheme to current wallpaper\n                    exec_shell_command_async(\n                        f'matugen image \"{wallpaper_path}\" -t {scheme}'\n                    )\n\n                    # Send notification\n                    exec_shell_command_async(\n                        f\"notify-send '🎨 Color Scheme' 'Applied {\n                            scheme_name\n                        } scheme' -a '{data.APP_NAME_CAP}' -e\"\n                    )\n                else:\n                    # No current wallpaper, just show scheme change notification\n                    exec_shell_command_async(\n                        f\"notify-send '🎨 Color Scheme' 'Set to {\n                            scheme_name\n                        } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e\"\n                    )\n            else:\n                # No current wallpaper, just show scheme change notification\n                exec_shell_command_async(\n                    f\"notify-send '🎨 Color Scheme' 'Set to {\n                        scheme_name\n                    } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e\"\n                )\n        else:\n            # Matugen is disabled, just save the setting\n            exec_shell_command_async(\n                f\"notify-send '🎨 Color Scheme' 'Set to {\n                    scheme_name\n                } (matugen disabled)' -a '{data.APP_NAME_CAP}' -e\"\n            )\n\n    def _apply_hex_color(self, hex_color: str, scheme: str = None):\n        \"\"\"Apply hex color using matugen. Assumes matugen is enabled.\"\"\"\n        if not hex_color.startswith(\"#\"):\n            hex_color = \"#\" + hex_color\n\n        if scheme is None:\n            scheme = self._get_current_scheme()\n\n        exec_shell_command_async(f'matugen color hex \"{hex_color}\" -t {scheme}')\n\n    def _apply_hex_color_direct(self, hex_color: str, scheme: str = None):\n        \"\"\"Apply hex color using matugen (following example_wallpapers.py pattern).\"\"\"\n        if not hex_color.startswith(\"#\"):\n            hex_color = \"#\" + hex_color\n\n        if scheme is None:\n            scheme = self._get_current_scheme()\n\n        exec_shell_command_async(f'matugen color hex \"{hex_color}\" -t {scheme}')\n\n        # Send notification\n        exec_shell_command_async(\n            f\"notify-send '🎨 Hex Color Applied' 'Applied color: {hex_color}' -a '{\n                data.APP_NAME_CAP\n            }' -e\"\n        )\n\n    def _generate_random_hex_color(self) -> str:\n        \"\"\"Generate a random hex color.\"\"\"\n        hue = random.randint(0, 360)\n        return self._hsl_to_rgb_hex(hue)\n\n    def _get_status_indicators(self) -> tuple:\n        \"\"\"Get current status indicators for display.\"\"\"\n        current_scheme = self._get_current_scheme()\n        matugen_enabled = self._get_matugen_state()\n        scheme_name = self.schemes.get(current_scheme, current_scheme)\n\n        indicators = []\n        if not matugen_enabled:\n            indicators.append(\"Matugen Off\")\n\n        indicator_text = \" • \" + \" • \".join(indicators) if indicators else \"\"\n        status_text = f\"Matugen: {\n            'Enabled' if matugen_enabled else 'Disabled'\n        } • Scheme: {scheme_name}\"\n\n        return indicator_text, status_text, current_scheme, matugen_enabled\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Search wallpapers and provide management options.\"\"\"\n        results = []\n        query = query_string.lower().strip()\n\n        # Get status indicators for consistent display\n        indicator_text, status_text, current_scheme, matugen_enabled = (\n            self._get_status_indicators()\n        )\n\n        # Special commands\n        if query.strip() == \"random\":\n            # Show result for random wallpaper (execute on Enter)\n            results.append(\n                Result(\n                    title=f\"Random Wallpaper{indicator_text}\",\n                    subtitle=f\"Set a random wallpaper • {status_text}\",\n                    icon_name=\"media-playlist-shuffle-symbolic\",\n                    action=lambda: self._set_random_wallpaper(),\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\n                        \"action\": \"random\",\n                        \"bypass_max_results\": True,\n                        \"keep_launcher_open\": True,\n                    },\n                )\n            )\n        elif query.startswith(\"random\") and query.strip() != \"random\":\n            # Show suggestion for random wallpaper (partial match)\n            results.append(\n                Result(\n                    title=f\"Random Wallpaper{indicator_text}\",\n                    subtitle=f\"Set a random wallpaper • {status_text}\",\n                    icon_name=\"media-playlist-shuffle-symbolic\",\n                    action=lambda: self._set_random_wallpaper(),\n                    relevance=0.9,\n                    plugin_name=self.display_name,\n                    data={\n                        \"action\": \"random_suggestion\",\n                        \"bypass_max_results\": True,\n                        \"keep_launcher_open\": True,\n                    },\n                )\n            )\n\n        # Hex color commands\n        if \"color\" in query or \"hex\" in query or query.startswith(\"#\"):\n            # Check for scheme specification in the query\n            scheme = self._get_current_scheme()\n            for scheme_id, scheme_name in self.schemes.items():\n                if (\n                    scheme_name.lower() in query.lower()\n                    or scheme_id.lower() in query.lower()\n                ):\n                    scheme = scheme_id\n                    break\n\n            # Check for hex color patterns\n            hex_match = re.search(r\"#?([0-9A-Fa-f]{6})\", query)\n            if hex_match:\n                hex_color = \"#\" + hex_match.group(1)\n                scheme_name = self.schemes.get(scheme, scheme)\n\n                # Check if this is a complete hex color input (6 digits)\n                # Execute immediately when we have a complete 6-digit hex color\n                if len(hex_match.group(1)) == 6:\n                    # Check if there's additional text after the hex color\n                    hex_end_pos = hex_match.end()\n                    remaining_text = query[hex_end_pos:].strip()\n\n                    # Only show result if no additional text after hex color (exact match)\n                    if not remaining_text:\n                        # Hex colors work when matugen is OFF (following example_wallpapers.py pattern)\n                        if not matugen_enabled:\n                            # Show result for hex color (execute on Enter)\n                            results.append(\n                                Result(\n                                    title=f\"Apply Hex Color: {hex_color}{\n                                        indicator_text\n                                    }\",\n                                    subtitle=f\"Apply with {scheme_name} scheme • {\n                                        status_text\n                                    }\",\n                                    icon_name=\"color-picker-symbolic\",\n                                    action=lambda c=hex_color, s=scheme: self._apply_hex_color_direct(\n                                        c, s\n                                    ),\n                                    relevance=1.0,\n                                    plugin_name=self.display_name,\n                                    data={\n                                        \"action\": \"hex_color\",\n                                        \"color\": hex_color,\n                                        \"scheme\": scheme,\n                                        \"bypass_max_results\": True,\n                                        \"keep_launcher_open\": True,\n                                    },\n                                )\n                            )\n                        else:\n                            # Show error result when matugen is enabled\n                            results.append(\n                                Result(\n                                    title=f\"Cannot Apply Hex Color: {hex_color}{\n                                        indicator_text\n                                    }\",\n                                    subtitle=\"Matugen is enabled • Disable matugen to use hex colors\",\n                                    icon_name=\"color-picker-symbolic\",\n                                    action=lambda: None,\n                                    relevance=1.0,\n                                    plugin_name=self.display_name,\n                                    data={\n                                        \"action\": \"hex_color_failed\",\n                                        \"color\": hex_color,\n                                        \"bypass_max_results\": True,\n                                    },\n                                )\n                            )\n                    else:\n                        # Show suggestion for hex color with additional text (partial match)\n                        results.append(\n                            Result(\n                                title=f\"Apply Hex Color: {hex_color}{indicator_text}\",\n                                subtitle=f\"Apply with {scheme_name} scheme • {\n                                    status_text\n                                }\",\n                                icon_name=\"color-picker-symbolic\",\n                                action=lambda c=hex_color, s=scheme: (\n                                    self._apply_hex_color_direct(c, s)\n                                    if not matugen_enabled\n                                    else None\n                                ),\n                                relevance=0.9,\n                                plugin_name=self.display_name,\n                                data={\n                                    \"action\": \"hex_color_suggestion\",\n                                    \"color\": hex_color,\n                                    \"scheme\": scheme,\n                                    \"bypass_max_results\": True,\n                                    \"keep_launcher_open\": True,\n                                },\n                            )\n                        )\n                else:\n                    # Incomplete hex color, show as suggestion\n                    results.append(\n                        Result(\n                            title=f\"Hex Color (incomplete): {hex_color}\",\n                            subtitle=\"Complete the 6-digit hex color to apply\",\n                            icon_name=\"color-picker-symbolic\",\n                            action=lambda: None,\n                            relevance=0.7,\n                            plugin_name=self.display_name,\n                            data={\n                                \"action\": \"hex_color_incomplete\",\n                                \"color\": hex_color,\n                                \"bypass_max_results\": True,\n                            },\n                        )\n                    )\n            elif \"random\" in query and (\"color\" in query or \"hex\" in query):\n                # Check for exact matches for random color commands\n                if (\n                    query.strip() == \"color random\"\n                    or query.strip() == \"hex random\"\n                    or query.strip() == \"random color\"\n                    or query.strip() == \"random hex\"\n                ):\n                    # Random hex color - show result (execute on Enter)\n                    scheme_name = self.schemes.get(scheme, scheme)\n\n                    # Check if matugen is disabled (hex colors work when matugen is OFF)\n                    if not matugen_enabled:\n                        results.append(\n                            Result(\n                                title=f\"Random Hex Color{indicator_text}\",\n                                subtitle=f\"Generate and apply random color with {\n                                    scheme_name\n                                } scheme • {status_text}\",\n                                icon_name=\"color-picker-symbolic\",\n                                action=lambda s=scheme: self._apply_hex_color_direct(\n                                    self._generate_random_hex_color(), s\n                                ),\n                                relevance=1.0,\n                                plugin_name=self.display_name,\n                                data={\n                                    \"action\": \"random_hex\",\n                                    \"scheme\": scheme,\n                                    \"bypass_max_results\": True,\n                                    \"keep_launcher_open\": True,\n                                },\n                            )\n                        )\n                    else:\n                        # Show error result when matugen is enabled\n                        results.append(\n                            Result(\n                                title=f\"Cannot Apply Random Color{indicator_text}\",\n                                subtitle=\"Matugen is enabled • Disable matugen to use hex colors\",\n                                icon_name=\"color-picker-symbolic\",\n                                action=lambda: None,\n                                relevance=1.0,\n                                plugin_name=self.display_name,\n                                data={\n                                    \"action\": \"random_hex_failed\",\n                                    \"bypass_max_results\": True,\n                                },\n                            )\n                        )\n                else:\n                    # Show suggestion for random hex color (partial match)\n                    results.append(\n                        Result(\n                            title=f\"Random Hex Color{indicator_text}\",\n                            subtitle=f\"Generate and apply random color • {status_text}\",\n                            icon_name=\"color-picker-symbolic\",\n                            action=lambda s=scheme: (\n                                self._apply_hex_color_direct(\n                                    self._generate_random_hex_color(), s\n                                )\n                                if not matugen_enabled\n                                else None\n                            ),\n                            relevance=0.8,\n                            plugin_name=self.display_name,\n                            data={\n                                \"action\": \"random_hex_suggestion\",\n                                \"scheme\": scheme,\n                                \"bypass_max_results\": True,\n                                \"keep_launcher_open\": True,\n                            },\n                        )\n                    )\n            else:\n                # Show hex color help\n                results.append(\n                    Result(\n                        title=\"Hex Color Commands\",\n                        subtitle=\"Use: color #FF5733, hex #00FF00, color random\",\n                        icon_name=\"color-picker-symbolic\",\n                        action=lambda: None,\n                        relevance=0.8,\n                        plugin_name=self.display_name,\n                        data={\"action\": \"hex_help\", \"bypass_max_results\": True},\n                    )\n                )\n\n        # Color scheme commands\n        if \"scheme\" in query:\n            current_scheme = self._get_current_scheme()\n            matugen_enabled = self._get_matugen_state()\n\n            # Show all available schemes\n            for scheme_id, scheme_name in self.schemes.items():\n                # Check if this scheme matches the query (for filtering)\n                if query.strip() == \"scheme\":\n                    # Show all schemes when just \"scheme\" is typed\n                    scheme_matches = True\n                else:\n                    # Extract search terms from query (remove \"scheme\" keyword)\n                    search_terms = query.replace(\"scheme\", \"\").strip().split()\n                    scheme_matches = False\n\n                    # Check if any search term matches the scheme name or ID\n                    for term in search_terms:\n                        if (\n                            term.lower() in scheme_name.lower()\n                            or term.lower() in scheme_id.lower()\n                        ):\n                            scheme_matches = True\n                            break\n\n                if scheme_matches:\n                    # Create indicators\n                    indicators = []\n                    if scheme_id == current_scheme:\n                        indicators.append(\"Current\")\n                    if not matugen_enabled:\n                        indicators.append(\"Matugen Off\")\n\n                    indicator_text = (\n                        \" • \" + \" • \".join(indicators) if indicators else \"\"\n                    )\n\n                    results.append(\n                        Result(\n                            title=f\"{scheme_name}{indicator_text}\",\n                            subtitle=f\"Set color scheme to {scheme_name}\"\n                            + (\n                                \" (requires matugen enabled)\"\n                                if not matugen_enabled\n                                else \"\"\n                            ),\n                            icon_name=\"color-management-symbolic\",\n                            action=lambda s=scheme_id: self._set_current_scheme(s),\n                            relevance=1.0 if scheme_id == current_scheme else 0.8,\n                            plugin_name=self.display_name,\n                            data={\n                                \"action\": \"scheme_select\",\n                                \"scheme\": scheme_id,\n                                \"bypass_max_results\": True,\n                                \"keep_launcher_open\": True,\n                            },\n                        )\n                    )\n\n        # Matugen controls\n        if \"matugen\" in query:\n            current_state = self._get_matugen_state()\n\n            # Check for exact command matches\n            if query.strip() == \"matugen on\" or query.strip() == \"matugen enable\":\n                # Show result for enabling matugen (execute on Enter)\n                results.append(\n                    Result(\n                        title=f\"Enable Matugen{indicator_text}\",\n                        subtitle=f\"Turn on dynamic color generation • {status_text}\",\n                        icon_name=\"color-management-symbolic\",\n                        action=lambda: self._set_matugen_state(True),\n                        relevance=0.9,\n                        plugin_name=self.display_name,\n                        data={\n                            \"action\": \"matugen_on\",\n                            \"bypass_max_results\": True,\n                            \"keep_launcher_open\": True,\n                        },\n                    )\n                )\n            elif query.strip() == \"matugen off\" or query.strip() == \"matugen disable\":\n                # Show result for disabling matugen (execute on Enter)\n                results.append(\n                    Result(\n                        title=f\"Disable Matugen{indicator_text}\",\n                        subtitle=f\"Turn off dynamic color generation • {status_text}\",\n                        icon_name=\"color-management-symbolic\",\n                        action=lambda: self._set_matugen_state(False),\n                        relevance=0.9,\n                        plugin_name=self.display_name,\n                        data={\n                            \"action\": \"matugen_off\",\n                            \"bypass_max_results\": True,\n                            \"keep_launcher_open\": True,\n                        },\n                    )\n                )\n            elif query.strip() == \"matugen toggle\":\n                # Show result for toggling matugen (execute on Enter)\n                new_state = not current_state\n                results.append(\n                    Result(\n                        title=f\"Toggle Matugen to {\n                            'Enabled' if new_state else 'Disabled'\n                        }{indicator_text}\",\n                        subtitle=f\"Switch matugen to {\n                            'enabled' if new_state else 'disabled'\n                        } • {status_text}\",\n                        icon_name=\"color-management-symbolic\",\n                        action=lambda: self._set_matugen_state(new_state),\n                        relevance=0.9,\n                        plugin_name=self.display_name,\n                        data={\n                            \"action\": \"matugen_toggle\",\n                            \"bypass_max_results\": True,\n                            \"keep_launcher_open\": True,\n                        },\n                    )\n                )\n            elif (\"on\" in query or \"enable\" in query) and not query.strip().endswith(\n                (\"on\", \"enable\")\n            ):\n                # Show suggestion for enabling (partial match)\n                results.append(\n                    Result(\n                        title=f\"Enable Matugen{indicator_text}\",\n                        subtitle=f\"Turn on dynamic color generation • {status_text}\",\n                        icon_name=\"color-management-symbolic\",\n                        action=lambda: self._set_matugen_state(True),\n                        relevance=0.8,\n                        plugin_name=self.display_name,\n                        data={\n                            \"action\": \"matugen_on_suggestion\",\n                            \"bypass_max_results\": True,\n                            \"keep_launcher_open\": True,\n                        },\n                    )\n                )\n            elif (\"off\" in query or \"disable\" in query) and not query.strip().endswith(\n                (\"off\", \"disable\")\n            ):\n                # Show suggestion for disabling (partial match)\n                results.append(\n                    Result(\n                        title=f\"Disable Matugen{indicator_text}\",\n                        subtitle=f\"Turn off dynamic color generation • {status_text}\",\n                        icon_name=\"color-management-symbolic\",\n                        action=lambda: self._set_matugen_state(False),\n                        relevance=0.8,\n                        plugin_name=self.display_name,\n                        data={\n                            \"action\": \"matugen_off_suggestion\",\n                            \"bypass_max_results\": True,\n                            \"keep_launcher_open\": True,\n                        },\n                    )\n                )\n            elif \"toggle\" in query and not query.strip().endswith(\"toggle\"):\n                # Show suggestion for toggling (partial match)\n                results.append(\n                    Result(\n                        title=f\"Toggle Matugen{indicator_text}\",\n                        subtitle=f\"Switch matugen state • {status_text}\",\n                        icon_name=\"color-management-symbolic\",\n                        action=lambda: self._set_matugen_state(not current_state),\n                        relevance=0.8,\n                        plugin_name=self.display_name,\n                        data={\n                            \"action\": \"matugen_toggle_suggestion\",\n                            \"bypass_max_results\": True,\n                            \"keep_launcher_open\": True,\n                        },\n                    )\n                )\n            else:\n                # Show current state with scheme info\n                results.append(\n                    Result(\n                        title=f\"Matugen: {'Enabled' if current_state else 'Disabled'}{\n                            indicator_text\n                        }\",\n                        subtitle=f\"Dynamic colors • {status_text}\",\n                        icon_name=\"color-management-symbolic\",\n                        action=lambda: None,\n                        relevance=0.8,\n                        plugin_name=self.display_name,\n                        data={\"action\": \"matugen_status\", \"bypass_max_results\": True},\n                    )\n                )\n\n        # Status command\n        if query == \"status\" or query == \"info\":\n            results.append(\n                Result(\n                    title=f\"Wallpaper System Status{indicator_text}\",\n                    subtitle=status_text,\n                    icon_name=\"color-management-symbolic\",\n                    action=lambda: None,\n                    relevance=1.0,\n                    plugin_name=self.display_name,\n                    data={\"action\": \"status\", \"bypass_max_results\": True},\n                )\n            )\n\n            # Show quick actions\n            results.append(\n                Result(\n                    title=f\"Random Wallpaper{indicator_text}\",\n                    subtitle=f\"Set a random wallpaper • {status_text}\",\n                    icon_name=\"media-playlist-shuffle-symbolic\",\n                    action=lambda: self._set_random_wallpaper(),\n                    relevance=0.9,\n                    plugin_name=self.display_name,\n                    data={\n                        \"action\": \"random_quick\",\n                        \"bypass_max_results\": True,\n                        \"keep_launcher_open\": True,\n                    },\n                )\n            )\n\n        # Search wallpapers by filename - Show ALL wallpapers like example_wallpapers.py\n        if not query or (\n            query\n            and \"matugen\" not in query\n            and \"random\" not in query\n            and \"scheme\" not in query\n            and \"status\" not in query\n            and \"info\" not in query\n            and \"color\" not in query\n            and \"hex\" not in query\n        ):\n            matching_wallpapers = []\n            for wallpaper in self.wallpapers:\n                if not query or query in wallpaper.lower():\n                    # Calculate relevance\n                    relevance = 1.0 if query == wallpaper.lower() else 0.7\n                    if query and query in wallpaper.lower():\n                        relevance = 0.8\n                    matching_wallpapers.append((wallpaper, relevance))\n\n            # Sort by relevance and show ALL wallpapers (like example_wallpapers.py)\n            matching_wallpapers.sort(key=lambda x: x[1], reverse=True)\n\n            # Show ALL wallpapers instead of limiting (following example_wallpapers.py pattern)\n            for wallpaper, relevance in matching_wallpapers:\n                # Use fast thumbnail loading\n                icon = self._get_thumbnail_fast(wallpaper)\n\n                results.append(\n                    Result(\n                        title=f\"{wallpaper}{indicator_text if not query else ''}\",\n                        subtitle=f\"Set as wallpaper{\n                            ' • ' + status_text if not query else ''\n                        }\",\n                        icon=icon,\n                        icon_name=\"image-x-generic-symbolic\" if not icon else None,\n                        action=lambda w=wallpaper: self._set_wallpaper(w),\n                        relevance=relevance,\n                        plugin_name=self.display_name,\n                        data={\n                            \"wallpaper\": wallpaper,\n                            \"action\": \"set\",\n                            \"bypass_max_results\": True,\n                            \"keep_launcher_open\": True,\n                        },\n                    )\n                )\n\n        # Sort by relevance - no limit since we use bypass_max_results\n        results.sort(key=lambda x: x.relevance, reverse=True)\n        return results\n"
  },
  {
    "path": "modules/launcher/plugins/websearch.py",
    "content": "import subprocess\nimport urllib.parse\nfrom typing import List\n\nfrom modules.launcher.plugin_base import PluginBase\nfrom modules.launcher.result import Result\n\n\nclass WebSearchPlugin(PluginBase):\n    \"\"\"\n    Web search plugin that supports multiple search engines.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.display_name = \"Web Search\"\n        self.description = \"Search the web using various search engines\"\n        self.current_trigger = \"\"  # Track the current active trigger\n\n        # Define search engines with their URLs and icons\n        self.search_engines = {\n            \"google\": {\n                \"name\": \"Google\",\n                \"url\": \"https://www.google.com/search?q={}\",\n                \"icon\": \"google-symbolic\",\n                \"description\": \"Search with Google\",\n            },\n            \"duckduckgo\": {\n                \"name\": \"DuckDuckGo\",\n                \"url\": \"https://duckduckgo.com/?q={}\",\n                \"icon\": \"preferences-desktop-search\",\n                \"description\": \"Search with DuckDuckGo (privacy-focused)\",\n            },\n            \"youtube\": {\n                \"name\": \"YouTube\",\n                \"url\": \"https://www.youtube.com/results?search_query={}\",\n                \"icon\": \"youtube-symbolic\",\n                \"description\": \"Search videos on YouTube\",\n            },\n            \"github\": {\n                \"name\": \"GitHub\",\n                \"url\": \"https://github.com/search?q={}\",\n                \"icon\": \"github-desktop-symbolic\",\n                \"description\": \"Search repositories on GitHub\",\n            },\n            \"stackoverflow\": {\n                \"name\": \"Stack Overflow\",\n                \"url\": \"https://stackoverflow.com/search?q={}\",\n                \"icon\": \"stack\",\n                \"description\": \"Search programming questions on Stack Overflow\",\n            },\n            \"wikipedia\": {\n                \"name\": \"Wikipedia\",\n                \"url\": \"https://en.wikipedia.org/wiki/Special:Search?search={}\",\n                \"icon\": \"search-menus-symbolic\",\n                \"description\": \"Search articles on Wikipedia\",\n            },\n            \"reddit\": {\n                \"name\": \"Reddit\",\n                \"url\": \"https://www.reddit.com/search/?q={}\",\n                \"icon\": \"reddit-symbolic\",\n                \"description\": \"Search discussions on Reddit\",\n            },\n            \"linkedin\": {\n                \"name\": \"LinkedIn\",\n                \"url\": \"https://www.linkedin.com/search/results/all/?keywords={}\",\n                \"icon\": \"link\",\n                \"description\": \"Search professionals and jobs on LinkedIn\",\n            },\n        }\n\n        # Default search engine\n        self.default_engine = \"google\"\n\n    def initialize(self):\n        \"\"\"Initialize the web search plugin.\"\"\"\n        # Set up triggers for web search - only main triggers, not individual search engines\n        triggers = [\"?\"]\n\n        # Don't add individual search engine triggers to avoid cluttering trigger keywords\n        # Search engines can still be used within web search context (e.g., \"web google cats\")\n\n        self.set_triggers(triggers)\n\n    def get_active_trigger(self, query_string: str) -> str:\n        \"\"\"Override to track which trigger was activated.\"\"\"\n        trigger = super().get_active_trigger(query_string)\n        if trigger:\n            self.current_trigger = trigger.strip()\n        return trigger\n\n    def cleanup(self):\n        \"\"\"Cleanup the web search plugin.\"\"\"\n        pass\n\n    def query(self, query_string: str) -> List[Result]:\n        \"\"\"Process web search queries.\"\"\"\n        results = []\n\n        if not query_string.strip():\n            # Show available search engines when no query\n            return self._get_search_engine_list()\n\n        # Check if the query is a URL\n        if self._is_url(query_string):\n            results.append(self._create_url_result(query_string))\n            return results\n\n        # Parse query to check if it starts with a search engine name\n        engine_name, search_query = self._parse_engine_query(query_string)\n\n        if engine_name and engine_name in self.search_engines:\n            # Specific search engine specified in query (e.g., \"google cats\")\n            if search_query:\n                # Search with specific engine\n                results.append(self._create_search_result(engine_name, search_query))\n            else:\n                # Show specific engine info\n                results.append(self._create_engine_info_result(engine_name))\n        else:\n            # General search - offer multiple engines\n            search_query = query_string.strip()\n\n            # Add default search engine first\n            results.append(\n                self._create_search_result(self.default_engine, search_query)\n            )\n\n            # Add other popular search engines\n            popular_engines = [\"duckduckgo\", \"youtube\", \"github\"]\n            for engine in popular_engines:\n                if engine != self.default_engine:\n                    results.append(self._create_search_result(engine, search_query))\n\n        return results\n\n    def _parse_engine_query(self, query: str) -> tuple[str, str]:\n        \"\"\"Parse query to extract search engine and search terms.\"\"\"\n        query = query.strip().lower()\n\n        for engine in self.search_engines.keys():\n            if query.startswith(f\"{engine} \"):\n                return engine, query[len(engine) :].strip()\n            elif query == engine:\n                return engine, \"\"\n\n        return \"\", query\n\n    def _is_url(self, text: str) -> bool:\n        \"\"\"Check if the text is a URL.\"\"\"\n        text = text.strip().lower()\n        return text.startswith((\"http://\", \"https://\", \"www.\")) or (\n            \".\" in text and \" \" not in text and len(text) > 3\n        )\n\n    def _create_url_result(self, url: str) -> Result:\n        \"\"\"Create a result for opening a URL directly.\"\"\"\n        # Add protocol if missing\n        if not url.startswith((\"http://\", \"https://\")):\n            if url.startswith(\"www.\"):\n                url = \"https://\" + url\n            else:\n                url = \"https://\" + url\n\n        return Result(\n            title=f\"Open {url}\",\n            subtitle=\"Open this URL in your default browser\",\n            icon_name=\"link\",\n            action=lambda u=url: self._open_url(u),\n            relevance=1.0,\n            plugin_name=self.display_name,\n            data={\"type\": \"url\", \"url\": url},\n        )\n\n    def _open_url(self, url: str):\n        \"\"\"Open a URL directly in the default browser.\"\"\"\n        try:\n            subprocess.Popen(\n                [\"xdg-open\", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL\n            )\n        except Exception as e:\n            print(f\"Failed to open URL: {e}\")\n\n    def _get_search_engine_list(self) -> List[Result]:\n        \"\"\"Get list of available search engines.\"\"\"\n        results = []\n\n        for engine_id, engine_info in self.search_engines.items():\n            result = Result(\n                title=engine_info[\"name\"],\n                subtitle=engine_info[\"description\"],\n                icon_name=engine_info[\"icon\"],\n                action=lambda e=engine_id: self._show_engine_help(e),\n                relevance=1.0 if engine_id == self.default_engine else 0.8,\n                plugin_name=self.display_name,\n                data={\"type\": \"engine_info\", \"engine\": engine_id},\n            )\n            results.append(result)\n\n        return results\n\n    def _create_search_result(self, engine_id: str, query: str) -> Result:\n        \"\"\"Create a search result for a specific engine and query.\"\"\"\n        engine_info = self.search_engines[engine_id]\n\n        return Result(\n            title=f\"Search '{query}' on {engine_info['name']}\",\n            subtitle=f\"{engine_info['description']} - {query}\",\n            icon_name=engine_info[\"icon\"],\n            action=lambda e=engine_id, q=query: self._perform_search(e, q),\n            relevance=1.0 if engine_id == self.default_engine else 0.9,\n            plugin_name=self.display_name,\n            data={\"type\": \"search\", \"engine\": engine_id, \"query\": query},\n        )\n\n    def _create_engine_info_result(self, engine_id: str) -> Result:\n        \"\"\"Create an info result for a specific search engine.\"\"\"\n        engine_info = self.search_engines[engine_id]\n\n        return Result(\n            title=f\"{engine_info['name']} Search\",\n            subtitle=f\"{engine_info['description']} - Type your search query\",\n            icon_name=engine_info[\"icon\"],\n            action=lambda: None,  # No action for info result\n            relevance=1.0,\n            plugin_name=self.display_name,\n            data={\"type\": \"engine_ready\", \"engine\": engine_id},\n        )\n\n    def _perform_search(self, engine_id: str, query: str):\n        \"\"\"Perform a web search using the specified engine.\"\"\"\n        if not query.strip():\n            return\n\n        engine_info = self.search_engines.get(engine_id)\n        if not engine_info:\n            return\n\n        # URL encode the search query\n        encoded_query = urllib.parse.quote_plus(query)\n        search_url = engine_info[\"url\"].format(encoded_query)\n\n        try:\n            # Open the search URL in the default browser\n            subprocess.Popen(\n                [\"xdg-open\", search_url],\n                stdout=subprocess.DEVNULL,\n                stderr=subprocess.DEVNULL,\n            )\n        except Exception as e:\n            print(f\"Failed to open search URL: {e}\")\n\n    def _show_engine_help(self, engine_id: str):\n        \"\"\"Show help for a specific search engine.\"\"\"\n        engine_info = self.search_engines.get(engine_id)\n        if engine_info:\n            print(\n                f\"Search engine: {engine_info['name']} - {engine_info['description']}\"\n            )\n"
  },
  {
    "path": "modules/launcher/result.py",
    "content": "from dataclasses import dataclass\nfrom typing import Any, Callable, Optional\n\nfrom gi.repository import GdkPixbuf, Gtk\n\n\n@dataclass\nclass Result:\n    \"\"\"\n    Represents a search result that can be displayed and activated.\n    \"\"\"\n\n    # Display information\n    title: str\n    subtitle: str = \"\"\n    subtitle_markup: Optional[str] = None  # Pango markup for subtitle\n    description: str = \"\"\n    icon: Optional[GdkPixbuf.Pixbuf] = None\n    icon_name: Optional[str] = None\n    icon_markup: Optional[str] = None\n    # Behavior\n    action: Optional[Callable[[], Any]] = None\n    relevance: float = 0.0\n\n    # Custom widget support\n    custom_widget: Optional[Gtk.Widget] = None\n\n    # Metadata\n    plugin_name: str = \"\"\n    data: Optional[dict] = None\n\n    def activate(self):\n        \"\"\"Activate this result (execute its action).\"\"\"\n        if self.action:\n            return self.action()\n        else:\n            raise NotImplementedError(\"No action defined for this result\")\n\n    def __post_init__(self):\n        \"\"\"Post-initialization processing.\"\"\"\n        # Ensure relevance is within valid range\n        self.relevance = max(0.0, min(1.0, self.relevance))\n\n        # Set default data if None\n        if self.data is None:\n            self.data = {}\n\n    def __str__(self):\n        return f\"Result(title='{self.title}', relevance={self.relevance})\"\n\n    def __repr__(self):\n        return self.__str__()\n"
  },
  {
    "path": "modules/launcher/result_item.py",
    "content": "import gi\nfrom fabric.core.service import Signal\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.eventbox import EventBox\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.label import Label\nfrom modules.launcher.result import Result\n\ngi.require_version(\"Gtk\", \"3.0\")\n\n\nclass ResultItem(EventBox):\n    \"\"\"\n    Widget for displaying a single search result.\n    \"\"\"\n\n    # Signals\n    @Signal\n    def clicked(self, index: int) -> None:\n        \"\"\"Emitted when result is clicked.\"\"\"\n        pass\n\n    @Signal\n    def hovered(self, index: int) -> None:\n        \"\"\"Emitted when result is hovered.\"\"\"\n        pass\n\n    def __init__(\n        self, result: Result, selected: bool = False, index: int = 0, **kwargs\n    ):\n        super().__init__(name=\"launcher-result-item\", **kwargs)\n\n        self.result = result\n        self._selected = selected\n        self.index = index\n\n        # Setup UI\n        self._setup_ui()\n\n        # Connect signals\n        self.connect(\"button-press-event\", self._on_button_press)\n        self.connect(\"enter-notify-event\", self._on_enter)\n        self.connect(\"leave-notify-event\", self._on_leave)\n\n        # Set initial selection state\n        self.set_selected(selected)\n\n    def _setup_ui(self):\n        \"\"\"Setup the result item UI.\"\"\"\n        # Main container\n        main_box = Box(\n            name=\"result-item-main\",\n            orientation=\"h\",\n            spacing=12,\n            h_align=\"fill\",\n            v_align=\"center\",\n        )\n        self.add(main_box)\n\n        # Icon\n        if self.result.icon:\n            icon_widget = Image(pixbuf=self.result.icon, name=\"result-item-icon\")\n        elif self.result.icon_name:\n            icon_widget = Image(\n                icon_name=self.result.icon_name, icon_size=48, name=\"result-item-icon\"\n            )\n        elif self.result.icon_markup:\n            icon_widget = Label(\n                markup=self.result.icon_markup, name=\"launcher-icon-label\"\n            )\n        else:\n            # Default icon\n            icon_widget = Image(\n                icon_name=\"application-default-icon\",\n                icon_size=48,\n                name=\"result-item-icon\",\n            )\n\n        main_box.add(icon_widget)\n\n        # Text container\n        text_box = Box(\n            name=\"result-item-text\",\n            orientation=\"v\",\n            spacing=2,\n            h_expand=True,\n            v_align=\"center\",\n        )\n        main_box.add(text_box)\n\n        # Title\n        title_label = Label(\n            label=self.result.title,\n            name=\"result-item-title\",\n            h_align=\"start\",\n            v_align=\"center\",\n            ellipsize=\"end\",\n        )\n        text_box.add(title_label)\n\n        # Subtitle (if present)\n        if self.result.subtitle or self.result.subtitle_markup:\n            if self.result.subtitle_markup:\n                # Use markup for subtitle (supports Pango markup)\n                subtitle_label = Label(\n                    markup=self.result.subtitle_markup,\n                    name=\"result-item-subtitle\",\n                    h_align=\"start\",\n                    v_align=\"center\",\n                    ellipsize=\"end\",\n                )\n            else:\n                # Use plain text for subtitle\n                subtitle_label = Label(\n                    label=self.result.subtitle,\n                    name=\"result-item-subtitle\",\n                    h_align=\"start\",\n                    v_align=\"center\",\n                    ellipsize=\"end\",\n                )\n            text_box.add(subtitle_label)\n\n        # Plugin name (small text)\n        if self.result.plugin_name:\n            plugin_label = Label(\n                label=f\"via {self.result.plugin_name}\",\n                name=\"result-item-plugin\",\n                h_align=\"start\",\n                v_align=\"center\",\n                ellipsize=\"end\",\n            )\n            text_box.add(plugin_label)\n\n    def set_selected(self, selected: bool):\n        \"\"\"Set the selection state of this result item.\"\"\"\n        self._selected = selected\n\n        if selected:\n            self.add_style_class(\"selected\")\n        else:\n            self.remove_style_class(\"selected\")\n\n    def get_selected(self) -> bool:\n        \"\"\"Get the selection state of this result item.\"\"\"\n        return self._selected\n\n    def _on_button_press(self, widget, event):\n        \"\"\"Handle button press events.\"\"\"\n        if event.button == 1:  # Left click\n            self.clicked.emit(self.index)\n            return True\n        return False\n\n    def _on_enter(self, widget, event):\n        \"\"\"Handle mouse enter events.\"\"\"\n        # Emit hover signal to update selection\n        self.hovered.emit(self.index)\n        return False\n\n    def _on_leave(self, widget, event):\n        \"\"\"Handle mouse leave events.\"\"\"\n        # Could be used for hover effects cleanup\n        return False\n"
  },
  {
    "path": "modules/launcher/trigger_config.py",
    "content": "import json\nimport os\nfrom typing import Any, Dict, List\n\nfrom fabric.utils import get_relative_path\n\n\nclass TriggerConfig:\n    def __init__(self, config_path: str = None):\n        if config_path is None:\n            config_path = get_relative_path(\"../../config/assets/launcher.json\")\n\n        self.config_path = config_path\n\n        # Load configuration from JSON file\n        config = {\"launcher_config\": {}, \"settings\": {}}\n        if os.path.exists(config_path):\n            try:\n                with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                    config = json.load(f)\n            except Exception as e:\n                print(f\"Error loading trigger config: {e}\")\n\n        self.config = config\n        self.launcher_config = self.config.get(\"launcher_config\", {})\n\n        # Initialize settings with defaults\n        default_settings = {\n            \"max_examples_shown\": 2,\n            \"default_icon\": \"application-default-icon\",\n            \"fallback_example_template\": \"{trigger} <search>\",\n            \"config_version\": \"1.0\",\n        }\n        self.settings = {**default_settings, **self.config.get(\"settings\", {})}\n\n    def get_trigger_examples(self, trigger: str) -> List[str]:\n        examples = self.launcher_config.get(trigger, {}).get(\"examples\", [])\n        if not examples:\n            template = self.settings.get(\n                \"fallback_example_template\", \"{trigger} <search>\"\n            )\n            examples = [template.format(trigger=trigger)]\n        return examples\n\n    def get_trigger_icon(self, trigger: str) -> str:\n        icon = self.launcher_config.get(trigger, {}).get(\n            \"icon\", self.settings.get(\"default_icon\", \"application-default-icon\")\n        )\n        return icon\n\n    def get_trigger_description(self, trigger: str) -> str:\n        return self.launcher_config.get(trigger, {}).get(\n            \"description\", f\"{trigger} - No description available\"\n        )\n\n    def get_all_triggers(self) -> Dict[str, Dict[str, Any]]:\n        return self.launcher_config.copy()\n"
  },
  {
    "path": "modules/notification/notification.py",
    "content": "import os\nimport hashlib\nimport time\nimport uuid\n\nfrom fabric.utils import get_relative_path\nfrom gi.repository import Gdk, GdkPixbuf, GLib, Gtk  # type: ignore\nfrom loguru import logger\n\nimport config.data as data\nfrom .unified_cache import (\n    get_unified_cache_key,\n    save_to_cache,\n    get_from_cache,\n    cleanup_cache,\n    get_fallback_icon,\n    ensure_cache_dir\n)\nfrom fabric.notifications import (\n    Notification,\n    NotificationAction,\n    NotificationCloseReason,\n)\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.eventbox import EventBox\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.label import Label\nfrom utils.roam import modus_service\nfrom utils.functions import escape_markup_text\nfrom widgets.custom_image import CustomImage\nfrom widgets.customrevealer import SlideRevealer\nfrom widgets.wayland import WaylandWindow as Window\nfrom services.modus import notification_service\n\nNOTIFICATION_WIDTH = 360\nNOTIFICATION_IMAGE_SIZE = 48\n\nNOTIFICATION_WIDTH = 360\nNOTIFICATION_IMAGE_SIZE = 48\n\n# Unified notification cache directory (for both app icons and notification images)\nUNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, \"notifications\")\n\n# Backward compatibility constants\nNOTIFICATION_ICON_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR\nNOTIFICATION_IMAGE_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR\n\n\ndef ensure_notification_cache_dirs():\n    \"\"\"Ensure unified notification cache directory exists\"\"\"\n    os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True)\n\n\ndef cleanup_old_cache_files():\n    \"\"\"Clean up old notification cache files (older than 7 days)\"\"\"\n    try:\n        if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):\n            return\n\n        current_time = time.time()\n        week_ago = current_time - (7 * 24 * 60 * 60)  # 7 days\n\n        for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):\n            filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)\n            try:\n                if os.path.isfile(filepath):\n                    file_mtime = os.path.getmtime(filepath)\n                    if file_mtime < week_ago:\n                        os.unlink(filepath)\n                        logger.debug(f\"Cleaned up old notification cache: {filename}\")\n            except Exception as e:\n                logger.warning(f\"Failed to cleanup cache file {filename}: {e}\")\n    except Exception as e:\n        logger.warning(f\"Failed to cleanup notification cache: {e}\")\n\n\ndef get_unified_cache_key(source_data, size=None, app_name=None):\n    \"\"\"Generate a unified cache key that works for both app icons and notification images\"\"\"\n    try:\n        if hasattr(source_data, \"get_pixels\"):\n            # For pixbuf data - use hash of pixel data for deterministic caching\n            try:\n                pixel_data = source_data.get_pixels()\n                image_hash = hashlib.md5(pixel_data).hexdigest()[:8]\n                return image_hash\n            except Exception:\n                # Fallback to timestamp if pixel data fails\n                return str(int(time.time()))[:8]\n        elif isinstance(source_data, str):\n            # For file paths - create hash-based name\n            if source_data.startswith(\"file://\"):\n                source_data = source_data[7:]\n            \n            # Create hash from file path and size\n            hash_input = source_data\n            if size:\n                hash_input += f\"_{size[0]}x{size[1]}\"\n            \n            return hashlib.md5(hash_input.encode()).hexdigest()[:8]\n        else:\n            # Fallback to timestamp\n            return str(int(time.time()))[:8]\n    except Exception:\n        # Ultimate fallback\n        return str(int(time.time()))[:8]\n\n\n# Backward compatibility\nget_cache_key = get_unified_cache_key\n\n\ndef save_pixbuf_to_cache(pixbuf, cache_key, cache_dir):\n    \"\"\"Save a pixbuf to the specified cache directory\"\"\"\n    try:\n        ensure_notification_cache_dirs()\n        cache_path = os.path.join(cache_dir, f\"{cache_key}.png\")\n\n        # Don't overwrite existing cache\n        if os.path.exists(cache_path):\n            return cache_path\n\n        pixbuf.savev(cache_path, \"png\", [], [])\n        logger.debug(f\"Cached notification icon: {cache_key}\")\n        return cache_path\n    except Exception as e:\n        logger.warning(f\"Failed to cache notification icon: {e}\")\n        return None\n\n\ndef get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None):\n    \"\"\"Get a cached pixbuf or return None if not found\"\"\"\n    if cache_dir is None:\n        cache_dir = NOTIFICATION_ICON_CACHE_DIR\n\n    try:\n        cache_path = os.path.join(cache_dir, f\"{cache_key}.png\")\n        if os.path.exists(cache_path):\n            logger.debug(f\"Using cached notification icon: {cache_key}\")\n            logger.debug(f\"Using cached notification icon: {cache_key}\")\n            return GdkPixbuf.Pixbuf.new_from_file_at_scale(\n                cache_path, fallback_size[0], fallback_size[1], True\n            )\n    except Exception as e:\n        logger.warning(f\"Failed to load cached notification icon: {e}\")\n    return None\n\n\ndef cache_notification_icon(source, size=(48, 48), app_name=None):\n    \"\"\"Optimized notification icon caching with immediate pixbuf generation and caching\"\"\"\n    try:\n        ensure_notification_cache_dirs()\n\n        # Handle different source types with optimized caching\n        if isinstance(source, str):\n            cache_key = get_unified_cache_key(source, size, app_name)\n\n            # Check cache first for immediate return\n            cached_pixbuf = get_cached_pixbuf(\n                cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR\n            )\n            if cached_pixbuf:\n                return cached_pixbuf\n\n            # Load, cache, and return icon in one optimized flow\n            if source.startswith(\"file://\"):\n                # Local file URL - process and cache immediately\n                file_path = source[7:]\n                pixbuf = load_and_cache_local_icon(file_path, cache_key, size)\n            elif os.path.exists(source):\n                # Direct file path - process and cache immediately\n                pixbuf = load_and_cache_local_icon(source, cache_key, size)\n            else:\n                # Icon name - resolve from theme and cache immediately\n                pixbuf = load_and_cache_theme_icon(source, cache_key, size)\n\n            return pixbuf\n\n        elif hasattr(source, \"scale_simple\"):\n            # Already a pixbuf - cache it directly with optimized flow\n            cache_key = get_unified_cache_key(source, size, app_name)\n\n            # Check cache first\n            cached_pixbuf = get_cached_pixbuf(\n                cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR\n            )\n            if cached_pixbuf:\n                return cached_pixbuf\n\n            # Scale once and cache immediately\n            scaled_pixbuf = source.scale_simple(\n                size[0], size[1], GdkPixbuf.InterpType.BILINEAR\n            )\n            save_pixbuf_to_cache(scaled_pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)\n            return scaled_pixbuf\n\n    except Exception as e:\n        logger.warning(f\"Failed to cache notification icon: {e}\")\n\n    # Return fallback with caching\n    return get_fallback_notification_icon(size)\n\n\ndef load_and_cache_local_icon(file_path, cache_key, size):\n    \"\"\"Load a local icon file and cache it\"\"\"\n    try:\n        if os.path.exists(file_path):\n            pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(\n                file_path, size[0], size[1], True\n            )\n            save_pixbuf_to_cache(pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)\n            return pixbuf\n    except Exception as e:\n        logger.warning(f\"Failed to load local notification icon {file_path}: {e}\")\n\n    return get_fallback_notification_icon(size)\n\n\ndef load_and_cache_theme_icon(icon_name, cache_key, size):\n    \"\"\"Load an icon from the current theme and cache it\"\"\"\n    # For simplicity, just use fallback since theme icon loading is complex in GTK4\n    logger.debug(f\"Using fallback for theme icon {icon_name}\")\n    return get_fallback_notification_icon(size)\n\n\ndef get_fallback_notification_icon(size=(48, 48)):\n    \"\"\"Get the fallback notification icon\"\"\"\n    try:\n        fallback_path = get_relative_path(\"../../config/assets/icons/notification.png\")\n        return GdkPixbuf.Pixbuf.new_from_file_at_scale(\n            fallback_path, size[0], size[1], True\n        )\n    except Exception as e:\n        logger.warning(f\"Failed to load fallback notification icon: {e}\")\n        # Create a simple colored rectangle as ultimate fallback\n        try:\n            return GdkPixbuf.Pixbuf.new(\n                GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1]\n            )\n        except:\n            return None\n\n\ndef get_notification_image_cache_key(notification_id, image_pixbuf):\n    \"\"\"Generate a deterministic cache key based on image content to prevent duplicate caching\"\"\"\n    try:\n        # Use image content hash for deterministic caching\n        if image_pixbuf and hasattr(image_pixbuf, \"get_pixels\"):\n            try:\n                pixel_data = image_pixbuf.get_pixels()\n                image_hash = hashlib.md5(pixel_data).hexdigest()[:8]\n                return image_hash\n            except Exception:\n                # If pixel data fails, use image dimensions + timestamp\n                try:\n                    width = image_pixbuf.get_width()\n                    height = image_pixbuf.get_height()\n                    dimension_hash = hashlib.md5(f\"{width}x{height}\".encode()).hexdigest()[:8]\n                    return dimension_hash\n                except Exception:\n                    pass\n        \n        # Fallback to timestamp for invalid pixbufs\n        return str(int(time.time()))[:8]\n    except Exception:\n        # Ultimate fallback\n        return str(int(time.time()))[:8]\n\n\ndef cache_notification_image(notification_id, image_pixbuf, size=(64, 64)):\n    \"\"\"Smart notification image caching that avoids duplicate caching\"\"\"\n    try:\n        ensure_notification_cache_dirs()\n\n        # Generate deterministic cache key based on image content\n        cache_key = get_notification_image_cache_key(notification_id, image_pixbuf)\n        cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f\"{cache_key}.png\")\n\n        # Check if already cached to avoid redundant work\n        if os.path.exists(cache_path):\n            logger.debug(f\"Image cache hit - already exists: {cache_key}\")\n            return cache_path, cache_key\n\n        # Try to scale and save the image\n        try:\n            scaled_pixbuf = image_pixbuf.scale_simple(\n                size[0], size[1], GdkPixbuf.InterpType.BILINEAR\n            )\n            scaled_pixbuf.savev(cache_path, \"png\", [], [])\n            logger.debug(f\"Generated and cached notification image: {cache_key}\")\n            return cache_path, cache_key\n        except Exception as scale_error:\n            logger.debug(f\"Failed to cache image (temp file likely gone): {scale_error}\")\n            return None, None\n\n    except Exception as e:\n        logger.warning(f\"Failed to cache notification image: {e}\")\n        return None, None\n\n\ndef get_cached_notification_image(cache_key):\n    \"\"\"Get a cached notification image or return None if not found\"\"\"\n    try:\n        cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f\"{cache_key}.png\")\n        if os.path.exists(cache_path):\n            return GdkPixbuf.Pixbuf.new_from_file(cache_path)\n    except Exception as e:\n        logger.warning(f\"Failed to load cached notification image: {e}\")\n    return None\n\n\ndef cleanup_notification_image_cache(cache_key=None):\n    \"\"\"Clean up notification image cache - specific key or all\"\"\"\n    try:\n        ensure_notification_cache_dirs()\n\n        if cache_key:\n            # Remove specific cached image\n            cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f\"{cache_key}.png\")\n            if os.path.exists(cache_path):\n                os.unlink(cache_path)\n                logger.debug(f\"Cleaned up cached notification image: {cache_key}\")\n        else:\n            # Remove all cached images\n            for filename in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR):\n                if filename.endswith(\".png\"):\n                    filepath = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, filename)\n                    try:\n                        os.unlink(filepath)\n                        logger.debug(f\"Cleaned up cached notification image: {filename}\")\n                    except Exception as e:\n                        logger.warning(f\"Failed to cleanup cache file {filename}: {e}\")\n    except Exception as e:\n        logger.warning(f\"Failed to cleanup notification image cache: {e}\")\n\n\ndef cleanup_notification_specific_caches(\n    app_icon_source=None, notification_image_cache_key=None\n):\n    \"\"\"Clean up caches specific to a notification (both app icon and notification image) - SINGLE ICON SIZE\"\"\"\n    try:\n        # Clean up notification image cache\n        if notification_image_cache_key:\n            cleanup_notification_image_cache(notification_image_cache_key)\n\n        # Clean up app icon cache for this specific source (only 35x35 version)\n        if app_icon_source:\n            # Only clean 35x35 version since we only cache this size now\n            cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35))\n            cache_path_35 = os.path.join(\n                NOTIFICATION_ICON_CACHE_DIR, f\"{cache_key_35}.png\"\n            )\n            if os.path.exists(cache_path_35):\n                os.unlink(cache_path_35)\n                logger.debug(f\"Cleaned up cached app icon (35x35): {cache_key_35}\")\n\n    except Exception as e:\n        logger.warning(f\"Failed to cleanup notification specific caches: {e}\")\n\n\ndef cleanup_all_notification_caches():\n    \"\"\"Clean up ALL notification caches (icons and images)\"\"\"\n    try:\n        # Clean icon cache\n        if os.path.exists(NOTIFICATION_ICON_CACHE_DIR):\n            for filename in os.listdir(NOTIFICATION_ICON_CACHE_DIR):\n                if filename.endswith(\".png\"):\n                    filepath = os.path.join(NOTIFICATION_ICON_CACHE_DIR, filename)\n                    try:\n                        os.unlink(filepath)\n                        logger.debug(f\"Cleaned up cached notification icon: {filename}\")\n                    except Exception as e:\n                        logger.warning(\n                            f\"Failed to cleanup icon cache file {filename}: {e}\"\n                        )\n\n        # Clean image cache\n        cleanup_notification_image_cache()\n        logger.info(\"Cleaned up all notification caches\")\n    except Exception as e:\n        logger.warning(f\"Failed to cleanup all notification caches: {e}\")\n\n\ndef verify_cache_persistence():\n    \"\"\"Verify that cached icons persist and can be loaded after restart\"\"\"\n    try:\n        icon_cache_files = []\n        image_cache_files = []\n\n        if os.path.exists(NOTIFICATION_ICON_CACHE_DIR):\n            icon_cache_files = [\n                f for f in os.listdir(NOTIFICATION_ICON_CACHE_DIR) if f.endswith(\".png\")\n            ]\n\n        if os.path.exists(NOTIFICATION_IMAGE_CACHE_DIR):\n            image_cache_files = [\n                f\n                for f in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR)\n                if f.endswith(\".png\")\n            ]\n\n        logger.info(\n            f\"Cache persistence check: {len(icon_cache_files)} icons, {\n                len(image_cache_files)\n            } images cached\"\n        )\n\n        # Test loading a few cached items to verify they work\n        for cache_file in icon_cache_files[:2]:  # Test first 2 icon files\n            try:\n                cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file)\n                test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)\n                if test_pixbuf:\n                    logger.debug(f\"Successfully verified cached icon: {cache_file}\")\n            except Exception as e:\n                logger.warning(f\"Failed to load cached icon {cache_file}: {e}\")\n\n        for cache_file in image_cache_files[:2]:  # Test first 2 image files\n            try:\n                cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file)\n                test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)\n                if test_pixbuf:\n                    logger.debug(f\"Successfully verified cached image: {cache_file}\")\n            except Exception as e:\n                logger.warning(f\"Failed to load cached image {cache_file}: {e}\")\n\n        return len(icon_cache_files) + len(image_cache_files) > 0\n\n    except Exception as e:\n        logger.error(f\"Failed to verify cache persistence: {e}\")\n        return False\n\n\ndef migrate_persistent_notifications():\n    \"\"\"Migrate persistent notifications to use cached assets when temp files are gone\"\"\"\n    try:\n        from services.modus import notification_service\n\n        migrated_count = 0\n        for cached_notification in notification_service.cached_notifications:\n            notification = cached_notification._notification\n\n            # Check if notification has image_pixbuf but temp file might be gone\n            if hasattr(notification, \"image_pixbuf\") and notification.image_pixbuf:\n                try:\n                    # Try to access pixel data to test if temp file still exists\n                    notification.image_pixbuf.get_pixels()\n                except Exception:\n                    # Temp file is gone, ensure app icon is cached as fallback\n                    try:\n                        if hasattr(notification, \"app_icon\") and notification.app_icon:\n                            cache_notification_icon(notification.app_icon, (35, 35))\n                            migrated_count += 1\n                            logger.debug(\n                                f\"Migrated notification for {\n                                    notification.app_name\n                                } to use cached app icon\"\n                            )\n                    except Exception as cache_error:\n                        logger.debug(\n                            f\"Failed to cache app icon for {notification.app_name}: {\n                                cache_error\n                            }\"\n                        )\n\n        if migrated_count > 0:\n            logger.info(\n                f\"Migrated {migrated_count} persistent notifications to use cached assets\"\n            )\n\n    except Exception as e:\n        logger.warning(f\"Failed to migrate persistent notifications: {e}\")\n\n\n# Initialize cache and verify persistence on module load\nensure_notification_cache_dirs()\ncleanup_old_cache_files()\nverify_cache_persistence()\n\n# Run migration for persistent notifications on startup\ntry:\n    migrate_persistent_notifications()\nexcept Exception as e:\n    logger.debug(f\"Migration skipped (service not ready): {e}\")\n\n\ndef preload_notification_assets(notification):\n    \"\"\"Preload and cache notification assets with robust error handling for persistent notifications - SINGLE ICON SIZE\"\"\"\n    try:\n        # Cache app icon only at content size (35x35) - scale down for headers at runtime\n        if hasattr(notification, \"app_icon\") and notification.app_icon:\n            try:\n                # Only cache at 35x35 to reduce disk usage - headers will scale this down\n                cache_notification_icon(notification.app_icon, (35, 35))\n            except Exception as icon_error:\n                logger.debug(\n                    f\"Failed to preload app icon for {notification.app_name}: {\n                        icon_error\n                    }\"\n                )\n\n        # Cache notification image if available\n        if hasattr(notification, \"image_pixbuf\") and notification.image_pixbuf:\n            cache_notification_image(notification.id, notification.image_pixbuf, (35, 35))\n\n    except Exception as e:\n        logger.warning(f\"Failed to preload notification assets: {e}\")\n\n\ndef smooth_revealer_animation(revealer: SlideRevealer, duration: int = 280):\n    \"\"\"Configure revealer for ultra-smooth animation\"\"\"\n    revealer.duration = duration\n\n\nclass ActionButton(Button):\n    def __init__(\n        self, action: NotificationAction, index: int, total: int, notification_box\n    ):\n        super().__init__(\n            name=\"action-button\",\n            h_expand=True,\n            on_clicked=self.on_clicked,\n            child=Label(name=\"button-label\", label=action.label),\n        )\n        self.action = action\n        self.notification_box = notification_box\n        style_class = (\n            \"start-action\"\n            if index == 0\n            else \"end-action\"\n            if index == total - 1\n            else \"middle-action\"\n        )\n        self.add_style_class(style_class)\n        self.connect(\n            \"enter-notify-event\", lambda *_: notification_box.hover_button(self)\n        )\n        self.connect(\n            \"leave-notify-event\", lambda *_: notification_box.unhover_button(self)\n        )\n\n    def on_clicked(self, *_):\n        # Mark for cache cleanup when action button is clicked\n        self.notification_box._should_cleanup_cache = True\n        self.action.invoke()\n        self.action.parent.close(\"dismissed-by-user\")\n\n\nclass NotificationWidget(Box):\n    def __init__(\n        self,\n        notification: Notification,\n        timeout_ms=data.NOTIFICATION_TIMEOUT,\n        show_close_button=True,\n        name=\"notification\",\n        **kwargs,\n    ):\n        self.show_close_button = show_close_button\n        self.close_button = None\n        self._is_hovered = False\n        self.notification_image_cache_key = None  # Track cached image for cleanup\n        self.app_icon_source = (\n            notification.app_icon\n        )  # Track app icon source for cleanup\n        self._should_cleanup_cache = False  # Only cleanup cache on manual dismissal\n\n        super().__init__(\n            size=(NOTIFICATION_WIDTH, -1),\n            name=name,\n            orientation=\"v\",\n            h_align=\"fill\",\n            h_expand=True,\n            children=[\n                self.create_content(notification),\n                self.create_action_buttons(notification),\n            ],\n        )\n\n        self.notification = notification\n        self.timeout_ms = timeout_ms\n        self._timeout_id = None\n\n        # Add hover events to the main notification widget\n        self.connect(\"enter-notify-event\", self._on_enter_notify)\n        self.connect(\"leave-notify-event\", self._on_leave_notify)\n\n        self.start_timeout()\n\n    def create_header(self, notification):\n        \"\"\"Create notification header with optimized cached app icon - SINGLE CACHE SIZE\"\"\"\n        try:\n            # Get 35x35 cached icon and scale down to 24x24 for header\n            cached_app_icon_pixbuf = cache_notification_icon(\n                notification.app_icon, (35, 35)\n            )\n\n            if cached_app_icon_pixbuf:\n                # Scale down the 35x35 cached icon to 24x24 for header display\n                header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple(\n                    24, 24, GdkPixbuf.InterpType.BILINEAR\n                )\n                app_icon = CustomImage(pixbuf=header_icon_pixbuf)\n                app_icon.set_name(\"notification-icon\")\n            else:\n                # Fallback to theme icon if caching fails completely\n                app_icon = Image(\n                    name=\"notification-icon\",\n                    icon_name=\"notifications\",\n                    icon_size=24,\n                )\n        except Exception as e:\n            logger.warning(f\"Failed to load cached header icon: {e}\")\n            # Ultimate fallback\n            app_icon = Image(\n                name=\"notification-icon\",\n                icon_name=\"notifications\",\n                icon_size=24,\n            )\n\n        return CenterBox(\n            name=\"notification-title\",\n            start_children=[\n                Box(\n                    spacing=4,\n                    children=[\n                        app_icon,\n                        Label(\n                            notification.app_name,\n                            name=\"notification-app-name\",\n                            h_align=\"start\",\n                        ),\n                    ],\n                )\n            ],\n            end_children=[\n                self.create_close_button() if self.show_close_button else Box()\n            ],\n        )\n\n    def create_content(self, notification):\n        return Box(\n            name=\"notification-content\",\n            spacing=8,\n            children=[\n                Box(\n                    name=\"notification-image\",\n                    children=CustomImage(\n                        pixbuf=self._get_notification_pixbuf(notification)\n                    ),\n                ),\n                Box(\n                    name=\"notification-text\",\n                    orientation=\"v\",\n                    v_align=\"center\",\n                    children=[\n                        Box(\n                            name=\"notification-summary-box\",\n                            orientation=\"h\",\n                            children=[\n                                Label(\n                                    name=\"notification-summary\",\n                                    markup=escape_markup_text(notification.summary.replace(\"\\n\", \" \")),\n                                    h_align=\"start\",\n                                    max_chars_width=40,\n                                    ellipsization=\"end\",\n                                ),\n                                # Label(\n                                #     name=\"notification-app-name\",\n                                #     markup=\" | \" + notification.app_name,\n                                #     h_align=\"start\",\n                                #     ellipsization=\"end\",\n                                # ),\n                            ],\n                        ),\n                        (\n                            Label(\n                                markup=escape_markup_text(notification.body.replace(\"\\n\", \" \")),\n                                h_align=\"start\",\n                                max_chars_width=45,\n                                ellipsization=\"end\",\n                            )\n                            if notification.body\n                            else Label(\n                                markup=\"\",\n                                h_align=\"start\",\n                                ellipsization=\"end\",\n                            )\n                        ),\n                    ],\n                ),\n                Box(h_expand=True),\n                Box(\n                    orientation=\"v\",\n                    children=[\n                        Button(\n                            name=\"notification-close-button\",\n                            image=CustomImage(icon_name=\"close-symbolic\", icon_size=18),\n                            visible=True,  # Initially hidden\n                            on_clicked=lambda *_: self._manual_close(),\n                        ),\n                        Box(v_expand=True),\n                    ],\n                ),\n            ],\n        )\n\n    def _on_enter_notify(self, widget, event):\n        self._is_hovered = True\n        if self.close_button:\n            self.close_button.set_visible(True)\n        self.pause_timeout()\n        return False\n\n    def _on_leave_notify(self, widget, event):\n        self._is_hovered = False\n        if self.close_button:\n            self.close_button.set_visible(False)\n        self.resume_timeout()\n        return False\n\n    def get_pixbuf(self, icon_path, width, height):\n        \"\"\"Get pixbuf with caching support\"\"\"\n        try:\n            # Use the icon caching system\n            cached_pixbuf = cache_notification_icon(icon_path, (width, height))\n            if cached_pixbuf:\n                return cached_pixbuf\n        except Exception as e:\n            logger.warning(f\"Failed to get cached pixbuf for {icon_path}: {e}\")\n\n        # Fallback to original method if caching fails\n        if icon_path.startswith(\"file://\"):\n            icon_path = icon_path[7:]\n\n        if not os.path.exists(icon_path):\n            logger.warning(f\"Icon path does not exist: {icon_path}\")\n            return get_fallback_notification_icon((width, height))\n\n        try:\n            pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)\n            if pixbuf:\n                return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)\n            else:\n                return get_fallback_notification_icon((width, height))\n        except Exception as e:\n            logger.error(f\"Failed to load or scale icon: {e}\")\n            return get_fallback_notification_icon((width, height))\n\n    def _get_notification_pixbuf(self, notification):\n        \"\"\"Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat\"\"\"\n        notification_id = getattr(notification, \"id\", int(time.time()))\n\n        try:\n            # Try to get cached notification image first\n            if hasattr(notification, \"image_pixbuf\") and notification.image_pixbuf:\n                try:\n                    cache_key = get_notification_image_cache_key(notification_id, notification.image_pixbuf)\n                    cached_image = get_cached_notification_image(cache_key)\n                    if cached_image:\n                        return cached_image\n                except Exception as image_error:\n                    logger.debug(f\"Notification image processing failed: {image_error}\")\n                    # Continue to app icon fallback\n\n        except Exception as e:\n            logger.debug(f\"Failed to process notification image: {e}\")\n\n        # Use cached app icon as fallback\n        try:\n            app_icon_source = getattr(notification, \"app_icon\", None)\n            if app_icon_source:\n                cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))\n                if cached_app_icon:\n                    return cached_app_icon\n        except Exception as e:\n            logger.debug(f\"Failed to get cached app icon: {e}\")\n\n        # Ultimate fallback\n        return get_fallback_notification_icon((35, 35))\n\n    def create_action_buttons(self, notification):\n        return Box(\n            name=\"notification-action-buttons\",\n            spacing=4,\n            h_expand=True,\n            children=[\n                ActionButton(action, i, len(notification.actions), self)\n                for i, action in enumerate(notification.actions)\n            ],\n        )\n\n    def start_timeout(self):\n        self.stop_timeout()\n        self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification)\n\n    def stop_timeout(self):\n        if self._timeout_id is not None:\n            GLib.source_remove(self._timeout_id)\n            self._timeout_id = None\n\n    def close_notification(self):\n        self.notification.close(\"expired\")\n        self.stop_timeout()\n        return False\n\n    def pause_timeout(self):\n        self.stop_timeout()\n\n    def resume_timeout(self):\n        if not self._is_hovered:  # Only resume if not hovered\n            self.start_timeout()\n\n    def _manual_close(self):\n        \"\"\"Handle manual close button click - just close notification\"\"\"\n        self.notification.close(\"dismissed-by-user\")\n\n    def destroy(self):\n        self.stop_timeout()\n        # Only clean up caches if this was a manual dismissal\n        if self._should_cleanup_cache:\n            cleanup_notification_specific_caches(\n                app_icon_source=getattr(self, \"app_icon_source\", None),\n                notification_image_cache_key=getattr(\n                    self, \"notification_image_cache_key\", None\n                ),\n            )\n            logger.debug(f\"Cleaned up caches for manually dismissed notification\")\n        else:\n            logger.debug(f\"Preserved caches for timeout/auto-dismissed notification\")\n        super().destroy()\n\n    # @staticmethod\n    def set_pointer_cursor(self, widget, cursor_name):\n        window = widget.get_window()\n        if window:\n            cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name)\n            window.set_cursor(cursor)\n\n    def hover_button(self, button):\n        self.pause_timeout()\n        self.set_pointer_cursor(button, \"hand2\")\n\n    def unhover_button(self, button):\n        # Don't resume timeout here since the notification itself might still be hovered\n        self.set_pointer_cursor(button, \"arrow\")\n\n\nclass NotificationRevealer(SlideRevealer):\n    def __init__(\n        self,\n        notification: Notification,\n        on_transition_end=None,\n        parent_window=None,\n        **kwargs,\n    ):\n        self.notif_box = NotificationWidget(notification, show_close_button=False)\n        self.notification = notification\n        self.on_transition_end = on_transition_end\n        # Reference to NotificationCenter window for queue clearing\n        self.parent_window = parent_window\n        self._is_closing = False\n\n        # Enhanced swipe detection variables for Android-style animation\n        self._drag_start_y = 0\n        self._drag_start_x = 0\n        self._is_dragging = False\n        self._swipe_threshold = 80  # Distance to trigger auto-dismiss\n        self._swipe_velocity_threshold = (\n            150  # Velocity to trigger dismiss even on shorter swipes\n        )\n        self._swipe_in_progress = False\n        self._current_offset = 0\n        self._last_drag_time = 0\n        self._drag_velocity = 0\n        self._spring_back_duration = 200  # Duration for spring-back animation\n        self._dismiss_threshold = 0.3  # Dismiss if swiped 30% of width\n\n        # Animation state\n        self._animation_in_progress = False\n        self._spring_timer_id = None\n        self._css_provider = None\n\n        # Wrap notification in EventBox for swipe detection\n        self.event_box = EventBox(\n            events=[\n                \"button-press-event\",\n                \"button-release-event\",\n                \"motion-notify-event\",\n            ],\n            child=self.notif_box,\n        )\n\n        super().__init__(\n            child=self.event_box,\n            direction=\"right\",\n            duration=280,  # Faster, smoother duration\n        )\n\n        smooth_revealer_animation(self)\n\n        # Connect our own handler that manages the slide animation\n        self.notification.connect(\"closed\", self.on_resolved)\n\n        self._animation_in_progress = True\n\n    def _ease_out_cubic(self, t):\n        \"\"\"Smoother easing function for better animation quality\"\"\"\n        return 1 - pow(1 - t, 3)\n\n    def _ease_out_quart(self, t):\n        \"\"\"Even smoother easing for ultra-smooth animations\"\"\"\n        return 1 - pow(1 - t, 4)\n\n    def _apply_transform(self, offset_x, opacity, scale):\n        \"\"\"Apply smooth CSS transforms for animation\"\"\"\n        try:\n            # Create CSS transformation\n            transform_css = f\"\"\"\n                opacity: {opacity};\n                transform: translateX({offset_x}px) scale({scale});\n                transition: none;\n            \"\"\"\n\n            # Apply to the notification box\n            if hasattr(self.notif_box, \"get_style_context\"):\n                style_context = self.notif_box.get_style_context()\n                if style_context:\n                    # Use CSS provider for smooth transforms\n                    if not hasattr(self, \"_css_provider\") or not self._css_provider:\n                        from gi.repository import Gtk\n\n                        self._css_provider = Gtk.CssProvider()\n                        style_context.add_provider(\n                            self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION\n                        )\n\n                    css_data = f\"* {{ {transform_css} }}\"\n                    self._css_provider.load_from_data(css_data.encode())\n\n        except Exception as e:\n            logger.debug(f\"Transform apply failed (non-critical): {e}\")\n\n    def _animate_dismiss(self, start_offset):\n        \"\"\"Animate the notification sliding out with smooth 60fps animation\"\"\"\n        target_offset = NOTIFICATION_WIDTH + 50\n        duration = 200  # Slightly longer for smoother feel\n\n        if self._spring_timer_id:\n            GLib.source_remove(self._spring_timer_id)\n\n        start_time = GLib.get_monotonic_time() / 1000\n        offset_diff = target_offset - start_offset\n\n        def animate_step():\n            current_time = GLib.get_monotonic_time() / 1000\n            elapsed = current_time - start_time\n            progress = min(1.0, elapsed / duration)\n\n            # Use smoother easing for premium feel\n            eased_progress = self._ease_out_quart(progress)\n            current_offset = start_offset + (offset_diff * eased_progress)\n\n            # Smoother fade and scale transitions\n            opacity = max(0.0, 1.0 - (progress * 0.9))  # Gentler fade\n            scale = max(0.9, 1.0 - (progress * 0.1))  # Subtle scale\n\n            self._apply_transform(current_offset, opacity, scale)\n\n            if progress >= 1.0:\n                # Mark notification for cache cleanup on swipe dismissal\n                self.notif_box._should_cleanup_cache = True\n                try:\n                    self.notification.close(\"dismissed-by-user\")\n                except:\n                    pass\n                return False\n\n            return True\n\n        # Use consistent 60fps timing\n        self._animation_in_progress = True\n        self._spring_timer_id = GLib.timeout_add(16, animate_step)  # ~60 FPS\n\n    def _calculate_drag_velocity(self, current_x):\n        \"\"\"Calculate the velocity of the drag gesture\"\"\"\n        current_time = GLib.get_monotonic_time() / 1000\n\n        if self._last_drag_time > 0:\n            time_diff = current_time - self._last_drag_time\n            if time_diff > 0:\n                distance_diff = current_x - self._drag_start_x - self._current_offset\n                self._drag_velocity = abs(distance_diff / time_diff)\n\n        self._last_drag_time = current_time\n\n    def _on_animation_complete(self, is_hiding=False):\n        if is_hiding:\n            # Manually destroy the notification widget since we disconnected its handler\n            self.notif_box.destroy()\n\n            if self.on_transition_end:\n                self.on_transition_end()\n            self.destroy()\n\n    def on_resolved(\n        self,\n        _notification: Notification,\n        reason: NotificationCloseReason,\n    ):\n        if self._is_closing:\n            return\n\n        self._is_closing = True\n\n        # Clean up any ongoing animations\n        if self._spring_timer_id:\n            GLib.source_remove(self._spring_timer_id)\n\n        # Use different slide directions based on dismiss reason\n        if reason == \"expired\":\n            # Gentle fade-out for auto-dismiss\n            self.set_slide_direction(\"left\")\n            self.duration = 250  # Slightly slower for natural feel\n        elif self._swipe_in_progress:\n            # Quick slide for swipe dismissals\n            self.duration = 150\n            self.set_slide_direction(\"right\")\n        else:\n            # Smooth slide for manual close\n            self.set_slide_direction(\"right\")\n            self.duration = 200\n\n        self.hide()\n        # Consistent timing for smooth transitions\n        timeout_duration = self.duration + 50\n        GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True))\n\n    def destroy(self):\n        # Clean up CSS provider and timers\n        if self._spring_timer_id:\n            GLib.source_remove(self._spring_timer_id)\n        super().destroy()\n\n\nclass NotificationState:\n    IDLE = 0\n    SHOWING = 1\n    HIDING = 2\n    TRANSITIONING = 3  # New state for smooth transitions\n\n\nclass ModusNoti(Window):\n    def __init__(self):\n        self._server = notification_service\n\n        self.notifications = Box(\n            v_expand=True,\n            h_expand=True,\n            style=\"margin: 1px 0px 1px 1px;\",\n            orientation=\"v\",\n            spacing=5,\n        )\n\n        # Enhanced queue system for ultra-smooth transitions\n        self.notification_queue = []\n        self.current_notification = None\n        self.notification_state = NotificationState.IDLE\n        self._transition_timer_id = None\n        self._debounce_timer_id = None\n        self._last_notification_time = 0\n\n        # Queue management settings for smooth behavior\n        self.MAX_QUEUE_SIZE = 3  # Limit queue to prevent overwhelming\n        self.TRANSITION_DELAY = 100  # Smoother transition timing\n        self.DEBOUNCE_DELAY = 50  # Prevent rapid fire notifications\n\n        self._server.connect(\"notification-added\", self.on_new_notification)\n        super().__init__(\n            anchor=\"top right\",\n            child=self.notifications,\n            layer=\"overlay\",\n            title=\"modus-notifications\",  # More specific title for debugging\n            all_visible=True,\n            visible=False,  # Start hidden, show only when we have content\n            exclusive=False,\n        )\n\n    def on_new_notification(self, fabric_notif, id):\n        notification: Notification = fabric_notif.get_notification_from_id(id)\n        \n        # Check if notification still exists (might have been removed already)\n        if not notification:\n            return\n\n        if self._server.dont_disturb or modus_service.dont_disturb:\n            # Notification is already cached by the service, just don't show popup\n            return\n\n        # Preload assets immediately for optimal caching and display performance\n        preload_notification_assets(notification)\n\n        # Implement smart queue management for smooth transitions\n        current_time = GLib.get_monotonic_time() / 1000\n\n        # If queue is getting full, remove oldest notifications smoothly\n        if len(self.notification_queue) >= self.MAX_QUEUE_SIZE:\n            # Remove oldest notification from queue (not current showing one)\n            if self.notification_queue:\n                oldest = self.notification_queue.pop(0)\n                try:\n                    oldest.close(\"dismissed-by-user\")\n                except:\n                    pass\n\n        # Add new notification to queue\n        self.notification_queue.append(notification)\n\n        # Debounce rapid notifications for smoother experience\n        if self._debounce_timer_id:\n            GLib.source_remove(self._debounce_timer_id)\n\n        self._debounce_timer_id = GLib.timeout_add(\n            self.DEBOUNCE_DELAY,\n            lambda: self._process_notification_queue_debounced() or False,\n        )\n\n    def _process_notification_queue_debounced(self):\n        \"\"\"Process queue after debounce delay for smooth transitions\"\"\"\n        self._debounce_timer_id = None\n        self._process_notification_queue()\n        return False\n\n    def _process_notification_queue(self):\n        # If we're currently showing a notification and there's a new one in queue\n        if (\n            self.notification_state == NotificationState.SHOWING\n            and self.current_notification\n            and self.notification_queue\n        ):\n            # Smooth transition: start hiding current notification\n            self.notification_state = NotificationState.TRANSITIONING\n            self._start_smooth_transition()\n\n        elif (\n            self.notification_state == NotificationState.IDLE\n            and self.notification_queue\n        ):\n            # If we're idle and have notifications in queue, show the next one\n            self._show_next_notification()\n\n    def _start_smooth_transition(self):\n        \"\"\"Start smooth transition between notifications\"\"\"\n        if self.current_notification and not self.current_notification._is_closing:\n            # Don't mark for cache cleanup during smooth transitions\n            # to maintain performance\n\n            # Use shorter timeout for smooth transitions\n            self.current_notification.notif_box.timeout_ms = 100\n\n            # Force close current notification with smooth animation\n            try:\n                self.current_notification.notification.close(\"expired\")\n            except:\n                pass\n\n    def _show_next_notification(self):\n        if (\n            not self.notification_queue\n            or self.notification_state != NotificationState.IDLE\n        ):\n            return\n\n        notification = self.notification_queue.pop(0)\n        \n        # Check if notification is still valid (might have been removed)\n        if not notification or not hasattr(notification, 'app_icon'):\n            # Skip invalid notifications and try next one\n            if self.notification_queue:\n                self._show_next_notification()\n            return\n            \n        self.notification_state = NotificationState.SHOWING\n\n        new_box = NotificationRevealer(\n            notification,\n            on_transition_end=lambda: self._on_notification_finished(new_box),\n            parent_window=self,\n        )\n\n        self.current_notification = new_box\n\n        # Clear any existing children\n        for child in list(self.notifications.children):\n            try:\n                self.notifications.remove(child)\n            except:\n                pass\n\n        self.notifications.children = [new_box]\n\n        # Show the window now that we have content to display\n        self.set_visible(True)\n\n        new_box.show()\n        self.notifications.queue_resize()\n\n        def start_animation():\n            if new_box.get_parent() and new_box.get_realized():\n                new_box.reveal()\n                return False\n            return True\n\n        GLib.idle_add(start_animation)\n\n    def _on_notification_finished(self, notification_box):\n        if notification_box != self.current_notification:\n            return\n\n        # Cancel any pending transition timer\n        if self._transition_timer_id:\n            GLib.source_remove(self._transition_timer_id)\n            self._transition_timer_id = None\n\n        # Safely remove notification box\n        try:\n            if notification_box in self.notifications.children:\n                self.notifications.remove(notification_box)\n        except:\n            pass\n\n        # Reset state\n        self.current_notification = None\n        self.notification_state = NotificationState.IDLE\n\n        # Process next notification with optimized delay for ultra-smooth transitions\n        if self.notification_queue:\n            self._transition_timer_id = GLib.timeout_add(\n                self.TRANSITION_DELAY,  # Consistent smooth timing\n                lambda: self._show_next_notification() or False,\n            )\n        else:\n            # Hide window when no more notifications to show\n            self.set_visible(False)\n\n    def show_next_notification(self):\n        # Legacy method for compatibility - redirect to new implementation\n        self._show_next_notification()\n\n    def on_notification_finished(self, notification_box):\n        # Legacy method for compatibility - redirect to new implementation\n        self._on_notification_finished(notification_box)\n\n    def clear_notification_queue(self):\n        \"\"\"Clear queue with smooth cleanup\"\"\"\n        queue_length = len(self.notification_queue)\n        if queue_length > 0:\n            # Smooth dismissal of queued notifications\n            for notification in list(self.notification_queue):\n                try:\n                    notification.close(\"dismissed-by-user\")\n                except:\n                    pass\n            self.notification_queue.clear()\n\n        # Also clean current notification if showing\n        if self.current_notification:\n            # Mark for cache cleanup when clearing queue\n            self.current_notification.notif_box._should_cleanup_cache = True\n\n        # Clear animation timers\n        if self._transition_timer_id:\n            GLib.source_remove(self._transition_timer_id)\n            self._transition_timer_id = None\n\n        if self._debounce_timer_id:\n            GLib.source_remove(self._debounce_timer_id)\n            self._debounce_timer_id = None\n\n    def get_queue_length(self):\n        return len(self.notification_queue)\n"
  },
  {
    "path": "modules/notification/notification_center.py",
    "content": "from collections import defaultdict\nimport time\n\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.eventbox import EventBox\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.revealer import Revealer\nfrom fabric.widgets.scrolledwindow import ScrolledWindow\nfrom gi.repository import GLib, GdkPixbuf\nfrom loguru import logger\n\nfrom modules.notification.notification import (\n    NotificationWidget,\n    cache_notification_icon,\n    cache_notification_image,\n    get_cached_notification_image,\n    get_notification_image_cache_key,\n    preload_notification_assets,\n    cleanup_all_notification_caches,\n    cleanup_notification_specific_caches,\n    get_fallback_notification_icon,\n)\nfrom services.modus import notification_service\nfrom utils.functions import escape_markup_text\nfrom widgets.custom_image import CustomImage\nfrom widgets.wayland import WaylandWindow as Window\nfrom config import data\n\n\nclass ExpandableNotificationGroup(Box):\n    def __init__(self, app_name, notifications, **kwargs):\n        super().__init__(\n            name=\"notification-group\", orientation=\"v\", spacing=0, **kwargs\n        )\n\n        self.app_name = app_name\n        self.notifications = notifications\n        self.is_expanded = False  # Always start collapsed\n\n        # Create collapsed state first (shows only latest notification)\n        self.create_collapsed_state()\n\n        # Create expanded state (hidden initially)\n        self.create_expanded_state()\n\n        # Ensure we start in collapsed state\n        self.collapsed_eventbox.set_visible(True)\n        self.expanded_container.set_visible(False)\n\n    def create_collapsed_state(self):\n        latest_notification = self.notifications[0]  # Most recent notification\n\n        # Create clickable event box\n        self.collapsed_eventbox = EventBox(\n            events=[\"button-press-event\"],\n        )\n        self.collapsed_eventbox.connect(\"button-press-event\", self.on_clicked)\n\n        # Only create stacked effect if we have multiple notifications\n        num_notifications = len(self.notifications)\n\n        if num_notifications == 1:\n            # Single notification - no stacking needed\n            notification_widget = NotificationCenterWidget(\n                notification=latest_notification\n            )\n            self.collapsed_eventbox.add(notification_widget)\n        else:\n            # Multiple notifications - create stacked effect\n            # Create container for the entire stack\n            stack_container = Box(\n                name=\"notification-stack-container\",\n                orientation=\"v\",\n                spacing=0,\n            )\n\n            # Add bottom shadow layer first (deepest)\n            if num_notifications >= 3:\n                bottom_shadow = Box(\n                    name=\"stack-shadow-bottom\",\n                )\n                stack_container.add(bottom_shadow)\n\n            # Add middle shadow layer\n            if num_notifications >= 2:\n                middle_shadow = Box(\n                    name=\"stack-shadow-middle\",\n                )\n                stack_container.add(middle_shadow)\n\n            # Add the main notification content on top\n            main_notification = Box(\n                name=\"stack-main-notification\",\n                spacing=8,\n                children=[\n                    Box(\n                        name=\"notification-image\",\n                        children=CustomImage(\n                            pixbuf=self._get_notification_pixbuf_for_group(\n                                latest_notification\n                            )\n                        ),\n                    ),\n                    Box(\n                        name=\"notification-text\",\n                        orientation=\"v\",\n                        v_align=\"center\",\n                        h_expand=True,\n                        children=[\n                            Box(\n                                name=\"notification-summary-box\",\n                                orientation=\"h\",\n                                children=[\n                                    Label(\n                                        name=\"notification-summary\",\n                                        markup=f\"<b>{self.app_name}</b>\",\n                                        h_align=\"start\",\n                                        ellipsization=\"end\",\n                                    ),\n                                ],\n                            ),\n                            Label(\n                                name=\"notification-body\",\n                                markup=escape_markup_text(latest_notification._notification.summary.replace(\n                                    \"\\n\", \" \"\n                                )),\n                                max_chars_width=25,\n                                h_align=\"start\",\n                                ellipsization=\"end\",\n                            ),\n                        ],\n                    ),\n                    Box(\n                        name=\"notification-count\",\n                        orientation=\"v\",\n                        children=[\n                            Button(\n                                name=\"notification-close\",\n                                image=CustomImage(\n                                    icon_name=\"close-symbolic\", icon_size=18\n                                ),\n                                visible=True,\n                                on_clicked=lambda *_: self._close_single_notification_and_stop_propagation(\n                                    latest_notification\n                                ),\n                            ),\n                            Label(\n                                name=\"notification-count-label\",\n                                label=f\"{len(self.notifications)}\",\n                                h_align=\"end\",\n                            ),\n                        ],\n                    ),\n                ],\n            )\n            stack_container.add(main_notification)\n            self.collapsed_eventbox.add(stack_container)\n\n        self.add(self.collapsed_eventbox)\n\n        # Create expanded state (hidden initially)\n        self.create_expanded_state()\n\n    def _get_notification_pixbuf_for_group(self, cached_notification):\n        \"\"\"Get notification pixbuf using cached image key - fallback to app icon\"\"\"\n        notification = cached_notification._notification\n        notification_id = getattr(notification, \"id\", None)\n\n        # First try to get cached notification image using stored cache key\n        if (\n            hasattr(cached_notification, \"cache_metadata\")\n            and cached_notification.cache_metadata\n        ):\n            notification_image_cache_key = cached_notification.cache_metadata.get(\n                \"notification_image_cache_key\"\n            )\n            if notification_image_cache_key:\n                try:\n                    cached_image = get_cached_notification_image(\n                        notification_image_cache_key\n                    )\n                    if cached_image:\n                        logger.debug(\n                            f\"Using cached notification image: {notification_image_cache_key}\"\n                        )\n                        return cached_image\n                except Exception as e:\n                    logger.debug(f\"Failed to load cached notification image: {e}\")\n\n        # Fallback to app icon using cached key\n        if (\n            hasattr(cached_notification, \"cache_metadata\")\n            and cached_notification.cache_metadata\n        ):\n            app_icon_cache_key = cached_notification.cache_metadata.get(\n                \"app_icon_cache_key\"\n            )\n            if app_icon_cache_key:\n                try:\n                    from modules.notification.unified_cache import get_from_cache\n\n                    cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35))\n                    if cached_app_icon:\n                        # logger.debug(f\"Using cached app icon: {app_icon_cache_key}\")\n                        return cached_app_icon\n                except Exception as e:\n                    logger.debug(f\"Failed to load cached app icon: {e}\")\n\n        # Final fallback - try to cache app icon directly if available\n        try:\n            app_icon_source = getattr(notification, \"app_icon\", None)\n            if app_icon_source:\n                cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))\n                if cached_app_icon:\n                    logger.debug(\n                        f\"Using directly cached app icon for: {app_icon_source}\"\n                    )\n                    return cached_app_icon\n        except Exception as e:\n            logger.debug(f\"Failed to get directly cached app icon: {e}\")\n\n        # Ultimate fallback\n        logger.debug(\"Using fallback notification icon\")\n        return get_fallback_notification_icon((35, 35))\n\n    def create_expanded_state(self):\n        # Create main expanded container\n        self.expanded_container = Box(\n            name=\"notification-group-expanded-container\",\n            orientation=\"v\",\n            spacing=0,\n        )\n\n        # Header with app name and controls\n        self.header_content = Box(\n            orientation=\"h\",\n            h_expand=True,\n            children=[\n                Label(\n                    name=\"notification-group-title\",\n                    markup=f\"<b>{self.app_name}</b>\",\n                    h_align=\"start\",\n                    h_expand=True,\n                ),\n                Button(\n                    name=\"notification-show-less\",\n                    label=\"Show less\",\n                    on_clicked=self.collapse,\n                    h_align=\"end\",\n                ),\n                Button(\n                    name=\"notification-close-summery\",\n                    h_expand=False,\n                    v_expand=False,\n                    on_clicked=self.close_all,\n                    image=CustomImage(\n                        icon_name=\"close-symbolic\",\n                        name=\"notification-close-header\",\n                        icon_size=18,\n                        h_align=\"end\",\n                    ),\n                    visible=True,\n                ),\n            ],\n        )\n\n        # Wrap header in revealer for slide-up animation during collapse\n        self.header_revealer = Revealer(\n            child=self.header_content,\n            transition_type=\"slide-up\",\n            transition_duration=300,\n            child_revealed=False,\n        )\n\n        # Box for individual notifications\n        self.notifications_list = Box(\n            name=\"notification-group-notifications\",\n            orientation=\"v\",\n            spacing=5,\n        )\n\n        # Add individual notifications to the list\n        for notification in self.notifications:\n            notification_widget = NotificationCenterWidget(notification=notification)\n            self.notifications_list.add(notification_widget)\n\n        # Wrap notifications list in revealer for slide-down animation\n        self.notifications_revealer = Revealer(\n            child=self.notifications_list,\n            transition_type=\"slide-down\",\n            transition_duration=300,\n            child_revealed=False,\n        )\n\n        # Wrap notifications revealer in crossfade revealer for closing animation\n        self.notifications_crossfade = Revealer(\n            child=self.notifications_revealer,\n            transition_type=\"crossfade\",\n            transition_duration=250,\n            child_revealed=True,  # Start revealed so crossfade works on close\n        )\n\n        # Add header revealer and notifications crossfade to container\n        self.expanded_container.add(self.header_revealer)\n        self.expanded_container.add(self.notifications_crossfade)\n\n        # Add the container to the main group\n        self.add(self.expanded_container)\n\n        # Hide the entire expanded container initially\n        self.expanded_container.set_visible(False)\n\n    def on_clicked(self, widget, event):\n        if event.button == 1:  # Left click\n            # Always allow expansion, even for single notifications\n            # This ensures single notifications can show their expanded entry view\n            self.expand()\n        return True\n\n    def expand(self, *args):\n        \"\"\"Expand to show all notifications in this group with slide-down animation\"\"\"\n        self.is_expanded = True\n        self.collapsed_eventbox.set_visible(False)\n        self.expanded_container.set_visible(True)\n\n        # Show header immediately (no animation on expand)\n        self.header_revealer.set_reveal_child(True)\n\n        # Ensure crossfade is revealed for expand\n        self.notifications_crossfade.set_reveal_child(True)\n\n        # Small delay then animate notifications sliding down\n        GLib.timeout_add(50, lambda: self.notifications_revealer.set_reveal_child(True))\n        logger.debug(f\"Expanded notification group: {self.app_name}\")\n\n    def collapse(self, *args):\n        \"\"\"Collapse with header sliding up, notifications crossfading, then sliding up\"\"\"\n        self.is_expanded = False\n\n        # Start header slide-up and notifications crossfade simultaneously\n        self.header_revealer.set_reveal_child(False)\n        self.notifications_crossfade.set_reveal_child(False)\n\n        # Show collapsed state and hide expanded container halfway through crossfade\n        GLib.timeout_add(125, self._show_collapsed_midway)\n\n        # After crossfade completes, start slide-up animation (just for cleanup)\n        GLib.timeout_add(\n            260, lambda: self.notifications_revealer.set_reveal_child(False)\n        )\n\n        logger.debug(f\"Collapsed notification group: {self.app_name}\")\n\n    def _show_collapsed_midway(self):\n        \"\"\"Show collapsed state and hide expanded container to prevent deformation\"\"\"\n        self.collapsed_eventbox.set_visible(True)\n        self.expanded_container.set_visible(False)\n        return False  # Don't repeat timeout\n\n    def _complete_collapse(self):\n        \"\"\"Complete the collapse animation - no longer needed but kept for compatibility\"\"\"\n        return False  # Don't repeat timeout\n\n    def close_all(self, *args):\n        \"\"\"Close all notifications in this group with proper cache cleanup\"\"\"\n        # Close all notifications in this group\n        for notification in self.notifications:\n            try:\n                # Get notification cache metadata for cleanup\n                cache_metadata = getattr(notification, \"cache_metadata\", {})\n\n                # Clean up caches using stored metadata\n                from modules.notification.notification import (\n                    cleanup_notification_specific_caches,\n                )\n\n                cleanup_notification_specific_caches(\n                    app_icon_source=notification._notification.app_icon,\n                    notification_image_cache_key=cache_metadata.get(\n                        \"notification_image_cache_key\"\n                    ),\n                )\n\n                logger.debug(\n                    f\"Cleaned up caches for notification ID: {notification._notification.id}\"\n                )\n\n                notification_service.remove_cached_notification(notification.cache_id)\n            except Exception as e:\n                logger.error(\n                    f\"Error removing notification {notification.cache_id}: {e}\"\n                )\n\n    def _close_single_notification(self, notification):\n        \"\"\"Close a single notification from this group with proper cache cleanup\"\"\"\n        try:\n            # Get notification cache metadata for cleanup\n            cache_metadata = getattr(notification, \"cache_metadata\", {})\n\n            # Clean up caches using stored metadata\n            from modules.notification.notification import (\n                cleanup_notification_specific_caches,\n            )\n\n            cleanup_notification_specific_caches(\n                app_icon_source=notification._notification.app_icon,\n                notification_image_cache_key=cache_metadata.get(\n                    \"notification_image_cache_key\"\n                ),\n            )\n\n            logger.debug(\n                f\"Cleaned up caches for notification ID: {notification._notification.id}\"\n            )\n\n            notification_service.remove_cached_notification(notification.cache_id)\n            logger.debug(f\"Closed single notification: {notification.cache_id}\")\n        except Exception as e:\n            logger.error(\n                f\"Error removing single notification {notification.cache_id}: {e}\"\n            )\n\n    def _close_single_notification_and_stop_propagation(self, notification):\n        \"\"\"Close notification and prevent click from expanding the group\"\"\"\n        self._close_single_notification(notification)\n\n        # If this was the last notification in the group, the group will be removed\n        # by the notification_removed signal handler. If there are still notifications,\n        # we need to check if this group should be removed from view.\n        remaining_notifications = [\n            n for n in self.notifications if n.cache_id != notification.cache_id\n        ]\n\n        if not remaining_notifications:\n            # This was the last notification, the group will be destroyed by signal handler\n            pass\n        else:\n            # Update the notifications list and refresh the view immediately\n            self.notifications = remaining_notifications\n            # Force immediate UI update by destroying and recreating collapsed state\n            if hasattr(self, \"collapsed_eventbox\"):\n                self.collapsed_eventbox.destroy()\n            self.create_collapsed_state()\n            self.show_all()\n\n        return True  # Stop event propagation\n\n\nclass NotificationCenterWidget(NotificationWidget):\n    def __init__(self, notification, **kwargs):\n        self.notification_id = notification.cache_id\n        self.cache_metadata = getattr(notification, \"cache_metadata\", {})\n\n        super().__init__(\n            notification._notification,\n            timeout_ms=0,\n            show_close_button=True,\n            name=\"notification-centre-notifs\",\n            **kwargs,\n        )\n\n    def _get_notification_pixbuf(self, notification):\n        \"\"\"Get notification pixbuf using cached image key - fallback to app icon\"\"\"\n        notification_id = getattr(notification, \"id\", None)\n\n        # First try to get cached notification image using stored cache key\n        if self.cache_metadata:\n            notification_image_cache_key = self.cache_metadata.get(\n                \"notification_image_cache_key\"\n            )\n            if notification_image_cache_key:\n                try:\n                    cached_image = get_cached_notification_image(\n                        notification_image_cache_key\n                    )\n                    if cached_image:\n                        logger.debug(\n                            f\"Using cached notification image: {notification_image_cache_key}\"\n                        )\n                        return cached_image\n                except Exception as e:\n                    logger.debug(f\"Failed to load cached notification image: {e}\")\n\n        # Fallback to app icon using cached key\n        if self.cache_metadata:\n            app_icon_cache_key = self.cache_metadata.get(\"app_icon_cache_key\")\n            if app_icon_cache_key:\n                try:\n                    from modules.notification.unified_cache import get_from_cache\n\n                    cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35))\n                    if cached_app_icon:\n                        # logger.debug(f\"Using cached app icon: {app_icon_cache_key}\")\n                        return cached_app_icon\n                except Exception as e:\n                    logger.debug(f\"Failed to load cached app icon: {e}\")\n\n        # Final fallback - try to cache app icon directly if available\n        try:\n            app_icon_source = getattr(notification, \"app_icon\", None)\n            if app_icon_source:\n                cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))\n                if cached_app_icon:\n                    logger.debug(\n                        f\"Using directly cached app icon for: {app_icon_source}\"\n                    )\n                    return cached_app_icon\n        except Exception as e:\n            logger.debug(f\"Failed to get directly cached app icon: {e}\")\n\n        # Ultimate fallback\n        logger.debug(\"Using fallback notification icon\")\n        return get_fallback_notification_icon((35, 35))\n\n    def create_content(self, notification):\n        # Create our custom close button for notification center\n\n        self.close_button = Button(\n            name=\"notif-close-button\",\n            image=CustomImage(\n                icon_name=\"close-symbolic\", name=\"notification-close\", icon_size=18\n            ),\n            visible=True,  # Always visible in notification center\n            on_clicked=self._on_close_clicked,\n        )\n        self.close_button.connect(\n            \"enter-notify-event\", lambda *_: self.hover_button(self.close_button)\n        )\n        self.close_button.connect(\n            \"leave-notify-event\", lambda *_: self.unhover_button(self.close_button)\n        )\n\n        # Create the content box manually with our custom close button\n        return Box(\n            name=\"notification-content\",\n            spacing=8,\n            children=[\n                Box(\n                    name=\"notification-image\",\n                    children=CustomImage(\n                        pixbuf=self._get_notification_pixbuf(notification)\n                    ),\n                ),\n                Box(\n                    name=\"notification-text\",\n                    orientation=\"v\",\n                    v_align=\"center\",\n                    children=[\n                        Box(\n                            name=\"notification-summary-box\",\n                            orientation=\"h\",\n                            children=[\n                                Label(\n                                    name=\"notification-summary\",\n                                    markup=escape_markup_text(notification.summary.replace(\"\\n\", \" \")),\n                                    h_align=\"start\",\n                                    max_chars_width=25,\n                                    ellipsization=\"end\",\n                                ),\n                            ],\n                        ),\n                        (\n                            Label(\n                                markup=escape_markup_text(notification.body.replace(\"\\n\", \" \")),\n                                h_align=\"start\",\n                                max_chars_width=35,\n                                ellipsization=\"end\",\n                            )\n                            if notification.body\n                            else Label(\n                                markup=\"\",\n                                h_align=\"start\",\n                                ellipsization=\"end\",\n                            )\n                        ),\n                    ],\n                ),\n                Box(h_expand=True),\n                Box(\n                    orientation=\"v\",\n                    children=[\n                        self.close_button,  # Use our custom close button\n                        Box(v_expand=True),\n                    ],\n                ),\n            ],\n        )\n\n    # Override to disable the action buttons\n    def create_action_buttons(self, notification):\n        return Box(name=\"notification-action-buttons\")\n\n    def _on_close_clicked(self, *args):\n        try:\n            # Use instance cache metadata instead of notification attribute\n            cache_metadata = self.cache_metadata\n\n            # Clean up caches using stored metadata\n            from modules.notification.notification import (\n                cleanup_notification_specific_caches,\n            )\n\n            cleanup_notification_specific_caches(\n                app_icon_source=self.notification.app_icon,\n                notification_image_cache_key=cache_metadata.get(\n                    \"notification_image_cache_key\"\n                ),\n            )\n\n            logger.debug(\n                f\"Cleaned up caches for notification center ID: {self.notification.id}\"\n            )\n\n            notification_service.remove_cached_notification(self.notification_id)\n        except Exception as e:\n            logger.error(f\"Error removing notification {self.notification_id}: {e}\")\n\n    # Override to disable timeout functionality\n    def start_timeout(self):\n        pass\n\n    # Override to disable timeout functionality\n    def stop_timeout(self):\n        pass\n\n    # Override to disable auto-close functionality\n    def close_notification(self):\n        return False\n\n\nclass NotificationCenter(Window):\n    def __init__(self):\n        super().__init__(\n            layer=\"overlay\",\n            anchor=\"top right\",\n            visible=False,\n            keyboard_mode=\"on-demand\",\n            title=\"modus\",\n        )\n\n        NOTIFICATION_CENTER_WIDTH = 410\n        self.set_size_request(NOTIFICATION_CENTER_WIDTH, 600)\n\n        # Group notifications by app name\n        self.notification_groups = defaultdict(list)\n        self.group_widgets = {}\n\n        notification_service.connect(\n            \"cached-notification-added\", self.on_notification_added\n        )\n        notification_service.connect(\n            \"cached-notification-removed\", self.on_notification_removed\n        )\n        notification_service.connect(\"clear-all\", self.on_clear_all)\n        notification_service.connect(\"notify::count\", self.on_count_changed)\n\n        main_box = Box(\n            orientation=\"v\",\n            spacing=5,\n            name=\"noti-center-box\",\n        )\n\n        self.scrolled = ScrolledWindow(h_expand=False, v_expand=False)\n        self.notifications_box = Box(\n            v_expand=False,\n            h_expand=False,\n            style=\"margin: 1px 0px 1px 1px;\",\n            orientation=\"v\",\n            spacing=5,\n        )\n        self.scrolled.add(self.notifications_box)\n        main_box.add(self.scrolled)\n\n        # No notifications label - REMOVED\n\n        self.clear_all_button = Button(\n            name=\"noti-clear-button\",\n            label=\"Clear\",\n            on_clicked=self.clear_all_notifications,\n            visible=(notification_service.count > 0),\n        )\n        self.button_centre_box = CenterBox(\n            center_children=[self.clear_all_button],\n        )\n        main_box.add(self.button_centre_box)\n\n        # Wrap main content in revealer for slide-left animation\n        self.main_revealer = Revealer(\n            child=main_box,\n            transition_type=\"slide-left\",\n            transition_duration=400,\n            child_revealed=False,\n        )\n\n        self.children = self.main_revealer\n\n        # Load existing notifications and group them\n        self._rebuild_notification_groups()\n\n        self.add_keybinding(\"Escape\", self._on_escape_pressed)\n        self.connect(\"destroy\", self._on_destroy)\n\n    def _rebuild_notification_groups(self):\n        \"\"\"Rebuild notification groups from scratch with enhanced asset preloading and debugging\"\"\"\n        # Clear existing groups\n        self.notification_groups.clear()\n        self.group_widgets.clear()\n\n        # Clear notifications box\n        for child in self.notifications_box.get_children():\n            child.destroy()\n\n        # Group notifications by app name and preload assets with debugging\n        rebuild_count = 0\n        for cached_notification in notification_service.cached_notifications:\n            app_name = cached_notification._notification.app_name\n            notification_id = getattr(cached_notification._notification, \"id\", None)\n\n            # Skip ignored apps during rebuild\n            if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:\n                continue\n\n            # Preload assets for each cached notification to ensure display consistency\n            preload_notification_assets(cached_notification._notification)\n\n            self.notification_groups[app_name].append(cached_notification)\n            rebuild_count += 1\n\n        logger.info(\n            f\"Rebuilt {rebuild_count} notifications into {\n                len(self.notification_groups)\n            } groups\"\n        )\n\n        # Create group widgets and handle limited apps\n        for app_name, notifications in self.notification_groups.items():\n            # Sort notifications by ID (highest ID first - newest notifications)\n            # This ensures the latest notifications appear at the top of each group\n            notifications.sort(\n                key=lambda n: getattr(n._notification, \"id\", 0), reverse=True\n            )\n\n            # Handle limited apps history - only keep 5 notifications during rebuild\n            if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY:\n                if len(notifications) > 5:\n                    # Keep only the 5 most recent notifications\n                    self.notification_groups[app_name] = notifications[:5]\n\n            group_widget = ExpandableNotificationGroup(\n                app_name, self.notification_groups[app_name]\n            )\n            self.group_widgets[app_name] = group_widget\n            self.notifications_box.add(group_widget)\n\n    def on_notification_added(self, service, cached_notification):\n        try:\n            app_name = cached_notification._notification.app_name\n\n            # Check if this app should be ignored for history (don't add to notification center)\n            if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:\n                return\n\n            # Preload assets for notification center display (ensure caching consistency)\n            preload_notification_assets(cached_notification._notification)\n\n            # Add to groups in sorted order (highest ID first - maintains newest-first ordering)\n            notifications_list = self.notification_groups[app_name]\n            new_notification_id = getattr(cached_notification._notification, \"id\", 0)\n\n            # Find the correct position to insert based on ID (highest first)\n            insert_position = 0\n            for i, existing_notification in enumerate(notifications_list):\n                existing_id = getattr(existing_notification._notification, \"id\", 0)\n                if new_notification_id > existing_id:\n                    insert_position = i\n                    break\n                insert_position = i + 1\n\n            notifications_list.insert(insert_position, cached_notification)\n\n            # Handle limited apps history - only keep 5 notifications\n            if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY:\n                notifications_for_app = self.notification_groups[app_name]\n                if len(notifications_for_app) > 5:\n                    # Remove oldest notifications beyond the limit\n                    excess_notifications = notifications_for_app[5:]\n                    self.notification_groups[app_name] = notifications_for_app[:5]\n\n                    # Remove excess notifications from the service cache\n                    for excess_notification in excess_notifications:\n                        try:\n                            notification_service.remove_cached_notification(\n                                excess_notification.cache_id\n                            )\n                        except Exception as e:\n                            logger.error(f\"Error removing excess notification: {e}\")\n\n            # Update or create group widget\n            if app_name in self.group_widgets:\n                # Update existing group\n                group_widget = self.group_widgets[app_name]\n                group_widget.notifications = self.notification_groups[app_name]\n                # Refresh the group widget\n                self._refresh_group_widget(group_widget)\n            else:\n                # Create new group widget\n                group_widget = ExpandableNotificationGroup(\n                    app_name, self.notification_groups[app_name]\n                )\n                self.group_widgets[app_name] = group_widget\n                self.notifications_box.pack_start(group_widget, False, False, 0)\n                group_widget.show_all()\n\n            logger.debug(f\"Added notification to group {app_name}\")\n        except Exception as e:\n            logger.error(f\"Error adding notification to group: {e}\")\n\n    def _refresh_group_widget(self, group_widget):\n        \"\"\"Refresh a group widget's content\"\"\"\n        try:\n            # Remove existing children\n            for child in group_widget.get_children():\n                group_widget.remove(child)\n\n            # Recreate content\n            group_widget.create_collapsed_state()\n            group_widget.show_all()\n\n        except Exception as e:\n            logger.error(f\"Error refreshing group widget: {e}\")\n\n    def on_notification_removed(self, service, cached_notification):\n        try:\n            app_name = cached_notification._notification.app_name\n\n            # Remove from groups\n            if app_name in self.notification_groups:\n                self.notification_groups[app_name] = [\n                    n\n                    for n in self.notification_groups[app_name]\n                    if n.cache_id != cached_notification.cache_id\n                ]\n\n                # If no more notifications for this app, remove group widget\n                if not self.notification_groups[app_name]:\n                    if app_name in self.group_widgets:\n                        group_widget = self.group_widgets[app_name]\n                        group_widget.destroy()\n                        del self.group_widgets[app_name]\n                        del self.notification_groups[app_name]\n                else:\n                    # Update existing group widget\n                    group_widget = self.group_widgets[app_name]\n                    group_widget.notifications = self.notification_groups[app_name]\n                    self._refresh_group_widget(group_widget)\n\n            # Clean up caches\n            cleanup_notification_specific_caches(\n                app_icon_source=getattr(cached_notification, \"app_icon_source\", None),\n                notification_image_cache_key=getattr(\n                    cached_notification, \"notification_image_cache_key\", None\n                ),\n            )\n\n            logger.debug(f\"Removed notification from group {app_name}\")\n        except Exception as e:\n            logger.error(f\"Error removing notification from group: {e}\")\n\n    def on_clear_all(self, service):\n        try:\n            # Clear all groups\n            self.notification_groups.clear()\n            self.group_widgets.clear()\n\n            # Clear all remaining cached notification images AND icons\n            cleanup_all_notification_caches()\n            for child in self.notifications_box.get_children():\n                child.destroy()\n            logger.debug(\"Cleared all notification groups and remaining cached images\")\n        except Exception as e:\n            logger.error(f\"Error clearing notification groups: {e}\")\n\n    def on_count_changed(self, service, count=None):\n        current_count = notification_service.count\n        # No notifications label removed - only update clear button and scrolled visibility\n        self.clear_all_button.set_visible(current_count > 0)\n        self.scrolled.set_visible(current_count > 0)\n\n        # Auto-close notification center when no notifications remain\n        if current_count == 0 and hasattr(self, \"mousecapture\"):\n            self.mousecapture.hide_child_window()\n\n    def clear_all_notifications(self, *_):\n        # Clear all groups\n        self.notification_groups.clear()\n        self.group_widgets.clear()\n\n        # Clear all remaining cached notification images AND icons when clear all is clicked\n        cleanup_all_notification_caches()  # Clear ALL caches (icons + images)\n        notification_service.clear_all_cached_notifications()\n        if hasattr(self, \"mousecapture\"):\n            self.mousecapture.hide_child_window()\n\n    def _on_escape_pressed(self, *_):\n        if hasattr(self, \"mousecapture\"):\n            self.mousecapture.hide_child_window()\n\n    def _init_mousecapture(self, mousecapture):\n        self.mousecapture = mousecapture\n\n    def _set_mousecapture(self, visible):\n        \"\"\"Control notification center visibility with slide-left animation\"\"\"\n        if visible:\n            self.main_revealer.set_reveal_child(True)\n        else:\n            self.main_revealer.set_reveal_child(False)\n        logger.debug(f\"Notification center visibility set to: {visible}\")\n\n    def _on_destroy(self, *_):\n        # Signals will be automatically disconnected when the object is destroyed\n        pass\n"
  },
  {
    "path": "modules/notification/unified_cache.py",
    "content": "import os\nimport hashlib\nimport time\nimport uuid\n\nfrom fabric.utils import get_relative_path\nfrom gi.repository import GdkPixbuf\nfrom loguru import logger\n\nimport config.data as data\n\n# Unified notification cache directory (for both app icons and notification images)\nUNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, \"notifications\")\n\n\ndef ensure_cache_dir():\n    \"\"\"Ensure unified notification cache directory exists\"\"\"\n    os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True)\n\n\ndef get_unified_cache_key(source_data, size=None, app_name=None):\n    \"\"\"Generate a unified cache key that works for both app icons and notification images\"\"\"\n    try:\n        if hasattr(source_data, \"get_pixels\"):\n            # For pixbuf data - use hash of pixel data for deterministic caching\n            try:\n                pixel_data = source_data.get_pixels()\n                image_hash = hashlib.md5(pixel_data).hexdigest()[:8]\n                return image_hash\n            except Exception:\n                # Fallback to random UUID if pixel data fails\n                return str(uuid.uuid4())[:8]\n        elif isinstance(source_data, str):\n            # For file paths - create hash-based name\n            if source_data.startswith(\"file://\"):\n                source_data = source_data[7:]\n\n            # Create hash from file path and size\n            hash_input = source_data\n            if size:\n                hash_input += f\"_{size[0]}x{size[1]}\"\n\n            return hashlib.md5(hash_input.encode()).hexdigest()[:8]\n        else:\n            # Fallback to random UUID\n            return str(uuid.uuid4())[:8]\n    except Exception:\n        # Ultimate fallback\n        return str(uuid.uuid4())[:8]\n\n\ndef save_to_cache(pixbuf, cache_key, size=None):\n    \"\"\"Save a pixbuf to the unified cache directory\"\"\"\n    try:\n        ensure_cache_dir()\n        cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f\"{cache_key}.png\")\n\n        # Don't overwrite existing cache\n        if os.path.exists(cache_path):\n            logger.debug(f\"Cache hit - already exists: {cache_key}\")\n            return cache_path, cache_key\n\n        # Scale if size is specified\n        if size and (pixbuf.get_width() != size[0] or pixbuf.get_height() != size[1]):\n            pixbuf = pixbuf.scale_simple(\n                size[0], size[1], GdkPixbuf.InterpType.BILINEAR\n            )\n\n        pixbuf.savev(cache_path, \"png\", [], [])\n        logger.debug(f\"Cached notification asset: {cache_key}\")\n        return cache_path, cache_key\n    except Exception as e:\n        logger.warning(f\"Failed to cache notification asset: {e}\")\n        return None, None\n\n\ndef get_from_cache(cache_key, size=None):\n    \"\"\"Get a cached asset or return None if not found\"\"\"\n    try:\n        cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f\"{cache_key}.png\")\n        if os.path.exists(cache_path):\n            # logger.debug(f\"Using cached asset: {cache_key}\")\n            if size:\n                return GdkPixbuf.Pixbuf.new_from_file_at_scale(\n                    cache_path, size[0], size[1], True\n                )\n            else:\n                return GdkPixbuf.Pixbuf.new_from_file(cache_path)\n    except Exception as e:\n        logger.warning(f\"Failed to load cached asset: {e}\")\n    return None\n\n\ndef cleanup_cache(cache_key=None):\n    \"\"\"Clean up unified cache - specific key or all\"\"\"\n    try:\n        ensure_cache_dir()\n\n        if cache_key:\n            # Remove specific cached asset\n            cache_path = os.path.join(\n                UNIFIED_NOTIFICATION_CACHE_DIR, f\"{cache_key}.png\"\n            )\n            if os.path.exists(cache_path):\n                os.unlink(cache_path)\n                logger.debug(f\"Cleaned up cached asset: {cache_key}\")\n        else:\n            # Remove all cached assets\n            for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):\n                if filename.endswith(\".png\"):\n                    filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)\n                    try:\n                        os.unlink(filepath)\n                        logger.debug(f\"Cleaned up cached asset: {filename}\")\n                    except Exception as e:\n                        logger.warning(f\"Failed to cleanup cache file {filename}: {e}\")\n    except Exception as e:\n        logger.warning(f\"Failed to cleanup cache: {e}\")\n\n\ndef cleanup_old_cache_files():\n    \"\"\"Clean up old cache files (older than 7 days)\"\"\"\n    try:\n        if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):\n            return\n\n        current_time = time.time()\n        week_ago = current_time - (7 * 24 * 60 * 60)  # 7 days\n\n        for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):\n            filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)\n            try:\n                if os.path.isfile(filepath):\n                    file_mtime = os.path.getmtime(filepath)\n                    if file_mtime < week_ago:\n                        os.unlink(filepath)\n                        logger.debug(f\"Cleaned up old cache: {filename}\")\n            except Exception as e:\n                logger.warning(f\"Failed to cleanup cache file {filename}: {e}\")\n    except Exception as e:\n        logger.warning(f\"Failed to cleanup cache: {e}\")\n\n\ndef verify_cache_persistence():\n    \"\"\"Verify that cached assets persist and can be loaded after restart\"\"\"\n    try:\n        cache_files = []\n\n        if os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):\n            cache_files = [\n                f\n                for f in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR)\n                if f.endswith(\".png\")\n            ]\n\n        logger.info(f\"Cache persistence check: {len(cache_files)} assets cached\")\n\n        # Test loading a few cached items to verify they work\n        for cache_file in cache_files[:2]:  # Test first 2 files\n            try:\n                cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file)\n                test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)\n                if test_pixbuf:\n                    logger.debug(f\"Successfully verified cached asset: {cache_file}\")\n            except Exception as e:\n                logger.warning(f\"Failed to load cached asset {cache_file}: {e}\")\n\n        return len(cache_files) > 0\n\n    except Exception as e:\n        logger.error(f\"Failed to verify cache persistence: {e}\")\n        return False\n\n\ndef get_fallback_icon(size=(48, 48)):\n    \"\"\"Get the fallback notification icon\"\"\"\n    try:\n        fallback_path = get_relative_path(\"../../config/assets/icons/notification.png\")\n        return GdkPixbuf.Pixbuf.new_from_file_at_scale(\n            fallback_path, size[0], size[1], True\n        )\n    except Exception as e:\n        logger.warning(f\"Failed to load fallback icon: {e}\")\n        # Create a simple colored rectangle as ultimate fallback\n        try:\n            return GdkPixbuf.Pixbuf.new(\n                GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1]\n            )\n        except:\n            return None\n\n\n# Initialize cache on module load\nensure_cache_dir()\ncleanup_old_cache_files()\nverify_cache_persistence()\n\n"
  },
  {
    "path": "modules/osd.py",
    "content": "import math\nimport time\nfrom typing import ClassVar, Literal\n\nfrom gi.repository import GLib, GObject\n\nfrom fabric.audio import Audio\nfrom fabric.utils.helpers import get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.revealer import Revealer\nfrom fabric.widgets.scale import Scale, ScaleMark\nfrom fabric.widgets.svg import Svg\nfrom services.brightness import Brightness\nfrom utils.animator import Animator\nfrom widgets.wayland import WaylandWindow as Window\n\n\nclass AnimatedScale(Scale):\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs)\n        self.animator = None\n\n    def animate_value(self, value: float):\n        if not self.animator:\n            self.animator = Animator(\n                bezier_curve=(0.34, 1.56, 0.64, 1.0),\n                duration=0.8,\n                min_value=self.min_value,\n                max_value=self.value,\n                tick_widget=self,\n                notify_value=lambda p, *_: self.set_value(p.value),\n            )\n        self.animator.pause()\n        self.animator.min_value = self.value\n        self.animator.max_value = value\n        self.animator.play()\n\n\nclass BrightnessOSDContainer(Box):\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs, orientation=\"v\", spacing=3, name=\"osd\")\n        self.brightness_service = Brightness.get_initial()\n        self.scale = AnimatedScale(\n            marks=(ScaleMark(value=i) for i in range(0, 101, 10)),\n            value=70,\n            min_value=0,\n            max_value=100,\n            increments=(1, 1),\n            orientation=\"h\",\n        )\n        self.osd_window_image = Svg(\n            get_relative_path(\"../config/assets/icons/brightness/brightness.svg\"),\n            size=(100, 150),\n            name=\"osd-image\",\n            h_align=\"center\",\n            v_align=\"center\",\n            h_expand=True,\n            v_expand=True,\n        )\n\n        self.add(self.osd_window_image)\n        self.add(self.scale)\n        self.update_brightness()\n\n        self.scale.connect(\"value-changed\", lambda *_: self.update_brightness())\n        self.brightness_service.connect(\"screen\", self.on_brightness_changed)\n\n    def update_brightness(self) -> None:\n        current_brightness = self.brightness_service.screen_brightness\n        normalized_brightness = self._normalize_brightness(current_brightness)\n        if current_brightness != 0:\n            self.scale.animate_value(normalized_brightness)\n\n    def get_svg(self, value):\n        b_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3)\n        return b_level\n\n    def on_brightness_changed(self, _sender: any, value: float, *_args) -> None:\n        normalized_brightness = self._normalize_brightness(value)\n        self.osd_window_image.set_from_file(\n            get_relative_path(\n                f\"../config/assets/icons/brightness/brightness-{\n                    self.get_svg(normalized_brightness)\n                }.svg\"\n            )\n        )\n        self.scale.animate_value(normalized_brightness)\n\n    def _normalize_brightness(self, brightness: float) -> float:\n        return (brightness / self.brightness_service.max_screen) * 100\n\n\nclass AudioOSDContainer(Box):\n    __gsignals__: ClassVar[dict] = {\n        \"volume-changed\": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),\n    }\n\n    def __init__(self, **kwargs):\n        super().__init__(\n            **kwargs,\n            orientation=\"v\",\n            name=\"osd\",\n        )\n        self.audio = Audio()\n        self.scale = AnimatedScale(\n            value=70,\n            marks=(ScaleMark(value=i) for i in range(1, 100, 10)),\n            min_value=0,\n            max_value=100,\n            increments=(1, 1),\n            orientation=\"h\",\n        )\n        self.osd_window_image = Svg(\n            get_relative_path(\"../config/assets/icons/volume/audio-volume.svg\"),\n            size=(150, 150),\n            name=\"osd-image\",\n            h_align=\"center\",\n            v_align=\"center\",\n            h_expand=True,\n            v_expand=True,\n        )\n\n        self.previous_volume = None\n        self.previous_muted = None\n\n        self.add(self.osd_window_image)\n        self.add(self.scale)\n        self.sync_with_audio()\n\n        self.scale.connect(\"value-changed\", self.on_volume_changed)\n        self.audio.connect(\"notify::speaker\", self.on_audio_speaker_changed)\n        self.audio.connect(\"speaker-changed\", self.on_speaker_changed)\n\n        # Connect to current speaker if available\n        self._connect_speaker_signals()\n\n    def _connect_speaker_signals(self):\n        \"\"\"Connect to speaker's changed signal which should fire on both volume and mute changes\"\"\"\n        if self.audio.speaker:\n            # Connect to the main 'changed' signal from AudioStream\n            self.audio.speaker.connect(\"changed\", self.on_speaker_stream_changed)\n\n    def on_speaker_stream_changed(self, *_):\n        \"\"\"This should be called whenever the speaker stream changes (volume OR mute)\"\"\"\n        if self.audio.speaker:\n            current_volume = (\n                round(self.audio.speaker.volume)\n                if hasattr(self.audio.speaker, \"volume\")\n                else 0\n            )\n            current_muted = (\n                self.audio.speaker.muted\n                if hasattr(self.audio.speaker, \"muted\")\n                else False\n            )\n\n            # Check if either volume or mute state changed\n            if (\n                self.previous_volume != current_volume\n                or self.previous_muted != current_muted\n            ):\n                self.previous_volume = current_volume\n                self.previous_muted = current_muted\n                self.update_volume()\n                self.emit(\"volume-changed\")\n\n    def get_svg(self, value):\n        audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3)\n        return audio_level\n\n    def sync_with_audio(self):\n        if self.audio.speaker:\n            volume = (\n                round(self.audio.speaker.volume)\n                if hasattr(self.audio.speaker, \"volume\")\n                else 0\n            )\n            self.scale.set_value(volume)\n            self.previous_volume = volume\n            self.previous_muted = (\n                self.audio.speaker.muted\n                if hasattr(self.audio.speaker, \"muted\")\n                else False\n            )\n\n    def on_volume_changed(self, *_):\n        if self.audio.speaker:\n            volume = self.scale.value\n            if 0 <= volume <= 100:\n                self.audio.speaker.set_volume(volume)\n                self.update_volume_display(volume)\n                self.emit(\"volume-changed\")\n\n    def update_volume_display(self, volume=None):\n        \"\"\"Update the visual display based on current volume/mute state\"\"\"\n        if not self.audio.speaker:\n            return\n\n        if volume is None:\n            volume = (\n                round(self.audio.speaker.volume)\n                if hasattr(self.audio.speaker, \"volume\")\n                else 0\n            )\n\n        is_muted = (\n            self.audio.speaker.muted if hasattr(self.audio.speaker, \"muted\") else False\n        )\n\n        if volume == 0 or is_muted:\n            self.scale.add_style_class(\"muted\")\n            display_volume = 0\n        else:\n            self.scale.remove_style_class(\"muted\")\n            display_volume = volume\n\n        self.osd_window_image.set_from_file(\n            get_relative_path(\n                f\"../config/assets/icons/volume/audio-volume-{\n                    self.get_svg(display_volume)\n                }.svg\"\n            )\n        )\n\n    def on_audio_speaker_changed(self, *_):\n        self._connect_speaker_signals()\n        self.update_volume()\n\n    def on_speaker_changed(self, *_):\n        self._connect_speaker_signals()\n        self.update_volume()\n\n    def update_volume(self, *_):\n        if self.audio.speaker and not self.is_hovered():\n            volume = (\n                round(self.audio.speaker.volume)\n                if hasattr(self.audio.speaker, \"volume\")\n                else 0\n            )\n            self.scale.set_value(volume)\n            self.update_volume_display(volume)\n\n\nclass MicrophoneOSDContainer(Box):\n    __gsignals__: ClassVar[dict] = {\n        \"mic-changed\": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),\n    }\n\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs, orientation=\"v\", spacing=13, name=\"osd\")\n        self.audio = Audio()\n        self.scale = AnimatedScale(\n            marks=(ScaleMark(value=i) for i in range(1, 100, 10)),\n            value=70,\n            min_value=0,\n            max_value=100,\n            increments=(1, 1),\n            orientation=\"h\",\n        )\n\n        self.osd_window_image = Svg(\n            get_relative_path(\"../config/assets/icons/mic/microphone.svg\"),\n            name=\"osd-image\",\n            size=(100, 150),\n            h_align=\"center\",\n            v_align=\"center\",\n            h_expand=True,\n            v_expand=True,\n        )\n        self.previous_volume = None\n        self.previous_muted = None\n\n        self.add(self.osd_window_image)\n        self.add(self.scale)\n        self.sync_with_audio()\n\n        self.scale.connect(\"value-changed\", self.on_volume_changed)\n        self.audio.connect(\"notify::microphone\", self.on_audio_microphone_changed)\n        self.audio.connect(\"microphone-changed\", self.on_microphone_changed)\n\n        # Connect to current microphone if available\n        self._connect_microphone_signals()\n\n    def _connect_microphone_signals(self):\n        \"\"\"Connect to microphone's changed signal which should fire on both volume and mute changes\"\"\"\n        if self.audio.microphone:\n            # Connect to the main 'changed' signal from AudioStream\n            self.audio.microphone.connect(\"changed\", self.on_microphone_stream_changed)\n\n    def on_microphone_stream_changed(self, *_):\n        \"\"\"This should be called whenever the microphone stream changes (volume OR mute)\"\"\"\n        if self.audio.microphone:\n            current_volume = (\n                round(self.audio.microphone.volume)\n                if hasattr(self.audio.microphone, \"volume\")\n                else 0\n            )\n            current_muted = (\n                self.audio.microphone.muted\n                if hasattr(self.audio.microphone, \"muted\")\n                else False\n            )\n\n            # Check if either volume or mute state changed\n            if (\n                self.previous_volume != current_volume\n                or self.previous_muted != current_muted\n            ):\n                self.previous_volume = current_volume\n                self.previous_muted = current_muted\n                self.update_volume()\n                self.emit(\"mic-changed\")\n\n    def get_svg(self, value):\n        audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3)\n        return audio_level\n\n    def sync_with_audio(self):\n        if self.audio.microphone:\n            volume = (\n                round(self.audio.microphone.volume)\n                if hasattr(self.audio.microphone, \"volume\")\n                else 0\n            )\n            self.scale.set_value(volume)\n            self.previous_volume = volume\n            self.previous_muted = (\n                self.audio.microphone.muted\n                if hasattr(self.audio.microphone, \"muted\")\n                else False\n            )\n\n    def on_volume_changed(self, *_):\n        if self.audio.microphone:\n            volume = self.scale.value\n            if 0 <= volume <= 100:\n                self.audio.microphone.set_volume(volume)\n                self.update_volume_display(volume)\n                self.emit(\"mic-changed\")\n\n    def update_volume_display(self, volume=None):\n        \"\"\"Update the visual display based on current volume/mute state\"\"\"\n        if not self.audio.microphone:\n            return\n\n        if volume is None:\n            volume = (\n                round(self.audio.microphone.volume)\n                if hasattr(self.audio.microphone, \"volume\")\n                else 0\n            )\n\n        is_muted = (\n            self.audio.microphone.muted\n            if hasattr(self.audio.microphone, \"muted\")\n            else False\n        )\n\n        if volume == 0 or is_muted:\n            self.scale.add_style_class(\"muted\")\n            display_volume = 0\n        else:\n            self.scale.remove_style_class(\"muted\")\n            display_volume = volume\n\n        self.osd_window_image.set_from_file(\n            get_relative_path(\n                f\"../config/assets/icons/mic/microphone-{\n                    self.get_svg(display_volume)\n                }.svg\"\n            )\n        )\n\n    def on_audio_microphone_changed(self, *_):\n        self._connect_microphone_signals()\n        self.update_volume()\n\n    def on_microphone_changed(self, *_):\n        self._connect_microphone_signals()\n        self.update_volume()\n\n    def update_volume(self, *_):\n        if self.audio.microphone and not self.is_hovered():\n            volume = (\n                round(self.audio.microphone.volume)\n                if hasattr(self.audio.microphone, \"volume\")\n                else 0\n            )\n            self.scale.set_value(volume)\n            self.update_volume_display(volume)\n\n\nclass OSD(Window):\n    def __init__(self, **kwargs):\n        self.audio_container = AudioOSDContainer()\n        self.brightness_container = BrightnessOSDContainer()\n        self.microphone_container = MicrophoneOSDContainer()\n\n        self.timeout = 1000\n\n        self.revealer = Revealer(\n            transition_type=\"slide-up\",\n            transition_duration=100,\n            child_revealed=False,\n        )\n\n        self.main_box = Box(\n            orientation=\"v\",\n            h_expand=True,\n            children=[self.revealer],\n        )\n\n        super().__init__(\n            layer=\"overlay\",\n            anchor=\"bottom\",\n            title=\"modus\",\n            child=self.main_box,\n            visible=False,\n            pass_through=True,\n            keyboard_mode=\"on-demand\",\n            **kwargs,\n        )\n\n        self.last_activity_time = time.time()\n\n        # Connect to the containers' signals\n        self.audio_container.connect(\"volume-changed\", self.show_audio)\n        self.brightness_container.brightness_service.connect(\n            \"screen\", self.show_brightness\n        )\n        self.microphone_container.connect(\"mic-changed\", self.show_microphone)\n\n        GLib.timeout_add(100, self.check_inactivity)\n\n    def show_audio(self, *_):\n        self.show_box(box_to_show=\"audio\")\n        self.reset_inactivity_timer()\n\n    def show_brightness(self, *_):\n        self.show_box(box_to_show=\"brightness\")\n        self.reset_inactivity_timer()\n\n    def show_microphone(self, *_):\n        self.show_box(box_to_show=\"microphone\")\n        self.reset_inactivity_timer()\n\n    def show_box(self, box_to_show: Literal[\"audio\", \"brightness\", \"microphone\"]):\n        self.set_visible(True)\n        if box_to_show == \"audio\":\n            self.revealer.children = self.audio_container\n        elif box_to_show == \"brightness\":\n            self.revealer.children = self.brightness_container\n        elif box_to_show == \"microphone\":\n            self.revealer.children = self.microphone_container\n        self.revealer.set_reveal_child(True)\n        self.reset_inactivity_timer()\n\n    def start_hide_timer(self):\n        self.set_visible(False)\n\n    def reset_inactivity_timer(self):\n        self.last_activity_time = time.time()\n\n    def check_inactivity(self):\n        if time.time() - self.last_activity_time >= (self.timeout / 1000):\n            self.start_hide_timer()\n        return True\n"
  },
  {
    "path": "modules/panel/components/enhanced_system_tray.py",
    "content": "\"\"\"\nEnhanced System Tray Icon Handling\n\nThis module provides enhanced icon loading capabilities for system tray items,\nincluding fallback mechanisms for file paths and common icon locations.\n\"\"\"\n\nimport os\n\nfrom gi.repository import GdkPixbuf, Gtk\n\nfrom fabric.system_tray.widgets import SystemTrayItem\n\n# FIX: the tooltip should show application names instead of unknown\n\n\ndef patched_do_update_properties(self, *_):\n    # Try default GTK theme first\n    icon_name = self._item.icon_name\n    attention_icon_name = self._item.attention_icon_name\n\n    if self._item.status == \"NeedsAttention\" and attention_icon_name:\n        preferred_icon_name = attention_icon_name\n    else:\n        preferred_icon_name = icon_name\n\n    # Try to load from default GTK theme\n    if preferred_icon_name:\n        try:\n            default_theme = Gtk.IconTheme.get_default()\n            if default_theme.has_icon(preferred_icon_name):\n                pixbuf = default_theme.load_icon(\n                    preferred_icon_name, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE\n                )\n                if pixbuf:\n                    self._image.set_from_pixbuf(pixbuf)\n                    # Set tooltip\n                    tooltip = self._item.tooltip\n                    self.set_tooltip_markup(\n                        tooltip.description or tooltip.title or self._item.title.title()\n                        if self._item.title\n                        else \"Unknown\"\n                    )\n                    return\n        except:\n            pass\n\n        # Enhanced fallback handling for file paths\n        if preferred_icon_name and self._try_load_icon_from_path(preferred_icon_name):\n            return\n\n    # Fallback to original implementation\n    original_do_update_properties(self, *_)\n\n\ndef _try_load_icon_from_path(self, icon_path):\n    try:\n        # Check if it's a file path and handle it directly\n        if os.path.isabs(icon_path) or \"/\" in icon_path:\n            # Try to load as SVG from the original path if it exists\n            if os.path.exists(icon_path):\n                if icon_path.lower().endswith(\".svg\"):\n                    # Load SVG directly\n                    pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(\n                        icon_path, self._icon_size, self._icon_size\n                    )\n                    if pixbuf:\n                        self._image.set_from_pixbuf(pixbuf)\n                        self._set_tooltip()\n                        return True\n                else:\n                    # Load other image formats\n                    pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(\n                        icon_path, self._icon_size, self._icon_size\n                    )\n                    if pixbuf:\n                        self._image.set_from_pixbuf(pixbuf)\n                        self._set_tooltip()\n                        return True\n\n            # If it's a file path, try to extract just the filename for theme lookup\n            filename = os.path.basename(icon_path)\n            if filename:\n                # Remove extension for theme lookup\n                name_without_ext = os.path.splitext(filename)[0]\n                default_theme = Gtk.IconTheme.get_default()\n\n                # Try filename without extension\n                if default_theme.has_icon(name_without_ext):\n                    pixbuf = default_theme.load_icon(\n                        name_without_ext,\n                        self._icon_size,\n                        Gtk.IconLookupFlags.FORCE_SIZE,\n                    )\n                    if pixbuf:\n                        self._image.set_from_pixbuf(pixbuf)\n                        self._set_tooltip()\n                        return True\n\n                # Try full filename\n                if default_theme.has_icon(filename):\n                    pixbuf = default_theme.load_icon(\n                        filename, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE\n                    )\n                    if pixbuf:\n                        self._image.set_from_pixbuf(pixbuf)\n                        self._set_tooltip()\n                        return True\n\n            # If it looks like a file path but doesn't exist, try common icon locations\n            if os.path.isabs(icon_path):\n                common_icon_dirs = [\n                    \"/usr/share/icons\",\n                    \"/usr/share/pixmaps\",\n                    \"/usr/local/share/icons\",\n                    \"/usr/local/share/pixmaps\",\n                    os.path.expanduser(\"~/.local/share/icons\"),\n                    os.path.expanduser(\"~/.icons\"),\n                ]\n\n                filename = os.path.basename(icon_path)\n                for icon_dir in common_icon_dirs:\n                    potential_path = os.path.join(icon_dir, filename)\n                    if os.path.exists(potential_path):\n                        try:\n                            pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(\n                                potential_path, self._icon_size, self._icon_size\n                            )\n                            if pixbuf:\n                                self._image.set_from_pixbuf(pixbuf)\n                                self._set_tooltip()\n                                return True\n                        except:\n                            continue\n\n    except Exception:\n        pass\n\n    return False\n\n\ndef _set_tooltip(self):\n    tooltip = self._item.tooltip\n    self.set_tooltip_markup(\n        tooltip.description or tooltip.title or self._item.title.title()\n        if self._item.title\n        else \"Unknown\"\n    )\n\n\ndef apply_enhanced_system_tray():\n    # Store original method\n    global original_do_update_properties\n    original_do_update_properties = SystemTrayItem.do_update_properties\n\n    # Attach helper methods to SystemTrayItem class\n    SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path\n    SystemTrayItem._set_tooltip = _set_tooltip\n\n    # Replace the do_update_properties method\n    SystemTrayItem.do_update_properties = patched_do_update_properties\n\n\n# Store reference to original method\noriginal_do_update_properties = None\n"
  },
  {
    "path": "modules/panel/components/indicators.py",
    "content": "from fabric.bluetooth import BluetoothClient\nfrom fabric.utils import get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.svg import Svg\n\nfrom modules.controlcenter.battery import BatteryControl\nfrom modules.controlcenter.bluetooth import BluetoothConnections\nfrom modules.controlcenter.wifi import WifiConnections\nfrom services.battery import Battery\nfrom services.network import NetworkClient\nfrom utils.roam import modus_service\nfrom utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon\nfrom widgets.mousecapture import DropDownMouseCapture\nfrom widgets.wayland import WaylandWindow as Window\n\n\nclass BluetoothIndicator(Box):\n    def __init__(self, show_window=True, **kwargs):\n        super().__init__(name=\"bluetooth-indicator\", orientation=\"h\", **kwargs)\n        self.show_window = show_window\n\n        self.bluetooth = BluetoothClient()\n        self.bt_icon = Svg(\n            name=\"bt-icon\",\n            size=22,\n            svg_file=get_relative_path(\n                \"../../../config/assets/icons/applets/bluetooth-clear.svg\"\n            ),\n        )\n\n        self.bt_button = Button(\n            name=\"bt-button\", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked\n        )\n\n        self.add(self.bt_button)\n\n        # Create Bluetooth control center widget only if show_window is True\n        if self.show_window:\n            self.bluetooth_window = Window(\n                layer=\"overlay\",\n                title=\"modus\",\n                anchor=\"top right\",\n                margin=\"2px 10px 0px 0px\",\n                exclusivity=\"auto\",\n                keyboard_mode=\"on-demand\",\n                name=\"bluetooth-control-window\",\n                visible=False,\n            )\n\n            self.bluetooth_widget = BluetoothConnections(self, show_back_button=False)\n            self.bluetooth_window.children = [self.bluetooth_widget]\n\n            # Create mouse capture for Bluetooth widget\n            self.bluetooth_mousecapture = DropDownMouseCapture(\n                layer=\"top\", child_window=self.bluetooth_window\n            )\n        else:\n            self.bluetooth_window = None\n            self.bluetooth_widget = None\n            self.bluetooth_mousecapture = None\n\n        modus_service.connect(\"bluetooth-changed\", self.on_bluetooth_changed)\n        self.bluetooth.connect(\"changed\", self.on_bluetooth_direct_changed)\n        self.bluetooth.connect(\"device-added\", self.on_device_added)\n        self.bluetooth.connect(\"device-removed\", self.on_device_removed)\n\n        self.update_modus_service_bluetooth_state()\n        self.update_state()\n\n    def update_state(self):\n        if not self.bluetooth.enabled:\n            self.bt_icon.set_from_file(\n                get_relative_path(\n                    \"../../../config/assets/icons/applets/bluetooth-off-clear.svg\"\n                )\n            )\n            tooltip = \"Bluetooth disabled\"\n        else:\n            connected_devices = self.bluetooth.connected_devices\n            if connected_devices:\n                self.bt_icon.set_from_file(\n                    get_relative_path(\n                        \"../../../config/assets/icons/applets/bluetooth-clear.svg\"\n                    )\n                )\n                if len(connected_devices) >= 1:\n                    self.bt_icon.set_from_file(\n                        get_relative_path(\n                            \"../../../config/assets/icons/applets/bluetooth-paired.svg\"\n                        )\n                    )\n                    device = connected_devices[0]\n                    tooltip = f\"Connected to {device.alias}\"\n                    if device.battery_percentage > 0:\n                        tooltip += f\" ({device.battery_percentage:.0f}%)\"\n                else:\n                    tooltip = f\"Connected to {len(connected_devices)} devices\"\n            else:\n                self.bt_icon.set_from_file(\n                    get_relative_path(\n                        \"../../../config/assets/icons/applets/bluetooth-clear.svg\"\n                    )\n                )\n                tooltip = \"No devices connected\"\n\n        self.bt_button.set_tooltip_text(tooltip)\n\n    def on_bluetooth_changed(self, service, new_bluetooth_state):\n        self.update_state()\n\n    def on_bluetooth_direct_changed(self, *args):\n        self.update_modus_service_bluetooth_state()\n        self.update_state()\n\n    def on_device_added(self, _, address):\n        self.update_modus_service_bluetooth_state()\n        self.update_state()\n\n    def on_device_removed(self, _, address):\n        self.update_modus_service_bluetooth_state()\n        self.update_state()\n\n    def update_modus_service_bluetooth_state(self):\n        if not self.bluetooth.enabled:\n            bluetooth_state = \"disabled\"\n        else:\n            connected_devices = self.bluetooth.connected_devices\n            if connected_devices:\n                if len(connected_devices) == 1:\n                    device = connected_devices[0]\n                    bluetooth_state = f\"connected:{device.alias}\"\n                    if (\n                        hasattr(device, \"battery_percentage\")\n                        and device.battery_percentage > 0\n                    ):\n                        bluetooth_state += f\":{device.battery_percentage:.0f}%\"\n                else:\n                    bluetooth_state = f\"connected:{len(connected_devices)}_devices\"\n            else:\n                bluetooth_state = \"enabled\"\n\n        modus_service.bluetooth = bluetooth_state\n\n    def on_bluetooth_clicked(self, *args):\n        \"\"\"Handle Bluetooth indicator click\"\"\"\n        if self.show_window and self.bluetooth_mousecapture:\n            self.bluetooth_mousecapture.toggle_mousecapture()\n\n    def close_bluetooth(self, *args):\n        \"\"\"Close Bluetooth control center\"\"\"\n        if self.show_window and self.bluetooth_mousecapture:\n            self.bluetooth_mousecapture.hide_child_window()\n\n    def hide_controlcenter(self, *args):\n        \"\"\"Hide Bluetooth control center\"\"\"\n        if self.show_window and self.bluetooth_mousecapture:\n            self.bluetooth_mousecapture.hide_child_window()\n\n\nclass NetworkIndicator(Box):\n    def __init__(self, show_window=True, **kwargs):\n        super().__init__(name=\"network-indicator\", orientation=\"h\", **kwargs)\n        self.show_window = show_window\n\n        self.network_service = NetworkClient()\n\n        self.network_icon = Svg(\n            name=\"network-icon\",\n            size=22,\n            svg_file=get_relative_path(\n                \"../../../config/assets/icons/applets/wifi-clear.svg\"\n            ),\n        )\n\n        self.network_button = Button(\n            name=\"network-button\",\n            child=self.network_icon,\n            on_clicked=self.on_wifi_clicked,\n        )\n\n        self.add(self.network_button)\n\n        # Create WiFi control center widget only if show_window is True\n        if self.show_window:\n            self.wifi_window = Window(\n                layer=\"overlay\",\n                title=\"modus\",\n                anchor=\"top right\",\n                margin=\"2px 10px 0px 0px\",\n                exclusivity=\"auto\",\n                keyboard_mode=\"on-demand\",\n                name=\"wifi-control-window\",\n                visible=False,\n            )\n\n            self.wifi_widget = WifiConnections(self, show_back_button=False)\n            self.wifi_window.children = [self.wifi_widget]\n\n            # Create mouse capture for WiFi widget\n            self.wifi_mousecapture = DropDownMouseCapture(\n                layer=\"top\", child_window=self.wifi_window\n            )\n        else:\n            self.wifi_window = None\n            self.wifi_widget = None\n            self.wifi_mousecapture = None\n\n        modus_service.connect(\"wlan-changed\", self.on_wlan_changed)\n        self.network_service.connect(\"wifi-device-added\", self.on_wifi_device_added)\n        self.network_service.connect(\n            \"ethernet-device-added\", self.on_ethernet_device_added\n        )\n        self.network_service.connect(\"changed\", self.on_network_changed)\n\n        self.update_modus_service_wlan_state()\n        self.update_state()\n\n    def on_wlan_changed(self, service, new_wlan_state):\n        self.update_state()\n\n    def on_wifi_device_added(self, *args):\n        \"\"\"Called when WiFi device is added\"\"\"\n        if self.network_service.wifi_device:\n            self.network_service.wifi_device.connect(\n                \"changed\", self.on_network_direct_changed\n            )\n        self.update_modus_service_wlan_state()\n        self.update_state()\n\n    def on_ethernet_device_added(self, *args):\n        \"\"\"Called when Ethernet device is added\"\"\"\n        if self.network_service.ethernet_device:\n            self.network_service.ethernet_device.connect(\n                \"changed\", self.on_network_direct_changed\n            )\n        self.update_modus_service_wlan_state()\n        self.update_state()\n\n    def on_network_direct_changed(self, *args):\n        self.update_modus_service_wlan_state()\n        self.update_state()\n\n    def on_network_changed(self, *args):\n        self.update_modus_service_wlan_state()\n        self.update_state()\n\n    def update_modus_service_wlan_state(self):\n        wlan_state = \"disconnected\"\n\n        # Check WiFi first (prioritize WiFi over Ethernet)\n        if self.network_service.wifi_device:\n            wifi = self.network_service.wifi_device\n            if not wifi.wireless_enabled:\n                wlan_state = \"disabled\"\n            elif wifi.active_access_point:\n                ap = wifi.active_access_point\n                wlan_state = f\"connected:{ap.ssid}\"\n                if ap.strength >= 0:\n                    wlan_state += f\":{ap.strength}%\"\n            else:\n                wlan_state = \"enabled\"\n\n        # Check Ethernet if WiFi is not connected\n        elif self.network_service.ethernet_device:\n            ethernet = self.network_service.ethernet_device\n            if ethernet.internet == \"activated\":\n                wlan_state = \"ethernet:connected\"\n                if hasattr(ethernet, \"speed\") and ethernet.speed:\n                    wlan_state += f\":{ethernet.speed}\"\n            elif ethernet.internet == \"activating\":\n                wlan_state = \"ethernet:connecting\"\n            else:\n                wlan_state = \"ethernet:disconnected\"\n\n        modus_service.wlan = wlan_state\n\n    def update_state(self):\n        tooltip = \"No network connection\"\n        icon_file = \"wifi-off-clear.svg\"\n\n        # Check WiFi first (prioritize WiFi over Ethernet)\n        if self.network_service.wifi_device:\n            wifi = self.network_service.wifi_device\n            if not wifi.wireless_enabled:\n                icon_file = \"wifi-off-clear.svg\"\n                tooltip = \"WiFi disabled\"\n            elif wifi.active_access_point:\n                ap = wifi.active_access_point\n                # Use dynamic WiFi icon based on signal strength\n                wifi_icon_path = get_wifi_icon_for_strength(ap.strength)\n                self.network_icon.set_from_file(wifi_icon_path)\n                tooltip = f\"Connected to {ap.ssid}\"\n                if ap.strength >= 0:\n                    tooltip += f\" ({ap.strength}%)\"\n                self.network_button.set_tooltip_text(tooltip)\n                return  # Early return to avoid setting icon again\n            else:\n                icon_file = \"wifi-off-clear.svg\"\n                tooltip = \"WiFi disconnected\"\n\n        # Check Ethernet if WiFi is not connected\n        elif self.network_service.ethernet_device:\n            ethernet = self.network_service.ethernet_device\n            if ethernet.internet == \"activated\":\n                icon_file = \"network-wired.svg\"\n                tooltip = \"Ethernet connected\"\n                if hasattr(ethernet, \"speed\") and ethernet.speed:\n                    tooltip += f\" ({ethernet.speed})\"\n            elif ethernet.internet == \"activating\":\n                icon_file = \"network-wired.svg\"\n                tooltip = \"Ethernet connecting...\"\n            else:\n                icon_file = \"network-wired-offline.svg\"\n                tooltip = \"Ethernet disconnected\"\n\n        self.network_icon.set_from_file(\n            get_relative_path(f\"../../../config/assets/icons/applets/{icon_file}\")\n        )\n        self.network_button.set_tooltip_text(tooltip)\n\n    def on_wifi_clicked(self, *args):\n        \"\"\"Handle WiFi indicator click\"\"\"\n        if self.show_window and self.wifi_mousecapture:\n            self.wifi_mousecapture.toggle_mousecapture()\n\n    def close_wifi(self, *args):\n        \"\"\"Close WiFi control center\"\"\"\n        if self.show_window and self.wifi_mousecapture:\n            self.wifi_mousecapture.hide_child_window()\n\n    def hide_controlcenter(self, *args):\n        \"\"\"Hide WiFi control center\"\"\"\n        if self.show_window and self.wifi_mousecapture:\n            self.wifi_mousecapture.hide_child_window()\n\n\nclass BatteryIndicator(Box):\n    def __init__(self, show_window=True, **kwargs):\n        super().__init__(name=\"battery-indicator\", orientation=\"h\", **kwargs)\n        self.show_window = show_window\n\n        self.battery_service = Battery()\n\n        self.battery_icon = Svg(\n            name=\"battery-icon\",\n            size=23,\n            svg_file=get_relative_path(\n                \"../../../config/assets/icons/battery/battery-100.svg\"\n            ),\n        )\n\n        self.battery_button = Button(\n            name=\"battery-button\",\n            child=self.battery_icon,\n            on_clicked=self.on_battery_clicked,\n        )\n\n        self.battery_label = Label(name=\"battery-label\", label=\"--- %\")\n\n        self.add(self.battery_label)\n        self.add(self.battery_button)\n\n        # Create Battery control center widget only if show_window is True\n        if self.show_window:\n            self.battery_window = Window(\n                layer=\"top\",\n                title=\"modus\",\n                anchor=\"top right\",\n                margin=\"2px 10px 0px 0px\",\n                exclusivity=\"auto\",\n                keyboard_mode=\"on-demand\",\n                name=\"battery-control-window\",\n                visible=False,\n            )\n\n            self.battery_widget = BatteryControl(self, show_back_button=False)\n            self.battery_window.children = [self.battery_widget]\n\n            # Create mouse capture for Battery widget\n            self.battery_mousecapture = DropDownMouseCapture(\n                layer=\"top\", child_window=self.battery_window\n            )\n        else:\n            self.battery_window = None\n            self.battery_widget = None\n            self.battery_mousecapture = None\n\n        modus_service.connect(\"battery-changed\", self.on_battery_changed)\n        self.battery_service.connect(\"changed\", self.on_battery_direct_changed)\n\n        self.update_modus_service_battery_state()\n        self.update_state()\n\n    def on_battery_changed(self, service, new_battery_state):\n        self.update_state()\n\n    def on_battery_direct_changed(self, *args):\n        self.update_modus_service_battery_state()\n        self.update_state()\n\n    def update_modus_service_battery_state(self):\n        if not self.battery_service.is_present:\n            battery_state = \"not_present\"\n        else:\n            percentage = self.battery_service.percentage\n            state = self.battery_service.state.lower()\n\n            battery_state = f\"{state}:{percentage}%\"\n\n            if state == \"discharging\":\n                time_to_empty = self.battery_service.time_to_empty\n                if time_to_empty != \"N/A\":\n                    battery_state += f\":{time_to_empty}\"\n            elif state == \"charging\":\n                time_to_full = self.battery_service.time_to_full\n                if time_to_full != \"N/A\":\n                    battery_state += f\":{time_to_full}\"\n\n        modus_service.battery = battery_state\n\n    def get_battery_tooltip(self, percentage, state):\n        tooltip = f\"Battery: {percentage}%\"\n\n        if state == \"CHARGING\":\n            tooltip += \" (Charging)\"\n            time_to_full = self.battery_service.time_to_full\n            if time_to_full != \"N/A\":\n                tooltip += f\" - {time_to_full} until full\"\n        elif state == \"DISCHARGING\":\n            time_to_empty = self.battery_service.time_to_empty\n            if time_to_empty != \"N/A\":\n                tooltip += f\" - {time_to_empty} remaining\"\n        elif state == \"FULLY_CHARGED\":\n            tooltip += \" (Fully charged)\"\n\n        return tooltip\n\n    def update_state(self):\n        if not self.battery_service.is_present:\n            # Hide the entire battery component when no battery is present\n            self.set_visible(False)\n            return\n        else:\n            # Show the battery component when battery is present\n            self.set_visible(True)\n\n            percentage = self.battery_service.percentage\n            state = self.battery_service.state\n            is_charging = state in [\"CHARGING\", \"FULLY_CHARGED\"]\n\n            icon_file = Battery.get_battery_icon_file(\n                percentage, is_charging, base_path=\"../../../config/assets/icons/\"\n            )\n            tooltip = self.get_battery_tooltip(percentage, state)\n            percentage_text = f\"{percentage}%\"\n\n            # Update icon, tooltip, and percentage label\n            self.battery_icon.set_from_file(get_relative_path(icon_file))\n            self.battery_button.set_tooltip_text(tooltip)\n            self.battery_label.set_label(percentage_text)\n\n    def on_battery_clicked(self, *args):\n        \"\"\"Handle Battery indicator click\"\"\"\n        if self.show_window and self.battery_mousecapture:\n            self.battery_mousecapture.toggle_mousecapture()\n\n    def close_battery(self, *args):\n        \"\"\"Close Battery control center\"\"\"\n        if self.show_window and self.battery_mousecapture:\n            self.battery_mousecapture.hide_child_window()\n\n    def hide_controlcenter(self, *args):\n        \"\"\"Hide Battery control center\"\"\"\n        if self.show_window and self.battery_mousecapture:\n            self.battery_mousecapture.hide_child_window()\n"
  },
  {
    "path": "modules/panel/components/menubar.py",
    "content": "import json\nimport os\nimport subprocess\n\nfrom fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow\nfrom fabric.utils import FormattedString\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.label import Label\n\nfrom modules.about import About, AboutApp\nfrom utils.roam import modus_service\nfrom widgets.dropdown import ModusDropdown, dropdown_divider\nfrom widgets.mousecapture import DropDownMouseCapture\nfrom utils.app_name_resolver import format_window\n\n\ndef show_about_app():\n    \"\"\"Show about dialog for current active application\"\"\"\n    try:\n        # Use modus_service's Hyprland connection to get current window info\n        wmclass = \"\"\n        title = \"\"\n\n        if (\n            hasattr(modus_service, \"_hyprland_connection\")\n            and modus_service._hyprland_connection\n        ):\n            window_data = modus_service._hyprland_connection.send_command(\n                \"j/activewindow\"\n            ).reply\n            if window_data:\n                window_info = json.loads(window_data.decode(\"utf-8\"))\n                wmclass = window_info.get(\"class\", \"\")\n                title = window_info.get(\"title\", \"\")\n\n        # Don't show about dialog if there's no active window (Finder state)\n        if not wmclass and not title:\n            return\n\n        app_name = modus_service.current_active_app_name or \"Finder\"\n        # Don't show about dialog for Finder\n        if app_name == \"Finder\":\n            return\n\n        about_window = AboutApp(app_name=app_name, wmclass=wmclass)\n        about_window.toggle(None)\n    except Exception:\n        # Fallback: only show if we have a real app name\n        app_name = modus_service.current_active_app_name or \"\"\n        if app_name and app_name != \"Finder\":\n            about_window = AboutApp(app_name=app_name, wmclass=\"\")\n            about_window.toggle(None)\n\n\ndef dropdown_option(\n    label: str = \"\",\n    keybind: str = \"\",\n    on_click='echo \"ModusPanelDropdown Action\"',\n    on_clicked=None,\n):\n    def on_click_subthread(button):\n        # Execute the action first\n        if on_clicked:\n            on_clicked(button)\n        else:\n            subprocess.Popen(\n                f\"nohup {on_click} &\",\n                shell=True,\n                stdout=subprocess.DEVNULL,\n                stderr=subprocess.DEVNULL,\n            )\n\n        # Hide dropdown by finding the current visible dropdown and calling its hide method\n        from widgets.dropdown import dropdowns\n\n        for dropdown in dropdowns:\n            if dropdown.is_visible() and hasattr(dropdown, \"hide_via_mousecapture\"):\n                dropdown.hide_via_mousecapture()\n                break\n\n    return Button(\n        child=CenterBox(\n            start_children=[\n                Label(label=label, h_align=\"start\", name=\"dropdown-option-label\"),\n            ],\n            end_children=[\n                Label(label=keybind, h_align=\"end\", name=\"dropdown-option-keybind\")\n            ],\n            orientation=\"horizontal\",\n            h_align=\"fill\",\n            h_expand=True,\n            v_expand=True,\n        ),\n        name=\"dropdown-option\",\n        h_align=\"fill\",\n        on_clicked=on_click_subthread,\n        h_expand=True,\n        v_expand=True,\n    )\n\n\nclass SystemDropdown(ModusDropdown):\n    def __init__(self, parent, **kwargs):\n        super().__init__(\n            dropdown_id=\"os-menu\",\n            parent=parent,\n            dropdown_children=[\n                dropdown_option(\n                    \"About this PC\", on_clicked=lambda _: About().toggle(_)\n                ),\n                dropdown_divider(\"---------------------\"),\n                dropdown_option(\n                    \"System Settings...\",\n                    # TODO: Open Modus own setting\n                    # on_click=\"xdg-open settings\",\n                ),\n                dropdown_divider(\"---------------------\"),\n                dropdown_option(\"Force Quit\", \"\", \"hyprctl kill\"),\n                dropdown_divider(\"---------------------\"),\n                dropdown_option(\"Sleep\", \"\", \"systemctl suspend\"),\n                dropdown_option(\"Restart...\", \"\", \"systemctl reboot\"),\n                dropdown_option(\"Shut Down...\", \"\", \"shutdown now\"),\n                dropdown_divider(\"---------------------\"),\n                dropdown_option(\"Lock Screen\", \"󰘳     L\", \"hyprlock\"),\n            ],\n            **kwargs,\n        )\n\n\nclass MenuBarDropdowns:\n    def __init__(self, parent):\n        self.parent = parent\n\n        # System dropdown\n        self.system_dropdown = SystemDropdown(parent=parent)\n        self.menu_button_dropdown = DropDownMouseCapture(\n            layer=\"bottom\", child_window=self.system_dropdown\n        )\n        self.menu_button = Button(\n            label=\"Modus\",\n            name=\"menu-button\",\n            style_classes=\"button\",\n            on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(),\n        )\n        self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button)\n\n        # Global menu dropdowns\n        self.global_title_menu_about = dropdown_option(\n            f\"About {modus_service.current_active_app_name}\",\n            on_clicked=lambda _: show_about_app(),\n        )\n        self.global_menu_title = DropDownMouseCapture(\n            layer=\"bottom\",\n            child_window=ModusDropdown(\n                dropdown_id=\"global-menu-title\",\n                parent=parent,\n                dropdown_children=[self.global_title_menu_about],\n            ),\n        )\n\n        self.global_menu_file = None\n        self.global_menu_edit = None\n        self.global_menu_view = DropDownMouseCapture(\n            layer=\"bottom\",\n            child_window=ModusDropdown(\n                dropdown_id=\"global-menu-view\",\n                parent=parent,\n                dropdown_children=[\n                    dropdown_option(\n                        \"Enter Full Screen\",\n                        on_click=\"hyprctl dispatch fullscreen\",\n                    ),\n                ],\n            ),\n        )\n        self.global_menu_go = None\n        self.global_menu_window = DropDownMouseCapture(\n            layer=\"bottom\",\n            child_window=ModusDropdown(\n                dropdown_id=\"global-menu-window\",\n                parent=parent,\n                dropdown_children=[\n                    dropdown_option(\n                        \"Zoom In\",\n                        \"󰍉     +\",\n                        on_click=\"hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')\",\n                    ),\n                    dropdown_option(\n                        \"Zoom Out\",\n                        \"󰍉     -\",\n                        on_click=\"hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')\",\n                    ),\n                    dropdown_divider(\"---------------------\"),\n                    dropdown_option(\n                        \"Move Window to Left\",\n                        on_click=\"hyprctl dispatch movewindow l\",\n                    ),\n                    dropdown_option(\n                        \"Move Window to Right\",\n                        on_click=\"hyprctl dispatch movewindow r\",\n                    ),\n                    dropdown_option(\n                        \"Cycle Through Windows\",\n                        on_click=\"hyprctl dispatch cyclenext\",\n                    ),\n                    dropdown_divider(\"---------------------\"),\n                    dropdown_option(\n                        \"Float\", on_click=\"hyprctl dispatch togglefloating\"\n                    ),\n                    dropdown_option(\"Quit\", on_click=\"hyprctl dispatch killactive\"),\n                    dropdown_option(\"Pseudo\", on_click=\"hyprctl dispatch pseudo\"),\n                    dropdown_option(\n                        \"Toggle Split\", on_click=\"hyprctl dispatch togglesplit\"\n                    ),\n                    dropdown_option(\"Center\", on_click=\"hyprctl dispatch centerwindow\"),\n                    dropdown_option(\"Group\", on_click=\"hyprctl dispatch togglegroup\"),\n                    dropdown_option(\n                        \"Pin\",\n                        on_clicked=lambda _: subprocess.run(\n                            \"bash ~/.config/scripts/winpin.sh\", shell=True\n                        ),\n                    ),\n                ],\n            ),\n        )\n\n        self.global_menu_help = DropDownMouseCapture(\n            layer=\"bottom\",\n            child_window=ModusDropdown(\n                dropdown_id=\"global-menu-help\",\n                parent=parent,\n                dropdown_children=[\n                    dropdown_option(\n                        \"Modus\",\n                        on_click=\"xdg-open https://github.com/S4NKALP/Modus/issues\",\n                    ),\n                    dropdown_divider(\"---------------------\"),\n                    dropdown_option(\n                        \"Hyprland Wiki\", on_click=\"xdg-open https://wiki.hyprland.org/\"\n                    ),\n                ],\n            ),\n        )\n\n        # Create menu buttons\n        modus_service.connect(\n            \"current-active-app-name-changed\",\n            lambda _, value: self.global_title_menu_about.set_property(\n                \"label\", f\"About {value}\"\n            ),\n        )\n\n        # Connect to active app name changes to update the title button\n        modus_service.connect(\"current-active-app-name-changed\", self._on_active_app_changed)\n\n        self.global_menu_button_title = Button(\n            child=ActiveWindow(\n                formatter=FormattedString(\n                    \"{ format_window(win_title, win_class) }\",\n                    format_window=format_window,\n                )\n            ),\n            name=\"global-title-button\",\n            style_classes=\"button\",\n            on_clicked=self._on_title_button_clicked,\n        )\n\n        self.global_menu_title.child_window.set_pointing_to(\n            self.global_menu_button_title\n        )\n\n        self.global_menu_button_file = Button(\n            label=\"File\", name=\"global-menu-button-file\", style_classes=\"button\"\n        )\n        self.global_menu_button_edit = Button(\n            label=\"Edit\", name=\"global-menu-button-edit\", style_classes=\"button\"\n        )\n        self.global_menu_button_view = Button(\n            label=\"View\",\n            name=\"global-menu-button-view\",\n            style_classes=\"button\",\n            on_clicked=lambda _: self.global_menu_view.toggle_mousecapture(),\n        )\n        self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view)\n        self.global_menu_button_go = Button(\n            label=\"Go\", name=\"global-menu-button-go\", style_classes=\"button\"\n        )\n        self.global_menu_button_window = Button(\n            label=\"Window\",\n            name=\"global-menu-button-window\",\n            style_classes=\"button\",\n            on_clicked=lambda _: self.global_menu_window.toggle_mousecapture(),\n        )\n        self.global_menu_window.child_window.set_pointing_to(\n            self.global_menu_button_window\n        )\n        self.global_menu_button_help = Button(\n            label=\"Help\",\n            name=\"global-menu-button-help\",\n            style_classes=\"button\",\n            on_clicked=lambda _: self.global_menu_help.toggle_mousecapture(),\n        )\n        self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help)\n\n        modus_service.connect(\"current-dropdown-changed\", self.changed_dropdown)\n        modus_service.connect(\"dropdowns-hide-changed\", self.hide_dropdowns)\n\n    def _on_title_button_clicked(self, _):\n        \"\"\"Handle title button click - only show dropdown if there's an active window\"\"\"\n        try:\n            # Use modus_service's Hyprland connection to get current window info\n            if (\n                hasattr(modus_service, \"_hyprland_connection\")\n                and modus_service._hyprland_connection\n            ):\n                window_data = modus_service._hyprland_connection.send_command(\n                    \"j/activewindow\"\n                ).reply\n                if window_data:\n                    window_info = json.loads(window_data.decode(\"utf-8\"))\n                    wmclass = window_info.get(\"class\", \"\")\n                    title = window_info.get(\"title\", \"\")\n\n                    # Only show dropdown if there's an active window (not Finder)\n                    if wmclass or title:\n                        self.global_menu_title.toggle_mousecapture()\n                    return\n\n            # Fallback: check if current_active_app_name is not \"Finder\"\n            if (\n                modus_service.current_active_app_name\n                and modus_service.current_active_app_name != \"Finder\"\n            ):\n                self.global_menu_title.toggle_mousecapture()\n        except Exception:\n            # If we can't get window info, don't show dropdown\n            pass\n\n    def _on_active_app_changed(self, _, value):\n        \"\"\"Handle active app name changes\"\"\"\n        # Update the \"About\" menu item label\n        self.global_title_menu_about.set_property(\"label\", f\"About {value}\")\n\n    def hide_dropdowns(self, *_):\n        self.menu_button.remove_style_class(\"active\")\n        self.global_menu_button_edit.remove_style_class(\"active\")\n        self.global_menu_button_file.remove_style_class(\"active\")\n        self.global_menu_button_go.remove_style_class(\"active\")\n        self.global_menu_button_help.remove_style_class(\"active\")\n        self.global_menu_button_title.remove_style_class(\"active\")\n        self.global_menu_button_view.remove_style_class(\"active\")\n        self.global_menu_button_window.remove_style_class(\"active\")\n\n    def changed_dropdown(self, _, dropdown_id):\n        self.hide_dropdowns(_, True)\n        match dropdown_id:\n            case \"os-menu\":\n                self.menu_button.add_style_class(\"active\")\n            case \"global-menu-edit\":\n                self.global_menu_button_edit.add_style_class(\"active\")\n            case \"global-menu-file\":\n                self.global_menu_button_file.add_style_class(\"active\")\n            case \"global-menu-go\":\n                self.global_menu_button_go.add_style_class(\"active\")\n            case \"global-menu-help\":\n                self.global_menu_button_help.add_style_class(\"active\")\n            case \"global-menu-title\":\n                self.global_menu_button_title.add_style_class(\"active\")\n            case \"global-menu-view\":\n                self.global_menu_button_view.add_style_class(\"active\")\n            case \"global-menu-window\":\n                self.global_menu_button_window.add_style_class(\"active\")\n            case _:\n                pass\n\n\nclass MenuBar(Box):\n    \"\"\"Main MenuBar widget that contains all menu buttons\"\"\"\n\n    def __init__(self, parent_window=None, **kwargs):\n        # Extract parent_window from kwargs if not provided as parameter\n        if parent_window is None:\n            parent_window = kwargs.pop(\"parent_window\", None)\n\n        super().__init__(name=\"menubar\", orientation=\"horizontal\", spacing=0, **kwargs)\n\n        # Create the dropdown system\n        self.dropdown_system = MenuBarDropdowns(parent=parent_window)\n\n        # Add all the menu buttons to the menubar\n        self.children = [\n            self.dropdown_system.global_menu_button_title,\n            self.dropdown_system.global_menu_button_file,\n            self.dropdown_system.global_menu_button_edit,\n            self.dropdown_system.global_menu_button_view,\n            self.dropdown_system.global_menu_button_go,\n            self.dropdown_system.global_menu_button_window,\n            self.dropdown_system.global_menu_button_help,\n        ]\n\n    def show_system_dropdown(self, imac_button):\n        self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to(\n            imac_button\n        )\n        mouse_capture = self.dropdown_system.menu_button_dropdown\n        if mouse_capture.is_visible():\n            mouse_capture.set_child_window_visible(False)\n        else:\n            mouse_capture.set_child_window_visible(True)\n"
  },
  {
    "path": "modules/panel/components/recording_indicator.py",
    "content": "import os\nimport subprocess\nimport time\n\nfrom fabric.utils import get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.svg import Svg\nfrom gi.repository import GLib\n\n\nclass RecordingIndicator(Button):\n    def __init__(self, **kwargs):\n        super().__init__(name=\"panel-button\", visible=True, **kwargs)\n\n        self.script_path = get_relative_path(\"../../../scripts/screen-capture.sh\")\n        self.recording_start_time = None\n        self.last_process_check = 0\n        self.process_check_interval = 1.0\n        self.timer_update_interval = 1000\n        self.status_check_interval = 2000\n\n        self.timer_timeout_id = None\n        self.status_timeout_id = None\n\n        self.recording_icon = Svg(\n            name=\"indicators-icon\",\n            size=24,\n            svg_file=get_relative_path(\n                \"../../../config/assets/icons/misc/media-record.svg\"\n            ),\n        )\n        self.time_label = Label(\n            name=\"recording-time-label\",\n            markup=\"00:00\",\n            max_width_chars=5,\n            ellipsize=\"none\",\n        )\n\n        self.recording_box = Box(\n            orientation=\"h\",\n            spacing=2,\n            children=[self.recording_icon, self.time_label],\n            size=(80, -1),\n        )\n\n        self.add(self.recording_box)\n\n        self.connect(\"clicked\", self.on_stop_recording)\n        self.connect(\"button-press-event\", self.on_button_press)\n        self.hide()\n\n        GLib.timeout_add(100, self._delayed_init)\n\n    def on_button_press(self, *args):\n        GLib.timeout_add(100, lambda: self.remove_style_class(\"pressed\") or False)\n        return False\n\n    def is_recorder_running(self):\n        # add more process names if needed\n        recorder_processes = [\"wf-recorder\", \"gpu-screen-recorder\"]\n\n        try:\n            for proc in recorder_processes:\n                result = subprocess.run(\n                    [\"pgrep\", \"-x\", proc],\n                    capture_output=True,\n                    text=True,\n                    timeout=1,\n                )\n                if result.returncode == 0:\n                    return True  # Found a running recorder process\n            return False  # None found running\n        except Exception:\n            return False\n\n    def check_recording_status(self):\n        current_time = time.time()\n        self.last_process_check = current_time\n\n        try:\n            is_recording = self.is_recorder_running()\n\n            if is_recording:\n                if not self.get_visible():\n                    self.set_visible(True)\n                    if self.timer_timeout_id is None:\n                        self.timer_timeout_id = GLib.timeout_add(\n                            self.timer_update_interval, self.update_timer_display\n                        )\n\n                if self.recording_start_time is None:\n                    self.recording_start_time = self.get_recording_start_time()\n\n                self.update_timer_display()\n            else:\n                if self.get_visible():\n                    self.set_visible(False)\n                    self.cleanup_recording_state()\n\n        except Exception as e:\n            print(f\"[DEBUG] Error checking recording status: {e}\")\n            if self.get_visible():\n                self.set_visible(False)\n                self.cleanup_recording_state()\n\n        return True\n\n    def update_timer_display(self):\n        if not self.get_visible() or self.recording_start_time is None:\n            return False\n\n        try:\n            elapsed_seconds = int(time.time() - self.recording_start_time)\n            minutes = elapsed_seconds // 60\n            seconds = elapsed_seconds % 60\n            time_text = f\"{minutes:02d}:{seconds:02d}\"\n\n            self.time_label.set_markup(time_text)\n            self.set_tooltip_text(\n                f\"Recording in progress ({time_text}) - Click to stop\"\n            )\n\n            return True\n        except Exception as e:\n            print(f\"[DEBUG] Error updating timer display: {e}\")\n            return False\n\n    def cleanup_recording_state(self):\n        self.recording_start_time = None\n\n        if self.timer_timeout_id:\n            GLib.source_remove(self.timer_timeout_id)\n            self.timer_timeout_id = None\n\n    def get_recording_start_time(self):\n        wf_file = \"/tmp/recording_start_time.txt\"\n        gpu_file = \"/tmp/gpu_recording_start_time.txt\"\n\n        def read_timestamp(path):\n            try:\n                with open(path, \"r\") as f:\n                    content = f.read().strip()\n                    if content:\n                        t = float(content)\n                        if abs(t - time.time()) <= 3600:\n                            return t\n                    return os.path.getmtime(path)\n            except (OSError, ValueError):\n                return None\n\n        if os.path.exists(wf_file):\n            t = read_timestamp(wf_file)\n            if t:\n                print(\"[DEBUG] Using wf-recorder start time\")\n                return t\n\n        if os.path.exists(gpu_file):\n            t = read_timestamp(gpu_file)\n            if t:\n                print(\"[DEBUG] Using gpu-screen-recorder start time\")\n                return t\n\n        print(\"[DEBUG] No start time file found, using current time\")\n        return time.time()\n\n    def on_stop_recording(self, *args):\n        try:\n            self.set_visible(False)\n            self.cleanup_recording_state()\n\n            def send_stop_command():\n                try:\n                    subprocess.Popen(\n                        [self.script_path, \"record\", \"stop\"],\n                        stdout=subprocess.DEVNULL,\n                        stderr=subprocess.DEVNULL,\n                    )\n                except Exception as e:\n                    print(f\"[DEBUG] Error sending stop command: {e}\")\n\n            GLib.idle_add(send_stop_command)\n            GLib.timeout_add(500, self._verify_recording_stopped)\n            GLib.timeout_add(1500, self._verify_recording_stopped)\n            GLib.timeout_add(3000, self._verify_recording_stopped)\n\n        except Exception:\n            self.set_visible(False)\n            self.cleanup_recording_state()\n\n    def _verify_recording_stopped(self):\n        try:\n            if self.is_recorder_running():\n                if self.recording_start_time is None:\n                    self.recording_start_time = self.get_recording_start_time()\n\n                if self.recording_start_time:\n                    self.set_visible(True)\n                    self.update_timer_display()\n\n                    if self.timer_timeout_id is None:\n                        self.timer_timeout_id = GLib.timeout_add(\n                            self.timer_update_interval, self.update_timer_display\n                        )\n            else:\n                self.set_visible(False)\n                self.cleanup_recording_state()\n\n        except Exception:\n            self.set_visible(False)\n            self.cleanup_recording_state()\n\n        return False\n\n    def _delayed_init(self):\n        try:\n            self.check_recording_status()\n            self.status_timeout_id = GLib.timeout_add(\n                self.status_check_interval, self.check_recording_status\n            )\n\n        except Exception as e:\n            print(f\"[DEBUG] Error in delayed recording indicator init: {e}\")\n        return False\n\n    def destroy(self):\n        if self.timer_timeout_id:\n            GLib.source_remove(self.timer_timeout_id)\n            self.timer_timeout_id = None\n\n        if self.status_timeout_id:\n            GLib.source_remove(self.status_timeout_id)\n            self.status_timeout_id = None\n\n        super().destroy()\n"
  },
  {
    "path": "modules/panel/main.py",
    "content": "from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton\nfrom fabric.system_tray.widgets import SystemTray\nfrom fabric.utils import get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.datetime import DateTime\nfrom fabric.widgets.revealer import Revealer\nfrom fabric.widgets.svg import Svg\n\nimport config.data as data\nfrom modules.controlcenter.main import ModusControlCenter\nfrom modules.notification.notification_center import NotificationCenter\nfrom modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray\nfrom modules.panel.components.indicators import (\n    BatteryIndicator,\n    BluetoothIndicator,\n    NetworkIndicator,\n)\nfrom modules.panel.components.menubar import MenuBar\nfrom modules.panel.components.recording_indicator import RecordingIndicator\nfrom modules.todo.todo_widget import TodoListCapture\nfrom services.modus import notification_service\nfrom utils.functions import is_special_workspace_id\nfrom utils.roam import modus_service\nfrom widgets.mousecapture import MouseCapture\nfrom widgets.wayland import WaylandWindow as Window\n\n# Apply enhanced system tray icon handling\napply_enhanced_system_tray()\n\n\nclass Panel(Window):\n    def __init__(self, **kwargs):\n        super().__init__(\n            name=\"bar\",\n            title=\"modus\",\n            layer=\"top\",\n            anchor=\"left top right\",\n            exclusivity=\"auto\",\n            visible=False,\n        )\n\n        self.launcher = kwargs.get(\"launcher\", None)\n        self.menubar = MenuBar(parent_window=self)\n\n        self.workspace_indicator = HyprlandWorkspaces(\n            name=\"workspaces\",\n            spacing=4,\n            buttons_factory=lambda ws_id: (\n                None\n                if data.HIDE_SPECIAL_WORKSPACE and is_special_workspace_id(ws_id)\n                else WorkspaceButton(id=ws_id, label=str(ws_id))\n            ),\n        )\n\n        self.imac = Button(\n            name=\"panel-button\",\n            child=Svg(\n                size=16,\n                svg_file=get_relative_path(\"../../config/assets/icons/misc/logo.svg\"),\n            ),\n            on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac),\n        )\n\n        self.tray = SystemTray(name=\"system-tray\", spacing=4, icon_size=20)\n        self.tray_revealer = Revealer(\n            name=\"tray-revealer\",\n            child=self.tray,\n            child_revealed=False,\n            transition_type=\"slide-left\",\n            transition_duration=300,\n        )\n\n        self.chevron_button = Button(\n            name=\"panel-button\",\n            child=Svg(\n                size=16,\n                svg_file=get_relative_path(\n                    \"../../config/assets/icons/misc/chevron-right.svg\"\n                ),\n            ),\n            on_clicked=self.toggle_tray,\n        )\n\n        self.indicators = Box(\n            name=\"indicators\",\n            orientation=\"h\",\n            spacing=4,\n            children=[\n                BatteryIndicator(),\n                NetworkIndicator(),\n                BluetoothIndicator(),\n            ],\n        )\n\n        self.search = Button(\n            name=\"panel-button\",\n            on_clicked=lambda *_: self.search_apps(),\n            child=Svg(\n                size=22,\n                svg_file=get_relative_path(\"../../config/assets/icons/misc/search.svg\"),\n            ),\n        )\n\n        self.control_center = MouseCapture(\n            layer=\"top\", child_window=ModusControlCenter()\n        )\n\n        self.control_center_button = Button(\n            name=\"control-center-button\",\n            style_classes=\"button\",\n            on_clicked=self.control_center.toggle_mousecapture,\n            child=Svg(\n                size=22,\n                svg_file=get_relative_path(\n                    \"../../config/assets/icons/misc/control-center.svg\"\n                ),\n            ),\n        )\n\n        # Notification Center with MouseCapture\n        self.notification_center = MouseCapture(\n            layer=\"overlay\", child_window=NotificationCenter()\n        )\n\n        # Todo List with MouseCapture\n        self.todo_list = TodoListCapture()\n\n        # Notification Center Icon\n        self.notification_icon = Svg(\n            size=22,\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/notifications/notification-inactive.svg\"\n            ),\n        )\n\n        self.notification_center_icon_button = Button(\n            name=\"notification-center-icon-button\",\n            child=self.notification_icon,\n            on_clicked=self.on_notification_icon_clicked,\n        )\n\n        # Clickable DateTime for todo list\n        self.datetime_button = Button(\n            name=\"datetime-button\",\n            child=DateTime(name=\"date-time\", formatters=[\"%a %-d %b %I:%M %P\"]),\n            on_clicked=self.on_datetime_clicked,\n        )\n\n        self.recording_indicator = RecordingIndicator()\n\n        self.children = CenterBox(\n            name=\"panel\",\n            start_children=Box(\n                name=\"modules-left\",\n                children=[\n                    self.imac,\n                    self.menubar,\n                ],\n            ),\n            center_children=Box(\n                name=\"modules-center\",\n                children=self.recording_indicator,\n            ),\n            end_children=Box(\n                name=\"modules-right\",\n                spacing=4,\n                orientation=\"h\",\n                children=[\n                    self.workspace_indicator,\n                    self.tray_revealer,\n                    self.chevron_button,\n                    self.indicators,\n                    self.search,\n                    self.control_center_button,\n                    self.datetime_button,\n                    self.notification_center_icon_button,\n                ],\n            ),\n        )\n\n        # Connect to DND state changes for notification icon\n        modus_service.connect(\"dont-disturb-changed\", self.on_dnd_changed)\n\n        # Connect to notification service for icon state updates\n        notification_service.connect(\n            \"notify::count\", self.on_notification_count_changed\n        )\n\n        # Set initial notification icon state\n        self.update_notification_icon()\n\n        return self.show_all()\n\n    def search_apps(self):\n        self.launcher.show_launcher()\n\n    def toggle_tray(self, *_):\n        current_state = self.tray_revealer.child_revealed\n        self.tray_revealer.child_revealed = not current_state\n\n        if self.tray_revealer.child_revealed:\n            self.chevron_button.get_child().set_from_file(\n                get_relative_path(\"../../config/assets/icons/misc/chevron-left.svg\")\n            )\n        else:\n            self.chevron_button.get_child().set_from_file(\n                get_relative_path(\"../../config/assets/icons/misc/chevron-right.svg\")\n            )\n\n    def on_dnd_changed(self, _, dnd_state):\n        \"\"\"Handle DND state changes from the service.\"\"\"\n        self.update_notification_icon()  # Update notification icon when DND changes\n\n    def on_notification_count_changed(self, service, *args):\n        \"\"\"Handle notification count changes from the service.\"\"\"\n        self.update_notification_icon()\n\n    def on_notification_icon_clicked(self, *args):\n        \"\"\"Handle notification icon clicks - only open center if there are notifications.\"\"\"\n        count = notification_service.count\n        if count > 0:\n            # Only open notification center if there are notifications\n            self.notification_center.toggle_mousecapture()\n        # Do nothing if no notifications\n\n    def on_datetime_clicked(self, *args):\n        \"\"\"Handle datetime button clicks - open todo list.\"\"\"\n        self.todo_list.toggle_mousecapture()\n\n    def update_notification_icon(self):\n        \"\"\"Update the notification icon based on count and DND state.\"\"\"\n        count = notification_service.count\n        dnd_enabled = modus_service.dont_disturb\n\n        if dnd_enabled:\n            # DND is enabled - show disabled icon\n            icon_file = \"notification-disabled.svg\"\n        elif count > 0:\n            # Has notifications - show active icon\n            icon_file = \"notification-active.svg\"\n        else:\n            # No notifications - show inactive icon\n            icon_file = \"notification-inactive.svg\"\n\n        icon_path = get_relative_path(\n            f\"../../config/assets/icons/notifications/{icon_file}\"\n        )\n        self.notification_icon.set_from_file(icon_path)\n"
  },
  {
    "path": "modules/switcher.py",
    "content": "import json\n\nimport gi\nfrom gi.repository import Gdk, Glace\n\nimport config.data as data\nfrom fabric.hyprland.widgets import get_hyprland_connection\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.eventbox import EventBox\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.label import Label\nfrom utils.icon_resolver import IconResolver\nfrom utils.occlusion import get_screen_dimensions\nfrom utils.functions import is_special_workspace\nfrom widgets.wayland import WaylandWindow as Window\n\ngi.require_version(\"Glace\", \"0.1\")\n\n\nclass ApplicationSwitcher(Window):\n    def __init__(self, **kwargs):\n        super().__init__(\n            name=\"application-switcher\",\n            title=\"modus-switcher\",\n            layer=\"top\",\n            anchor=\"center\",\n            exclusivity=\"auto\",\n            keyboard_mode=\"exclusive\",\n            visible=False,  # Start hidden until explicitly shown\n            **kwargs,\n        )\n\n        self.conn = get_hyprland_connection()\n        self.icon_resolver = IconResolver()\n        self.windows = []\n        self.current_index = 0\n        self.tab_pressed = False\n        self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW\n        self.icon_size = 64\n\n        # Initialize Glace manager for window previews\n        self._manager = Glace.Manager()\n\n        # Calculate preview size based on screen ratio\n        # Formula: screen_ratio = a:b, width = x, height = (x*b)/a\n        screen_width, screen_height = get_screen_dimensions()\n        preview_width = 150  # Base width\n        preview_height = int((preview_width * screen_height) / screen_width)\n        self.preview_size = [preview_width, preview_height]\n\n        self.glace_clients = {}  # Map window addresses to Glace clients\n        self.window_previews = {}  # Map window addresses to preview images\n\n        container = Box(\n            name=\"application-switcher-container\",\n            orientation=\"v\",\n            h_align=\"center\",\n            v_align=\"center\",\n            expand=True,\n        )\n        self.add(container)\n\n        self.view = Box(\n            name=\"application-switcher-view\",\n            orientation=\"v\",\n            spacing=12,\n            h_align=\"center\",\n            v_align=\"center\",\n        )\n        container.add(self.view)\n        self.connect(\"key-press-event\", self.on_key_press)\n        self.connect(\"key-release-event\", self.on_key_release)\n\n        # Connect to Glace manager signals to track clients\n        self._manager.connect(\"client-added\", self._on_glace_client_added)\n        self._manager.connect(\"client-removed\", self._on_glace_client_removed)\n\n        self.show_all()\n        self.hide()\n\n    def show_switcher(self) -> None:\n        self.update_windows()\n        if not self.windows:\n            return\n\n        self.show()\n        self.grab_keyboard()\n        self.tab_pressed = False\n\n    def hide_switcher(self) -> None:\n        self.hide()\n        self.ungrab_keyboard()\n\n    def _on_glace_client_added(self, _, client):\n        \"\"\"Handle when a Glace client is added\"\"\"\n        try:\n            # Map the client by its window address for later lookup\n            # We'll need to match this with Hyprland window data\n            client_id = client.get_id()\n            self.glace_clients[client_id] = client\n        except Exception as e:\n            print(f\"Error adding Glace client: {e}\")\n\n    def _on_glace_client_removed(self, _, client):\n        \"\"\"Handle when a Glace client is removed\"\"\"\n        try:\n            client_id = client.get_id()\n            if client_id in self.glace_clients:\n                del self.glace_clients[client_id]\n        except Exception as e:\n            print(f\"Error removing Glace client: {e}\")\n\n    def _find_glace_client_for_window(self, window):\n        \"\"\"Find the corresponding Glace client for a Hyprland window\"\"\"\n        try:\n            window_class = window.get(\"class\", \"\").lower()\n            window_title = window.get(\"title\", \"\")\n\n            # Try to match by app_id/class and title\n            for _, client in self.glace_clients.items():\n                try:\n                    client_app_id = client.get_app_id()\n                    client_title = client.get_title()\n\n                    if (\n                        client_app_id\n                        and client_app_id.lower() == window_class\n                        and client_title\n                        and client_title == window_title\n                    ):\n                        return client\n                except Exception:\n                    continue\n\n            # Fallback: try to match by class only\n            for _, client in self.glace_clients.items():\n                try:\n                    client_app_id = client.get_app_id()\n                    if client_app_id and client_app_id.lower() == window_class:\n                        return client\n                except Exception:\n                    continue\n\n        except Exception as e:\n            print(f\"Error finding Glace client: {e}\")\n\n        return None\n\n    def create_preview_for_window(self, window):\n        \"\"\"Create a preview image for a specific window\"\"\"\n        glace_client = self._find_glace_client_for_window(window)\n\n        # Create a placeholder image first\n        preview_image = Image()\n\n        if glace_client:\n\n            def capture_callback(pbuf, _):\n                try:\n                    scaled_pixbuf = pbuf.scale_simple(\n                        self.preview_size[0],\n                        self.preview_size[1],\n                        2,  # GdkPixbuf.InterpType.BILINEAR\n                    )\n                    preview_image.set_from_pixbuf(scaled_pixbuf)\n                except Exception as e:\n                    print(f\"Error setting preview image: {e}\")\n\n            try:\n                self._manager.capture_client(\n                    client=glace_client,\n                    overlay_cursor=False,\n                    callback=capture_callback,\n                    user_data=None,\n                )\n            except Exception as e:\n                print(f\"Error capturing client preview: {e}\")\n                # Fallback to icon if preview fails\n                self._set_fallback_icon(preview_image, window)\n        else:\n            # Use icon as fallback if no Glace client found\n            self._set_fallback_icon(preview_image, window)\n\n        return preview_image\n\n    def _set_fallback_icon(self, image_widget, window):\n        \"\"\"Set a fallback icon when preview is not available\"\"\"\n        class_name = window.get(\"class\", \"\").lower()\n        icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size)\n        if not icon_img:\n            icon_img = self.icon_resolver.get_icon_pixbuf(\n                \"application-x-executable-symbolic\", self.icon_size\n            )\n        image_widget.set_from_pixbuf(icon_img)\n\n    def _is_special_workspace(self, client):\n        return is_special_workspace(client)\n\n    def update_windows(self) -> None:\n        for child in self.view.get_children():\n            self.view.remove(child)\n\n        try:\n            clients_data = self.conn.send_command(\"j/clients\").reply\n            if not clients_data:\n                return\n            clients = json.loads(clients_data.decode(\"utf-8\"))\n\n            # Filter out hidden windows and optionally special workspace windows\n            filtered_windows = []\n            for c in clients:\n                if c.get(\"hidden\", False):\n                    continue\n                # Skip clients in special workspaces if the setting is enabled\n                if (\n                    data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS\n                    and self._is_special_workspace(c)\n                ):\n                    continue\n                filtered_windows.append(c)\n\n            self.windows = filtered_windows\n\n            active_data = self.conn.send_command(\"j/activewindow\").reply\n            active_window = (\n                json.loads(active_data.decode(\"utf-8\")) if active_data else None\n            )\n\n            self.current_index = 0\n            if active_window:\n                for i, window in enumerate(self.windows):\n                    if window.get(\"address\") == active_window.get(\"address\"):\n                        self.current_index = i\n                        break\n\n            current_row = Box(\n                name=\"window-row\",\n                orientation=\"h\",\n                spacing=12,\n                h_align=\"center\",\n                v_align=\"center\",\n            )\n            self.view.add(current_row)\n\n            for i, window in enumerate(self.windows):\n                title = window.get(\"title\", \"\")\n\n                # Create preview image for this window\n                preview_image = self.create_preview_for_window(window)\n\n                button_content = Box(\n                    name=\"switcher-button\",\n                    orientation=\"v\",\n                    spacing=4,\n                    h_align=\"center\",\n                    v_align=\"center\",\n                    children=[\n                        Box(\n                            name=\"switcher-preview-box\",\n                            style_classes=[\"window-basic\", \"sleek-border\"],\n                            children=[preview_image],\n                            h_align=\"center\",\n                            v_align=\"center\",\n                        ),\n                        Label(\n                            label=title[:15] + \"...\" if len(title) > 15 else title,\n                            h_align=\"center\",\n                            v_align=\"center\",\n                            max_width_chars=15,\n                            ellipsize=\"end\",\n                        ),\n                    ],\n                )\n\n                event_box = EventBox(\n                    name=\"window-button\",\n                    style_classes=[\"active\"] if i == self.current_index else None,\n                    child=button_content,\n                )\n                current_row.add(event_box)\n\n                if (i + 1) % self.items_per_row == 0 and i + 1 < len(self.windows):\n                    current_row = Box(\n                        name=\"window-row\",\n                        orientation=\"h\",\n                        spacing=12,\n                        h_align=\"center\",\n                        v_align=\"center\",\n                    )\n                    self.view.add(current_row)\n\n            self.view.show_all()\n            self.update_selection()\n        except Exception as e:\n            print(f\"Failed to update windows: {e}\")\n\n    def on_key_press(self, _, event):\n        keyval = event.keyval\n        state = event.state\n        alt_pressed = bool(state & Gdk.ModifierType.MOD1_MASK)\n\n        if not self.windows:\n            return False\n\n        if keyval == Gdk.KEY_Escape:\n            self.hide_switcher()\n            return True\n\n        if keyval == Gdk.KEY_Tab:\n            if not self.tab_pressed or alt_pressed:\n                self.current_index = (self.current_index + 1) % len(self.windows)\n                self.update_selection()\n                self.tab_pressed = True\n            return True\n\n        if keyval == Gdk.KEY_ISO_Left_Tab or (\n            keyval == Gdk.KEY_Tab and (state & Gdk.ModifierType.SHIFT_MASK)\n        ):\n            self.current_index = (self.current_index - 1) % len(self.windows)\n            self.update_selection()\n            return True\n\n        if keyval == Gdk.KEY_Return:\n            self.activate_selected()\n            self.hide_switcher()\n            return True\n\n        if keyval == Gdk.KEY_Right or keyval == Gdk.KEY_l:\n            self.current_index = (self.current_index + 1) % len(self.windows)\n            self.update_selection()\n            return True\n\n        if keyval == Gdk.KEY_Left or keyval == Gdk.KEY_h:\n            self.current_index = (self.current_index - 1) % len(self.windows)\n            self.update_selection()\n            return True\n\n        if keyval == Gdk.KEY_Down:\n            next_index = self.current_index + self.items_per_row\n            if next_index < len(self.windows):\n                self.current_index = next_index\n                self.update_selection()\n            return True\n\n        if keyval == Gdk.KEY_Up:\n            next_index = self.current_index - self.items_per_row\n            if next_index >= 0:\n                self.current_index = next_index\n                self.update_selection()\n            return True\n\n        return False\n\n    def on_key_release(self, _, event):\n        keyval = event.keyval\n\n        if keyval in (Gdk.KEY_Alt_L, Gdk.KEY_Alt_R):\n            self.activate_selected()\n            self.hide_switcher()\n            return True\n\n        if keyval == Gdk.KEY_Tab:\n            self.tab_pressed = False\n            return True\n\n        return False\n\n    def update_selection(self):\n        for row in self.view.get_children():\n            for i, child in enumerate(row.get_children()):\n                index = self.view.get_children().index(row) * self.items_per_row + i\n                if index == self.current_index:\n                    child.add_style_class(\"active\")\n                else:\n                    child.remove_style_class(\"active\")\n\n    def activate_selected(self):\n        if not self.windows or self.current_index >= len(self.windows):\n            return\n\n        window = self.windows[self.current_index]\n        address = window.get(\"address\")\n        if address:\n            try:\n                command = f\"/dispatch focuswindow address:{address}\"\n                self.conn.send_command(command)\n            except Exception as e:\n                print(f\"Failed to focus window: {e}\")\n\n    def grab_keyboard(self):\n        try:\n            display = Gdk.Display.get_default()\n            seat = display.get_default_seat()\n            window = self.get_window()\n            seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None)\n        except Exception as e:\n            print(f\"Failed to grab keyboard: {e}\")\n\n    def ungrab_keyboard(self):\n        try:\n            display = Gdk.Display.get_default()\n            seat = display.get_default_seat()\n            seat.ungrab()\n        except Exception as e:\n            print(f\"Failed to ungrab keyboard: {e}\")\n"
  },
  {
    "path": "modules/todo/__init__.py",
    "content": "# Todo module"
  },
  {
    "path": "modules/todo/todo_widget.py",
    "content": "# Standard library imports\nfrom datetime import datetime\n\n# Fabric imports\nfrom fabric.utils import get_relative_path\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.entry import Entry\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.scrolledwindow import ScrolledWindow\nfrom fabric.widgets.svg import Svg\nfrom gi.repository import GLib\n\n# Local imports\nfrom services.todo import todo_service\nfrom widgets.mousecapture import MouseCapture\nfrom widgets.wayland import WaylandWindow as Window\n\n\nclass TodoItem(Box):\n    \"\"\"Individual todo item widget\"\"\"\n\n    def __init__(self, todo_data, todo_list_widget, **kwargs):\n        self.todo_data = todo_data\n        self.todo_list_widget = todo_list_widget\n        self.editing = False\n\n        super().__init__(\n            name=\"todo-item\",\n            orientation=\"h\",\n            spacing=8,\n            style_classes=[\"menu\"],\n            **kwargs,\n        )\n\n        self._build_ui()\n\n    def _build_ui(self):\n        \"\"\"Build the todo item UI\"\"\"\n        # Checkbox for completion - using SVG icons\n        checkbox_icon = (\n            \"checkbox-check.svg\"\n            if self.todo_data[\"completed\"]\n            else \"checkbox-uncheck.svg\"\n        )\n        self.checkbox_icon = Svg(\n            name=\"todo-checkbox-icon\",\n            size=24,\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/todo/\" + checkbox_icon\n            ),\n        )\n        self.checkbox = Button(\n            name=\"todo-checkbox\",\n            child=self.checkbox_icon,\n            on_clicked=self._toggle_completion,\n        )\n\n        # Todo text (can be converted to entry for editing)\n        text_content = self.todo_data[\"text\"]\n        if self.todo_data[\"completed\"]:\n            text_content = f\"<s>{text_content}</s>\"\n\n        self.text_label = Label(\n            markup=text_content,\n            name=\"todo-text\",\n            h_align=\"start\",\n            h_expand=True,\n            line_wrap=\"word-char\",\n            style_classes=(\n                [\"title-widget\"]\n                if not self.todo_data[\"completed\"]\n                else [\"status-label\"]\n            ),\n        )\n\n        # Date/time label\n        created_at = datetime.fromisoformat(self.todo_data[\"created_at\"])\n        date_text = created_at.strftime(\"%b %d, %Y at %I:%M %p\")\n\n        self.date_label = Label(\n            label=date_text,\n            name=\"todo-date\",\n            h_align=\"start\",\n            style_classes=[\"todo-date-text\"],\n        )\n\n        self.text_entry = Entry(\n            name=\"todo-text-entry\",\n            text=self.todo_data[\"text\"],\n            h_expand=True,\n            visible=False,\n        )\n        self.text_entry.connect(\"activate\", self._save_edit)\n\n        # Priority indicator\n        # priority_symbols = {\"high\": \"🔴\", \"medium\": \"🟡\", \"low\": \"🟢\"}\n        #\n        # self.priority_label = Label(\n        #     label=priority_symbols.get(self.todo_data[\"priority\"], \"🟡\"),\n        #     name=\"todo-priority-label\",\n        # )\n        # self.priority_button = Button(\n        #     name=\"todo-priority\",\n        #     size=(20, 20),\n        #     child=self.priority_label,\n        #     on_clicked=self._cycle_priority,\n        # )\n        #\n        # Edit button - using SVG icon\n        self.edit_icon = Svg(\n            name=\"todo-edit-icon\",\n            size=12,\n            svg_file=get_relative_path(\"../../config/assets/icons/todo/edit.svg\"),\n        )\n        self.edit_button = Button(\n            name=\"todo-edit\",\n            child=self.edit_icon,\n            on_clicked=self._start_edit,\n        )\n\n        # Delete button - using SVG icon\n        self.delete_icon = Svg(\n            name=\"todo-delete-icon\",\n            size=12,\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/todo/delete-symbolic.svg\"\n            ),\n        )\n        self.delete_button = Button(\n            name=\"todo-delete\",\n            child=self.delete_icon,\n            on_clicked=self._delete_todo,\n        )\n\n        # Text container that switches between label and entry\n        self.text_container = Box(\n            orientation=\"v\",\n            h_expand=True,\n            children=[\n                Box(orientation=\"h\", children=[self.text_label]),\n                self.date_label,\n            ],\n        )\n\n        self.children = [\n            self.checkbox,\n            self.text_container,\n            # self.priority_button,\n            self.edit_button,\n            self.delete_button,\n        ]\n\n    def _toggle_completion(self, *_):\n        \"\"\"Toggle todo completion status\"\"\"\n        todo_service.toggle_todo(self.todo_data[\"id\"])\n\n    def _cycle_priority(self, *_):\n        \"\"\"Cycle through priority levels\"\"\"\n        priorities = [\"low\", \"medium\", \"high\"]\n        current_index = priorities.index(self.todo_data[\"priority\"])\n        new_priority = priorities[(current_index + 1) % len(priorities)]\n        todo_service.set_priority(self.todo_data[\"id\"], new_priority)\n\n    def _start_edit(self, *_):\n        \"\"\"Start editing the todo text\"\"\"\n        if self.editing:\n            return\n\n        self.editing = True\n        self.text_container.children = [\n            Box(orientation=\"h\", children=[self.text_entry]),\n            self.date_label,\n        ]\n        self.text_entry.set_visible(True)\n        self.text_entry.grab_focus()\n        self.text_entry.set_position(-1)  # Move cursor to end\n\n    def _save_edit(self, *_):\n        \"\"\"Save the edited todo text\"\"\"\n        if not self.editing:\n            return\n\n        new_text = self.text_entry.get_text().strip()\n        if new_text:\n            todo_service.edit_todo(self.todo_data[\"id\"], new_text)\n\n        self._cancel_edit()\n\n    def _cancel_edit(self):\n        \"\"\"Cancel editing and revert to label\"\"\"\n        self.editing = False\n        self.text_container.children = [\n            Box(orientation=\"h\", children=[self.text_label]),\n            self.date_label,\n        ]\n        self.text_entry.set_visible(False)\n\n    def _delete_todo(self, *_):\n        \"\"\"Delete this todo\"\"\"\n        todo_service.delete_todo(self.todo_data[\"id\"])\n\n    def update_from_data(self, todo_data):\n        \"\"\"Update the widget from new todo data\"\"\"\n        self.todo_data = todo_data\n\n        # Update checkbox icon by recreating it\n        checkbox_icon = (\n            \"checkbox-check.svg\" if todo_data[\"completed\"] else \"checkbox-uncheck.svg\"\n        )\n        new_checkbox_icon = Svg(\n            name=\"todo-checkbox-icon\",\n            size=20,\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/todo/\" + checkbox_icon\n            ),\n        )\n        self.checkbox.set_child(new_checkbox_icon)\n        self.checkbox_icon = new_checkbox_icon\n\n        # Update text and styling with markup\n        text_content = todo_data[\"text\"]\n        if todo_data[\"completed\"]:\n            text_content = f\"<s>{text_content}</s>\"\n\n        self.text_label.set_markup(text_content)\n        self.text_label.style_classes = (\n            [\"title-widget\"] if not todo_data[\"completed\"] else [\"status-label\"]\n        )\n\n        # Update date/time\n        created_at = datetime.fromisoformat(todo_data[\"created_at\"])\n        date_text = created_at.strftime(\"%b %d, %Y at %I:%M %p\")\n        self.date_label.set_label(date_text)\n\n\nclass TodoListWidget(Window):\n    \"\"\"Main todo list widget window\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(\n            title=\"modus-todo\",\n            anchor=\"top right\",\n            margin=\"2px 10px 0px 0px\",\n            exclusivity=\"auto\",\n            keyboard_mode=\"on-demand\",\n            name=\"todo-list-window\",\n            visible=False,  # Back to hidden by default\n            **kwargs,\n        )\n\n        self.todo_items = {}  # Maps todo IDs to TodoItem widgets\n\n        # Register callback with todo service\n        todo_service.add_callback(self._on_todo_event)\n\n        self._build_ui()\n        self._refresh_todos()\n\n        # Add keybinding for escape\n        self.add_keybinding(\"Escape\", self.hide_todo_list)\n\n    def _build_ui(self):\n        \"\"\"Build the main UI\"\"\"\n        # Header with title and stats\n        self.stats_label = Label(\n            label=\"\",\n            name=\"todo-stats\",\n            style_classes=[\"status-label\"],\n            h_align=\"start\",\n        )\n\n        self.header = Box(\n            name=\"todo-header\",\n            orientation=\"v\",\n            children=[\n                Label(\n                    label=\"Todo List\",\n                    name=\"todo-title\",\n                    style_classes=[\"title\"],\n                    h_align=\"start\",\n                ),\n                self.stats_label,\n            ],\n        )\n\n        # Add new todo section\n        self.new_todo_entry = Entry(\n            name=\"new-todo-entry\",\n            placeholder_text=\"Add a new task...\",\n            h_expand=True,\n        )\n        self.new_todo_entry.connect(\"activate\", self._add_todo)\n\n        # Add button - using SVG icon\n        self.add_icon = Svg(\n            name=\"add-todo-icon\",\n            size=12,\n            svg_file=get_relative_path(\n                \"../../config/assets/icons/todo/plus-symbolic.svg\"\n            ),\n        )\n        self.add_button = Button(\n            name=\"add-todo-button\",\n            child=self.add_icon,\n            on_clicked=self._add_todo,\n        )\n\n        self.add_section = Box(\n            name=\"todo-add-section\",\n            orientation=\"h\",\n            spacing=8,\n            style_classes=[\"menu\"],\n            children=[\n                self.new_todo_entry,\n                self.add_button,\n            ],\n        )\n\n        # Todo items container\n        self.todos_container = Box(\n            name=\"todos-container\",\n            orientation=\"v\",\n            spacing=4,\n        )\n\n        # Scrolled window for todos\n        self.scrolled = ScrolledWindow(\n            name=\"todos-scrolled\",\n            min_content_height=300,\n            max_content_height=500,\n            min_content_width=400,\n            child=self.todos_container,\n            policy=\"automatic\",\n            v_expand=True,  # Allow vertical expansion\n        )\n\n        # Clear completed button\n        self.clear_button = Button(\n            name=\"clear-completed-button\",\n            label=\"Clear Completed\",\n            style_classes=[\"status-label\"],\n            on_clicked=self._clear_completed,\n        )\n\n        # Main container\n        self.main_container = Box(\n            name=\"todo-main-container\",\n            orientation=\"v\",\n            spacing=8,\n            style_classes=[\"menu\"],\n            children=[\n                self.header,\n                self.add_section,\n                self.scrolled,\n                self.clear_button,\n            ],\n        )\n\n        self.children = [self.main_container]\n\n    def _add_todo(self, *_):\n        \"\"\"Add a new todo\"\"\"\n        text = self.new_todo_entry.get_text().strip()\n        if text:\n            todo_service.add_todo(text)\n            self.new_todo_entry.set_text(\"\")\n\n    def _clear_completed(self, *_):\n        \"\"\"Clear all completed todos\"\"\"\n        todo_service.clear_completed()\n\n    def _on_todo_event(self, event_type, data=None):\n        \"\"\"Handle todo service events via callback\"\"\"\n        if event_type == \"todos-changed\":\n            GLib.idle_add(self._refresh_todos)\n        elif event_type == \"todo-added\":\n            GLib.idle_add(self._refresh_todos)\n        elif event_type == \"todo-deleted\":\n            GLib.idle_add(self._refresh_todos)\n        elif event_type in [\"todo-toggled\", \"todo-edited\", \"todo-priority-changed\"]:\n            if data and data[\"id\"] in self.todo_items:\n                GLib.idle_add(\n                    lambda: self.todo_items[data[\"id\"]].update_from_data(data)\n                )\n            GLib.idle_add(self._update_stats)\n\n    def _refresh_todos(self, *_):\n        \"\"\"Refresh the entire todo list\"\"\"\n        # Clear existing items\n        self.todo_items.clear()\n        self.todos_container.children = []\n\n        # Get all todos\n        todos = todo_service.todos\n\n        # Sort todos: incomplete first, then by priority, then by creation date\n        def sort_key(todo):\n            priority_order = {\"high\": 0, \"medium\": 1, \"low\": 2}\n            return (\n                todo[\"completed\"],  # False (incomplete) comes before True (completed)\n                priority_order.get(todo[\"priority\"], 1),\n                todo[\"created_at\"],\n            )\n\n        sorted_todos = sorted(todos, key=sort_key)\n\n        # Create todo item widgets\n        for todo in sorted_todos:\n            todo_item = TodoItem(todo, self)\n            self.todo_items[todo[\"id\"]] = todo_item\n\n        # Update container children\n        self.todos_container.children = list(self.todo_items.values())\n\n        # Update stats\n        self._update_stats()\n\n    def _update_stats(self):\n        \"\"\"Update the statistics display\"\"\"\n        stats = todo_service.get_stats()\n        stats_text = f\"{stats['pending']} pending, {stats['completed']} completed\"\n        self.stats_label.set_label(stats_text)\n\n    def set_visible(self, visible):\n        \"\"\"Override set_visible for debugging\"\"\"\n        super().set_visible(visible)\n\n    def hide_todo_list(self, *_):\n        \"\"\"Hide the todo list\"\"\"\n        if hasattr(self, \"_mousecapture_parent\"):\n            self._mousecapture_parent.toggle_mousecapture()\n        self.set_visible(False)\n\n    def _init_mousecapture(self, mousecapture):\n        \"\"\"Initialize mousecapture parent reference\"\"\"\n        self._mousecapture_parent = mousecapture\n\n    def destroy(self):\n        \"\"\"Clean up when destroyed\"\"\"\n        # Remove callback from todo service\n        todo_service.remove_callback(self._on_todo_event)\n\n        super().destroy()\n\n\nclass TodoListCapture(MouseCapture):\n    \"\"\"MouseCapture wrapper for the todo list\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(\n            layer=\"top\",\n            child_window=TodoListWidget(),\n            **kwargs,\n        )\n"
  },
  {
    "path": "modules/widget.py",
    "content": "# Standard library imports\nimport psutil\nimport requests\nimport urllib.parse\nimport datetime\nimport time\nimport subprocess\nimport calendar\nfrom concurrent.futures import ThreadPoolExecutor\nfrom typing import Optional, Tuple, List, Dict, Any\n\n# Fabric imports\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.label import Label\nfrom fabric.widgets.overlay import Overlay\nfrom fabric.widgets.datetime import DateTime\nfrom fabric.widgets.circularprogressbar import CircularProgressBar\nfrom widgets.wayland import WaylandWindow as Window\nfrom fabric.utils import invoke_repeater\nfrom gi.repository import GLib\n\n# Local imports\nfrom config.data import load_config\n\n# Module-level constants\nWEATHER_UPDATE_INTERVAL = 600  # 10 minutes\nWEATHER_CACHE_TIMEOUT = 1800  # 30 minutes\nSYSTEM_UPDATE_INTERVAL = 1000  # 1 second\nCALENDAR_UPDATE_INTERVAL = int(\n    (\n        (\n            datetime.datetime.combine(\n                datetime.date.today() + datetime.timedelta(days=1), datetime.time.min\n            )\n            - datetime.datetime.now()\n        ).total_seconds()\n    )\n    * 1000\n)  # Calculate time till midnight\nLOCATION_CACHE_TIMEOUT = 604800  # 7 days (extended from 24h)\n\n# Thread pool for async operations\nexecutor = ThreadPoolExecutor(max_workers=4)\n\n# Weather condition to CSS class mapping (iOS-style gradients)\nWEATHER_GRADIENT_MAP = {\n    # Clear/Sunny conditions - bright blue to lighter blue\n    0: \"weather-clear\",  # Clear sky\n    1: \"weather-mostly-clear\",  # Mainly clear\n    # Cloudy conditions - grey gradients\n    2: \"weather-partly-cloudy\",  # Partly cloudy\n    3: \"weather-overcast\",  # Overcast\n    # Fog conditions - muted grey/blue\n    45: \"weather-fog\",  # Fog\n    48: \"weather-fog\",  # Depositing rime fog\n    # Light rain/drizzle - blue-grey gradients\n    51: \"weather-light-rain\",  # Light drizzle\n    53: \"weather-rain\",  # Moderate drizzle\n    55: \"weather-rain\",  # Dense drizzle\n    61: \"weather-light-rain\",  # Slight rain\n    80: \"weather-light-rain\",  # Slight rain showers\n    # Heavy rain - darker blue-grey\n    63: \"weather-heavy-rain\",  # Moderate rain\n    65: \"weather-heavy-rain\",  # Heavy rain\n    81: \"weather-heavy-rain\",  # Moderate rain showers\n    82: \"weather-storm\",  # Violent rain showers\n    # Snow conditions - blue-white gradients\n    56: \"weather-snow\",  # Light freezing drizzle\n    57: \"weather-snow\",  # Dense freezing drizzle\n    66: \"weather-snow\",  # Light freezing rain\n    67: \"weather-snow\",  # Heavy freezing rain\n    71: \"weather-snow\",  # Slight snow fall\n    73: \"weather-heavy-snow\",  # Moderate snow fall\n    75: \"weather-heavy-snow\",  # Heavy snow fall\n    77: \"weather-snow\",  # Snow grains\n    85: \"weather-snow\",  # Slight snow showers\n    86: \"weather-heavy-snow\",  # Heavy snow showers\n    # Storm conditions - dark dramatic gradients\n    95: \"weather-storm\",  # Thunderstorm\n    96: \"weather-storm\",  # Thunderstorm with slight hail\n    99: \"weather-storm\",  # Thunderstorm with heavy hail\n}\n\n# Weather condition to emoji mapping\nWEATHER_EMOJI_MAP = {\n    0: \"☀️\",  # Clear sky\n    1: \"🌤️\",  # Mainly clear\n    2: \"⛅\",  # Partly cloudy\n    3: \"☁️\",  # Overcast\n    45: \"🌫️\",  # Fog\n    48: \"🌫️\",  # Depositing rime fog\n    51: \"🌦️\",  # Light drizzle\n    53: \"🌧️\",  # Moderate drizzle\n    55: \"🌧️\",  # Dense drizzle\n    56: \"🌨️\",  # Light freezing drizzle\n    57: \"🌨️\",  # Dense freezing drizzle\n    61: \"🌦️\",  # Slight rain\n    63: \"🌧️\",  # Moderate rain\n    65: \"🌧️\",  # Heavy rain\n    66: \"🌨️\",  # Light freezing rain\n    67: \"🌨️\",  # Heavy freezing rain\n    71: \"🌨️\",  # Slight snow fall\n    73: \"❄️\",  # Moderate snow fall\n    75: \"❄️\",  # Heavy snow fall\n    77: \"🌨️\",  # Snow grains\n    80: \"🌦️\",  # Slight rain showers\n    81: \"🌧️\",  # Moderate rain showers\n    82: \"⛈️\",  # Violent rain showers\n    85: \"🌨️\",  # Slight snow showers\n    86: \"❄️\",  # Heavy snow showers\n    95: \"⛈️\",  # Thunderstorm\n    96: \"⛈️\",  # Thunderstorm with slight hail\n    99: \"⛈️\",  # Thunderstorm with heavy hail\n}\n\n# Weather condition descriptions\nWEATHER_DESC_MAP = {\n    0: \"Clear sky\",\n    1: \"Mainly clear\",\n    2: \"Partly cloudy\",\n    3: \"Overcast\",\n    45: \"Fog\",\n    48: \"Depositing rime fog\",\n    51: \"Light drizzle\",\n    53: \"Moderate drizzle\",\n    55: \"Dense drizzle\",\n    56: \"Light freezing drizzle\",\n    57: \"Dense freezing drizzle\",\n    61: \"Slight rain\",\n    63: \"Moderate rain\",\n    65: \"Heavy rain\",\n    66: \"Light freezing rain\",\n    67: \"Heavy freezing rain\",\n    71: \"Slight snow\",\n    73: \"Moderate snow\",\n    75: \"Heavy snow\",\n    77: \"Snow grains\",\n    80: \"Light rain showers\",\n    81: \"Moderate rain showers\",\n    82: \"Violent rain showers\",\n    85: \"Slight snow showers\",\n    86: \"Heavy snow showers\",\n    95: \"Thunderstorm\",\n    96: \"Thunderstorm with hail\",\n    99: \"Thunderstorm with heavy hail\",\n}\n\n# Location APIs in order of preference (fastest first)\nLOCATION_APIS = [\n    \"https://ipapi.co/json/\",  # Fastest, 200ms average\n    \"http://ip-api.com/json/\",  # Fast fallback, 150ms average\n    \"https://ipinfo.io/json\",  # Original fallback\n]\n\n# Global cache for weather data\n_weather_cache: Dict[str, Tuple[Any, float]] = {}\n_location_cache: Dict[str, Tuple[float, float, float]] = {}\n\n\ndef get_location() -> str:\n    \"\"\"Get current location using multiple IP geolocation APIs with fallback.\"\"\"\n    for api_url in LOCATION_APIS:\n        try:\n            response = requests.get(api_url, timeout=2)\n            if response.status_code == 200:\n                data = response.json()\n                # Handle different API response formats\n                city = data.get(\"city\", \"\")\n                if city:\n                    return city.replace(\" \", \"\")\n        except requests.RequestException as e:\n            print(f\"Location API {api_url} failed: {e}\")\n            continue\n\n    print(\"All location APIs failed\")\n    return \"\"\n\n\ndef get_coordinates(city: str) -> Optional[Tuple[float, float]]:\n    \"\"\"Get coordinates for a city using Nominatim geocoding API.\"\"\"\n    cache_key = city.lower()\n    current_time = time.time()\n\n    # Check cache first (cache for 7 days)\n    if cache_key in _location_cache:\n        lat, lon, timestamp = _location_cache[cache_key]\n        if current_time - timestamp < LOCATION_CACHE_TIMEOUT:\n            return lat, lon\n\n    try:\n        encoded_city = urllib.parse.quote(city)\n        url = f\"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1\"\n        response = requests.get(\n            url, timeout=3, headers={\"User-Agent\": \"Modus-Desktop/1.0\"}\n        )\n\n        if response.status_code == 200:\n            data = response.json()\n            if data:\n                lat = float(data[0][\"lat\"])\n                lon = float(data[0][\"lon\"])\n                _location_cache[cache_key] = (lat, lon, current_time)\n                return lat, lon\n    except (requests.RequestException, ValueError, KeyError) as e:\n        print(f\"Error geocoding {city}: {e}\")\n\n    return None\n\n\ndef get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]:\n    \"\"\"Fetch weather data from Open-Meteo API.\"\"\"\n    try:\n        url = (\n            f\"https://api.open-meteo.com/v1/forecast?\"\n            f\"latitude={lat}&longitude={lon}\"\n            f\"&current_weather=true\"\n            f\"&daily=temperature_2m_max,temperature_2m_min\"\n            f\"&timezone=auto\"\n            f\"&forecast_days=1\"\n        )\n\n        response = requests.get(url, timeout=3)\n        if response.status_code == 200:\n            return response.json()\n    except requests.RequestException as e:\n        print(f\"Error fetching weather data: {e}\")\n\n    return None\n\n\ndef format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]:\n    \"\"\"Format weather data into the expected format.\"\"\"\n    try:\n        current = weather_data[\"current_weather\"]\n        daily = weather_data[\"daily\"]\n\n        # Get weather code and map to emoji and description\n        weather_code = current[\"weathercode\"]\n        emoji = WEATHER_EMOJI_MAP.get(weather_code, \"🌤️\")\n        condition = WEATHER_DESC_MAP.get(weather_code, \"Unknown\")\n        gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, \"weather-clear\")\n\n        # Temperature\n        temp = f\"{round(current['temperature'])}°\"\n\n        # Daily min/max temperatures\n        max_temp = f\"{round(daily['temperature_2m_max'][0])}°\"\n        min_temp = f\"{round(daily['temperature_2m_min'][0])}°\"\n\n        return [emoji, temp, condition, city, max_temp, min_temp, gradient_class]\n\n    except (KeyError, IndexError, TypeError) as e:\n        print(f\"Error formatting weather data: {e}\")\n        return None\n\n\ndef get_weather(callback):\n    \"\"\"Fetch weather data asynchronously using Open-Meteo API.\"\"\"\n\n    def fetch_weather():\n        # Get location\n        location = get_location()\n        if not location:\n            return GLib.idle_add(callback, None)\n\n        # Check cache first\n        cache_key = location.lower()\n        current_time = time.time()\n\n        if cache_key in _weather_cache:\n            cached_data, timestamp = _weather_cache[cache_key]\n            if current_time - timestamp < WEATHER_CACHE_TIMEOUT:\n                return GLib.idle_add(callback, cached_data)\n\n        # Get coordinates for the location\n        coords = get_coordinates(location)\n        if not coords:\n            return GLib.idle_add(callback, None)\n\n        lat, lon = coords\n\n        # Fetch weather data\n        weather_data = get_weather_data(lat, lon)\n        if not weather_data:\n            return GLib.idle_add(callback, None)\n\n        # Format data\n        formatted_data = format_weather_data(weather_data, location)\n        if formatted_data:\n            # Cache the result\n            _weather_cache[cache_key] = (formatted_data, current_time)\n            GLib.idle_add(callback, formatted_data)\n        else:\n            GLib.idle_add(callback, None)\n\n    executor.submit(fetch_weather)\n\n\ndef update_weather(widget):\n    \"\"\"Update weather widget with new data.\"\"\"\n\n    def fetch_and_update():\n        get_weather(lambda weather_info: update_widget(widget, weather_info))\n        return True\n\n    GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update)\n    fetch_and_update()\n\n\ndef update_widget(widget, weather_info):\n    \"\"\"Update widget labels with weather information.\"\"\"\n    if weather_info:\n        widget.weatherinfo = weather_info\n        widget.update_labels(weather_info)\n\n\nclass Weather(Box):\n    \"\"\"Weather widget displaying current conditions and forecast.\"\"\"\n\n    def __init__(self, parent, **kwargs):\n        super().__init__(\n            name=\"weather-widget\",\n            h_expand=True,\n            v_expand=True,\n            justification=\"right\",\n            orientation=\"v\",\n            all_visible=False,\n            **kwargs,\n        )\n\n        self.parent = parent\n        self.weatherinfo = None\n\n        # Create labels with better organization\n        self._create_labels()\n        self._layout_labels()\n\n        # Start weather updates\n        update_weather(self)\n\n    def _create_labels(self):\n        \"\"\"Create all weather labels.\"\"\"\n        self.city = Label(\n            name=\"city\",\n            label=\"Loading...\",\n            justification=\"right\",\n            h_align=\"start\",\n            max_chars_width=12,\n            ellipsization=\"end\",\n        )\n        self.temperature = Label(name=\"temperature\", label=\"--°\", h_align=\"start\")\n        self.condition_em = Label(name=\"condition-emoji\", label=\"🌤️\", h_align=\"start\")\n        self.condition = Label(\n            name=\"condition\",\n            label=\"Loading...\",\n            max_chars_width=18,\n            ellipsization=\"end\",\n            h_align=\"start\",\n        )\n        self.feels_like = Label(name=\"feels-like\", label=\"H:-- L:--\", h_align=\"start\")\n\n    def _layout_labels(self):\n        \"\"\"Add labels to the widget in proper order.\"\"\"\n        labels = [\n            self.city,\n            self.temperature,\n            self.condition_em,\n            self.condition,\n            self.feels_like,\n        ]\n        for label in labels:\n            self.add(label)\n\n    def update_labels(self, weather_info: List[str]):\n        \"\"\"Update weather labels with new data.\"\"\"\n        if not weather_info or len(weather_info) != 7:\n            return\n\n        emoji, temp, condition, location, maxtemp, mintemp, gradient_class = (\n            weather_info\n        )\n        maxmin = f\"H:{maxtemp} L:{mintemp}\"\n\n        # Batch update labels for better performance\n        label_updates = [\n            (self.city, location),\n            (self.temperature, temp),\n            (self.condition_em, emoji),\n            (self.condition, condition),\n            (self.feels_like, maxmin),\n        ]\n\n        for label, text in label_updates:\n            label.set_label(text)\n\n        # Apply gradient background based on weather condition\n        self.parent.set_visible(True)\n\n\nclass WeatherContainer(Box):\n    \"\"\"Container for weather widget.\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(\n            orientation=\"v\",\n            name=\"weather-container\",\n            v_expand=True,\n            v_align=\"center\",\n            size=(170, 170),\n            visible=True,\n            h_align=\"center\",\n            children=[Weather(self)],\n            **kwargs,\n        )\n\n\nclass Date(Box):\n    \"\"\"Date widget displaying day, month, and date.\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(\n            name=\"date-widget\",\n            h_expand=True,\n            v_expand=True,\n            justification=\"center\",\n            h_align=\"center\",\n            v_align=\"start\",\n            orientation=\"v\",\n            **kwargs,\n        )\n\n        # Create date components\n        self.top = Box(orientation=\"h\", name=\"date-top\", h_expand=True)\n\n        # Use consistent interval for all date components\n        date_interval = 10000  # 10 seconds\n        self.dateone = DateTime(formatters=[\"%a\"], interval=date_interval, name=\"day\")\n        self.datetwo = DateTime(formatters=[\"%b\"], interval=date_interval, name=\"month\")\n        self.datethree = DateTime(\n            formatters=[\"%-d\"], interval=date_interval, name=\"date\"\n        )\n\n        # Layout components\n        self.top.add(self.dateone)\n        self.top.add(self.datetwo)\n        self.add(self.top)\n        self.add(self.datethree)\n\n\nclass DateContainer(Box):\n    \"\"\"Container for date widget.\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(\n            orientation=\"v\",\n            name=\"date-container\",\n            v_expand=True,\n            size=(170, 170),\n            v_align=\"center\",\n            h_align=\"center\",\n            children=[Date()],\n            **kwargs,\n        )\n\n\nclass Calendar(Box):\n    \"\"\"Calendar widget displaying current month.\"\"\"\n\n    def __init__(self, **kwargs):\n        # Set Sunday as first day of week\n        calendar.setfirstweekday(6)  # 6 = Sunday\n        super().__init__(\n            name=\"calendar-widget\",\n            h_expand=True,\n            v_expand=True,\n            orientation=\"v\",\n            **kwargs,\n        )\n\n        # Cache current date for efficiency\n        self._update_current_date()\n\n        # Create calendar components\n        self._create_header()\n        self._create_days_header()\n        self._create_calendar_grid()\n\n        # Layout components\n        self.add(self.month_label)\n        self.add(self.days_header)\n        self.add(self.calendar_grid)\n\n        # Schedule updates\n        invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed)\n\n    def _update_current_date(self):\n        \"\"\"Update cached current date values.\"\"\"\n        now = datetime.datetime.now()\n        self.current_month = now.month\n        self.current_year = now.year\n        self.current_day = now.day\n\n    def _create_header(self):\n        \"\"\"Create month header label.\"\"\"\n        self.month_label = Label(\n            name=\"calendar-month\",\n            label=calendar.month_name[self.current_month],\n            h_align=\"start\",\n            justification=\"left\",\n        )\n\n    def _create_days_header(self):\n        \"\"\"Create day abbreviations header.\"\"\"\n        self.days_header = Box(\n            name=\"calendar-days-header\", orientation=\"h\", h_expand=True, spacing=2\n        )\n\n        day_names = [\"S\", \"M\", \"T\", \"W\", \"T\", \"F\", \"S\"]\n        for i, day_name in enumerate(day_names):\n            is_weekend = i in (0, 6)  # Sunday or Saturday\n            day_label = Label(\n                name=(\n                    \"calendar-day-header-weekend\"\n                    if is_weekend\n                    else \"calendar-day-header\"\n                ),\n                label=day_name,\n                h_align=\"center\",\n                h_expand=True,\n            )\n            self.days_header.add(day_label)\n\n    def _create_calendar_grid(self):\n        \"\"\"Create calendar grid container.\"\"\"\n        self.calendar_grid = Box(name=\"calendar-grid\", orientation=\"v\", spacing=1)\n        self.update_calendar()\n\n    def update_calendar_if_needed(self) -> bool:\n        \"\"\"Check if date changed and update calendar if needed.\"\"\"\n        now = datetime.datetime.now()\n        if (\n            now.month != self.current_month\n            or now.year != self.current_year\n            or now.day != self.current_day\n        ):\n\n            self._update_current_date()\n            self.update_calendar()\n        return True\n\n    def update_calendar(self):\n        \"\"\"Update the calendar grid with current month.\"\"\"\n        # Clear existing calendar efficiently\n        children = self.calendar_grid.get_children()\n        for child in children:\n            self.calendar_grid.remove(child)\n\n        # Update month label\n        self.month_label.set_label(calendar.month_name[self.current_month])\n\n        # Generate calendar\n        cal = calendar.monthcalendar(self.current_year, self.current_month)\n        current_date = datetime.datetime.now()\n\n        for week in cal:\n            week_box = Box(orientation=\"h\", spacing=2, h_expand=True)\n\n            for day_index, day in enumerate(week):\n                if day == 0:\n                    # Empty day slot\n                    day_label = Label(\n                        name=\"calendar-day-empty\",\n                        label=\"\",\n                        h_align=\"center\",\n                        h_expand=True,\n                    )\n                else:\n                    # Regular day\n                    is_today = (\n                        day == self.current_day\n                        and self.current_month == current_date.month\n                        and self.current_year == current_date.year\n                    )\n                    is_weekend = day_index in (0, 6)  # Sunday or Saturday\n\n                    if is_today:\n                        name = \"calendar-day-today\"\n                    elif is_weekend:\n                        name = \"calendar-day-weekend\"\n                    else:\n                        name = \"calendar-day\"\n\n                    day_label = Label(\n                        name=name, label=str(day), h_align=\"center\", h_expand=True\n                    )\n\n                week_box.add(day_label)\n            self.calendar_grid.add(week_box)\n\n\nclass CalendarContainer(Box):\n    \"\"\"Container for calendar widget.\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(\n            orientation=\"v\",\n            name=\"calendar-box-widget\",\n            v_expand=True,\n            size=(170, 170),\n            v_align=\"center\",\n            h_align=\"center\",\n            children=[Calendar()],\n            **kwargs,\n        )\n\n\nclass SystemInfoBase(Box):\n    \"\"\"Base class for system information widgets.\"\"\"\n\n    @staticmethod\n    def create_progress_bar(name: str = \"progress-bar\", size: int = 80, **kwargs):\n        \"\"\"Create a standardized circular progress bar.\"\"\"\n        return CircularProgressBar(\n            name=name,\n            start_angle=270,\n            end_angle=630,\n            min_value=0,\n            max_value=100,\n            size=size,\n            **kwargs,\n        )\n\n    def __init__(self, name: str, **kwargs):\n        super().__init__(\n            layer=\"bottom\",\n            title=\"sysinfo\",\n            name=name,\n            visible=True,\n            size=(170, 170),\n            h_expand=True,\n            v_expand=True,\n            all_visible=True,\n            **kwargs,\n        )\n\n        # Create progress bar and labels\n        self.progress = self.create_progress_bar(name=\"progress\")\n        self.main_label = Label(\n            label=\"0%\\nLoading\", justification=\"center\", name=\"progress-label\"\n        )\n\n        # Create info container\n        self.info_container = Box(\n            name=\"info-container\",\n            orientation=\"v\",\n            spacing=2,\n            h_align=\"center\",\n        )\n\n        # Create main layout\n        self.progress_container = Box(\n            name=\"progress-bar-container\",\n            h_expand=True,\n            v_expand=True,\n            orientation=\"v\",\n            spacing=12,\n            h_align=\"center\",\n            v_align=\"center\",\n            children=[\n                Box(\n                    children=[\n                        Overlay(\n                            child=self.progress,\n                            tooltip_text=\"\",\n                            overlays=self.main_label,\n                        )\n                    ]\n                ),\n                Box(\n                    h_align=\"center\",\n                    justification=\"centre\",\n                    orientation=\"v\",\n                    children=[self.info_container],\n                ),\n            ],\n        )\n\n        self.add(self.progress_container)\n\n        # Don't start updates here - let subclasses call start_updates() when ready\n\n    def start_updates(self):\n        \"\"\"Start the update timer - call this after subclass initialization is complete.\"\"\"\n        invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update)\n\n    def create_info_line(\n        self, indicator_name: str, info_text: str, value_text: str\n    ) -> Box:\n        \"\"\"Create an information line with indicator, label, and value.\"\"\"\n        indicator = Label(label=\"■\", name=indicator_name)\n        info_label = Label(label=info_text, name=\"info-text\")\n        value_label = Label(label=value_text, name=\"info-value\")\n\n        line = Box(\n            orientation=\"h\",\n            spacing=4,\n            h_align=\"start\",\n            children=[indicator, info_label, value_label],\n        )\n\n        # Store references for easy updates\n        line.indicator = indicator\n        line.info_label = info_label\n        line.value_label = value_label\n\n        return line\n\n    def update(self) -> bool:\n        \"\"\"Override in subclasses.\"\"\"\n        raise NotImplementedError\n\n\nclass RamInfo(SystemInfoBase):\n    \"\"\"RAM usage information widget.\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(\"info-box-widget\", **kwargs)\n\n        # Create info lines and store references\n        self.used_line = self.create_info_line(\"used-color-indicator\", \"Used\", \"0.0GB\")\n        self.free_line = self.create_info_line(\"free-color-indicator\", \"Free\", \"0.0GB\")\n\n        # Add to info container\n        self.info_container.add(self.used_line)\n        self.info_container.add(self.free_line)\n\n        # Now that everything is set up, start updates\n        self.start_updates()\n\n    def update(self) -> bool:\n        \"\"\"Update RAM information.\"\"\"\n        try:\n            mem = psutil.virtual_memory()\n\n            # Update main label\n            self.main_label.set_label(f\" {round(mem.percent):<2} %\\nRAM\")\n\n            # Calculate values\n            used_gb = mem.used / (1024**3)\n            free_gb = mem.available / (1024**3)\n\n            # Update info labels using stored references\n            self.used_line.value_label.set_label(f\"{round(used_gb, 1)}GB\")\n            self.free_line.value_label.set_label(f\"{round(free_gb, 1)}GB\")\n\n            # Update progress bar (use GLib.idle_add for thread safety)\n            GLib.idle_add(self.progress.set_value, mem.percent)\n\n        except Exception as e:\n            print(f\"Error updating RAM info: {e}\")\n\n        return True\n\n\nclass CpuInfo(SystemInfoBase):\n    \"\"\"CPU usage and temperature information widget.\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(\"info-box-widget\", **kwargs)\n\n        # Create temperature info components\n        self.temp_info = Label(label=\"Temp\", name=\"info-text\")\n        self.temp_value = Label(label=\"0°C\", name=\"info-value\")\n\n        # Create temperature info line (no indicator)\n        self.temp_line = Box(\n            orientation=\"h\",\n            spacing=4,\n            h_align=\"start\",\n            children=[self.temp_info, self.temp_value],\n        )\n\n        # Add to info container\n        self.info_container.add(self.temp_line)\n\n        # Now that everything is set up, start updates\n        self.start_updates()\n\n    def get_cpu_temp(self) -> Optional[float]:\n        \"\"\"Get CPU temperature from system sensors.\"\"\"\n        try:\n            temps = psutil.sensors_temperatures()\n            if not temps:\n                return None\n\n            # Search for CPU temperature sensors\n            cpu_sensor_names = [\"coretemp\", \"k10temp\", \"cpu\"]\n            cpu_label_patterns = [\"package id 0\", \"core 0\", \"\"]\n\n            for name, entries in temps.items():\n                if any(sensor in name.lower() for sensor in cpu_sensor_names):\n                    for entry in entries:\n                        entry_label = (entry.label or \"\").lower()\n                        if any(\n                            pattern in entry_label for pattern in cpu_label_patterns\n                        ):\n                            return round(entry.current, 1)\n        except Exception as e:\n            print(f\"Error reading CPU temperature: {e}\")\n\n        return None\n\n    def update(self) -> bool:\n        \"\"\"Update CPU information.\"\"\"\n        try:\n            # Get CPU usage\n            cpu = psutil.cpu_percent()\n\n            # Update main label\n            self.main_label.set_label(f\" {round(cpu):<2} %\\nCPU\")\n\n            # Update temperature using stored reference\n            temp = self.get_cpu_temp()\n            temp_text = f\"{temp}°C\" if temp is not None else \"N/A\"\n            self.temp_value.set_label(temp_text)\n\n            # Update progress bar (use GLib.idle_add for thread safety)\n            GLib.idle_add(self.progress.set_value, cpu)\n\n        except Exception as e:\n            print(f\"Error updating CPU info: {e}\")\n\n        return True\n\n\nclass Deskwidgets(Window):\n    \"\"\"Desktop widgets manager - handles all desktop widgets.\"\"\"\n\n    config = load_config()\n\n    def __init__(self, **kwargs):\n        # Create the main invisible window that manages the widgets\n        super().__init__(\n            name=\"desktop-widget-manager\",\n            layer=\"bottom\",\n            title=\"modus-desktop-widget-manager\",\n            visible=False,  # This window is invisible - just manages the others\n            size=(1, 1),  # Minimal size\n            anchor=\"top left\",\n            **kwargs,\n        )\n\n        # Create separate independent windows as attributes\n        self.top_left = Window(\n            anchor=\"top left\",\n            title=\"modus-widgets-topleft\",\n            orientation=\"h\",\n            layer=\"bottom\",\n            visible=False,  # Start hidden until content ready\n            child=Box(\n                name=\"desktop-widgets-container\",\n                children=[\n                    DateContainer(),\n                    WeatherContainer(),\n                    CalendarContainer(),\n                ],\n            ),\n        )\n\n        self.bottom_left = Window(\n            anchor=\"bottom right\",\n            title=\"modus-widgets-bottomright\",\n            orientation=\"h\",\n            layer=\"bottom\",\n            visible=False,  # Start hidden until content ready\n            child=Box(\n                name=\"desktop-widgets-container\",\n                children=[\n                    CpuInfo(),\n                    RamInfo(),\n                ],\n            ),\n        )\n\n        # Show widgets after initialization is complete\n        self.top_left.set_visible(True)\n        self.bottom_left.set_visible(True)\n"
  },
  {
    "path": "requirements.txt",
    "content": "certifi==2025.8.3\ncharset-normalizer==3.4.2\nclick==8.2.1\nidna==3.10\nloguru==0.7.3\npillow==12.2.0\npsutil==7.0.0\npycairo==1.28.0\npydbus==0.6.0\nPyGObject==3.52.3\npyotp==2.9.0\npyzbar==0.1.9\nRapidFuzz==3.13.0\nrequests==2.33.0\nsetproctitle==1.3.6\nthefuzz==0.22.1\nurllib3==2.6.3\n"
  },
  {
    "path": "scripts/gamemode.sh",
    "content": "#!/usr/bin/env sh\n\n# Check if animations are disabled (game mode is active)\ncheck_gamemode() {\n\tHYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}')\n\tif [ \"$HYPRGAMEMODE\" = 0 ]; then\n\t\techo \"t\"\n\t\treturn 0\n\telse\n\t\techo \"f\"\n\t\treturn 1\n\tfi\n}\n\n# Toggle game mode state\ntoggle_gamemode() {\n\tHYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}')\n\tif [ \"$HYPRGAMEMODE\" = 1 ]; then\n\t\thyprctl --batch \"\\\n            keyword animations:enabled 0;\\\n            keyword decoration:shadow:enabled 0;\\\n            keyword decoration:blur:enabled 0;\\\n            keyword general:gaps_in 0;\\\n            keyword general:gaps_out 0;\\\n            keyword general:border_size 1;\\\n            keyword decoration:rounding 0\"\n\t\texit\n\tfi\n\thyprctl reload\n}\n\n# Main script logic\ncase \"$1\" in\ncheck)\n\tcheck_gamemode\n\t;;\n*)\n\ttoggle_gamemode\n\t;;\nesac\n"
  },
  {
    "path": "scripts/hyprpicker.sh",
    "content": "#!/bin/bash\n\npick_rgb() {\n\n\t# Execute hyprpicker with RGB format and save the output to a variable\n\thyprpicker -a -n -f rgb && sleep 0.1\n\n\t# Create a temporal 64x64 PNG file with the color in /tmp using convert\n\tmagick -size 64x64 xc:\"rgb($(wl-paste))\" /tmp/color.png\n\n\t# Send a notification using the file as an icon\n\tnotify-send \"RGB color picked\" \"rgb($(wl-paste))\" -i /tmp/color.png -a \"Hyprpicker\"\n\n\t# Remove the temporal file\n\trm /tmp/color.png\n\n\t# Exit\n\texit 0\n\n}\n\npick_hex() {\n\n\t# Execute hyprpicker and save the output to a variable\n\thyprpicker -a -n -f hex && sleep 0.1\n\n\t# Create a temporal 64x64 PNG file with the color in /tmp using convert\n\tmagick -size 64x64 xc:\"$(wl-paste)\" /tmp/color.png\n\n\t# Send a notification using the file as an icon\n\tnotify-send \"HEX color picked\" \"$(wl-paste)\" -i /tmp/color.png -a \"Hyprpicker\"\n\n\t# Remove the temporal file\n\trm /tmp/color.png\n\n\t# Exit\n\texit 0\n\n}\n\npick_hsv() {\n\n\t# Copy the color to the clipboard\n\techo -n \"$(hyprpicker -n -f hsv)\" | wl-copy -n\n\n\t# Create a temporal 64x64 PNG file with the color in /tmp using convert\n\tmagick -size 64x64 xc:\"hsv($(wl-paste))\" /tmp/color.png\n\n\t# Send a notification using the file as an icon\n\tnotify-send \"HSV color picked\" \"hsv($(wl-paste))\" -i /tmp/color.png -a \"Hyprpicker\"\n\n\t# Remove the temporal file\n\trm /tmp/color.png\n\n\t# Exit\n\texit 0\n\n}\n\ncase \"$1\" in\n-rgb)\n\tpick_rgb\n\t;;\n-hsv)\n\tpick_hsv\n\t;;\n-hex)\n\tpick_hex\n\t;;\n\n*)\n\techo \"Usage: $0 [-rgb|-hex|-hsv]\"\n\texit 1\n\t;;\nesac\n"
  },
  {
    "path": "scripts/screen-capture.sh",
    "content": "#!/bin/env bash\n\n# Script name\nSCRIPT_NAME=$(basename \"$0\")\n\n# Function to display usage\nusage() {\n\tcat <<EOF\nUsage: $SCRIPT_NAME <command> [options]\n\nCommands:\n  screenshot <target>     Take a screenshot\n    Targets:\n      selection            - Screenshot selected area\n      eDP-1               - Screenshot eDP-1 display\n      HDMI-A-1            - Screenshot HDMI-A-1 display\n      both                - Screenshot both displays\n\n  record <target>        Start/stop recording (with audio)\n    Targets:\n      selection           - Record selected area\n      eDP-1              - Record eDP-1 display\n      HDMI-A-1           - Record HDMI-A-1 display\n      stop               - Stop current recording\n\n  record-noaudio <target> Start/stop recording (no audio)\n    Targets:\n      selection           - Record selected area without audio\n      eDP-1              - Record eDP-1 display without audio\n      HDMI-A-1           - Record HDMI-A-1 display without audio\n      stop               - Stop current recording\n\n  record-hq <target>     Start/stop high-quality recording (for YouTube)\n    Targets:\n      selection           - Record selected area in high quality\n      eDP-1              - Record eDP-1 display in high quality\n      HDMI-A-1           - Record HDMI-A-1 display in high quality\n      stop               - Stop current recording\n\n  record-gif <target>    Start/stop GIF recording\n    Targets:\n      selection           - Record selected area as GIF\n      eDP-1              - Record eDP-1 display as GIF\n      HDMI-A-1           - Record HDMI-A-1 display as GIF\n      stop               - Stop current recording\n\n  status                 Check if recording is active (exit 0 if recording, 1 if not)\n\n  convert <format> [file] Convert recordings\n    Formats:\n      webm               - Convert latest MKV to WebM (or specify file)\n      iphone             - Convert latest MKV to iPhone format (or specify file)\n      youtube            - Convert latest video to YouTube format (or specify file)\n      gif                - Convert latest video to GIF (or specify file)\n\n    Optional [file] parameter:\n      - If not provided, converts the latest recorded video\n      - If provided, converts the specified file (full path or filename in Recordings folder)\n\nExamples:\n  $SCRIPT_NAME screenshot selection\n  $SCRIPT_NAME record eDP-1\n  $SCRIPT_NAME record-noaudio selection\n  $SCRIPT_NAME record-hq eDP-1\n  $SCRIPT_NAME record-gif selection\n  $SCRIPT_NAME record stop\n  $SCRIPT_NAME convert gif                    # Convert latest video to GIF\n  $SCRIPT_NAME convert youtube               # Convert latest video for YouTube\n  $SCRIPT_NAME convert webm /path/to/video.mkv  # Convert specific file to WebM\n\nEOF\n\texit 1\n}\n\n# Check if no arguments provided\nif [ $# -eq 0 ]; then\n\tusage\nfi\n\n# Function to send screenshot notification with action buttons\nsend_screenshot_notification() {\n\tlocal full_path=\"$1\"\n\tlocal save_dir=$(dirname \"$full_path\")\n\n\tACTION=$(notify-send -a \"Modus\" -i \"$full_path\" \"Screenshot saved\" \"in $full_path\" \\\n\t\t-A \"view=View\" -A \"edit=Edit\" -A \"open=Open Folder\")\n\n\tcase \"$ACTION\" in\n\t\tview) xdg-open \"$full_path\" ;;\n\t\tedit) swappy -f \"$full_path\" ;;\n\t\topen) xdg-open \"$save_dir\" ;;\n\tesac\n}\n\n# Function to send recording notification with action buttons\nsend_recording_notification() {\n\tlocal full_path=\"$1\"\n\tlocal save_dir=$(dirname \"$full_path\")\n\n\tACTION=$(notify-send -a \"Modus\" -i \"camera-video-symbolic\" \"Recording saved\" \"in $full_path\" \\\n\t\t-A \"view=View\" -A \"open=Open Folder\")\n\n\tcase \"$ACTION\" in\n\t\tview) xdg-open \"$full_path\" ;;\n\t\topen) xdg-open \"$save_dir\" ;;\n\tesac\n}\n\nwf-recorder_check() {\n\tif pgrep -x \"wf-recorder\" >/dev/null; then\n\t\tpkill -INT -x wf-recorder\n\t\t# Get the recording file path and send notification with actions\n\t\tif [ -f /tmp/recording.txt ]; then\n\t\t\trecording_file=$(cat /tmp/recording.txt)\n\t\t\twl-copy <\"$recording_file\"\n\t\t\tsend_recording_notification \"$recording_file\"\n\t\telse\n\t\t\tnotify-send \"Recording stopped\" \"wf-recorder process terminated\"\n\t\tfi\n\t\t# Clean up recording start time file\n\t\trm -f /tmp/recording_start_time.txt\n\t\texit 0\n\tfi\n}\n\n# Function to record with standard settings\nrecord_video() {\n\tlocal output_file=\"$1\"\n\tshift\n\n\twf-recorder \"$@\" -f \"$output_file\" -c libvpx-vp9 --pixel-format yuv420p -F \"eq=brightness=0.12:contrast=1.1\"\n}\n\n# Function to record without audio\nrecord_video_noaudio() {\n\tlocal output_file=\"$1\"\n\tshift\n\n\twf-recorder \"$@\" -f \"$output_file\" -c libvpx-vp9 --pixel-format yuv420p -F \"eq=brightness=0.12:contrast=1.1\" --no-audio\n}\n\nrecord_high_quality() {\n\tlocal output_file=\"$1\"\n\tshift\n\n\t# High quality settings for YouTube uploads\n\t# - h264_vaapi for hardware encoding (if available) or libx264 for software\n\t# - yuv420p pixel format for maximum compatibility\n\t# - High bitrate (8000k) for quality\n\t# - GOP size of 30 for better seeking\n\t# - Preset 'slow' for better compression efficiency\n\t# - CRF 18 for high quality (lower = better quality, 0-51 scale)\n\t# - Audio at 192k bitrate\n\t# - 60 FPS for smooth motion\n\t# - No color filters to maintain original colors\n\n\t# Check if VAAPI hardware encoding is available\n\tif vainfo &>/dev/null && wf-recorder --help | grep -q \"h264_vaapi\"; then\n\t\t# Use hardware encoding for better performance\n\t\twf-recorder \"$@\" -f \"$output_file\" \\\n\t\t\t-c h264_vaapi \\\n\t\t\t-p \"preset=slow\" \\\n\t\t\t-p \"crf=18\" \\\n\t\t\t-r 60 \\\n\t\t\t-b 8000000 \\\n\t\t\t-B 192000 \\\n\t\t\t--pixel-format yuv420p \\\n\t\t\t-g 30\n\telse\n\t\t# Fallback to software encoding\n\t\twf-recorder \"$@\" -f \"$output_file\" \\\n\t\t\t-c libx264 \\\n\t\t\t-p \"preset=slow\" \\\n\t\t\t-p \"crf=18\" \\\n\t\t\t-r 60 \\\n\t\t\t-b 8000000 \\\n\t\t\t-B 192000 \\\n\t\t\t--pixel-format yuv420p \\\n\t\t\t-g 30\n\tfi\n}\n\nrecord_gif() {\n\tlocal output_file=\"$1\"\n\tshift\n\n\t# Record temporary video first (MKV format for better quality)\n\tlocal temp_video=\"/tmp/gif_recording_$(date +%s).mkv\"\n\techo \"$temp_video\" >/tmp/gif_temp_video.txt\n\n\t# GIF-optimized recording settings:\n\t# - Lower framerate (15 fps) for smaller file size\n\t# - No audio recording\n\t# - Standard codec for compatibility\n\twf-recorder \"$@\" -f \"$temp_video\" \\\n\t\t-c libvpx-vp9 \\\n\t\t-r 15 \\\n\t\t--pixel-format yuv420p \\\n\t\t--no-audio\n\n\t# After recording stops, convert to GIF\n\tif [ -f \"$temp_video\" ]; then\n\t\tnotify-send \"Converting to GIF\" \"Processing recording...\"\n\n\t\t# Create high-quality GIF with optimized palette\n\t\t# Using ffmpeg with palette generation for better colors\n\t\tffmpeg -i \"$temp_video\" \\\n\t\t\t-vf \"fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle\" \\\n\t\t\t-loop 0 \\\n\t\t\t\"$output_file\" 2>/tmp/gif_conversion.log\n\n\t\tif [ $? -eq 0 ]; then\n\t\t\t# Clean up temp file\n\t\t\trm -f \"$temp_video\"\n\t\t\trm -f /tmp/gif_temp_video.txt\n\n\t\t\t# Copy to clipboard and send notification with actions\n\t\t\twl-copy <\"$output_file\"\n\t\t\tsend_recording_notification \"$output_file\"\n\t\telse\n\t\t\terror=$(cat /tmp/gif_conversion.log | tail -n 5)\n\t\t\tnotify-send \"GIF Conversion Failed\" \"Error: $error\"\n\t\t\trm -f \"$temp_video\"\n\t\t\trm -f /tmp/gif_temp_video.txt\n\t\tfi\n\tfi\n}\n\n# Function to find the latest video file for conversion\nfind_latest_video() {\n\tlocal format=\"$1\"\n\tlocal recording_dir=\"${HOME}/Videos/Recordings\"\n\n\tcase \"$format\" in\n\t\t\"webm\"|\"iphone\")\n\t\t\t# For webm and iphone, only look for MKV files\n\t\t\tfind \"$recording_dir\" -name \"*.mkv\" -type f -printf '%T@ %p\\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2-\n\t\t\t;;\n\t\t\"youtube\"|\"gif\")\n\t\t\t# For youtube and gif, look for both MKV and MP4 files\n\t\t\tfind \"$recording_dir\" \\( -name \"*.mkv\" -o -name \"*.mp4\" \\) -type f -printf '%T@ %p\\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2-\n\t\t\t;;\n\t\t*)\n\t\t\techo \"\"\n\t\t\t;;\n\tesac\n}\n\n# Function to resolve video file path\nresolve_video_file() {\n\tlocal format=\"$1\"\n\tlocal file_param=\"$2\"\n\tlocal recording_dir=\"${HOME}/Videos/Recordings\"\n\n\tif [ -n \"$file_param\" ]; then\n\t\t# File parameter provided\n\t\tif [ -f \"$file_param\" ]; then\n\t\t\t# Full path provided and exists\n\t\t\techo \"$file_param\"\n\t\telif [ -f \"$recording_dir/$file_param\" ]; then\n\t\t\t# Filename provided, exists in recordings folder\n\t\t\techo \"$recording_dir/$file_param\"\n\t\telse\n\t\t\techo \"\"\n\t\tfi\n\telse\n\t\t# No file parameter, find latest\n\t\tfind_latest_video \"$format\"\n\tfi\n}\n\n# Parse command\nCOMMAND=\"$1\"\nTARGET=\"$2\"\nFILE_PARAM=\"$3\"  # Optional file parameter for convert command\n\n# Set up file paths\nIMG=\"${HOME}/Pictures/Screenshots/$(date +%Y-%m-%d_%H-%m-%s).png\"\nVID=\"${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).mkv\"\n\ncase \"$COMMAND\" in\n\"screenshot\")\n\tcase \"$TARGET\" in\n\t\"selection\")\n\t\tgrim -g \"$(slurp)\" \"$IMG\"\n\t\twl-copy <\"$IMG\"\n\t\tsend_screenshot_notification \"$IMG\"\n\t\t;;\n\t\"eDP-1\")\n\t\tgrim -c -o eDP-1 \"$IMG\"\n\t\twl-copy <\"$IMG\"\n\t\tsend_screenshot_notification \"$IMG\"\n\t\t;;\n\t\"HDMI-A-1\")\n\t\tgrim -c -o HDMI-A-1 \"$IMG\"\n\t\twl-copy <\"$IMG\"\n\t\tsend_screenshot_notification \"$IMG\"\n\t\t;;\n\t\"both\")\n\t\tgrim -c -o eDP-1 \"${IMG//.png/-eDP-1.png}\"\n\t\tgrim -c -o HDMI-A-1 \"${IMG//.png/-HDMI-A-1.png}\"\n\t\tmontage \"${IMG//.png/-eDP-1.png}\" \"${IMG//.png/-HDMI-A-1.png}\" -tile 2x1 -geometry +0+0 \"$IMG\"\n\t\twl-copy <\"$IMG\"\n\t\trm \"${IMG//.png/-eDP-1.png}\" \"${IMG//.png/-HDMI-A-1.png}\"\n\t\tsend_screenshot_notification \"$IMG\"\n\t\t;;\n\t*)\n\t\techo \"Error: Invalid screenshot target '$TARGET'\"\n\t\tusage\n\t\t;;\n\tesac\n\t;;\n\n\"record\")\n\tcase \"$TARGET\" in\n\t\"stop\")\n\t\twf-recorder_check\n\t\t;;\n\t\"selection\")\n\t\twf-recorder_check\n\t\techo \"$VID\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\trecord_video \"$VID\" -g \"$(slurp)\"\n\t\t;;\n\t\"eDP-1\")\n\t\twf-recorder_check\n\t\techo \"$VID\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\trecord_video \"$VID\" -a -o eDP-1\n\t\t;;\n\t\"HDMI-A-1\")\n\t\twf-recorder_check\n\t\techo \"$VID\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\trecord_video \"$VID\" -a -o HDMI-A-1\n\t\t;;\n\t*)\n\t\techo \"Error: Invalid record target '$TARGET'\"\n\t\tusage\n\t\t;;\n\tesac\n\t;;\n\n\"record-noaudio\")\n\tcase \"$TARGET\" in\n\t\"stop\")\n\t\twf-recorder_check\n\t\t;;\n\t\"selection\")\n\t\twf-recorder_check\n\t\techo \"$VID\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\trecord_video_noaudio \"$VID\" -g \"$(slurp)\"\n\t\t;;\n\t\"eDP-1\")\n\t\twf-recorder_check\n\t\techo \"$VID\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\trecord_video_noaudio \"$VID\" -o eDP-1\n\t\t;;\n\t\"HDMI-A-1\")\n\t\twf-recorder_check\n\t\techo \"$VID\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\trecord_video_noaudio \"$VID\" -o HDMI-A-1\n\t\t;;\n\t*)\n\t\techo \"Error: Invalid record-noaudio target '$TARGET'\"\n\t\tusage\n\t\t;;\n\tesac\n\t;;\n\n\"record-hq\")\n\t# Change file extension to mp4 for high quality recordings\n\tVID_HQ=\"${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s)-hq.mp4\"\n\n\tcase \"$TARGET\" in\n\t\"stop\")\n\t\twf-recorder_check\n\t\t;;\n\t\"selection\")\n\t\twf-recorder_check\n\t\techo \"$VID_HQ\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\tnotify-send \"High Quality Recording\" \"Starting YouTube-quality recording...\"\n\t\trecord_high_quality \"$VID_HQ\" -g \"$(slurp)\"\n\t\t;;\n\t\"eDP-1\")\n\t\twf-recorder_check\n\t\techo \"$VID_HQ\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\tnotify-send \"High Quality Recording\" \"Starting YouTube-quality recording on eDP-1...\"\n\t\trecord_high_quality \"$VID_HQ\" -a -o eDP-1\n\t\t;;\n\t\"HDMI-A-1\")\n\t\twf-recorder_check\n\t\techo \"$VID_HQ\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\tnotify-send \"High Quality Recording\" \"Starting YouTube-quality recording on HDMI-A-1...\"\n\t\trecord_high_quality \"$VID_HQ\" -a -o HDMI-A-1\n\t\t;;\n\t*)\n\t\techo \"Error: Invalid record-hq target '$TARGET'\"\n\t\tusage\n\t\t;;\n\tesac\n\t;;\n\n\"record-gif\")\n\t# GIF files go to a specific location\n\tGIF=\"${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).gif\"\n\n\tcase \"$TARGET\" in\n\t\"stop\")\n\t\twf-recorder_check\n\t\t;;\n\t\"selection\")\n\t\twf-recorder_check\n\t\techo \"$GIF\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\tnotify-send \"GIF Recording\" \"Starting GIF recording (15 FPS)...\"\n\t\trecord_gif \"$GIF\" -g \"$(slurp)\"\n\t\t;;\n\t\"eDP-1\")\n\t\twf-recorder_check\n\t\techo \"$GIF\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\tnotify-send \"GIF Recording\" \"Starting GIF recording on eDP-1...\"\n\t\trecord_gif \"$GIF\" -o eDP-1\n\t\t;;\n\t\"HDMI-A-1\")\n\t\twf-recorder_check\n\t\techo \"$GIF\" >/tmp/recording.txt\n\t\tdate +%s >/tmp/recording_start_time.txt\n\t\tnotify-send \"GIF Recording\" \"Starting GIF recording on HDMI-A-1...\"\n\t\trecord_gif \"$GIF\" -o HDMI-A-1\n\t\t;;\n\t*)\n\t\techo \"Error: Invalid record-gif target '$TARGET'\"\n\t\tusage\n\t\t;;\n\tesac\n\t;;\n\n\"status\")\n\t# Check if wf-recorder is running\n\tif pgrep -x \"wf-recorder\" >/dev/null; then\n\t\techo \"true\"\n\t\texit 0\n\telse\n\t\techo \"false\"\n\t\texit 0\n\tfi\n\t;;\n\n\"convert\")\n\tcase \"$TARGET\" in\n\t\"webm\")\n\t\t# Check if ffmpeg is installed\n\t\tif ! command -v ffmpeg >/dev/null 2>&1; then\n\t\t\tnotify-send \"Error\" \"ffmpeg is not installed. Please install it to use this feature.\"\n\t\t\texit 1\n\t\tfi\n\n\t\t# Resolve the video file to convert\n\t\tvideo_file=$(resolve_video_file \"webm\" \"$FILE_PARAM\")\n\n\t\tif [ -z \"$video_file\" ] || [ ! -f \"$video_file\" ]; then\n\t\t\tif [ -n \"$FILE_PARAM\" ]; then\n\t\t\t\tnotify-send \"WebM Conversion Error\" \"File not found: $FILE_PARAM\"\n\t\t\telse\n\t\t\t\tnotify-send \"WebM Conversion Error\" \"No MKV files found in Recordings folder\"\n\t\t\tfi\n\t\t\texit 1\n\t\tfi\n\n\t\t# Ensure it's an MKV file\n\t\tif [[ \"$video_file\" != *.mkv ]]; then\n\t\t\tnotify-send \"WebM Conversion Error\" \"Only MKV files can be converted to WebM. Found: $(basename \"$video_file\")\"\n\t\t\texit 1\n\t\tfi\n\n\t\twebm_file=\"${video_file%.mkv}.webm\"\n\n\t\t# Check if webm version doesn't already exist\n\t\tif [ -f \"$webm_file\" ]; then\n\t\t\tnotify-send \"WebM Conversion Skipped\" \"WebM version already exists: $(basename \"$webm_file\")\"\n\t\t\texit 0\n\t\tfi\n\n\t\tnotify-send \"Converting to WebM\" \"Processing: $(basename \"$video_file\")\"\n\n\t\t# Convert the file\n\t\tffmpeg -y -i \"$video_file\" -c:v libvpx -b:v 1M -c:a libvorbis \"$webm_file\" 2>/tmp/ffmpeg_error.log\n\n\t\tif [ $? -eq 0 ]; then\n\t\t\tfile_size=$(du -h \"$webm_file\" | cut -f1)\n\t\t\tnotify-send \"WebM Conversion Success\" \"$(basename \"$video_file\") → $(basename \"$webm_file\") ($file_size)\"\n\t\telse\n\t\t\terror=$(cat /tmp/ffmpeg_error.log | tail -n 5)\n\t\t\tnotify-send \"WebM Conversion Failed\" \"Error: $error\"\n\t\tfi\n\t\t;;\n\n\t\"iphone\")\n\t\t# Check if ffmpeg is installed\n\t\tif ! command -v ffmpeg >/dev/null 2>&1; then\n\t\t\tnotify-send \"Error\" \"ffmpeg is not installed. Please install it to use this feature.\"\n\t\t\texit 1\n\t\tfi\n\n\t\t# Resolve the video file to convert\n\t\tvideo_file=$(resolve_video_file \"iphone\" \"$FILE_PARAM\")\n\n\t\tif [ -z \"$video_file\" ] || [ ! -f \"$video_file\" ]; then\n\t\t\tif [ -n \"$FILE_PARAM\" ]; then\n\t\t\t\tnotify-send \"iPhone Conversion Error\" \"File not found: $FILE_PARAM\"\n\t\t\telse\n\t\t\t\tnotify-send \"iPhone Conversion Error\" \"No MKV files found in Recordings folder\"\n\t\t\tfi\n\t\t\texit 1\n\t\tfi\n\n\t\t# Ensure it's an MKV file\n\t\tif [[ \"$video_file\" != *.mkv ]]; then\n\t\t\tnotify-send \"iPhone Conversion Error\" \"Only MKV files can be converted for iPhone. Found: $(basename \"$video_file\")\"\n\t\t\texit 1\n\t\tfi\n\n\t\tbase_filename=$(basename \"$video_file\")\n\n\t\t# Skip files with \"iphone\" in the filename\n\t\tif [[ $base_filename == *\"iphone\"* ]]; then\n\t\t\tnotify-send \"iPhone Conversion Skipped\" \"File already appears to be iPhone format: $(basename \"$video_file\")\"\n\t\t\texit 0\n\t\tfi\n\n\t\tiphone_file=\"${video_file%.mkv}-iphone.mp4\"\n\n\t\t# Check if iPhone version doesn't already exist\n\t\tif [ -f \"$iphone_file\" ]; then\n\t\t\tnotify-send \"iPhone Conversion Skipped\" \"iPhone version already exists: $(basename \"$iphone_file\")\"\n\t\t\texit 0\n\t\tfi\n\n\t\tnotify-send \"Converting for iPhone\" \"Processing: $(basename \"$video_file\")\"\n\n\t\t# Convert the file\n\t\tffmpeg -y -i \"$video_file\" -vcodec h264 -acodec aac \"$iphone_file\" 2>/tmp/ffmpeg_error.log\n\n\t\tif [ $? -eq 0 ]; then\n\t\t\tfile_size=$(du -h \"$iphone_file\" | cut -f1)\n\t\t\tnotify-send \"iPhone Conversion Success\" \"$(basename \"$video_file\") → $(basename \"$iphone_file\") ($file_size)\"\n\t\telse\n\t\t\terror=$(cat /tmp/ffmpeg_error.log | tail -n 5)\n\t\t\tnotify-send \"iPhone Conversion Failed\" \"Error: $error\"\n\t\tfi\n\t\t;;\n\n\t\"youtube\")\n\t\t# Check if ffmpeg is installed\n\t\tif ! command -v ffmpeg >/dev/null 2>&1; then\n\t\t\tnotify-send \"Error\" \"ffmpeg is not installed. Please install it to use this feature.\"\n\t\t\texit 1\n\t\tfi\n\n\t\t# Resolve the video file to convert\n\t\tvideo_file=$(resolve_video_file \"youtube\" \"$FILE_PARAM\")\n\n\t\tif [ -z \"$video_file\" ] || [ ! -f \"$video_file\" ]; then\n\t\t\tif [ -n \"$FILE_PARAM\" ]; then\n\t\t\t\tnotify-send \"YouTube Conversion Error\" \"File not found: $FILE_PARAM\"\n\t\t\telse\n\t\t\t\tnotify-send \"YouTube Conversion Error\" \"No video files found in Recordings folder\"\n\t\t\tfi\n\t\t\texit 1\n\t\tfi\n\n\t\tbase_filename=$(basename \"$video_file\")\n\n\t\t# Skip files already marked as YouTube uploads\n\t\tif [[ $base_filename == *\"youtube\"* ]]; then\n\t\t\tnotify-send \"YouTube Conversion Skipped\" \"File already appears to be YouTube format: $(basename \"$video_file\")\"\n\t\t\texit 0\n\t\tfi\n\n\t\t# Create YouTube optimized filename\n\t\tyoutube_file=\"${video_file%.*}-youtube.mp4\"\n\n\t\t# Check if YouTube version doesn't already exist\n\t\tif [ -f \"$youtube_file\" ]; then\n\t\t\tnotify-send \"YouTube Conversion Skipped\" \"YouTube version already exists: $(basename \"$youtube_file\")\"\n\t\t\texit 0\n\t\tfi\n\n\t\tnotify-send \"Converting for YouTube\" \"Processing: $(basename \"$video_file\")\"\n\n\t\t# YouTube recommended settings:\n\t\t# - H.264 codec with High profile\n\t\t# - 1080p or source resolution\n\t\t# - 60fps or source framerate\n\t\t# - High bitrate for quality (8-12 Mbps for 1080p60)\n\t\t# - AAC audio at 384kbps\n\t\t# - yuv420p pixel format for compatibility\n\t\t# - Keyframe interval of 2 seconds (GOP)\n\t\t# - No filters to preserve original colors\n\n\t\tffmpeg -y -i \"$video_file\" \\\n\t\t\t-c:v libx264 \\\n\t\t\t-profile:v high \\\n\t\t\t-preset slow \\\n\t\t\t-crf 18 \\\n\t\t\t-pix_fmt yuv420p \\\n\t\t\t-c:a aac \\\n\t\t\t-b:a 384k \\\n\t\t\t-movflags +faststart \\\n\t\t\t\"$youtube_file\" 2>/tmp/ffmpeg_error.log\n\n\t\tif [ $? -eq 0 ]; then\n\t\t\tfile_size=$(du -h \"$youtube_file\" | cut -f1)\n\t\t\tnotify-send \"YouTube Conversion Success\" \"$(basename \"$video_file\") → $(basename \"$youtube_file\") ($file_size)\"\n\t\telse\n\t\t\terror=$(cat /tmp/ffmpeg_error.log | tail -n 5)\n\t\t\tnotify-send \"YouTube Conversion Failed\" \"Error converting $(basename \"$video_file\"): $error\"\n\t\tfi\n\t\t;;\n\n\t\"gif\")\n\t\t# Check if ffmpeg is installed\n\t\tif ! command -v ffmpeg >/dev/null 2>&1; then\n\t\t\tnotify-send \"Error\" \"ffmpeg is not installed. Please install it to use this feature.\"\n\t\t\texit 1\n\t\tfi\n\n\t\t# Resolve the video file to convert\n\t\tvideo_file=$(resolve_video_file \"gif\" \"$FILE_PARAM\")\n\n\t\tif [ -z \"$video_file\" ] || [ ! -f \"$video_file\" ]; then\n\t\t\tif [ -n \"$FILE_PARAM\" ]; then\n\t\t\t\tnotify-send \"GIF Conversion Error\" \"File not found: $FILE_PARAM\"\n\t\t\telse\n\t\t\t\tnotify-send \"GIF Conversion Error\" \"No video files found in Recordings folder\"\n\t\t\tfi\n\t\t\texit 1\n\t\tfi\n\n\t\tbase_filename=$(basename \"$video_file\")\n\n\t\t# Skip files already GIFs\n\t\tif [[ $base_filename == *.gif ]]; then\n\t\t\tnotify-send \"GIF Conversion Skipped\" \"File is already a GIF: $(basename \"$video_file\")\"\n\t\t\texit 0\n\t\tfi\n\n\t\t# Create GIF filename\n\t\tgif_file=\"${video_file%.*}.gif\"\n\n\t\t# Check if GIF version doesn't already exist\n\t\tif [ -f \"$gif_file\" ]; then\n\t\t\tnotify-send \"GIF Conversion Skipped\" \"GIF version already exists: $(basename \"$gif_file\")\"\n\t\t\texit 0\n\t\tfi\n\n\t\tnotify-send \"Converting to GIF\" \"Processing: $(basename \"$video_file\")\"\n\n\t\t# Get video dimensions for scaling\n\t\twidth=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=s=x:p=0 \"$video_file\")\n\n\t\t# Scale down if wider than 800px to keep file size reasonable\n\t\tif [ \"$width\" -gt 800 ]; then\n\t\t\tscale_filter=\"scale=800:-1:flags=lanczos,\"\n\t\telse\n\t\t\tscale_filter=\"\"\n\t\tfi\n\n\t\t# Create high-quality GIF with optimized palette\n\t\tffmpeg -i \"$video_file\" \\\n\t\t\t-vf \"${scale_filter}fps=15,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle\" \\\n\t\t\t-loop 0 \\\n\t\t\t\"$gif_file\" 2>/tmp/ffmpeg_error.log\n\n\t\tif [ $? -eq 0 ]; then\n\t\t\tfile_size=$(du -h \"$gif_file\" | cut -f1)\n\t\t\tnotify-send \"GIF Conversion Success\" \"$(basename \"$video_file\") → $(basename \"$gif_file\") ($file_size)\"\n\t\t\tsend_recording_notification \"$gif_file\"\n\t\telse\n\t\t\terror=$(cat /tmp/ffmpeg_error.log | tail -n 5)\n\t\t\tnotify-send \"GIF Conversion Failed\" \"Error converting $(basename \"$video_file\"): $error\"\n\t\tfi\n\t\t;;\n\n\t*)\n\t\techo \"Error: Invalid convert format '$TARGET'\"\n\t\tusage\n\t\t;;\n\tesac\n\t;;\n\n*)\n\techo \"Error: Invalid command '$COMMAND'\"\n\tusage\n\t;;\nesac\n"
  },
  {
    "path": "services/__init__.py",
    "content": "\"\"\"\nModus services package.\nContains background services and utilities for the shell.\n\"\"\"\n"
  },
  {
    "path": "services/auth.py",
    "content": "import json\nimport os\nimport subprocess\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\nfrom urllib.parse import parse_qs, urlparse\n\nimport pyotp\nfrom PIL import Image\nfrom pyzbar.pyzbar import decode\n\nimport config.data as data\n\n\ndef get_otp_file_path():\n    \"\"\"Returns the path to the OTP file inside the cache directory.\"\"\"\n    cache_dir = Path(data.CACHE_DIR) / \"otp\"\n    # Create the directory if it doesn't exist\n    cache_dir.mkdir(parents=True, exist_ok=True)\n\n    file_path = cache_dir / \"otp_codes.json\"\n\n    # Create the file if it doesn't exist\n    if not file_path.exists():\n        with open(file_path, \"w\") as f:\n            json.dump([], f)\n        print(f\"File {file_path} created successfully!\")\n\n    return file_path\n\n\ndef capture_selected_area(filename=\"/tmp/screenshot.png\"):\n    \"\"\"\n    Uses slurp to let the user select an area of the screen,\n    then captures that area using grim.\n    \"\"\"\n    try:\n        # slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400)\n        result = subprocess.run([\"slurp\"], check=True, capture_output=True, text=True)\n        geometry = result.stdout.strip()\n        if not geometry:\n            print(\"No area selected with slurp.\")\n            return None\n    except subprocess.CalledProcessError as e:\n        print(\"Error selecting area with slurp:\", e)\n        return None\n\n    try:\n        # grim uses -g to capture a specific region\n        subprocess.run([\"grim\", \"-g\", geometry, filename], check=True)\n    except subprocess.CalledProcessError as e:\n        print(\"Error capturing screenshot with grim:\", e)\n        return None\n\n    return filename\n\n\ndef read_and_save_to_json():\n    # Get the full path to the JSON file\n    json_file = get_otp_file_path()\n\n    screenshot = capture_selected_area()\n    if screenshot is None:\n        print(\"Failed to capture the selected area.\")\n        return False\n\n    # Short delay to ensure the file is written\n    time.sleep(1)\n\n    # Open the captured image\n    try:\n        img = Image.open(screenshot)\n    except Exception as e:\n        print(\"Error opening the image:\", e)\n        return False\n\n    # Decode QR Code(s) from the image\n    decoded_objects = decode(img)\n    if not decoded_objects:\n        print(\"No QR Code detected in the selected area.\")\n        return False\n\n    results = []\n\n    for obj in decoded_objects:\n        data = obj.data.decode(\"utf-8\")\n        print(\"QR Code detected:\", data)\n\n        result_entry = {\"timestamp\": datetime.now().isoformat(), \"qr_data\": data}\n\n        if data.startswith(\"otpauth://\"):\n            parsed = urlparse(data)\n            query = parse_qs(parsed.query)\n\n            # Extract the secret properly\n            secret = query.get(\"secret\", [None])[0]\n            issuer_from_query = query.get(\"issuer\", [None])[0]\n\n            # Extract the label (path), which may contain issuer and account\n            label = parsed.path.lstrip(\"/\") if parsed.path else \"\"\n            account_name = label\n            issuer_from_path = None\n\n            if \":\" in label:\n                parts = label.split(\":\", 1)\n                issuer_from_path = parts[0]\n                account_name = parts[1]\n\n            # Prefer issuer from path if available, otherwise use from query\n            issuer = issuer_from_path or issuer_from_query\n\n            # Extract the period (default is 30 seconds)\n            period = int(query.get(\"period\", [\"30\"])[0])\n\n            # Create TOTP object with the correct interval\n            totp = pyotp.TOTP(secret, interval=period)\n            current_otp = totp.now()\n            print(f\"Generated OTP: {current_otp} (valid for {period} seconds)\")\n\n            result_entry.update(\n                {\n                    \"type\": \"otp\",\n                    \"secret\": secret,\n                    \"issuer\": issuer,\n                    \"account_name\": account_name,\n                }\n            )\n        else:\n            result_entry[\"type\"] = \"unknown\"\n            print(\"Unrecognized format. Expected a URI like otpauth://\")\n            return False\n\n        results.append(result_entry)\n\n    # Load existing data if the file exists\n    existing_data = []\n    if os.path.exists(json_file):\n        try:\n            with open(json_file, \"r\") as f:\n                existing_data = json.load(f)\n        except json.JSONDecodeError:\n            print(f\"Error reading the file {json_file}. Creating a new one.\")\n\n    # Append new results\n    existing_data.extend(results)\n\n    # Save to JSON file\n    with open(json_file, \"w\") as f:\n        json.dump(existing_data, f, indent=4)\n\n    print(f\"OTP data saved to {json_file}\")\n    return True\n\n\ndef CodeOTP(uri):\n    parsed = urlparse(uri)\n    query = parse_qs(parsed.query)\n    secret = query.get(\"secret\", [None])[0]\n\n    if secret is None:\n        return None\n    else:\n        totp = pyotp.TOTP(secret)\n        return totp.now()\n\n\n# TOTP/OTP utility functions\ndef generate_totp(secret: str) -> str:\n    \"\"\"Generate TOTP code from secret.\"\"\"\n    try:\n        return pyotp.TOTP(secret).now()\n    except Exception as e:\n        print(f\"Error generating TOTP: {e}\")\n        return None\n\n\ndef get_time_remaining() -> int:\n    \"\"\"Get seconds remaining until next token refresh.\"\"\"\n    return 30 - (int(time.time()) % 30)\n\n\ndef get_time_remaining_with_blink() -> str:\n    \"\"\"Get time remaining with blinking effect.\"\"\"\n    time_remaining = get_time_remaining()\n    current_second = int(time.time())\n    should_blink = current_second % 2 == 0\n\n    if should_blink:\n        return f\"<span alpha='30%'>{time_remaining}s</span>\"\n    else:\n        return f\"{time_remaining}s\"\n\n\ndef validate_base32_secret(secret: str) -> dict:\n    \"\"\"Validate and clean Base32 secret.\"\"\"\n    import base64\n    import re\n\n    try:\n        # Clean up the secret - remove spaces, dashes, and convert to uppercase\n        clean_secret = secret.replace(\" \", \"\").replace(\"-\", \"\").replace(\"_\", \"\").upper()\n\n        # Remove any non-base32 characters\n        clean_secret = re.sub(r\"[^A-Z2-7]\", \"\", clean_secret)\n\n        # Add padding if needed (Base32 requires padding to multiple of 8)\n        while len(clean_secret) % 8 != 0:\n            clean_secret += \"=\"\n\n        # Validate Base32 format\n        try:\n            base64.b32decode(clean_secret)\n        except Exception as e:\n            return {\"success\": False, \"error\": f\"Invalid Base32 secret: {str(e)}\"}\n\n        # Test if the secret can generate a valid TOTP\n        try:\n            test_totp = pyotp.TOTP(clean_secret)\n            test_code = test_totp.now()\n            if not test_code or len(test_code) != 6:\n                raise ValueError(\"Generated invalid TOTP code\")\n        except Exception as e:\n            return {\"success\": False, \"error\": f\"Cannot generate TOTP: {str(e)}\"}\n\n        return {\"success\": True, \"secret\": clean_secret}\n    except Exception as e:\n        return {\"success\": False, \"error\": f\"Unexpected error: {str(e)}\"}\n\n\ndef parse_otpauth_uri(uri: str, account_name: str = \"\") -> dict:\n    \"\"\"Parse otpauth URI and extract account information.\"\"\"\n    try:\n        parsed = urlparse(uri)\n        if parsed.scheme != \"otpauth\" or parsed.netloc != \"totp\":\n            return {\n                \"success\": False,\n                \"error\": \"Only otpauth://totp/ URIs are supported\",\n            }\n\n        if not account_name:\n            account_path = parsed.path.lstrip(\"/\")\n            if \":\" in account_path:\n                issuer, extracted_name = account_path.split(\":\", 1)\n                account_name = extracted_name\n            else:\n                account_name = account_path\n\n        params = parse_qs(parsed.query)\n        secret = params.get(\"secret\", [\"\"])[0]\n        issuer = params.get(\"issuer\", [\"\"])[0]\n        algorithm = params.get(\"algorithm\", [\"SHA1\"])[0]\n        digits = int(params.get(\"digits\", [\"6\"])[0])\n        period = int(params.get(\"period\", [\"30\"])[0])\n\n        if not secret:\n            return {\"success\": False, \"error\": \"No secret found in URI\"}\n\n        return {\n            \"success\": True,\n            \"account_name\": account_name,\n            \"secret\": secret,\n            \"issuer\": issuer,\n            \"algorithm\": algorithm,\n            \"digits\": digits,\n            \"period\": period,\n        }\n    except Exception as e:\n        return {\"success\": False, \"error\": f\"Error parsing otpauth URI: {str(e)}\"}\n\n\ndef scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:\n    \"\"\"Scan QR code and add OTP account to secrets file.\"\"\"\n    try:\n        # Capture QR code from screen\n        screenshot_path = capture_selected_area()\n        if not screenshot_path:\n            return {\"success\": False, \"error\": \"QR scan cancelled or failed\"}\n\n        # Decode QR code\n        try:\n            img = Image.open(screenshot_path)\n            decoded_objects = decode(img)\n\n            if not decoded_objects:\n                return {\n                    \"success\": False,\n                    \"error\": \"No QR code detected in selected area\",\n                }\n\n            # Process the first QR code found\n            qr_data = decoded_objects[0].data.decode(\"utf-8\")\n            print(f\"QR Code detected: {qr_data}\")\n\n            if qr_data.startswith(\"otpauth://\"):\n                # Parse otpauth URI\n                result = parse_otpauth_uri(qr_data, account_name)\n                if not result[\"success\"]:\n                    return result\n\n                # Load existing secrets\n                secrets = {}\n                if os.path.exists(secrets_file_path):\n                    try:\n                        with open(secrets_file_path, \"r\", encoding=\"utf-8\") as f:\n                            secrets = json.load(f)\n                    except Exception as e:\n                        print(f\"Error loading secrets: {e}\")\n\n                # Add new account\n                secrets[result[\"account_name\"]] = {\n                    \"secret\": result[\"secret\"],\n                    \"issuer\": result[\"issuer\"],\n                    \"algorithm\": result[\"algorithm\"],\n                    \"digits\": result[\"digits\"],\n                    \"period\": result[\"period\"],\n                }\n\n                # Save secrets\n                try:\n                    os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True)\n                    with open(secrets_file_path, \"w\", encoding=\"utf-8\") as f:\n                        json.dump(secrets, f, indent=2)\n                except Exception as e:\n                    return {\n                        \"success\": False,\n                        \"error\": f\"Error saving secrets: {str(e)}\",\n                    }\n\n                display_name = (\n                    f\"{result['issuer']} - {result['account_name']}\"\n                    if result[\"issuer\"]\n                    else result[\"account_name\"]\n                )\n                return {\n                    \"success\": True,\n                    \"account_name\": result[\"account_name\"],\n                    \"display_name\": display_name,\n                    \"message\": f\"Successfully added OTP account: {display_name}\",\n                }\n            else:\n                return {\"success\": False, \"error\": \"QR code is not an otpauth URI\"}\n\n        except Exception as e:\n            return {\"success\": False, \"error\": f\"Error processing QR code: {str(e)}\"}\n\n    except Exception as e:\n        return {\"success\": False, \"error\": f\"Error during QR scan: {str(e)}\"}\n"
  },
  {
    "path": "services/battery.py",
    "content": "import psutil\nfrom gi.repository import GLib\nfrom pydbus import SystemBus\n\nfrom fabric.core import Property, Service, Signal\n\nDeviceState = {\n    0: \"UNKNOWN\",\n    1: \"CHARGING\",\n    2: \"DISCHARGING\",\n    3: \"EMPTY\",\n    4: \"FULLY_CHARGED\",\n    5: \"PENDING_CHARGE\",\n    6: \"PENDING_DISCHARGE\",\n}\n\n\nclass Battery(Service):\n    @staticmethod\n    def seconds_to_hours_minutes(seconds):\n        hours = seconds // 3600\n        minutes = (seconds % 3600) // 60\n        return f\"{hours}h {minutes}m\" if hours else f\"{minutes}m\"\n\n    @staticmethod\n    def get_battery_icon_level(percentage):\n        \"\"\"Get battery icon level based on percentage\"\"\"\n        if percentage >= 90:\n            return \"100\"\n        elif percentage >= 80:\n            return \"090\"\n        elif percentage >= 70:\n            return \"080\"\n        elif percentage >= 60:\n            return \"070\"\n        elif percentage >= 50:\n            return \"060\"\n        elif percentage >= 40:\n            return \"050\"\n        elif percentage >= 30:\n            return \"040\"\n        elif percentage >= 20:\n            return \"030\"\n        elif percentage >= 10:\n            return \"020\"\n        else:\n            return \"010\"\n\n    @staticmethod\n    def get_battery_icon_file(percentage, is_charging, base_path=\"\"):\n        \"\"\"Get battery icon file path\"\"\"\n        level = Battery.get_battery_icon_level(percentage)\n        suffix = \"-charging\" if is_charging else \"\"\n        return f\"{base_path}battery/battery-{level}{suffix}.svg\"\n\n    @staticmethod\n    def get_profile_display_name(profile: str) -> str:\n        \"\"\"Get user-friendly display name for power profile\"\"\"\n        profile_names = {\n            \"power-saver\": \"Power Saver\",\n            \"powersave\": \"Power Saver\",\n            \"power_saver\": \"Power Saver\",\n            \"balanced\": \"Balanced\",\n            \"balance\": \"Balanced\",\n            \"performance\": \"Performance\",\n            \"performance-mode\": \"Performance\",\n        }\n        return profile_names.get(profile, profile.title())\n\n    @Signal\n    def changed(self) -> None: ...\n\n    @Signal\n    def profile_changed(self, value: str) -> None: ...\n\n    @Property(int, \"readable\")\n    def percentage(self):\n        if self._use_psutil_fallback:\n            if self._psutil_battery:\n                return int(self._psutil_battery.percent)\n            return 0\n        return int(self._battery.Percentage)\n\n    @Property(str, \"readable\")\n    def temperature(self):\n        if self._use_psutil_fallback:\n            return \"N/A\"  # psutil doesn't provide temperature\n        return (\n            f\"{self._battery.Temperature}°C\"\n            if hasattr(self._battery, \"Temperature\")\n            else \"N/A\"\n        )\n\n    @Property(str, \"readable\")\n    def time_to_empty(self):\n        if self._use_psutil_fallback:\n            if self._psutil_battery and hasattr(self._psutil_battery, \"secsleft\"):\n                return self.seconds_to_hours_minutes(self._psutil_battery.secsleft)\n            return \"N/A\"\n        return self.seconds_to_hours_minutes(getattr(self._battery, \"TimeToEmpty\", 0))\n\n    @Property(str, \"readable\")\n    def time_to_full(self):\n        if self._use_psutil_fallback:\n            return \"N/A\"  # psutil doesn't provide time to full\n        return self.seconds_to_hours_minutes(getattr(self._battery, \"TimeToFull\", 0))\n\n    @Property(str, \"readable\")\n    def icon_name(self):\n        if self._use_psutil_fallback:\n            return \"battery\"  # Generic icon name for psutil fallback\n        return self._battery.IconName\n\n    @Property(str, \"readable\")\n    def state(self):\n        if self._use_psutil_fallback:\n            if self._psutil_battery:\n                # psutil returns power_plugged boolean, convert to state\n                if self._psutil_battery.power_plugged:\n                    if self._psutil_battery.percent >= 100:\n                        return \"FULLY_CHARGED\"\n                    else:\n                        return \"CHARGING\"\n                else:\n                    return \"DISCHARGING\"\n            return \"UNKNOWN\"\n        return DeviceState.get(self._battery.State, \"UNKNOWN\")\n\n    @Property(str, \"readable\")\n    def capacity(self):\n        if self._use_psutil_fallback:\n            return \"N/A\"  # psutil doesn't provide capacity info\n        return f\"{int(self._battery.Capacity)}%\"\n\n    @Property(bool, \"readable\", default_value=False)\n    def is_present(self):\n        if self._use_psutil_fallback:\n            return self._psutil_battery is not None\n        return self._battery.IsPresent\n\n    @Property(str, \"readable\")\n    def power_profile(self):\n        if hasattr(self, \"_profile_proxy\") and self._profile_proxy:\n            try:\n                return self._profile_proxy.ActiveProfile\n            except Exception:\n                return None\n        return None\n\n    @Property(list, \"readable\")\n    def available_profiles(self):\n        if hasattr(self, \"_profile_proxy\") and self._profile_proxy:\n            try:\n                profiles = []\n                for p in self._profile_proxy.Profiles:\n                    if hasattr(p, \"Profile\"):\n                        profiles.append(p.Profile)\n                    elif isinstance(p, dict) and \"Profile\" in p:\n                        profiles.append(p[\"Profile\"])\n                    elif isinstance(p, str):\n                        profiles.append(p)\n                return profiles\n            except Exception:\n                return []\n        return []\n\n    def change_power_profile(self, profile: str) -> bool:\n        if not hasattr(self, \"_profile_proxy\") or not self._profile_proxy:\n            return False\n\n        # Get available profiles using the same logic as available_profiles property\n        available_profiles = []\n        try:\n            for p in self._profile_proxy.Profiles:\n                if hasattr(p, \"Profile\"):\n                    available_profiles.append(p.Profile)\n                elif isinstance(p, dict) and \"Profile\" in p:\n                    available_profiles.append(p[\"Profile\"])\n                elif isinstance(p, str):\n                    available_profiles.append(p)\n        except Exception:\n            return False\n\n        if profile not in available_profiles:\n            return False\n\n        try:\n            self._profile_proxy.ActiveProfile = profile\n            self.profile_changed.emit(profile)\n            self.changed.emit()\n            return True\n        except Exception:\n            return False\n\n    def __init__(self):\n        super().__init__()\n        self._bus = SystemBus()\n        self._use_psutil_fallback = False\n        self._psutil_battery = None\n        self._profile_proxy = None  # Initialize to None first\n\n        # Battery device\n        try:\n            self._battery = self._bus.get(\n                \"org.freedesktop.UPower\", \"/org/freedesktop/UPower/devices/battery_BAT0\"\n            )\n            self._battery.onPropertiesChanged = self.handle_battery_change\n        except Exception:\n            # Fallback to psutil if UPower is not available\n            self._use_psutil_fallback = True\n            try:\n                self._psutil_battery = psutil.sensors_battery()\n                if self._psutil_battery is None:\n                    return  # No battery found\n                # Start periodic updates for psutil fallback - increased interval\n                GLib.timeout_add_seconds(10, self._update_psutil_battery)\n            except Exception:\n                return  # psutil battery not available either\n\n        # PowerProfiles - Initialize after other attributes\n        try:\n            self._profile_proxy = self._bus.get(\n                \"net.hadess.PowerProfiles\", \"/net/hadess/PowerProfiles\"\n            )\n            # Use onPropertiesChanged for consistency with battery device\n            self._profile_proxy.onPropertiesChanged = (\n                lambda _, changed, __: self._handle_profile_props_changed(changed)\n            )\n        except Exception:\n            self._profile_proxy = None\n\n        self.changed.emit()\n\n    def _update_psutil_battery(self):\n        \"\"\"Update psutil battery data periodically\"\"\"\n        try:\n            self._psutil_battery = psutil.sensors_battery()\n            self.changed.emit()\n        except Exception:\n            pass  # Continue trying\n        return True  # Keep the timeout active\n\n    def _handle_profile_props_changed(self, changed):\n        \"\"\"Internal handler for property changes that processes only the changed properties\"\"\"\n        if \"ActiveProfile\" in changed:\n            new_profile = changed[\"ActiveProfile\"]\n            self.profile_changed.emit(new_profile)\n            self.changed.emit()\n\n    def handle_battery_change(self, iface, changed, invalidated):\n        self.changed.emit()\n"
  },
  {
    "path": "services/brightness.py",
    "content": "import os\n\nfrom gi.repository import GLib\nfrom loguru import logger\n\nfrom fabric.core.service import Property, Service, Signal\nfrom fabric.utils import exec_shell_command_async, monitor_file\n\n\ndef exec_brightnessctl_async(args: str):\n    exec_shell_command_async(f\"brightnessctl {args}\", lambda _: None)\n\n\n# Discover screen backlight device\ntry:\n    screen_device = os.listdir(\"/sys/class/backlight\")\n    screen_device = screen_device[0] if screen_device else \"\"\nexcept FileNotFoundError:\n    logger.error(\"No backlight devices found, brightness control disabled\")\n    screen_device = \"\"\n\n\nclass Brightness(Service):\n    \"\"\"Service to manage screen brightness levels.\"\"\"\n\n    instance = None\n\n    @staticmethod\n    def get_initial():\n        if Brightness.instance is None:\n            Brightness.instance = Brightness()\n\n        return Brightness.instance\n\n    @Signal\n    def screen(self, value: int) -> None:\n        \"\"\"Signal emitted when screen brightness changes.\"\"\"\n        # Implement as needed for your application\n\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs)\n\n        # Path for screen backlight control\n        self.screen_backlight_path = f\"/sys/class/backlight/{screen_device}\"\n\n        # Initialize maximum brightness level\n        self.max_screen = self.do_read_max_brightness(self.screen_backlight_path)\n\n        if screen_device == \"\":\n            return\n\n        # Monitor screen brightness file\n        self.screen_monitor = monitor_file(f\"{self.screen_backlight_path}/brightness\")\n\n        self.screen_monitor.connect(\n            \"changed\",\n            lambda _, file, *args: self.emit(\n                \"screen\",\n                round(int(file.load_bytes()[0].get_data())),\n            ),\n        )\n\n        # Log the initialization of the service\n        logger.info(f\"Brightness service initialized for device: {screen_device}\")\n\n    def do_read_max_brightness(self, path: str) -> int:\n        # Reads the maximum brightness value from the specified path.\n        max_brightness_path = os.path.join(path, \"max_brightness\")\n        if os.path.exists(max_brightness_path):\n            with open(max_brightness_path) as f:\n                return int(f.readline())\n        return -1  # Return -1 if file doesn't exist, indicating an error.\n\n    @Property(int, \"read-write\")\n    def screen_brightness(self) -> int:\n        # Property to get or set the screen brightness.\n        brightness_path = os.path.join(self.screen_backlight_path, \"brightness\")\n        if os.path.exists(brightness_path):\n            with open(brightness_path) as f:\n                return int(f.readline())\n        logger.warning(f\"Brightness file does not exist: {brightness_path}\")\n        return -1  # Return -1 if file doesn't exist, indicating error.\n\n    @screen_brightness.setter\n    def screen_brightness(self, value: int):\n        # Setter for screen brightness property.\n        if not (0 <= value <= self.max_screen):\n            value = max(0, min(value, self.max_screen))\n\n        try:\n            exec_brightnessctl_async(f\"--device '{screen_device}' set {value}\")\n            self.emit(\"screen\", int((value / self.max_screen) * 100))\n        except GLib.Error as e:\n            logger.error(f\"Error setting screen brightness: {e.message}\")\n        except Exception as e:\n            logger.exception(f\"Unexpected error setting screen brightness: {e}\")\n"
  },
  {
    "path": "services/custom_notification.py",
    "content": "# Standard library imports\nimport json\nimport os\nimport time\nfrom typing import List\n\n# Fabric imports\nimport gi\n\ngi.require_version(\"Gtk\", \"3.0\")\ngi.require_version(\"GdkPixbuf\", \"2.0\")\n\nfrom gi.repository import GdkPixbuf\n\nimport config.data as data\nfrom fabric.core.service import Property, Service, Signal\nfrom fabric.notifications import (\n    Notification,\n    NotificationAction,\n    NotificationImagePixmap,\n    Notifications,\n)\n\ngi.require_version(\"Gtk\", \"3.0\")\ngi.require_version(\"GdkPixbuf\", \"2.0\")\n\nNOTIFICATION_CACHE_FILE = f\"{data.CACHE_DIR}/notification_history.json\"\n\n\nclass CachedNotification(Service):\n    @classmethod\n    def create_from_dict(cls, data, **kwargs):\n        \"\"\"Create CachedNotification from enhanced JSON data\"\"\"\n        data[\"timeout\"] = 0\n        self = cls.__new__(cls)\n        Service.__init__(self, **kwargs)\n        self._notification = Notification.deserialize(data)\n        self._cache_id = data[\"cached-id\"]  # Set directly to private var\n        \n        # Store cache metadata for cleanup\n        self.cache_metadata = data.get(\"cache_metadata\", {})\n        self.timestamp = data.get(\"timestamp\", int(time.time()))\n        \n        return self\n\n    @Signal\n    def removed_from_cache(self) -> None: ...\n\n    @Property(int, \"readable\")\n    def cache_id(self) -> int:\n        return self._cache_id\n\n    @Property(str, \"readable\")\n    def app_name(self) -> str:\n        return self._notification.app_name\n\n    @Property(str, \"readable\")\n    def app_icon(self) -> str:\n        return self._notification.app_icon\n\n    @Property(str, \"readable\")\n    def summary(self) -> str:\n        return self._notification.summary\n\n    @Property(str, \"readable\")\n    def body(self) -> str:\n        return self._notification.body\n\n    @Property(int, \"readable\")\n    def id(self) -> int:\n        return self._notification.id\n\n    @Property(int, \"readable\")\n    def replaces_id(self) -> int:\n        return self._notification.replaces_id\n\n    @Property(int, \"readable\")\n    def urgency(self) -> int:\n        return self._notification.urgency\n\n    @Property(list[NotificationAction], \"readable\")\n    def actions(self) -> list[NotificationAction]:\n        return self._notification.actions\n\n    @Property(NotificationImagePixmap, \"readable\")\n    def image_pixmap(self) -> NotificationImagePixmap:\n        return self._notification.image_pixmap  # type: ignore\n\n    @Property(str, \"readable\")\n    def image_file(self) -> str:\n        return self._notification.image_file  # type: ignore\n\n    @Property(object, \"readable\")\n    def image_pixbuf(self) -> GdkPixbuf.Pixbuf | None:\n        try:\n            if self.image_pixmap:\n                return self.image_pixmap.as_pixbuf()\n            if self.image_file and os.path.exists(self.image_file):\n                try:\n                    return GdkPixbuf.Pixbuf.new_from_file(self.image_file)\n                except Exception:\n                    # If file can't be loaded, return None\n                    pass\n        except Exception:\n            # If any error occurs (including temp file gone), return None safely\n            pass\n        return None\n\n    @Property(dict, \"readable\")\n    def serialized(self) -> dict:\n        \"\"\"Enhanced serialization with cache metadata - stores only cache keys\"\"\"\n        from modules.notification.notification import (\n            get_cache_key,\n            get_notification_image_cache_key,\n        )\n        \n        # Get better cache keys for icons\n        app_icon_cache_key = None\n        notification_image_cache_key = None\n        \n        if self.app_icon:\n            app_icon_cache_key = get_cache_key(self.app_icon, (35, 35), self.app_name)\n        \n        # Only try to get notification image cache key if we can safely access image_pixbuf\n        if self.id:\n            try:\n                # First check if we already have the cache key stored\n                if hasattr(self, 'cache_metadata') and self.cache_metadata:\n                    notification_image_cache_key = self.cache_metadata.get('notification_image_cache_key')\n                \n                # If not, try to generate it safely\n                if not notification_image_cache_key and hasattr(self._notification, 'image_pixbuf'):\n                    try:\n                        # Check if image_pixbuf exists and can be accessed without loading from file\n                        image_pixbuf = getattr(self._notification, 'image_pixbuf', None)\n                        if image_pixbuf:\n                            notification_image_cache_key = get_notification_image_cache_key(\n                                self.id, image_pixbuf\n                            )\n                    except (AttributeError, OSError, Exception):\n                        # If temp file is gone or any other error, just mark as None\n                        pass\n            except Exception:\n                # If any error occurs during cache key generation, skip it\n                pass\n        \n        return {\n            \"cached-id\": self.cache_id,\n            \"id\": self.id,\n            \"replaces-id\": self.replaces_id,\n            \"app-name\": self.app_name,\n            \"app-icon\": self.app_icon,\n            \"summary\": self.summary,\n            \"body\": self.body,\n            \"urgency\": self.urgency,\n            \"actions\": [(action.identifier, action.label) for action in self.actions],\n            \"image-file\": self.image_file,\n            # Only store image-pixmap if no cache key is available (fallback)\n            \"image-pixmap\": None,  # Don't store image data, only cache key\n            \"timestamp\": int(time.time()),\n            \"group\": self.app_name,  # Group notifications by app name\n            # Enhanced cache metadata - store only cache keys\n            \"cache_metadata\": {\n                \"app_icon_cache_key\": app_icon_cache_key,\n                \"notification_image_cache_key\": notification_image_cache_key,\n                \"has_cached_image\": notification_image_cache_key is not None,\n                \"cache_timestamp\": int(time.time())\n            }\n        }\n\n    def __init__(self, notification: Notification, cache_id: int, **kwargs):\n        super().__init__()\n        self._notification: Notification = notification\n        self._cache_id = cache_id\n        self.cache_metadata = {}\n        self.timestamp = int(time.time())\n\n    def remove_from_cache(self):\n        self.removed_from_cache.emit()\n\n\nclass CachedNotifications(Notifications):\n    \"\"\"A service to manage the cached notifications.\"\"\"\n\n    @Signal\n    def clear_all(self) -> None:\n        \"\"\"Signal emitted when notifications are emptied.\"\"\"\n        pass\n\n    @Signal\n    def cached_notification_added(self, notification: CachedNotification) -> None:\n        \"\"\"Signal emitted when a notification is cached.\"\"\"\n        pass\n\n    @Signal\n    def cached_notification_removed(self, notification: CachedNotification) -> None:\n        \"\"\"Signal emitted when a notification is removed from cache.\"\"\"\n        pass\n\n    @Property(List[CachedNotification], \"readable\")\n    def cached_notifications(self) -> List[CachedNotification]:\n        \"\"\"Return the cached notifications.\"\"\"\n        return list(self._cached_notifications.values())\n\n    @Property(int, \"readable\")\n    def count(self) -> int:\n        \"\"\"Return the count of notifications.\"\"\"\n        return self._count\n\n    @Property(bool, \"read-write\", default_value=False)\n    def dont_disturb(self) -> bool:\n        \"\"\"Return the pause status.\"\"\"\n        return self._dont_disturb\n        \n    def set_dont_disturb(self, value: bool):\n        \"\"\"Set the pause status.\"\"\"\n        self._dont_disturb = value\n        self.notify(\"dont-disturb\")\n\n    def __init__(self, **kwargs):\n        super().__init__()\n        self._cached_notifications: dict[int, CachedNotification] = {}\n        self._signal_handlers = {}  # Store signal handlers by notification_id\n        self._dont_disturb = False\n        self._count = 0\n        self._next_cache_id = 1  # Track next available cache ID\n        self._session_start_time = int(time.time())  # Track session start time for deduplication\n\n        self.load_cached_notifications()\n        \n        # Connect to the notification_added signal to cache new notifications\n        # Note: self here refers to the CachedNotifications service, which inherits from Notifications\n        # So we connect to our own notification_added signal\n        super().notification_added.connect(self.on_notification_added)\n\n    def load_cached_notifications(self) -> dict[int, CachedNotification]:\n        \"\"\"Load cached notifications from a JSON file (deserialization).\"\"\"\n        try:\n            with open(NOTIFICATION_CACHE_FILE, \"r\") as file:\n                data = json.load(file)  # Load list of serialized notifications\n        except (FileNotFoundError, json.JSONDecodeError):\n            # If file doesn't exist or is corrupted, start with empty list\n            data = []\n\n        max_cache_id = 0\n        for notification in data:\n            cached_notification = CachedNotification.create_from_dict(notification)\n            cache_id = cached_notification.cache_id\n            max_cache_id = max(max_cache_id, cache_id)\n            \n            handler_id = cached_notification.connect(\n                \"removed-from-cache\",\n                lambda *args: self.remove_cached_notification(\n                    notification_id=cache_id\n                ),\n            )\n            self._signal_handlers[cache_id] = handler_id\n            self._cached_notifications[cache_id] = cached_notification\n            self._count += 1\n\n        # Set next cache ID to be higher than any existing ID\n        self._next_cache_id = max_cache_id + 1\n        self.notify(\"count\")\n        return self._cached_notifications\n\n    def cache_notifications(self) -> None:\n        \"\"\"Save cached notifications to a JSON file.\"\"\"\n        # Ensure cache directory exists\n        os.makedirs(os.path.dirname(NOTIFICATION_CACHE_FILE), exist_ok=True)\n\n        serialized_data = [\n            notif.serialized for notif in self._cached_notifications.values()\n        ]  # Convert to serializable format\n        with open(NOTIFICATION_CACHE_FILE, \"w\") as file:\n            json.dump(serialized_data, file, indent=4)\n\n    def clear_all_cached_notifications(self):\n        \"\"\"Empty the notifications with enhanced cache cleanup\"\"\"\n        # Clean up all cached files before clearing notifications\n        from modules.notification.notification import cleanup_all_notification_caches\n        \n        for cached_notification in self._cached_notifications.values():\n            handler_id = self._signal_handlers.pop(cached_notification.cache_id, None)\n            if handler_id:\n                cached_notification.disconnect(handler_id)\n                \n        # Clear all notification caches (icons and images)\n        cleanup_all_notification_caches()\n        \n        self._cached_notifications = {}\n        self.cache_notifications()\n        self._count = 0\n        self._next_cache_id = 1  # Reset cache ID counter\n        self.notify(\"count\")\n        self.clear_all.emit()\n\n    def on_notification_added(self, service, notification_id: int) -> None:\n        \"\"\"Handle notification added and cache it with enhanced metadata - GUARANTEED STORAGE\"\"\"\n        # Don't call super() - we're handling this ourselves\n        \n        # Import logger at the top of the function\n        from loguru import logger\n        \n        notification = self.get_notification_from_id(notification_id)\n\n        if not notification:\n            logger.error(f\"CRITICAL: Failed to get notification with ID {notification_id}\")\n            return\n\n        # Import here to avoid circular imports\n        from config import data\n        from modules.notification.notification import (\n            preload_notification_assets,\n            cache_notification_icon,\n            cache_notification_image,\n            get_cache_key,\n            get_notification_image_cache_key\n        )\n\n        # Check if this app should be ignored for history (don't cache)\n        if notification.app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:\n            # Don't cache notifications from ignored apps, but still allow popup display\n            logger.debug(f\"Ignoring notification from {notification.app_name} (in ignore list)\")\n            return\n\n        # Check for duplicates using both notification ID and timestamp to avoid session restart issues\n        existing_notification = None\n        current_time = int(time.time())\n        \n        for cached_notif in self._cached_notifications.values():\n            # Only consider it a duplicate if:\n            # 1. Same notification ID AND\n            # 2. Notification was cached in the current session (after session start time) AND  \n            # 3. Notification was cached recently (within last 5 minutes)\n            cached_time = getattr(cached_notif, 'timestamp', 0)\n            is_recent = (current_time - cached_time) < 300  # 5 minutes\n            is_current_session = cached_time >= self._session_start_time\n            \n            if (cached_notif._notification.id == notification.id and \n                is_current_session and is_recent):\n                existing_notification = cached_notif\n                break\n        \n        if existing_notification:\n            logger.debug(f\"Notification ID {notification.id} already cached in current session, skipping\")\n            return\n\n        logger.debug(f\"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}...\")\n\n        # GUARANTEED STORAGE: Always create and store notification to history first\n        cache_id = self._next_cache_id\n        self._next_cache_id += 1\n        self._count += 1\n\n        cached_notification = CachedNotification(\n            notification=notification, cache_id=cache_id\n        )\n        # Set cache_id directly since it's read-only property  \n        cached_notification._cache_id = cache_id\n        \n        # Initialize cache metadata (will be populated below)\n        cached_notification.cache_metadata = {\n            \"app_icon_cache_key\": None,\n            \"notification_image_cache_key\": None,\n            \"has_cached_image\": False,\n            \"cache_timestamp\": int(time.time())\n        }\n        \n        # IMMEDIATELY store to history before attempting any caching operations\n        handler_id = cached_notification.connect(\n            \"removed-from-cache\",\n            lambda *args: self.remove_cached_notification(notification_id=cache_id),\n        )\n        self._signal_handlers[cache_id] = handler_id\n        self._cached_notifications[cache_id] = cached_notification\n        \n        # Save to JSON file immediately - GUARANTEED STORAGE\n        try:\n            self.cache_notifications()\n            logger.debug(f\"GUARANTEED: Notification {cache_id} stored to history\")\n        except Exception as e:\n            logger.error(f\"CRITICAL: Failed to save notification {cache_id} to history: {e}\")\n        \n        # Now attempt asset caching (failures here won't affect history storage)\n        try:\n            # Preload assets and store cache metadata\n            preload_notification_assets(notification)\n            \n            # Store enhanced cache metadata\n            app_icon_cache_key = None\n            notification_image_cache_key = None\n            \n            if notification.app_icon:\n                try:\n                    # Only cache at 35x35 to reduce disk usage - headers will scale this down\n                    app_icon_cache_key = get_cache_key(notification.app_icon, (35, 35), notification.app_name)\n                    cache_notification_icon(notification.app_icon, (35, 35), notification.app_name)\n                    cached_notification.cache_metadata[\"app_icon_cache_key\"] = app_icon_cache_key\n                    logger.debug(f\"Cached app icon (35x35) for notification {cache_id}\")\n                except Exception as e:\n                    logger.warning(f\"Failed to cache app icon for notification {cache_id}: {e}\")\n            \n            if hasattr(notification, 'image_pixbuf'):\n                try:\n                    # Safely try to access image_pixbuf\n                    image_pixbuf = getattr(notification, 'image_pixbuf', None)\n                    if image_pixbuf:\n                        notification_image_cache_key = get_notification_image_cache_key(\n                            notification.id, image_pixbuf\n                        )\n                        cache_notification_image(notification.id, image_pixbuf, (35, 35))\n                        cached_notification.cache_metadata[\"notification_image_cache_key\"] = notification_image_cache_key\n                        cached_notification.cache_metadata[\"has_cached_image\"] = True\n                        logger.debug(f\"Cached notification image for notification {cache_id}\")\n                except (AttributeError, OSError, Exception) as e:\n                    logger.warning(f\"Failed to cache notification image for notification {cache_id}: {e}\")\n            \n            # Update cached notification with final metadata\n            self._cached_notifications[cache_id] = cached_notification\n            \n            # Save updated metadata to JSON\n            self.cache_notifications()\n            \n        except Exception as e:\n            logger.error(f\"Asset caching failed for notification {cache_id}, but notification is still stored: {e}\")\n\n        # Always emit signals regardless of caching success\n        self.notify(\"count\")\n        self.emit(\"cached-notification-added\", cached_notification)\n        \n        logger.debug(f\"Successfully processed notification: Cache ID={cache_id}, Total cached={len(self._cached_notifications)}\")\n\n    def remove_cached_notification(self, notification_id: int):\n        \"\"\"Remove the notification of given id with enhanced cache cleanup\"\"\"\n        if notification_id in self._cached_notifications:\n            cached_notification = self._cached_notifications.pop(notification_id)\n            \n            # Enhanced cache cleanup using stored metadata\n            if hasattr(cached_notification, 'cache_metadata'):\n                cache_metadata = cached_notification.cache_metadata\n                \n                # Clean up specific cached files using stored keys\n                from modules.notification.notification import cleanup_notification_specific_caches\n                cleanup_notification_specific_caches(\n                    app_icon_source=cached_notification.app_icon,\n                    notification_image_cache_key=cache_metadata.get('notification_image_cache_key')\n                )\n            \n            self.cache_notifications()  # Update JSON\n            self._count -= 1\n            self.notify(\"count\")\n            \n            # Get the stored signal handler ID and disconnect it\n            handler_id = self._signal_handlers.pop(notification_id, None)\n            if handler_id:\n                cached_notification.disconnect(handler_id)\n\n            # Emit signal to notify UI that notification was removed\n            self.emit(\"cached-notification-removed\", cached_notification)\n\n    def toggle_dnd(self):\n        self.set_dont_disturb(not self.dont_disturb)\n"
  },
  {
    "path": "services/modus.py",
    "content": "import json\n\nfrom fabric.core.service import Property, Service, Signal\nfrom fabric.hyprland.service import Hyprland\nfrom loguru import logger\n\nfrom services.custom_notification import CachedNotifications\n\nnotification_service = CachedNotifications()\n\n\nclass ModusService(Service):\n    @Signal\n    def bluetooth_changed(self, new_bluetooth: str) -> None: ...\n\n    @Signal\n    def volume_changed(self, new_volume: int) -> None: ...\n\n    @Signal\n    def wlan_changed(self, new_wlan: str) -> None: ...\n\n    @Signal\n    def battery_changed(self, new_battery: str) -> None: ...\n\n    @Signal\n    def dock_apps_changed(self, new_dock_apps: str) -> None: ...\n\n    @Signal\n    def dont_disturb_changed(self, value: bool) -> None: ...\n\n    @Signal\n    def current_active_app_name_changed(self, value: str) -> None: ...\n\n    @Signal\n    def current_workspace_changed(self, value: str) -> None: ...\n\n    @Signal\n    def music_changed(self, value: str) -> None: ...\n\n    @Signal\n    def current_dropdown_changed(self, value: str) -> None: ...\n\n    @Signal\n    def dropdowns_hide_changed(self, value: bool) -> None: ...\n\n    @Signal\n    def dock_width_changed(self, value: int) -> None: ...\n\n    @Signal\n    def dock_height_changed(self, value: int) -> None: ...\n\n    @Signal\n    def dock_hidden_changed(self, value: bool) -> None: ...\n\n    @Signal\n    def show_notificationcenter_changed(self, value: bool) -> None: ...\n\n    @Signal\n    def notification_count_changed(self, value: int) -> None: ...\n\n    @Property(str, flags=\"read-write\")\n    def current_active_app_name(self) -> str:\n        return self._current_active_app_name\n\n    @Property(str, flags=\"read-write\")\n    def current_workspace(self) -> str:\n        return self._current_workspace\n\n    @Property(str, flags=\"read-write\")\n    def bluetooth(self) -> str:\n        return self._bluetooth\n\n    @Property(str, flags=\"read-write\")\n    def wlan(self) -> str:\n        return self._wlan\n\n    @Property(str, flags=\"read-write\")\n    def battery(self) -> str:\n        return self._battery\n\n    @Property(int, flags=\"read-write\")\n    def volume(self) -> int:\n        return self._volume\n\n    @Property(str, flags=\"read-write\")\n    def dock_apps(self) -> str:\n        return self._dock_apps\n\n    @Property(bool, flags=\"read-write\", default_value=False)\n    def dont_disturb(self) -> bool:\n        return self._dont_disturb\n\n    @Property(str, flags=\"read-write\")\n    def music(self) -> str:\n        return self._music\n\n    @Property(str, flags=\"read-write\")\n    def current_dropdown(self) -> str:\n        return self._current_dropdown\n\n    @Property(bool, flags=\"read-write\", default_value=False)\n    def dropdowns_hide(self) -> bool:\n        return self._dropdowns_hide\n\n    @Property(int, flags=\"read-write\")\n    def dock_width(self) -> int:\n        return self._dock_width\n\n    @Property(int, flags=\"read-write\")\n    def dock_height(self) -> int:\n        return self._dock_height\n\n    @Property(bool, flags=\"read-write\", default_value=False)\n    def dock_hidden(self) -> bool:\n        return self._dock_hidden\n\n    @Property(bool, flags=\"read-write\", default_value=False)\n    def show_notificationcenter(self) -> bool:\n        return self._show_notificationcenter\n\n    @current_active_app_name.setter\n    def current_active_app_name(self, value: str):\n        if value != self._current_active_app_name:\n            self._current_active_app_name = value\n            self.current_active_app_name_changed(value)\n\n    @current_workspace.setter\n    def current_workspace(self, value: str):\n        if value != self._current_workspace:\n            self._current_workspace = value\n            self.current_workspace_changed(value)\n\n    @volume.setter\n    def volume(self, value: int):\n        if value != self._volume:\n            self._name = value\n            self.volume_changed(value)\n\n    @wlan.setter\n    def wlan(self, value: str):\n        if value != self._wlan:\n            self._wlan = value\n            self.wlan_changed(value)\n\n    @battery.setter\n    def battery(self, value: str):\n        if value != self._battery:\n            self._battery = value\n            self.battery_changed(value)\n\n    @bluetooth.setter\n    def bluetooth(self, value: str):\n        if value != self._bluetooth:\n            self._bluetooth = value\n            self.bluetooth_changed(value)\n\n    @dock_apps.setter\n    def dock_apps(self, value: str):\n        if value != self._dock_apps:\n            self._dock_apps = value\n            self.dock_apps_changed(value)\n\n    @dont_disturb.setter\n    def dont_disturb(self, value: bool):\n        if value != self._dont_disturb:\n            self._dont_disturb = value\n            self.dont_disturb_changed(value)\n\n    @music.setter\n    def music(self, value: str):\n        if value != self._music:\n            self._music = value\n            self.music_changed(value)\n\n    @current_dropdown.setter\n    def current_dropdown(self, value: str):\n        if value != self._current_dropdown:\n            self._current_dropdown = value\n            self.current_dropdown_changed(value)\n\n    @dropdowns_hide.setter\n    def dropdowns_hide(self, value: bool):\n        if value != self._dropdowns_hide:\n            self._dropdowns_hide = value\n            self.dropdowns_hide_changed(value)\n\n    @dock_width.setter\n    def dock_width(self, value: int):\n        if value != self._dock_width:\n            self._dock_width = value\n            self.dock_width_changed(value)\n\n    @dock_height.setter\n    def dock_height(self, value: int):\n        if value != self._dock_height:\n            self._dock_height = value\n            self.dock_height_changed(value)\n\n    @dock_hidden.setter\n    def dock_hidden(self, value: bool):\n        if value != self._dock_hidden:\n            self._dock_hidden = value\n            self.dock_hidden_changed(value)\n\n    @show_notificationcenter.setter\n    def show_notificationcenter(self, value: bool):\n        if value != self._show_notificationcenter:\n            self._show_notificationcenter = value\n            self.show_notificationcenter_changed(value)\n\n    def sc(self, signal_name: str, callback: callable, def_value=\"...\"):\n        self.connect(signal_name, callback)\n        # Return current property value instead of default\n        if signal_name == \"bluetooth-changed\":\n            return self.bluetooth if self.bluetooth else \"Off\"\n        elif signal_name == \"wlan-changed\":\n            return self.wlan if self.wlan else \"No Connection\"\n        elif signal_name == \"battery-changed\":\n            return self.battery if self.battery else \"Unknown\"\n        elif signal_name == \"music-changed\":\n            return self.music if self.music else \"\"\n        else:\n            return def_value\n\n    def __init__(self):\n        super().__init__()\n        self._volume = 0\n        self._wlan = \"\"\n        self._battery = \"\"\n        self._bluetooth = \"\"\n        self._dock_apps = \"\"\n        self._dont_disturb = False\n        self._current_active_app_name = \"Finder\"  # Changed from \"Hyprland\" to \"Finder\"\n        self._current_workspace = \"1\"\n        self._music = \"\"\n        self._current_dropdown = None\n        self._dropdowns_hide = False\n        self._dock_hidden = False\n        self._show_notificationcenter = False\n\n        self._dock_width = 0\n        self._dock_height = 0\n\n        # Initialize Hyprland connection for workspace and window monitoring\n        self._setup_workspace_monitoring()\n        self._setup_active_window_monitoring()\n\n    def _setup_workspace_monitoring(self):\n        \"\"\"Setup Hyprland connection and workspace monitoring\"\"\"\n        try:\n            self._hyprland_connection = Hyprland()\n\n            # Get initial workspace\n            workspace_data = self._hyprland_connection.send_command(\n                \"j/activeworkspace\"\n            ).reply\n            active_workspace = json.loads(workspace_data.decode(\"utf-8\"))[\"name\"]\n            self._current_workspace = str(active_workspace)\n\n            # Connect to workspace change events\n            self._hyprland_connection.connect(\n                \"event::workspace\", self._on_workspace_changed\n            )\n\n        except Exception as e:\n            logger.error(f\"[ModusService] Failed to setup workspace monitoring: {e}\")\n            self._current_workspace = \"1\"\n\n    def _setup_active_window_monitoring(self):\n        \"\"\"Setup active window monitoring\"\"\"\n        try:\n            if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection:\n                return\n\n            # Get initial active window\n            self._update_active_window()\n\n            # Note: The HyprlandActiveWindow widget from Fabric library\n            # should handle active window updates automatically.\n            # We just need to ensure the initial state is correct.\n\n        except Exception as e:\n            logger.error(f\"[ModusService] Failed to setup active window monitoring: {e}\")\n\n    def _update_active_window(self):\n        \"\"\"Update the current active app name based on active window\"\"\"\n        try:\n            if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection:\n                return\n\n            window_data = self._hyprland_connection.send_command(\"j/activewindow\").reply\n            if not window_data:\n                self.current_active_app_name = \"Finder\"\n                return\n\n            window_info = json.loads(window_data.decode(\"utf-8\"))\n            wmclass = window_info.get(\"class\", \"\")\n            title = window_info.get(\"title\", \"\")\n\n            # Handle the case when there's no active window\n            if not title and not wmclass:\n                self.current_active_app_name = \"Finder\"\n                return\n\n            # Simple app name formatting without circular import\n            name = wmclass if wmclass else title\n            if name:\n                # Basic formatting: capitalize first letter and remove file extensions\n                name = str(name).title()\n                if \".\" in name:\n                    name = name.split(\".\")[-1]\n            else:\n                name = \"Finder\"\n\n            self.current_active_app_name = name\n\n        except Exception as e:\n            logger.error(f\"[ModusService] Error updating active window: {e}\")\n            self.current_active_app_name = \"Finder\"\n\n    def _on_workspace_changed(self, obj, signal):\n        \"\"\"Handle workspace change events from Hyprland\"\"\"\n        try:\n            workspace_name = json.loads(signal.data[0])\n            self.current_workspace = str(workspace_name)\n        except Exception as e:\n            logger.error(f\"[ModusService] Error processing workspace change: {e}\")\n\n    def remove_notification(self, id: int):\n        notification_service.remove_cached_notification(id)\n        self.notification_count_changed(self.notification_count)\n\n    def clear_all_notifications(self):\n        notification_service.clear_all_cached_notifications()\n        self.notification_count_changed(self.notification_count)\n\n    def get_cached_notifications(self):\n        return notification_service.cached_notifications\n\n    def get_deserialized_with_ids(self):\n        return [\n            (notif._notification, notif.cache_id)\n            for notif in notification_service.cached_notifications\n        ]\n\n    @property\n    def notification_count(self) -> int:\n        return notification_service.count\n\n    def toggle_dnd(self):\n        notification_service.toggle_dnd()\n        self.dont_disturb_changed(notification_service.dont_disturb)\n\n\nglobal modus_service\ntry:\n    modus_service = ModusService()\nexcept Exception as e:\n    logger.error(\"[Main] Failed to create ModusShellService:\", e)\n"
  },
  {
    "path": "services/mpris.py",
    "content": "# Standard library imports\nimport contextlib\n\nimport gi\n\n# Fabric imports\nfrom fabric.core.service import Property, Service, Signal\nfrom fabric.utils import bulk_connect\nfrom gi.repository import GLib\nfrom loguru import logger\n\n\nclass PlayerctlImportError(ImportError):\n    def __init__(self, *args):\n        super().__init__(\n            \"Playerctl is not installed, please install it first\",\n            *args,\n        )\n\n\ntry:\n    gi.require_version(\"Playerctl\", \"2.0\")\n    from gi.repository import Playerctl\nexcept ValueError:\n    raise PlayerctlImportError\n\n\nclass MprisPlayer(Service):\n    \"\"\"A service to manage a mpris player.\"\"\"\n\n    @Signal\n    def exit(self, value: bool) -> bool: ...\n\n    @Signal\n    def changed(self) -> None: ...\n\n    def __init__(\n        self,\n        player: Playerctl.Player,\n        **kwargs,\n    ):\n        self._signal_connectors: dict = {}\n        self._player: Playerctl.Player = player\n        super().__init__(**kwargs)\n        for sn in [\"playback-status\", \"loop-status\", \"shuffle\"]:\n            self._signal_connectors[sn] = self._player.connect(\n                sn,\n                lambda *args, sn=sn: self.notifier(sn, args),\n            )\n\n        self._signal_connectors[\"exit\"] = self._player.connect(\n            \"exit\",\n            self.on_player_exit,\n        )\n        self._signal_connectors[\"metadata\"] = self._player.connect(\n            \"metadata\",\n            lambda *_: self.update_status(),\n        )\n        GLib.idle_add(self.update_status_once)\n\n    def update_status(self):\n        # schedule each notifier asynchronously.\n        def notify_property(prop):\n            if self.get_property(prop) is not None:\n                self.notifier(prop)\n\n        for prop in [\n            \"metadata\",\n            \"title\",\n            \"artist\",\n            \"arturl\",\n            \"length\",\n        ]:\n            GLib.idle_add(lambda p=prop: (notify_property(p), False))\n        for prop in [\n            \"can-seek\",\n            \"can-pause\",\n            \"can-shuffle\",\n            \"can-go-next\",\n            \"can-go-previous\",\n        ]:\n            GLib.idle_add(lambda p=prop: (self.notifier(p), False))\n\n    def update_status_once(self):\n        # schedule notifier calls for each property\n        def notify_all():\n            for prop in self.list_properties():  # type: ignore\n                self.notifier(prop.name)\n            return False\n\n        GLib.idle_add(notify_all, priority=GLib.PRIORITY_DEFAULT_IDLE)\n\n    def notifier(self, name: str, args=None):\n        def notify_and_emit():\n            self.notify(name)\n            self.emit(\"changed\")\n            return False\n\n        GLib.idle_add(notify_and_emit, priority=GLib.PRIORITY_DEFAULT_IDLE)\n\n    def on_player_exit(self, player):\n        for id in list(self._signal_connectors.values()):\n            with contextlib.suppress(Exception):\n                self._player.disconnect(id)\n        del self._signal_connectors\n        GLib.idle_add(lambda: (self.emit(\"exit\", True), False))\n        del self._player\n\n    def toggle_shuffle(self, *_):\n        if self.can_shuffle:\n            # schedule the shuffle toggle in the GLib idle loop\n            GLib.idle_add(lambda: (setattr(self, \"shuffle\", not self.shuffle), False))\n        # else do nothing\n\n    def play_pause(self, *_):\n        if self.can_pause:\n            GLib.idle_add(lambda: (self._player.play_pause(), False))\n\n    def next(self, *_):\n        if self.can_go_next:\n            GLib.idle_add(lambda: (self._player.next(), False))\n\n    def previous(self, *_):\n        if self.can_go_previous:\n            GLib.idle_add(lambda: (self._player.previous(), False))\n\n    # Properties\n    @Property(str, \"readable\")\n    def player_name(self) -> int:\n        return self._player.get_property(\"player-name\")  # type: ignore\n\n    @Property(int, \"read-write\", default_value=0)\n    def position(self) -> int:\n        return self._player.get_property(\"position\")  # type: ignore\n\n    @position.setter\n    def position(self, new_pos: int):\n        self._player.set_position(new_pos)\n\n    @Property(object, \"readable\")\n    def metadata(self) -> dict:\n        return self._player.get_property(\"metadata\")  # type: ignore\n\n    @Property(str or None, \"readable\")\n    def arturl(self) -> str | None:\n        if \"mpris:artUrl\" in self.metadata.keys():  # type: ignore  # noqa: SIM118\n            return self.metadata[\"mpris:artUrl\"]  # type: ignore\n        return None\n\n    @Property(str or None, \"readable\")\n    def length(self) -> str | None:\n        if \"mpris:length\" in self.metadata.keys():  # type: ignore  # noqa: SIM118\n            return self.metadata[\"mpris:length\"]  # type: ignore\n        return None\n\n    @Property(str, \"readable\")\n    def artist(self) -> str:\n        artist = self._player.get_artist()  # type: ignore\n        if isinstance(artist, (list, tuple)):\n            return \", \".join(artist)\n        return artist\n\n    @Property(str, \"readable\")\n    def album(self) -> str:\n        return self._player.get_album()  # type: ignore\n\n    @Property(str, \"readable\")\n    def title(self):\n        return self._player.get_title()\n\n    @Property(bool, \"read-write\", default_value=False)\n    def shuffle(self) -> bool:\n        return self._player.get_property(\"shuffle\")  # type: ignore\n\n    @shuffle.setter\n    def shuffle(self, do_shuffle: bool):\n        self.notifier(\"shuffle\")\n        return self._player.set_shuffle(do_shuffle)\n\n    @Property(str, \"readable\")\n    def playback_status(self) -> str:\n        return {\n            Playerctl.PlaybackStatus.PAUSED: \"paused\",\n            Playerctl.PlaybackStatus.PLAYING: \"playing\",\n            Playerctl.PlaybackStatus.STOPPED: \"stopped\",\n            # type: ignore\n        }.get(self._player.get_property(\"playback_status\"), \"unknown\")\n\n    @Property(str, \"read-write\")\n    def loop_status(self) -> str:\n        return {\n            Playerctl.LoopStatus.NONE: \"none\",\n            Playerctl.LoopStatus.TRACK: \"track\",\n            Playerctl.LoopStatus.PLAYLIST: \"playlist\",\n        }.get(\n            self._player.get_property(\"loop_status\"), \"unknown\"\n        )  # type: ignore\n\n    @loop_status.setter\n    def loop_status(self, status: str):\n        loop_status = {\n            \"none\": Playerctl.LoopStatus.NONE,\n            \"track\": Playerctl.LoopStatus.TRACK,\n            \"playlist\": Playerctl.LoopStatus.PLAYLIST,\n        }.get(status)\n        self._player.set_loop_status(loop_status) if loop_status else None\n\n    @Property(bool, \"readable\", default_value=False)\n    def can_go_next(self) -> bool:\n        return self._player.get_property(\"can_go_next\")  # type: ignore\n\n    @Property(bool, \"readable\", default_value=False)\n    def can_go_previous(self) -> bool:\n        return self._player.get_property(\"can_go_previous\")  # type: ignore\n\n    @Property(bool, \"readable\", default_value=False)\n    def can_seek(self) -> bool:\n        return self._player.get_property(\"can_seek\")  # type: ignore\n\n    @Property(bool, \"readable\", default_value=False)\n    def can_pause(self) -> bool:\n        return self._player.get_property(\"can_pause\")  # type: ignore\n\n    @Property(bool, \"readable\", default_value=False)\n    def can_shuffle(self) -> bool:\n        try:\n            self._player.set_shuffle(self._player.get_property(\"shuffle\"))\n            return True\n        except Exception:\n            return False\n\n    @Property(bool, \"readable\", default_value=False)\n    def can_loop(self) -> bool:\n        try:\n            self._player.set_shuffle(self._player.get_property(\"shuffle\"))\n            return True\n        except Exception:\n            return False\n\n\nclass MprisPlayerManager(Service):\n    \"\"\"A service to manage mpris players.\"\"\"\n\n    @Signal\n    def player_appeared(self, player: Playerctl.Player) -> Playerctl.Player: ...\n\n    @Signal\n    def player_vanished(self, player_name: str) -> str: ...\n\n    def __init__(\n        self,\n        **kwargs,\n    ):\n        self._manager = Playerctl.PlayerManager.new()\n        self._signal_connections = []\n        \n        # Track signal connections for cleanup\n        connections = bulk_connect(\n            self._manager,\n            {\n                \"name-appeared\": self.on_name_appeared,\n                \"name-vanished\": self.on_name_vanished,\n            },\n        )\n        # Store as (object, handler_id) tuples\n        for handler_id in connections:\n            self._signal_connections.append((self._manager, handler_id))\n            \n        self.add_players()\n        super().__init__(**kwargs)\n\n    def destroy(self):\n        \"\"\"Clean up resources when the manager is destroyed.\"\"\"\n        # Disconnect all signal connections\n        for obj, handler_id in self._signal_connections:\n            try:\n                obj.disconnect(handler_id)\n            except Exception as e:\n                logger.warning(f\"Failed to disconnect manager signal: {e}\")\n        self._signal_connections.clear()\n        \n        # Clean up the manager\n        if hasattr(self, '_manager'):\n            del self._manager\n\n    def on_name_appeared(self, manager, player_name: Playerctl.PlayerName):\n        logger.info(f\"[MprisPlayer] {player_name.name} appeared\")\n        new_player = Playerctl.Player.new_from_name(player_name)\n        manager.manage_player(new_player)\n        self.emit(\"player-appeared\", new_player)  # type: ignore\n\n    def on_name_vanished(self, manager, player_name: Playerctl.PlayerName):\n        logger.info(f\"[MprisPlayer] {player_name.name} vanished\")\n        self.emit(\"player-vanished\", player_name.name)  # type: ignore\n\n    def add_players(self):\n        # type: ignore\n        for player in self._manager.get_property(\"player-names\"):\n            self._manager.manage_player(Playerctl.Player.new_from_name(player))  # type: ignore\n\n    @Property(object, \"readable\")\n    def players(self):\n        return self._manager.get_property(\"players\")  # type: ignore\n"
  },
  {
    "path": "services/network.py",
    "content": "from gi.repository import NM, GLib\nimport gi\nfrom typing import List, Optional\nfrom fabric.core.service import Property, Service, Signal\nfrom fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case\nfrom loguru import logger\n\ngi.require_version(\"NM\", \"1.0\")  # Ensure the correct version is loaded\n\n\nclass NetworkClient(Service):\n    \"\"\"A service to manage network devices\"\"\"\n\n    @Signal\n    def wifi_device_added(self) -> None: ...\n\n    @Signal\n    def ethernet_device_added(self) -> None: ...\n\n    @Signal\n    def wifi_device_removed(self) -> None: ...\n\n    @Signal\n    def ethernet_device_removed(self) -> None: ...\n\n    @Signal\n    def changed(self) -> None: ...\n\n    @Property(list, \"readable\")\n    def connections(self) -> Optional[list]:\n        \"\"\"Returns the active connections, if available.\"\"\"\n        return self._client.get_property(\"connections\") if self._client else None\n\n    @Property(object, \"readable\")\n    def wifi_device(self) -> Optional[object]:\n        \"\"\"Returns the WiFi device if available.\"\"\"\n        return self._wifi_device\n\n    @Property(object, \"readable\")\n    def ethernet_device(self) -> Optional[object]:\n        \"\"\"Returns the Ethernet device if available.\"\"\"\n        return self._ethernet_device\n\n    @Property(str, \"readable\")\n    def primary_connection(self) -> Optional[str]:\n        \"\"\"Returns the primary connection if available.\"\"\"\n        return self._client.get_property(\"primary_connection\") if self._client else None\n\n    @Property(str, \"readable\")\n    def active_connection(self) -> Optional[str]:\n        \"\"\"Returns the active connection if available.\"\"\"\n        return self._client.get_property(\"active_connection\") if self._client else None\n\n    @Property(str, \"readable\")\n    def state(self) -> str:\n        \"\"\"Returns the current network state.\"\"\"\n        if not self._client:\n            return \"disconnected\"\n        return snake_case_to_kebab_case(\n            get_enum_member_name(\n                self._client.get_property(\"state\"), default=\"disconnected\"\n            )\n        )\n\n    @Property(str, \"readable\")\n    def connectivity(self) -> str:\n        \"\"\"Returns the connectivity state.\"\"\"\n        if not self._client:\n            return \"disconnected\"\n        return snake_case_to_kebab_case(\n            get_enum_member_name(\n                self._client.get_property(\"connectivity\"), default=\"disconnected\"\n            )\n        )\n\n    @Property(list, \"readable\")\n    def devices(self) -> Optional[list]:\n        \"\"\"Returns the list of network devices if available.\"\"\"\n        return self._client.get_property(\"devices\") if self._client else None\n\n    @Property(str, \"readable\")\n    def hostname(self) -> Optional[str]:\n        \"\"\"Returns the hostname if available.\"\"\"\n        return self._client.get_property(\"hostname\") if self._client else None\n\n    @Property(bool, \"read-write\", default_value=False)\n    def networking_enabled(self) -> bool:\n        \"\"\"Checks if networking is enabled.\"\"\"\n        return (\n            self._client.get_property(\"networking_enabled\") if self._client else False\n        )\n\n    @networking_enabled.setter\n    def networking_enabled(self, value: bool):\n        \"\"\"Sets the networking state if the client is available.\"\"\"\n        if self._client:\n            self._client.set_property(\"networking_enabled\", value)\n\n    @Property(bool, \"read-write\", default_value=False)\n    def wireless_enabled(self) -> bool:\n        \"\"\"Checks if wireless networking is enabled.\"\"\"\n        return self._client.get_property(\"wireless_enabled\") if self._client else False\n\n    @wireless_enabled.setter\n    def wireless_enabled(self, value: bool):\n        \"\"\"Sets the wireless networking state if the client is available.\"\"\"\n        if self._client:\n            self._client.set_property(\"wireless_enabled\", value)\n\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs)\n\n        self._client: NM.Client = None\n        self._wifi_device: Wifi | None = None\n        self._ethernet_device: Ethernet | None = None\n\n        logger.info(\"[Network] Initializing client asynchronously...\")\n\n        # Start async NM client initialization\n        NM.Client.new_async(None, self.on_client_ready)\n\n    def on_client_ready(self, source, result):\n        \"\"\"Callback when NM.Client is ready.\"\"\"\n        try:\n            self._client = NM.Client.new_finish(result)  # Retrieve client instance\n            logger.info(\"[Network] NM.Client initialized successfully!\")\n\n            # Connect signals\n            bulk_connect(\n                self._client,\n                {\n                    \"device-added\": lambda _, device: self.on_device_added(\n                        device=device\n                    ),\n                    \"device-removed\": lambda _, device: self.on_device_removed(\n                        device=device\n                    ),\n                    \"notify::state\": lambda *args: self.notifier(\"state\"),\n                    \"notify::networking-enabled\": lambda *args: self.notifier(\n                        \"networking-enabled\"\n                    ),\n                    \"notify::wireless-enabled\": lambda *args: self.notifier(\n                        \"wireless-enabled\"\n                    ),\n                    # \"notify::primary-connection\": lambda *args: self.notifier('primary-connection'),\n                    # \"notify::active-connection\": lambda *args: self.notifier('active-connection'),\n                    # \"active-connection-added\": lambda *args: self.emit(\"changed\"),\n                    # \"active-connection-removed\": lambda *args: self.emit(\"changed\")\n                },\n            )\n\n            # Process devices AFTER client is ready\n            for device in self.do_get_raw_devices():\n                self.on_device_added(device=device)\n\n            self.notify(\"state\")\n            self.notify(\"networking-enabled\")\n            self.notify(\"wireless-enabled\")\n\n        except Exception as e:\n            logger.error(f\"[Network] Error initializing NM.Client: {e}\")\n\n    def do_get_raw_devices(self) -> list[NM.Device]:\n        return [\n            dev\n            for dev in self.devices\n            if dev.get_device_type() in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET)\n        ]\n\n    def on_device_added(self, device):\n        device_type = device.get_device_type()\n        if device_type == NM.DeviceType.WIFI and not self._wifi_device:\n            logger.info(\"[Network] WiFi device detected, initializing...\")\n            self._wifi_device = Wifi(client=self, device=device)\n            self.wifi_device_added.emit()\n\n        elif device_type == NM.DeviceType.ETHERNET and not self._ethernet_device:\n            logger.info(\"[Network] Ethernet device detected, initializing...\")\n            self._ethernet_device = Ethernet(client=self, device=device)\n            self.ethernet_device_added.emit()\n\n    def on_device_removed(self, device):\n        if device == self._wifi_device:\n            logger.info(\"[Network] WiFi device removed.\")\n            self._wifi_device = None\n            self.wifi_device_removed.emit()\n\n        elif device == self._ethernet_device:\n            logger.info(\"[Network] Ethernet device removed.\")\n            self._ethernet_device = None\n            self.ethernet_device_removed.emit()\n\n    def toggle_network(self):\n        \"\"\"Enable or disable Network\"\"\"\n        self.networking_enabled = not self.networking_enabled\n\n    def deactivate_connection(self, connection):\n        \"\"\"Disconnect\"\"\"\n        self._client.deactivate_connection_async(connection, None, None)\n\n    def notifier(self, name):\n        self.notify(name)\n        self.emit(\"changed\")\n\n\nclass AccessPoint(Service):\n    \"\"\"A service to manage access points\"\"\"\n\n    @Signal\n    def changed(self) -> None: ...\n\n    @Property(object, \"readable\")\n    def device(self) -> object:\n        return self._device\n\n    @Property(int, \"readable\")\n    def strength(self) -> int:\n        return self._ap.get_property(\"strength\")\n\n    @Property(int, \"readable\")\n    def frequency(self) -> int:\n        return self._ap.get_property(\"frequency\")\n\n    @Property(str, \"readable\")\n    def bssid(self) -> str:\n        return self._ap.get_property(\"bssid\") if self._ap else None\n\n    @Property(str, \"readable\")\n    def hw_address(self) -> str:\n        return self._ap.get_property(\"hw_address\")\n\n    @Property(str, \"readable\")\n    def ssid(self) -> str:\n        ssid = self._ap.get_ssid()\n        return NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else \"Unknown\"\n\n    @Property(str, \"readable\")\n    def icon(self) -> str:\n        return {\n            80: \"network-wireless-signal-excellent-symbolic\",\n            60: \"network-wireless-signal-good-symbolic\",\n            40: \"network-wireless-signal-ok-symbolic\",\n            20: \"network-wireless-signal-weak-symbolic\",\n            00: \"network-wireless-signal-none-symbolic\",\n        }.get(\n            min(80, 20 * round(self.strength / 20)),\n            \"network-wireless-no-route-symbolic\",\n        )\n\n    @Property(bool, \"readable\", default_value=False)\n    def requires_password(self) -> bool:\n        ssid = self.ssid\n        settings = self._client.connections\n        connection = None\n        for setting in settings:\n            wifi_setting = setting.get_setting_wireless()\n            if (\n                wifi_setting\n                and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid\n            ):\n                connection = setting\n                break\n        if not connection:\n            return bool(self._ap.get_wpa_flags() or self._ap.get_rsn_flags())\n        return False\n\n    @Property(bool, \"readable\", default_value=False)\n    def is_active(self) -> bool:\n        if self._device.active_access_point:\n            return self.bssid == self._device.active_access_point.get_bssid()\n        return False\n\n    def __init__(self, device: \"Wifi\", ap: NM.AccessPoint, **kwargs):\n        super().__init__(**kwargs)\n        self._client: NetworkClient = device.client\n        self._device: Wifi = device\n        self._ap: NM.AccessPoint = ap\n\n        self._ap.connect(\"notify::strength\", lambda *args: self.notifier(\"strength\"))\n        self._device.connect(\n            \"notify::active-access-point\", lambda *args: self.notifier(\"is-active\")\n        )\n\n    def notifier(self, name: str, *args):\n        self.notify(name)\n        self.emit(\"changed\")\n        return\n\n\nclass Wifi(Service):\n    \"\"\"A service to manage wifi devices\"\"\"\n\n    @Signal\n    def changed(self) -> None: ...\n\n    @Signal\n    def ap_added(self, ap: AccessPoint) -> None: ...\n\n    @Signal\n    def ap_removed(self, ap: AccessPoint) -> None: ...\n\n    @Property(NetworkClient, \"readable\")\n    def client(self) -> NetworkClient:\n        \"\"\"Returns the client\"\"\"\n        return self._client\n\n    @Property(bool, \"read-write\", default_value=False)\n    def wireless_enabled(self) -> bool:\n        \"\"\"Returns if the wifi is enabled\"\"\"\n        return self._client.get_property(\"wireless_enabled\")\n\n    @wireless_enabled.setter\n    def wireless_enabled(self, value: bool):\n        return self._client.set_property(\"wireless_enabled\", value)\n\n    @Property(list[AccessPoint], \"readable\")\n    def access_points(self) -> list[AccessPoint]:\n        return sorted(\n            self._access_points.values(), key=lambda x: x.is_active, reverse=True\n        )\n\n    @Property(AccessPoint, \"readable\")\n    def active_access_point(self) -> Optional[AccessPoint]:\n        return self._active_access_point\n\n    def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs):\n        super().__init__(**kwargs)\n        self._client: NetworkClient = client\n        self._device: NM.DeviceWifi = device\n        self._active_access_point: NM.AccessPoint | None = None\n        self._access_points: dict[str, AccessPoint] = {}\n\n        bulk_connect(\n            self._device,\n            {\n                \"notify::active-access-point\": lambda *args: self.on_access_point_activated(),\n                \"access-point-added\": lambda _, ap: self.on_access_point_added(ap=ap),\n                \"access-point-removed\": lambda _, ap: self.on_access_point_removed(\n                    ap=ap\n                ),\n                # \"state-changed\": lambda device, new, old, reason: self.on_state_changed(new),\n            },\n        )\n\n        self._client.connect(\n            \"notify::wireless-enabled\", lambda *args: self.notifier(\"wireless-enabled\")\n        )\n\n        for ap in self.do_get_access_points():\n            self.on_access_point_added(ap=ap)\n\n        self.on_access_point_activated()\n\n    def on_state_changed(self, state):\n        self.emit(\"changed\")\n\n    def do_get_access_points(self):\n        return self._device.get_access_points()\n\n    def on_access_point_added(self, ap):\n        ssid = ap.get_ssid()\n        ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else \"Unknown\"\n\n        access_point: AccessPoint = AccessPoint(ap=ap, device=self)\n\n        self._access_points[ssid] = access_point\n\n        self.ap_added.emit(access_point)\n\n        # self.notifier(\"access-points\")\n\n        logger.info(f\"[Wifi] New access point added with ssid: {ssid}\")\n\n    def on_access_point_removed(self, ap):\n        ssid = ap.get_ssid()\n        ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else \"Unknown\"\n\n        if not (access_point := self._access_points.pop(ssid, None)):\n            return logger.warning(\n                f\"[Network] tried to remove a unknwon access point with ssid {ssid}\"\n            )\n\n        self.ap_removed.emit(access_point)\n\n        logger.info(f\"[Wifi] Access point with ssid: {ssid} removed.\")\n\n    def on_access_point_activated(self):\n        if self._device.get_active_access_point():\n            self._active_access_point: AccessPoint = AccessPoint(\n                ap=self._device.get_active_access_point(), device=self\n            )\n\n        else:\n            self._active_access_point = None\n\n        self.notifier(\"active-access-point\")\n\n        logger.info(\"[Wifi] New active connection\")\n\n    def disconnect_wifi(self):\n        \"\"\"Disconnect from the current WiFi network.\"\"\"\n        active_connection = self._device.get_active_connection()\n        self._client.deactivate_connection(active_connection)\n        logger.info(\"[Wifi] Wifi network disconnected\")\n\n    def scan(self):\n        self._device.request_scan_async(\n            None,\n            lambda device, result: [\n                device.request_scan_finish(result),\n                self.notifier(\"access-points\"),\n            ],\n        )\n        logger.info(\"[Wifi] Scan started\")\n\n    def toggle_wifi(self):\n        \"\"\"Enable or disable WiFi\"\"\"\n        self.wireless_enabled = not self.wireless_enabled\n\n    def connect_to_wifi(self, ap: AccessPoint, password: str = None, callback=None):\n        \"\"\"Connect to a WiFi network.\"\"\"\n\n        ssid = ap.ssid\n\n        if ssid == \"Unknown\":\n            logger.error(\"Invalid access point data\")\n            if callback:\n                callback(False, \"Invalid access point data\")\n            return False\n\n        logger.info(f\"Connecting to WiFi SSID: {ssid}\")\n\n        # Check for existing connections\n        settings = self._client.connections\n        connection = None\n\n        for setting in settings:\n            wifi_setting = setting.get_setting_wireless()\n            if (\n                wifi_setting\n                and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid\n            ):\n                connection = setting\n                break\n\n        def on_activation_result(client, result):\n            \"\"\"Handle the result of connection activation\"\"\"\n            try:\n                active_connection = client.activate_connection_finish(result)\n                if active_connection:\n                    logger.info(f\"Successfully connected to '{ssid}'\")\n                    if callback:\n                        callback(True, \"Connected successfully\")\n                else:\n                    logger.error(f\"Failed to connect to '{ssid}'\")\n                    if callback:\n                        callback(False, \"Failed to connect. Please try again.\")\n            except Exception as e:\n                error_msg = str(e).lower()\n                logger.error(f\"Connection to '{ssid}' failed: {e}\")\n\n                # Parse NetworkManager error messages and provide user-friendly responses\n                if \"802-11-wireless-security\" in error_msg:\n                    if \"property is invalid\" in error_msg or \"psk\" in error_msg:\n                        user_msg = \"Incorrect password. Please try again.\"\n                    else:\n                        user_msg = \"Security configuration error. Please try again.\"\n                elif \"secrets were required\" in error_msg or \"no secrets\" in error_msg:\n                    user_msg = \"Incorrect password. Please try again.\"\n                elif \"timeout\" in error_msg:\n                    user_msg = \"Connection timeout. Please try again.\"\n                elif \"not found\" in error_msg or \"no such device\" in error_msg:\n                    user_msg = \"Network not available. Please try again.\"\n                elif \"already connected\" in error_msg:\n                    user_msg = \"Already connected to this network.\"\n                else:\n                    user_msg = \"Failed to connect. Please try again.\"\n\n                if callback:\n                    callback(False, user_msg)\n\n        if not connection:\n            # Create a new connection profile\n            logger.info(f\"Creating new WiFi connection for SSID '{ssid}'\")\n            connection = NM.SimpleConnection.new()\n\n            # Required connection settings\n            s_con = NM.SettingConnection.new()\n            s_con.set_property(NM.SETTING_CONNECTION_ID, ssid)\n            s_con.set_property(NM.SETTING_CONNECTION_TYPE, \"802-11-wireless\")\n            s_con.set_property(\n                NM.SETTING_CONNECTION_INTERFACE_NAME, self._device.get_iface()\n            )  # Set interface name\n            connection.add_setting(s_con)\n\n            # Wireless settings\n            s_wifi = NM.SettingWireless.new()\n            s_wifi.set_property(NM.SETTING_WIRELESS_SSID, GLib.Bytes.new(ssid.encode()))\n            s_wifi.set_property(\n                NM.SETTING_WIRELESS_MODE, \"infrastructure\"\n            )  # Ensure mode is correct\n            connection.add_setting(s_wifi)\n\n            # Security settings (only if password is required and provided)\n            if ap.requires_password:\n                if not password:\n                    logger.error(\"Password required but not provided\")\n                    if callback:\n                        callback(False, \"Password required but not provided\")\n                    return False\n\n                s_sec = NM.SettingWirelessSecurity.new()\n                s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, \"wpa-psk\")\n                s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password)\n                connection.add_setting(s_sec)\n\n            # IPv4 settings\n            s_ipv4 = NM.SettingIP4Config.new()\n            s_ipv4.set_property(\"method\", \"auto\")\n            connection.add_setting(s_ipv4)\n\n            # IPv6 settings\n            s_ipv6 = NM.SettingIP6Config.new()\n            s_ipv6.set_property(\"method\", \"auto\")\n            connection.add_setting(s_ipv6)\n\n            # Callback for async connection\n            def on_connection_added(client, result):\n                try:\n                    new_connection = client.add_connection_finish(result)\n                    if not new_connection:\n                        logger.error(f\"Failed to create connection for '{ssid}'\")\n                        if callback:\n                            callback(\n                                False, \"Failed to create connection. Please try again.\"\n                            )\n                        return\n\n                    logger.info(f\"Connection for '{ssid}' added successfully\")\n\n                    # Now activate the newly created connection\n                    client.activate_connection_async(\n                        new_connection, self._device, None, None, on_activation_result\n                    )\n                except Exception as e:\n                    error_msg = str(e).lower()\n                    logger.error(f\"Failed to add connection: {e}\")\n\n                    # Parse connection creation errors and provide user-friendly responses\n                    if \"802-11-wireless-security\" in error_msg:\n                        if \"property is invalid\" in error_msg or \"psk\" in error_msg:\n                            user_msg = \"Incorrect password. Please try again.\"\n                        else:\n                            user_msg = \"Security configuration error. Please try again.\"\n                    elif \"invalid\" in error_msg or \"property\" in error_msg:\n                        user_msg = \"Invalid network configuration. Please try again.\"\n                    else:\n                        user_msg = \"Failed to connect. Please try again.\"\n\n                    if callback:\n                        callback(False, user_msg)\n\n            # Save the new connection\n            self._client._client.add_connection_async(\n                connection, True, None, on_connection_added\n            )\n        else:\n            # Activate existing connection\n            self._client._client.activate_connection_async(\n                connection, self._device, None, None, on_activation_result\n            )\n\n        return True\n\n    def notifier(self, name: str, *args):\n        self.notify(name)\n        self.emit(\"changed\")\n        return\n\n\nclass Ethernet(Service):\n    \"\"\"A service to manage ethernet devices\"\"\"\n\n    @Signal\n    def changed(self) -> None: ...\n\n    @Signal\n    def enabled(self) -> bool: ...\n\n    @Property(int, \"readable\")\n    def speed(self) -> str:\n        speed_mbps = self._device.get_speed()\n        return f\"{speed_mbps} Mb/s\"\n\n    @Property(str, \"readable\")\n    def state(self) -> str:\n        return snake_case_to_kebab_case(\n            get_enum_member_name(self._device.get_state(), default=\"disconnected\")\n        )\n\n    @Property(str, \"readable\")\n    def internet(self) -> str:\n        if self._active_connection:\n            return snake_case_to_kebab_case(\n                get_enum_member_name(\n                    self._active_connection.get_state(), default=\"disconnected\"\n                )\n            )\n        return \"disconnected\"\n\n    @Property(str, \"readable\")\n    def iface(self) -> str:\n        return self._device.get_iface() if self._device else None\n\n    @Property(str, \"readable\")\n    def icon_name(self) -> str:\n        network = self.internet\n        if network == \"activated\":\n            return \"network-wired-symbolic\"\n        elif network == \"activating\":\n            return \"network-wired-acquiring-symbolic\"\n        return \"network-wired-disconnected-symbolic\"\n\n    def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None:\n        super().__init__(**kwargs)\n        self._client: NM.Client = client\n        self._device: NM.DeviceEthernet = device\n        self._active_connection = None\n\n        self._device.connect(\n            \"state-changed\", lambda *args: self.on_network_state_changed()\n        )\n\n        self.update_active_connection()\n\n    def on_network_state_changed(self):\n        \"\"\"Called when networking is toggled on/off.\"\"\"\n        if self.state != \"unmanaged\":\n            # Re-initialize device when networking is re-enabled\n            self.update_active_connection()\n        else:\n            self._active_connection = None\n\n    def update_active_connection(self):\n        \"\"\"Updates the active connection and connects to its state change signal.\"\"\"\n        active_connection = self._device.get_active_connection()\n        if active_connection:\n            self._active_connection = active_connection\n            self._active_connection.connect(\n                \"state-changed\", lambda *args: self.emit(\"changed\")\n            )\n\n    def get_network_stats(self):\n        \"\"\"Fetch received and transmitted bytes from the system files\"\"\"\n        try:\n            # Read data from /sys/class/net for accurate speed\n            with open(\n                f\"/sys/class/net/{self.iface}/statistics/rx_bytes\", \"r\"\n            ) as rx_file:\n                rx_bytes = int(rx_file.read().strip())\n            with open(\n                f\"/sys/class/net/{self.iface}/statistics/tx_bytes\", \"r\"\n            ) as tx_file:\n                tx_bytes = int(tx_file.read().strip())\n            return rx_bytes, tx_bytes\n        except FileNotFoundError:\n            return None, None\n"
  },
  {
    "path": "services/todo.py",
    "content": "# Standard library imports\nimport json\nimport uuid\nfrom datetime import datetime\nfrom pathlib import Path\n\n# Fabric imports\nfrom fabric.core.service import Property, Service\n\n# Local imports\nimport config.data as data\n\n\nclass TodoService(Service):\n    \"\"\"Service for managing persistent todo list with JSON storage\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self._todos = []\n        self._file_path = self._get_todos_file_path()\n        self._load_todos()\n        self._callbacks = []\n\n    def add_callback(self, callback):\n        \"\"\"Add a callback function to be notified of changes\"\"\"\n        self._callbacks.append(callback)\n\n    def remove_callback(self, callback):\n        \"\"\"Remove a callback function\"\"\"\n        if callback in self._callbacks:\n            self._callbacks.remove(callback)\n\n    def _notify_callbacks(self, event_type, data=None):\n        \"\"\"Notify all registered callbacks of changes\"\"\"\n        for callback in self._callbacks:\n            try:\n                callback(event_type, data)\n            except Exception as e:\n                print(f\"Error in todo callback: {e}\")\n\n    def _get_todos_file_path(self):\n        \"\"\"Returns the path to the todos JSON file\"\"\"\n        cache_dir = Path(data.CACHE_DIR) / \"todos\"\n        cache_dir.mkdir(parents=True, exist_ok=True)\n        return cache_dir / \"todos.json\"\n\n    def _load_todos(self):\n        \"\"\"Load todos from JSON file\"\"\"\n        try:\n            if self._file_path.exists():\n                with open(self._file_path, \"r\", encoding=\"utf-8\") as f:\n                    self._todos = json.load(f)\n            else:\n                self._todos = []\n        except Exception as e:\n            print(f\"Error loading todos: {e}\")\n            self._todos = []\n\n    def _save_todos(self):\n        \"\"\"Save todos to JSON file\"\"\"\n        try:\n            with open(self._file_path, \"w\", encoding=\"utf-8\") as f:\n                json.dump(self._todos, f, indent=2, ensure_ascii=False)\n        except Exception as e:\n            print(f\"Error saving todos: {e}\")\n\n    @Property(list, \"readable\")\n    def todos(self):\n        \"\"\"Get all todos\"\"\"\n        return self._todos.copy()\n\n    def add_todo(self, text: str, priority: str = \"medium\") -> dict:\n        \"\"\"Add a new todo item\"\"\"\n        todo = {\n            \"id\": str(uuid.uuid4()),\n            \"text\": text,\n            \"completed\": False,\n            \"priority\": priority,  # low, medium, high\n            \"created_at\": datetime.now().isoformat(),\n            \"updated_at\": datetime.now().isoformat(),\n        }\n        self._todos.append(todo)\n        self._save_todos()\n        self._notify_callbacks(\"todo-added\", todo)\n        self._notify_callbacks(\"todos-changed\")\n        return todo\n\n    def delete_todo(self, todo_id: str) -> bool:\n        \"\"\"Delete a todo item by ID\"\"\"\n        for i, todo in enumerate(self._todos):\n            if todo[\"id\"] == todo_id:\n                deleted_todo = self._todos.pop(i)\n                self._save_todos()\n                self._notify_callbacks(\"todo-deleted\", deleted_todo)\n                self._notify_callbacks(\"todos-changed\")\n                return True\n        return False\n\n    def toggle_todo(self, todo_id: str) -> bool:\n        \"\"\"Toggle completion status of a todo item\"\"\"\n        for todo in self._todos:\n            if todo[\"id\"] == todo_id:\n                todo[\"completed\"] = not todo[\"completed\"]\n                todo[\"updated_at\"] = datetime.now().isoformat()\n                self._save_todos()\n                self._notify_callbacks(\"todo-toggled\", todo)\n                self._notify_callbacks(\"todos-changed\")\n                return True\n        return False\n\n    def edit_todo(self, todo_id: str, new_text: str) -> bool:\n        \"\"\"Edit the text of a todo item\"\"\"\n        for todo in self._todos:\n            if todo[\"id\"] == todo_id:\n                todo[\"text\"] = new_text\n                todo[\"updated_at\"] = datetime.now().isoformat()\n                self._save_todos()\n                self._notify_callbacks(\"todo-edited\", todo)\n                self._notify_callbacks(\"todos-changed\")\n                return True\n        return False\n\n    def set_priority(self, todo_id: str, priority: str) -> bool:\n        \"\"\"Set the priority of a todo item\"\"\"\n        if priority not in [\"low\", \"medium\", \"high\"]:\n            return False\n\n        for todo in self._todos:\n            if todo[\"id\"] == todo_id:\n                todo[\"priority\"] = priority\n                todo[\"updated_at\"] = datetime.now().isoformat()\n                self._save_todos()\n                self._notify_callbacks(\"todo-priority-changed\", todo)\n                self._notify_callbacks(\"todos-changed\")\n                return True\n        return False\n\n    def get_todo(self, todo_id: str) -> dict | None:\n        \"\"\"Get a specific todo by ID\"\"\"\n        for todo in self._todos:\n            if todo[\"id\"] == todo_id:\n                return todo.copy()\n        return None\n\n    def clear_completed(self):\n        \"\"\"Remove all completed todos\"\"\"\n        initial_count = len(self._todos)\n        self._todos = [todo for todo in self._todos if not todo[\"completed\"]]\n        if len(self._todos) < initial_count:\n            self._save_todos()\n            self._notify_callbacks(\"todos-changed\")\n\n    def get_stats(self) -> dict:\n        \"\"\"Get todo statistics\"\"\"\n        total = len(self._todos)\n        completed = sum(1 for todo in self._todos if todo[\"completed\"])\n        pending = total - completed\n\n        return {\n            \"total\": total,\n            \"completed\": completed,\n            \"pending\": pending,\n            \"completion_rate\": (completed / total * 100) if total > 0 else 0,\n        }\n\n\n# Global service instance\ntodo_service = TodoService()\n\n"
  },
  {
    "path": "styles/about.css",
    "content": "#about-menu {\n  background-color: alpha(#000, 0.34);\n\n  box-shadow:\n    inset 0 -0.5px 0 0.5px alpha(#555, 0.7),\n    inset 0 0.5px 0 0.5px alpha(#777, 0.7);\n  border: 1px solid alpha(#111, 0.3);\n  border-top: none;\n  border-radius: 1.25rem;\n  padding: 2rem;\n}\n\n#about-options {\n  background-color: alpha(#333, 0.9);\n  border-radius: 15px;\n  padding: 2rem;\n}\n\n#about-logo-box {\n  margin-top: 2rem;\n  margin-bottom: 2rem;\n}\n\n#about-button-box {\n  padding: 1rem;\n}\n#vendor-label {\n  color: #999999;\n  margin-top: -1rem;\n}\n\n#info-label {\n  color: #888888;\n}\n#about-info-title-box {\n  padding-left: 4rem;\n}\n\n#about-name-label {\n  font-size: 24px;\n  margin: 0px;\n  font-weight: 600;\n}\n\n#about-date-label {\n  font-size: 11px;\n  margin: 0px;\n  color: #ddd;\n  font-weight: 400;\n  margin-bottom: 2rem;\n}\n\n#about-chip-title-label,\n#about-so-title-label,\n#about-mem-title-label {\n  font-size: 12px;\n  margin: 0px;\n  margin-right: 10px;\n  font-weight: 500;\n}\n\n#more-info-button {\n  background-color: #888888;\n  border-radius: 5px;\n  padding: 0px 10px;\n  margin: 1px 2px;\n  transition: all 200ms ease-in-out;\n}\n#more-info-button:hover {\n  background-color: rgb(72, 130, 255);\n  background-color: #2369ff;\n}\n\n#more-info-button label {\n  font-size: 12px;\n  margin: 0px;\n  font-weight: 400;\n  color: #fff;\n}\n"
  },
  {
    "path": "styles/battery-widget.css",
    "content": "#battery-widget {\n  background: rgba(0, 0, 0, 0);\n  padding-left: 5px;\n  padding-right: 5px;\n}\n.battery-main-title {\n  font-size: 16px;\n  font-weight: bold;\n\n  color: rgba(255, 255, 255, 0.95);\n  margin: 6px 10px;\n}\n\n.battery-power-source {\n  font-size: 0.9em;\n  font-weight: 500;\n  color: rgba(255, 255, 255, 0.8);\n  margin: 0px 12px;\n}\n\n.battery-section-title {\n  font-size: 0.9em;\n  font-weight: 500;\n  color: rgba(255, 255, 255, 0.9);\n  margin: 8px 12px 4px 12px;\n}\n\n.battery-energy-item {\n  margin: 4px 12px;\n  padding: 4px 0;\n}\n\n.battery-status-section {\n  /* background: rgba(255, 255, 255, 0.06); */\n  /* border-radius: 12px; */\n  padding: 2px;\n  /* margin: 8px 0; */\n  /* border: 1px solid rgba(255, 255, 255, 0.08); */\n}\n\n.battery-percentage {\n  font-size: 16px;\n  margin-right: 8px;\n  font-weight: 500;\n  color: rgba(255, 255, 255, 0.95);\n}\n\n#energy-mode-button-clickable {\n  background: transparent;\n  border: none;\n  border-radius: 8px;\n  padding: 6px 12px;\n  margin: 1px 0;\n}\n\n#energy-mode-button-clickable:hover {\n  background: rgba(255, 255, 255, 0.1);\n}\n\n#energy-mode-icon {\n  border-radius: 50%;\n  padding: 4px;\n  margin-top: -2px;\n  margin-bottom: -2px;\n  margin-right: 6px;\n  margin-left: -6px;\n  background-color: rgba(255, 255, 255, 0.1);\n  color: rgba(255, 255, 255, 0.7);\n}\n\n#energy-mode-icon.connected {\n  background-color: #007aff;\n  color: #ffffff;\n}\n.battery-power-source {\n  font-size: 12px;\n  margin-bottom: -4px;\n  color: alpha(#ffffff, 0.5);\n}\n.battery-section-title {\n  font-size: 14px;\n  color: alpha(#ffffff, 0.7);\n  margin: 2px 0 0 8px;\n}\n.battery-power-mode {\n  font-size: 13px;\n}\n\n.battery-settings-button:hover {\n  margin-top: -5px;\n  background: alpha(#ffffff, 0.1);\n  min-height: 30px;\n  border-radius: 8px;\n}\n.battery-settings-button {\n  margin-top: -5px;\n  min-height: 30px;\n  border-radius: 8px;\n}\n.battery-settings-button label {\n  font-weight: 500;\n  color: alpha(#ffffff, 1);\n}\n#energy-mode-button:first-child {\n  margin-top: 0;\n}\n#energy-mode-button {\n  margin-top: -8px;\n  margin-bottom: 4px;\n  color: rgba(255, 255, 255, 0.85);\n}\n.gamemode-button {\n  font-size: 12px;\n  margin-left: -6px;\n  color: rgba(255, 255, 255, 0.85);\n}\n\n#game-mode-button {\n  margin: 2px 0;\n}\n#game-mode-icon {\n  margin-left: -6px;\n}\n\n#game-mode-button-clickable {\n  background: transparent;\n  border: none;\n  border-radius: 8px;\n  padding: 6px 12px;\n  margin: 1px 0;\n  /* min-height: 36px; */\n}\n\n#game-mode-button-clickable:hover {\n  background: rgba(255, 255, 255, 0.1);\n}\n\n#game-mode-icon {\n  border-radius: 50%;\n  padding: 4px;\n  margin-right: 8px;\n  background-color: rgba(255, 255, 255, 0.1);\n  color: rgba(255, 255, 255, 0.7);\n}\n\n#game-mode-icon.connected {\n  background-color: #007aff;\n  color: #ffffff;\n}\n"
  },
  {
    "path": "styles/colors.css",
    "content": ":vars {\n  --foreground: #e4e1e9;\n  --background: #131318;\n  --cursor: #e4e1e9;\n  --primary: #bec2ff;\n  --on-primary: #262b60;\n  --secondary: #c5c4dd;\n  --on-secondary: #2e2f42;\n  --tertiary: #e7b9d5;\n  --on-tertiary: #45263c;\n  --surface: #131318;\n  --surface-bright: #39393f;\n  --error: #ffb4ab;\n  --error-dim: #ff8678;\n  --on-error: #690005;\n  --error-container: #93000a;\n  --outline: #91909a;\n  --shadow: #000000;\n  --red: #ffb2b9;\n  --red-dim: #ff7f8b;\n  --green: #95d5a7;\n  --green-dim: #70c789;\n  --yellow: #b8cf84;\n  --yellow-dim: #a3c15f;\n  --blue: #bec2ff;\n  --blue-dim: #8b92ff;\n  --magenta: #e4b7f3;\n  --magenta-dim: #d48bec;\n  --cyan: #82d3e2;\n  --cyan-dim: #59c4d8;\n  --white: #82d3e0;\n}\n"
  },
  {
    "path": "styles/controlcenter.css",
    "content": ".title {\n  font-size: 16px;\n  font-family: \"SF Pro Rounded\";\n  font-weight: bold;\n}\n#control-center-menu {\n  background-color: transparent;\n  border-radius: 12px;\n  box-shadow: none;\n  margin: 0;\n}\n\n#separator {\n  min-height: 0.09rem;\n  margin: 3px 0;\n  background-color: alpha(#fff, 0.2);\n  border-radius: 8px;\n}\n\n/* #bluetooth-control-window { */\n/* #wifi-control-window, */\n#battery-control-window {\n  margin: 6px;\n  /* background-color: alpha(#fff, 0.09); */\n  border: 1px solid alpha(#111, 0.3);\n  box-shadow: inset 0 0 200px 0 alpha(#111, 0.3);\n  border-radius: 15px;\n}\n\n#bluetooth-title,\n#wifi-title {\n  font-size: 16px;\n  font-weight: bold;\n}\n#control-center-widgets {\n  background-color: alpha(#010101, 0.01);\n  box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);\n  /* border: 1px solid alpha(#111, 0.4); */\n  border-radius: 12px;\n  padding: 0.5rem;\n}\n#focus-widget {\n}\n#wb-widget,\n#brightness-menu {\n  background-color: alpha(#000, 0.08);\n  box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);\n  border: 1px solid alpha(#111, 0.4);\n  border-radius: 12px;\n}\n/* Widgets */\n#wifi-widget,\n#bluetooth-widget,\n#nightlight-widget {\n  min-width: 140px;\n  padding: 5px 0 0px 0;\n}\n\n.icon {\n  background-color: #2369ff;\n  font-size: 15px;\n  padding: 20px 20px;\n}\n\n#bluetooth-widget-label,\n#wifi-widget-label,\n#nightlight-widget-label {\n  font-size: 12px;\n  margin-left: 5px;\n  font-weight: 500;\n  color: #999;\n}\n\n/* WiFi Password Dialog */\n#wifi-password-dialog {\n  background-color: transparent;\n}\n\n#wifi-dialog-background {\n  background-color: alpha(#fff, 0.05);\n  border-radius: 12px;\n  border: 1px solid rgba(255, 255, 255, 0.1);\n  padding: 20px;\n  min-width: 450px;\n}\n\n#wifi-dialog-title-container {\n  /* margin-bottom: 12px; */\n}\n\n#wifi-dialog-icon {\n  color: #007aff;\n  margin-right: 4px;\n}\n\n#wifi-dialog-title {\n  color: #ffffff;\n  font-size: 14px;\n  font-weight: 500;\n}\n\n#wifi-dialog-error {\n  color: #ff4444;\n  font-size: 12px;\n  font-weight: 500;\n  /* margin-bottom: 8px; */\n}\n\n#wifi-dialog-password-container {\n  margin: 5px 0;\n}\n\n#wifi-dialog-password-label {\n  color: #ffffff;\n  font-size: 13px;\n  font-weight: 500;\n  margin-bottom: 4px;\n}\n\n#wifi-dialog-password-entry {\n  background-color: rgba(255, 255, 255, 0.1);\n  border: 1px solid rgba(255, 255, 255, 0.2);\n  border-radius: 6px;\n  padding: 8px 12px;\n  color: #ffffff;\n  font-size: 13px;\n}\n\n#wifi-dialog-password-entry:focus {\n  border-color: #007aff;\n  box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3);\n  background-color: rgba(255, 255, 255, 0.15);\n}\n\n#wifi-dialog-show-password-box {\n  margin-top: 4px;\n}\n\n#wifi-dialog-show-password-button {\n  background-color: transparent;\n  border: none;\n  padding: 4px;\n  border-radius: 4px;\n  min-width: 24px;\n  min-height: 24px;\n}\n\n#wifi-dialog-show-password-button:hover {\n  background-color: alpha(#fff, 0.1);\n}\n\n#wifi-dialog-show-password-button image {\n  color: #ffffff;\n}\n\n#wifi-dialog-show-password-label {\n  color: #ffffff;\n  font-size: 12px;\n}\n\n#wifi-dialog-button-box {\n  margin-top: -15px;\n}\n\n#wifi-dialog-cancel-button,\n#wifi-dialog-join-button {\n  border-radius: 6px;\n  font-size: 13px;\n  font-weight: 500;\n  min-width: 80px;\n  min-height: 30px;\n}\n\n#wifi-dialog-cancel-button {\n  background-color: rgba(255, 255, 255, 0.1);\n  border: 1px solid rgba(255, 255, 255, 0.2);\n  color: #ffffff;\n}\n\n#wifi-dialog-cancel-button:hover {\n  background-color: rgba(255, 255, 255, 0.15);\n}\n\n#wifi-dialog-join-button {\n  background-color: #007aff;\n  border: 1px solid #007aff;\n  color: #ffffff;\n}\n\n#wifi-dialog-join-button:hover {\n  background-color: #0056cc;\n  border-color: #0056cc;\n}\n\n#wifi-dialog-join-button.disabled {\n  opacity: 0.5;\n  background-color: #555555;\n  border-color: #555555;\n  color: #aaaaaa;\n}\n\n#wifi-dialog-join-button.disabled:hover {\n  background-color: #555555;\n  border-color: #555555;\n  color: #aaaaaa;\n}\n\n#bluetooth-widget-title {\n  font-size: 12px;\n  font-weight: 500;\n  color: #fff;\n}\n\n/* #bluetooth-widget-top { */\n/*   padding-bottom: 10px; */\n/*   border-bottom: 1px solid alpha(#aaa, 0.3); */\n/* } */\n\n#device-icon {\n  border-radius: 50%;\n  padding: 5px;\n  margin-bottom: 2.5px;\n  margin-top: 2.5px;\n  margin-right: 10px;\n  background-color: #555;\n}\n\n#devices-title {\n  color: alpha(#fff, 0.6);\n}\n\n#device-icon.paired {\n  background-color: #888;\n}\n\n#device-icon.connected {\n  background-color: #2369ff;\n}\n\n#toggle-button {\n  min-width: 40px;\n  min-height: 20px;\n  background-color: #bababa;\n  border-radius: 15px;\n  padding: 2px;\n  transition: background-color 0.3s ease;\n}\n\n#toggle-button slider {\n  background-color: #fff;\n  border-radius: 50%;\n  min-width: 16px;\n  min-height: 8px;\n  transition: background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25);\n}\n\n#toggle-button:checked {\n  background-color: #4487f6;\n}\n\n/* #toggle-button:checked slider { */\n/*   background-color: #fff; */\n/* } */\n/**/\n/* #toggle-button:checked image { */\n/*   opacity: 0; */\n/* } */\n\n.menu {\n  background-color: alpha(#000, 0.08);\n  box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);\n  border: 1px solid alpha(#111, 0.4);\n  border-radius: 12px;\n  margin: 5px;\n  /* background-color: alpha(#999, 0.1); */\n  /* border: 0.5px solid alpha(#000, 0.4); */\n  /* border-radius: 8px; */\n  padding: 1rem;\n}\n\n.title {\n  padding: 0;\n  font-weight: 500;\n  font-size: 12px;\n}\n\n#bluetooth-widget-name,\n#wifi-widget-name,\n#nightlight-widget-name {\n  margin-top: 6px;\n  font-size: 14px;\n  font-weight: bold;\n}\n\n.ct {\n  margin-left: 5px;\n}\n\n#vol-slider-box {\n  /* margin-bottom: 2px; */\n  margin-top: -10px;\n  /* border: 1px solid alpha(#fff, 1); */\n}\n#volume-widget-slider {\n  /* margin-bottom: 2px; */\n}\n#volume-widget-icon {\n  font-size: 22px;\n  margin-top: -35px;\n  margin-right: -36px;\n  color: #000;\n}\n\n#brightness-widget-icon {\n  font-size: 15px;\n  margin-top: -27px;\n  margin-right: -29px;\n  color: #000;\n}\n\n#control-center-menu slider {\n  background: linear-gradient(135deg, #ffffff 0%, #f0f1f2 100%);\n  border-color: #000;\n  min-width: 28px;\n  min-height: 28px;\n  margin: -2px;\n  border-radius: 50%;\n  /* background-image: none; */\n  /* background-color: transparent; */\n  /* min-width: 4px; */\n  /* min-height: 40px; */\n  /* margin: -9px; */\n}\n\n#control-center-menu slider:hover {\n  border-color: #999;\n  min-width: 30px;\n  min-height: 30px;\n  margin: -3px;\n  border-radius: 50%;\n  /* background-image: none; */\n  /* background-color: transparent; */\n  /* min-width: 4px; */\n  /* min-height: 40px; */\n  /* margin: -9px; */\n}\n#control-center-menu scale {\n  background-color: transparent;\n  margin-top: 10px;\n  border-radius: 20px;\n}\n\n#control-center-menu trough {\n  min-width: 25px;\n  border-radius: 99px;\n  background-color: alpha(#666, 0.5);\n  border: 1px solid alpha(#444, 0.3);\n}\n\n#control-center-menu highlight {\n  background: alpha(#fff, 0.8);\n  border-radius: 99px;\n}\n\n#control-center-menu mark indicator {\n  background: none;\n  background-image: none;\n  color: alpha(#fff, 0.2);\n}\n\n#control-center-menu mark label {\n  background: none;\n  background-image: none;\n  color: alpha(#fff, 0.2);\n}\n\n/* Per-app volume control styles */\n#per-app-volume-control {\n  min-height: 140px;\n  min-width: 350px;\n  /* background-color: alpha(#000, 0.3); */\n  /* background-color: alpha(#999, 0.1); */\n  /* box-shadow: inset 0 0 200px 0 alpha(#111, 0.3); */\n  /* border: 0.5px solid alpha(#000, 0.4); */\n  border-radius: 8px;\n  padding: 1rem;\n\n  /* padding: 1rem; */\n}\n\n#apps-scrolled-container {\n  min-height: 120px;\n  min-width: 120px;\n}\n#back-button {\n  padding: 8px 12px;\n  background-color: alpha(#fff, 0.1);\n  border-radius: 8px;\n  border: none;\n  color: #fff;\n  font-size: 13px;\n  font-weight: 500;\n  transition: background-color 0.2s ease;\n}\n#back-button:hover {\n  background-color: alpha(#fff, 0.18);\n}\n\n/* Apple-style app volume items */\n.apple-app-volume-item {\n  margin: 3px 0;\n  padding: 16px;\n  background-color: alpha(#fff, 0.08);\n  border-radius: 12px;\n  border: 1px solid alpha(#fff, 0.12);\n  transition: background-color 0.2s ease;\n}\n\n.apple-app-volume-item:hover {\n  background-color: alpha(#fff, 0.12);\n}\n\n.apple-app-name {\n  font-size: 14px;\n  font-weight: 600;\n  color: #fff;\n  margin-bottom: 4px;\n  letter-spacing: -0.01em;\n}\n\n/* Apple-style volume slider */\n#apple-volume-slider {\n  background-color: transparent;\n  margin-top: 4px;\n  margin-bottom: 4px;\n  min-height: 32px;\n}\n\n#apple-volume-slider trough {\n  min-width: 28px;\n  min-height: 6px;\n  border-radius: 3px;\n  background-color: alpha(#fff, 0.25);\n  border: none;\n  box-shadow: inset 0 1px 2px alpha(#000, 0.2);\n}\n\n#apple-volume-slider highlight {\n  background: linear-gradient(90deg, #007aff 0%, #0051d0 100%);\n  border-radius: 3px;\n  border: none;\n  box-shadow: 0 1px 3px alpha(#007aff, 0.3);\n}\n\n#apple-volume-slider slider {\n  background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);\n  /* border: 2px solid alpha(#007aff, 0.8); */\n  border-radius: 50%;\n  min-width: 20px;\n  min-height: 20px;\n  margin: -10px;\n  box-shadow:\n    0 2px 8px alpha(#000, 0.15),\n    0 1px 3px alpha(#000, 0.2);\n  transition: all 0.15s ease;\n}\n\n#apple-volume-slider slider:hover {\n  background: linear-gradient(135deg, #ffffff 0%, #f0f1f2 100%);\n  border-color: #007aff;\n  box-shadow:\n    0 4px 12px alpha(#000, 0.2),\n    0 2px 6px alpha(#007aff, 0.3);\n  min-width: 22px;\n  min-height: 22px;\n  margin: -9px;\n}\n\n#apple-volume-slider slider:active {\n  background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);\n  min-width: 21px;\n  min-height: 21px;\n  margin: -9px;\n  box-shadow:\n    0 2px 6px alpha(#000, 0.25),\n    0 1px 3px alpha(#007aff, 0.4);\n}\n\n/* Legacy app volume styles (fallback) */\n.app-volume-item {\n  margin: 8px 0;\n  padding: 8px;\n  background-color: alpha(#fff, 0.05);\n  border-radius: 6px;\n  border: 1px solid alpha(#fff, 0.1);\n}\n\n.app-name {\n  font-size: 13px;\n  font-weight: 500;\n  color: #fff;\n  margin-bottom: 5px;\n}\n\n#app-volume-slider {\n  background-color: transparent;\n  margin-top: 5px;\n}\n\n#app-volume-slider trough {\n  min-width: 20px;\n  border-radius: 99px;\n  background-color: alpha(#666, 0.5);\n  border: 1px solid alpha(#444, 0.3);\n}\n\n#app-volume-slider highlight {\n  background: alpha(#0078d4, 0.8);\n  border-radius: 99px;\n}\n\n#app-volume-slider slider {\n  background-color: #fff;\n  padding: 2px;\n  min-width: 4px;\n  min-height: 18px;\n  border-radius: 20px;\n}\n\n/* Circle button for per-app volume */\n#per-app-volume-button {\n  background-color: alpha(#fff, 0.15);\n  border: 1px solid alpha(#999, 0.6);\n  /* min-width: 33px; */\n  /* min-height: 30px; */\n  border-radius: 50%;\n}\n\n#per-app-volume-button:hover {\n  background-color: alpha(#fff, 0.25);\n  /* border-color: #007aff; */\n  box-shadow: 0 4px 12px alpha(#000, 0.2);\n}\n\n#per-app-volume-button:active {\n  background-color: alpha(#fff, 0.2);\n  box-shadow: 0 1px 3px alpha(#000, 0.3);\n}\n\n#per-app-volume-icon {\n  background-color: alpha(#fff, 0.15);\n\n  border: 2px solid alpha(#999, 0.6);\n\n  border-radius: 50%;\n\n  min-width: 30px;\n}\n\n/* Apple-style seek bar for expanded player */\n#apple-seek-bar {\n  background-color: transparent;\n  margin-top: 8px;\n  margin-bottom: 4px;\n  min-height: 10px;\n}\n\n#apple-seek-bar trough {\n  min-width: 28px;\n  min-height: 3px;\n  border-radius: 1.5px;\n  background-color: alpha(#fff, 0.3);\n  border: none;\n  box-shadow: inset 0 1px 1px alpha(#000, 0.1);\n}\n\n#apple-seek-bar highlight {\n  background: #007aff;\n  border-radius: 1.5px;\n  border: none;\n  box-shadow: 0 0 2px alpha(#007aff, 0.4);\n}\n\n#apple-seek-bar slider {\n  background: #ffffff;\n  border: 1px solid alpha(#007aff, 0.8);\n  border-radius: 50%;\n  min-width: 12px;\n  min-height: 12px;\n  margin: -4.5px;\n  box-shadow:\n    0 1px 3px alpha(#000, 0.2),\n    0 0 0 1px alpha(#fff, 0.8);\n  transition: all 0.1s ease;\n}\n\n#apple-seek-bar slider:hover {\n  background: #ffffff;\n  border-color: #007aff;\n  min-width: 14px;\n  min-height: 14px;\n  margin: -5.5px;\n  box-shadow:\n    0 2px 6px alpha(#000, 0.25),\n    0 0 0 2px alpha(#007aff, 0.3);\n}\n\n#apple-seek-bar slider:active {\n  background: #f8f9fa;\n  min-width: 13px;\n  min-height: 13px;\n  margin: -5px;\n  box-shadow:\n    0 1px 3px alpha(#000, 0.3),\n    0 0 0 2px alpha(#007aff, 0.5);\n}\n\n/* macOS-style expanded player - more compact */\n#macos-outer-player-box {\n  min-height: 80px; /* Reduced height */\n  min-width: 380px; /* Slightly wider to accommodate expanded track info */\n  /* margin: 10px; */\n  padding: 10px;\n  /* background-color: alpha(#000, 0.4); */\n}\n\n#macos-main-section {\n  margin-left: 5px;\n}\n\n#macos-album-cover-image image {\n  min-width: 70px;\n  min-height: 70px;\n  border-radius: 9px;\n}\n#macos-album-image {\n  min-width: 70px;\n  min-height: 70px;\n  border-radius: 9px;\n  background-position: center;\n  background-size: cover;\n  box-shadow: 0 2px 8px alpha(#000, 0.3);\n}\n\n#macos-album-image-no {\n  min-width: 90px;\n  min-height: 90px;\n  border-radius: 9px;\n  margin-left: 12px;\n  background-position: center;\n  background-size: cover;\n  box-shadow: 0 2px 8px alpha(#000, 0.3);\n}\n#macos-album-image image {\n  min-width: 70px;\n  min-height: 70px;\n  border-radius: 9px;\n  box-shadow: 0 2px 8px alpha(#000, 0.3);\n}\n\n/* Track info container that expands to fill available space */\n#macos-track-info {\n  margin-top: 5px;\n  margin-left: 15px;\n  margin-right: 15px;\n}\n\n#macos-player-title {\n  font-weight: 600;\n  font-size: 16px;\n  color: #ffffff;\n  margin-bottom: 2px;\n}\n\n#macos-player-artist {\n  font-weight: 400;\n  margin-top: -2px;\n  font-size: 13px;\n  color: alpha(#999, 0.8);\n}\n\n#macos-player-album {\n  font-weight: 400;\n  margin-top: -4px;\n  font-size: 13px;\n  color: alpha(#999, 0.8);\n  margin-bottom: 8px;\n}\n/* macOS seek bar styling */\n#macos-seek-bar {\n  background-color: transparent;\n  margin: 8px 0 4px 0;\n  min-height: 10px;\n}\n\n#macos-seek-bar trough {\n  min-width: 200px;\n\n  min-height: 2px;\n  border-radius: 2px;\n  background-color: alpha(#fff, 0.25);\n  border: none;\n}\n\n#macos-seek-bar highlight {\n  background: #ffffff;\n  border-radius: 2px;\n  border: none;\n}\n\n#macos-seek-bar slider {\n  background: #ffffff;\n  border: none;\n  border-radius: 20%;\n  min-width: 1px;\n  min-height: 8px;\n  margin: -3px;\n  box-shadow: 0 1px 3px alpha(#000, 0.3);\n  transition: all 0.1s ease;\n}\n\n#macos-seek-bar slider:hover {\n  min-width: 4px;\n  min-height: 14px;\n  margin: -5px;\n  box-shadow: 0 2px 6px alpha(#000, 0.4);\n}\n\n#macos-position-label,\n#macos-length-label {\n  font-size: 11px;\n  color: alpha(#999, 0.9);\n  font-weight: 400;\n}\n\n/* macOS control buttons - more compact */\n#macos-button-box {\n  margin-top: -1px; /* Reduced top margin */\n}\n\n#macos-control-button {\n  background: transparent;\n  border: none;\n  border-radius: 50%;\n  min-width: 28px; /* Slightly smaller */\n  opacity: 0.7;\n  min-height: 28px;\n  padding: 5px; /* Reduced padding */\n  color: #ffffff;\n  transition: all 0.15s ease;\n}\n\n#macos-control-button:hover {\n  opacity: 1;\n}\n\n#macos-play-button {\n  background: transparent;\n  border: none;\n  border-radius: 50%;\n  min-width: 36px; /* Slightly smaller */\n  opacity: 0.7;\n  min-height: 36px;\n  padding: 7px; /* Reduced padding */\n  color: #ffffff;\n  transition: all 0.15s ease;\n}\n\n#macos-play-button:hover {\n  opacity: 1;\n}\n\n/* macOS player switcher dots - compact horizontal layout */\n#macos-stack-buttons-box {\n  margin: 2px 5px; /* Minimal margins */\n}\n\n.macos-switcher-dot {\n  background: alpha(#fff, 0.3);\n  /* border: none; */\n  border-radius: 50%;\n  min-width: 12px; /* Smaller dots */\n  min-height: 12px;\n  margin: 2px; /* Reduced margins */\n  /* transition: all 0.2s ease; */\n}\n\n.macos-switcher-dot:hover {\n  background: alpha(#fff, 0.5);\n}\n\n.macos-switcher-dot.active {\n  background: #ffffff;\n  box-shadow: 0 0 0 1px alpha(#fff, 0.3); /* Smaller glow */\n}\n\n/* Per app-volume-item */\n\n.compact-app-volume-item {\n  padding-bottom: 5px;\n  padding-right: 14px;\n  border-bottom: 1px solid alpha(#fff, 0.1);\n}\n#app-icon {\n  margin-top: 8px;\n}\n.app-name-compact {\n  font-size: 14px;\n  font-weight: bold;\n  margin-top: 8px;\n}\n\n#compact-app-volume-slider scale {\n  margin-right: 8px;\n  padding-right: 8px;\n}\n\n#expanded-seek-bar {\n  background-color: transparent;\n  margin: 8px 0 4px 0;\n  min-height: 10px;\n}\n\n#expanded-seek-bar trough {\n  min-width: 200px;\n\n  min-height: 2px;\n  border-radius: 2px;\n  background-color: alpha(#fff, 0.25);\n  border: none;\n}\n\n#expanded-seek-bar highlight {\n  background: #ffffff;\n  border-radius: 2px;\n  border: none;\n}\n\n#expanded-seek-bar slider {\n  background: #ffffff;\n  border: none;\n  border-radius: 20%;\n  min-width: 1px;\n  min-height: 10px;\n  margin: -3px;\n  box-shadow: 0 1px 3px alpha(#000, 0.3);\n  transition: all 0.1s ease;\n}\n\n#expanded-seek-bar slider:hover {\n  min-width: 4px;\n  min-height: 14px;\n  margin: -5px;\n  box-shadow: 0 2px 6px alpha(#000, 0.4);\n}\n\n/* macOS-style WiFi interface */\n#wifi-connections,\n#bluetooth-connections {\n  /* background-color: rgba(255, 255, 255, 0.08); */\n  background-color: alpha(#fff, 0.05);\n  box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);\n  border: 1px solid alpha(#111, 0.4);\n  border-radius: 8px;\n  /* padding: 0.5rem; */\n  /* border-radius: 12px; */\n  padding: 10px;\n}\n\n/* WiFi network slots - macOS style */\n#wifi-network-slot button {\n  border-radius: 8px;\n  padding: 4px 4px;\n  /* min-width: 280px; */\n  margin: 2px 0;\n}\n\n#wifi-network-slot button:hover {\n  background-color: rgba(255, 255, 255, 0.1);\n}\n\n/* WiFi-specific device icon styles */\n#wifi-connections #device-icon {\n  background-color: rgba(255, 255, 255, 0.1);\n}\n\n#wifi-connections #device-icon.connected {\n  background-color: #007aff;\n  color: #ffffff;\n}\n\n#wifi-network-name {\n  color: #ffffff;\n  font-size: 14px;\n  font-weight: 500;\n}\n\n#wifi-lock-icon {\n  color: rgba(255, 255, 255, 0.6);\n  margin-left: 8px;\n}\n\n/* Other Networks and Settings buttons */\n#wifi-other-button {\n  background: transparent;\n  border: none;\n  padding: 8px 6px;\n  margin-bottom: -12px;\n  margin-top: -6px;\n  border-radius: 8px;\n  /* transition: background-color 0.2s ease; */\n}\n\n#wifi-other-button:hover {\n  background-color: rgba(255, 255, 255, 0.1);\n}\n#wifi-other-button:last-child {\n  margin-bottom: 3px;\n}\n\n#device-button {\n  /* background-color: #000; */\n  border-radius: 8px;\n}\n.device-slot:hover {\n  background-color: rgba(255, 255, 255, 0.05);\n}\n\n.button-hovered {\n  background-color: rgba(255, 255, 255, 0.1);\n}\n\n#wifi-other-device label,\n#wifi-other-button label {\n  font-size: 12px;\n  font-weight: bold;\n}\n#app-volume-header {\n  font-size: 16px;\n  padding-left: 5px;\n  margin-top: -2px;\n  font-weight: bold;\n  color: #ffffff;\n}\n\n.wifi-icon-box {\n  background-color: #aaaaaa;\n  border-radius: 50%;\n  padding: 2px;\n  /* margin-right: 8px; */\n}\n.wifi-icon-box-connected {\n  background-color: #007aff;\n  border-radius: 50%;\n  padding: 2px;\n  /* margin-right: 8px; */\n}\n\n/* #flight-icon { */\n/*   margin-left: -10px; */\n/* } */\n\n.title-widget {\n  /* margin-top: -14px; */\n  /* margin-left: 5px; */\n  font-size: 13px;\n  font-family: \"SF Pro Rounded\";\n  font-weight: bold;\n}\n.status-label {\n  font-size: 11px;\n  font-weight: 500;\n  color: #aaa;\n  /* margin-top: -28px; */\n  /* margin-left: 5px; */\n  font-family: \"SF Pro Rounded\";\n  /* font-weight: bold; */\n  /* margin-bottom: 2px; */\n}\n\n#app-control-box {\n  min-width: 100px;\n}\n#caffeine-widget,\n#flight-widget {\n  padding: 0px 0 5px 0;\n  min-width: 50px;\n}\n"
  },
  {
    "path": "styles/dock.css",
    "content": "#dock {\n  background-color: alpha(#fff, 0.07);\n  padding: 4px 4px;\n  margin: 4px 4px;\n  border: none;\n  border-radius: 16px;\n  transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);\n}\n\n#dock.shown {\n  transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);\n}\n\n#dock_item_main_container {\n  transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);\n}\n\n#dock_item.shown:hover #dock_item_main_container {\n  margin-top: -12px;\n}\n\n#dock_item.shown.semi_hovered #dock_item_main_container {\n  margin-top: -7px;\n}\n/**/\n/* #dock_item.shown.semi_hovered #dock_item_indicator { */\n/*   margin-top: 11px; */\n/* } */\n\n/* #dock_item.shown.activated:hover #dock_item_main_container { */\n/*   margin-top: -12px; */\n/* } */\n/**/\n/* #dock_item.shown.activated:hover #dock_item_indicator { */\n/*   margin-top: 16px; */\n/* } */\n/**/\n/* #dock_item.shown.activated #dock_item_main_container { */\n/*   margin-top: -4px; */\n/* } */\n\n#dock_item.shown.activated #dock_item_icon {\n  opacity: 1;\n  /* background-color: #01458e; */\n}\n\n/* #dock_item.shown.activated #dock_item_indicator { */\n/*   min-width: 4px; */\n/*   margin-top: 8px; */\n/*   background-color: #aac7ff; */\n/* } */\n\n#dock_item_icon {\n  border-radius: 13px;\n  opacity: 0.7;\n  transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);\n}\n\n/* #dock_item_indicator { */\n/*   transition: all 0.15s ease-out; */\n/*   min-height: 4px; */\n/*   border-radius: 4px; */\n/* } */\n\n#dock_item {\n  transition: margin 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);\n}\n\n#dock_item.shown {\n  padding-left: 4px;\n  opacity: 1;\n}\n\n#dock_item.shown:first-child {\n  padding-left: 0px;\n}\n\n.dock_separator {\n  transition: all 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);\n  min-width: 1.5px;\n  min-height: 50px;\n  margin: 0px 4px;\n  border-radius: 2px;\n  background-color: #2b5ea7;\n}\n\n.dock_separator.hidden {\n  min-width: 0px;\n  background-color: transparent;\n  margin: 0px;\n}\n\n/* #dock_item.shown:not(.activated) { */\n/*   min-width: 0px; */\n/*   min-height: 0px; */\n/*   margin-top: 0px; */\n/*   background-color: transparent; */\n/* } */\n"
  },
  {
    "path": "styles/dropdown.css",
    "content": "#dropdown-menu {\n  background-color: transparent;\n\n  border-radius: 0.75rem;\n  margin: 0;\n}\n\n#dropdown-options {\n  background-color: alpha(#000, 0.3);\n  box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);\n  border: 1px solid alpha(#111, 0.4);\n  border-radius: 0.75rem;\n  padding: 0.5rem;\n}\n\n#dropdown-option {\n  background-color: transparent;\n  border-radius: 5px;\n  padding: 0px 10px;\n  margin: 1px 2px;\n  transition: all 0ms ease-in-out;\n}\n\n#dropdown-option:hover {\n  transition: all 20ms ease-in-out;\n  color: #222;\n  background-color: alpha(#2369ff, 1);\n}\n\n#dropdown-option-label {\n  font-size: 13px;\n  margin: 0px;\n  color: #fff;\n  font-weight: 400;\n}\n\n#dropdown-option-label:first-child {\n  padding-right: 5rem;\n}\n\n#dropdown-option:hover #dropdown-option-keybind {\n  color: #fff;\n}\n\n#dropdown-option-keybind {\n  font-weight: 500;\n  color: #aaa;\n}\n\n/* Divider */\n\n#dropdown-divider-box {\n  background-color: transparent;\n  border-radius: 5px;\n  padding: 0px 10px;\n  margin: 2px;\n}\n#dropdown-divider {\n  border-bottom: 1px solid alpha(#aaa, 0.3);\n  margin: 2px 0;\n}\n"
  },
  {
    "path": "styles/launcher.css",
    "content": "#launcher {\n  background-color: alpha(#000, 0.3);\n  padding: 0;\n  border-radius: 12px;\n  min-width: 640px;\n  border: 1px solid rgba(255, 255, 255, 0.1);\n}\n\n/* #launcher-search { */\n/*   color: #000; */\n/* } */\n#header_box {\n  padding: 0px 20px 0 20px;\n  color: #000;\n}\n\n#close-button,\n#config-button {\n  background-color: transparent;\n  border-radius: 6px;\n  padding: 6px;\n  transition: all 0.15s ease;\n}\n\n#close-button:hover,\n#close-button:focus,\n#config-button:hover,\n#config-button:focus {\n  background-color: rgba(255, 255, 255, 0.1);\n  border-radius: 6px;\n}\n\n#close-button.focused,\n#config-button.focused {\n  background-color: rgba(0, 122, 255, 0.2);\n  border-radius: 6px;\n  box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.4);\n}\n\n#close-button.focused #close-label {\n  color: #007aff;\n}\n\n#config-button.focused #config-label {\n  color: #007aff;\n}\n\n#close-button:active {\n  background-color: rgba(255, 255, 255, 0.2);\n  border-radius: 6px;\n}\n\n#close-label {\n  color: rgba(255, 255, 255, 0.7);\n  font-size: 18px;\n}\n\n#close-button:active #close-label {\n  color: rgba(255, 255, 255, 0.9);\n}\n\n#config-button:active {\n  background-color: rgba(255, 255, 255, 0.2);\n  border-radius: 6px;\n}\n\n#config-label {\n  color: rgba(255, 255, 255, 0.7);\n  font-size: 18px;\n}\n\n#config-button:active #config-label {\n  color: rgba(255, 255, 255, 0.9);\n}\n\n#launcher-icon-label {\n  font-size: 20px;\n  padding: 6px;\n  color: rgba(255, 255, 255, 0.8);\n}\n\n#launcher-search {\n  font-weight: 400;\n  font-size: 36px;\n  background-color: transparent;\n  color: rgba(255, 255, 255, 0.95);\n  border: none;\n  border-radius: 0;\n  padding: 12px 0;\n  margin: 0;\n}\n\n#launcher-search:focus {\n  background-color: transparent;\n  box-shadow: none;\n  border: none;\n}\n\n#launcher-search selection {\n  color: white;\n  background-color: rgba(0, 122, 255, 0.8);\n}\n\n#launcher-results-scroll {\n  margin: 0;\n  border-radius: 0;\n  background: transparent;\n  padding: 0 20px 20px 20px;\n}\n\n#launcher-results-scroll scrollbar {\n  border-radius: 0;\n  background-color: transparent;\n  padding: 0;\n  margin: 0;\n  min-width: 0;\n}\n\n#launcher-results-scroll scrollbar slider {\n  border-radius: 2px;\n  min-width: 4px;\n  min-height: 20px;\n  background-color: rgba(255, 255, 255, 0.3);\n  margin: 0;\n}\n\n#launcher-results-scroll scrollbar:hover slider {\n  background-color: rgba(255, 255, 255, 0.5);\n}\n\n#launcher-results {\n  background: transparent;\n  margin-top: 8px;\n}\n\n#launcher-result-item {\n  border-radius: 8px;\n  padding: 12px 16px;\n  margin: 2px 0;\n  min-height: 64px;\n  transition: all 0.15s ease;\n  background: transparent;\n}\n\n@keyframes loadSlot {\n  0% {\n    opacity: 0;\n  }\n\n  100% {\n    opacity: 1;\n  }\n}\n\n#launcher-result-item:focus,\n#launcher-result-item:selected,\n#launcher-result-item:hover,\n#launcher-result-item.selected {\n  border-radius: 8px;\n  background-color: rgba(0, 122, 255, 0.8);\n  padding: 12px 16px;\n  margin: 2px 0;\n}\n\n#launcher-result-item.selected #result-item-title {\n  color: white;\n  font-weight: 600;\n}\n\n#launcher-result-item.selected #result-item-subtitle {\n  color: rgba(255, 255, 255, 0.8);\n}\n\n#launcher-result-item.selected #result-item-plugin {\n  color: rgba(255, 255, 255, 0.6);\n}\n\n#result-item-main {\n  min-height: 56px;\n}\n\n#launcher-result-item {\n  min-height: 56px;\n}\n\n#result-item-icon {\n  min-width: 56px;\n  min-height: 56px;\n  margin-right: 16px;\n  border-radius: 12px;\n}\n\n#result-item-title {\n  font-size: 18px;\n  font-weight: 500;\n  color: rgba(255, 255, 255, 0.95);\n  margin-top: 4px;\n  margin-bottom: 2px;\n}\n\n#result-item-subtitle {\n  font-size: 14px;\n  color: rgba(255, 255, 255, 0.6);\n  margin-bottom: 4px;\n}\n\n#result-item-plugin {\n  font-size: 12px;\n  color: rgba(255, 255, 255, 0.4);\n  font-style: normal;\n  margin-bottom: 4px;\n  opacity: 1;\n  font-weight: 400;\n}\n\n#network-password-entry {\n  border: 1px solid rgba(255, 255, 255, 0.2);\n  background: rgba(255, 255, 255, 0.05);\n  color: rgba(255, 255, 255, 0.9);\n  padding: 12px;\n  border-radius: 8px;\n  margin-bottom: 8px;\n  font-size: 14px;\n}\n\n#network-password-entry:focus {\n  border: 1px solid rgba(0, 122, 255, 0.6);\n  background: rgba(255, 255, 255, 0.1);\n  box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2);\n}\n"
  },
  {
    "path": "styles/lock.css",
    "content": "#lockscreen-bg {\n  background-size: cover;\n}\n#indicator-box {\n  margin-right: 10px;\n  margin-top: 4px;\n}\n#container-box {\n  min-height: 50px;\n  min-width: 250px;\n  transition: all 0.7s ease-in-out;\n}\n#password-entry {\n  background-color: alpha(#fff, 0.4);\n  border-radius: 20px;\n  color: white;\n  min-width: 220px;\n  min-height: 40px;\n  padding-right: 5px;\n  padding-left: 5px;\n  font-size: 14px;\n  font-weight: 500;\n  transition: all 0.7s ease-in-out;\n}\n#profile-box {\n  margin-bottom: 10px;\n}\n#username {\n  font-size: 21px;\n  font-weight: bold;\n  margin-top: 5px;\n  transition: all 0.7s ease-in-out;\n}\n#lock-date label {\n  background-color: transparent;\n  color: alpha(#fff, 0.6);\n  border: none;\n  font-size: 30px;\n  margin-top: 80px;\n  font-weight: bold;\n}\n#lock-clock label {\n  background-color: transparent;\n  color: alpha(#fff, 0.6);\n  border: none;\n  font-size: 120px;\n  margin-top: -20px;\n  font-weight: bold;\n}\n\n#face-icon image {\n  margin-bottom: 10px;\n}\n#face-icon {\n  padding-bottom: 10px;\n}\n\n#unlock-box {\n  margin-bottom: 40px;\n}\n#unlock-text {\n  font-size: 15px;\n  color: alpha(#fff, 0.6);\n  font-weight: bold;\n}\n"
  },
  {
    "path": "styles/notification-center.css",
    "content": "#noti-center-box {\n  background-color: transparent;\n\n  /* border: 1px solid alpha(#111, 0.3); */\n  border-radius: 20px;\n  margin-right: 10px;\n  /* padding: 20px; */\n  /* margin: 6px; */\n}\n\n#noti-clear-button {\n  /* min-width: 10px; */\n  background-color: alpha(#1c2328, 0.05);\n  min-height: 25px;\n  min-width: 70px;\n  border: 1px solid #525155;\n  border-radius: 15px;\n  margin-top: 10px;\n}\n\n#notif-clear-button label {\n  font-size: 15px;\n}\n\n#noti-clear-button:hover {\n  background-color: alpha(#fff, 0.09);\n}\n\n/* Expandable notification group styles */\n#notification-group {\n  margin: 4px 0;\n}\n\n/* Single notification (no stacking) */\n#single-notification-content {\n  background-color: alpha(#1c2328, 0.05);\n  border: 1.5px solid #525155;\n  border-radius: 12px;\n  padding: 12px;\n  transition: all 0.2s ease;\n}\n\n#single-notification-content:hover {\n  background-color: alpha(#999, 0.9);\n  border: 1.5px solid #525155;\n}\n\n/* Stacked notification container */\n#notification-stack-container {\n  background-color: transparent;\n  margin: 8px 0;\n}\n\n/* Bottom shadow layer (deepest) - offset to show stacking from below */\n#stack-shadow-bottom {\n  background-color: alpha(#1c2328, 0.05);\n  border-top: 1px solid #525155;\n  border-radius: 12px;\n  opacity: 1;\n  min-height: 16px;\n  margin-left: 20px;\n  margin-right: -4px;\n  margin-bottom: -10px;\n}\n\n/* Middle shadow layer */\n#stack-shadow-middle {\n  background-color: alpha(#1c2328, 0.05);\n\n  border-top: 1px solid #525155;\n  border-radius: 12px;\n  opacity: 1;\n  min-height: 20px;\n  margin-left: 10px;\n  margin-right: -2px;\n  margin-bottom: -14px;\n}\n\n/* Main notification content (top layer) */\n#stack-main-notification {\n  background-color: alpha(#1c2328, 0.05);\n\n  border: 1.2px solid #525155;\n  border-radius: 12px;\n  padding: 12px;\n  transition: all 0.2s ease;\n}\n\n/* Hover effects for stacked notifications */\n#notification-stack-container:hover #stack-main-notification {\n  background-color: alpha(#525155, 0.1);\n  border: 1px solid #525155;\n}\n\n#notification-stack-container:hover #stack-shadow-middle {\n  opacity: 0.7;\n}\n\n#notification-stack-container:hover #stack-shadow-bottom {\n  opacity: 0.5;\n}\n\n/* Collapsed notification state */\n#notification-content-collapsed {\n  background-color: alpha(#000, 0.05);\n  border: 1px solid #525155;\n  border-radius: 12px;\n  padding: 12px;\n  margin: 2px 0;\n  transition: all 0.2s ease;\n}\n\n#notification-content-collapsed:hover {\n  background-color: alpha(#525155, 0.1);\n  border: 1px solid #525155;\n}\n\n/* Notification count badge */\n#notification-count-label {\n  background-color: alpha(#fff, 0.2);\n  color: rgba(255, 255, 255, 0.8);\n  border-radius: 10px;\n  padding: 2px 6px;\n  font-size: 10px;\n  font-weight: 500;\n  min-width: 16px;\n}\n\n/* Expanded notification group header */\n#notification-group-header {\n  background-color: alpha(#000, 0.05);\n  border: 1px solid #525155;\n  border-radius: 12px 12px 0 0;\n  padding: 8px 12px;\n  margin: 2px 0 0 0;\n}\n\n#notification-group-title {\n  color: #ffffff;\n  font-weight: bold;\n  font-size: 14px;\n}\n\n#notification-show-less {\n  background-color: transparent;\n  color: rgba(255, 255, 255, 0.7);\n  border: 1px solid alpha(#fff, 0.2);\n  border-radius: 6px;\n  padding: 2px 8px;\n  font-size: 11px;\n  margin-right: 8px;\n}\n\n#notification-show-less:hover {\n  background-color: alpha(#999, 0.7);\n  color: rgba(255, 255, 255, 0.9);\n}\n\n#notification-close-all {\n  background-color: transparent;\n  color: rgba(255, 255, 255, 0.7);\n  border: 1px solid alpha(#fff, 0.2);\n  border-radius: 6px;\n  padding: 2px 8px;\n  font-size: 12px;\n  min-width: 24px;\n}\n\n#notification-close-all:hover {\n  background-color: alpha(#ff5555, 0.2);\n  color: rgba(255, 255, 255, 0.9);\n  border-color: alpha(#ff5555, 0.4);\n}\n\n/* Expanded notification list */\n#notification-group-expanded {\n  border: 1px solid #525155;\n  border-top: none;\n  border-radius: 0 0 12px 12px;\n  /* background-color: alpha(#000, 0.02); */\n}\n\n/* Stacked notification group styles */\n.stacked-notification-group {\n  margin: 4px 0;\n}\n\n.stacked-notification-container {\n}\n\n.stacked-notification-item {\n  background-color: alpha(#000, 0.05);\n  border: 1px solid #525155;\n  border-radius: 12px;\n  transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);\n}\n\n#notif-clear-btn {\n  border-radius: 10px;\n  font-size: 12px;\n  padding: 2px;\n}\n\n#notif-clear-btn:hover {\n  background-color: var(--surface-bright);\n}\n\n#stacked-notification-item:hover {\n  background-color: alpha(#525155, 0.1);\n  border: 1px solid #525155;\n}\n\n/* Stack count indicator */\n#stack-count-indicator {\n  font-size: 10px;\n  color: rgba(255, 255, 255, 0.7);\n  margin: 2px 8px;\n  font-weight: 500;\n}\n\n/* Expanded view styles - notifications use their default styling */\n#expanded-notification-list {\n  margin-top: 8px;\n}\n\n/* Make expanded notifications clickable with subtle hover effect */\n#expanded-notification-list:hover {\n  background-color: alpha(#111, 0.9);\n  border-radius: 8px;\n  transition: background-color 0.2s ease;\n}\n\n#notification-group-title {\n  font-size: 19px;\n  margin: 10px;\n  font-weight: bold;\n}\n#notification-show-less {\n  background-color: alpha(#1c2328, 0.05);\n  font-size: 12px;\n  margin: 10px;\n  border-radius: 15px;\n}\n\n#notification-close-all {\n  margin: 10px 10px 10px 0;\n  background-color: alpha(#1c2328, 0.05);\n  border-radius: 15px;\n}\n\n#notif-close-button {\n  background-color: transparent;\n}\n\n#notification-group-expanded {\n  background-color: transparent;\n  border: none;\n}\n\n#notification-content {\n  background-color: transparent;\n}\n#notification-count-label {\n  background-color: transparent;\n  color: white;\n  font-size: 13px;\n}\n#notification-centre-notifs {\n  background-color: alpha(#1c2328, 0.05);\n  min-height: 40px;\n  padding: 10px;\n  border: 1px solid #525155;\n  border-radius: 12px;\n}\n\n#notification-centre-notifs:last-child {\n  margin-bottom: 10px;\n}\n\n#notification-close-header {\n  margin-right: 10px;\n}\n#notification-close,\n#notification-close-header {\n  min-width: 23px;\n  min-height: 23px;\n  border-radius: 50%;\n}\n#notification-close:hover {\n  background-color: alpha(#666, 0.8);\n  min-width: 23px;\n  min-height: 23px;\n  border-radius: 50%;\n}\n#notification-close-summery:hover .notification-close-header button {\n  background-color: transparent;\n}\n"
  },
  {
    "path": "styles/notification.css",
    "content": "#notification {\n  padding: 10px;\n  background-color: alpha(#fff, 0.03);\n  border-radius: 8px;\n  margin-top: 10px;\n  margin-right: 10px;\n  /* margin: 5px; */\n}\n\n#notification-close-button {\n  background-color: alpha(#999, 0.3);\n  border-radius: 8px;\n}\n#notification-close-button:hover {\n  background-color: alpha(#2369ff, 0.9);\n}\n\n#notification-action-buttons button {\n  background-color: alpha(#999, 0.3);\n  padding: 4px;\n  border-radius: 7px;\n  transition: background-color 0.1s ease;\n}\n\n#notification-action-buttons button:hover {\n  background-color: alpha(#2369ff, 0.9);\n}\n\n#notification-action-buttons button:active {\n  background-color: var(--primary);\n}\n\n#notification-action-buttons button:active #button-label {\n  color: var(--shadow);\n}\n\n#notification-image image {\n  border-radius: 16px;\n  color: var(--on-surface);\n}\n\n#notification-summary,\n#button-label {\n}\n\n#notification-summary {\n  font-weight: bold;\n  font-size: 16px;\n  /* color: var(--primary); */\n}\n\n#notification-app-name {\n  color: var(--outline);\n  font-weight: bold;\n}\n\n#action-button {\n  margin-top: 8px;\n}\n\n#notif-close-button {\n  background-color: var(--surface);\n  border-radius: 16px;\n  padding: 4px;\n  transition: background-color 0.1s ease;\n}\n\n#notif-close-button:hover,\n#notif-close-button:focus {\n  background-color: var(--surface-bright);\n}\n\n#notif-close-button:active {\n  background-color: var(--red-dim);\n}\n\n#notif-close-label {\n  color: var(--red-dim);\n  font-size: 20px;\n}\n\n#notif-close-button:active #notif-close-label {\n  color: var(--shadow);\n}\n"
  },
  {
    "path": "styles/osd.css",
    "content": "#osd {\n  background-color: alpha(#fff, 0.09);\n  padding: 12px 20px;\n  margin: 70px;\n  min-height: 200px;\n  border-radius: 16px;\n}\n\n#osd scale {\n  min-width: 180px;\n}\n\n#osd trough {\n  background: var(--surface);\n  min-height: 15px;\n  margin-right: 4px;\n  transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);\n  border-radius: 16px;\n}\n\n#osd trough highlight {\n  border-radius: 100px;\n  background: var(--primary);\n  transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);\n}\n\n#osd.muted trough highlight,\n#osd.muted slider,\n#osd scale.muted trough highlight,\n#osd scale.muted slider {\n  background-color: var(--surface-bright);\n}\n\n#brighntess-icons.muted,\n#vol-icon.muted,\n#mic-icon.muted {\n  color: var(--outline);\n}\n"
  },
  {
    "path": "styles/panel.css",
    "content": "#panel {\n  background-color: alpha(#fff, 0.07);\n\n  /* border-bottom: 1px solid alpha(#010101, 0.025); */\n  margin-top: -4px;\n  margin-bottom: -4px;\n  transition: all 120ms ease-in-out;\n}\n\n#panel-icon {\n  font-size: 18px;\n}\n#global-title-button {\n  font-weight: bold;\n}\n\n#panel-button {\n  margin: 0 4px 0 4px;\n  min-width: 20px;\n  min-height: 20px;\n}\n\n#panel-button:hover {\n  background-color: alpha(#fff, 0.1);\n}\n\n#menubar {\n  margin: 4px 0;\n}\n\n#menubar label {\n  font-size: 13px;\n  /* font-weight: 400; */\n}\n#battery-label {\n  font-weight: 500;\n  margin-right: 1px;\n}\n#battery-button {\n  padding-right: 3px;\n  padding-left: 3px;\n  border-radius: 8px;\n}\n\n#battery-button:hover {\n  background-color: alpha(#fff, 0.1);\n}\n\n#network-button {\n  padding-right: 3px;\n  padding-left: 3px;\n  border-radius: 8px;\n}\n\n#network-button:hover {\n  background-color: alpha(#fff, 0.1);\n}\n\n#bt-button {\n  padding-right: 3px;\n  padding-left: 3px;\n  border-radius: 8px;\n}\n\n#bt-button:hover {\n  background-color: alpha(#fff, 0.1);\n}\n\n#tray-button {\n  border-radius: 8px;\n  margin: 0 4px 0 0px;\n  min-width: 20px;\n  min-height: 20px;\n}\n\n#tray-button:hover {\n  background-color: alpha(#fff, 0.1);\n}\n\n#date-time {\n  margin: 0 10px;\n  font-weight: 500;\n}\n\n#modules-left,\n#modules-right {\n  margin: 0 5px;\n  padding: 3px 0;\n}\n\n.button {\n  border-radius: 5px;\n  padding: 0 5px;\n  margin: 0 2.5px;\n  background: radial-gradient(alpha(#aaa, 0) 0%, transparent, transparent);\n  transition: all 100ms ease-in-out;\n}\n\n.button:hover {\n  background: radial-gradient(alpha(#aaa, 0.2) 100%, transparent, transparent);\n}\n\n#modus-button label {\n  font-size: 17px;\n  margin: 0 10px;\n}\n\n#workspace-indicator {\n  margin: 0 4px;\n}\n\n#workspaces label {\n  font-family: \"SF Pro Rounded\";\n  color: white; /* fully transparent text */\n}\n#workspaces > button {\n  padding-top: 0px;\n  padding-right: 16px;\n  font-family: \"SF Pro Rounded\";\n  padding-left: 16px;\n\n  margin: 6px 0px;\n  min-width: 12px;\n  border: 1px solid var(--outline);\n  border-radius: 8px;\n}\n\n#workspaces > button:hover {\n  background-color: alpha(#fff, 0.1);\n}\n\n#workspaces > button.active {\n  background-color: alpha(#fff, 0.9);\n  /* font-weight: bold; */\n}\n\n#workspaces > button.active label {\n  color: alpha(#000, 1);\n  font-family: \"SF Pro Rounded\";\n}\n#workspaces > button.urgent {\n  background-color: alpha(var(--on-error), 0.7);\n}\n\n#workspaces > button.empty {\n  background-color: transparent;\n}\n"
  },
  {
    "path": "styles/player.css",
    "content": "#player-stack-button {\n  border-radius: 5px;\n  min-width: 7px;\n  min-height: 7px;\n  margin: 10px 3px;\n}\n\n#button-box-c {\n  /* margin-left: -10px; */\n\n  padding-right: 15px;\n}\n\n#player-stack-button:hover {\n  background-color: #646464;\n  box-shadow: inset rgba(255, 255, 255, 0.5) 0 0 10px;\n}\n\n#player-stack-button.active {\n  border-radius: 5px;\n  background-color: rgb(221.25, 221.25, 223.65);\n}\n\n#outer-player-box {\n  min-height: 110px;\n  min-width: 320px;\n  border-radius: 10px;\n  margin: 0 6px;\n  background-color: alpha(#000, 0.3);\n  box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);\n}\n\n#outer-player-box-c {\n  min-height: 55px;\n  min-width: 245px;\n  border-radius: 8px;\n  /* margin: 0 6px; */\n  /* background-color: alpha(#000, 0.3); */\n  /* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */\n}\n#outer-no-player-box-c {\n  min-height: 55px;\n  min-width: 310px;\n  border-radius: 8px;\n  /* margin: 0 6px; */\n  /* background-color: alpha(#000, 0.3); */\n  /* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */\n}\n#box-c {\n  background-color: alpha(#000, 0.08);\n  box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);\n  border: 1px solid alpha(#111, 0.4);\n  border-radius: 12px;\n  margin-top: 0.5rem;\n  margin-left: 4px;\n  margin-right: 4px;\n  margin-bottom: 0.5rem;\n\n  min-height: 50px;\n  min-width: 330px;\n  /* border-radius: 8px; */\n  /* margin: 0 6px 10px; */\n  /* border: 0.5px solid alpha(#000, 0.4); */\n  /* background-color: alpha(#999, 0.1); */\n  /* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */\n}\n#player-box:disabled highlight,\n#player-box:disabled progress {\n  background-color: rgba(205, 214, 244, 0.6);\n  background-image: none;\n}\n\n#player-info-box-c {\n  margin-left: 10px;\n}\n#player-info-box {\n  margin-left: 10px;\n}\n\n#player-title-c {\n  /* margin-top: 18px; */\n  font-weight: bold;\n  font-size: 14px;\n}\n#player-title {\n  margin-top: 18px;\n  font-weight: 700;\n  font-size: 16px;\n}\n#player-title-no {\n  font-weight: 700;\n  margin-left: 120px;\n  font-size: 16px;\n}\n#player-app-icon {\n  margin-bottom: 10px;\n}\n\n#player-artist-c {\n  margin-top: -5px;\n  color: #999;\n  font-size: 12px;\n}\n#player-artist,\n#player-album {\n  font-weight: 500;\n  font-size: 12px;\n}\n\n#player-controls {\n  margin-top: 5px;\n  margin-bottom: 15px;\n}\n\n.player-icon {\n  font-size: 16px;\n}\n\n.player-button {\n  padding: 1px;\n}\n\n#player-box #player-controls #button-box .player-button:disabled {\n  color: #5f5f5f;\n}\n\n#player-button {\n  opacity: 0.5;\n}\n#player-button:hover {\n  opacity: 0.9;\n}\n\n#btn svg {\n  margin: 0 2px;\n}\n.album-image-c {\n  min-width: 50px;\n  min-height: 50px;\n  background-position: center;\n  margin-top: 10px;\n  margin-bottom: 10px;\n  background-size: cover;\n  margin-left: 10px;\n  border-radius: 5px;\n}\n.album-image {\n  min-width: 70px;\n  min-height: 70px;\n  background-position: center;\n  background-size: cover;\n  margin-left: 12px;\n  border-radius: 9px;\n}\n\n#seek-bar slider {\n  background-image: none;\n  background-color: transparent;\n  padding: 0px;\n}\n\n#seek-bar scale {\n  background-color: transparent;\n  margin-top: 10px;\n  border-radius: 10px;\n}\n\n#seek-bar trough {\n  min-width: 10px;\n  border-radius: 99px;\n  background-color: alpha(#666, 0.5);\n  border: 1px solid alpha(#444, 0.3);\n}\n\n#seek-bar highlight {\n  background: alpha(#fff, 0.8);\n  border-radius: 99px;\n}\n"
  },
  {
    "path": "styles/switcher.css",
    "content": "#application-switcher-container {\n  background: var(--shadow);\n  border-radius: 16px;\n}\n\n#application-switcher-view {\n  min-height: 120px;\n  padding: 4px;\n}\n\n#switcher-button {\n  padding: 4px;\n  min-width: 100px;\n}\n\n#window-button {\n  background-color: var(--surface);\n  border-radius: 4px;\n}\n\n#switcher-button:hover {\n  background-color: var(--surface);\n}\n\n#window-button.active {\n  background-color: var(--surface-bright);\n  border: 3px solid var(--surface-bright);\n}\n\n#switcher-button label {\n  color: var(--foreground);\n  font-size: 10px;\n  margin-top: 4px;\n}\n\n#window-row {\n  padding: 18px;\n}\n"
  },
  {
    "path": "styles/todo.css",
    "content": "/* Todo List Styles - matching control center design */\n\n#todo-list-window {\n  /* background-color: alpha(#fff, 0.05); */\n  border-radius: 12px;\n  box-shadow: none;\n  margin: 0;\n}\n\n#todo-main-container {\n  box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);\n  border-radius: 12px;\n  padding: 12px; /* Increased padding */\n  min-height: 500px; /* Ensure minimum height */\n  min-width: 350px; /* Ensure minimum width */\n}\n\n#todo-header {\n  margin-bottom: 8px;\n}\n\n#todo-title {\n  font-size: 16px;\n  font-family: \"SF Pro Rounded\";\n  font-weight: bold;\n  color: #ffffff;\n}\n\n#todo-stats {\n  font-size: 12px;\n  font-weight: 500;\n  color: #999;\n  margin-top: -2px;\n}\n\n/* Add new todo section */\n#todo-add-section {\n  box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);\n  border: 1px solid alpha(#111, 0.4);\n  border-radius: 12px;\n  padding: 1rem;\n  margin-bottom: 8px;\n  min-height: 40px; /* Ensure minimum height */\n}\n\n#new-todo-entry {\n  background-color: rgba(255, 255, 255, 0.1);\n  border: 1px solid rgba(255, 255, 255, 0.2);\n  border-radius: 6px;\n  padding: 8px 12px;\n  color: #ffffff;\n  font-size: 13px;\n  font-family: \"SF Pro Rounded\";\n}\n\n#new-todo-entry:focus {\n  border-color: #007aff;\n  box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3);\n  background-color: rgba(255, 255, 255, 0.15);\n}\n\n#add-todo-button {\n  /* background-color: #007aff; */\n  /* border: 1px solid #007aff; */\n  border-radius: 6px;\n  min-width: 20px;\n  min-height: 12px;\n  color: #ffffff;\n  font-size: 16px;\n}\n\n#add-todo-button:hover {\n  /* background-color: #0056cc; */\n  /* border-color: #0056cc; */\n}\n\n/* Todo items container */\n#todos-scrolled {\n  background-color: transparent;\n  min-height: 200px; /* Minimum height for content */\n}\n\n#todos-container {\n  padding: 4px;\n}\n\n/* Individual todo items */\n#todo-item {\n  background-color: alpha(#000, 0.2); /* More visible background */\n  border: 1px solid alpha(#111, 0.4);\n  border-radius: 12px;\n  padding: 12px;\n  margin: 4px 0;\n  min-height: 40px; /* Ensure minimum height */\n  transition: background-color 0.2s ease;\n}\n\n#todo-item:hover {\n  background-color: alpha(#fff, 0.12);\n}\n\n/* Todo item controls */\n#todo-checkbox {\n  background-color: transparent;\n  border-radius: 50%; /* Circular appearance */\n  min-width: 20px;\n  min-height: 20px;\n  margin-right: 8px;\n}\n\n#todo-checkbox:hover {\n  border-color: #007aff;\n}\n\n/* SVG icon styling for checkboxes and buttons */\n#todo-checkbox-icon {\n  color: #007aff;\n}\n\n#todo-edit-icon,\n#todo-delete-icon,\n#add-todo-icon {\n  color: #ffffff;\n  opacity: 0.8;\n}\n\n#todo-edit-icon:hover,\n#todo-delete-icon:hover,\n#add-todo-icon:hover {\n  opacity: 1;\n}\n\n#todo-text {\n  font-size: 13px;\n  font-weight: 400;\n  color: #ffffff;\n  font-family: \"SF Pro Rounded\";\n}\n\n#todo-date {\n  font-size: 10px;\n  font-weight: 400;\n  color: #999999;\n  font-family: \"SF Pro Rounded\";\n  margin-top: 2px;\n  opacity: 0.8;\n}\n\n.todo-date-text {\n  color: #999999;\n  font-size: 10px;\n}\n\n#todo-text-entry {\n  background-color: rgba(255, 255, 255, 0.1);\n  border: 1px solid rgba(255, 255, 255, 0.2);\n  border-radius: 4px;\n  padding: 4px 8px;\n  color: #ffffff;\n  font-size: 14px;\n  font-family: \"SF Pro Rounded\";\n}\n\n#todo-text-entry:focus {\n  border-color: #007aff;\n  box-shadow: 0 0 0 1px rgba(0, 122, 255, 0.3);\n  background-color: rgba(255, 255, 255, 0.15);\n}\n\n/* Priority, edit, delete buttons */\n#todo-priority,\n#todo-edit,\n#todo-delete {\n  background-color: transparent;\n  border: none;\n  border-radius: 4px;\n  min-width: 20px;\n  min-height: 20px;\n  margin: 0 2px;\n  transition: background-color 0.2s ease;\n}\n\n#todo-priority:hover,\n#todo-edit:hover,\n#todo-delete:hover {\n  background-color: alpha(#fff, 0.1);\n}\n\n#todo-priority-label,\n#todo-edit-label,\n#todo-delete-label {\n  font-size: 12px;\n}\n\n/* Clear completed button */\n#clear-completed-button {\n  background-color: alpha(#fff, 0.1);\n  border: 1px solid alpha(#999, 0.3);\n  border-radius: 8px;\n  padding: 8px 16px;\n  color: #999;\n  font-size: 12px;\n  font-weight: 500;\n  font-family: \"SF Pro Rounded\";\n  margin-top: 8px;\n  transition: background-color 0.2s ease;\n}\n\n#clear-completed-button:hover {\n  background-color: alpha(#fff, 0.15);\n  color: #ffffff;\n}\n\n/* Completed todos styling */\n#todo-item.completed {\n  opacity: 0.6;\n}\n\n#todo-item.completed #todo-text {\n  text-decoration: line-through;\n  color: #999;\n}\n\n/* Priority styling */\n.priority-high #todo-priority-label {\n  color: #ff4444;\n}\n\n.priority-medium #todo-priority-label {\n  color: #ffaa00;\n}\n\n.priority-low #todo-priority-label {\n  color: #44ff44;\n}\n\n/* Scrollbar styling to match control center */\n#todos-scrolled scrollbar {\n  background-color: transparent;\n  border-radius: 8px;\n  margin: 2px;\n  min-width: 8px;\n}\n\n#todos-scrolled scrollbar slider {\n  background-color: alpha(#fff, 0.3);\n  border-radius: 4px;\n  min-width: 6px;\n  margin: 1px;\n}\n\n#todos-scrolled scrollbar slider:hover {\n  background-color: alpha(#fff, 0.5);\n}\n\n#todos-scrolled scrollbar slider:active {\n  background-color: alpha(#fff, 0.7);\n}\n"
  },
  {
    "path": "styles/tray.css",
    "content": "tooltip {\n  border: #474747 solid 1px;\n  border-radius: 0.75rem;\n  background-color: alpha(#fff, 0.05);\n}\n\n@keyframes tooltipShow {\n  from {\n    opacity: 0;\n  }\n\n  to {\n    opacity: 1;\n  }\n}\n\ntooltip > * {\n  padding: 3px 10px;\n  border-radius: 0.75rem;\n}\n\nmenu {\n  border: #474747 solid 1px;\n  border-radius: 0.75rem;\n  transition: all 100ms ease-in-out;\n  background-color: alpha(#fff, 0.05);\n}\n\nmenu > menuitem {\n  padding: 5px 10px;\n  margin: 1px 2px;\n  font-size: 13px;\n  border-bottom: #474747 solid 1px;\n}\n\nmenu > menuitem:last-child {\n  border-bottom: none;\n}\nmenu > menuitem:hover {\n  transition: all 20ms ease-in-out;\n  font-size: 13px;\n  border-radius: 4px;\n  color: #222;\n  background-color: alpha(#2369ff, 1);\n}\n\nmenu > menuitem:hover > label {\n  color: #fff;\n  font-weight: 400;\n}\n"
  },
  {
    "path": "styles/widgets.css",
    "content": "/* Weather Widget CSS */\n#weather-container {\n  background: linear-gradient(to bottom, #202020, #141414);\n  margin: 5px 5px 20px 5px;\n  min-width: 170px;\n  min-height: 170px;\n  border-radius: 16px;\n  transition: background 0.8s ease-in-out;\n}\n\n#weather-widget label {\n  margin-left: 12px;\n}\n\n#city {\n  margin-top: 10px;\n  color: var(--blue-dim);\n  font-weight: 600;\n  font-size: 20px;\n}\n#temperature {\n  font-size: 50px;\n  font-weight: 400;\n}\n\n#condition-emoji {\n  margin-top: 5px;\n  font-size: 16px;\n}\n\n#condition {\n  font-size: 15px;\n  font-weight: 600;\n}\n#feels-like {\n  font-weight: 600;\n  font-size: 15px;\n}\n/* # */\n\n/* Calendar Widget */\n#calendar-box-widget {\n  background: linear-gradient(to bottom, #202020, #141414);\n  margin: 5px 20px 20px 10px;\n  min-width: 170px;\n  min-height: 170px;\n  border-radius: 16px;\n}\n\n#calendar-widget {\n  margin: 5px 10px 0px 10px;\n}\n\n#calendar-month {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--blue-dim);\n  margin-bottom: 6px;\n}\n\n#calendar-days-header {\n  margin-bottom: 4px;\n}\n\n#calendar-day-header {\n  font-size: 12px;\n  font-weight: 600;\n  color: #ffffff;\n  min-width: 18px;\n  min-height: 10px;\n}\n\n#calendar-day-header-weekend {\n  font-size: 12px;\n  font-weight: 600;\n  color: #8e8d93;\n  min-width: 18px;\n  min-height: 10px;\n}\n\n#calendar-day {\n  font-size: 12px;\n  margin: 1.4px 0px 1.4px 0px;\n  font-weight: 500;\n  color: #ffffff;\n  min-width: 18px;\n  min-height: 10px;\n}\n\n#calendar-day-weekend {\n  font-size: 12px;\n  font-weight: 500;\n  color: #8e8d93;\n  min-width: 18px;\n  min-height: 16px;\n}\n\n#calendar-day-today {\n  font-size: 12px;\n  font-weight: 700;\n  color: #191919;\n  background-color: var(--blue-dim);\n  border-radius: 50%;\n  min-width: 18px;\n  min-height: 16px;\n  padding: 2px;\n}\n\n#calendar-day-empty {\n  min-width: 18px;\n  min-height: 16px;\n}\n\n#calendar-grid {\n  margin-top: 2px;\n}\n\n/* Date Widget CSS */\n#date-widget label {\n  margin-top: 10px;\n}\n#day label,\n#month label {\n  color: var(--foreground);\n  padding: 5px;\n  margin-top: 10px;\n  font-size: 30px;\n  color: alpha(#fff, 0.9);\n  font-weight: 600;\n}\n\n#date label {\n  color: alpha(#fff, 0.9);\n\n  margin-top: -10px;\n  font-size: 90px;\n  font-weight: 600;\n}\n#day label {\n  color: alpha(var(--blue-dim), 0.9);\n}\n\n#date-container {\n  background: linear-gradient(to bottom, #202020, #141414);\n  margin: 5px 10px 20px 5px;\n  min-width: 170px;\n  min-height: 170px;\n  border-radius: 16px;\n}\n\n/* System Info Widget CSS  */\n#info-box-widget {\n  background: linear-gradient(to bottom, #202020, #141414);\n  margin: 5px 5px 5px 5px;\n  min-width: 170px;\n  min-height: 170px;\n  border-radius: 16px;\n}\n#ram-progress {\n  margin-left: 12px;\n  color: var(--foreground);\n}\n#progress-label {\n  font-weight: 600;\n  font-size: 13px;\n}\n#progress {\n  border: solid 8px var(--blue-dim);\n  color: alpha(#aaaaaa, 0.7);\n}\n\n/* Color indicators for memory usage */\n#used-color-indicator {\n  color: var(--blue-dim);\n  font-size: 10px;\n  font-weight: 900;\n}\n\n#free-color-indicator {\n  color: #7c7c7c;\n  font-size: 10px;\n  font-weight: 900;\n}\n\n#temp-color-indicator {\n  color: var(--blue-dim);\n  font-size: 10px;\n  font-weight: 900;\n}\n\n/* Info text styling */\n#info-text {\n  font-size: 13px;\n  font-weight: 500;\n  color: #8e8e8e;\n  min-width: 32px;\n}\n\n#info-value {\n  font-size: 12px;\n  font-weight: 600;\n  color: #ffffff;\n}\n\n#info-container {\n  margin-left: -8px;\n  margin-top: 5px;\n}\n\n#info {\n  margin-left: -13px;\n  margin-top: 5px;\n  font-size: 14px;\n  font-weight: 600;\n}\n#component-name {\n  font-size: 10px;\n}\n"
  },
  {
    "path": "utils/__init__.py",
    "content": "\"\"\"\nModus services package.\nContains background services and utilities for the shell.\n\"\"\"\n"
  },
  {
    "path": "utils/animator.py",
    "content": "from typing import cast\n\nfrom gi.repository import GLib, Gtk\n\nfrom fabric import Property, Service, Signal\n\n\nclass Animator(Service):\n    @Signal\n    def finished(self) -> None: ...\n\n    @Property(tuple[float, float, float, float], \"read-write\")\n    def bezier_curve(self) -> tuple[float, float, float, float]:\n        return self._bezier_curve\n\n    @bezier_curve.setter\n    def bezier_curve(self, value: tuple[float, float, float, float]):\n        self._bezier_curve = value\n        return\n\n    @Property(float, \"read-write\")\n    def value(self):\n        return self._value\n\n    @value.setter\n    def value(self, value: float):\n        self._value = value\n        return\n\n    @Property(float, \"read-write\")\n    def max_value(self):\n        return self._max_value\n\n    @max_value.setter\n    def max_value(self, value: float):\n        self._max_value = value\n        return\n\n    @Property(float, \"read-write\")\n    def min_value(self):\n        return self._min_value\n\n    @min_value.setter\n    def min_value(self, value: float):\n        self._min_value = value\n        return\n\n    @Property(bool, \"read-write\", default_value=False)\n    def playing(self):\n        return self._playing\n\n    @playing.setter\n    def playing(self, value: bool):\n        self._playing = value\n        return\n\n    @Property(bool, \"read-write\", default_value=False)\n    def repeat(self):\n        return self._repeat\n\n    @repeat.setter\n    def repeat(self, value: bool):\n        self._repeat = value\n        return\n\n    def __init__(\n        self,\n        bezier_curve: tuple[float, float, float, float],\n        duration: float,\n        min_value: float = 0.0,\n        max_value: float = 1.0,\n        repeat: bool = False,\n        tick_widget: Gtk.Widget | None = None,\n        **kwargs,\n    ):\n        super().__init__(**kwargs)\n        self._bezier_curve = (1, 0, 1, 1)\n        self._duration = 5\n        self._value = 0.0\n        self._min_value = 0.0\n        self._max_value = 1.0\n        self._repeat = False\n\n        self.bezier_curve = bezier_curve\n        self.duration = duration\n        self.value = min_value\n        self.min_value = min_value\n        self.max_value = max_value\n        self.repeat = repeat\n\n        self.playing = False\n        self._start_time = None\n        self._tick_handler = None\n        self._timeline_pos = 0\n        self._tick_widget = tick_widget\n\n    def do_get_time_now(self):\n        return GLib.get_monotonic_time() / 1_000_000\n\n    def do_lerp(self, start: float, end: float, time: float) -> float:\n        return start + (end - start) * time\n\n    def do_interpolate_cubic_bezier(self, time: float) -> float:\n        y_points = (0, self.bezier_curve[1], self.bezier_curve[3], 1)\n        return (\n            (1 - time) ** 3 * y_points[0]\n            + 3 * (1 - time) ** 2 * time * y_points[1]\n            + 3 * (1 - time) * time**2 * y_points[2]\n            + time**3 * y_points[3]\n        )\n\n    def do_ease(self, time: float) -> float:\n        return self.do_lerp(\n            self.min_value, self.max_value, self.do_interpolate_cubic_bezier(time)\n        )\n\n    def do_update_value(self, delta_time: float):\n        if not self.playing:\n            return\n\n        elapsed_time = delta_time - cast(float, self._start_time)\n\n        self._timeline_pos = min(1, elapsed_time / self.duration)\n\n        self.value = self.do_ease(self._timeline_pos)\n\n        if not self._timeline_pos >= 1:\n            return\n\n        if not self.repeat:\n            self.value = self.max_value\n            self.finished()\n            self.pause()\n            return\n\n        self._start_time = delta_time\n        self._timeline_pos = 0\n        return\n\n    def do_handle_tick(self, *_):\n        current_time = self.do_get_time_now()\n        self.do_update_value(current_time)\n        return True\n\n    def do_remove_tick_handlers(self):\n        if self._tick_handler:\n            if self._tick_widget:\n                self._tick_widget.remove_tick_callback(self._tick_handler)\n            else:\n                GLib.source_remove(self._tick_handler)\n        self._tick_handler = None\n        return\n\n    def play(self):\n        if self.playing:\n            return\n\n        self._start_time = self.do_get_time_now()\n\n        if not self._tick_handler:\n            if self._tick_widget:\n                self._tick_handler = self._tick_widget.add_tick_callback(\n                    self.do_handle_tick\n                )\n            else:\n                self._tick_handler = GLib.timeout_add(16, self.do_handle_tick)\n\n        self.playing = True\n        return\n\n    def pause(self):\n        self.playing = False\n        return self.do_remove_tick_handlers()\n\n    def stop(self):\n        if not self._tick_handler:\n            self._timeline_pos = 0\n            self.playing = False\n            return\n        return self.do_remove_tick_handlers()\n"
  },
  {
    "path": "utils/app_name_resolver.py",
    "content": "import os\nfrom utils.roam import modus_service\n\n\nclass AppName:\n    def __init__(self, path=\"/usr/share/applications\"):\n        self.files = os.listdir(path)\n        self.path = path\n\n    def get_app_name(self, wmclass, format_=False):\n        desktop_file = \"\"\n        for f in self.files:\n            if f.startswith(wmclass + \".desktop\"):\n                desktop_file = f\n\n        desktop_app_name = wmclass\n\n        if desktop_file == \"\":\n            return wmclass\n        with open(os.path.join(self.path, desktop_file), \"r\") as f:\n            lines = f.readlines()\n            for line in lines:\n                if line.startswith(\"Name=\"):\n                    desktop_app_name = line.split(\"=\")[1].strip()\n                    break\n        return desktop_app_name\n\n    def get_app_exec(self, wmclass, format_=False):\n        desktop_file = \"\"\n        for f in self.files:\n            if f.startswith(wmclass + \".desktop\"):\n                desktop_file = f\n\n        desktop_app_name = wmclass\n\n        if desktop_file == \"\":\n            return wmclass\n        with open(os.path.join(self.path, desktop_file), \"r\") as f:\n            lines = f.readlines()\n            for line in lines:\n                if line.startswith(\"Exec=\"):\n                    desktop_app_name = line.split(\"=\")[1].strip()\n                    break\n        return desktop_app_name\n\n    def get_desktop_file(self, wmclass):\n        desktop_file = \"\"\n        for f in self.files:\n            if f.startswith(wmclass + \".desktop\"):\n                desktop_file = f\n        return desktop_file\n\n    def format_app_name(self, title, wmclass, update=False):\n        # Handle case when both title and wmclass are empty (no active window)\n        if not title and not wmclass:\n            name = \"Finder\"\n        else:\n            name = wmclass\n            if name == \"\":\n                name = title\n\n            # Try to get the proper app name from desktop file only if wmclass is not empty\n            if wmclass:\n                name = self.get_app_name(wmclass=wmclass)\n\n            # Smart title formatting (capitalize first letter)\n            name = str(name).title()\n            if \".\" in name:\n                name = name.split(\".\")[-1]\n\n        if update:\n            modus_service.current_active_app_name = name\n        return name\n\n\n# Create a global instance for use across modules\napp_name_resolver = AppName()\n\n\ndef format_window(title, wmclass):\n    # Handle the case when HyprlandActiveWindow passes \"unknown\" instead of empty strings\n    if (not title or title == \"unknown\") and (not wmclass or wmclass == \"unknown\"):\n        return \"Finder\"\n    \n    # Clean up \"unknown\" values\n    if title == \"unknown\":\n        title = \"\"\n    if wmclass == \"unknown\":\n        wmclass = \"\"\n    \n    name = app_name_resolver.format_app_name(title, wmclass, True)\n    return name\n\n"
  },
  {
    "path": "utils/conversion.py",
    "content": "import threading\nimport time\nfrom concurrent.futures import ThreadPoolExecutor\nfrom typing import Dict, Optional, Tuple\n\nimport requests\n\n\nclass CurrencyCache:\n    \"\"\"Thread-safe currency exchange rate cache with background updates.\"\"\"\n\n    def __init__(self):\n        # {base_currency: {rates_data, timestamp}}\n        self._cache: Dict[str, Dict] = {}\n        self._cache_lock = threading.Lock()\n        self._cache_ttl = 3600  # 1 hour in seconds\n        self._request_timeout = 2  # 2 seconds for faster response\n        self._executor = ThreadPoolExecutor(\n            max_workers=2, thread_name_prefix=\"currency\"\n        )\n        self._pending_requests: Dict[str, threading.Event] = {}\n\n    def get_rate(self, from_code: str, to_code: str) -> Optional[Tuple[float, float]]:\n        \"\"\"\n        Get exchange rate from cache or fetch if needed.\n        Returns (rate, cache_age_seconds) or None if unavailable.\n        \"\"\"\n        from_lower = from_code.lower()\n        to_lower = to_code.lower()\n\n        if from_lower == to_lower:\n            return 1.0, 0.0\n\n        current_time = time.time()\n\n        with self._cache_lock:\n            # Check if we have fresh cached data\n            if from_lower in self._cache:\n                cache_entry = self._cache[from_lower]\n                cache_age = current_time - cache_entry[\"timestamp\"]\n\n                if cache_age < self._cache_ttl and to_lower in cache_entry[\"rates\"]:\n                    rate = cache_entry[\"rates\"][to_lower][\"rate\"]\n                    return rate, cache_age\n\n            # Check if we're already fetching this currency\n            if from_lower in self._pending_requests:\n                # Don't wait, return cached data if available (even if stale)\n                if (\n                    from_lower in self._cache\n                    and to_lower in self._cache[from_lower][\"rates\"]\n                ):\n                    cache_entry = self._cache[from_lower]\n                    cache_age = current_time - cache_entry[\"timestamp\"]\n                    rate = cache_entry[\"rates\"][to_lower][\"rate\"]\n                    return rate, cache_age\n                return None\n\n            # Start background fetch\n            self._pending_requests[from_lower] = threading.Event()\n\n        # Submit background task to fetch rates\n        self._executor.submit(self._fetch_rates_background, from_lower)\n\n        # Return stale cached data if available\n        with self._cache_lock:\n            if (\n                from_lower in self._cache\n                and to_lower in self._cache[from_lower][\"rates\"]\n            ):\n                cache_entry = self._cache[from_lower]\n                cache_age = current_time - cache_entry[\"timestamp\"]\n                rate = cache_entry[\"rates\"][to_lower][\"rate\"]\n                return rate, cache_age\n\n        return None\n\n    def _fetch_rates_background(self, from_code: str):\n        \"\"\"Fetch exchange rates in background thread.\"\"\"\n        try:\n            url = f\"https://www.floatrates.com/daily/{from_code}.json\"\n            response = requests.get(url, timeout=self._request_timeout)\n\n            if response.status_code == 200:\n                rates_data = response.json()\n                current_time = time.time()\n\n                with self._cache_lock:\n                    self._cache[from_code] = {\n                        \"rates\": rates_data,\n                        \"timestamp\": current_time,\n                    }\n\n        except Exception as e:\n            print(f\"Background currency fetch failed for {from_code}: {e}\")\n        finally:\n            # Mark request as complete\n            with self._cache_lock:\n                if from_code in self._pending_requests:\n                    self._pending_requests[from_code].set()\n                    del self._pending_requests[from_code]\n\n    def cleanup(self):\n        \"\"\"Cleanup resources.\"\"\"\n        self._executor.shutdown(wait=False)\n        with self._cache_lock:\n            self._cache.clear()\n            self._pending_requests.clear()\n\n\n# Global currency cache instance\n_currency_cache = CurrencyCache()\n\n\nclass Units:\n    def __init__(self):\n        self.WEIGHT_CHART: dict[str, tuple[float, float]] = {\n            \"kilogram\": (1, 1),\n            \"kg\": (1, 1),\n            \"tonne\": (1000, 0.001),\n            \"ton\": (1000, 0.001),\n            \"gram\": (1e-3, 1e3),\n            \"g\": (1e-3, 1e3),\n            \"milligram\": (1e-6, 1e6),\n            \"mg\": (1e-6, 1e6),\n            \"metric-ton\": (1000, 0.001),\n            \"metric-tonne\": (1000, 0.001),\n            \"long-ton\": (1016.04608, 0.0009842073),\n            \"short-ton\": (907.184, 0.0011023122),\n            \"pound\": (0.453592, 2.2046244202),\n            \"lb\": (0.453592, 2.2046244202),\n            \"stone\": (6.35029, 0.1574731728),\n            \"st\": (6.35029, 0.1574731728),\n            \"ounce\": (0.0283495, 35.273990723),\n            \"oz\": (0.0283495, 35.273990723),\n            \"carrat\": (0.0002, 5000),\n            \"ct\": (0.0002, 5000),\n            \"atomic-mass-unit\": (1.660540199e-27, 6.022136652e26),\n        }\n\n        self.LENGTH_CHART: dict[str, float] = {\n            # meter\n            \"m\": 1,\n            \"M\": 1,\n            \"meter\": 1,\n            # kilometer\n            \"km\": 1e3,\n            \"KM\": 1e3,\n            \"kilometer\": 1e3,\n            # centimeter\n            \"cm\": 1e-2,\n            \"CM\": 1e-2,\n            \"centimeter\": 1e-2,\n            # millimeter\n            \"mm\": 1e-3,\n            \"MM\": 1e-3,\n            \"millimeter\": 1e-3,\n            # micrometer\n            \"um\": 1e-6,\n            \"UM\": 1e-6,\n            \"micrometer\": 1e-6,\n            # nanometer\n            \"nm\": 1e-9,\n            \"NM\": 1e-9,\n            \"nanometer\": 1e-9,\n            # mile\n            \"mi\": 1609.344,\n            \"MI\": 1609.344,\n            \"mile\": 1609.344,\n            # yard\n            \"yd\": 0.9144,\n            \"YD\": 0.9144,\n            \"yard\": 0.9144,\n            # foot\n            \"ft\": 0.3048,\n            \"FT\": 0.3048,\n            \"foot\": 0.3048,\n            \"feet\": 0.3048,\n            # inch\n            \"in\": 0.0254,\n            \"IN\": 0.0254,\n            \"inch\": 0.0254,\n            \"inches\": 0.0254,\n            # nautical mile\n            \"nmi\": 1852,\n            \"NMI\": 1852,\n            \"nautical-mile\": 1852,\n        }\n\n        self.STORAGE_TYPE_CHART: dict[str, float] = {\n            \"bit\": 1,\n            \"byte\": 8,\n            \"B\": 8,\n            \"kilobyte\": 8192,\n            \"KB\": 8192,\n            \"megabyte\": 8388608,\n            \"MB\": 8388608,\n            \"gigabyte\": 8589934592,\n            \"GB\": 8589934592,\n            \"terabyte\": 8796093022208,\n            \"TB\": 8796093022208,\n            \"petabyte\": 9007199254740992,\n            \"PB\": 9007199254740992,\n            \"exabyte\": 9223372036854775808,\n            \"EB\": 9223372036854775808,\n        }\n\n        self.TEMPERATURE_CHART = {\n            \"celsius\": (lambda v: v + 273.15, lambda v: v - 273.15),\n            \"c\": (lambda v: v + 273.15, lambda v: v - 273.15),\n            \"fahrenheit\": (\n                lambda v: (v - 32) * 5 / 9 + 273.15,\n                lambda v: (v - 273.15) * 9 / 5 + 32,\n            ),\n            \"f\": (\n                lambda v: (v - 32) * 5 / 9 + 273.15,\n                lambda v: (v - 273.15) * 9 / 5 + 32,\n            ),\n            \"kelvin\": (lambda v: v, lambda v: v),\n            \"k\": (lambda v: v, lambda v: v),\n            \"rankine\": (lambda v: v * 5 / 9, lambda v: v * 9 / 5),\n            \"reaumur\": (lambda v: v * 5 / 4 + 273.15, lambda v: (v - 273.15) * 4 / 5),\n        }\n\n        self.TIME_CHART: dict[str, float] = {\n            \"second\": 1,\n            \"s\": 1,\n            \"minute\": 60,\n            \"min\": 60,\n            \"m\": 60,\n            \"hour\": 3600,\n            \"h\": 3600,\n            \"milisecond\": 1e-3,\n            \"ms\": 1e-3,\n            \"day\": 86400,\n            \"d\": 86400,\n            \"week\": 604800,\n            \"w\": 604800,\n            \"fortnight\": 1209600,\n            \"month\": 2628000,  # Approximation (30.44 days)\n            \"mo\": 2628000,  # Approximation (30.44 days)\n            \"year\": 31536000,  # Approximation (365 days)\n            \"yr\": 31536000,  # Approximation (365 days)\n            \"decade\": 315360000,  # Approximation (10 years)\n            \"dec\": 315360000,  # Approximation (10 years)\n            \"century\": 3153600000,  # Approximation (100 years)\n            \"cent\": 3153600000,  # Approximation (100 years)\n            \"millennium\": 31536000000,  # Approximation (1000 years)\n            \"millenia\": 31536000000,  # Approximation (1000 years)\n        }\n\n        self.LIQUID_VOLUME_CHART: dict[str, float] = {\n            \"liter\": 1,\n            \"l\": 1,\n            \"milliliter\": 1e-3,\n            \"ml\": 1e-3,\n            \"gallon\": 3.78541,\n            \"quart\": 0.946353,\n            \"pint\": 0.473176,\n            \"fluid-ounce\": 0.0295735,\n            \"fl-oz\": 0.0295735,\n            \"oz\": 0.0295735,\n            \"ounce\": 0.0295735,\n            \"cup\": 0.236588,\n            \"tablespoon\": 0.0147868,\n            \"tbsp\": 0.0147868,\n            \"teaspoon\": 0.00492892,\n            \"tsp\": 0.00492892,\n        }\n\n        self.ANGLE_CHART: dict[str, float] = {\n            \"degree\": 1,\n            \"deg\": 1,\n            \"radian\": 57.2958,\n            \"rad\": 57.2958,\n            \"gradian\": 0.9,\n            \"gon\": 0.9,\n        }\n\n        self.ENERGY_CHART: dict[str, float] = {\n            \"joule\": 1,\n            \"j\": 1,\n            \"kilojoule\": 1000,\n            \"kj\": 1000,\n            \"calorie\": 4.184,\n            \"cal\": 4.184,\n            \"kilocalorie\": 4184,\n            \"kcal\": 4184,\n            \"watt-hour\": 3600,\n            \"wh\": 3600,\n            \"kilowatt-hour\": 3.6e6,\n            \"kwh\": 3.6e6,\n        }\n\n        self.SPEED_CHART: dict[str, float] = {\n            \"mps\": 1,\n            \"kmph\": 0.277778,\n            \"mph\": 0.44704,\n            \"fps\": 0.3048,\n            \"knot\": 0.514444,\n        }\n\n        self.PRESSURE_CHART: dict[str, float] = {\n            \"pascal\": 1,\n            \"Pa\": 1,\n            \"bar\": 100000,\n            \"atm\": 101325,\n            \"torr\": 133.322,\n            \"mmHg\": 133.322,\n            \"psi\": 6894.76,\n        }\n\n        self.FORCE_CHART: dict[str, float] = {\n            \"newton\": 1,\n            \"N\": 1,\n            \"kilonewton\": 1000,\n            \"kN\": 1000,\n            \"pound-force\": 4.44822,\n            \"lbf\": 4.44822,\n            \"dyne\": 1e-5,\n        }\n\n        self.POWER_CHART: dict[str, float] = {\n            \"watt\": 1,\n            \"W\": 1,\n            \"kilowatt\": 1000,\n            \"kW\": 1000,\n            \"horsepower\": 745.7,\n            \"hp\": 745.7,\n            \"megawatt\": 1e6,\n            \"MW\": 1e6,\n        }\n\n        self.VOLTAGE_CHART: dict[str, float] = {\n            \"volt\": 1,\n            \"V\": 1,\n            \"millivolt\": 1e-3,\n            \"mV\": 1e-3,\n            \"kilovolt\": 1000,\n            \"kV\": 1000,\n            \"megavolt\": 1e6,\n            \"MV\": 1e6,\n        }\n\n        self.CURRENT_CHART: dict[str, float] = {\n            \"ampere\": 1,\n            \"A\": 1,\n            \"milliampere\": 1e-3,\n            \"mA\": 1e-3,\n            \"microampere\": 1e-6,\n            \"μA\": 1e-6,\n        }\n\n        self.RESISTANCE_CHART: dict[str, float] = {\n            \"ohm\": 1,\n            \"Ω\": 1,\n            \"kilohm\": 1000,\n            \"kΩ\": 1000,\n            \"megohm\": 1e6,\n            \"MΩ\": 1e6,\n        }\n\n        self.CAPACITANCE_CHART: dict[str, float] = {\n            \"farad\": 1,\n            \"F\": 1,\n            \"millifarad\": 1e-3,\n            \"mF\": 1e-3,\n            \"microfarad\": 1e-6,\n            \"μF\": 1e-6,\n            \"nanofarad\": 1e-9,\n            \"nF\": 1e-9,\n        }\n\n        self.INDUCTANCE_CHART: dict[str, float] = {\n            \"henry\": 1,\n            \"H\": 1,\n            \"millihenry\": 1e-3,\n            \"mH\": 1e-3,\n            \"microhenry\": 1e-6,\n            \"μH\": 1e-6,\n            \"nanohenry\": 1e-9,\n            \"nH\": 1e-9,\n        }\n\n        self.FREQUENCY_CHART: dict[str, float] = {\n            \"hertz\": 1,\n            \"Hz\": 1,\n            \"kilohertz\": 1e3,\n            \"kHz\": 1e3,\n            \"megahertz\": 1e6,\n            \"MHz\": 1e6,\n            \"gigahertz\": 1e9,\n            \"GHz\": 1e9,\n        }\n\n        self.LUMINANCE_CHART: dict[str, float] = {\n            \"candela\": 1,\n            \"cd\": 1,\n            \"lumen\": 1,\n            \"lm\": 1,\n            \"lux\": 1,\n            \"lx\": 1,\n        }\n\n        self.AREA_CHART: dict[str, float] = {\n            \"square-meter\": 1,\n            \"m2\": 1,\n            \"square-kilometer\": 1e6,\n            \"km2\": 1e6,\n            \"hectare\": 1e4,\n            \"ha\": 1e4,\n            \"are\": 1e2,\n            \"a\": 1e2,\n            \"square-centimeter\": 1e-4,\n            \"cm2\": 1e-4,\n            \"square-millimeter\": 1e-6,\n            \"mm2\": 1e-6,\n        }\n\n        # We no longer use currency_converter here.\n\n\nclass Conversion:\n    def __init__(self):\n        self.units = Units()\n        self.currency_cache = _currency_cache\n\n    def convert(self, value: float, from_type: str, to_type: str):\n        \"\"\"\n        Generalized conversion function that works with all categories,\n        including currency via floatrates.com.\n        \"\"\"\n        # Collection of all non-currency charts\n        charts = {\n            \"WEIGHT_CHART\": self.units.WEIGHT_CHART,\n            \"LENGTH_CHART\": self.units.LENGTH_CHART,\n            \"TEMPERATURE_CHART\": self.units.TEMPERATURE_CHART,\n            \"TIME_CHART\": self.units.TIME_CHART,\n            \"LIQUID_VOLUME_CHART\": self.units.LIQUID_VOLUME_CHART,\n            \"STORAGE_TYPE_CHART\": self.units.STORAGE_TYPE_CHART,\n            \"ANGLE_CHART\": self.units.ANGLE_CHART,\n            \"ENERGY_CHART\": self.units.ENERGY_CHART,\n            \"SPEED_CHART\": self.units.SPEED_CHART,\n            \"PRESSURE_CHART\": self.units.PRESSURE_CHART,\n            \"FORCE_CHART\": self.units.FORCE_CHART,\n            \"POWER_CHART\": self.units.POWER_CHART,\n            \"VOLTAGE_CHART\": self.units.VOLTAGE_CHART,\n            \"CURRENT_CHART\": self.units.CURRENT_CHART,\n            \"RESISTANCE_CHART\": self.units.RESISTANCE_CHART,\n            \"CAPACITANCE_CHART\": self.units.CAPACITANCE_CHART,\n            \"INDUCTANCE_CHART\": self.units.INDUCTANCE_CHART,\n            \"FREQUENCY_CHART\": self.units.FREQUENCY_CHART,\n            \"LUMINANCE_CHART\": self.units.LUMINANCE_CHART,\n            \"AREA_CHART\": self.units.AREA_CHART,\n        }\n\n        # 1) Check if it's in any of the charts (non-currency)\n        for chart_name, chart in charts.items():\n            if from_type in chart and to_type in chart:\n                # Temperatures use lambdas\n                if chart_name == \"TEMPERATURE_CHART\":\n                    if from_type == to_type:\n                        return value\n                    to_kelvin = chart[from_type][0]\n                    from_kelvin = chart[to_type][1]\n                    return from_kelvin(to_kelvin(value))\n\n                # Handle WEIGHT_CHART separately (tuple values)\n                if chart_name == \"WEIGHT_CHART\":\n                    if from_type == to_type:\n                        return value\n                    to_kg = chart[from_type][0]\n                    from_kg = chart[to_type][1]\n                    return value * to_kg * from_kg\n\n                # Any other numeric chart\n                if from_type == to_type:\n                    return value\n                return value * (chart[from_type] / chart[to_type])\n\n        # 2) If both are currency codes (e.g. “USD”, “ARS”)\n        #    we assume they are uppercase and have 3 letters.\n        if (\n            len(from_type) == 3\n            and len(to_type) == 3\n            and from_type.isalpha()\n            and to_type.isalpha()\n        ):\n            result = self._convert_currency_fast(value, from_type, to_type)\n            if result is not None:\n                return result\n            # Fallback to slow method if fast method fails\n            return self._convert_currency_via_floatrates(value, from_type, to_type)\n\n        # 3) If it doesn't fall into any case, error.\n        raise ValueError(f\"Unsupported conversion: {from_type} to {to_type}\")\n\n    def _convert_currency_fast(\n        self, value: float, from_code: str, to_code: str\n    ) -> Optional[float]:\n        \"\"\"\n        Fast currency conversion using cached exchange rates.\n        Returns None if rate is not available in cache.\n        \"\"\"\n        rate_info = self.currency_cache.get_rate(from_code, to_code)\n        if rate_info is not None:\n            rate, _ = rate_info  # cache_age not needed here\n            return value * rate\n        return None\n\n    def _convert_currency_via_floatrates(\n        self, value: float, from_code: str, to_code: str\n    ) -> float:\n        \"\"\"\n        Converts using the JSON from floatrates.com:\n        - Makes GET to https://www.floatrates.com/daily/{from_lower}.json\n        - Takes the rate from the to_lower key and multiplies.\n        \"\"\"\n        from_lower = from_code.lower()\n        to_lower = to_code.lower()\n\n        if from_lower == to_lower:\n            return value\n\n        url = f\"https://www.floatrates.com/daily/{from_lower}.json\"\n        resp = requests.get(url, timeout=5)\n        if resp.status_code != 200:\n            raise ValueError(f\"Error getting data from floatrates for {from_code}\")\n\n        data = resp.json()\n        if to_lower not in data:\n            raise ValueError(\n                f\"Target currency '{to_code}' not found in floatrates response for '{\n                    from_code\n                }'\"\n            )\n\n        rate = data[to_lower][\"rate\"]\n        return value * rate\n\n    def parse_input_and_convert(self, input: str):\n        parts = input.split()\n        addition = \"s\" if parts[-1].endswith(\"s\") else \"\"\n\n        if \"and\" in parts:  # value unit1 and value2 unit2 _ to target_unit\n            parts.remove(\"and\")\n            if len(parts) != 6:\n                raise ValueError(\n                    \"Invalid format. Expected: 'value from_type and value2 from_type2 _ to_type'\"\n                )\n\n            value1, from_type1, value2, from_type2, _, to_type = parts\n            value1, value2 = float(value1), float(value2)\n            from_type1 = self.clean_type(from_type1)\n            from_type2 = self.clean_type(from_type2)\n            to_type = self.clean_type(to_type)\n\n            if from_type1 == from_type2:\n                return (\n                    self.convert(value1 + value2, from_type1, to_type),\n                    to_type + addition,\n                )\n            else:\n                res = 0\n                res += self.convert(value1, from_type1, to_type)\n                res += self.convert(value2, from_type2, to_type)\n                return res, to_type + addition\n        else:\n            if len(parts) != 4:\n                raise ValueError(\n                    \"Invalid format. Expected: 'value from_type _ to_type'\"\n                )\n            value, from_type, _, to_type = parts\n            value = float(value)\n            from_type = self.clean_type(from_type)\n            to_type = self.clean_type(to_type)\n            return self.convert(value, from_type, to_type), to_type + addition\n\n    def clean_type(self, type: str) -> str:\n        \"\"\"\n        If it's currency (3 letters), convert to uppercase.\n        If it ends in 's' (and is not 'celsius'), remove the 's' for\n        other units.\"\"\"\n        if len(type) == 3 and type.isalpha():\n            return type.upper()\n        if type.endswith(\"s\") and type.lower() != \"celsius\":\n            # For tables that have singular/plural\n            singular = type[:-1].lower()\n            # If it exists in STORAGE_TYPE_CHART, we use it;\n            # if not, we return singular in lowercase for other charts.\n            if singular in self.units.STORAGE_TYPE_CHART:\n                return singular\n            return singular.lower()\n        return type\n\n    def cleanup(self):\n        \"\"\"Cleanup resources.\"\"\"\n        self.currency_cache.cleanup()\n\n    def get_currency_cache_info(\n        self, from_code: str, to_code: str\n    ) -> Optional[Tuple[bool, float]]:\n        \"\"\"\n        Get currency cache information for UI display.\n        Returns (is_fresh, cache_age_seconds) or None if not cached.\n        \"\"\"\n        rate_info = self.currency_cache.get_rate(from_code, to_code)\n        if rate_info is not None:\n            _, cache_age = rate_info  # rate not needed here\n            is_fresh = cache_age < 300  # Consider fresh if less than 5 minutes old\n            return is_fresh, cache_age\n        return None\n\n\n# Quick usage example:\nif __name__ == \"__main__\":\n    conv = Conversion()\n    # Convert 10 USD to ARS:\n    result, suffix = conv.parse_input_and_convert(\"10 USD _ ARS\")\n    print(f\"{result:.2f} {suffix}\")  # Ex: \"10 USD _ ARS\" -> \"38754.23 ARS\"\n"
  },
  {
    "path": "utils/functions.py",
    "content": "import html\nimport json\nimport os\nimport threading\nfrom typing import Dict, List, Optional\n\nfrom loguru import logger\n\n# Threading helper functions\n\n\ndef thread(target, *args, **kwargs) -> threading.Thread:\n    \"\"\"\n    Simply run the given function in a thread.\n    The provided args and kwargs will be passed to the function.\n    \"\"\"\n    th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True)\n    th.start()\n    return th\n\n\ndef run_in_thread(func):\n    \"\"\"\n    Decorator to run the decorated function in a thread.\n    \"\"\"\n\n    def wrapper(*args, **kwargs):\n        return thread(func, *args, **kwargs)\n\n    return wrapper\n\n\n@run_in_thread\ndef write_json_file(data: Dict, path: str):\n    try:\n        with open(path, \"w\") as f:\n            json.dump(data, f, indent=4, ensure_ascii=False)\n    except Exception as e:\n        logger.warning(f\"Failed to write json: {e}\")\n\n\ndef read_json_file(file_path: str) -> Optional[List]:\n    if not os.path.exists(file_path):\n        logger.error(f\"JSON file {file_path} does not exist.\")\n        return None\n\n    with open(file_path, \"r\") as file:\n        try:\n            return json.load(file)\n        except json.JSONDecodeError as e:\n            logger.error(f\"Failed to read JSON file {file_path}: {e}\")\n            return None\n\n\ndef get_wifi_icon_for_strength(strength: int) -> str:\n    \"\"\"\n    Get the appropriate WiFi icon based on signal strength.\n\n    Args:\n        strength: Signal strength from 0-100\n\n    Returns:\n        Absolute path to the appropriate WiFi icon\n    \"\"\"\n    # Get the current directory where this script is located\n    current_dir = os.path.dirname(os.path.abspath(__file__))\n    # Get the project root (parent of utils directory)\n    project_root = os.path.dirname(current_dir)\n\n    if strength >= 80:\n        icon_name = \"network-wireless-100.svg\"\n    elif strength >= 60:\n        icon_name = \"network-wireless-80.svg\"\n    elif strength >= 40:\n        icon_name = \"network-wireless-60.svg\"\n    elif strength >= 20:\n        icon_name = \"network-wireless-40.svg\"\n    elif strength > 0:\n        icon_name = \"network-wireless-20.svg\"\n    else:\n        icon_name = \"network-wireless-0.svg\"\n\n    return os.path.join(project_root, \"config\", \"assets\", \"icons\", \"wifi\", icon_name)\n\n\ndef get_wifi_connecting_icon() -> str:\n    \"\"\"\n    Get the WiFi connecting icon path.\n\n    Returns:\n        Absolute path to the WiFi connecting icon\n    \"\"\"\n    # Get the current directory where this script is located\n    current_dir = os.path.dirname(os.path.abspath(__file__))\n    # Get the project root (parent of utils directory)\n    project_root = os.path.dirname(current_dir)\n\n    return os.path.join(\n        project_root, \"config\", \"assets\", \"icons\", \"wifi\", \"wifi-connecting.svg\"\n    )\n\n\n# Function to check if a workspace ID is special\ndef is_special_workspace_id(ws_id) -> bool:\n    try:\n        # Convert to int if it's a string\n        workspace_id = int(ws_id)\n        # Special workspaces have negative IDs\n        return workspace_id < 0\n    except (ValueError, TypeError):\n        # If it's a string, check if it starts with \"special:\"\n        if isinstance(ws_id, str) and ws_id.startswith(\"special:\"):\n            return True\n        return False\n\n\n# Function to check if a client is on a special workspace\ndef is_special_workspace(client: dict) -> bool:\n    if \"workspace\" not in client:\n        return False\n\n    workspace = client[\"workspace\"]\n\n    # Check workspace name first\n    if \"name\" in workspace:\n        workspace_name = workspace[\"name\"]\n        if is_special_workspace_id(workspace_name):\n            return True\n\n    # Check workspace ID\n    if \"id\" in workspace:\n        workspace_id = workspace[\"id\"]\n        if is_special_workspace_id(workspace_id):\n            return True\n\n    return False\n\n\ndef escape_markup_text(text: str) -> str:\n    \"\"\"\n    Escape special characters in text to make it safe for Pango markup.\n\n    Args:\n        text: Raw text that may contain special characters\n\n    Returns:\n        Escaped text safe for use in Pango markup\n    \"\"\"\n    if not text or not isinstance(text, str):\n        return \"\"\n\n    # Use html.escape to escape XML/HTML special characters\n    # This handles &, <, >, and quotes\n    return html.escape(text)\n"
  },
  {
    "path": "utils/icon_resolver.py",
    "content": "import json\nimport os\nimport re\n\nimport gi\n\ngi.require_version(\"Gtk\", \"3.0\")\nfrom gi.repository import GLib, Gtk\nfrom loguru import logger\n\nimport config.data as data\n\nICON_CACHE_FILE = data.CACHE_DIR + \"/icons.json\"\nif not os.path.exists(data.CACHE_DIR):\n    os.makedirs(data.CACHE_DIR)\n\n\nclass IconResolver:\n    def __init__(\n        self, default_applicaiton_icon: str = \"application-x-executable-symbolic\"\n    ):\n        if os.path.exists(ICON_CACHE_FILE):\n            with open(ICON_CACHE_FILE) as f:\n                try:\n                    self._icon_dict = json.load(f)\n                except json.JSONDecodeError:\n                    logger.info(\"[ICONS] Cache file does not exist or is corrupted\")\n                    self._icon_dict = {}\n        else:\n            self._icon_dict = {}\n\n        self.default_applicaiton_icon = default_applicaiton_icon\n\n    def get_icon_name(self, app_id: str):\n        if app_id in self._icon_dict:\n            return self._icon_dict[app_id]\n        new_icon = self._compositor_find_icon(app_id)\n        logger.info(\n            f\"[ICONS] found new icon: '{new_icon}' for app id: '{app_id}', storing...\"\n        )\n        self._store_new_icon(app_id, new_icon)\n        return new_icon\n\n    def get_icon_pixbuf(self, app_id: str, size: int = 16):\n        icon_theme = Gtk.IconTheme.get_default()\n        icon_name = self.get_icon_name(app_id)\n        try:\n            # Try to load the resolved icon.\n            return icon_theme.load_icon(icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE)\n        except GLib.Error as primary_error:\n            logger.warning(\n                f\"Warning: Icon '{icon_name}' not found in theme. Error: {primary_error}\"\n            )\n            try:\n                # Fallback to the default application icon.\n                return icon_theme.load_icon(\n                    self.default_applicaiton_icon, size, Gtk.IconLookupFlags.FORCE_SIZE\n                )\n            except GLib.Error as fallback_error:\n                logger.error(\n                    f\"Error: Fallback icon '{self.default_applicaiton_icon}' also not found. Error: {fallback_error}\"\n                )\n                return None\n\n    def _store_new_icon(self, app_id: str, icon: str):\n        self._icon_dict[app_id] = icon\n        with open(ICON_CACHE_FILE, \"w\") as f:\n            json.dump(self._icon_dict, f)\n\n    def _get_icon_from_desktop_file(self, desktop_file_path: str):\n        # Retrieve the icon specified in the [Desktop Entry] section.\n        with open(desktop_file_path) as f:\n            for line in f.readlines():\n                if \"Icon=\" in line:\n                    return \"\".join(line[5:].split())\n            return self.default_applicaiton_icon\n\n    def _get_desktop_file(self, app_id: str) -> str | None:\n        data_dirs = GLib.get_system_data_dirs()\n        for data_dir in data_dirs:\n            data_dir = os.path.join(data_dir, \"applications\")\n            if os.path.exists(data_dir):\n                files = os.listdir(data_dir)\n                matching = [\n                    s for s in files if \"\".join(app_id.lower().split()) in s.lower()\n                ]\n                if matching:\n                    return os.path.join(data_dir, matching[0])\n                for word in list(filter(None, re.split(r\"-|\\.|_|\\s\", app_id))):\n                    matching = [s for s in files if word.lower() in s.lower()]\n                    if matching:\n                        return os.path.join(data_dir, matching[0])\n        return None\n\n    def _compositor_find_icon(self, app_id: str):\n        icon_theme = Gtk.IconTheme.get_default()\n        if icon_theme.has_icon(app_id):\n            return app_id\n        if icon_theme.has_icon(app_id + \"-desktop\"):\n            return app_id + \"-desktop\"\n        desktop_file = self._get_desktop_file(app_id)\n        return (\n            self._get_icon_from_desktop_file(desktop_file)\n            if desktop_file\n            else self.default_applicaiton_icon\n        )\n"
  },
  {
    "path": "utils/inhibit.py",
    "content": "# From https://github.com/stwa/wayland-idle-inhibitor\n# License: WTFPL Version 2\n\nimport argparse\nimport os\nimport subprocess\nimport sys\nfrom dataclasses import dataclass\nfrom signal import SIGINT, SIGTERM, signal\nfrom threading import Event, Timer\n\nimport setproctitle\nfrom pywayland.client.display import Display\nfrom pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import (\n    ZwpIdleInhibitManagerV1,\n)\nfrom pywayland.protocol.wayland.wl_compositor import WlCompositor\nfrom pywayland.protocol.wayland.wl_registry import WlRegistryProxy\nfrom pywayland.protocol.wayland.wl_surface import WlSurface\n\n\n@dataclass\nclass GlobalRegistry:\n    surface: WlSurface | None = None\n    inhibit_manager: ZwpIdleInhibitManagerV1 | None = None\n\n\ndef parse_duration(duration_str: str) -> int:\n    \"\"\"Parse duration string into seconds.\n    Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'on', 'off'\n    \"\"\"\n    try:\n        if duration_str.lower() in [\"on\", \"off\"]:\n            return 0\n        elif duration_str.endswith(\"h\"):\n            return int(float(duration_str[:-1]) * 3600)\n        elif duration_str.endswith(\"m\"):\n            return int(float(duration_str[:-1]) * 60)\n        elif duration_str.endswith(\"s\"):\n            return int(float(duration_str[:-1]))\n        else:\n            return int(duration_str)\n    except ValueError:\n        raise ValueError(\n            \"Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc.\"\n        )\n\n\ndef kill_existing_inhibit_processes():\n    \"\"\"Kill any existing modus-inhibit processes.\"\"\"\n    try:\n        # Find all modus-inhibit processes except the current one\n        output = subprocess.check_output([\"pgrep\", \"-f\", \"modus-inhibit\"], text=True)\n        pids = output.strip().split(\"\\n\")\n        current_pid = str(os.getpid())\n\n        killed_count = 0\n        for pid in pids:\n            pid = pid.strip()\n            if pid and pid != current_pid:\n                try:\n                    subprocess.run([\"kill\", pid], check=True)\n                    killed_count += 1\n                except subprocess.CalledProcessError:\n                    pass  # Process might have already exited\n\n        if killed_count > 0:\n            print(f\"Stopped {killed_count} existing inhibit process(es)\")\n        else:\n            print(\"No existing inhibit processes were running\")\n        return killed_count\n\n    except subprocess.CalledProcessError:\n        # No processes found\n        print(\"No existing inhibit processes were running\")\n        return 0\n    except Exception as e:\n        print(f\"Error stopping existing processes: {e}\")\n        return 0\n\n\ndef handle_registry_global(\n    wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int\n) -> None:\n    global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry()\n\n    if iface_name == \"wl_compositor\":\n        compositor = wl_registry.bind(id_num, WlCompositor, version)\n        global_registry.surface = compositor.create_surface()  # type: ignore\n    elif iface_name == \"zwp_idle_inhibit_manager_v1\":\n        global_registry.inhibit_manager = wl_registry.bind(\n            id_num, ZwpIdleInhibitManagerV1, version\n        )\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(\n        description=\"Inhibit system idle for a specified duration\"\n    )\n    parser.add_argument(\n        \"duration\",\n        nargs=\"?\",\n        default=\"0\",\n        help='Duration to inhibit (e.g., \"1h\", \"30m\", \"45s\", \"on\", \"off\"). Use \"on\" for indefinite, \"off\" to stop.',\n    )\n    args = parser.parse_args()\n\n    # Handle \"off\" argument - kill existing processes and exit\n    if args.duration.lower() == \"off\":\n        kill_existing_inhibit_processes()\n        sys.exit(0)\n\n    done = Event()\n    signal(SIGINT, lambda _, __: done.set())\n    signal(SIGTERM, lambda _, __: done.set())\n\n    global_registry = GlobalRegistry()\n\n    try:\n        display = Display()\n        display.connect()\n\n        registry = display.get_registry()  # type: ignore\n        registry.user_data = global_registry\n        registry.dispatcher[\"global\"] = handle_registry_global\n\n        def shutdown() -> None:\n            display.dispatch()\n            display.roundtrip()\n            display.disconnect()\n\n        display.dispatch()\n        display.roundtrip()\n\n        if global_registry.surface is None:\n            print(\"Error: Failed to create Wayland surface.\")\n            shutdown()\n            sys.exit(1)\n\n        if global_registry.inhibit_manager is None:\n            print(\"Error: Your Wayland compositor does not support idle inhibition.\")\n            print(\"This usually means either:\")\n            print(\n                \"1. Your compositor (like Hyprland) doesn't support the idle-inhibit protocol\"\n            )\n            print(\"2. The protocol is not enabled in your compositor\")\n            print(\"\\nFor Hyprland, you can enable it by adding to your config:\")\n            print(\"misc:disable_autoreload = true\")\n            print(\"misc:enable_wayland_protocols = true\")\n            shutdown()\n            sys.exit(1)\n\n        inhibitor = global_registry.inhibit_manager.create_inhibitor(  # type: ignore\n            global_registry.surface\n        )\n\n        display.dispatch()\n        display.roundtrip()\n\n        duration = parse_duration(args.duration)\n        if duration > 0:\n            print(f\"Inhibiting idle for {args.duration}...\")\n            # Set up timer to release inhibition\n            timer = Timer(duration, lambda: done.set())\n            timer.start()\n        else:\n            print(\"Inhibiting idle indefinitely...\")\n\n        done.wait()\n        print(\"Shutting down...\")\n\n        inhibitor.destroy()\n        if duration > 0:\n            timer.cancel()\n\n        shutdown()\n\n    except Exception as e:\n        print(f\"Error: {str(e)}\")\n        print(\"Make sure you're running this under a Wayland session.\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    setproctitle.setproctitle(\"modus-inhibit\")\n    main()\n"
  },
  {
    "path": "utils/monitors.py",
    "content": "import json\nimport warnings\nfrom typing import Dict\nimport time\nfrom fabric.hyprland import Hyprland\nfrom gi.repository import Gdk\nfrom functools import lru_cache\n\nwarnings.filterwarnings(\"ignore\", category=DeprecationWarning)\n\n\ndef ttl_lru_cache(seconds_to_live: int, maxsize: int = 128):\n    def wrapper(func):\n        @lru_cache(maxsize)\n        def inner(__ttl, *args, **kwargs):\n            return func(*args, **kwargs)\n\n        return lambda *args, **kwargs: inner(\n            time.time() // seconds_to_live, *args, **kwargs\n        )\n\n    return wrapper\n\n\nclass HyprlandWithMonitors(Hyprland):\n    \"\"\"A Hyprland class with additional monitor common.\"\"\"\n\n    instance = None\n\n    @staticmethod\n    def get_default():\n        if HyprlandWithMonitors.instance is None:\n            HyprlandWithMonitors.instance = HyprlandWithMonitors()\n\n        return HyprlandWithMonitors.instance\n\n    def __init__(self, commands_only: bool = False, **kwargs):\n        self.display: Gdk.Display = Gdk.Display.get_default()\n        super().__init__(commands_only, **kwargs)\n\n    @ttl_lru_cache(100, 5)\n    def get_all_monitors(self) -> Dict:\n        monitors = json.loads(self.send_command(\"j/monitors\").reply)\n        return {monitor[\"id\"]: monitor[\"name\"] for monitor in monitors}\n\n    def get_gdk_monitor_id_from_name(self, plug_name: str) -> int | None:\n        for i in range(self.display.get_n_monitors()):\n            if self.display.get_default_screen().get_monitor_plug_name(i) == plug_name:\n                return i\n        return None\n\n    def get_gdk_monitor_id(self, hyprland_id: int) -> int | None:\n        monitors = self.get_all_monitors()\n        if hyprland_id in monitors:\n            return self.get_gdk_monitor_id_from_name(monitors[hyprland_id])\n        return None\n\n    def get_current_gdk_monitor_id(self) -> int | None:\n        active_workspace = json.loads(self.send_command(\"j/activeworkspace\").reply)\n        return self.get_gdk_monitor_id_from_name(active_workspace[\"monitor\"])\n"
  },
  {
    "path": "utils/occlusion.py",
    "content": "import json\nimport subprocess\n\nimport config.data as data\n\n\ndef get_current_workspace():\n    \"\"\"\n    Get the current workspace ID using hyprctl.\n    \"\"\"\n    try:\n        result = subprocess.run(\n            [\"hyprctl\", \"activeworkspace\"], capture_output=True, text=True\n        )\n        # Assume the output similar to: \"ID <number>\"\n        # Extracting the number from the output\n        parts = result.stdout.split()\n        for i, part in enumerate(parts):\n            if part == \"ID\" and i + 1 < len(parts):\n                return int(parts[i + 1])\n    except Exception as e:\n        print(f\"Error getting current workspace: {e}\")\n    return -1\n\n\ndef get_screen_dimensions():\n    \"\"\"\n    Get screen dimensions from hyprctl.\n\n    Returns:\n        tuple: (width, height) of the monitor containing the current workspace\n    \"\"\"\n    try:\n        # Get current workspace\n        workspace_id = get_current_workspace()\n\n        # Get monitor information\n        result = subprocess.run(\n            [\"hyprctl\", \"-j\", \"monitors\"], capture_output=True, text=True\n        )\n        monitors = json.loads(result.stdout)\n\n        # Find the monitor containing our workspace\n        for monitor in monitors:\n            if monitor.get(\"activeWorkspace\", {}).get(\"id\") == workspace_id:\n                return monitor.get(\"width\", data.CURRENT_WIDTH), monitor.get(\n                    \"height\", data.CURRENT_HEIGHT\n                )\n\n        # Fallback to first monitor\n        if monitors:\n            return monitors[0].get(\"width\", data.CURRENT_WIDTH), monitors[0].get(\n                \"height\", data.CURRENT_HEIGHT\n            )\n    except Exception as e:\n        print(f\"Error getting screen dimensions: {e}\")\n\n    # Default fallback values\n    return data.CURRENT_WIDTH, data.CURRENT_HEIGHT\n\n\ndef check_occlusion(occlusion_region, workspace=None):\n    \"\"\"\n    Check if a region is occupied by any window on a given workspace.\n\n    Parameters:\n        occlusion_region: Can be one of:\n            - tuple (side, size): where side is \"top\", \"bottom\", \"left\", or \"right\"\n              and size is the pixel width of the region\n            - tuple (x, y, width, height): The full region coordinates (legacy format)\n        workspace (int, optional): The workspace ID to check. If None, the current workspace is used.\n\n    Returns:\n        bool: True if any window overlaps with the occlusion region, False otherwise.\n    \"\"\"\n    if workspace is None:\n        workspace = get_current_workspace()\n\n    # Handle simplified side-based format\n    if isinstance(occlusion_region, tuple) and len(occlusion_region) == 2:\n        side, size = occlusion_region\n        if isinstance(side, str):\n            # Convert side-based format to coordinates\n            screen_width, screen_height = get_screen_dimensions()\n\n            if side.lower() == \"bottom\":\n                occlusion_region = (0, screen_height - size, screen_width, size)\n            elif side.lower() == \"top\":\n                occlusion_region = (0, 0, screen_width, size)\n            elif side.lower() == \"left\":\n                occlusion_region = (0, 0, size, screen_height)\n            elif side.lower() == \"right\":\n                occlusion_region = (screen_width - size, 0, size, screen_height)\n\n    # Ensure occlusion_region is in the correct format (x, y, width, height)\n    if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4:\n        print(f\"Invalid occlusion region format: {occlusion_region}\")\n        return False\n\n    try:\n        result = subprocess.run(\n            [\"hyprctl\", \"-j\", \"clients\"], capture_output=True, text=True\n        )\n        clients = json.loads(result.stdout)\n    except Exception as e:\n        print(f\"Error retrieving client windows: {e}\")\n        return False\n\n    occ_x, occ_y, occ_width, occ_height = occlusion_region\n    occ_x2 = occ_x + occ_width\n    occ_y2 = occ_y + occ_height\n\n    for client in clients:\n        # Check if client is mapped\n        if not client.get(\"mapped\", False):\n            continue\n\n        # Ensure client has proper workspace information and matches the workspace\n        client_workspace = client.get(\"workspace\", {})\n        if client_workspace.get(\"id\") != workspace:\n            continue\n\n        # Ensure client has position and size info\n        position = client.get(\"at\")\n        size = client.get(\"size\")\n        if not position or not size:\n            continue\n\n        x, y = position\n        width, height = size\n        win_x1, win_y1 = x, y\n        win_x2, win_y2 = x + width, y + height\n\n        # Check for intersection between the window and occlusion region\n        if not (\n            win_x2 <= occ_x or win_x1 >= occ_x2 or win_y2 <= occ_y or win_y1 >= occ_y2\n        ):\n            return True  # Occlusion region is occupied\n\n    return False  # No window overlaps the occlusion region\n"
  },
  {
    "path": "utils/roam.py",
    "content": "from loguru import logger\n\nfrom fabric.audio import Audio\n\nfrom services.modus import ModusService, notification_service as notification_service_instance\n\nglobal modus_service\ntry:\n    modus_service = ModusService()\nexcept Exception as e:\n    logger.error(f\"[Main] Failed to create ModusService: {e}\")\n    modus_service = None\n\nif modus_service is None:\n    logger.warning(\n        \"[Main] ModusService was not initialized. Functionality may be limited.\"\n    )\n\nglobal notification_service\ntry:\n    notification_service = notification_service_instance\nexcept Exception as e:\n    logger.error(f\"[Main] Failed to create NotificationService: {e}\")\n    notification_service = None\n\nif notification_service is None:\n    logger.warning(\n        \"[Main] NotificationService was not initialized. Notifications may not work.\"\n    )\n\nglobal audio_service\ntry:\n    audio_service = Audio()\nexcept Exception as e:\n    logger.error(f\"[Main] Failed to create AudioService: {e}\")\n    audio_service = None\n\nif audio_service is None:\n    logger.warning(\n        \"[Main] AudioService was not initialized. Audio features may not work.\"\n    )\n"
  },
  {
    "path": "widgets/circle_image.py",
    "content": "import math\nfrom typing import Literal\n\nimport cairo\nimport gi\nfrom fabric.core.service import Property\nfrom fabric.widgets.widget import Widget\n\ngi.require_version(\"Gtk\", \"3.0\")\nfrom gi.repository import Gdk, GdkPixbuf, Gtk  # noqa: E402\n\n\nclass CircleImage(Gtk.DrawingArea, Widget):\n    \"\"\"A widget that displays an image in a circular shape with a 1:1 aspect ratio.\"\"\"\n\n    @Property(int, \"read-write\")\n    def angle(self) -> int:\n        return self._angle\n\n    @angle.setter\n    def angle(self, value: int):\n        self._angle = value % 360\n        self.queue_draw()\n\n    def __init__(\n        self,\n        image_file: str | None = None,\n        pixbuf: GdkPixbuf.Pixbuf | None = None,\n        name: str | None = None,\n        visible: bool = True,\n        all_visible: bool = False,\n        style: str | None = None,\n        tooltip_text: str | None = None,\n        tooltip_markup: str | None = None,\n        h_align: (\n            Literal[\"fill\", \"start\", \"end\", \"center\", \"baseline\"] | Gtk.Align | None\n        ) = None,\n        v_align: (\n            Literal[\"fill\", \"start\", \"end\", \"center\", \"baseline\"] | Gtk.Align | None\n        ) = None,\n        h_expand: bool = False,\n        v_expand: bool = False,\n        size: int | None = None,\n        **kwargs,\n    ):\n        Gtk.DrawingArea.__init__(self)\n        Widget.__init__(\n            self,\n            name=name,\n            visible=visible,\n            all_visible=all_visible,\n            style=style,\n            tooltip_text=tooltip_text,\n            tooltip_markup=tooltip_markup,\n            h_align=h_align,\n            v_align=v_align,\n            h_expand=h_expand,\n            v_expand=v_expand,\n            size=size,\n            **kwargs,\n        )\n        self.size = size if size is not None else 100  # Default size if not provided\n        self._angle = 0\n        # Original image for reprocessing\n        self._orig_image: GdkPixbuf.Pixbuf | None = None\n        self._image: GdkPixbuf.Pixbuf | None = None\n        if image_file:\n            pix = GdkPixbuf.Pixbuf.new_from_file(image_file)\n            self._orig_image = pix\n            self._image = self._process_image(pix)\n        elif pixbuf:\n            self._orig_image = pixbuf\n            self._image = self._process_image(pixbuf)\n        self.connect(\"draw\", self.on_draw)\n\n    def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:\n        \"\"\"Crop the image to a centered square and scale it to the widget’s size.\"\"\"\n        width, height = pixbuf.get_width(), pixbuf.get_height()\n        if width != height:\n            square_size = min(width, height)\n            x_offset = (width - square_size) // 2\n            y_offset = (height - square_size) // 2\n            pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size)\n        else:\n            square_size = width\n        if square_size != self.size:\n            pixbuf = pixbuf.scale_simple(\n                self.size, self.size, GdkPixbuf.InterpType.BILINEAR\n            )\n        return pixbuf\n\n    def on_draw(self, widget: \"CircleImage\", ctx: cairo.Context):\n        if self._image:\n            ctx.save()\n            # Create a circular clipping path\n            ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi)\n            ctx.clip()\n            # Rotate around the center of the square image\n            ctx.translate(self.size / 2, self.size / 2)\n            ctx.rotate(self._angle * math.pi / 180.0)\n            ctx.translate(-self.size / 2, -self.size / 2)\n            Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0)\n            ctx.paint()\n            ctx.restore()\n\n    def set_image_from_file(self, new_image_file: str):\n        if not new_image_file:\n            return\n        pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file)\n        self._orig_image = pixbuf\n        self._image = self._process_image(pixbuf)\n        self.queue_draw()\n\n    def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf):\n        if not pixbuf:\n            return\n        self._orig_image = pixbuf\n        self._image = self._process_image(pixbuf)\n        self.queue_draw()\n\n    def set_image_size(self, size: int):\n        self.size = size\n        if self._orig_image:\n            self._image = self._process_image(self._orig_image)\n        self.queue_draw()\n"
  },
  {
    "path": "widgets/custom_image.py",
    "content": "import math\nfrom typing import cast\n\nimport cairo\nfrom gi.repository import Gtk\n\nfrom fabric.widgets.image import Image\n\n\nclass CustomImage(Image):\n    def do_render_rectangle(\n        self, cr: cairo.Context, width: int, height: int, radius: int = 0\n    ):\n        cr.move_to(radius, 0)\n        cr.line_to(width - radius, 0)\n        cr.arc(width - radius, radius, radius, -(math.pi / 2), 0)\n        cr.line_to(width, height - radius)\n        cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2))\n        cr.line_to(radius, height)\n        cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi)\n        cr.line_to(0, radius)\n        cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2)))\n        cr.close_path()\n\n    def do_draw(self, cr: cairo.Context):\n        context = self.get_style_context()\n        width, height = self.get_allocated_width(), self.get_allocated_height()\n        cr.save()\n\n        self.do_render_rectangle(\n            cr,\n            width,\n            height,\n            cast(int, context.get_property(\"border-radius\", Gtk.StateFlags.NORMAL)),\n        )\n        cr.clip()\n        Image.do_draw(self, cr)\n\n        cr.restore()\n"
  },
  {
    "path": "widgets/customrevealer.py",
    "content": "from gi.repository import GLib, Gtk\nimport gi\nimport math\n\ngi.require_version(\"Gtk\", \"3.0\")\n\n# TODO: UsE BETTER APPROACH IF POSSIBLE\n\n\nclass AnimationManager:\n    _instance = None\n    _animating_widgets = set()\n    _timer_id = None\n    _containers_to_redraw = set()\n\n    @classmethod\n    def get_instance(cls):\n        if cls._instance is None:\n            cls._instance = cls()\n        return cls._instance\n\n    def add_widget(self, widget):\n        self._animating_widgets.add(widget)\n        if self._timer_id is None:\n            # Use 120 FPS for ultra-smooth animations like macOS\n            self._timer_id = GLib.timeout_add(8, self._animate_all)  # 120 FPS\n\n\n    def remove_widget(self, widget):\n        self._animating_widgets.discard(widget)\n        if not self._animating_widgets and self._timer_id:\n            # Stop timer when no widgets are animating\n            GLib.source_remove(self._timer_id)\n            self._timer_id = None\n            # Clear any pending redraws\n            self._containers_to_redraw.clear()\n\n    def _animate_all(self):\n        # Clear previous frame's redraw queue\n        self._containers_to_redraw.clear()\n\n        widgets_to_remove = []\n        \n        # Process all animations in a single frame\n        for widget in list(self._animating_widgets):\n            if not widget._calculate_position():\n                widgets_to_remove.append(widget)\n            else:\n                container = widget._get_container_for_redraw()\n                if container:\n                    self._containers_to_redraw.add(container)\n\n        # Apply all position changes at once to prevent conflicts\n        for widget in self._animating_widgets:\n            widget._apply_position()\n\n        # Batch redraw calls to minimize performance impact\n        for container in self._containers_to_redraw:\n            container.queue_draw()\n\n        # Remove completed animations\n        for widget in widgets_to_remove:\n            self.remove_widget(widget)\n\n        return len(self._animating_widgets) > 0  # Continue if widgets remain\n\n    def get_active_widget_count(self):\n        \"\"\"Return the number of currently animating widgets\"\"\"\n        return len(self._animating_widgets)\n\n    def _get_optimal_interval(self):\n        \"\"\"Keep consistent 120 FPS for macOS-like smoothness\"\"\"\n        return 8  # 120 FPS\n\n\n    def _start_timer(self):\n        interval = self._get_optimal_interval()\n        self._timer_id = GLib.timeout_add(interval, self._animate_all)\n\n    def _adjust_frame_rate(self):\n        # No need to adjust frame rate anymore - keep it consistent\n        pass\n\n\nclass MacOSEasing:\n    \"\"\"macOS-style easing functions for natural motion\"\"\"\n    \n    @staticmethod\n    def ease_out_expo(t):\n        \"\"\"Exponential ease out - fast start, slow end\"\"\"\n        return 1 - math.pow(2, -10 * t) if t != 1 else 1\n    \n    @staticmethod\n    def ease_in_out_quart(t):\n        \"\"\"Quartic ease in-out for smooth acceleration/deceleration\"\"\"\n        return 8 * t * t * t * t if t < 0.5 else 1 - math.pow(-2 * t + 2, 4) / 2\n    \n    @staticmethod\n    def ease_out_back(t):\n        \"\"\"Back ease out for slight overshoot effect\"\"\"\n        c1 = 1.70158\n        c3 = c1 + 1\n        return 1 + c3 * math.pow(t - 1, 3) + c1 * math.pow(t - 1, 2)\n    \n    @staticmethod\n    def ease_out_cubic_bezier(t):\n        \"\"\"Custom cubic bezier similar to macOS default (0.25, 0.1, 0.25, 1.0)\"\"\"\n        # Approximation of cubic-bezier(0.25, 0.1, 0.25, 1.0)\n        return t * t * t * (t * (6 * t - 15) + 10)\n    \n    @staticmethod\n    def ease_in_cubic(t):\n        \"\"\"Cubic ease in for smooth acceleration\"\"\"\n        return t * t * t\n    \n    @staticmethod\n    def ease_out_quint(t):\n        \"\"\"Quintic ease out for very smooth deceleration\"\"\"\n        return 1 - math.pow(1 - t, 5)\n\n\nclass SlideRevealer(Gtk.Overlay):\n    def __init__(self, child: Gtk.Widget, direction=\"right\", duration=350, size=None):\n        super().__init__()\n\n        self.child = child\n        self.direction = direction\n        self.duration = duration  # Slightly faster for snappier feel\n        self.fixed_size = size\n        self._revealed = False\n        self._animating = False\n        self._start_time = None\n        self._show_animation = False\n        self._pending_position = None\n        self._current_position = (0.0, 0.0)  # Use float for sub-pixel positioning\n        self._animation_id = None  # Track individual animation instances\n\n        self._fixed = Gtk.Fixed()\n        self._fixed.set_has_window(False)\n        self._fixed.add(child)\n        self.add_overlay(self._fixed)\n\n        if self.fixed_size:\n            self.set_size_request(self.fixed_size[0], self.fixed_size[1])\n            child.hide()\n            self.show_all()\n        else:\n            child.connect(\"size-allocate\", self._on_size_allocate)\n            child.hide()\n            self.show_all()\n\n    def _on_size_allocate(self, _widget, allocation):\n        if not self.fixed_size:\n            current_req = self.get_size_request()\n            if (\n                current_req[0] != allocation.width\n                or current_req[1] != allocation.height\n            ):\n                self.set_size_request(allocation.width, allocation.height)\n\n    def set_reveal_child(self, reveal: bool):\n        if reveal:\n            self.reveal()\n        else:\n            self.hide()\n\n    def reveal(self):\n        if self._revealed and not self._animating:\n            return\n        self._revealed = True\n\n        # Ensure widget is properly laid out before starting animation\n        if self.get_realized():\n            self._start_animation(show=True)\n        else:\n            # Wait for widget to be realized\n            def on_realize(*_):\n                self._start_animation(show=True)\n                self.disconnect_by_func(on_realize)\n            self.connect(\"realize\", on_realize)\n\n    def hide(self):\n        if not self._revealed and not self._animating:\n            return\n        self._revealed = False\n        self._start_animation(show=False)\n\n    def _start_animation(self, show: bool):\n        # Stop any existing animation for this widget\n        if self._animating:\n            AnimationManager.get_instance().remove_widget(self)\n\n        self._cached_dimensions = self._get_dimensions()\n        if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0:\n            self._animating = False\n            return\n\n        # Use high-precision monotonic time for smooth animations\n        self._start_time = GLib.get_monotonic_time()\n        self._animating = True\n        self._show_animation = show\n        self._animation_id = id(self)  # Unique ID for this animation instance\n\n        if show:\n            self.child.show()\n            pos = self._get_offscreen_pos_cached()\n            self._current_position = (float(pos[0]), float(pos[1]))\n            self._fixed.move(self.child, int(pos[0]), int(pos[1]))\n\n        def start_with_dimensions():\n            AnimationManager.get_instance().add_widget(self)\n            return False\n\n        # Use idle_add to ensure layout is complete\n        GLib.idle_add(start_with_dimensions)\n\n    def _calculate_position(self):\n        if not self._animating:\n            return False\n\n        elapsed = (GLib.get_monotonic_time() - self._start_time) / 1000\n        t = min(elapsed / self.duration, 1.0)\n\n        # Use different easing functions for better smoothness\n        if self._show_animation:\n            # Use quintic ease out for very smooth revealing\n            eased = MacOSEasing.ease_out_quint(t)\n        else:\n            # Use cubic ease in for smooth hiding\n            eased = MacOSEasing.ease_in_cubic(t)\n\n        self._pending_position = self._get_position_at_progress_cached(eased)\n\n        if t >= 1.0:\n            self._animating = False\n            self._cached_dimensions = None\n            self._animation_id = None\n            if not self._show_animation:\n                GLib.idle_add(lambda: self.child.hide())\n            return False\n        return True\n\n    def _apply_position(self):\n        if self._pending_position:\n            x, y = self._pending_position\n            # Use sub-pixel positioning for smoother motion\n            self._current_position = (x, y)\n            # Round to nearest pixel for actual positioning\n            pixel_x, pixel_y = int(round(x)), int(round(y))\n            self._fixed.move(self.child, pixel_x, pixel_y)\n            self._pending_position = None\n\n    def _get_container_for_redraw(self):\n        return self\n\n    def _get_dimensions(self):\n        if self.fixed_size:\n            return self.fixed_size\n        else:\n            alloc = self.child.get_allocation()\n            return alloc.width, alloc.height\n\n    def _get_offscreen_pos_cached(self):\n        w, h = self._cached_dimensions\n        if self.direction == \"left\":\n            return -w, 0\n        elif self.direction == \"right\":\n            return w, 0\n        elif self.direction == \"top\":\n            return 0, -h\n        elif self.direction == \"bottom\":\n            return 0, h\n        return 0, 0\n\n    def _get_position_at_progress_cached(self, progress):\n        w, h = self._cached_dimensions\n        if self._show_animation:\n            # Showing animation: slide from offscreen to onscreen (0,0)\n            if self.direction == \"left\":\n                return -w + w * progress, 0.0\n            elif self.direction == \"right\":\n                return w - w * progress, 0.0\n            elif self.direction == \"top\":\n                return 0.0, -h + h * progress\n            elif self.direction == \"bottom\":\n                return 0.0, h - h * progress\n        else:\n            # Hiding animation: slide from onscreen (0,0) to offscreen\n            if self.direction == \"left\":\n                return -w * progress, 0.0  # Slide left (negative x)\n            elif self.direction == \"right\":\n                return w * progress, 0.0  # Slide right (positive x)\n            elif self.direction == \"top\":\n                return 0.0, -h * progress  # Slide up (negative y)\n            elif self.direction == \"bottom\":\n                return 0.0, h * progress  # Slide down (positive y)\n        return 0.0, 0.0\n\n    def set_slide_direction(self, direction):\n        self.direction = direction\n\n    def is_revealed(self):\n        return self._revealed\n\n    def is_animating(self):\n        return self._animating\n\n    def get_child_revealed(self):\n        return self._revealed\n\n    def stop_animation(self):\n        if self._animating:\n            AnimationManager.get_instance().remove_widget(self)\n            self._animating = False\n            self._cached_dimensions = None\n            self._animation_id = None\n\n    def destroy(self):\n        self.stop_animation()\n        super().destroy()"
  },
  {
    "path": "widgets/dropdown.py",
    "content": "from fabric.widgets.box import Box\nfrom fabric.widgets.centerbox import CenterBox\nfrom fabric.widgets.eventbox import EventBox\nfrom utils.roam import modus_service\nfrom widgets.popup_window import PopupWindow\n\ndropdowns = []\n\n\ndef dropdown_divider(comment):\n    return Box(\n        children=[Box(name=\"dropdown-divider\", h_expand=True)],\n        name=\"dropdown-divider-box\",\n        h_align=\"fill\",\n        h_expand=True,\n        v_expand=True,\n    )\n\n\nclass ModusDropdown(PopupWindow):\n    def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs):\n        super().__init__(\n            layer=\"top\",\n            exclusivity=\"auto\",\n            name=\"dropdown-menu\",\n            title=\"modus\",\n            keyboard_mode=\"none\",\n            visible=False,\n            **kwargs,\n        )\n\n        self.id = dropdown_id or str(len(dropdowns))\n        dropdowns.append(self)\n        self._mousecapture_parent = None  # Will be set by mousecapture\n\n        modus_service.connect(\"dropdowns-hide-changed\", self.hide_dropdown)\n\n        self.dropdown = Box(\n            children=dropdown_children or [],\n            h_expand=True,\n            name=\"dropdown-options\",\n            orientation=\"vertical\",\n        )\n\n        self.child_box = CenterBox(start_children=[self.dropdown])\n\n        self.event_box = EventBox(\n            events=[\"enter-notify-event\", \"leave-notify-event\"],\n            child=self.child_box,\n            all_visible=True,\n        )\n\n        self.children = [self.event_box]\n        self.connect(\"button-press-event\", self.hide_dropdown)\n        self.add_keybinding(\"Escape\", self.hide_dropdown)\n\n    def toggle_dropdown(self, button, parent=None):\n        self.set_visible(not self.is_visible())\n        modus_service.current_dropdown = self.id if self.is_visible() else None\n\n    def _init_mousecapture(self, mousecapture):\n        \"\"\"Store reference to mousecapture parent for hiding\"\"\"\n        self._mousecapture_parent = mousecapture\n\n    def hide_dropdown(self, widget, event):\n        if self.is_visible():\n            self.hide()\n            if str(modus_service.current_dropdown) == str(self.id):\n                modus_service.current_dropdown = None\n\n    def hide_via_mousecapture(self):\n        \"\"\"Hide dropdown via mousecapture parent\"\"\"\n        if self._mousecapture_parent:\n            self._mousecapture_parent.hide_child_window()\n\n    def _set_mousecapture(self, visible: bool) -> None:\n        self.set_visible(visible)\n        if visible:\n            modus_service.current_dropdown = self.id\n        else:\n            if str(modus_service.current_dropdown) == str(self.id):\n                modus_service.current_dropdown = None\n\n    def on_cursor_enter(self, *_):\n        self.set_visible(True)\n\n    def on_cursor_leave(self, *_):\n        if self.is_hovered():\n            return\n        self.set_visible(False)\n        modus_service.dropdowns_hide = not modus_service.dropdowns_hide\n"
  },
  {
    "path": "widgets/mousecapture.py",
    "content": "from typing import Any\n\nimport cairo\nfrom gi.repository import GLib, GtkLayerShell  # type: ignore\n\nfrom fabric.widgets.eventbox import EventBox\nfrom fabric.widgets.widget import Widget\nfrom utils.roam import modus_service\nfrom widgets.wayland import WaylandWindow as Window\n\n\nclass MouseCapture(Window):\n    \"\"\"A background overlay that captures outside clicks without blocking child window interactions\"\"\"\n\n    def __init__(self, layer: str, child_window: Window, **kwargs):\n        super().__init__(\n            layer=\"top\",  # Use top layer to capture events\n            anchor=\"top bottom left right\",\n            exclusivity=\"auto\",\n            title=\"modus\",\n            name=\"MouseCapture\",\n            keyboard_mode=\"none\",  # Don't steal keyboard\n            all_visible=False,\n            visible=False,\n            **kwargs,\n        )\n\n        GtkLayerShell.set_exclusive_zone(self, -1)\n\n        self.child_window = child_window\n\n        # Ensure child window is on overlay layer to be above this capture\n        if hasattr(self.child_window, \"layer\"):\n            self.child_window.layer = \"overlay\"\n\n        if hasattr(self.child_window, \"_init_mousecapture\"):\n            self.child_window._init_mousecapture(self)\n\n        # Create transparent event box that captures clicks\n        self.event_box = EventBox(\n            events=[\"button-press-event\"],\n            all_visible=True,\n        )\n        self.event_box.connect(\"button-press-event\", self.on_overlay_click)\n        self.children = [self.event_box]\n\n        # Make the overlay transparent\n        self.set_app_paintable(True)\n        self.connect(\"draw\", self.on_draw)\n\n        # Add escape key binding to child window\n        if hasattr(self.child_window, \"add_keybinding\"):\n            self.child_window.add_keybinding(\"Escape\", self.hide_child_window)\n\n    def on_draw(self, _widget, cr):\n        \"\"\"Make overlay transparent\"\"\"\n        cr.set_source_rgba(0, 0, 0, 0)  # Fully transparent\n        cr.set_operator(cairo.OPERATOR_SOURCE)\n        cr.paint()\n        return False\n\n    def on_overlay_click(self, _widget, event):\n        \"\"\"Handle overlay clicks - check if click is outside child window\"\"\"\n        if not self.child_window.is_visible():\n            return False\n\n        # Get click coordinates\n        click_x = event.x_root\n        click_y = event.y_root\n\n        # Get child window bounds\n        try:\n            child_x, child_y = self.child_window.get_position()\n            child_allocation = self.child_window.get_allocation()\n\n            # Check if click is inside child window bounds\n            inside_child = (\n                child_x <= click_x <= child_x + child_allocation.width\n                and child_y <= click_y <= child_y + child_allocation.height\n            )\n\n            if not inside_child:\n                # Click is outside child window - hide it with delay\n                GLib.timeout_add(\n                    50, lambda: self.hide_child_window(None, None) or False\n                )\n                return True  # Consume the event\n\n        except Exception as e:\n            print(f\"Error checking click position: {e}\")\n            # If we can't determine position, hide child window to be safe\n            GLib.timeout_add(50, lambda: self.hide_child_window(None, None) or False)\n            return True\n\n        # Click is inside child window - don't consume event\n        return False\n\n    def show_child_window(self, widget: Widget = None, event: Any = None) -> None:\n        self.set_child_window_visible(True)\n\n    def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:\n        self.set_child_window_visible(False)\n\n    def set_child_window_visible(self, visible: bool) -> None:\n        if visible:\n            self.child_window.show()\n            self.show()\n        else:\n            self.child_window.hide()\n            self.hide()\n\n        if hasattr(self.child_window, \"_set_mousecapture\"):\n            self.child_window._set_mousecapture(visible)\n\n    def toggle_mousecapture(self, *_) -> None:\n        if self.is_visible():\n            self.set_child_window_visible(False)\n        else:\n            self.set_child_window_visible(True)\n\n\nclass DropDownMouseCapture(MouseCapture):\n    \"\"\"A specialized MouseCapture for dropdown menus with service integration\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        modus_service.connect(\"dropdowns-hide-changed\", self.dropdowns_hide_changed)\n\n    def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:\n        \"\"\"Hide child window and update dropdown service state\"\"\"\n        # Update service state before hiding to prevent conflicts\n        if hasattr(self.child_window, \"id\"):\n            if str(modus_service.current_dropdown) == str(self.child_window.id):\n                modus_service.current_dropdown = None\n        super().hide_child_window(widget, event)\n\n    def dropdowns_hide_changed(self, widget: Widget = None, event: Any = None) -> None:\n        \"\"\"Handle dropdown service hide changes\"\"\"\n        if hasattr(self.child_window, \"id\"):\n            if modus_service.current_dropdown == self.child_window.id:\n                return\n        return self.hide_child_window(widget, event)\n"
  },
  {
    "path": "widgets/popup_window.py",
    "content": "import contextlib\nimport gi  # type: ignore\nfrom gi.repository import Gdk, Gtk, GtkLayerShell  # type: ignore\n\nfrom widgets.wayland import WaylandWindow\nfrom utils.monitors import HyprlandWithMonitors\n\ngi.require_version(\"GtkLayerShell\", \"0.1\")\n\n\nclass PopupWindow(WaylandWindow):\n    \"\"\"A simple popover window that can point to a widget.\"\"\"\n\n    def __init__(\n        self,\n        parent: WaylandWindow,\n        pointing_to: Gtk.Widget | None = None,\n        margin: tuple[int, ...] | str = \"0px 0px 0px 0px\",\n        enable_boundary_checking: bool = True,\n        **kwargs,\n    ):\n        super().__init__(**kwargs)\n        self.exclusivity = \"none\"\n        self._is_centered = False\n        self._parent = parent\n        self._pointing_widget = pointing_to\n        self._hyprland = HyprlandWithMonitors()\n        self._base_margin = self.extract_margin(margin)\n        self.margin = self._base_margin.values()\n        self._enable_boundary_checking = enable_boundary_checking\n\n        self.connect(\"notify::visible\", self.do_update_handlers)\n\n    def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]:\n        if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()):  # type: ignore\n            return 0, 0\n        allocation = widget.get_allocation()\n        x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or (\n            0,\n            0,\n        )\n        return round(x / 2), round(y / 2)\n\n    def set_pointing_to(self, widget: Gtk.Widget | None):\n        if self._pointing_widget:\n            with contextlib.suppress(Exception):\n                self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)\n        self._pointing_widget = widget\n        return self.do_update_handlers()\n\n    def do_update_handlers(self, *_):\n        if not self._pointing_widget:\n            return\n\n        if not self.get_visible():\n            try:\n                self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)\n                self.disconnect_by_func(self.do_handle_size_allocate)\n            except Exception:\n                pass\n            return\n\n        self._pointing_widget.connect(\"size-allocate\", self.do_handle_size_allocate)\n        self.connect(\"size-allocate\", self.do_handle_size_allocate)\n\n        return self.do_handle_size_allocate()\n\n    def do_handle_size_allocate(self, *_):\n        return self.do_reposition(self.do_calculate_edges())\n\n    def do_calculate_edges(self):\n        move_axe = \"x\"\n        parent_anchor = self._parent.anchor\n\n        if len(parent_anchor) != 3:\n            self.anchor = \"left bottom\"\n            self._is_centered = True\n            return move_axe\n\n        if (\n            GtkLayerShell.Edge.LEFT in parent_anchor\n            and GtkLayerShell.Edge.RIGHT in parent_anchor\n        ):\n            # horizontal -> move on x-axies\n            move_axe = \"x\"\n            if GtkLayerShell.Edge.TOP in parent_anchor:\n                self.anchor = \"left top\"\n            else:\n                self.anchor = \"left bottom\"\n        elif (\n            GtkLayerShell.Edge.TOP in parent_anchor\n            and GtkLayerShell.Edge.BOTTOM in parent_anchor\n        ):\n            # vertical -> move on y-axies\n            move_axe = \"y\"\n            if GtkLayerShell.Edge.RIGHT in parent_anchor:\n                self.anchor = \"top right\"\n            else:\n                self.anchor = \"top left\"\n\n        self._is_centered = False\n        return move_axe\n\n    def do_reposition(self, move_axe: str):\n        parent_margin = self._parent.margin\n        parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3]\n\n        height = self.get_allocated_height()\n        width = self.get_allocated_width()\n\n        # Get monitor geometry for boundary checking\n        current_monitor_id = self._hyprland.get_current_gdk_monitor_id()\n        if current_monitor_id is not None:\n            monitor = self._hyprland.display.get_monitor(current_monitor_id)\n            monitor_geometry = monitor.get_geometry()\n            monitor_x, monitor_y = monitor_geometry.x, monitor_geometry.y\n            monitor_width, monitor_height = monitor_geometry.width, monitor_geometry.height\n        else:\n            # Fallback to default screen\n            screen = Gdk.Screen.get_default()\n            monitor_x, monitor_y = 0, 0\n            monitor_width, monitor_height = screen.get_width(), screen.get_height()\n\n        if self._pointing_widget:\n            coords = self.get_coords_for_widget(self._pointing_widget)\n            coords_centered = (\n                round(coords[0] + self._pointing_widget.get_allocated_width() / 2),\n                round(coords[1] + self._pointing_widget.get_allocated_height() / 2),\n            )\n        else:\n            coords_centered = (\n                round(self._parent.get_allocated_width() / 2),\n                round(self._parent.get_allocated_height() / 2),\n            )\n\n        if self._is_centered:\n            # Calculate centered position with boundary checking\n            centered_x = (\n                (monitor_width / 2 - self._parent.get_allocated_width() / 2)\n                - width / 2\n            ) + coords_centered[0]\n\n            # Apply boundary checking only if enabled\n            if self._enable_boundary_checking:\n                if centered_x < monitor_x:\n                    centered_x = monitor_x\n                elif centered_x + width > monitor_x + monitor_width:\n                    centered_x = monitor_x + monitor_width - width\n\n            self.margin = tuple(\n                a + b\n                for a, b in zip(\n                    (0, 0, 0, centered_x),\n                    self._base_margin.values(),\n                )\n            )\n            return\n\n        # Calculate position with boundary checking\n        if move_axe == \"x\":\n            # Horizontal positioning\n            calculated_x = round((parent_x_margin + coords_centered[0]) - (width / 2))\n\n            # Apply boundary checking only if enabled\n            if self._enable_boundary_checking:\n                if calculated_x < monitor_x:\n                    calculated_x = monitor_x\n                elif calculated_x + width > monitor_x + monitor_width:\n                    calculated_x = monitor_x + monitor_width - width\n\n            margin_values = (0, 0, 0, calculated_x)\n        else:\n            # Vertical positioning\n            calculated_y = round((parent_y_margin + coords_centered[1]) - (height / 2))\n\n            # Apply boundary checking only if enabled\n            if self._enable_boundary_checking:\n                if calculated_y < monitor_y:\n                    calculated_y = monitor_y\n                elif calculated_y + height > monitor_y + monitor_height:\n                    calculated_y = monitor_y + monitor_height - height\n\n            margin_values = (calculated_y, 0, 0, 0)\n\n        self.margin = tuple(\n            a + b\n            for a, b in zip(\n                margin_values,\n                self._base_margin.values(),\n            )\n        )\n"
  },
  {
    "path": "widgets/wayland.py",
    "content": "import re\nfrom collections.abc import Iterable\nfrom enum import Enum\nfrom typing import Literal, cast\n\nimport cairo\nimport gi\nfrom gi.repository import Gdk, GObject, Gtk\nfrom loguru import logger\n\nfrom fabric.core.service import Property\nfrom fabric.utils.helpers import extract_css_values, get_enum_member\nfrom fabric.widgets.window import Window\n\ngi.require_version(\"Gtk\", \"3.0\")\n\ntry:\n    gi.require_version(\"GtkLayerShell\", \"0.1\")\n    from gi.repository import GtkLayerShell\nexcept:\n    raise ImportError(\n        \"looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)\"\n    )\n\n\nclass WaylandWindowExclusivity(Enum):\n    NONE = 1\n    NORMAL = 2\n    AUTO = 3\n\n\nclass Layer(GObject.GEnum):\n    BACKGROUND = 0\n    BOTTOM = 1\n    TOP = 2\n    OVERLAY = 3\n    ENTRY_NUMBER = 4\n\n\nclass KeyboardMode(GObject.GEnum):\n    NONE = 0\n    EXCLUSIVE = 1\n    ON_DEMAND = 2\n    ENTRY_NUMBER = 3\n\n\nclass Edge(GObject.GEnum):\n    LEFT = 0\n    RIGHT = 1\n    TOP = 2\n    BOTTOM = 3\n    ENTRY_NUMBER = 4\n\n\nclass WaylandWindow(Window):\n    @Property(\n        Layer,\n        flags=\"read-write\",\n        default_value=Layer.TOP,\n    )\n    def layer(self) -> Layer:  # type: ignore\n        return self._layer  # type: ignore\n\n    @layer.setter\n    def layer(\n        self,\n        value: Literal[\"background\", \"bottom\", \"top\", \"overlay\"] | Layer,\n    ) -> None:\n        self._layer = get_enum_member(Layer, value, default=Layer.TOP)\n        return GtkLayerShell.set_layer(self, self._layer)\n\n    @Property(int, \"read-write\")\n    def monitor(self) -> int:\n        if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))):\n            return -1\n        display = monitor.get_display() or Gdk.Display.get_default()\n        for i in range(0, display.get_n_monitors()):\n            if display.get_monitor(i) is monitor:\n                return i\n        return -1\n\n    @monitor.setter\n    def monitor(self, monitor: int | Gdk.Monitor) -> bool:\n        if isinstance(monitor, int):\n            display = Gdk.Display().get_default()\n            monitor = display.get_monitor(monitor)\n        return (\n            (GtkLayerShell.set_monitor(self, monitor), True)[1]\n            if monitor is not None\n            else False\n        )\n\n    @Property(WaylandWindowExclusivity, \"read-write\")\n    def exclusivity(self) -> WaylandWindowExclusivity:\n        return self._exclusivity\n\n    @exclusivity.setter\n    def exclusivity(\n        self, value: Literal[\"none\", \"normal\", \"auto\"] | WaylandWindowExclusivity\n    ) -> None:\n        value = get_enum_member(\n            WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE\n        )\n        self._exclusivity = value\n        match value:\n            case WaylandWindowExclusivity.NORMAL:\n                return GtkLayerShell.set_exclusive_zone(self, True)\n            case WaylandWindowExclusivity.AUTO:\n                return GtkLayerShell.auto_exclusive_zone_enable(self)\n            case _:\n                return GtkLayerShell.set_exclusive_zone(self, False)\n\n    @Property(bool, \"read-write\", default_value=False)\n    def pass_through(self) -> bool:\n        return self._pass_through\n\n    @pass_through.setter\n    def pass_through(self, pass_through: bool = False):\n        self._pass_through = pass_through\n        region = cairo.Region() if pass_through is True else None\n        self.input_shape_combine_region(region)\n        del region\n        return\n\n    @Property(\n        KeyboardMode,\n        \"read-write\",\n        default_value=KeyboardMode.NONE,\n    )\n    def keyboard_mode(self) -> KeyboardMode:\n        return self._keyboard_mode\n\n    @keyboard_mode.setter\n    def keyboard_mode(\n        self,\n        value: (\n            Literal[\n                \"none\",\n                \"exclusive\",\n                \"on-demand\",\n                \"entry-number\",\n            ]\n            | KeyboardMode\n        ),\n    ):\n        self._keyboard_mode = get_enum_member(\n            KeyboardMode, value, default=KeyboardMode.NONE\n        )\n        return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode)\n\n    @Property(tuple[Edge, ...], \"read-write\")\n    def anchor(self):\n        return tuple(\n            x\n            for x in [\n                Edge.TOP,\n                Edge.RIGHT,\n                Edge.BOTTOM,\n                Edge.LEFT,\n            ]\n            if GtkLayerShell.get_anchor(self, x)\n        )\n\n    @anchor.setter\n    def anchor(self, value: str | Iterable[Edge]) -> None:\n        self._anchor = value\n        if isinstance(value, (list, tuple)) and all(\n            isinstance(edge, Edge) for edge in value\n        ):\n            for edge in [\n                Edge.TOP,\n                Edge.RIGHT,\n                Edge.BOTTOM,\n                Edge.LEFT,\n            ]:\n                if edge not in value:\n                    GtkLayerShell.set_anchor(self, edge, False)\n                GtkLayerShell.set_anchor(self, edge, True)\n            return\n        elif isinstance(value, str):\n            for edge, anchored in WaylandWindow.extract_edges_from_string(\n                value\n            ).items():\n                GtkLayerShell.set_anchor(self, edge, anchored)\n\n        return\n\n    @Property(tuple[int, ...], flags=\"read-write\")\n    def margin(self) -> tuple[int, ...]:\n        return tuple(\n            GtkLayerShell.get_margin(self, x)\n            for x in [\n                Edge.TOP,\n                Edge.RIGHT,\n                Edge.BOTTOM,\n                Edge.LEFT,\n            ]\n        )\n\n    @margin.setter\n    def margin(self, value: str | Iterable[int]) -> None:\n        for edge, mrgv in WaylandWindow.extract_margin(value).items():\n            GtkLayerShell.set_margin(self, edge, mrgv)\n        return\n\n    @Property(object, \"read-write\")\n    def keyboard_mode(self):\n        kb_mode = GtkLayerShell.get_keyboard_mode(self)\n        if GtkLayerShell.get_keyboard_interactivity(self):\n            kb_mode = KeyboardMode.EXCLUSIVE\n        return kb_mode\n\n    @keyboard_mode.setter\n    def keyboard_mode(\n        self,\n        value: Literal[\"none\", \"exclusive\", \"on-demand\"] | KeyboardMode,\n    ):\n        return GtkLayerShell.set_keyboard_mode(\n            self,\n            get_enum_member(\n                KeyboardMode,\n                value,\n                default=KeyboardMode.NONE,\n            ),\n        )\n\n    def __init__(\n        self,\n        layer: Literal[\"background\", \"bottom\", \"top\", \"overlay\"] | Layer = Layer.TOP,\n        anchor: str = \"\",\n        margin: str | Iterable[int] = \"0px 0px 0px 0px\",\n        exclusivity: (\n            Literal[\"auto\", \"normal\", \"none\"] | WaylandWindowExclusivity\n        ) = WaylandWindowExclusivity.NONE,\n        keyboard_mode: (\n            Literal[\"none\", \"exclusive\", \"on-demand\"] | KeyboardMode\n        ) = KeyboardMode.NONE,\n        pass_through: bool = False,\n        monitor: int | Gdk.Monitor | None = None,\n        title: str = \"fabric\",\n        type: Literal[\"top-level\", \"popup\"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL,\n        child: Gtk.Widget | None = None,\n        name: str | None = None,\n        visible: bool = True,\n        all_visible: bool = False,\n        style: str | None = None,\n        style_classes: Iterable[str] | str | None = None,\n        tooltip_text: str | None = None,\n        tooltip_markup: str | None = None,\n        h_align: (\n            Literal[\"fill\", \"start\", \"end\", \"center\", \"baseline\"] | Gtk.Align | None\n        ) = None,\n        v_align: (\n            Literal[\"fill\", \"start\", \"end\", \"center\", \"baseline\"] | Gtk.Align | None\n        ) = None,\n        h_expand: bool = False,\n        v_expand: bool = False,\n        size: Iterable[int] | int | None = None,\n        **kwargs,\n    ):\n        Window.__init__(\n            self,\n            title=title,\n            type=type,\n            child=child,\n            name=name,\n            visible=False,\n            all_visible=False,\n            style=style,\n            style_classes=style_classes,\n            tooltip_text=tooltip_text,\n            tooltip_markup=tooltip_markup,\n            h_align=h_align,\n            v_align=v_align,\n            h_expand=h_expand,\n            v_expand=v_expand,\n            size=size,\n            **kwargs,\n        )\n        self._layer = Layer.ENTRY_NUMBER\n        self._keyboard_mode = KeyboardMode.NONE\n        self._anchor = anchor\n        self._exclusivity = WaylandWindowExclusivity.NONE\n        self._pass_through = pass_through\n\n        GtkLayerShell.init_for_window(self)\n        GtkLayerShell.set_namespace(self, title)\n        self.connect(\n            \"notify::title\",\n            lambda *_: GtkLayerShell.set_namespace(self, self.get_title()),\n        )\n        if monitor is not None:\n            self.monitor = monitor\n        self.layer = layer\n        self.anchor = anchor\n        self.margin = margin\n        self.keyboard_mode = keyboard_mode\n        self.exclusivity = exclusivity\n        self.pass_through = pass_through\n        (\n            self.show_all()\n            if all_visible is True\n            else self.show() if visible is True else None\n        )\n\n    def steal_input(self) -> None:\n        return GtkLayerShell.set_keyboard_interactivity(self, True)\n\n    def return_input(self) -> None:\n        return GtkLayerShell.set_keyboard_interactivity(self, False)\n\n    # custom overrides\n    def show(self) -> None:\n        super().show()\n        return self.do_handle_post_show_request()\n\n    def show_all(self) -> None:\n        super().show_all()\n        return self.do_handle_post_show_request()\n\n    def do_handle_post_show_request(self) -> None:\n        if not self.get_children():\n            logger.warning(\n                \"[WaylandWindow] showing an empty window is not recommended, some compositors might freak out.\"\n            )\n        self.pass_through = self._pass_through\n        return\n\n    @staticmethod\n    def extract_anchor_values(string: str) -> tuple[str, ...]:\n        \"\"\"\n        extracts the geometry values from a given geometry string.\n\n        :param string: the string containing the geometry values.\n        :type string: str\n        :return: a list of unique directions extracted from the geometry string.\n        :rtype: list\n        \"\"\"\n        direction_map = {\"l\": \"left\", \"t\": \"top\", \"r\": \"right\", \"b\": \"bottom\"}\n        pattern = re.compile(r\"\\b(left|right|top|bottom)\\b\", re.IGNORECASE)\n        matches = pattern.findall(string)\n        return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches)))\n\n    @staticmethod\n    def extract_edges_from_string(string: str) -> dict[\"Edge\", bool]:\n        anchor_values = WaylandWindow.extract_anchor_values(string.lower())\n        return {\n            Edge.TOP: \"top\" in anchor_values,\n            Edge.RIGHT: \"right\" in anchor_values,\n            Edge.BOTTOM: \"bottom\" in anchor_values,\n            Edge.LEFT: \"left\" in anchor_values,\n        }\n\n    @staticmethod\n    def extract_margin(input: str | Iterable[int]) -> dict[\"Edge\", int]:\n        margins = (\n            extract_css_values(input.lower())\n            if isinstance(input, str)\n            else (\n                input\n                if isinstance(input, (tuple, list)) and len(input) == 4\n                else (0, 0, 0, 0)\n            )\n        )\n        return {\n            Edge.TOP: margins[0],\n            Edge.RIGHT: margins[1],\n            Edge.BOTTOM: margins[2],\n            Edge.LEFT: margins[3],\n        }\n"
  },
  {
    "path": "widgets/wifi_password_dialog.py",
    "content": "import gi\nfrom gi.repository import Gdk, GLib\n\nfrom fabric.widgets.box import Box\nfrom fabric.widgets.button import Button\nfrom fabric.widgets.entry import Entry\nfrom fabric.widgets.image import Image\nfrom fabric.widgets.label import Label\n\n# from widgets.wayland import WaylandWindow as Window\n\nfrom fabric.widgets.window import Window\n\ngi.require_version(\"Gtk\", \"3.0\")\n\n\nclass WiFiPasswordDialog(Window):\n    def __init__(\n        self,\n        ssid: str,\n        on_connect_callback=None,\n        on_cancel_callback=None,\n        on_dialog_closed=None,\n        **kwargs,\n    ):\n        super().__init__(\n            title=\"modus\",\n            layer=\"overlay\",\n            anchor=\"center\",\n            keyboard_mode=\"on-demand\",\n            visible=False,\n            name=\"wifi-password-dialog\",\n            **kwargs,\n        )\n\n        self.ssid = ssid\n        self.on_connect_callback = on_connect_callback\n        self.on_cancel_callback = on_cancel_callback\n        # self.set_size_request(400, 300)\n        self.set_resizable(False)\n        self.on_dialog_closed = on_dialog_closed\n        self.is_connecting = False\n        self.connection_timeout_id = None\n\n        self._create_dialog_content()\n        self.connect(\"key-press-event\", self._on_key_press)\n        self.connect(\"notify::visible\", self._on_visibility_changed)\n\n    def _create_dialog_content(self):\n        self.wifi_icon = Image(\n            icon_name=\"network-wireless-symbolic\", size=20, name=\"wifi-dialog-icon\"\n        )\n\n        self.title_label = Label(\n            label=f'The Wi-Fi network \"{self.ssid}\" requires a WPA2 password.',\n            name=\"wifi-dialog-title\",\n            h_align=\"start\",\n            wrap=True,\n            max_width_chars=40,\n        )\n\n        self.title_container = Box(\n            orientation=\"h\",\n            spacing=8,\n            children=[self.wifi_icon, self.title_label],\n            name=\"wifi-dialog-title-container\",\n            h_align=\"start\",\n        )\n\n        self.error_label = Label(\n            label=\"Incorrect password. Please try again.\",\n            name=\"wifi-dialog-error\",\n            h_align=\"center\",\n            visible=False,\n        )\n\n        self.password_label = Label(\n            label=\"Password:\", name=\"wifi-dialog-password-label\", h_align=\"start\"\n        )\n\n        self.password_entry = Entry(\n            placeholder_text=\"Enter password\",\n            name=\"wifi-dialog-password-entry\",\n            visibility=False,\n            h_expand=True,\n            h_align=\"fill\",\n        )\n\n        self.password_entry.connect(\"activate\", lambda *_: self._on_join_clicked())\n        self.password_entry.connect(\"changed\", self._on_password_changed)\n\n        self.password_visible = False\n        self.show_password_button = Button(\n            image=Image(icon_name=\"view-conceal-symbolic\", size=16),\n            name=\"wifi-dialog-show-password-button\",\n            on_clicked=self._on_show_password_clicked,\n        )\n\n        self.show_password_label = Label(\n            label=\"Show password\", name=\"wifi-dialog-show-password-label\"\n        )\n\n        self.show_password_box = Box(\n            orientation=\"h\",\n            spacing=8,\n            children=[self.show_password_button, self.show_password_label],\n            name=\"wifi-dialog-show-password-box\",\n        )\n\n        self.cancel_button = Button(\n            label=\"Cancel\",\n            name=\"wifi-dialog-cancel-button\",\n            on_clicked=self._on_cancel_clicked,\n        )\n\n        self.join_button = Button(\n            label=\"Join\",\n            name=\"wifi-dialog-join-button\",\n            on_clicked=self._on_join_clicked,\n        )\n\n        self.button_box = Box(\n            orientation=\"h\",\n            spacing=12,\n            h_expand=True,\n            children=[self.cancel_button, self.join_button],\n            name=\"wifi-dialog-button-box\",\n            h_align=\"end\",\n        )\n\n        self.password_container = Box(\n            orientation=\"v\",\n            h_expand=True,\n            spacing=6,\n            children=[self.password_label, self.password_entry, self.show_password_box],\n            name=\"wifi-dialog-password-container\",\n        )\n\n        self.content_box = Box(\n            orientation=\"v\",\n            h_expand=True,\n            spacing=12,\n            children=[\n                self.title_container,\n                self.error_label,\n                self.password_container,\n                self.button_box,\n            ],\n            name=\"wifi-dialog-content\",\n            h_align=\"fill\",\n            v_align=\"center\",\n        )\n\n        self.dialog_background = Box(\n            children=[self.content_box],\n            name=\"wifi-dialog-background\",\n            h_align=\"center\",\n            v_align=\"center\",\n        )\n\n        self.children = self.dialog_background\n\n        self._update_join_button_state()\n\n    def _on_password_changed(self, entry):\n        self._update_join_button_state()\n\n    def _update_join_button_state(self):\n        password = self.password_entry.get_text().strip()\n        has_password = len(password) > 0\n\n        if has_password:\n            self.join_button.set_opacity(1.0)\n            self.join_button.remove_style_class(\"disabled\")\n        else:\n            self.join_button.set_opacity(0.5)\n            self.join_button.add_style_class(\"disabled\")\n\n    def _on_show_password_clicked(self, *args):\n        self.password_visible = not self.password_visible\n        self.password_entry.set_visibility(self.password_visible)\n\n        icon_name = (\n            \"view-reveal-symbolic\" if self.password_visible else \"view-conceal-symbolic\"\n        )\n        self.show_password_button.get_image().set_property(\"icon-name\", icon_name)\n\n    def _on_key_press(self, widget, event):\n        keyval = event.keyval\n\n        if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter:\n            self._on_join_clicked()\n            return True\n        elif keyval == Gdk.KEY_Escape:\n            self._on_cancel_clicked()\n            return True\n\n        return False\n\n    def _on_visibility_changed(self, widget, *args):\n        \"\"\"Handle visibility changes\"\"\"\n        if self.get_visible():\n            GLib.timeout_add(100, lambda: self.password_entry.grab_focus())\n\n    def _on_cancel_clicked(self, *args):\n        self.hide()\n        if self.on_cancel_callback:\n            self.on_cancel_callback()\n        if self.on_dialog_closed:\n            self.on_dialog_closed()\n\n    def _on_join_clicked(self, *args):\n        if self.is_connecting:\n            return\n\n        password = self.password_entry.get_text().strip()\n        if not password:\n            self.password_entry.grab_focus()\n            return\n\n        self.is_connecting = True\n        self.join_button.set_sensitive(False)\n\n        self.connection_timeout_id = GLib.timeout_add(5000, self._connection_timeout)\n        self.error_label.set_visible(False)\n\n        self.hide()\n        if self.on_connect_callback:\n            self.on_connect_callback(self.ssid, password)\n        if self.on_dialog_closed:\n            self.on_dialog_closed()\n\n    def _connection_timeout(self):\n        if self.is_connecting:\n            self.is_connecting = False\n            self.join_button.set_sensitive(True)\n            self.show_error(\"Connection timeout. Please try again.\")\n        return False\n\n    def show_dialog(self):\n        if self.connection_timeout_id:\n            GLib.source_remove(self.connection_timeout_id)\n            self.connection_timeout_id = None\n\n        self.show_all()\n        self.password_entry.set_text(\"\")\n        self.error_label.set_visible(False)\n\n        self.password_visible = False\n        self.password_entry.set_visibility(False)\n        self.show_password_button.get_image().set_property(\n            \"icon-name\", \"view-conceal-symbolic\"\n        )\n\n        self.is_connecting = False\n        self.join_button.set_sensitive(True)\n\n        self._update_join_button_state()\n\n    def show_error(self, message=\"Incorrect password. Please try again.\"):\n        if self.connection_timeout_id:\n            GLib.source_remove(self.connection_timeout_id)\n            self.connection_timeout_id = None\n\n        self.is_connecting = False\n        self.join_button.set_sensitive(True)\n\n        if not self.get_visible():\n            self.error_label.set_text(message)\n            self.error_label.set_visible(True)\n            self.show_all()\n            GLib.timeout_add(10, lambda: self._focus_and_select_password())\n        else:\n            self.error_label.set_text(message)\n            self.error_label.set_visible(True)\n            self._focus_and_select_password()\n\n    def _focus_and_select_password(self):\n        try:\n            self.password_entry.grab_focus()\n            self.password_entry.select_region(0, -1)\n            return False\n        except:\n            return False\n\n    def get_password(self):\n        return self.password_entry.get_text()\n\n    def destroy_dialog(self):\n        self.hide()\n        self.destroy()\n"
  }
]