commit 7d8d7dacae56bec20c175b9b7466b67dabb075ab Author: Kiana Sheibani Date: Tue Oct 7 19:43:46 2025 -0400 init: working version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3f5b81d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..83a3a29 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Quickshell Desktop - `toki-night` + +This is my desktop environment, built using [Quickshell](https://quickshell.org/). + +Some other Quickshell projects that inspired this one (and that I borrowed some code from): + +- [caelestia-shell](https://github.com/caelestia-dots/shell) +- [illogical-impulse](https://github.com/end-4/dots-hyprland) + diff --git a/assets/nixos-logo.svg b/assets/nixos-logo.svg new file mode 100644 index 0000000..45c273a --- /dev/null +++ b/assets/nixos-logo.svg @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/config/Config.qml b/config/Config.qml new file mode 100644 index 0000000..9050fa0 --- /dev/null +++ b/config/Config.qml @@ -0,0 +1,186 @@ +pragma Singleton + +import QtQuick +import Quickshell + +Singleton { + readonly property list terminalCommand: ["alacritty", "-e"] + + readonly property QtObject anim: QtObject { + readonly property QtObject durations: QtObject { + readonly property int small: 200 + readonly property int normal: 400 + readonly property int large: 600 + readonly property int extraLarge: 1000 + readonly property int expressiveFastSpatial: 350 + readonly property int expressiveDefaultSpatial: 500 + readonly property int expressiveEffects: 200 + } + + readonly property QtObject curves: QtObject { + readonly property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + readonly property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + readonly property list standard: [0.2, 0, 0, 1, 1, 1] + readonly property list standardAccel: [0.3, 0, 1, 1, 1, 1] + readonly property list standardDecel: [0, 0, 0, 1, 1, 1] + readonly property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1] + readonly property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] + readonly property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] + } + } + + readonly property QtObject colors: QtObject { + // Base colors + readonly property color red: "#f7768e" + readonly property color orange: "#ff9e64" + readonly property color yellow: "#e0af68" + readonly property color turqoise: "#73daca" + readonly property color green: "#9ece6a" + readonly property color cyan: "#b4f9f8" + readonly property color blue: "#7aa2f7" + readonly property color purple: "#bb9af7" + readonly property color magenta: "#d291ea" + readonly property color violet: "#9a94ce" + readonly property color brown: "#cfc9c2" + readonly property color pink: "#f6a8cf" + + // Background + readonly property color bg: "#161621" + readonly property color container: "#202330" + readonly property color containerDash: "#1e202e" + readonly property color containerAlt: "#262a3b" + readonly property color overlay: "#030305" + + // Foreground + readonly property color primary: "#a9b1d6" + readonly property color secondary: "#c0caf5" + readonly property color tertiary: "#787c99" + readonly property color inactive: "#42465d" + readonly property color primaryDark: "#343b58" + + // Highlight + readonly property color highlight: "#c4feff" + readonly property color error: red + readonly property color errorBg: "#3f2536" + + // By section + readonly property color dashboard: magenta + readonly property color mixer: cyan + readonly property color media: blue + readonly property color performance: orange + readonly property color workspaces: pink + readonly property color workspaceMove: red + readonly property color launcherApps: pink + readonly property color launcherActions: power + readonly property color activeWindow: pink + readonly property color calendar: turqoise + readonly property color network: purple + readonly property color idle: orange + readonly property color battery: green + readonly property color batteryWarning: error + readonly property color power: red + readonly property color osd: violet + readonly property color volume: cyan + readonly property color mic: blue + readonly property color brightness: yellow + readonly property color notification: magenta + + readonly property color nixos: "#7dcfff" + } + + readonly property QtObject font: QtObject { + readonly property QtObject family: QtObject { + readonly property string sans: "Rubik" + readonly property string mono: "JetBrains Mono Nerd Font" + readonly property string material: "Material Symbols Rounded" + } + + readonly property QtObject size: QtObject { + readonly property int small: 8 + readonly property int smaller: 10 + readonly property int normal: 12 + readonly property int larger: 15 + readonly property int large: 20 + readonly property int largest: 28 + } + } + + readonly property QtObject border: QtObject { + readonly property int thickness: 6 + readonly property int rounding: 12 + } + + readonly property QtObject bar: QtObject { + readonly property int height: 40 + readonly property int containerHeight: 30 + readonly property int workspaceMargin: 2 + } + + readonly property QtObject osd: QtObject { + readonly property real volumeIncrement: 0.04 + readonly property real micIncrement: 0.04 + readonly property real brightnessIncrement: 0.05 + readonly property int hideDelay: 1500 + + readonly property int sliderLength: 200 + readonly property int sliderWidth: 30 + } + + readonly property QtObject notifs: QtObject { + readonly property bool expire: true + readonly property int defaultExpireTimeout: 5000 + readonly property real clearThreshold: 0.3 + + readonly property int width: 350 + readonly property int imageSize: 36 + readonly property int badgeSize: 24 + } + + readonly property QtObject dashboard: QtObject { + readonly property int timeWidth: 240 + readonly property int timeHeight: 100 + readonly property int weatherWidth: 280 + + readonly property real mixerWidth: 360 + readonly property real mixerHeight: 480 + + readonly property real mediaCoverArtWidth: 240 + readonly property real mediaCoverArtHeight: 180 + readonly property int mediaUpdateInterval: 500 + + readonly property real workspaceWidth: 280 + } + + readonly property QtObject launcher: QtObject { + readonly property int dragThreshold: 32 + readonly property string actionPrefix: ">" + readonly property string specialPrefix: "@" + readonly property int itemWidth: 600 + readonly property int itemHeight: 50 + readonly property int maxItemCount: 8 + } + + readonly property QtObject services: QtObject { + readonly property string weatherLocation: "" + readonly property bool useFahrenheit: [Locale.ImperialUSSystem, Locale.ImperialSystem].includes(Qt.locale().measurementSystem) + + readonly property string defaultPlayer: "Spotify" + + readonly property string sunsetFrom: "21:00" + readonly property string sunsetTo: "9:00" + readonly property int sunsetTemperature: 4500 + } + + readonly property QtObject session: QtObject { + readonly property real buttonSize: 64 + + property list logout: ["hyprctl", "dispatch", "exit"] + property list lock: ["hyprlock", "--immediate"] + property list suspend: ["systemctl", "suspend"] + property list hibernate: ["systemctl", "suspend-then-hibernate"] + property list sleep: suspend + property list reboot: ["systemctl", "reboot"] + property list shutdown: ["systemctl", "poweroff"] + } +} diff --git a/custom/Anim.qml b/custom/Anim.qml new file mode 100644 index 0000000..19aed9c --- /dev/null +++ b/custom/Anim.qml @@ -0,0 +1,8 @@ +import qs.config +import QtQuick + +NumberAnimation { + duration: Config.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Config.anim.curves.standard +} diff --git a/custom/CAnim.qml b/custom/CAnim.qml new file mode 100644 index 0000000..e991190 --- /dev/null +++ b/custom/CAnim.qml @@ -0,0 +1,8 @@ +import qs.config +import QtQuick + +ColorAnimation { + duration: Config.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Config.anim.curves.standard +} diff --git a/custom/ColoredIcon.qml b/custom/ColoredIcon.qml new file mode 100644 index 0000000..0957ea2 --- /dev/null +++ b/custom/ColoredIcon.qml @@ -0,0 +1,29 @@ +pragma ComponentBehavior: Bound + +import Quickshell.Widgets +import QtQuick + +IconImage { + id: root + + required property color color + property color dominantColor + + asynchronous: true + + /* layer.enabled: true */ + /* layer.effect: Colouriser { */ + /* sourceColor: root.dominantColour */ + /* colorizationColor: root.colour */ + /* } */ + + /* layer.onEnabledChanged: { */ + /* if (layer.enabled && status === Image.Ready) */ + /* CUtils.getDominantColour(this, c => dominantColour = c); */ + /* } */ + + /* onStatusChanged: { */ + /* if (layer.enabled && status === Image.Ready) */ + /* CUtils.getDominantColour(this, c => dominantColour = c); */ + /* } */ +} diff --git a/custom/CustomBusyIndicator.qml b/custom/CustomBusyIndicator.qml new file mode 100644 index 0000000..6e75d93 --- /dev/null +++ b/custom/CustomBusyIndicator.qml @@ -0,0 +1,82 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Shapes + +BusyIndicator { + id: root + + property color fg: Config.colors.primary + property color bg: Config.colors.container + + background: null + + contentItem: Shape { + id: shape + + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + RotationAnimator on rotation { + from: 0 + to: 180 + running: root.visible && root.running + loops: Animation.Infinite + duration: Config.anim.durations.extraLarge + easing.type: Easing.Linear + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } + + ShapePath { + strokeWidth: Math.min(root.implicitWidth, root.implicitHeight) * 0.18 + strokeColor: root.bg + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: shape.width / 2 + centerY: shape.height / 2 + radiusX: root.implicitWidth / 2 + radiusY: root.implicitHeight / 2 + startAngle: 0 + sweepAngle: 360 + } + + Behavior on strokeColor { + CAnim {} + } + } + + ShapePath { + strokeWidth: Math.min(root.implicitWidth, root.implicitHeight) * 0.18 + strokeColor: root.fg + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: shape.width / 2 + centerY: shape.height / 2 + radiusX: root.implicitWidth / 2 + radiusY: root.implicitHeight / 2 + startAngle: -sweepAngle / 2 + sweepAngle: 60 + } + + PathAngleArc { + centerX: shape.width / 2 + centerY: shape.height / 2 + radiusX: root.implicitWidth / 2 + radiusY: root.implicitHeight / 2 + startAngle: 180 - sweepAngle / 2 + sweepAngle: 60 + } + + Behavior on strokeColor { + CAnim {} + } + } + } +} diff --git a/custom/CustomClippingRect.qml b/custom/CustomClippingRect.qml new file mode 100644 index 0000000..12be9fc --- /dev/null +++ b/custom/CustomClippingRect.qml @@ -0,0 +1,8 @@ + +import QtQuick +import Quickshell.Widgets +import qs.config + +ClippingRectangle { + color: "transparent" +} diff --git a/custom/CustomFilledSlider.qml b/custom/CustomFilledSlider.qml new file mode 100644 index 0000000..de86aae --- /dev/null +++ b/custom/CustomFilledSlider.qml @@ -0,0 +1,156 @@ +import qs.services +import qs.config +import QtQuick +import QtQuick.Controls + +Slider { + id: root + + required property string icon + required property color color + property real oldValue + property bool initializing: true + + Timer { + id: initDelay + running: true + interval: 100 + onTriggered: root.initializing = false + } + + orientation: Qt.Vertical + + background: CustomRect { + color: Config.colors.container + radius: 1000 + + CustomRect { + implicitWidth: root.orientation === Qt.Horizontal ? root.handle.x + root.handle.width : root.handle.width + y: root.orientation === Qt.Vertical ? root.handle.y : 0 + implicitHeight: root.orientation === Qt.Vertical ? parent.height - y : root.handle.height + + color: root.color + opacity: 0.8 + radius: parent.radius + } + } + + handle: Item { + id: handle + + property bool moving: false + + x: root.orientation === Qt.Horizontal ? root.visualPosition * (root.availableWidth - width) : 0 + y: root.orientation === Qt.Vertical ? root.visualPosition * (root.availableHeight - height) : 0 + implicitWidth: root.orientation === Qt.Horizontal ? root.height : root.width + implicitHeight: implicitWidth + + Elevation { + anchors.fill: parent + radius: rect.radius + level: handleInteraction.containsMouse ? 2 : 1 + } + + CustomRect { + id: rect + anchors.fill: parent + + color: root.color + radius: 1000 + + MouseArea { + id: handleInteraction + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.NoButton + } + + MaterialIcon { + id: icon + + property bool moving: handle.moving + property bool stoppingMove: false + + function update(): void { + text = moving ? Qt.binding(() => Math.round(root.value * 100)) : Qt.binding(() => root.icon); + font.family = moving ? Config.font.family.sans : Config.font.family.material; + font.pointSize = moving ? Config.font.size.smaller : Config.font.size.larger; + } + + animate: !moving && !stoppingMove + animateDuration: Config.anim.durations.small + text: root.icon + font.pointSize: Config.font.size.larger + color: Config.colors.primaryDark + anchors.centerIn: parent + + Behavior on moving { + enabled: icon.moving !== targetValue + SequentialAnimation { + PropertyAction { + target: icon + property: "stoppingMove" + value: true + } + Anim { + target: icon + property: "scale" + from: 1 + to: 0 + duration: Config.anim.durations.small / 2 + easing.bezierCurve: Config.anim.curves.standardAccel + } + ScriptAction { + script: icon.update() + } + Anim { + target: icon + property: "scale" + from: 0 + to: 1 + duration: Config.anim.durations.small / 2 + easing.bezierCurve: Config.anim.curves.standardDecel + } + PropertyAction { + target: icon + property: "stoppingMove" + value: false + } + } + } + } + + } + } + + onPressedChanged: handle.moving = pressed + + onValueChanged: { + if (Math.abs(value - oldValue) < 0.01) + return; + if (!initializing) { + oldValue = value; + handle.moving = true; + stateChangeDelay.restart(); + } + } + + Timer { + id: stateChangeDelay + + interval: 600 + onTriggered: { + if (!root.pressed) + handle.moving = false; + } + } + + Behavior on value { + enabled: !root.initializing + Anim { + duration: Config.anim.durations.normal + } + } +} diff --git a/custom/CustomListView.qml b/custom/CustomListView.qml new file mode 100644 index 0000000..7607baf --- /dev/null +++ b/custom/CustomListView.qml @@ -0,0 +1,14 @@ +import qs.custom +import QtQuick + +ListView { + id: root + + maximumFlickVelocity: 3000 + + rebound: Transition { + Anim { + properties: "x,y" + } + } +} diff --git a/custom/CustomMouseArea.qml b/custom/CustomMouseArea.qml new file mode 100644 index 0000000..8b111c0 --- /dev/null +++ b/custom/CustomMouseArea.qml @@ -0,0 +1,22 @@ +import QtQuick + +MouseArea { + property int scrollAccumulatedY: 0 + property int scrollThreshold: 160 + + function onWheel(event: WheelEvent): void { + } + + onWheel: event => { + // Update accumulated scroll + if (Math.sign(event.angleDelta.y) !== Math.sign(scrollAccumulatedY)) + scrollAccumulatedY = 0; + scrollAccumulatedY += event.angleDelta.y; + + // Trigger handler and reset if above threshold + if (Math.abs(scrollAccumulatedY) >= scrollThreshold) { + onWheel(event); + scrollAccumulatedY = 0; + } + } +} diff --git a/custom/CustomRect.qml b/custom/CustomRect.qml new file mode 100644 index 0000000..098a019 --- /dev/null +++ b/custom/CustomRect.qml @@ -0,0 +1,7 @@ + +import QtQuick +import qs.config + +Rectangle { + color: "transparent" +} diff --git a/custom/CustomScrollBar.qml b/custom/CustomScrollBar.qml new file mode 100644 index 0000000..6271a0b --- /dev/null +++ b/custom/CustomScrollBar.qml @@ -0,0 +1,107 @@ +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates + +ScrollBar { + id: root + + required property Flickable flickable + property bool shouldBeActive + property real nonAnimPosition + property bool animating + + onHoveredChanged: { + if (hovered) + shouldBeActive = true; + else + shouldBeActive = flickable.moving; + } + + onPositionChanged: { + if (position === nonAnimPosition) + animating = false; + else if (!animating) + nonAnimPosition = position; + } + + position: nonAnimPosition + implicitWidth: 5 + + contentItem: CustomRect { + anchors.left: parent.left + anchors.right: parent.right + opacity: { + if (root.size === 1) + return 0; + if (fullMouse.pressed) + return 0.8; + if (mouse.containsMouse) + return 0.6; + if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive) + return 0.4; + return 0; + } + radius: 1000 + color: Config.colors.secondary + + MouseArea { + id: mouse + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + Behavior on opacity { + Anim {} + } + } + + Connections { + target: root.flickable + + function onMovingChanged(): void { + if (root.flickable.moving) + root.shouldBeActive = true; + else + hideDelay.restart(); + } + } + + Timer { + id: hideDelay + + interval: 600 + onTriggered: root.shouldBeActive = root.flickable.moving || root.hovered + } + + CustomMouseArea { + id: fullMouse + + anchors.fill: parent + preventStealing: true + + onPressed: event => { + root.animating = true; + root.nonAnimPosition = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); + } + + onPositionChanged: event => root.nonAnimPosition = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)) + + function onWheel(event: WheelEvent): void { + root.animating = true; + if (event.angleDelta.y > 0) + root.nonAnimPosition = Math.max(0, root.nonAnimPosition - 0.1); + else if (event.angleDelta.y < 0) + root.nonAnimPosition = Math.min(1 - root.size, root.nonAnimPosition + 0.1); + } + } + + Behavior on position { + enabled: !fullMouse.pressed + + Anim {} + } +} diff --git a/custom/CustomShortcut.qml b/custom/CustomShortcut.qml new file mode 100644 index 0000000..5815bee --- /dev/null +++ b/custom/CustomShortcut.qml @@ -0,0 +1,5 @@ +import Quickshell.Hyprland + +GlobalShortcut { + appid: "quickshell" +} diff --git a/custom/CustomSlider.qml b/custom/CustomSlider.qml new file mode 100644 index 0000000..42b901b --- /dev/null +++ b/custom/CustomSlider.qml @@ -0,0 +1,59 @@ +import qs.config +import qs.custom +import qs.services +import QtQuick +import QtQuick.Controls + +Slider { + id: root + + property color progressColor: Config.colors.primary + + background: Item { + CustomRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.topMargin: root.implicitHeight / 4 + anchors.bottomMargin: root.implicitHeight / 4 + + implicitWidth: root.handle.x - root.implicitHeight / 6 + + color: root.progressColor + radius: 1000 + topRightRadius: root.implicitHeight / 15 + bottomRightRadius: root.implicitHeight / 15 + } + + CustomRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.topMargin: root.implicitHeight / 4 + anchors.bottomMargin: root.implicitHeight / 4 + + implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6 + + color: Config.colors.container + radius: 1000 + topLeftRadius: root.implicitHeight / 15 + bottomLeftRadius: root.implicitHeight / 15 + } + } + + handle: CustomRect { + x: root.visualPosition * root.availableWidth - implicitWidth / 2 + + implicitWidth: root.implicitHeight / 3 + implicitHeight: root.implicitHeight + + color: Config.colors.secondary + radius: 1000 + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } + } +} diff --git a/custom/CustomSwitch.qml b/custom/CustomSwitch.qml new file mode 100644 index 0000000..efdcb41 --- /dev/null +++ b/custom/CustomSwitch.qml @@ -0,0 +1,155 @@ +import qs.config +import qs.custom +import qs.services +import qs.util +import QtQuick +import QtQuick.Controls +import QtQuick.Shapes + +Switch { + id: root + + property real size: Config.font.size.normal * 1.5 + property color bg: Config.colors.container + property color accent: Color.mute(Config.colors.accent) + + implicitWidth: implicitIndicatorWidth + implicitHeight: implicitIndicatorHeight + + indicator: CustomRect { + radius: 1000 + color: root.checked ? root.accent : root.bg + + implicitWidth: implicitHeight * 1.7 + implicitHeight: size + + CustomRect { + readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight + + radius: 1000 + color: root.checked ? Config.colors.secondary : Config.colors.tertiary + + x: root.checked ? parent.implicitWidth - nonAnimWidth : 0 + implicitWidth: nonAnimWidth + implicitHeight: parent.implicitHeight + anchors.verticalCenter: parent.verticalCenter + + CustomRect { + anchors.fill: parent + radius: parent.radius + + color: root.checked ? Config.colors.tertiary : Config.colors.secondary + opacity: root.pressed ? 0.4 : root.hovered ? 0.2 : 0 + + Behavior on opacity { + Anim {} + } + } + + Shape { + id: icon + + property point start1: { + if (root.pressed) + return Qt.point(width * 0.2, height / 2); + if (root.checked) + return Qt.point(width * 0.2, height / 2); + return Qt.point(width * 0.15, height * 0.15); + } + property point end1: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.8, height / 2); + } + if (root.checked) + return Qt.point(width * 0.45, height * 0.7); + return Qt.point(width * 0.85, height * 0.85); + } + property point start2: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.2, height / 2); + } + if (root.checked) + return Qt.point(width * 0.45, height * 0.7); + return Qt.point(width * 0.15, height * 0.85); + } + property point end2: { + if (root.pressed) + return Qt.point(width * 0.8, height / 2); + if (root.checked) + return Qt.point(width * 0.9, height * 0.2); + return Qt.point(width * 0.85, height * 0.15); + } + + anchors.centerIn: parent + width: height + height: parent.implicitHeight * 0.45 + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + strokeWidth: root.size * 0.1 + strokeColor: Config.colors.primaryDark + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + startX: icon.start1.x + startY: icon.start1.y + + PathLine { + x: icon.end1.x + y: icon.end1.y + } + PathMove { + x: icon.start2.x + y: icon.start2.y + } + PathLine { + x: icon.end2.x + y: icon.end2.y + } + + Behavior on strokeColor { + CAnim {} + } + } + + Behavior on start1 { + PropAnim {} + } + Behavior on end1 { + PropAnim {} + } + Behavior on start2 { + PropAnim {} + } + Behavior on end2 { + PropAnim {} + } + } + + Behavior on x { + Anim {} + } + + Behavior on implicitWidth { + Anim {} + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + enabled: false + } + + component PropAnim: PropertyAnimation { + duration: Config.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Config.anim.curves.standard + } +} diff --git a/custom/CustomText.qml b/custom/CustomText.qml new file mode 100644 index 0000000..b422d93 --- /dev/null +++ b/custom/CustomText.qml @@ -0,0 +1,46 @@ + +import QtQuick +import Quickshell +import qs.config + +Text { + id: root + + // Either boolean or function taking old and new text + property var animate: false + property string animateProp: "opacity" + property real animateFrom: 0 + property real animateTo: 1 + property int animateDuration: Config.anim.durations.normal + + renderType: Text.NativeRendering + textFormat: Text.PlainText + color: Config.colors.primary + linkColor: Config.colors.highlight + font.family: Config.font.family.sans + font.pointSize: Config.font.size.smaller + + Behavior on text { + enabled: typeof root.animate === "boolean" + ? root.animate : root.animate(root.text, targetValue) + + SequentialAnimation { + Anim { + to: root.animateFrom + easing.bezierCurve: Config.anim.curves.standardAccel + } + PropertyAction {} + Anim { + to: root.animateTo + easing.bezierCurve: Config.anim.curves.standardDecel + } + } + } + + component Anim: NumberAnimation { + target: root + property: root.animateProp + duration: root.animateDuration / 2 + easing.type: Easing.BezierSpline + } +} diff --git a/custom/CustomTextField.qml b/custom/CustomTextField.qml new file mode 100644 index 0000000..5d5cd3d --- /dev/null +++ b/custom/CustomTextField.qml @@ -0,0 +1,68 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import QtQuick +import QtQuick.Controls + +TextField { + id: root + + color: Config.colors.secondary + placeholderTextColor: Config.colors.tertiary + font.family: Config.font.family.sans + font.pointSize: Config.font.size.smaller + renderType: TextField.NativeRendering + cursorVisible: !readOnly + + background: null + + cursorDelegate: CustomRect { + id: cursor + + property bool disableBlink + + implicitWidth: 2 + color: Config.colors.primary + radius: 20 + + Connections { + target: root + + function onCursorPositionChanged(): void { + if (root.activeFocus && root.cursorVisible) { + cursor.opacity = 1; + cursor.disableBlink = true; + enableBlink.restart(); + } + } + } + + Timer { + id: enableBlink + + interval: 100 + onTriggered: cursor.disableBlink = false + } + + Timer { + running: root.activeFocus && root.cursorVisible && !cursor.disableBlink + repeat: true + triggeredOnStart: true + interval: 500 + onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1 + } + + Binding { + when: !root.activeFocus || !root.cursorVisible + cursor.opacity: 0 + } + + Behavior on opacity { + Anim { + duration: Config.anim.durations.small + } + } + } +} diff --git a/custom/CustomWindow.qml b/custom/CustomWindow.qml new file mode 100644 index 0000000..f6e4573 --- /dev/null +++ b/custom/CustomWindow.qml @@ -0,0 +1,10 @@ + +import Quickshell +import Quickshell.Wayland + +PanelWindow { + required property string name + + WlrLayershell.namespace: `quickshell-${name}` + color: "transparent" +} diff --git a/custom/Elevation.qml b/custom/Elevation.qml new file mode 100644 index 0000000..33c40fb --- /dev/null +++ b/custom/Elevation.qml @@ -0,0 +1,17 @@ +import qs.config +import QtQuick +import QtQuick.Effects + +RectangularShadow { + property int level + property real dp: [0, 1, 3, 6, 8, 12][level] + + color: Qt.alpha(Config.colors.bg, 0.9) + blur: (dp * 5) ** 0.7 + spread: -dp * 0.3 + (dp * 0.1) ** 2 + offset.y: dp / 2 + + Behavior on dp { + Anim {} + } +} diff --git a/custom/ExtraIndicator.qml b/custom/ExtraIndicator.qml new file mode 100644 index 0000000..d53da87 --- /dev/null +++ b/custom/ExtraIndicator.qml @@ -0,0 +1,49 @@ +import qs.services +import qs.config +import QtQuick + +CustomRect { + required property int extra + + anchors.right: parent.right + anchors.margins: 12 + + color: Config.colors.inactive + radius: 12 + + implicitWidth: count.implicitWidth + 20 + implicitHeight: count.implicitHeight + 10 + + opacity: extra > 0 ? 1 : 0 + scale: extra > 0 ? 1 : 0.5 + + Elevation { + anchors.fill: parent + radius: parent.radius + opacity: parent.opacity + z: -1 + level: 2 + } + + CustomText { + id: count + + anchors.centerIn: parent + animate: false + text: qsTr("+%1").arg(parent.extra) + color: Config.colors.secondary + } + + Behavior on opacity { + Anim { + duration: Config.anim.durations.expressiveFastSpatial + } + } + + Behavior on scale { + Anim { + duration: Config.anim.durations.expressiveFastSpatial + easing.bezierCurve: Config.anim.curves.expressiveFastSpatial + } + } +} diff --git a/custom/GlowEffect.qml b/custom/GlowEffect.qml new file mode 100644 index 0000000..c28c926 --- /dev/null +++ b/custom/GlowEffect.qml @@ -0,0 +1,18 @@ + +import QtQuick +import QtQuick.Effects + +MultiEffect { + id: root + + anchors.fill: source + + property alias glowColor: root.shadowColor + shadowEnabled: true + blurMultiplier: 0.3 + blurMax: 30 + + Behavior on shadowColor { + CAnim {} + } +} diff --git a/custom/MaterialIcon.qml b/custom/MaterialIcon.qml new file mode 100644 index 0000000..5893a09 --- /dev/null +++ b/custom/MaterialIcon.qml @@ -0,0 +1,32 @@ +import qs.config +import QtQuick + +CustomText { + id: root + + property real fill + property int grade: -25 + property bool animateAxes: true + + Behavior on font.variableAxes { + enabled: root.animateAxes && root.animate + + SequentialAnimation { + PauseAnimation { + duration: root.animateDuration / 2 + } + PropertyAction {} + } + } + + color: Config.colors.secondary + animateProp: "scale" + font.family: "Material Symbols Rounded" + font.pointSize: Config.font.size.normal + font.variableAxes: ({ + FILL: fill, + GRAD: grade, + opsz: fontInfo.pixelSize, + wght: fontInfo.weight + }) +} diff --git a/custom/StateLayer.qml b/custom/StateLayer.qml new file mode 100644 index 0000000..fc5422b --- /dev/null +++ b/custom/StateLayer.qml @@ -0,0 +1,101 @@ + +import QtQuick +import qs.config +import qs.custom +import qs.services + +MouseArea { + id: root + + property bool disabled + property color color: Config.colors.primary + property real radius: 1000 + + function onClicked(): void {} + + cursorShape: disabled ? undefined : Qt.PointingHandCursor + hoverEnabled: true + + onPressed: event => { + rippleAnim.x = event.x; + rippleAnim.y = event.y; + + const dist = (ox, oy) => ox * ox + oy * oy; + rippleAnim.radius = Math.sqrt(Math.max(dist(0, 0), dist(0, width), dist(width, 0), dist(width, height))); + + rippleAnim.restart(); + } + + onClicked: event => !disabled && onClicked(event) + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 0.1 + } + ParallelAnimation { + Anim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + duration: Config.anim.durations.large + easing.bezierCurve: Config.anim.curves.standardDecel + } + Anim { + target: ripple + property: "opacity" + to: 0 + duration: Config.anim.durations.large + easing.type: Easing.BezierSpline + easing.bezierCurve: Config.anim.curves.standardDecel + } + } + } + + CustomClippingRect { + id: hoverLayer + + anchors.fill: parent + + property real alpha: root.disabled ? 0 : root.pressed ? 0.1 : root.containsMouse ? 0.05 : 0 + color: Qt.alpha(root.color, alpha) + radius: root.radius + + Behavior on alpha { + Anim { + duration: Config.anim.durations.small + } + } + + CustomRect { + id: ripple + + radius: 1000 + color: root.color + opacity: 0 + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..005fab0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,64 @@ +{ + "nodes": { + "nix-systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1759386674, + "narHash": "sha256-wg1Lz/1FC5Q13R+mM5a2oTV9TA9L/CHHTm3/PiLayfA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "625ad6366178f03acd79f9e3822606dd7985b657", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "quickshell": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1759303785, + "narHash": "sha256-EUXrK7pUIzOQWR1dquZh26A6W8lsY2oiHEEZzQnsarM=", + "ref": "refs/heads/master", + "rev": "9662234759eb57f2a1057f2a1c667da1bf128c1c", + "revCount": 686, + "type": "git", + "url": "https://git.outfoxxed.me/outfoxxed/quickshell" + }, + "original": { + "type": "git", + "url": "https://git.outfoxxed.me/outfoxxed/quickshell" + } + }, + "root": { + "inputs": { + "nix-systems": "nix-systems", + "nixpkgs": "nixpkgs", + "quickshell": "quickshell" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7e30908 --- /dev/null +++ b/flake.nix @@ -0,0 +1,45 @@ +{ + description = "My personal desktop shell, made with Quickshell"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + nix-systems.url = "github:nix-systems/default"; + + quickshell = { + url = "git+https://git.outfoxxed.me/outfoxxed/quickshell"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, nix-systems, quickshell, ... } @ inputs: + let + inherit (nixpkgs) lib; + eachSystem = lib.genAttrs (import nix-systems); + + fonts = pkgs: with pkgs; [ material-symbols rubik nerd-fonts.jetbrains-mono ]; + in { + packages = eachSystem (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in rec { + toki-quickshell = pkgs.callPackage ./package.nix { + quickshell = quickshell.packages.${system}.default.override { + withX11 = false; + withI3 = false; + }; + fonts = fonts pkgs; + }; + default = toki-quickshell; + }); + + devShells = eachSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + shell = self.packages.${system}.default; + in { + default = pkgs.mkShell { + inputsFrom = [ shell ]; + packages = [ pkgs.clazy ] ++ fonts pkgs; + }; + }); + }; +} diff --git a/modules/Commands.qml b/modules/Commands.qml new file mode 100644 index 0000000..7445ca6 --- /dev/null +++ b/modules/Commands.qml @@ -0,0 +1,145 @@ +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import QtQuick + +Scope { + id: root + + // Workspace Handling + + function allWorkspaces(): list { + let ids = []; + States.screens.forEach((uiState, _, _) => { + for (let i = 0; i < uiState.workspaces.count; i++) { + ids.push(uiState.workspaces.get(i).workspace.id); + } + }) + return ids; + } + + function nextWorkspace(): int { + let id = 1; + const ws = allWorkspaces(); + while (ws.includes(id)) id++; + return id; + } + + // Workspace Commands + + function newWorkspace(): void { + Hypr.dispatch(`focusworkspaceoncurrentmonitor ${nextWorkspace()}`); + } + + CustomShortcut { + name: "newWorkspace" + description: "Go to a new workspace" + onPressed: root.newWorkspace(); + } + Timer { + id: newWorkspaceTimer + running: false + interval: Hypr.arbitraryRaceConditionDelay + onTriggered: { + const uiState = States.getForActive(); + const workspace = Hypr.focusedWorkspace; + // Add workspace if not already added during delay + let found = false; + for (let i = 0; i < uiState.workspaces.count; i++) { + if (uiState.workspaces.get(i).workspace.id === workspace.id) + found = true; + } + if (!found) + uiState.workspaces.append({"workspace": workspace}); + } + } + + function goToWorkspace(index: int): void { + const uiState = States.getForActive(); + if (index > uiState.workspaces.count) return; + let id; + if (index === uiState.workspaces.count) { + id = root.nextWorkspace(); + } else { + id = uiState.workspaces.get(index).workspace.id; + } + Hypr.dispatch(`workspace ${id}`); + } + + Instantiator { + model: [...Array(10).keys()] + delegate: CustomShortcut { + required property int modelData + name: `workspace${modelData + 1}` + description: `Go to workspace ${modelData + 1}` + onPressed: root.goToWorkspace(modelData) + } + } + + function moveToWorkspace(index: int): void { + const uiState = States.getForActive(); + if (index > uiState.workspaces.count) return; + let id; + if (index === uiState.workspaces.count) { + id = nextWorkspace(); + } else { + id = uiState.workspaces.get(index).workspace.id; + } + Hypr.dispatch(`movetoworkspace ${id}`); + } + + Instantiator { + model: [...Array(10).keys()] + delegate: CustomShortcut { + required property int modelData + name: `movetoworkspace${modelData + 1}` + description: `Move the focused window to workspace ${modelData + 1}` + onPressed: root.moveToWorkspace(modelData) + } + } + + // Panels + + CustomShortcut { + name: "dashboard" + description: "Toggle dashboard" + onPressed: { + const uiState = States.getForActive(); + uiState.dashboard = !uiState.dashboard; + } + } + + CustomShortcut { + name: "launcher" + description: "Toggle launcher" + onPressed: { + const uiState = States.getForActive(); + uiState.laucher = !uiState.launcher; + } + } + + CustomShortcut { + name: "session" + description: "Toggle session menu" + onPressed: { + const uiState = States.getForActive(); + uiState.session = !uiState.session; + } + } + + CustomShortcut { + name: "escape" + description: "Close every panel" + onPressed: { + States.screens.forEach((uiState, _, _) => { + uiState.dashboard = false; + uiState.launcher = false; + uiState.osd = false; + uiState.session = false; + uiState.blockScreen = false; + }); + } + } +} diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml new file mode 100644 index 0000000..03d0aad --- /dev/null +++ b/modules/bar/Bar.qml @@ -0,0 +1,137 @@ + +import QtQuick +import Quickshell +import qs.config +import qs.custom +import qs.services +import "modules" +import "popouts" as BarPopouts + +Item { + id: root + + required property PersistentProperties uiState + required property ShellScreen screen + required property BarPopouts.Wrapper popouts + + anchors.left: parent.left + anchors.top: parent.top + anchors.right: parent.right + implicitHeight: Config.bar.height + + // Modules + + NixOS { + id: nixos + objectName: "nixos" + + anchors.left: parent.left + } + + Workspaces { + id: workspaces + + anchors.left: nixos.right + + workspaces: root.uiState.workspaces + } + + Window { + id: window + objectName: "window" + + // Expand window title from center as much as possible + // without intersecting other modules + anchors.centerIn: parent + implicitWidth: 2 * Math.min( + (parent.width / 2) - workspaces.x - workspaces.width, + tray.x - (parent.width / 2)) - 40 + } + + Tray { + id: tray + objectName: "tray" + + anchors.right: clock.left + anchors.rightMargin: 8 + } + + Clock { + id: clock + objectName: "clock" + + anchors.right: statusIcons.left + anchors.rightMargin: 12 + } + + StatusIcons { + id: statusIcons + objectName: "statusIcons" + + anchors.right: power.left + anchors.rightMargin: 6 + } + + Power { + id: power + uiState: root.uiState + + anchors.right: parent.right + anchors.rightMargin: 6 + } + + // Popout Interactions + + function checkPopout(x: real): void { + const ch = childAt(x, height / 2); + if (!ch) { + popouts.hasCurrent = false; + return; + } + + const name = ch.objectName; + const left = ch.x; + const chWidth = ch.implicitWidth; + + if (name === "nixos") { + popouts.currentName = "nixos"; + popouts.currentCenter = 0; + popouts.hasCurrent = true; + } else if (name === "statusIcons") { + const layout = ch.children[0]; + const icon = layout.childAt(mapToItem(layout, x, 0).x, layout.height / 2); + if (icon && icon.objectName) { + popouts.currentName = icon.objectName; + popouts.currentCenter = Qt.binding(() => + icon.mapToItem(root, 0, icon.implicitWidth / 2).x); + popouts.hasCurrent = true; + } else if (icon && !icon.objectName) { + popouts.hasCurrent = false; + } + } else if (name === "tray") { + const index = Math.floor(((x - left) / chWidth) * tray.repeater.count); + const trayItem = tray.repeater.itemAt(index); + if (trayItem) { + popouts.currentName = `traymenu${index}`; + popouts.currentCenter = Qt.binding(() => + trayItem.mapToItem(root, 0, trayItem.implicitWidth / 2).x); + popouts.hasCurrent = true; + } + } else if (name === "clock") { + popouts.currentName = "calendar"; + popouts.currentCenter = ch.mapToItem(root, chWidth / 2, 0).x; + popouts.hasCurrent = true; + } else if (name === "window" && Hypr.activeToplevel) { + const inner = ch.childAt(mapToItem(ch, x, 0).x, height / 2) + if (inner) { + popouts.currentName = "activewindow"; + popouts.currentCenter = ch.mapToItem(root, chWidth / 2, 0).x; + popouts.hasCurrent = true; + } else { + popouts.hasCurrent = false; + } + } else { + popouts.hasCurrent = false; + } + } +} diff --git a/modules/bar/Container.qml b/modules/bar/Container.qml new file mode 100644 index 0000000..c80881e --- /dev/null +++ b/modules/bar/Container.qml @@ -0,0 +1,15 @@ + +import QtQuick +import Quickshell +import qs.config +import qs.custom + +CustomRect { + color: Config.colors.container + + implicitWidth: Math.max(childrenRect.width, height) + implicitHeight: Config.bar.containerHeight + anchors.verticalCenter: parent.verticalCenter + + radius: 1000 +} diff --git a/modules/bar/modules/Clock.qml b/modules/bar/modules/Clock.qml new file mode 100644 index 0000000..954c055 --- /dev/null +++ b/modules/bar/modules/Clock.qml @@ -0,0 +1,35 @@ + +import QtQuick +import qs.services +import qs.config +import qs.custom + +Row { + id: root + + anchors.verticalCenter: parent.verticalCenter + + property color color: Config.colors.turqoise + spacing: 4 + + MaterialIcon { + id: icon + + text: "calendar_month" + font.pointSize: Config.font.size.normal + 1 + color: root.color + + anchors.verticalCenter: parent.verticalCenter + } + + CustomText { + id: text + + anchors.verticalCenter: parent.verticalCenter + + text: Time.format("hh:mm") + font.pointSize: Config.font.size.smaller + font.family: Config.font.family.mono + color: root.color + } +} diff --git a/modules/bar/modules/NixOS.qml b/modules/bar/modules/NixOS.qml new file mode 100644 index 0000000..10c2b4a --- /dev/null +++ b/modules/bar/modules/NixOS.qml @@ -0,0 +1,15 @@ + +import qs.config +import qs.custom +import QtQuick + +CustomText { + anchors.verticalCenter: parent.verticalCenter + + text: "" + width: implicitWidth + 32 + horizontalAlignment: Text.AlignHCenter + font.family: Config.font.family.mono + font.pointSize: Config.font.size.normal + color: Config.colors.nixos +} diff --git a/modules/bar/modules/Power.qml b/modules/bar/modules/Power.qml new file mode 100644 index 0000000..4d9c55b --- /dev/null +++ b/modules/bar/modules/Power.qml @@ -0,0 +1,29 @@ + +import Quickshell +import qs.config +import qs.custom +import qs.services + +StateLayer { + required property PersistentProperties uiState + + anchors.verticalCenter: parent.verticalCenter + + implicitWidth: icon.implicitHeight + 10 + implicitHeight: implicitWidth + + function onClicked(): void { + uiState.session = !uiState.session; + } + + MaterialIcon { + id: icon + anchors.centerIn: parent + anchors.horizontalCenterOffset: 0.5 + + text: "power_settings_new" + color: Config.colors.error + font.bold: true + font.pointSize: Config.font.size.smaller + } +} diff --git a/modules/bar/modules/StatusIcons.qml b/modules/bar/modules/StatusIcons.qml new file mode 100644 index 0000000..59a9c17 --- /dev/null +++ b/modules/bar/modules/StatusIcons.qml @@ -0,0 +1,166 @@ +import Quickshell +import Quickshell.Services.UPower +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.config +import qs.custom +import qs.util +import qs.modules.bar + +Container { + id: root + implicitWidth: layout.width + 20 + + RowLayout { + id: layout + + anchors.centerIn: parent + spacing: 10 + + MaterialIcon { + id: network + objectName: "network" + + Layout.alignment: Qt.AlignVCenter + + text: Network.active ? Icons.getNetworkIcon(Network.active.strength ?? 0) : "wifi_off" + color: text !== "wifi_off" ? Config.colors.secondary : Config.colors.tertiary + animate: (from, to) => from === "wifi_off" || to === "wifi_off" + + MouseArea { + anchors.fill: parent + onClicked: Network.toggleWifi() + } + } + + /* MaterialIcon { */ + /* id: bluetooth */ + /* objectName: "bluetooth" */ + + /* anchors.verticalCenter: parent.verticalCenter */ + + /* animate: true */ + /* text: Bluetooth.powered ? "bluetooth" : "bluetooth_disabled" */ + /* } */ + + /* Row { */ + /* id: devices */ + /* objectName: "devices" */ + + /* anchors.verticalCenter: parent.verticalCenter */ + + /* Repeater { */ + /* id: repeater */ + + /* model: ScriptModel { */ + /* values: Bluetooth.devices.filter(d => d.connected) */ + /* } */ + + /* MaterialIcon { */ + /* required property Bluetooth.Device modelData */ + + /* animate: true */ + /* text: Icons.getBluetoothIcon(modelData.icon) */ + /* fill: 1 */ + /* } */ + /* } */ + /* } */ + + MaterialIcon { + id: idleinhibit + objectName: "idleinhibit" + + Layout.alignment: Qt.AlignVCenter + + text: Idle.inhibit ? "visibility" : "visibility_off" + color: text === "visibility" ? Config.colors.secondary : Config.colors.tertiary + fill: Idle.inhibit ? 1 : 0 + animate: true + + MouseArea { + anchors.fill: parent + onClicked: Idle.inhibit = !Idle.inhibit + } + } + + MaterialIcon { + id: battery + objectName: "battery" + + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: hasBattery ? -2 : 0 + Layout.topMargin: hasBattery ? 0.5 : 2 + + readonly property bool hasBattery: UPower.displayDevice.isLaptopBattery + readonly property real percentage: UPower.displayDevice.percentage + readonly property bool charging: !UPower.onBattery && batteryText.text !== "100" + readonly property bool warning: UPower.onBattery && percentage < 0.15 + + text: { + if (!hasBattery) { + if (PowerProfiles.profile === PowerProfile.PowerSaver) + return "energy_savings_leaf"; + if (PowerProfiles.profile === PowerProfile.Performance) + return "rocket_launch"; + return "balance"; + } + return `battery_android_full`; + } + fill: 1 + font.pointSize: hasBattery ? 18 : Config.font.size.normal + grade: 50 + font.weight: 100 + color: !hasBattery ? Config.colors.secondary : + warning ? Config.colors.errorBg : + batteryText.text === "100" ? Config.colors.battery : + Color.mute(Config.colors.battery, 0.6, 1.5) + + CustomRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.topMargin: 9 + anchors.bottomMargin: 9 + anchors.leftMargin: 3 + width: (battery.width - 7) * battery.percentage + radius: 2 + + visible: battery.hasBattery + color: battery.warning ? Config.colors.batteryWarning : Config.colors.battery + } + + Row { + anchors.centerIn: parent + anchors.horizontalCenterOffset: battery.charging ? width / 20 : -width / 15 + + visible: battery.hasBattery + spacing: -1 + + CustomText { + id: batteryText + + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: 0.5 + text: Math.round(battery.percentage * 100) + color: battery.warning ? Config.colors.batteryWarning : Config.colors.bg + font.family: Config.font.family.mono + font.pointSize: 6 + font.weight: 800 + } + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + + visible: battery.charging + text: "bolt" + fill: 1 + color: Config.colors.bg + font.pointSize: 7 + font.weight: 300 + } + + } + } + } +} diff --git a/modules/bar/modules/Tray.qml b/modules/bar/modules/Tray.qml new file mode 100644 index 0000000..4279687 --- /dev/null +++ b/modules/bar/modules/Tray.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.SystemTray +import qs.config +import qs.custom + +Item { + id: root + + clip: true + + anchors.verticalCenter: parent.verticalCenter + implicitWidth: layout.implicitWidth + implicitHeight: layout.implicitHeight + // To avoid warnings about being visible with no size + visible: width > 0 && height > 0 + + readonly property Item repeater: repeater + + Row { + id: layout + + add: Transition { + Anim { + property: "scale" + from: 0 + to: 1 + easing.bezierCurve: Config.anim.curves.standardDecel + } + } + + move: Transition { + Anim { + property: "scale" + to: 1 + easing.bezierCurve: Config.anim.curves.standardDecel + } + Anim { + properties: "x,y" + easing.bezierCurve: Config.anim.curves.standard + } + } + + Repeater { + id: repeater + + model: SystemTray.items + + MouseArea { + id: trayItem + + required property SystemTrayItem modelData + + implicitWidth: icon.implicitWidth + 10 + implicitHeight: icon.implicitHeight + + onClicked: modelData.activate() + + IconImage { + id: icon + anchors.centerIn: parent + + source: { + let icon = trayItem.modelData.icon; + if (icon.includes("?path=")) { + const [name, path] = icon.split("?path="); + icon = `file://${path}/${name.slice(name.lastIndexOf("/") + 1)}`; + } + return icon; + } + asynchronous: true + implicitSize: Config.font.size.larger + } + } + } + } + + Behavior on implicitWidth { + Anim { + easing.bezierCurve: Config.anim.curves.emphasized + } + } + + Behavior on implicitHeight { + Anim { + easing.bezierCurve: Config.anim.curves.emphasized + } + } +} diff --git a/modules/bar/modules/Window.qml b/modules/bar/modules/Window.qml new file mode 100644 index 0000000..8ff5fc1 --- /dev/null +++ b/modules/bar/modules/Window.qml @@ -0,0 +1,96 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.config +import qs.custom +import qs.services +import qs.util + +Item { + id: root + + property color color: Config.colors.primary + + implicitHeight: child.implicitHeight + + Item { + id: child + + anchors.centerIn: parent + implicitWidth: icon.implicitWidth + current.implicitWidth + current.anchors.leftMargin + implicitHeight: Math.max(icon.implicitHeight, current.implicitHeight) + + clip: true + + property Item current: text1 + + MaterialIcon { + id: icon + + animate: true + text: { + const cls = Hypr.activeToplevel?.lastIpcObject.class; + if (!cls) return "desktop_windows"; + Icons.getAppCategoryIcon(cls, "ad") + } + + color: root.color + font.pointSize: Config.font.size.larger + anchors.verticalCenter: parent.verticalCenter + } + + Title { + id: text1 + } + + Title { + id: text2 + } + + TextMetrics { + id: metrics + + text: Hypr.activeToplevel?.title ?? qsTr("Desktop") + font.pointSize: Config.font.size.smaller + font.family: Config.font.family.mono + elide: Qt.ElideRight + elideWidth: root.width - icon.width + + onTextChanged: { + const next = child.current === text1 ? text2 : text1; + next.text = elidedText; + child.current = next; + } + onElideWidthChanged: child.current.text = elidedText + } + + Behavior on implicitWidth { + Anim { + easing.bezierCurve: Config.anim.curves.emphasized + } + } + + Behavior on implicitHeight { + Anim { + easing.bezierCurve: Config.anim.curves.emphasized + } + } + } + + component Title: CustomText { + id: text + + anchors.verticalCenter: icon.verticalCenter + anchors.left: icon.right + anchors.leftMargin: 8 + + font.pointSize: metrics.font.pointSize + font.family: metrics.font.family + color: root.color + opacity: child.current === this ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } +} diff --git a/modules/bar/modules/Workspaces.qml b/modules/bar/modules/Workspaces.qml new file mode 100644 index 0000000..dbf44f7 --- /dev/null +++ b/modules/bar/modules/Workspaces.qml @@ -0,0 +1,234 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.config +import qs.custom +import qs.util +import qs.modules.bar +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland + +Container { + id: root + + required property ListModel workspaces + + readonly property HyprlandMonitor monitor: Hypr.monitorFor(QsWindow.window.screen) + + // Workspace Layout + + implicitWidth: Math.max(list.width + Config.bar.workspaceMargin * 2, height) + + Behavior on implicitWidth { + Anim { + easing.bezierCurve: Config.anim.curves.emphasized + } + } + + readonly property real workspaceSize: Config.bar.containerHeight - Config.bar.workspaceMargin * 2 + + Item { + id: listWrapper + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Config.bar.workspaceMargin + + width: list.width + height: list.height + layer.enabled: true + layer.smooth: true + + ListView { + id: list + anchors.centerIn: parent + width: contentWidth + height: root.workspaceSize + acceptedButtons: Qt.NoButton + boundsBehavior: Flickable.StopAtBounds + + spacing: Config.bar.workspaceMargin + orientation: ListView.Horizontal + + model: root.workspaces + + delegate: Item { + id: buttonWrapper + + required property int index + required property HyprlandWorkspace workspace + + width: button.width + height: button.height + + readonly property Item button: button + + StateLayer { + id: button + + anchors.horizontalCenter: buttonWrapper.horizontalCenter + anchors.verticalCenter: buttonWrapper.verticalCenter + width: height + height: root.workspaceSize + + drag.target: this + drag.axis: Drag.XAxis + drag.threshold: 2 + drag.minimumX: list.x + drag.maximumX: list.x + list.width - root.workspaceSize + + states: State { + name: "dragging" + when: button.drag.active + + ParentChange { + target: button + parent: listWrapper + } + AnchorChanges { + target: button + anchors.horizontalCenter: undefined + } + } + + transitions: Transition { + from: "dragging" + to: "" + ParentAnimation { + AnchorAnimation { + duration: Config.anim.durations.small + easing.type: Easing.BezierSpline + easing.bezierCurve: Config.anim.curves.standardDecel + } + } + } + + function onClicked(): void { + if (root.monitor.activeWorkspace !== workspace) + Hypr.dispatch(`workspace ${workspace.id}`); + } + + onXChanged: { + if (!drag.active) return; + const wsx = x / (width + list.spacing); + root.workspaces.move(index, Math.round(wsx), 1); + } + + MaterialIcon { + anchors.centerIn: parent + text: Icons.getWorkspaceIcon(buttonWrapper.workspace) + color: Config.colors.primary + font.pointSize: Config.font.size.larger + animate: true + animateDuration: Config.anim.durations.small + } + } + } + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + } + } + + remove: Transition { + Anim { + property: "opacity" + from: 1 + to: 0 + } + } + + move: Transition { + Anim { + property: "x" + } + Anim { + properties: "opacity" + to: 1 + } + } + + + displaced: Transition { + Anim { + property: "x" + } + Anim { + properties: "opacity" + to: 1 + } + } + } + + } + + // Active Indicator + + CustomRect { + id: activeInd + + readonly property Item active: { + list.count; + const activeWorkspace = root.monitor.activeWorkspace; + for (let i = 0; i < (root.workspaces?.count ?? 0); i++) { + if (root.workspaces.get(i).workspace === activeWorkspace) + return list.itemAtIndex(i); + } + return null; + } + + x: active ? (active.button.drag.active ? active.button.x : active.x) + Config.bar.workspaceMargin : 0 + y: Config.bar.workspaceMargin + width: active?.width ?? workspaceSize + height: active?.height ?? workspaceSize + + radius: 1000 + color: Config.colors.workspaces + + clip: true + + property bool transition: false + onActiveChanged: transition = true + + Behavior on x { + enabled: activeInd.transition + SequentialAnimation { + Anim { + easing.bezierCurve: Config.anim.curves.emphasized + } + PropertyAction { + target: activeInd + property: "transition" + value: false + } + } + } + + CustomRect { + id: base + + visible: false + anchors.fill: parent + color: Config.colors.primaryDark + } + + MultiEffect { + source: base + maskSource: listWrapper + maskEnabled: true + maskSpreadAtMin: 1 + maskThresholdMin: 0.5 + + x: -parent.x + Config.bar.workspaceMargin + implicitWidth: listWrapper.width + implicitHeight: listWrapper.height + + anchors.verticalCenter: parent.verticalCenter + } + } +} diff --git a/modules/bar/popouts/ActiveWindow.qml b/modules/bar/popouts/ActiveWindow.qml new file mode 100644 index 0000000..bb1e625 --- /dev/null +++ b/modules/bar/popouts/ActiveWindow.qml @@ -0,0 +1,425 @@ +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property PersistentProperties uiState + required property Item wrapper + required property HyprlandToplevel window + + property HyprlandToplevel toplevel: Hypr.activeToplevel + property var screen: QsWindow.window.screen + property bool pinned: false + + implicitWidth: childrenRect.width + implicitHeight: childrenRect.height + + Component.onCompleted: { + if (window) { + state = "detail"; + pinned = true; + toplevel = window; + } + wrapper.window = null; + } + + // States + + states: [ + State { + name: "detail" + StateChangeScript { + script: root.wrapper.persistent = true + } + ParentChange { + target: header + parent: infobox + } + PropertyChanges { + infobox { visible: true } + preview { visible: true } + pin { visible: true } + visit { visible: true } + del { visible: true } + expand { rotation: -90 } + title { maximumLineCount: 1 } + } + } + ] + + transitions: Transition { + Anim { + targets: [infobox, preview] + property: "scale" + from: 0; to: 1 + } + Anim { + target: buttons + property: "opacity" + from: 0; to: 1 + duration: Config.anim.durations.large * 2 + } + Anim { + targets: header + property: "scale" + to: 1 + } + } + + // Reveal on window title change + // (or close if window is invalid) + Anim on opacity { + id: reveal + from: 0 + to: 1 + } + + onToplevelChanged: { + root.opacity = 0; + if (!toplevel) { + root.wrapper.hasCurrent = false; + } else if (!root.pinned) { + reveal.restart(); + } else { + root.opacity = 1; + } + } + + RowLayout { + id: layout + + spacing: 15 + + RowLayout { + id: header + + spacing: 12 + + Binding { + when: infobox.visible + header { + anchors.left: infobox.left + anchors.right: infobox.right + anchors.top: infobox.top + anchors.margins: 12 + } + } + + IconImage { + id: icon + + Layout.alignment: Qt.AlignVCenter + implicitSize: 36 + source: Icons.getAppIcon(toplevel?.lastIpcObject.class ?? "", "") + } + + ColumnLayout { + id: names + + spacing: 0 + Layout.fillWidth: true + Layout.maximumWidth: 400 + + CustomText { + id: title + + Layout.fillWidth: true + text: toplevel?.title ?? "" + color: Config.colors.secondary + elide: Text.ElideRight + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + Behavior on text { + Anim { + target: names + property: "opacity" + from: 0 + to: 1 + } + } + } + + CustomText { + Layout.fillWidth: true + text: toplevel?.lastIpcObject.class ?? "" + font.pointSize: Config.font.size.small + color: Config.colors.tertiary + elide: Text.ElideRight + + Behavior on text { + Anim { + target: names + property: "opacity" + from: 0 + to: 1 + } + } + } + } + } + + CustomRect { + id: infobox + visible: false + transformOrigin: Item.TopRight + + implicitWidth: 300 + implicitHeight: 240 + + color: Config.colors.container + radius: 17 + + ColumnLayout { + id: infolayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 48 + anchors.margins: 12 + + spacing: 3 + + + CustomRect { + color: Config.colors.inactive + + Layout.fillWidth: true + Layout.preferredHeight: 1 + Layout.topMargin: 8 + Layout.bottomMargin: 6 + Layout.leftMargin: 5 + Layout.rightMargin: 5 + } + + Detail { + icon: "workspaces" + text: { + for (let i = 0; i < root.uiState.workspaces.count; i++) { + if (root.uiState.workspaces.get(i).workspace === root.toplevel?.workspace) + return qsTr("Workspace: %1").arg(i + 1) + } + return qsTr("Workspace unknown") + } + } + + Detail { + icon: "desktop_windows" + text: { + const mon = root.toplevel?.monitor; + mon ? qsTr("Monitor: %1 (%2)").arg(mon.name).arg(mon.id) : qsTr("Monitor: unknown") + } + } + Detail { + icon: "location_on" + text: qsTr("Address: %1").arg(`0x${root.toplevel?.address}` ?? "unknown") + } + + Detail { + icon: "location_searching" + text: qsTr("Position: %1, %2").arg(root.toplevel?.lastIpcObject.at[0] ?? -1).arg(root.toplevel?.lastIpcObject.at[1] ?? -1) + } + + Detail { + icon: "resize" + text: qsTr("Size: %1 ⨯ %2").arg(root.toplevel?.lastIpcObject.size[0] ?? -1).arg(root.toplevel?.lastIpcObject.size[1] ?? -1) + color: Config.colors.tertiary + } + + Detail { + icon: "account_tree" + text: qsTr("Process id: %1").arg(Number(root.toplevel?.lastIpcObject.pid ?? -1).toLocaleString(undefined, "f")) + color: Config.colors.primary + } + + Detail { + icon: "gradient" + text: qsTr("Xwayland: %1").arg(root.toplevel?.lastIpcObject.xwayland ? "yes" : "no") + } + + Detail { + icon: "picture_in_picture_center" + text: qsTr("Floating: %1").arg(root.toplevel?.lastIpcObject.floating ? "yes" : "no") + color: Config.colors.secondary + } + } + } + + ClippingWrapperRectangle { + id: preview + visible: false + + Layout.alignment: Qt.AlignVCenter + + transformOrigin: Item.TopLeft + color: "transparent" + radius: 12 + + ScreencopyView { + captureSource: root.toplevel?.wayland ?? null + live: true + + constraintSize.width: 375 + constraintSize.height: 240 + } + } + + Item { + id: buttons + + Layout.fillHeight: true + width: buttonTopLayout.width + + property real buttonSize: 37 + + ColumnLayout { + id: buttonTopLayout + spacing: 2 + anchors.top: parent.top + + StateLayer { + id: expand + + implicitWidth: buttons.buttonSize + implicitHeight: buttons.buttonSize + + function onClicked(): void { + if (root.state === "") + root.state = "detail"; + else + root.wrapper.hasCurrent = false; + } + + MaterialIcon { + anchors.centerIn: parent + anchors.horizontalCenterOffset: font.pointSize * 0.05 + + text: "chevron_right" + font.pointSize: Config.font.size.large + } + } + + StateLayer { + id: pin + visible: false + + implicitWidth: buttons.buttonSize + implicitHeight: buttons.buttonSize + + function onClicked(): void { + if (root.pinned) { + root.pinned = false; + root.toplevel = Qt.binding(() => Hypr.activeToplevel); + } else { + root.pinned = true; + root.toplevel = root.toplevel; + } + } + + MaterialIcon { + anchors.centerIn: parent + + text: "keep" + fill: root.pinned + font.pointSize: Config.font.size.large - 3 + } + } + } + + ColumnLayout { + id: buttonBottomLayout + anchors.bottom: parent.bottom + spacing: 2 + + StateLayer { + id: visit + visible: false + + implicitWidth: buttons.buttonSize + implicitHeight: buttons.buttonSize + + disabled: Hypr.activeToplevel === root.toplevel + function onClicked(): void { + if (root.toplevel) + Hypr.dispatch(`focuswindow address:0x${root.toplevel.address}`); + } + + MaterialIcon { + anchors.centerIn: parent + + text: "flip_to_front" + color: parent.disabled ? Config.colors.inactive : Config.colors.primary + font.pointSize: Config.font.size.large - 3 + + Behavior on color { + CAnim {} + } + } + } + + StateLayer { + id: del + visible: false + + Layout.bottomMargin: 8 + color: Config.colors.error + + implicitWidth: buttons.buttonSize + implicitHeight: buttons.buttonSize + + function onClicked(): void { + if (root.toplevel) + Hypr.dispatch(`killwindow address:0x${root.toplevel.address}`); + root.wrapper.hasCurrent = false; + } + + MaterialIcon { + anchors.centerIn: parent + + text: "delete" + color: Config.colors.error + font.pointSize: Config.font.size.large - 3 + } + } + } + } + } + + component Detail: RowLayout { + id: detail + + required property string icon + required property string text + property alias color: icon.color + + Layout.fillWidth: true + + spacing: 7 + + MaterialIcon { + id: icon + + Layout.alignment: Qt.AlignVCenter + font.pointSize: Config.font.size.smaller + text: detail.icon + } + + CustomText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + text: detail.text + elide: Text.ElideRight + font.family: Config.font.family.mono + font.pointSize: Config.font.size.smaller + } + } +} diff --git a/modules/bar/popouts/Background.qml b/modules/bar/popouts/Background.qml new file mode 100644 index 0000000..6f415cb --- /dev/null +++ b/modules/bar/popouts/Background.qml @@ -0,0 +1,80 @@ +import qs.config +import qs.custom +import qs.services +import QtQuick +import QtQuick.Shapes + +Shape { + id: root + + required property Item wrapper + readonly property bool invertLeftRounding: wrapper.x <= 2 + readonly property bool invertRightRounding: wrapper.x + wrapper.width >= wrapper.parent.width - 2 + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + property real ilr: invertLeftRounding ? -1 : 1 + property real irr: invertRightRounding ? -1 : 1 + + property real sideRounding: wrapper.y > 0 ? -1 : 1 + + ShapePath { + startX: -root.rounding * root.sideRounding + (invertRightRounding ? 1 : 0) + startY: -1 + strokeWidth: -1 + fillColor: Config.colors.bg + + PathArc { + relativeX: root.rounding * root.sideRounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: root.sideRounding < 0 ? PathArc.Counterclockwise : PathArc.Clockwise + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.roundingY - root.roundingY * root.ilr + } + PathArc { + relativeX: root.rounding + relativeY: root.roundingY * root.ilr + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: root.ilr < 0 ? PathArc.Clockwise : PathArc.Counterclockwise + } + PathLine { + relativeX: root.wrapper.width - root.rounding * 2 + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY * root.irr + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: root.irr < 0 ? PathArc.Clockwise : PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: -(root.wrapper.height - root.roundingY - root.roundingY * root.irr) + } + PathArc { + relativeX: root.rounding * root.sideRounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: root.sideRounding < 0 ? PathArc.Counterclockwise : PathArc.Clockwise + } + } + + Behavior on ilr { + Anim {} + } + + Behavior on irr { + Anim {} + } + + Behavior on sideRounding { + Anim {} + } +} diff --git a/modules/bar/popouts/Battery.qml b/modules/bar/popouts/Battery.qml new file mode 100644 index 0000000..c1459ef --- /dev/null +++ b/modules/bar/popouts/Battery.qml @@ -0,0 +1,351 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import Quickshell.Services.UPower +import QtQuick +import QtQuick.Shapes +import QtQuick.Layouts + +ColumnLayout { + id: root + + spacing: 4 + + readonly property color color: UPower.onBattery && UPower.displayDevice.percentage < 0.15 ? + Config.colors.batteryWarning : + Config.colors.battery + + Loader { + Layout.alignment: Qt.AlignHCenter + + active: UPower.displayDevice.isLaptopBattery + asynchronous: true + + height: active ? (item?.implicitHeight ?? 0) : 0 + + sourceComponent: Item { + anchors.horizontalCenter: parent.horizontalCenter + + implicitWidth: meter.width + implicitHeight: meter.height + estimate.height + 8 + + Shape { + id: meter + + preferredRendererType: Shape.CurveRenderer + visible: false + + readonly property real size: 96 + readonly property real padding: 8 + readonly property real thickness: 8 + readonly property real angle: 280 + + ShapePath { + id: path + + fillColor: "transparent" + strokeColor: Qt.alpha(root.color, 0.1) + strokeWidth: meter.thickness + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: detail.x + detail.width / 2 + centerY: detail.y + detail.height / 2 + radiusX: (meter.size + meter.thickness) / 2 + meter.padding + radiusY: radiusX + startAngle: -90 - meter.angle / 2 + sweepAngle: meter.angle + } + + Behavior on strokeColor { + CAnim {} + } + } + + ShapePath { + fillColor: "transparent" + strokeColor: root.color + strokeWidth: meter.thickness + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: detail.x + detail.width / 2 + centerY: detail.y + detail.height / 2 + radiusX: (meter.size + meter.thickness) / 2 + meter.padding + radiusY: radiusX + startAngle: -90 - meter.angle / 2 + sweepAngle: meter.angle * UPower.displayDevice.percentage + } + + Behavior on strokeColor { + CAnim {} + } + } + } + + Column { + id: detail + + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: (meter.size + meter.thickness - height) / 2 + meter.padding + spacing: -6 + + // HACK: Prevent load order issues + Component.onCompleted: meter.visible = true; + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + text: Math.round(UPower.displayDevice.percentage * 100) + "%" + font.pointSize: Config.font.size.largest + } + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottomMargin: 10 + text: UPowerDeviceState.toString(UPower.displayDevice.state) + animate: true + font.pointSize: Config.font.size.smaller + + height: implicitHeight * 1.4 + } + } + + Column { + id: estimate + + anchors.top: meter.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: 3 + spacing: -3 + + CustomText { + id: estimateTime + + anchors.horizontalCenter: parent.horizontalCenter + + text: UPower.onBattery ? Time.formatSeconds(UPower.displayDevice.timeToEmpty) || "--" + : Time.formatSeconds(UPower.displayDevice.timeToFull) || "--" + animate: (from, to) => from === "--" || to === "--" + font.family: Config.font.family.mono + font.pointSize: Config.font.size.normal + } + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + + text: UPower.onBattery ? "remaining" : "to full" + animate: true + font.family: Config.font.family.mono + font.pointSize: Config.font.size.small + } + } + } + } + + Loader { + Layout.alignment: Qt.AlignHCenter + + active: PowerProfiles.degradationReason !== PerformanceDegradationReason.None + asynchronous: true + + height: active ? (item?.implicitHeight ?? 0) : 0 + + sourceComponent: CustomRect { + implicitWidth: child.implicitWidth + 20 + implicitHeight: child.implicitHeight + 20 + + color: Config.colors.errorBg + border.color: Config.colors.error + radius: 12 + + Column { + id: child + + anchors.centerIn: parent + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 7 + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -font.pointSize / 10 + + text: "warning" + color: Config.colors.error + } + + CustomText { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Performance Degraded") + color: Config.colors.error + font.family: Config.font.family.mono + font.weight: 500 + } + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -font.pointSize / 10 + + text: "warning" + color: Config.colors.error + } + } + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + + text: qsTr("Reason: %1").arg(PerformanceDegradationReason.toString(PowerProfiles.degradationReason)) + color: Config.colors.secondary + } + } + } + } + + CustomRect { + id: profiles + + Layout.topMargin: 4 + + property string current: { + const p = PowerProfiles.profile; + if (p === PowerProfile.PowerSaver) + return saver.icon; + if (p === PowerProfile.Performance) + return perf.icon; + return balance.icon; + } + + Layout.alignment: Qt.AlignHCenter + Layout.leftMargin: 10 + Layout.rightMargin: 10 + + implicitWidth: saver.implicitHeight + balance.implicitHeight + perf.implicitHeight + 60 + implicitHeight: Math.max(saver.implicitHeight, balance.implicitHeight, perf.implicitHeight) + 8 + + color: Config.colors.container + radius: 1000 + + CustomRect { + id: indicator + + color: root.color + radius: 1000 + state: profiles.current + + states: [ + State { + name: saver.icon + + Fill { + item: saver + } + }, + State { + name: balance.icon + + Fill { + item: balance + } + }, + State { + name: perf.icon + + Fill { + item: perf + } + } + ] + + transitions: Transition { + AnchorAnimation { + duration: Config.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Config.anim.curves.emphasized + } + } + } + + Profile { + id: saver + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 4 + + profile: PowerProfile.PowerSaver + icon: "energy_savings_leaf" + } + + Profile { + id: balance + + anchors.centerIn: parent + + profile: PowerProfile.Balanced + icon: "balance" + } + + Profile { + id: perf + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: 4 + + profile: PowerProfile.Performance + icon: "rocket_launch" + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: -2 + text: "Performance: " + PowerProfile.toString(PowerProfiles.profile) + animate: true + color: Config.colors.secondary + font.pointSize: Config.font.size.small + font.weight: 500 + } + + + component Fill: AnchorChanges { + required property Item item + + target: indicator + anchors.left: item.left + anchors.right: item.right + anchors.top: item.top + anchors.bottom: item.bottom + } + + component Profile: StateLayer { + required property string icon + required property int profile + + implicitWidth: icon.implicitHeight + 5 + implicitHeight: icon.implicitHeight + 5 + + function onClicked(): void { + PowerProfiles.profile = profile; + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + + text: parent.icon + font.pointSize: Config.font.size.larger + color: profiles.current === text ? Config.colors.primaryDark : Config.colors.primary + fill: profiles.current === text ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + } +} diff --git a/modules/bar/popouts/Calendar.qml b/modules/bar/popouts/Calendar.qml new file mode 100644 index 0000000..fc4dbec --- /dev/null +++ b/modules/bar/popouts/Calendar.qml @@ -0,0 +1,289 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + + spacing: 4 + + property date currentDate: new Date() + property int currentYear: currentDate.getFullYear() + property int currentMonth: currentDate.getMonth() + + RowLayout { + Layout.alignment: Qt.AlignHCenter + + MaterialIcon { + Layout.bottomMargin: 1 + text: "calendar_month" + color: Config.colors.primary + font.pointSize: Config.font.size.large + } + + CustomText { + text: Time.format("hh:mm:ss") + color: Config.colors.primary + font.weight: 600 + font.pointSize: Config.font.size.large + font.family: Config.font.family.mono + } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 4 + spacing: 0 + + CustomText { + text: Time.format("dddd, MMMM d") + font.weight: 600 + font.pointSize: Config.font.size.normal + } + + CustomText { + text: Time.format("yyyy-MM-dd") + color: Config.colors.tertiary + font.pointSize: Config.font.size.smaller + } + } + + } + + // Calendar grid + GridLayout { + id: calendarGrid + + Layout.fillWidth: true + Layout.margins: 10 + rowSpacing: 7 + columnSpacing: 2 + + // Month navigation + RowLayout { + Layout.fillWidth: true + Layout.bottomMargin: 7 + Layout.columnSpan: 2 + + CustomRect { + implicitWidth: implicitHeight + implicitHeight: prevIcon.implicitHeight + 8 + + radius: 1000 + color: Config.colors.container + + StateLayer { + anchors.fill: parent + + function onClicked(): void { + if (root.currentMonth !== 0) { + root.currentMonth = root.currentMonth - 1; + } else { + root.currentMonth = 11; + root.currentYear = root.currentYear - 1; + } + } + } + + MaterialIcon { + id: prevIcon + + anchors.centerIn: parent + text: "chevron_left" + color: Config.colors.secondary + } + } + + CustomText { + Layout.fillWidth: true + + readonly property list monthNames: + Array.from({ length: 12 }, (_, i) => Qt.locale().monthName(i, Qt.locale().LongFormat)) + + text: monthNames[root.currentMonth] + " " + root.currentYear + horizontalAlignment: Text.AlignHCenter + font.weight: 600 + font.pointSize: Config.font.size.normal + } + + CustomRect { + implicitWidth: implicitHeight + implicitHeight: nextIcon.implicitHeight + 8 + radius: 1000 + color: Config.colors.container + + StateLayer { + anchors.fill: parent + + function onClicked(): void { + if (root.currentMonth !== 11) { + root.currentMonth = root.currentMonth + 1; + } else { + root.currentMonth = 0; + root.currentYear = root.currentYear + 1; + } + } + } + + MaterialIcon { + id: nextIcon + anchors.centerIn: parent + text: "chevron_right" + color: Config.colors.primary + } + } + } + + // Day headers + DayOfWeekRow { + Layout.row: 1 + Layout.column: 1 + Layout.fillWidth: true + Layout.preferredHeight: Config.font.size.largest + + delegate: CustomText { + required property var model + + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: model.shortName + color: Config.colors.tertiary + + font.pointSize: Config.font.size.small + font.weight: 500 + } + } + + CustomText { + Layout.row: 1 + Layout.leftMargin: -2 + text: "Week" + color: Config.colors.tertiary + font.pointSize: Config.font.size.small + font.weight: 500 + font.italic: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + // ISO week markers + WeekNumberColumn { + Layout.row: 2 + Layout.fillHeight: true + Layout.rightMargin: 15 + height: 240 + + month: root.currentMonth + year: root.currentYear + + delegate: CustomText { + required property var model + + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: model.weekNumber + color: Config.colors.tertiary + + font.pointSize: Config.font.size.small + font.weight: 600 + font.italic: true + } + } + + // Calendar days grid + MonthGrid { + Layout.row: 2 + Layout.column: 1 + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + Layout.margins: 6 + + month: root.currentMonth + year: root.currentYear + spacing: 20 + + delegate: Item { + id: dayItem + required property var model + + implicitWidth: implicitHeight + implicitHeight: dayText.implicitHeight + 10 + + CustomRect { + anchors.centerIn: parent + implicitWidth: parent.implicitHeight + implicitHeight: parent.implicitHeight + radius: 1000 + color: dayItem.model.today ? Config.colors.calendar : "transparent" + + StateLayer { + anchors.fill: parent + visible: dayItem.model.month === root.currentMonth + function onClicked(): void {} + } + + CustomText { + id: dayText + anchors.centerIn: parent + anchors.verticalCenterOffset: 0.5 + horizontalAlignment: Text.AlignHCenter + text: Qt.formatDate(dayItem.model.date, "d") + color: dayItem.model.today ? Config.colors.primaryDark : + dayItem.model.month === root.currentMonth ? Config.colors.primary : Config.colors.inactive + + font.pointSize: Config.font.size.small + font.weight: dayItem.model.today ? 600 : 400 + } + } + } + } + } + + // Today button + CustomRect { + Layout.fillWidth: true + implicitHeight: todayBtn.implicitHeight + 10 + radius: 25 + color: Config.colors.calendar + + StateLayer { + anchors.fill: parent + color: Config.colors.secondary + + function onClicked(): void { + const today = new Date(); + root.currentYear = today.getFullYear(); + root.currentMonth = today.getMonth(); + } + } + + Row { + id: todayBtn + anchors.centerIn: parent + spacing: 7 + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + + text: "today" + color: Config.colors.primaryDark + font.pointSize: Config.font.size.normal + } + + CustomText { + anchors.verticalCenter: parent.verticalCenter + + text: qsTr("Today") + color: Config.colors.primaryDark + } + } + } +} diff --git a/modules/bar/popouts/Content.qml b/modules/bar/popouts/Content.qml new file mode 100644 index 0000000..1ab7152 --- /dev/null +++ b/modules/bar/popouts/Content.qml @@ -0,0 +1,157 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.SystemTray +import Quickshell.Services.UPower +import QtQuick + +Item { + id: root + + required property PersistentProperties uiState + required property Item wrapper + required property HyprlandToplevel window + + anchors.centerIn: parent + + implicitWidth: (content.children.find(c => c.shouldBeActive)?.implicitWidth ?? 0) + 30 + implicitHeight: (content.children.find(c => c.shouldBeActive)?.implicitHeight ?? 0) + 20 + readonly property color color: content.children.find(c => c.active)?.color ?? "transparent" + clip: true + + Item { + id: content + + anchors.fill: parent + anchors.margins: 15 + + Popout { + name: "nixos" + source: "NixOS.qml" + color: Config.colors.nixos + } + + Popout { + name: "activewindow" + + sourceComponent: ActiveWindow { + uiState: root.uiState + wrapper: root.wrapper + window: root.window + } + color: Config.colors.activeWindow + } + + Popout { + name: "calendar" + source: "Calendar.qml" + color: Config.colors.calendar + } + + Popout { + name: "network" + source: "Network.qml" + color: Config.colors.network + } + + Popout { + name: "idleinhibit" + source: "IdleInhibit.qml" + color: Config.colors.idle + } + + Popout { + name: "battery" + source: "Battery.qml" + color: UPower.displayDevice.isLaptopBattery && + UPower.onBattery && UPower.displayDevice.percentage < 0.15 ? + Config.colors.batteryWarning : + Config.colors.battery + } + + Repeater { + model: SystemTray.items + + Popout { + id: trayMenu + + required property SystemTrayItem modelData + required property int index + + anchors.verticalCenterOffset: -5 + + name: `traymenu${index}` + sourceComponent: trayMenuComp + color: Qt.tint(Config.colors.brown, Qt.alpha(Config.colors.yellow, Math.min(index / 8, 1))) + + Connections { + target: root.wrapper + + function onHasCurrentChanged(): void { + if (root.wrapper.hasCurrent && trayMenu.shouldBeActive) { + trayMenu.sourceComponent = null; + trayMenu.sourceComponent = trayMenuComp; + } + } + } + + Component { + id: trayMenuComp + + TrayMenu { + popouts: root.wrapper + trayItem: trayMenu.modelData.menu + } + } + } + } + } + + component Popout: Loader { + id: popout + + required property string name + property bool shouldBeActive: root.wrapper.currentName === name + property color color + + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -3 + anchors.right: parent.right + + opacity: 0 + scale: 0.8 + active: false + asynchronous: true + + states: State { + name: "active" + when: popout.shouldBeActive + + PropertyChanges { + popout.active: true + popout.opacity: 1 + popout.scale: 1 + } + } + + transitions: [ + Transition { + from: "" + to: "active" + + SequentialAnimation { + PropertyAction { + target: popout + property: "active" + } + Anim { + properties: "opacity,scale" + } + } + } + ] + } +} diff --git a/modules/bar/popouts/IdleInhibit.qml b/modules/bar/popouts/IdleInhibit.qml new file mode 100644 index 0000000..68ab17c --- /dev/null +++ b/modules/bar/popouts/IdleInhibit.qml @@ -0,0 +1,48 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + spacing: 15 + + Toggle { + label.text: qsTr("Idle Inhibitor") + label.font.weight: 500 + checked: Idle.inhibit + toggle.onToggled: Idle.inhibit = !Idle.inhibit + } + + Toggle { + label.text: qsTr("Inhibit While Playing Audio") + checked: Idle.inhibitPipewire + toggle.onToggled: Idle.toggleInhibitPipewire() + } + + component Toggle: RowLayout { + property alias checked: toggle.checked + property alias label: label + property alias toggle: toggle + + Layout.fillWidth: true + Layout.rightMargin: 5 + spacing: 15 + + CustomText { + id: label + Layout.fillWidth: true + } + + CustomSwitch { + id: toggle + accent: Color.mute(Config.colors.idle, 1.1) + } + } +} diff --git a/modules/bar/popouts/Network.qml b/modules/bar/popouts/Network.qml new file mode 100644 index 0000000..350855e --- /dev/null +++ b/modules/bar/popouts/Network.qml @@ -0,0 +1,259 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property string connectingToSsid: "" + + spacing: 5 + width: 320 + + Toggle { + label.text: qsTr("Wifi %1".arg(Network.wifiEnabled ? "Enabled" : "Disabled")) + label.font.weight: 500 + checked: Network.wifiEnabled + toggle.onToggled: Network.enableWifi(checked) + } + + CustomText { + Layout.topMargin: 7 + text: qsTr("%1 networks available").arg(Network.networks.length) + color: Config.colors.primary + font.pointSize: Config.font.size.small + } + + CustomListView { + id: list + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight, 240) + spacing: 5 + clip: true + + CustomScrollBar.vertical: CustomScrollBar { + flickable: list + } + + model: ScriptModel { + values: [...Network.networks].sort((a, b) => { + if (a.active !== b.active) + return b.active - a.active; + return b.strength - a.strength; + }) + } + + delegate: RowLayout { + id: networkItem + + required property Network.AccessPoint modelData + readonly property bool isConnecting: root.connectingToSsid === modelData.ssid + readonly property bool loading: networkItem.isConnecting + + readonly property color iconColor: networkItem.modelData.active ? Config.colors.primary : Config.colors.inactive + + width: list.width - 8 + spacing: 5 + + MaterialIcon { + text: Icons.getNetworkIcon(networkItem.modelData.strength) + color: iconColor + + MouseArea { + width: networkItem.width + height: networkItem.height + + onClicked: { + if (Network.wifiEnabled && !networkItem.loading) { + if (networkItem.modelData.active) { + Network.disconnectFromNetwork(); + } else { + root.connectingToSsid = networkItem.modelData.ssid; + Network.connectToNetwork(networkItem.modelData.ssid, ""); + } + } + } + } + } + + MaterialIcon { + opacity: networkItem.modelData.isSecure ? 1 : 0 + text: "lock" + font.pointSize: Config.font.size.smaller + color: iconColor + } + + CustomText { + Layout.fillWidth: true + Layout.rightMargin: 10 + text: networkItem.modelData.ssid + elide: Text.ElideRight + font.weight: networkItem.modelData.active ? 500 : 400 + color: networkItem.modelData.active ? Config.colors.secondary : Config.colors.tertiary + } + + CustomBusyIndicator { + implicitWidth: implicitHeight + implicitHeight: Config.font.size.normal + + running: opacity > 0 + opacity: networkItem.loading ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + } + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + } + Anim { + property: "scale" + from: 0.7 + to: 1 + } + } + + remove: Transition { + Anim { + property: "opacity" + from: 1 + to: 0 + } + Anim { + property: "scale" + from: 1 + to: 0.7 + } + } + + addDisplaced: Transition { + Anim { + property: "y" + duration: Config.anim.durations.small + } + Anim { + properties: "opacity,scale" + to: 1 + } + } + + displaced: Transition { + Anim { + property: "y" + } + Anim { + properties: "opacity,scale" + to: 1 + } + } + } + + // Rescan button + CustomRect { + Layout.topMargin: 8 + Layout.fillWidth: true + implicitHeight: rescanBtn.implicitHeight + 10 + + radius: 17 + color: !Network.wifiEnabled ? Config.colors.inactive : Qt.alpha(Config.colors.network, Network.scanning ? 0.8 : 1) + + Behavior on color { + CAnim { duration: Config.anim.durations.small } + } + + StateLayer { + id: layer + anchors.fill: parent + + color: Config.colors.primaryDark + disabled: Network.scanning || !Network.wifiEnabled + + function onClicked(): void { + Network.rescanWifi(); + } + } + + Row { + id: rescanBtn + anchors.centerIn: parent + spacing: 7 + + property color color: layer.disabled ? Config.colors.bg : Config.colors.primaryDark + + Behavior on color { + CAnim { duration: Config.anim.durations.small } + } + + MaterialIcon { + id: scanIcon + anchors.verticalCenter: parent.verticalCenter + + animate: true + text: Network.scanning ? "refresh" : "wifi_find" + color: parent.color + + RotationAnimation on rotation { + running: Network.scanning + loops: Animation.Infinite + from: 0 + to: 360 + duration: 1000 + } + } + + CustomText { + anchors.verticalCenter: parent.verticalCenter + + text: Network.scanning ? qsTr("Scanning...") : qsTr("Rescan networks") + color: parent.color + } + } + } + + // Reset connecting state when network changes + Connections { + target: Network + + function onActiveChanged(): void { + if (Network.active && root.connectingToSsid === Network.active.ssid) { + root.connectingToSsid = ""; + } + } + + function onScanningChanged(): void { + if (!Network.scanning) + scanIcon.rotation = 0; + } + } + + component Toggle: RowLayout { + property alias checked: toggle.checked + property alias label: label + property alias toggle: toggle + + Layout.fillWidth: true + Layout.rightMargin: 5 + spacing: 15 + + CustomText { + id: label + Layout.fillWidth: true + } + + CustomSwitch { + id: toggle + accent: Color.mute(Config.colors.network) + } + } +} diff --git a/modules/bar/popouts/NixOS.qml b/modules/bar/popouts/NixOS.qml new file mode 100644 index 0000000..2e6cff5 --- /dev/null +++ b/modules/bar/popouts/NixOS.qml @@ -0,0 +1,161 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + spacing: 7 + width: 340 + + function nixosVersionShort(version: string): string { + const parts = version.split('.'); + return `${parts[0]}.${parts[1]}`; + } + + Row { + spacing: 12 + + Image { + anchors.verticalCenter: parent.verticalCenter + + readonly property real size: 72 + + source: "root:/assets/nixos-logo.svg" + width: size + height: size + sourceSize.width: size + sourceSize.height: size + } + + CustomText { + anchors.verticalCenter: parent.verticalCenter + text: "NixOS" + color: Config.colors.secondary + font.pointSize: Config.font.size.largest * 1.3 + } + + Column { + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -2 + + CustomText { + text: "v" + root.nixosVersionShort(NixOS.currentGen?.nixosVersion) + font.pointSize: Config.font.size.larger + } + + CustomText { + text: "Nix " + NixOS.nixVersion + } + } + } + + CustomRect { + Layout.topMargin: 5 + Layout.fillWidth: true + height: 180 + + radius: 17 + color: Config.colors.container + + CustomText { + id: genText + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 7 + + text: "Generations" + color: Config.colors.secondary + font.pointSize: Config.font.size.normal + font.weight: 500 + } + + CustomListView { + id: list + + anchors.top: genText.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 6 + anchors.topMargin: 8 + + spacing: 6 + clip: true + + model: ScriptModel { + values: [...NixOS.generations] + objectProp: "id" + } + + CustomScrollBar.vertical: CustomScrollBar { + flickable: list + } + + + delegate: CustomRect { + required property NixOS.Generation modelData + + width: list.width + height: 42 + + radius: 12 + color: modelData.current ? + Qt.tint(Config.colors.container, Qt.alpha(Config.colors.nixos, 0.2)) : + Config.colors.containerAlt + + Item { + anchors.fill: parent + anchors.margins: 7 + anchors.topMargin: 2 + anchors.bottomMargin: 1 + + CustomText { + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: 2 + + text: `Generation ${modelData.id}` + color: modelData.current ? Config.colors.nixos : Config.colors.secondary + } + + CustomText { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.rightMargin: 6 + + text: modelData.revision !== "Unknown" ? modelData.revision : "" + elide: Text.ElideRight + font.family: Config.font.family.mono + color: modelData.current ? Config.colors.secondary : Config.colors.primary + } + + CustomText { + anchors.top: parent.top + anchors.right: parent.right + + text: `NixOS ${root.nixosVersionShort(modelData.nixosVersion)} 🞄 Linux ${modelData.kernelVersion}` + color: modelData.current ? Config.colors.secondary : Config.colors.primary + } + + CustomText { + anchors.bottom: parent.bottom + anchors.right: parent.right + + text: Qt.formatDateTime(modelData.date, "yyyy-MM-dd ddd hh:mm") + font.family: Config.font.family.mono + color: modelData.current ? Config.colors.secondary : Config.colors.primary + } + } + } + } + } +} diff --git a/modules/bar/popouts/TrayMenu.qml b/modules/bar/popouts/TrayMenu.qml new file mode 100644 index 0000000..75b054d --- /dev/null +++ b/modules/bar/popouts/TrayMenu.qml @@ -0,0 +1,231 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland +import QtQuick +import QtQuick.Controls + +StackView { + id: root + + required property Item popouts + required property QsMenuHandle trayItem + + implicitWidth: currentItem.implicitWidth + implicitHeight: currentItem.implicitHeight + + initialItem: SubMenu { + handle: root.trayItem + } + + pushEnter: NoAnim {} + pushExit: NoAnim {} + popEnter: NoAnim {} + popExit: NoAnim {} + + component NoAnim: Transition { + NumberAnimation { + duration: 0 + } + } + + component SubMenu: Column { + id: menu + + required property QsMenuHandle handle + property bool isSubMenu + property bool shown + + spacing: 7 + + opacity: shown ? 1 : 0 + scale: shown ? 1 : 0.8 + + Component.onCompleted: shown = true + StackView.onActivating: shown = true + StackView.onDeactivating: shown = false + StackView.onRemoved: destroy() + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + QsMenuOpener { + id: menuOpener + + menu: menu.handle + } + + Repeater { + model: menuOpener.children + + CustomRect { + id: item + + required property QsMenuEntry modelData + + implicitWidth: 200 + implicitHeight: modelData.isSeparator ? 1 : children.implicitHeight + + radius: 100 + color: modelData.isSeparator ? Config.colors.inactive : "transparent" + + Loader { + id: children + + anchors.left: parent.left + anchors.right: parent.right + + active: !item.modelData.isSeparator + asynchronous: true + + sourceComponent: Item { + implicitHeight: label.implicitHeight + + StateLayer { + anchors.fill: parent + anchors.margins: -2 + anchors.leftMargin: -7 + anchors.rightMargin: -7 + + radius: item.radius + disabled: !item.modelData.enabled + + function onClicked(): void { + const entry = item.modelData; + if (entry.hasChildren) + root.push(subMenuComp.createObject(null, { + handle: entry, + isSubMenu: true + })); + else { + item.modelData.triggered(); + root.popouts.hasCurrent = false; + } + } + } + + Loader { + id: icon + + anchors.left: parent.left + + active: item.modelData.icon !== "" + asynchronous: true + + sourceComponent: IconImage { + implicitSize: label.implicitHeight + + source: item.modelData.icon + } + } + + CustomText { + id: label + + anchors.left: icon.right + anchors.leftMargin: icon.active ? 10 : 0 + + text: labelMetrics.elidedText + color: item.modelData.enabled ? Config.colors.primary : Config.colors.tertiary + } + + TextMetrics { + id: labelMetrics + + text: item.modelData.text + font.pointSize: label.font.pointSize + font.family: label.font.family + + elide: Text.ElideRight + elideWidth: 200 - (icon.active ? icon.implicitWidth + label.anchors.leftMargin : 0) - (expand.active ? expand.implicitWidth + 12 : 0) + } + + Loader { + id: expand + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + active: item.modelData.hasChildren + asynchronous: true + + sourceComponent: MaterialIcon { + text: "chevron_right" + color: item.modelData.enabled ? Config.colors.primary : Config.colors.tertiary + } + } + } + } + } + } + + Loader { + active: menu.isSubMenu + asynchronous: true + + sourceComponent: Item { + implicitWidth: back.implicitWidth + implicitHeight: back.implicitHeight + Appearance.spacing.small / 2 + + Item { + anchors.bottom: parent.bottom + implicitWidth: back.implicitWidth + implicitHeight: back.implicitHeight + + CustomRect { + anchors.fill: parent + anchors.margins: -7 + anchors.leftMargin: -7 + anchors.rightMargin: -14 + + radius: 1000 + color: Config.colors.container + + StateLayer { + anchors.fill: parent + radius: parent.radius + color: Config.colors.primary + + function onClicked(): void { + root.pop(); + } + } + } + + Row { + id: back + + anchors.verticalCenter: parent.verticalCenter + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + text: "chevron_left" + color: Config.colors.primary + } + + CustomText { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Back") + color: Config.colors.primary + } + } + } + } + } + } + + Component { + id: subMenuComp + + SubMenu {} + } +} diff --git a/modules/bar/popouts/Wrapper.qml b/modules/bar/popouts/Wrapper.qml new file mode 100644 index 0000000..305f17f --- /dev/null +++ b/modules/bar/popouts/Wrapper.qml @@ -0,0 +1,156 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick +import QtQuick.Effects + +Item { + id: root + + required property PersistentProperties uiState + required property ShellScreen screen + + readonly property real nonAnimWidth: content.implicitWidth + readonly property real nonAnimHeight: y > 0 || hasCurrent ? content.implicitHeight : 0 + + property string currentName + property real currentCenter + property bool hasCurrent + + property real currentCenterBounded: Math.min(Math.max(currentCenter, nonAnimWidth / 2), + parent.width - nonAnimWidth / 2) + x: currentCenterBounded - implicitWidth / 2 + + property HyprlandToplevel window + + property bool persistent + + visible: width > 0 && height > 0 + + implicitWidth: nonAnimWidth + implicitHeight: nonAnimHeight + + Background { + id: background + visible: false + wrapper: root + } + + MultiEffect { + anchors.fill: background + source: background + shadowEnabled: true + blurMultiplier: 0.3 + blurMax: 30 + shadowColor: content.active ? content.item.color : "transparent" + + Behavior on shadowColor { + CAnim {} + } + } + + Item { + anchors.fill: parent + clip: true + + Comp { + id: content + + shouldBeActive: root.hasCurrent + asynchronous: true + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + + sourceComponent: Content { + id: content + + wrapper: root + uiState: root.uiState + window: root.window + } + } + } + + Behavior on currentCenterBounded { + enabled: root.implicitHeight > 0 + + Anim { + easing.bezierCurve: Config.anim.curves.emphasized + } + } + + Behavior on implicitWidth { + enabled: root.implicitHeight > 0 + + Anim { + easing.bezierCurve: Config.anim.curves.emphasized + } + } + + Behavior on implicitHeight { + Anim { + easing.bezierCurve: Config.anim.curves.emphasized + } + } + + component Comp: Loader { + id: comp + + property bool shouldBeActive + + asynchronous: true + active: false + opacity: 0 + + states: State { + name: "active" + when: comp.shouldBeActive + + PropertyChanges { + comp.opacity: 1 + comp.active: true + } + } + + transitions: [ + Transition { + from: "" + to: "active" + + SequentialAnimation { + PropertyAction { + property: "active" + } + Anim { + property: "opacity" + easing.bezierCurve: Config.anim.curves.standard + } + } + }, + Transition { + from: "active" + to: "" + + SequentialAnimation { + Anim { + property: "opacity" + easing.bezierCurve: Config.anim.curves.standard + } + PropertyAction { + property: "active" + } + PropertyAction { + target: root + property: "persistent" + value: false + } + } + } + ] + } +} diff --git a/modules/dashboard/Background.qml b/modules/dashboard/Background.qml new file mode 100644 index 0000000..4c8701d --- /dev/null +++ b/modules/dashboard/Background.qml @@ -0,0 +1,59 @@ +import qs.config +import qs.services +import QtQuick +import QtQuick.Shapes + +Shape { + id: root + + required property Item wrapper + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.width < rounding * 2 + readonly property real roundingX: flatten ? wrapper.width / 2 : rounding + + ShapePath { + startX: -0.5 + startY: -root.rounding + strokeWidth: -1 + fillColor: Config.colors.bg + + PathArc { + relativeX: root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: root.wrapper.width - root.roundingX * 2 + relativeY: 0 + } + PathArc { + relativeX: root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.rounding * 2 + } + PathArc { + relativeX: -root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + } + PathLine { + relativeX: -(root.wrapper.width - root.roundingX * 2) + relativeY: 0 + } + PathArc { + relativeX: -root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + } +} diff --git a/modules/dashboard/Content.qml b/modules/dashboard/Content.qml new file mode 100644 index 0000000..7c367e9 --- /dev/null +++ b/modules/dashboard/Content.qml @@ -0,0 +1,124 @@ +pragma ComponentBehavior: Bound + +import qs.modules.bar.popouts as BarPopouts +import qs.config +import qs.custom +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property PersistentProperties uiState + required property BarPopouts.Wrapper popouts + + readonly property color color: tabs.color + readonly property real nonAnimHeight: view.implicitHeight + viewWrapper.anchors.margins * 2 + implicitWidth: tabs.implicitWidth + tabs.anchors.leftMargin + view.implicitWidth + viewWrapper.anchors.margins * 2 + implicitHeight: nonAnimHeight + + Tabs { + id: tabs + + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.leftMargin: 10 + anchors.margins: 15 + nonAnimHeight: root.nonAnimHeight - anchors.margins * 2 + uiState: root.uiState + } + + CustomClippingRect { + id: viewWrapper + + anchors.left: tabs.right + anchors.top: parent.top + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 15 + + radius: 17 + color: "transparent" + + Item { + id: view + + readonly property int currentIndex: root.uiState.dashboardTab + readonly property Item currentItem: column.children[currentIndex] + + anchors.fill: parent + + implicitWidth: currentItem.implicitWidth + implicitHeight: currentItem.implicitHeight + + ColumnLayout { + id: column + + y: -view.currentItem.y + spacing: 8 + + Pane { + sourceComponent: Dash { + uiState: root.uiState + } + } + + Pane { + sourceComponent: Mixer { + uiState: root.uiState + index: 1 + } + } + + Pane { + sourceComponent: Media { + uiState: root.uiState + } + } + + Pane { + source: "Performance.qml" + } + + Pane { + sourceComponent: Workspaces { + uiState: root.uiState + popouts: root.popouts + } + } + + Behavior on y { + Anim {} + } + } + } + } + + Behavior on implicitWidth { + Anim { + duration: Config.anim.durations.large + easing.bezierCurve: Config.anim.curves.emphasized + } + } + + Behavior on implicitHeight { + Anim { + duration: Config.anim.durations.large + easing.bezierCurve: Config.anim.curves.emphasized + } + } + + component Pane: Loader { + Layout.alignment: Qt.AlignLeft + + property real bufferY: 5 + Component.onCompleted: active = Qt.binding(() => { + const vy = Math.floor(-column.y); + const vey = Math.floor(vy + view.height); + return (vy >= y - bufferY && vy <= y + implicitHeight) || (vey >= y + bufferY && vey <= y + implicitHeight); + }) + } +} diff --git a/modules/dashboard/Dash.qml b/modules/dashboard/Dash.qml new file mode 100644 index 0000000..7b15a42 --- /dev/null +++ b/modules/dashboard/Dash.qml @@ -0,0 +1,65 @@ +import qs.config +import qs.custom +import qs.services +import Quickshell +import QtQuick.Layouts +import "dash" + +GridLayout { + id: root + + required property PersistentProperties uiState + + rowSpacing: 10 + columnSpacing: 10 + + Rect { + Layout.fillWidth: true + Layout.preferredWidth: Config.dashboard.timeWidth + Layout.preferredHeight: Config.dashboard.timeHeight + + DateTime {} + } + + Rect { + Layout.row: 1 + Layout.fillWidth: true + Layout.preferredHeight: 70 + + User {} + } + + Rect { + Layout.row: 2 + Layout.fillWidth: true + Layout.preferredHeight: 110 + + Weather {} + } + + Rect { + Layout.row: 0 + Layout.column: 1 + Layout.rowSpan: 3 + Layout.preferredWidth: 200 + Layout.preferredHeight: 300 + Layout.fillWidth: true + + Media {} + } + + Rect { + Layout.row: 3 + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: 300 + + Notifs {} + } + + component Rect: CustomRect { + radius: 12 + color: Config.colors.containerDash + } +} diff --git a/modules/dashboard/Media.qml b/modules/dashboard/Media.qml new file mode 100644 index 0000000..4a06d03 --- /dev/null +++ b/modules/dashboard/Media.qml @@ -0,0 +1,532 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Mpris +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes + +Item { + id: root + + required property PersistentProperties uiState + + property real playerProgress: { + const active = Players.active; + return active?.length ? active.position / active.length : 0; + } + + function lengthStr(length: int): string { + if (length < 0) + return "-1:-1"; + + const hours = Math.floor(length / 3600); + const mins = Math.floor((length % 3600) / 60); + const secs = Math.floor(length % 60).toString().padStart(2, "0"); + + if (hours > 0) + return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + return `${mins}:${secs}`; + } + + implicitWidth: slider.width + 32 + implicitHeight: childrenRect.height + + Timer { + running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } + + Item { + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: Config.dashboard.mediaCoverArtHeight + + CustomClippingRect { + id: cover + + anchors.horizontalCenter: parent.horizontalCenter + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.verticalCenter: parent.top + anchors.verticalCenterOffset: Config.dashboard.mediaCoverArtHeight / 2 + + implicitWidth: image.paintedWidth > 0 ? image.paintedWidth : Config.dashboard.mediaCoverArtHeight + implicitHeight: image.paintedHeight > 0 ? image.paintedHeight : Config.dashboard.mediaCoverArtHeight + + color: Config.colors.containerDash + radius: 12 + + MaterialIcon { + anchors.centerIn: parent + + grade: 200 + text: "art_track" + color: Config.colors.tertiary + font.pointSize: (parent.width * 0.4) || 1 + } + + Image { + id: image + + anchors.centerIn: parent + width: Config.dashboard.mediaCoverArtWidth + height: Config.dashboard.mediaCoverArtHeight + + source: Players.active?.trackArtUrl ?? "" + asynchronous: true + fillMode: Image.PreserveAspectFit + sourceSize.width: width + sourceSize.height: height + } + } + } + + ColumnLayout { + id: details + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 12 + Config.dashboard.mediaCoverArtHeight + + spacing: 8 + + CustomText { + id: title + + Layout.fillWidth: true + Layout.maximumWidth: parent.implicitWidth + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") + color: Config.colors.secondary + font.pointSize: Config.font.size.normal + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + + CustomText { + id: artist + + Layout.fillWidth: true + + visible: Players.active + animate: true + horizontalAlignment: Text.AlignHCenter + text: Players.active?.trackArtist || qsTr("Unknown artist") + opacity: Players.active ? 1 : 0 + color: Config.colors.primary + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + + CustomText { + id: album + + Layout.fillWidth: true + Layout.topMargin: -5 + + visible: text !== "" + animate: true + horizontalAlignment: Text.AlignHCenter + text: Players.active?.trackAlbum ?? "" + color: Config.colors.tertiary + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + + RowLayout { + id: controls + + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 12 + Layout.bottomMargin: 10 + + spacing: 7 + + PlayerControl { + icon.text: "skip_previous" + canUse: Players.active?.canGoPrevious ?? false + + function onClicked(): void { + Players.active?.previous(); + } + } + + CustomRect { + id: playBtn + + implicitWidth: Math.max(playIcon.implicitWidth, playIcon.implicitHeight) + implicitHeight: implicitWidth + + radius: Players.active?.isPlaying ? 12 : implicitHeight / 2 + color: { + if (!Players.active?.canTogglePlaying) + return "transparent"; + return Players.active?.isPlaying ? Config.colors.media : Config.colors.container; + } + + StateLayer { + anchors.fill: parent + disabled: !Players.active?.canTogglePlaying + color: Players.active?.isPlaying ? Config.colors.primaryDark : Config.colors.primary + radius: parent.radius + + function onClicked(): void { + Players.active?.togglePlaying(); + } + } + + MaterialIcon { + id: playIcon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -font.pointSize * 0.02 + anchors.verticalCenterOffset: font.pointSize * 0.02 + + animate: true + fill: 1 + text: Players.active?.isPlaying ? "pause" : "play_arrow" + color: { + if (!Players.active?.canTogglePlaying) + return Config.colors.inactive; + return Players.active?.isPlaying ? Config.colors.primaryDark : Config.colors.media; + } + font.pointSize: Config.font.size.largest + } + + Behavior on radius { + Anim {} + } + } + + PlayerControl { + icon.text: "skip_next" + canUse: Players.active?.canGoNext ?? false + + function onClicked(): void { + Players.active?.next(); + } + } + } + + CustomSlider { + id: slider + + enabled: !!Players.active + progressColor: Config.colors.media + implicitWidth: controls.implicitWidth * 1.5 + implicitHeight: 20 + + onMoved: { + const active = Players.active; + if (active?.canSeek && active?.positionSupported) + active.position = value * active.length; + } + + Binding { + target: slider + property: "value" + value: root.playerProgress + when: !slider.pressed + } + + CustomMouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + + function onWheel(event: WheelEvent) { + const active = Players.active; + if (!active?.canSeek || !active?.positionSupported) + return; + + event.accepted = true; + const delta = event.angleDelta.y > 0 ? 10 : -10; // Time 10 seconds + Qt.callLater(() => { + active.position = Math.max(0, Math.min(active.length, active.position + delta)); + }); + } + } + } + + Item { + Layout.fillWidth: true + Layout.topMargin: -6 + implicitHeight: Math.max(position.implicitHeight, length.implicitHeight) + opacity: Players.active ? 1 : 0 + visible: opacity > 0 + + CustomText { + id: position + + anchors.left: parent.left + + text: root.lengthStr(Players.active?.position ?? 0) + color: Config.colors.primary + font.pointSize: Config.font.size.small + } + + CustomText { + id: length + + anchors.right: parent.right + + text: root.lengthStr(Players.active?.length ?? 0) + color: Config.colors.primary + font.pointSize: Config.font.size.small + } + + Behavior on opacity { + Anim { duration: Config.anim.durations.small } + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 12 + spacing: 7 + + PlayerControl { + icon.text: "flip_to_front" + canUse: Players.active?.canRaise ?? false + fontSize: Config.font.size.larger + size: playerSelector.height + fill: 0 + color: Config.colors.container + activeColor: Config.colors.secondary + + function onClicked(): void { + Players.active?.raise(); + root.uiState.dashboard = false; + } + } + + CustomRect { + id: playerSelector + + property bool expanded + + Layout.alignment: Qt.AlignVCenter + + implicitWidth: slider.implicitWidth * 0.6 + implicitHeight: currentPlayer.implicitHeight + 14 + radius: 17 + color: Config.colors.container + z: 1 + + StateLayer { + anchors.fill: parent + disabled: Players.list.length <= 1 + + function onClicked(): void { + playerSelector.expanded = !playerSelector.expanded; + } + } + + RowLayout { + id: currentPlayer + + anchors.centerIn: parent + spacing: 7 + + PlayerIcon { + player: Players.active + } + + CustomText { + Layout.fillWidth: true + Layout.maximumWidth: playerSelector.implicitWidth - implicitHeight - parent.spacing - 20 + text: Players.active ? Players.getName(Players.active) : qsTr("No players") + color: Players.active ? Config.colors.primary : Config.colors.tertiary + elide: Text.ElideRight + } + } + + Elevation { + anchors.fill: playerSelectorBg + radius: playerSelectorBg.radius + opacity: playerSelector.expanded ? 1 : 0 + level: 2 + + Behavior on opacity { + Anim { + duration: Config.anim.durations.expressiveDefaultSpatial + } + } + } + + CustomClippingRect { + id: playerSelectorBg + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + implicitWidth: playerSelector.expanded ? playerList.implicitWidth : playerSelector.implicitWidth + implicitHeight: playerSelector.expanded ? playerList.implicitHeight : playerSelector.implicitHeight + + color: Config.colors.containerAlt + radius: 17 + opacity: playerSelector.expanded ? 1 : 0 + + ColumnLayout { + id: playerList + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + spacing: 0 + + Repeater { + model: [...Players.list].sort((a, b) => (a === Players.active) - (b === Players.active)) + + Item { + id: player + + required property MprisPlayer modelData + + Layout.fillWidth: true + Layout.minimumWidth: playerSelector.implicitWidth + implicitWidth: playerInner.implicitWidth + 20 + implicitHeight: playerInner.implicitHeight + 14 + + StateLayer { + disabled: !playerSelector.expanded + + function onClicked(): void { + playerSelector.expanded = false; + Players.manualActive = player.modelData; + } + } + + RowLayout { + id: playerInner + + anchors.centerIn: parent + spacing: 7 + + PlayerIcon { + player: player.modelData + } + + CustomText { + text: Players.getName(player.modelData) + color: Config.colors.primary + } + } + } + } + } + + Behavior on opacity { + Anim { + duration: Config.anim.durations.expressiveDefaultSpatial + } + } + + Behavior on implicitWidth { + Anim { + duration: Config.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on implicitHeight { + Anim { + duration: Config.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } + } + } + } + + PlayerControl { + icon.text: "close" + icon.anchors.horizontalCenterOffset: 0 + canUse: Players.active?.canQuit ?? false + size: playerSelector.height + fontSize: Config.font.size.larger + fill: 1 + color: Config.colors.container + activeColor: Config.colors.error + + function onClicked(): void { + Players.active?.quit(); + } + } + } + } + + component PlayerIcon: Loader { + id: loader + + required property MprisPlayer player + readonly property string icon: player ? Icons.getAppIcon(Players.getIdentity(player)) : "" + + Layout.fillHeight: true + sourceComponent: !player || icon === "image://icon/" ? fallbackIcon : playerImage + + Component { + id: playerImage + + IconImage { + implicitWidth: height + source: loader.icon + } + } + + Component { + id: fallbackIcon + + MaterialIcon { + text: loader.player ? "animated_images" : "music_off" + } + } + } + + component PlayerControl: CustomRect { + id: control + + required property bool canUse + property alias icon: icon + property int fontSize: Config.font.size.largest + property int size: Math.max(icon.implicitWidth, icon.implicitHeight) + property real fill: 1 + property color activeColor: Config.colors.media + + function onClicked() {} + + implicitWidth: size + implicitHeight: implicitWidth + radius: 1000 + + StateLayer { + anchors.fill: parent + disabled: !control.canUse + + function onClicked(): void { + control.onClicked(); + } + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -font.pointSize * 0.02 + anchors.verticalCenterOffset: font.pointSize * 0.02 + + animate: true + fill: control.fill + text: control.icon + color: control.canUse ? control.activeColor : Config.colors.inactive + font.pointSize: control.fontSize + } + } +} diff --git a/modules/dashboard/Mixer.qml b/modules/dashboard/Mixer.qml new file mode 100644 index 0000000..60c1ac4 --- /dev/null +++ b/modules/dashboard/Mixer.qml @@ -0,0 +1,208 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Pipewire + +Item { + id: root + + required property PersistentProperties uiState + required property int index + readonly property list nodes: Pipewire.nodes.values.filter(node => node.isSink && node.isStream) + + width: Config.dashboard.mixerWidth + height: Config.dashboard.mixerHeight + + PwObjectTracker { + objects: root.nodes + } + + Binding { + target: root.uiState + when: root.uiState.dashboardTab === root.index + property: "osdVolumeReact" + value: false + } + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: 7 + + CustomText { + Layout.topMargin: 6 + text: qsTr("Master Volume") + color: Config.colors.secondary + } + + CustomMouseArea { + Layout.fillWidth: true + implicitHeight: Config.osd.sliderWidth + + function onWheel(event: WheelEvent) { + if (event.angleDelta.y > 0) + Audio.increaseVolume(); + else if (event.angleDelta.y < 0) + Audio.decreaseVolume(); + } + + acceptedButtons: Qt.RightButton + onClicked: Audio.sink.audio.muted = !Audio.muted + + CustomFilledSlider { + anchors.fill: parent + + orientation: Qt.Horizontal + color: Audio.muted ? Config.colors.error : Config.colors.volume + icon: Icons.getVolumeIcon(value, Audio.muted) + value: Audio.volume + onMoved: Audio.setVolume(value) + + Behavior on color { + CAnim { + duration: Config.anim.durations.small + } + } + } + } + + CustomRect { + Layout.fillWidth: true + Layout.topMargin: 5 + Layout.bottomMargin: 5 + height: 1 + color: Config.colors.inactive + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + CustomListView { + id: list + + anchors.fill: parent + spacing: 12 + + model: ScriptModel { + values: [...root.nodes] + objectProp: "id" + } + + CustomScrollBar.vertical: CustomScrollBar { + flickable: list + } + + delegate: RowLayout { + id: entry + + required property PwNode modelData + spacing: 6 + + width: root.width + + IconImage { + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + visible: source != "" + implicitSize: slider.height * 1.4 + source: { + const icon = entry.modelData.properties["application.icon-name"]; + if (icon) + return Icons.getAppIcon(icon, "image-missing"); + Icons.getAppIcon(entry.modelData.name, "image-missing") + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 6 + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + text: { + // application.name -> description -> name + const app = entry.modelData.properties["application.name"] + ?? (entry.modelData.description != "" ? entry.modelData.description : entry.modelData.name); + const media = entry.modelData.properties["media.name"]; + return media != undefined ? `${app} 🞄 ${media}` : app; + } + color: Config.colors.secondary + } + + CustomMouseArea { + id: slider + Layout.fillWidth: true + implicitHeight: Config.osd.sliderWidth + + acceptedButtons: Qt.RightButton + onClicked: entry.modelData.audio.muted = !entry.modelData.audio.muted + + CustomFilledSlider { + + anchors.fill: parent + + orientation: Qt.Horizontal + color: entry.modelData.audio.muted ? Config.colors.error : Config.colors.volume + icon: Icons.getVolumeIcon(value, entry.modelData.audio.muted) + value: entry.modelData.audio.volume + onMoved: { + if (entry.modelData.ready) + entry.modelData.audio.volume = Math.max(0, Math.min(1, value)); + } + + + Behavior on color { + CAnim { + duration: Config.anim.durations.small + } + } + } + } + } + } + } + + CustomText { + anchors.centerIn: parent + + opacity: list.count === 0 ? 1 : 0 + visible: opacity > 0 + + text: qsTr("No audio sources") + color: Config.colors.tertiary + font.pointSize: Config.font.size.normal + + Behavior on opacity { + Anim {} + } + } + } + + /* RowLayout { */ + /* id: deviceSelectorRowLayout */ + /* Layout.fillWidth: true */ + /* uniformCellSizes: true */ + + /* AudioDeviceSelectorButton { */ + /* Layout.fillWidth: true */ + /* input: false */ + /* onClicked: root.showDeviceSelectorDialog(input) */ + /* } */ + /* AudioDeviceSelectorButton { */ + /* Layout.fillWidth: true */ + /* input: true */ + /* onClicked: root.showDeviceSelectorDialog(input) */ + /* } */ + /* } */ + } +} diff --git a/modules/dashboard/Performance.qml b/modules/dashboard/Performance.qml new file mode 100644 index 0000000..9a59e7a --- /dev/null +++ b/modules/dashboard/Performance.qml @@ -0,0 +1,234 @@ +import qs.config +import qs.custom +import qs.services +import qs.util +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + function displayTemp(temp: real): string { + return `${Math.ceil(Config.services.useFahrenheit ? temp * 1.8 + 32 : temp)}°${Config.services.useFahrenheit ? "F" : "C"}`; + } + + readonly property int padding: 20 + spacing: 28 + + Component.onCompleted: SystemUsage.refCount++; + Component.onDestruction: SystemUsage.refCount--; + + Resource { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: root.padding + Layout.leftMargin: root.padding + Layout.rightMargin: root.padding + + value1: SystemUsage.gpuPerc + label1: `${Math.round(SystemUsage.gpuPerc * 100)}%` + sublabel1: qsTr("GPU Usage") + + value2: Math.min(1, SystemUsage.gpuTemp / 90) + label2: root.displayTemp(SystemUsage.gpuTemp) + sublabel2: qsTr("Temp") + warning2: value2 > 0.75 + } + + Resource { + Layout.alignment: Qt.AlignHCenter + Layout.leftMargin: root.padding + Layout.rightMargin: root.padding + + primary: true + + value1: SystemUsage.cpuPerc + label1: `${Math.round(SystemUsage.cpuPerc * 100)}%` + sublabel1: qsTr("CPU Usage") + + value2: Math.min(1, SystemUsage.cpuTemp / 90) + label2: root.displayTemp(SystemUsage.cpuTemp) + sublabel2: qsTr("Temp") + warning2: value2 > 0.75 + } + + Resource { + Layout.alignment: Qt.AlignHCenter + Layout.leftMargin: root.padding + Layout.rightMargin: root.padding + Layout.bottomMargin: root.padding + + value1: SystemUsage.memPerc + label1: { + const fmt = SystemUsage.formatKib(SystemUsage.memUsed); + return `${+fmt.value.toFixed(1)}${fmt.unit}`; + } + sublabel1: qsTr("Memory") + + value2: SystemUsage.storagePerc + label2: { + const fmt = SystemUsage.formatKib(SystemUsage.storageUsed); + return `${Math.floor(fmt.value)}${fmt.unit}`; + } + sublabel2: qsTr("Storage") + } + + component Resource: Item { + id: res + + required property real value1 + required property string label1 + required property string sublabel1 + property bool warning1: value1 > 0.9 + required property real value2 + required property string label2 + required property string sublabel2 + property bool warning2: value2 > 0.9 + + property bool primary + readonly property real primaryMult: primary ? 1.2 : 1 + + readonly property real thickness: 10 * primaryMult + + property color fg1: warning1 ? Config.colors.error : Color.mute(Config.colors.performance) + property color fg2: warning2 ? Config.colors.error : Color.mute(Config.colors.performance, 1.5, 1.6) + property color bg1: Qt.alpha(warning1 ? Config.colors.error : Config.colors.performance, 0.1) + property color bg2: Qt.alpha(warning2 ? Config.colors.error : Config.colors.performance, 0.05) + + implicitWidth: implicitHeight + implicitHeight: 175 * primaryMult + + onValue1Changed: canvas.requestPaint() + onValue2Changed: canvas.requestPaint() + onFg1Changed: canvas.requestPaint() + onFg2Changed: canvas.requestPaint() + onBg1Changed: canvas.requestPaint() + onBg2Changed: canvas.requestPaint() + + Column { + anchors.centerIn: parent + + readonly property color color: res.warning1 ? Config.colors.error : + res.value1 === 0 ? Config.colors.inactive : Config.colors.primary + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + + text: res.label1 + color: parent.color + font.pointSize: Config.font.size.largest * res.primaryMult + } + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + + text: res.sublabel1 + color: parent.color + font.pointSize: Config.font.size.smaller * res.primaryMult + } + } + + Column { + anchors.horizontalCenter: parent.right + anchors.top: parent.verticalCenter + anchors.horizontalCenterOffset: -res.thickness / 2 + anchors.topMargin: res.thickness / 2 + 7 + + readonly property color color: res.warning2 ? Config.colors.error : + res.value2 === 0 ? Config.colors.inactive : Config.colors.primary + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + + text: res.label2 + color: parent.color + font.pointSize: Config.font.size.smaller * res.primaryMult + } + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + + text: res.sublabel2 + color: parent.color + font.pointSize: Config.font.size.small * res.primaryMult + } + } + + Canvas { + id: canvas + + readonly property real centerX: width / 2 + readonly property real centerY: height / 2 + + readonly property real arc1Start: degToRad(45) + readonly property real arc1End: degToRad(270) + readonly property real arc2Start: degToRad(360) + readonly property real arc2End: degToRad(280) + + function degToRad(deg: int): real { + return deg * Math.PI / 180; + } + + anchors.fill: parent + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + + ctx.lineWidth = res.thickness; + ctx.lineCap = "round"; + + const radius = (Math.min(width, height) - ctx.lineWidth) / 2; + const cx = centerX; + const cy = centerY; + const a1s = arc1Start; + const a1e = arc1End; + const a2s = arc2Start; + const a2e = arc2End; + + ctx.beginPath(); + ctx.arc(cx, cy, radius, a1s, a1e, false); + ctx.strokeStyle = res.bg1; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(cx, cy, radius, a1s, (a1e - a1s) * res.value1 + a1s, false); + ctx.strokeStyle = res.fg1; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(cx, cy, radius, a2s, a2e, true); + ctx.strokeStyle = res.bg2; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(cx, cy, radius, a2s, (a2e - a2s) * res.value2 + a2s, true); + ctx.strokeStyle = res.fg2; + ctx.stroke(); + } + } + + Behavior on value1 { + Anim {} + } + + Behavior on value2 { + Anim {} + } + + Behavior on fg1 { + CAnim {} + } + + Behavior on fg2 { + CAnim {} + } + + Behavior on bg1 { + CAnim {} + } + + Behavior on bg2 { + CAnim {} + } + } +} diff --git a/modules/dashboard/Tabs.qml b/modules/dashboard/Tabs.qml new file mode 100644 index 0000000..80d936e --- /dev/null +++ b/modules/dashboard/Tabs.qml @@ -0,0 +1,269 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property real nonAnimHeight + required property PersistentProperties uiState + readonly property int count: repeater.model.count + readonly property color color: indicator.currentItem.color + + implicitWidth: childrenRect.width + + ColumnLayout { + id: bar + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 16 + + width: 100 + + Repeater { + id: repeater + + model: ListModel {} + + Component.onCompleted: { + model.append({ + text: qsTr("Dashboard"), + iconName: "dashboard", + color: Config.colors.dashboard + }); + model.append({ + text: qsTr("Mixer"), + iconName: "tune", + color: Config.colors.mixer + }); + model.append({ + text: qsTr("Media"), + iconName: "queue_music", + color: Config.colors.media + }); + model.append({ + text: qsTr("Performance"), + iconName: "speed", + color: Config.colors.performance + }); + model.append({ + text: qsTr("Workspaces"), + iconName: "workspaces", + color: Config.colors.workspaces + }); + } + + delegate: Tab {} + } + } + + Item { + id: indicator + + anchors.left: bar.right + anchors.leftMargin: 8 + + property int currentIndex: root.uiState.dashboardTab + property Item currentItem: { + repeater.count; + repeater.itemAt(currentIndex) + } + + implicitWidth: 2 + implicitHeight: currentItem.implicitHeight + + y: currentItem ? currentItem.y + bar.y + (currentItem.height - implicitHeight) / 2 : 0 + + clip: true + + CustomRect { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + implicitWidth: parent.implicitWidth * 2 + + color: indicator.currentItem?.color ?? "transparent" + radius: 1000 + } + + Behavior on currentIndex { + SequentialAnimation { + Anim { + target: indicator + property: "implicitHeight" + to: 0 + duration: Config.anim.durations.small / 2 + } + PropertyAction {} + Anim { + target: indicator + property: "implicitHeight" + from: 0 + to: bar.children[root.uiState.dashboardTab].height + duration: Config.anim.durations.small / 2 + } + } + } + } + + CustomRect { + id: separator + + anchors.left: indicator.right + anchors.top: parent.top + anchors.bottom: parent.bottom + + implicitWidth: 1 + color: Config.colors.inactive + } + + component Tab: CustomMouseArea { + id: tab + + required property int index + required property string text + required property string iconName + required property color color + readonly property bool isCurrentItem: root.uiState.dashboardTab === index + + implicitHeight: icon.height + label.height + 8 + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onPressed: event => { + root.uiState.dashboardTab = tab.index; + + const stateY = stateWrapper.y; + rippleAnim.x = event.x; + rippleAnim.y = event.y - stateY; + + const dist = (ox, oy) => ox * ox + oy * oy; + rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y + stateY), + dist(event.x, stateWrapper.height - event.y), + dist(width - event.x, event.y + stateY), + dist(width - event.x, stateWrapper.height - event.y))); + + rippleAnim.restart(); + } + + function onWheel(event: WheelEvent): void { + if (event.angleDelta.y < 0) + root.uiState.dashboardTab = Math.min(root.uiState.dashboardTab + 1, root.count - 1); + else if (event.angleDelta.y > 0) + root.uiState.dashboardTab = Math.max(root.uiState.dashboardTab - 1, 0); + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 0.08 + } + Anim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + easing.bezierCurve: Config.anim.curves.standardDecel + } + Anim { + target: ripple + property: "opacity" + to: 0 + } + } + + ClippingRectangle { + id: stateWrapper + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: parent.height + + color: "transparent" + radius: 12 + + CustomRect { + id: stateLayer + + anchors.fill: parent + + color: tab.isCurrentItem ? tab.color : Config.colors.primary + opacity: tab.pressed ? 0.1 : tab.containsMouse ? 0.08 : 0 + + Behavior on opacity { + Anim {} + } + } + + CustomRect { + id: ripple + + radius: 1000 + color: tab.isCurrentItem ? tab.color : Config.colors.primary + opacity: 0 + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + MaterialIcon { + id: icon + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: label.top + + text: tab.iconName + color: tab.isCurrentItem ? tab.color : Config.colors.primary + fill: tab.isCurrentItem ? 1 : 0 + font.pointSize: Config.font.size.large + + Behavior on fill { + Anim { + duration: Config.anim.durations.small + } + } + } + + CustomText { + id: label + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + + text: tab.text + color: tab.isCurrentItem ? tab.color : Config.colors.primary + } + } +} diff --git a/modules/dashboard/Workspaces.qml b/modules/dashboard/Workspaces.qml new file mode 100644 index 0000000..4feeef9 --- /dev/null +++ b/modules/dashboard/Workspaces.qml @@ -0,0 +1,496 @@ +import qs.modules.bar.popouts as BarPopouts +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import Quickshell.Widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts + +Item { + id: root + + required property PersistentProperties uiState + required property BarPopouts.Wrapper popouts + + width: Config.dashboard.workspaceWidth + 32 + height: 750 + + property HyprlandWorkspace dragSourceWorkspace: null + property HyprlandWorkspace dragTargetWorkspace: null + + // Controls scrolling at edge of panel while dragging + property real dragScrollVelocity: 0 + + CustomListView { + id: list + anchors.fill: parent + anchors.margins: 12 + anchors.rightMargin: 0 + spacing: 12 + + clip: true + + CustomScrollBar.vertical: CustomScrollBar { + flickable: list + } + + model: Hypr.monitors + + delegate: Item { + id: monitor + + required property int index + required property HyprlandMonitor modelData + readonly property ListModel workspaces: States.screens.get(modelData).workspaces + + width: list.width - 20 + height: childrenRect.height + + Item { + id: monitorHeader + anchors.left: parent.left + anchors.right: parent.right + height: monIcon.height + + MaterialIcon { + id: monIcon + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + text: "desktop_windows" + color: Config.colors.tertiary + } + + CustomText { + id: monText + anchors.verticalCenter: parent.verticalCenter + anchors.left: monIcon.right + anchors.leftMargin: 4 + text: monitor.modelData.name + color: Config.colors.tertiary + font.family: Config.font.family.mono + font.pointSize: Config.font.size.smaller + } + + CustomRect { + anchors.verticalCenter: parent.verticalCenter + anchors.left: monText.right + anchors.right: parent.right + anchors.leftMargin: 8 + anchors.rightMargin: 18 + height: 1 + color: Config.colors.inactive + } + } + + ListView { + id: workspaceList + + anchors.top: monitorHeader.bottom + anchors.topMargin: 8 + width: list.width - 20 + spacing: 22 + + height: contentHeight + acceptedButtons: Qt.NoButton + boundsBehavior: Flickable.StopAtBounds + + model: monitor.workspaces + + delegate: Column { + id: entry + + required property int index + required property HyprlandWorkspace workspace + + width: list.width - 20 + + spacing: 5 + + property bool hoveredWhileDragging: false + z: root.dragSourceWorkspace === workspace ? 10 : 0 + + Item { + id: header + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: labelState.height + + property color nonAnimColor: entry.hoveredWhileDragging ? Config.colors.workspaceMove : + entry.workspace?.active ? Config.colors.workspaces : Config.colors.primary + property color color: nonAnimColor + + Behavior on color { + CAnim {} + } + + StateLayer { + id: labelState + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + implicitWidth: label.width + 20 + implicitHeight: label.height + 8 + color: Config.colors.secondary + + function onClicked(): void { + Hypr.dispatch(`workspace ${entry.workspace.id}`) + } + + Row { + id: label + + anchors.centerIn: parent + spacing: 7 + + MaterialIcon { + text: Icons.getWorkspaceIcon(entry.workspace) + color: header.color + font.pointSize: Config.font.size.larger + animate: true + animateDuration: Config.anim.durations.small + } + + CustomText { + anchors.top: parent.top + anchors.topMargin: 1 + text: qsTr("Workspace %1").arg(index + 1) + font.family: Config.font.family.mono + font.pointSize: Config.font.size.normal + color: header.color + } + } + } + + StateLayer { + anchors.verticalCenter: parent.verticalCenter + anchors.right: downArrow.left + anchors.rightMargin: 2 + + implicitWidth: implicitHeight + implicitHeight: upIcon.height + 6 + + disabled: entry.index === 0 && monitor.index === 0 + function onClicked(): void { + if (entry.index !== 0) { + monitor.workspaces.move(entry.index, entry.index - 1, 1) + } else { + const workspace = entry.workspace; + monitor.workspaces.remove(entry.index, 1); + const otherWorkspaces = list.itemAtIndex(monitor.index - 1).workspaces; + otherWorkspaces.insert(0, {"workspace": workspace}); + } + } + + MaterialIcon { + id: upIcon + + anchors.centerIn: parent + + text: "keyboard_arrow_up" + color: parent.disabled ? Config.colors.inactive : header.nonAnimColor + font.pointSize: Config.font.size.larger + + Behavior on color { + CAnim {} + } + } + } + + StateLayer { + id: downArrow + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + implicitWidth: implicitHeight + implicitHeight: downIcon.height + 6 + + disabled: entry.index === monitor.workspaces.count - 1 && monitor.index === list.count - 1 + function onClicked(): void { + if (entry.index !== monitor.workspaces.count - 1) { + root.uiState.workspaces.move(entry.index, entry.index + 1, 1) + } else { + const workspace = entry.workspace; + monitor.workspaces.remove(entry.index, 1); + const otherWorkspaces = list.itemAtIndex(monitor.index + 1).workspaces; + otherWorkspaces.append({"workspace": workspace}); + } + } + + MaterialIcon { + id: downIcon + anchors.centerIn: parent + + text: "keyboard_arrow_down" + color: parent.disabled ? Config.colors.inactive : header.nonAnimColor + font.pointSize: Config.font.size.larger + + Behavior on color { + CAnim {} + } + } + } + } + + CustomRect { + id: preview + + anchors.left: parent.left + anchors.right: parent.right + + readonly property HyprlandMonitor mon: entry.workspace.monitor + + // Exclude UI border and apply monitor scale + readonly property real monX: Config.border.thickness + readonly property real monY: Config.bar.height + readonly property real monWidth: (mon.width / mon.scale) - 2 * Config.border.thickness + readonly property real monHeight: (mon.height / mon.scale) - Config.bar.height - Config.border.thickness + + readonly property real aspectRatio: monHeight / monWidth + readonly property real sizeRatio: Config.dashboard.workspaceWidth / monWidth + + width: Config.dashboard.workspaceWidth + height: aspectRatio * width + + radius: 7 + color: entry.hoveredWhileDragging ? Config.colors.workspaceMove : + entry.workspace?.active ? Color.mute(Config.colors.workspaces, 1.5, 1.1) : Config.colors.container + + Behavior on color { + CAnim {} + } + + DropArea { + anchors.fill: parent + onEntered: { + root.dragTargetWorkspace = entry.workspace; + entry.hoveredWhileDragging = true; + } + onExited: { + if (root.draggingTargetWorkspace === entry.workspace) + root.draggingTargetWorkspace = null; + entry.hoveredWhileDragging = false; + } + } + + Repeater { + anchors.fill: parent + + model: entry.workspace?.toplevels + + delegate: Item { + id: window + + required property HyprlandToplevel modelData + + property var ipc: modelData.lastIpcObject + + opacity: ipc && ipc.at && !ipc.hidden ? 1 : 0 + + property real nonAnimX: ipc?.at ? (ipc.at[0] - preview.monX) * preview.sizeRatio : 0 + property real nonAnimY: ipc?.at ? (ipc.at[1] - preview.monY) * preview.sizeRatio : 0 + property real nonAnimWidth: ipc?.size ? ipc.size[0] * preview.sizeRatio : 0 + property real nonAnimHeight: ipc?.size ? ipc.size[1] * preview.sizeRatio : 0 + + x: nonAnimX + y: nonAnimY + width: nonAnimWidth + height: nonAnimHeight + + z: nonAnimX !== x || nonAnimY !== y ? 10 : 0 + + Behavior on x { + enabled: window.x !== 0 + Anim {} + } + Behavior on y { + enabled: window.y !== 0 + Anim {} + } + Behavior on width { + enabled: window.width !== 0 + Anim {} + } + Behavior on height { + enabled: window.height !== 0 + Anim {} + } + Behavior on opacity { Anim {} } + + ScreencopyView { + id: view + visible: false + anchors.left: parent.left + anchors.top: parent.top + layer.enabled: true + + // NOTE: Simulates cropping fill (which ScreencopyView does not natively support) + readonly property real aspectRatio: sourceSize.height / sourceSize.width + width: Math.max(window.nonAnimWidth, window.nonAnimHeight / aspectRatio) + height: Math.max(window.nonAnimHeight, window.nonAnimWidth * aspectRatio) + + captureSource: window.modelData.wayland + live: true + + CustomRect { + anchors.fill: parent + color: mouse.drag.active ? + Qt.tint(Qt.alpha(Config.colors.overlay, 0.7), Qt.alpha(Config.colors.workspaceMove, 0.2)) : + mouse.pressed ? + Qt.tint(Qt.alpha(Config.colors.overlay, 0.7), Qt.alpha(Config.colors.workspaces, 0.2)) : + mouse.containsMouse ? + Qt.tint(Qt.alpha(Config.colors.overlay, 0.5), Qt.alpha(Config.colors.workspaces, 0.1)) : + Qt.alpha(Config.colors.overlay, 0.5) + + Behavior on color { + CAnim { duration: Config.anim.durations.small } + } + } + } + + Item { + id: mask + visible: false + anchors.fill: view + layer.enabled: true + Rectangle { + width: window.width + height: window.height + radius: 8 + } + } + + MultiEffect { + anchors.fill: view + source: view + maskEnabled: true + maskSource: mask + } + + IconImage { + id: icon + + anchors.centerIn: parent + implicitSize: Math.min(48, window.width * 0.5, window.height * 0.5) + source: Icons.getAppIcon(window.ipc?.class ?? "", "") + } + + // Interactions + + Drag.active: mouse.drag.active + Drag.source: window + Drag.hotSpot.x: nonAnimWidth / 2 + Drag.hotSpot.y: nonAnimHeight / 2 + + MouseArea { + id: mouse + + anchors.fill: parent + + hoverEnabled: true + acceptedButtons: Qt.AllButtons + + drag.target: parent + onPressed: event => { + root.dragSourceWorkspace = entry.workspace; + window.Drag.hotSpot.x = event.x; + window.Drag.hotSpot.y = event.y; + } + onReleased: event => { + const targetWorkspace = root.dragTargetWorkspace; + root.dragSourceWorkspace = null; + root.dragScrollVelocity = 0; + if (targetWorkspace !== null && targetWorkspace !== entry.workspace) { + Hyprland.dispatch(`movetoworkspacesilent ${targetWorkspace.id}, address:0x${window.modelData.address}`) + } else { + window.x = Qt.binding(() => window.nonAnimX); + window.y = Qt.binding(() => window.nonAnimY); + } + } + + onClicked: event => { + if (event.button === Qt.LeftButton) { + root.uiState.dashboard = false; + Hypr.dispatch(`focuswindow address:0x${window.modelData.address}`); + } else if (event.button === Qt.MiddleButton) { + Hypr.dispatch(`closewindow address:0x${window.modelData.address}`); + } else if (event.button === Qt.RightButton) { + root.uiState.dashboard = false; + popouts.currentName = "activewindow"; + popouts.currentCenter = QsWindow.window.width / 2; + popouts.window = window.modelData; + popouts.hasCurrent = true; + } + } + + onPositionChanged: event => { + if (!drag.active) return; + const y = root.mapFromItem(this, 0, event.y).y; + if (y < 100) { + root.dragScrollVelocity = (y - 100) / 25; + } else if (y > root.height - 100) { + root.dragScrollVelocity = (y - root.height + 100) / 25; + } else { + root.dragScrollVelocity = 0; + } + } + } + } + } + } + } + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + } + } + + remove: Transition { + Anim { + property: "opacity" + from: 1 + to: 0 + } + } + + move: Transition { + Anim { + property: "y" + } + Anim { + properties: "opacity" + to: 1 + } + } + + displaced: Transition { + Anim { + property: "y" + } + Anim { + properties: "opacity" + to: 1 + } + } + } + } + } + + Timer { + running: root.dragScrollVelocity !== 0 + repeat: true + interval: 10 + + onTriggered: { + list.contentY += root.dragScrollVelocity; + list.returnToBounds(); + } + } +} diff --git a/modules/dashboard/Wrapper.qml b/modules/dashboard/Wrapper.qml new file mode 100644 index 0000000..eeffe09 --- /dev/null +++ b/modules/dashboard/Wrapper.qml @@ -0,0 +1,78 @@ +pragma ComponentBehavior: Bound + +import qs.modules.bar.popouts as BarPopouts +import qs.config +import qs.custom +import qs.util +import Quickshell +import Quickshell.Hyprland +import QtQuick + +Item { + id: root + + required property PersistentProperties uiState + required property BarPopouts.Wrapper popouts + + visible: width > 0 + implicitWidth: 0 + implicitHeight: content.implicitHeight + + states: State { + name: "visible" + when: root.uiState.dashboard + + PropertyChanges { + root.implicitWidth: content.implicitWidth + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitWidth" + duration: Config.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: Config.anim.curves.emphasized + } + } + ] + + Background { + id: background + visible: false + wrapper: root + } + + GlowEffect { + source: background + glowColor: content.active ? content.item.color : "transparent" + } + + Loader { + id: content + + Component.onCompleted: active = Qt.binding(() => root.uiState.dashboard || root.visible) + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + sourceComponent: Content { + uiState: root.uiState + popouts: root.popouts + } + } +} diff --git a/modules/dashboard/dash/DateTime.qml b/modules/dashboard/dash/DateTime.qml new file mode 100644 index 0000000..8f4ae5d --- /dev/null +++ b/modules/dashboard/dash/DateTime.qml @@ -0,0 +1,29 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.config +import qs.custom +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + anchors.centerIn: parent + anchors.verticalCenterOffset: -2 + spacing: -6 + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: Time.format("hh:mm:ss") + color: Config.colors.secondary + font.family: Config.font.family.mono + font.pointSize: Config.font.size.largest + font.weight: 600 + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: Time.format("dddd, yyyy-MM-dd") + color: Config.colors.tertiary + font.pointSize: Config.font.size.normal + } +} diff --git a/modules/dashboard/dash/Media.qml b/modules/dashboard/dash/Media.qml new file mode 100644 index 0000000..84fe2ee --- /dev/null +++ b/modules/dashboard/dash/Media.qml @@ -0,0 +1,243 @@ +import qs.config +import qs.custom +import qs.services +import qs.util +import QtQuick +import QtQuick.Shapes + +Item { + id: root + + anchors.fill: parent + + property real playerProgress: { + const active = Players.active; + return active?.length ? active.position / active.length : 0; + } + + Behavior on playerProgress { + Anim { + duration: Config.anim.durations.large + } + } + + Timer { + running: Players.active?.isPlaying ?? false + interval: 400 + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } + + Shape { + id: progress + + preferredRendererType: Shape.CurveRenderer + + readonly property int thickness: 8 + readonly property int angle: 300 + + ShapePath { + id: path + + fillColor: "transparent" + strokeColor: Qt.alpha(Config.colors.media, 0.2) + strokeWidth: progress.thickness + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: cover.x + cover.width / 2 + centerY: cover.y + cover.height / 2 + radiusX: (cover.width + progress.thickness) / 2 + 7 + radiusY: (cover.height + progress.thickness) / 2 + 7 + startAngle: -90 - progress.angle / 2 + sweepAngle: progress.angle + } + + Behavior on strokeColor { + CAnim {} + } + } + + ShapePath { + fillColor: "transparent" + strokeColor: Config.colors.media + strokeWidth: progress.thickness + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: cover.x + cover.width / 2 + centerY: cover.y + cover.height / 2 + radiusX: (cover.width + progress.thickness) / 2 + 7 + radiusY: (cover.height + progress.thickness) / 2 + 7 + startAngle: -90 - progress.angle / 2 + // NOTE: Cap progress angle to account for bad MPRIS players + sweepAngle: progress.angle * Math.min(root.playerProgress, 1) + } + + Behavior on strokeColor { + CAnim {} + } + } + } + + CustomClippingRect { + id: cover + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 18 + progress.thickness + + implicitHeight: width + color: Config.colors.inactive + radius: Infinity + + MaterialIcon { + anchors.centerIn: parent + + grade: 200 + text: "art_track" + color: Config.colors.tertiary + font.pointSize: (parent.width * 0.4) || 1 + } + + Image { + id: image + + anchors.fill: parent + + source: Players.active?.trackArtUrl ?? "" + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: width + sourceSize.height: height + } + } + + CustomText { + id: title + + anchors.top: cover.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: album.text === "" ? 24 : 18 + anchors.leftMargin: 15 + anchors.rightMargin: 15 + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") + color: Config.colors.secondary + font.pointSize: Config.font.size.normal + + elide: Text.ElideRight + } + + CustomText { + id: artist + + anchors.top: title.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 15 + anchors.rightMargin: 15 + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") + opacity: Players.active ? 1 : 0 + color: Config.colors.primary + + elide: Text.ElideRight + } + + CustomText { + id: album + + anchors.top: artist.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 15 + anchors.rightMargin: 15 + + animate: true + horizontalAlignment: Text.AlignHCenter + text: Players.active?.trackAlbum ?? "" + opacity: Players.active ? 1 : 0 + color: Config.colors.tertiary + font.pointSize: Config.font.size.small + elide: Text.ElideRight + } + + Row { + id: controls + + anchors.top: album.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: album.text === "" ? -4 : 2 + + spacing: 7 + + Control { + icon: "skip_previous" + canUse: Players.active?.canGoPrevious ?? false + + function onClicked(): void { + Players.active?.previous(); + } + } + + Control { + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + canUse: Players.active?.canTogglePlaying ?? false + + function onClicked(): void { + Players.active?.togglePlaying(); + } + } + + Control { + icon: "skip_next" + canUse: Players.active?.canGoNext ?? false + + function onClicked(): void { + Players.active?.next(); + } + } + } + + component Control: CustomRect { + id: control + + required property string icon + required property bool canUse + function onClicked(): void { + } + + implicitWidth: Math.max(icon.implicitHeight, icon.implicitHeight) + 12 + implicitHeight: implicitWidth + + StateLayer { + anchors.fill: parent + disabled: !control.canUse + radius: 1000 + + function onClicked(): void { + control.onClicked(); + } + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.verticalCenterOffset: font.pointSize * 0.05 + + animate: true + text: control.icon + color: control.canUse ? Config.colors.media : Config.colors.inactive + font.pointSize: Config.font.size.large + } + } +} diff --git a/modules/dashboard/dash/Notifs.qml b/modules/dashboard/dash/Notifs.qml new file mode 100644 index 0000000..81362eb --- /dev/null +++ b/modules/dashboard/dash/Notifs.qml @@ -0,0 +1,264 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts +import qs.modules.notifications as N + +ColumnLayout { + id: root + + anchors.fill: parent + + spacing: 7 + + readonly property int notifCount: + (Notifs.list && Notifs.list.length !== undefined) ? Notifs.list.length : + ((Notifs.list && Notifs.list.count !== undefined) ? Notifs.list.count : 0) + + function notifAt(i) { + if (!Notifs.list) + return undefined; + if (typeof Notifs.list.get === 'function') + return Notifs.list.get(i); + return Notifs.list[i]; + } + + function scrollToTop(): void { + if (notifScroll && notifScroll.contentItem && notifScroll.contentItem.contentY !== undefined) { + notifScroll.contentItem.contentY = 0; + } + } + + RowLayout { + Layout.alignment: Qt.AlignTop + Layout.margins: 10 + Layout.fillWidth: true + spacing: 7 + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + id: icon + text: { + if (Notifs.dnd) + return "notifications_off"; + if (notifCount > 0) + return "notifications_active"; + return "notifications"; + } + fill: { + if (Notifs.dnd) + return 0; + if (notifCount > 0) + return 1; + return 0; + } + color: Notifs.dnd ? Config.colors.error : Config.colors.notification + font.pointSize: Config.font.size.larger + animate: true + + Behavior on color { + SequentialAnimation { + PauseAnimation { + duration: icon.animateDuration / 2 + } + PropertyAction {} + } + } + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: notifCount > 0 ? qsTr("%1 notifications").arg(notifCount) : qsTr("No notifications") + font.weight: 600 + font.pointSize: Config.font.size.normal + animate: true + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + text: qsTr("Do Not Disturb") + } + + CustomSwitch { + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 5 + Layout.rightMargin: 5 + bg: Config.colors.containerAlt + accent: Color.mute(Config.colors.notification) + checked: Notifs.dnd + onToggled: Notifs.toggleDnd() + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.bottomMargin: 10 + + CustomListView { + id: notifScroll + anchors.fill: parent + anchors.margins: 10 + anchors.rightMargin: 5 + spacing: 12 + clip: true + + CustomScrollBar.vertical: CustomScrollBar { + flickable: notifScroll + } + + model: ScriptModel { + values: [...Notifs.list].reverse() + } + + delegate: Item { + id: wrapper + required property int index + required property var modelData + readonly property alias nonAnimHeight: notif.nonAnimHeight + + width: 405 + height: notif.implicitHeight + + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: true + } + PropertyAction { + target: wrapper + property: "enabled" + value: false + } + PropertyAction { + target: wrapper + property: "implicitHeight" + value: 0 + } + PropertyAction { + target: wrapper + property: "z" + value: 1 + } + Anim { + target: notif + property: "x" + to: (notif.x >= 0 ? Config.notifs.width : -Config.notifs.width) * 2 + easing.bezierCurve: Config.anim.curves.emphasized + } + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: false + } + } + + N.Notification { + id: notif + width: parent.width + notif: wrapper.modelData + color: wrapper.modelData.urgency === NotificationUrgency.Critical ? Config.colors.errorBg : Config.colors.containerAlt + inPopup: false + } + } + + add: Transition { + Anim { + property: "x" + from: Config.notifs.width + to: 0 + easing.bezierCurve: Config.anim.curves.emphasizedDecel + } + } + + move: Transition { + NotifAnim { + property: "y" + } + } + + displaced: Transition { + NotifAnim { + property: "y" + } + } + } + } + + Item { + Layout.alignment: Qt.AlignBottom + Layout.fillWidth: true + height: 0 + + CustomRect { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: bottomMargin + + property real bottomMargin: notifCount > 0 ? 12 : -4 + opacity: notifCount > 0 ? 1 : 0 + visible: opacity > 0 + implicitWidth: clearBtn.implicitWidth + 32 + implicitHeight: clearBtn.implicitHeight + 20 + + radius: 25 + color: Config.colors.inactive + + Behavior on opacity { + Anim {} + } + + Behavior on bottomMargin { + Anim {} + } + + StateLayer { + anchors.fill: parent + + function onClicked(): void { + for (let i = root.notifCount - 1; i >= 0; i--) { + const n = root.notifAt(i); + n?.notification?.dismiss(); + } + } + } + + RowLayout { + id: clearBtn + + anchors.centerIn: parent + spacing: 7 + + MaterialIcon { + id: clearIcon + text: "clear_all" + color: Config.colors.secondary + } + + CustomText { + text: qsTr("Clear all") + color: Config.colors.secondary + } + } + } + } + + component NotifAnim: Anim { + duration: Config.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } +} diff --git a/modules/dashboard/dash/User.qml b/modules/dashboard/dash/User.qml new file mode 100644 index 0000000..8272b25 --- /dev/null +++ b/modules/dashboard/dash/User.qml @@ -0,0 +1,73 @@ +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import QtQuick + +Column { + id: root + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 24 + + Item { + id: userLine + + implicitWidth: childrenRect.width + implicitHeight: childrenRect.height + + MaterialIcon { + id: userIcon + + anchors.left: parent.left + + text: "account_circle" + fill: 1 + color: Config.colors.primary + font.pointSize: Config.font.size.larger + } + + CustomText { + id: userText + + anchors.verticalCenter: userIcon.verticalCenter + anchors.left: userIcon.right + anchors.leftMargin: 7 + + text: qsTr("User: %1").arg(User.user) + color: Config.colors.secondary + } + } + + Item { + id: uptimeLine + + implicitWidth: childrenRect.width + implicitHeight: childrenRect.height + + MaterialIcon { + id: uptimeIcon + + anchors.left: parent.left + + text: "timer" + fill: 1 + color: Config.colors.primary + font.pointSize: Config.font.size.larger + } + + CustomText { + id: uptimeText + + anchors.verticalCenter: uptimeIcon.verticalCenter + anchors.left: uptimeIcon.right + anchors.verticalCenterOffset: 1 + anchors.leftMargin: 7 + + text: qsTr("Uptime: %1").arg(User.uptime) + color: Config.colors.secondary + } + } +} diff --git a/modules/dashboard/dash/Weather.qml b/modules/dashboard/dash/Weather.qml new file mode 100644 index 0000000..af520fc --- /dev/null +++ b/modules/dashboard/dash/Weather.qml @@ -0,0 +1,129 @@ +import qs.config +import qs.custom +import qs.services +import qs.util +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + anchors.fill: parent + anchors.margins: 16 + anchors.topMargin: 6 + + Component.onCompleted: Weather.reload() + + CustomText { + id: city + + anchors.top: parent.top + anchors.left: parent.left + + text: Weather.city + color: Config.colors.tertiary + } + + + MaterialIcon { + id: icon + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: font.pointSize * 0.25 + + animate: true + animateProp: "opacity" + text: Weather.icon + color: Weather.iconColor + font.pointSize: Config.font.size.largest * 1.8 + + Behavior on color { + SequentialAnimation { + PauseAnimation { + duration: icon.animateDuration / 2 + } + PropertyAction {} + } + } + } + + Column { + id: info + + anchors.left: icon.right + anchors.verticalCenter: icon.verticalCenter + anchors.verticalCenterOffset: -3 + anchors.leftMargin: 14 + spacing: 1 + + opacity: Weather.available ? 1 : 0 + + Behavior on opacity { + SequentialAnimation { + PauseAnimation { + duration: temp.animateDuration / 2 + } + PropertyAction {} + } + } + + CustomText { + id: temp + animate: true + text: Weather.temp + color: Config.colors.primary + font.pointSize: Config.font.size.large + font.weight: 500 + + // Reduce padding at bottom of text + height: implicitHeight * 0.9 + + CustomText { + anchors.left: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 12 + anchors.bottomMargin: 1 + + animate: true + text: Weather.feelsLike + color: Config.colors.tertiary + font.pointSize: Config.font.size.larger + } + } + + CustomText { + animate: true + text: Weather.description + color: Config.colors.secondary + + elide: Text.ElideRight + width: Math.min(implicitWidth, root.parent.width - icon.implicitWidth - info.anchors.leftMargin - 30) + } + + Item { + implicitWidth: childrenRect.width + implicitHeight: childrenRect.height + + MaterialIcon { + id: humidityIcon + + animate: true + text: Weather.humidityIcon + color: Config.colors.primary + font.pointSize: Config.font.size.normal + + } + + CustomText { + anchors.left: humidityIcon.right + anchors.verticalCenter: humidityIcon.verticalCenter + anchors.leftMargin: 2 + + animate: true + text: `${Math.round(Weather.humidity * 100)}% Humidity` + color: Config.colors.primary + } + } + } +} diff --git a/modules/launcher/Background.qml b/modules/launcher/Background.qml new file mode 100644 index 0000000..581f852 --- /dev/null +++ b/modules/launcher/Background.qml @@ -0,0 +1,59 @@ +import qs.config +import QtQuick +import QtQuick.Shapes + +Shape { + id: root + + required property Item wrapper + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + + ShapePath { + startX: (root.parent.width - root.wrapper.width) / 2 - rounding + startY: root.parent.height + 0.5 + strokeWidth: -1 + fillColor: Config.colors.bg + + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: -(root.wrapper.height - root.roundingY * 2) + } + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + PathLine { + relativeX: root.wrapper.width - root.rounding * 2 + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.roundingY * 2 + } + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + } + +} diff --git a/modules/launcher/Content.qml b/modules/launcher/Content.qml new file mode 100644 index 0000000..76a906b --- /dev/null +++ b/modules/launcher/Content.qml @@ -0,0 +1,167 @@ +pragma ComponentBehavior: Bound + +import "services" +import qs.services +import qs.config +import qs.custom +import Quickshell +import QtQuick + +Item { + id: root + + required property PersistentProperties uiState + required property var wrapper + required property var panels + + readonly property color color: list.color + readonly property int padding: 15 + readonly property int rounding: 25 + + implicitWidth: listWrapper.width + padding * 2 + implicitHeight: searchWrapper.height + listWrapper.height + padding * 2 + + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + + Item { + id: listWrapper + + implicitWidth: list.width + implicitHeight: list.height + root.padding + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: searchWrapper.top + anchors.bottomMargin: root.padding + + ContentList { + id: list + + uiState: root.uiState + wrapper: root.wrapper + panels: root.panels + search: search + padding: root.padding + rounding: root.rounding + } + } + + CustomRect { + id: searchWrapper + + color: Config.colors.container + radius: 1000 + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: root.padding + + implicitHeight: Math.max(searchIcon.implicitHeight, search.implicitHeight, clearIcon.implicitHeight) + + MaterialIcon { + id: searchIcon + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: root.padding + + text: "search" + color: Config.colors.tertiary + } + + CustomTextField { + id: search + + anchors.left: searchIcon.right + anchors.right: clearBtn.left + anchors.leftMargin: 7 + anchors.rightMargin: 7 + + topPadding: 12 + bottomPadding: 12 + + placeholderText: qsTr("Type \"%1\" for commands").arg(Config.launcher.actionPrefix) + + onAccepted: { + const currentItem = list.list?.currentItem; + if (currentItem) { + if (text.startsWith(Config.launcher.actionPrefix)) { + currentItem.modelData.onClicked(root.uiState); + } else { + Apps.launch(currentItem.modelData); + root.uiState.launcher = false; + } + } + } + + Keys.onUpPressed: list.list?.decrementCurrentIndex() + Keys.onDownPressed: list.list?.incrementCurrentIndex() + + Keys.onPressed: event => { + if (event.modifiers & Qt.ControlModifier) { + if (event.key === Qt.Key_J) { + list.list?.incrementCurrentIndex(); + event.accepted = true; + } else if (event.key === Qt.Key_K) { + list.list?.decrementCurrentIndex(); + event.accepted = true; + } + } else if (event.key === Qt.Key_Tab) { + list.list?.incrementCurrentIndex(); + event.accepted = true; + } else if (event.key === Qt.Key_Backtab || (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) { + list.list?.decrementCurrentIndex(); + event.accepted = true; + } + } + + Connections { + target: root.uiState + + function onLauncherChanged(): void { + if (root.uiState.launcher) + search.focus = true; + else { + search.text = ""; + list.list.currentIndex = 0; + } + } + } + } + + StateLayer { + id: clearBtn + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: root.padding + + implicitWidth: 24 + implicitHeight: 24 + + disabled: search.text === "" + onClicked: search.text = "" + opacity: disabled ? 0 : 1 + + MaterialIcon { + id: clearIcon + + anchors.centerIn: parent + + width: search.text ? implicitWidth : implicitWidth / 2 + + text: "close" + color: Config.colors.tertiary + + Behavior on width { + Anim { duration: Config.anim.durations.small } + } + } + + Behavior on opacity { + Anim { duration: Config.anim.durations.small } + } + } + } +} diff --git a/modules/launcher/ContentList.qml b/modules/launcher/ContentList.qml new file mode 100644 index 0000000..9da63fb --- /dev/null +++ b/modules/launcher/ContentList.qml @@ -0,0 +1,293 @@ +pragma ComponentBehavior: Bound + +import "items" +import "services" +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import QtQuick +import QtQuick.Controls + +Item { + id: root + + required property PersistentProperties uiState + required property var wrapper + required property var panels + required property TextField search + required property int padding + required property int rounding + + readonly property Item list: list + readonly property color color: list.color + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + implicitWidth: Config.launcher.itemWidth + implicitHeight: list.implicitHeight > 0 ? list.implicitHeight : empty.implicitHeight + + clip: true + + CustomListView { + id: list + + model: ScriptModel { + id: model + + onValuesChanged: list.currentIndex = 0 + } + + anchors.left: parent.left + anchors.right: parent.right + + spacing: 7 + orientation: Qt.Vertical + implicitHeight: (Config.launcher.itemHeight + spacing) * Math.min(Config.launcher.maxItemCount, count) - spacing + + highlightMoveDuration: Config.anim.durations.normal + highlightResizeDuration: 0 + keyNavigationWraps: true + + property color color + highlight: CustomRect { + radius: 1000 + color: list.color + opacity: 0.1 + } + + state: { + const text = root.search.text; + const prefix = Config.launcher.actionPrefix; + if (text.startsWith(prefix)) { + return "actions"; + } + + return "apps"; + } + + states: [ + State { + name: "apps" + + PropertyChanges { + model.values: Apps.search(root.search.text) + list.delegate: appItem + list.color: Config.colors.launcherApps + } + }, + State { + name: "actions" + + PropertyChanges { + model.values: Actions.query(root.search.text) + list.delegate: actionItem + list.color: Config.colors.launcherActions + } + } + ] + + // Disable animations before transition starts + onStateChanged: { + // NOTE: uiState check is necessary because this handler runs on startup + if (root.uiState.launcher) { + list.add.enabled = false; + list.remove.enabled = false; + } + } + + transitions: Transition { + SequentialAnimation { + ParallelAnimation { + Anim { + target: list + property: "opacity" + from: 1 + to: 0 + duration: Config.anim.durations.small + easing.bezierCurve: Config.anim.curves.standardAccel + } + Anim { + target: list + property: "scale" + from: 1 + to: 0.9 + duration: Config.anim.durations.small + easing.bezierCurve: Config.anim.curves.standardAccel + } + } + PropertyAction { + targets: [model, list] + properties: "values,delegate,color" + } + ParallelAnimation { + Anim { + target: list + property: "opacity" + from: 0 + to: 1 + duration: Config.anim.durations.small + easing.bezierCurve: Config.anim.curves.standardDecel + } + Anim { + target: list + property: "scale" + from: 0.9 + to: 1 + duration: Config.anim.durations.small + easing.bezierCurve: Config.anim.curves.standardDecel + } + } + PropertyAction { + targets: [list.add, list.remove] + property: "enabled" + value: true + } + } + } + + CustomScrollBar.vertical: CustomScrollBar { + flickable: list + } + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + } + Anim { + property: "scale" + from: 0.8 + to: 1 + } + } + + remove: Transition { + Anim { + property: "opacity" + from: 1 + to: 0 + } + Anim { + property: "scale" + from: 1 + to: 0.8 + } + } + + move: Transition { + Anim { + property: "y" + } + Anim { + properties: "opacity,scale" + to: 1 + } + } + + addDisplaced: Transition { + Anim { + property: "y" + duration: Config.anim.durations.small + } + Anim { + properties: "opacity,scale" + to: 1 + } + } + + displaced: Transition { + Anim { + property: "y" + } + Anim { + properties: "opacity,scale" + to: 1 + } + } + + Component { + id: appItem + + AppItem { + uiState: root.uiState + } + } + + Component { + id: actionItem + + ActionItem { + uiState: root.uiState + list: list + } + } + } + + Row { + id: empty + + opacity: root.list?.count === 0 ? 1 : 0 + scale: root.list?.count === 0 ? 1 : 0.5 + + spacing: 12 + padding: 15 + + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + + MaterialIcon { + text: "manage_search" + color: Config.colors.tertiary + font.pointSize: Config.font.size.largest + + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + + CustomText { + text: qsTr("No results") + color: Config.colors.tertiary + font.pointSize: Config.font.size.larger + font.weight: 500 + } + + CustomText { + text: qsTr("Try searching for something else") + color: Config.colors.tertiary + font.pointSize: Config.font.size.normal + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + } + + Behavior on implicitWidth { + enabled: root.uiState.launcher + + Anim { + duration: Config.anim.durations.large + easing.bezierCurve: Config.anim.curves.emphasizedDecel + } + } + + Behavior on implicitHeight { + enabled: root.uiState.launcher + + Anim { + duration: Config.anim.durations.large + easing.bezierCurve: Config.anim.curves.emphasizedDecel + } + } +} diff --git a/modules/launcher/Wrapper.qml b/modules/launcher/Wrapper.qml new file mode 100644 index 0000000..7628894 --- /dev/null +++ b/modules/launcher/Wrapper.qml @@ -0,0 +1,75 @@ +import qs.config +import qs.custom +import Quickshell +import Quickshell.Hyprland +import QtQuick + +Item { + id: root + + required property PersistentProperties uiState + required property Item panels + + visible: height > 0 + implicitHeight: 0 + implicitWidth: content.implicitWidth + + states: State { + name: "visible" + when: root.uiState.launcher + + PropertyChanges { + root.implicitHeight: content.implicitHeight + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitHeight" + duration: Config.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitHeight" + duration: Config.anim.durations.normal + easing.bezierCurve: Config.anim.curves.emphasized + } + } + ] + + HyprlandFocusGrab { + active: root.uiState.launcher + windows: [QsWindow.window] + onCleared: root.uiState.launcher = false + } + + Background { + id: background + visible: false + wrapper: root + } + + GlowEffect { + source: background + glowColor: content.color + } + + Content { + id: content + + uiState: root.uiState + wrapper: root + panels: root.panels + } +} diff --git a/modules/launcher/items/ActionItem.qml b/modules/launcher/items/ActionItem.qml new file mode 100644 index 0000000..82a4b07 --- /dev/null +++ b/modules/launcher/items/ActionItem.qml @@ -0,0 +1,80 @@ +import "../services" +import qs.config +import qs.custom +import qs.services +import qs.util +import QtQuick +import Quickshell + +Item { + id: root + + required property Actions.Action modelData + required property PersistentProperties uiState + required property var list + + implicitHeight: Config.launcher.itemHeight + + anchors.left: parent?.left + anchors.right: parent?.right + + StateLayer { + radius: 1000 + anchors.fill: parent + + function onClicked(): void { + root.uiState.launcher = false; + root.modelData?.onClicked(root.uiState); + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.margins: 7 + + MaterialIcon { + id: icon + + text: root.modelData?.icon ?? "" + font.pointSize: Config.font.size.largest + + anchors.verticalCenter: parent.verticalCenter + } + + Item { + anchors.left: icon.right + anchors.leftMargin: 12 + anchors.verticalCenter: icon.verticalCenter + + implicitWidth: parent.width - icon.width + implicitHeight: name.implicitHeight + desc.implicitHeight + + CustomText { + id: name + + text: root.modelData?.name ?? "" + font.pointSize: Config.font.size.normal + color: root.ListView.isCurrentItem ? Color.mute(Config.colors.launcherActions, 1.1) : Config.colors.primary + + Behavior on color { + CAnim {} + } + } + + CustomText { + id: desc + + text: root.modelData?.desc ?? "" + font.pointSize: Config.font.size.small + color: Config.colors.tertiary + + elide: Text.ElideRight + width: root.width - icon.width - 34 + + anchors.top: name.bottom + } + } + } +} diff --git a/modules/launcher/items/AppItem.qml b/modules/launcher/items/AppItem.qml new file mode 100644 index 0000000..dea01c2 --- /dev/null +++ b/modules/launcher/items/AppItem.qml @@ -0,0 +1,80 @@ +import "../services" +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import Quickshell.Widgets +import QtQuick + +Item { + id: root + + required property DesktopEntry modelData + required property PersistentProperties uiState + + implicitHeight: Config.launcher.itemHeight + + anchors.left: parent?.left + anchors.right: parent?.right + + StateLayer { + radius: 1000 + anchors.fill: parent + + function onClicked(): void { + Apps.launch(root.modelData); + root.uiState.launcher = false; + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.margins: 7 + + IconImage { + id: icon + + source: Quickshell.iconPath(root.modelData?.icon, "image-missing") + implicitSize: parent.height * 0.9 + + anchors.verticalCenter: parent.verticalCenter + } + + Item { + anchors.left: icon.right + anchors.leftMargin: 12 + anchors.verticalCenter: icon.verticalCenter + + implicitWidth: parent.width - icon.width + implicitHeight: name.implicitHeight + comment.implicitHeight + + CustomText { + id: name + + text: root.modelData?.name ?? "" + font.pointSize: Config.font.size.normal + color: root.ListView.isCurrentItem ? Color.mute(Config.colors.launcherApps) : Config.colors.primary + + Behavior on color { + CAnim {} + } + } + + CustomText { + id: comment + + text: (root.modelData?.comment || root.modelData?.genericName || root.modelData?.name) ?? "" + font.pointSize: Config.font.size.small + color: Config.colors.tertiary + + elide: Text.ElideRight + width: root.width - icon.width - 34 + + anchors.top: name.bottom + } + } + } +} diff --git a/modules/launcher/services/Actions.qml b/modules/launcher/services/Actions.qml new file mode 100644 index 0000000..29af49a --- /dev/null +++ b/modules/launcher/services/Actions.qml @@ -0,0 +1,86 @@ +pragma Singleton + +import ".." +import qs.config +import qs.services +import qs.util +import Quickshell +import QtQuick + +Searcher { + id: root + + readonly property list actions: [ + Action { + name: qsTr("Shutdown") + desc: qsTr("Shutdown the system") + icon: "power_settings_new" + + function onClicked(uiState: PersistentProperties): void { + Quickshell.execDetached(Config.session.shutdown); + } + }, + Action { + name: qsTr("Reboot") + desc: qsTr("Reboot the system") + icon: "cached" + + function onClicked(uiState: PersistentProperties): void { + Quickshell.execDetached(Config.session.reboot); + } + }, + Action { + name: qsTr("Logout") + desc: qsTr("Log out of the session and go back to the startup screen") + icon: "logout" + + function onClicked(uiState: PersistentProperties): void { + Quickshell.execDetached(Config.session.logout); + } + }, + Action { + name: qsTr("Lock") + desc: qsTr("Activate the lock screen (hyprlock)") + icon: "lock" + + function onClicked(uiState: PersistentProperties): void { + Quickshell.execDetached(Config.session.lock); + } + }, + Action { + name: qsTr("Suspend") + desc: qsTr("Suspend the system") + icon: "bedtime" + + function onClicked(uiState: PersistentProperties): void { + Quickshell.execDetached(Config.session.suspend); + } + }, + Action { + name: qsTr("Hibernate") + desc: qsTr("Suspend, then hibernate the system") + icon: "downloading" + + function onClicked(uiState: PersistentProperties): void { + Quickshell.execDetached(Config.session.hibernate); + } + } + ] + + function transformSearch(search: string): string { + return search.slice(Config.launcher.actionPrefix.length); + } + + list: actions.filter(a => !a.disabled) + useFuzzy: true + + component Action: QtObject { + required property string name + required property string desc + required property string icon + property bool disabled + + function onClicked(uiState: PersistentProperties): void { + } + } +} diff --git a/modules/launcher/services/Apps.qml b/modules/launcher/services/Apps.qml new file mode 100644 index 0000000..064de30 --- /dev/null +++ b/modules/launcher/services/Apps.qml @@ -0,0 +1,86 @@ +pragma Singleton + +import qs.config +import qs.util +import Quickshell +import QtQuick + +Searcher { + id: root + + function launch(entry: DesktopEntry): void { + if (entry.runInTerminal) + Quickshell.execDetached({ + command: [...Config.terminalCommand, ...entry.command], + workingDirectory: entry.workingDirectory + }); + else + Quickshell.execDetached({ + command: entry.command, + workingDirectory: entry.workingDirectory + }); + } + + function search(search: string): list { + const prefix = Config.launcher.specialPrefix; + + if (search.startsWith(`${prefix}i `)) { + keys = ["id", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}c `)) { + keys = ["categories", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}d `)) { + keys = ["desc", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}e `)) { + keys = ["execString", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}w `)) { + keys = ["wmClass", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}g `)) { + keys = ["genericName", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}k `)) { + keys = ["keywords", "name"]; + weights = [0.9, 0.1]; + } else { + keys = ["name"]; + weights = [1]; + + if (!search.startsWith(`${prefix}t `)) + return query(search).map(e => e.modelData); + } + + const results = query(search.slice(prefix.length + 2)).map(e => e.modelData); + if (search.startsWith(`${prefix}t `)) + return results.filter(a => a.runInTerminal); + return results; + } + + function selector(item: var): string { + return keys.map(k => item[k]).join(" "); + } + + list: variants.instances + useFuzzy: true + + Variants { + id: variants + + model: [...DesktopEntries.applications.values].sort((a, b) => a.name.localeCompare(b.name)) + + QtObject { + required property DesktopEntry modelData + readonly property string id: modelData.id + readonly property string name: modelData.name + readonly property string desc: modelData.comment + readonly property string execString: modelData.execString + readonly property string wmClass: modelData.startupClass + readonly property string genericName: modelData.genericName + readonly property string categories: modelData.categories.join(" ") + readonly property string keywords: modelData.keywords.join(" ") + } + } +} diff --git a/modules/notifications/Background.qml b/modules/notifications/Background.qml new file mode 100644 index 0000000..fe27fa9 --- /dev/null +++ b/modules/notifications/Background.qml @@ -0,0 +1,61 @@ +import qs.config +import qs.custom +import qs.services +import Quickshell +import QtQuick +import QtQuick.Shapes + +Shape { + id: root + + required property Item wrapper + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + property real fullHeightRounding: wrapper.height >= QsWindow.window?.height - Config.border.thickness * 2 ? -rounding : rounding + + ShapePath { + startX: root.wrapper.width + 0.5 + startY: root.wrapper.height + 0.5 + strokeWidth: -1 + fillColor: Config.colors.bg + + PathLine { + relativeX: -(root.wrapper.width + root.rounding) + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: -root.wrapper.height + root.roundingY * 2 + } + PathArc { + relativeX: root.fullHeightRounding + relativeY: -root.roundingY + radiusX: Math.abs(root.fullHeightRounding) + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: root.fullHeightRounding < 0 ? PathArc.Counterclockwise : PathArc.Clockwise + } + PathLine { + relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.fullHeightRounding : root.wrapper.width + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: -root.rounding + radiusX: root.rounding + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + } + + Behavior on fullHeightRounding { + Anim {} + } +} diff --git a/modules/notifications/Content.qml b/modules/notifications/Content.qml new file mode 100644 index 0000000..73f3b1f --- /dev/null +++ b/modules/notifications/Content.qml @@ -0,0 +1,195 @@ +import qs.config +import qs.custom +import qs.services +import Quickshell +import Quickshell.Widgets +import QtQuick + +Item { + id: root + + required property PersistentProperties uiState + required property Item panels + readonly property int padding: 15 + + readonly property var monitor: Hypr.monitorFor(QsWindow.window.screen) + readonly property var notifs: Notifs.list.filter(n => n.popup === monitor) + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: implicitHeight > 0 ? Config.notifs.width + padding * 2 : 0 + implicitHeight: { + const count = list.count; + if (count === 0) + return 0; + + let height = (count - 1) * 10 + padding * 2; + for (let i = 0; i < count; i++) + height += list.itemAtIndex(i)?.nonAnimHeight ?? 0; + + if (uiState.osd) { + const h = panels.osd.y - Config.border.rounding * 2; + if (height > h) + height = h; + } + + return Math.min((QsWindow.window?.screen?.height ?? 0) - Config.bar.height - Config.border.thickness - padding, height); + } + + ClippingWrapperRectangle { + anchors.fill: parent + anchors.margins: root.padding + + color: "transparent" + radius: 17 + + CustomListView { + id: list + + model: ScriptModel { + values: root.notifs + } + + anchors.fill: parent + + orientation: Qt.Vertical + spacing: 0 + cacheBuffer: QsWindow.window?.screen.height ?? 0 + + delegate: Item { + id: wrapper + + required property Notifs.Notif modelData + required property int index + readonly property alias nonAnimHeight: notif.nonAnimHeight + property int idx + + onIndexChanged: { + if (index !== -1) + idx = index; + } + + implicitWidth: notif.implicitWidth + implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : 10) + + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: true + } + PropertyAction { + target: wrapper + property: "enabled" + value: false + } + PropertyAction { + target: wrapper + property: "implicitHeight" + value: 0 + } + PropertyAction { + target: wrapper + property: "z" + value: 1 + } + Anim { + target: notif + property: "x" + to: (notif.x >= 0 ? Config.notifs.width : -Config.notifs.width) * 2 + easing.bezierCurve: Config.anim.curves.emphasized + } + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: false + } + } + + ClippingRectangle { + anchors.top: parent.top + anchors.topMargin: wrapper.idx === 0 ? 0 : 10 + + color: "transparent" + radius: notif.radius + implicitWidth: notif.implicitWidth + implicitHeight: notif.implicitHeight + + Notification { + id: notif + notif: wrapper.modelData + } + } + } + + move: Transition { + NotifAnim { + property: "y" + } + } + + displaced: Transition { + NotifAnim { + property: "y" + } + } + + ExtraIndicator { + anchors.top: parent.top + extra: { + const count = list.count; + if (count === 0) + return 0; + + const scrollY = list.contentY; + + let height = 0; + for (let i = 0; i < count; i++) { + height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + 10; + + if (height - 10 >= scrollY) + return i; + } + + return count; + } + } + + ExtraIndicator { + anchors.bottom: parent.bottom + extra: { + const count = list.count; + if (count === 0) + return 0; + + const scrollY = list.contentHeight - (list.contentY + list.height); + + let height = 0; + for (let i = count - 1; i >= 0; i--) { + height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + 10; + + if (height - 10 >= scrollY) + return count - i - 1; + } + + return 0; + } + } + } + } + + Behavior on implicitHeight { + NotifAnim {} + } + + component NotifAnim: Anim { + duration: Config.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } +} diff --git a/modules/notifications/Notification.qml b/modules/notifications/Notification.qml new file mode 100644 index 0000000..dd9dc91 --- /dev/null +++ b/modules/notifications/Notification.qml @@ -0,0 +1,599 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes + +CustomRect { + id: root + + required property Notifs.Notif notif + readonly property bool hasImage: notif.image.length > 0 + readonly property bool hasAppIcon: notif.appIcon.length > 0 + readonly property int nonAnimHeight: inner.nonAnimHeight + inner.anchors.margins * 2 + + property bool inPopup: true + property bool expanded: false + + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.errorBg : Config.colors.container + radius: 12 + implicitWidth: Config.notifs.width + implicitHeight: inner.implicitHeight + inner.anchors.margins * 2 + + onExpandedChanged: { + if (root.inPopup && expanded) + root.notif.timer.stop(); + } + + x: inPopup ? Config.notifs.width : 0 + Component.onCompleted: x = 0 + + Behavior on x { + Anim { + easing.bezierCurve: Config.anim.curves.emphasizedDecel + } + } + + RetainableLock { + object: root.notif.notification + locked: true + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: root.expanded && body.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.AllButtons + preventStealing: true + + onEntered: { + if (root.inPopup) + root.notif.timer.stop(); + } + onExited: { + if (root.inPopup && !pressed && !root.expanded) + root.notif.timer.start(); + } + + drag.target: parent + drag.axis: Drag.XAxis + + onPressed: event => { + if (event.button === Qt.MiddleButton) + root.notif.notification.dismiss(); + } + onReleased: event => { + if (root.inPopup && !containsMouse && !root.expanded) + root.notif.timer.start(); + + if (Math.abs(root.x) < Config.notifs.width * Config.notifs.clearThreshold) + root.x = 0; + else if (root.inPopup) + root.notif.popup = null; + else + root.notif.notification.dismiss(); + } + onClicked: event => { + if (event.button === Qt.LeftButton) { + const actions = root.notif.actions; + if (actions?.length === 1) + actions[0].invoke(); + else + root.expanded = !root.expanded; + } else if (event.button === Qt.RightButton) { + root.expanded = !root.expanded; + } + } + + Item { + id: inner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + + readonly property real nonAnimHeight: root.expanded ? summary.height + appName.height + body.height + actions.height + 16 + : Config.notifs.imageSize + implicitHeight: nonAnimHeight + + Behavior on implicitHeight { + Anim { + duration: Config.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } + } + + Loader { + id: image + + active: root.hasImage + asynchronous: true + + anchors.left: parent.left + anchors.top: parent.top + width: Config.notifs.imageSize + height: Config.notifs.imageSize + visible: root.hasImage || root.hasAppIcon + + sourceComponent: ClippingRectangle { + radius: 1000 + implicitWidth: Config.notifs.imageSize + implicitHeight: Config.notifs.imageSize + + Image { + anchors.fill: parent + source: Qt.resolvedUrl(root.notif.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + } + } + } + + Loader { + id: appIcon + + active: root.hasAppIcon || !root.hasImage + asynchronous: true + + anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter + anchors.verticalCenter: root.hasImage ? undefined : image.verticalCenter + anchors.right: root.hasImage ? image.right : undefined + anchors.bottom: root.hasImage ? image.bottom : undefined + + sourceComponent: CustomRect { + radius: 1000 + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.error + : root.notif.urgency === NotificationUrgency.Low ? Config.colors.inactive + : Config.colors.notification + implicitWidth: root.hasImage ? Config.notifs.badgeSize : Config.notifs.imageSize + implicitHeight: root.hasImage ? Config.notifs.badgeSize : Config.notifs.imageSize + + Loader { + id: icon + + active: root.hasAppIcon + asynchronous: true + + anchors.centerIn: parent + + width: Math.round(parent.width * 0.6) + height: Math.round(parent.width * 0.6) + + sourceComponent: IconImage { + anchors.fill: parent + source: Quickshell.iconPath(root.notif.appIcon) + asynchronous: true + } + } + + Loader { + active: !root.hasAppIcon + asynchronous: true + anchors.centerIn: parent + + sourceComponent: MaterialIcon { + text: Icons.getNotifIcon(root.notif.summary, root.notif.urgency) + + color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.primary + : Config.colors.primaryDark + font.pointSize: Config.font.size.larger + } + } + } + } + + CustomText { + id: appName + + anchors.top: parent.top + anchors.left: image.right + anchors.leftMargin: 10 + + animate: true + text: appNameMetrics.elidedText + maximumLineCount: 1 + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primary : Config.colors.tertiary + font.pointSize: Config.font.size.small + + opacity: root.expanded ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + TextMetrics { + id: appNameMetrics + + text: root.notif.appName + font.family: appName.font.family + font.pointSize: appName.font.pointSize + elide: Text.ElideRight + elideWidth: closeBtn.x - timeDetail.width - time.width - timeSep.width - summaryPreview.x - 21 + } + + CustomText { + id: summaryPreview + + anchors.top: parent.top + anchors.left: image.right + anchors.leftMargin: 10 + anchors.topMargin: 2 + + animate: true + text: summaryPreviewMetrics.elidedText + color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.primary : Config.colors.secondary + + opacity: root.expanded ? 0 : 1 + visible: opacity > 0 + + states: State { + name: "expanded" + when: root.expanded + + AnchorChanges { + target: summaryPreview + anchors.top: appName.bottom + } + } + + transitions: Transition { + AnchorAnimation { + duration: Config.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Config.anim.curves.standard + } + } + + Behavior on opacity { + Anim {} + } + } + + TextMetrics { + id: summaryPreviewMetrics + + text: root.notif.summary + font.family: summaryPreview.font.family + font.pointSize: summaryPreview.font.pointSize + elide: Text.ElideRight + elideWidth: closeBtn.x - time.width - timeSep.width - summaryPreview.x - 21 + } + + CustomText { + id: summary + + anchors.top: parent.top + anchors.left: image.right + anchors.right: parent.right + anchors.leftMargin: 10 + anchors.topMargin: 5 + anchors.rightMargin: 10 + + animate: true + text: root.notif.summary + color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.primary : Config.colors.secondary + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + states: State { + name: "expanded" + when: root.expanded + + AnchorChanges { + target: summary + anchors.top: appName.bottom + } + } + + transitions: Transition { + AnchorAnimation { + duration: Config.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Config.anim.curves.standard + } + } + + opacity: root.expanded ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + CustomText { + id: timeSep + + anchors.top: parent.top + anchors.left: summaryPreview.right + anchors.leftMargin: 7 + anchors.topMargin: root.expanded ? 0 : 3 + + text: "•" + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primary : Config.colors.tertiary + font.pointSize: Config.font.size.small + + states: State { + name: "expanded" + when: root.expanded + + AnchorChanges { + target: timeSep + anchors.left: appName.right + } + } + + transitions: Transition { + AnchorAnimation { + duration: Config.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Config.anim.curves.standard + } + } + } + + CustomText { + id: time + + anchors.top: parent.top + anchors.left: timeSep.right + anchors.leftMargin: 7 + anchors.topMargin: root.expanded ? 0 : 2 + + animate: true + horizontalAlignment: Text.AlignLeft + text: root.notif.timeSince + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primary : Config.colors.tertiary + font.pointSize: Config.font.size.small + } + + CustomText { + id: timeDetail + + anchors.verticalCenter: time.verticalCenter + anchors.left: time.right + anchors.leftMargin: 5 + + horizontalAlignment: Text.AlignLeft + text: `(${root.notif.timeStr})` + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primary : Config.colors.tertiary + font.pointSize: Config.font.size.small + + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + StateLayer { + id: closeBtn + + anchors.right: expandBtn.left + anchors.top: parent.top + + implicitWidth: 20 + implicitHeight: 20 + + function onClicked() { + root.notif.notification.dismiss(); + } + + MaterialIcon { + id: closeIcon + + anchors.centerIn: parent + + animate: true + text: "close" + font.pointSize: Config.font.size.normal + } + } + + StateLayer { + id: expandBtn + + anchors.right: parent.right + anchors.top: parent.top + + implicitWidth: 20 + implicitHeight: 20 + + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.secondary : Config.colors.primary + radius: 1000 + + function onClicked() { + root.expanded = !root.expanded + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + anchors.verticalCenterOffset: text === "expand_more" ? 1 : 0 + + animate: true + text: root.expanded ? "expand_less" : "expand_more" + font.pointSize: Config.font.size.normal + } + } + + CustomText { + id: bodyPreview + + anchors.left: summaryPreview.left + anchors.right: parent.right + anchors.top: summaryPreview.bottom + anchors.topMargin: 3 + anchors.rightMargin: 7 + + animate: true + textFormat: Text.MarkdownText + text: bodyPreviewMetrics.elidedText + color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.tertiary : Config.colors.primary + font.pointSize: Config.font.size.small + + opacity: root.expanded ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + TextMetrics { + id: bodyPreviewMetrics + + text: root.notif.bodyOneLine + font.family: bodyPreview.font.family + font.pointSize: bodyPreview.font.pointSize + elide: Text.ElideRight + elideWidth: bodyPreview.width + } + + CustomText { + id: body + + anchors.left: summary.left + anchors.right: parent.right + anchors.top: summary.bottom + anchors.rightMargin: 7 + anchors.topMargin: 3 + + animate: true + textFormat: Text.MarkdownText + text: root.notif.body + color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.tertiary : Config.colors.primary + font.pointSize: Config.font.size.small + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + height: text ? implicitHeight : 0 + + onLinkActivated: link => { + if (!root.expanded) + return; + + Quickshell.execDetached(["xdg-open", link]); + root.notif.notification.dismiss(); // TODO: change back to popup when notif dock impled + } + + opacity: root.expanded ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + RowLayout { + id: actions + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: body.bottom + anchors.topMargin: 10 + + spacing: 10 + + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + + Action { + modelData: QtObject { + readonly property string text: qsTr("Close") + function invoke(): void { + root.notif.notification.dismiss(); + } + } + } + + Repeater { + model: root.notif.actions + + delegate: Component { + Action {} + } + } + } + } + } + + CustomRect { + id: progressBar + + visible: root.inPopup + anchors.bottom: parent.bottom + height: 2 + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.error : Config.colors.inactive + opacity: root.notif.timer.running ? 1 : 0 + + NumberAnimation on implicitWidth { + from: root.width + to: 0 + duration: Config.notifs.defaultExpireTimeout + running: root.notif.timer.running + } + + Behavior on opacity { + Anim { duration: Config.anim.durations.small } + } + } + + component Action: CustomRect { + id: action + + required property var modelData + + radius: 1000 + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.error : Config.colors.inactive + + implicitWidth: actionText.width + 20 + implicitHeight: actionText.height + 10 + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: implicitHeight + + StateLayer { + anchors.fill: parent + radius: 1000 + color: root.notif.urgency === NotificationUrgency.Critical ? "#ffffff" : Config.colors.primary + + function onClicked(): void { + action.modelData.invoke(); + } + } + + CustomText { + id: actionText + + anchors.centerIn: parent + text: actionTextMetrics.elidedText + color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primaryDark + : root.notif.urgency === NotificationUrgency.Low ? Config.colors.primary : Config.colors.secondary + font.pointSize: Config.font.size.small + } + + TextMetrics { + id: actionTextMetrics + + text: action.modelData.text + font.family: actionText.font.family + font.pointSize: actionText.font.pointSize + elide: Text.ElideRight + elideWidth: { + const numActions = root.notif.actions.length + 1; + return (inner.width - actions.spacing * (numActions - 1)) / numActions - 20; + } + } + } +} diff --git a/modules/notifications/Wrapper.qml b/modules/notifications/Wrapper.qml new file mode 100644 index 0000000..b58c515 --- /dev/null +++ b/modules/notifications/Wrapper.qml @@ -0,0 +1,34 @@ +import qs.config +import qs.custom +import Quickshell +import Quickshell.Services.Notifications +import QtQuick + +Item { + id: root + + required property PersistentProperties uiState + required property Item panels + + implicitHeight: content.implicitHeight + implicitWidth: content.implicitWidth + + Background { + id: background + visible: false + wrapper: root + } + + GlowEffect { + source: background + glowColor: content.notifs.find(n => n.urgency === NotificationUrgency.Critical) ? + Config.colors.error : Config.colors.notification + } + + Content { + id: content + + uiState: root.uiState + panels: root.panels + } +} diff --git a/modules/osd/Background.qml b/modules/osd/Background.qml new file mode 100644 index 0000000..3fd99fb --- /dev/null +++ b/modules/osd/Background.qml @@ -0,0 +1,59 @@ +import qs.config +import qs.services +import QtQuick +import QtQuick.Shapes + +Shape { + id: root + + required property Item wrapper + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.width < rounding * 2 + readonly property real roundingX: flatten ? wrapper.width / 2 : rounding + + ShapePath { + startX: root.wrapper.width + 0.5 + startY: -root.rounding + strokeWidth: -1 + fillColor: Config.colors.bg + + PathArc { + relativeX: -root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + } + PathLine { + relativeX: -(root.wrapper.width - root.roundingX * 2) + relativeY: 0 + } + PathArc { + relativeX: -root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.rounding * 2 + } + PathArc { + relativeX: root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: root.wrapper.width - root.roundingX * 2 + relativeY: 0 + } + PathArc { + relativeX: root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + } + } +} diff --git a/modules/osd/Content.qml b/modules/osd/Content.qml new file mode 100644 index 0000000..3495b96 --- /dev/null +++ b/modules/osd/Content.qml @@ -0,0 +1,161 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property var uiState + required property Brightness.Monitor monitor + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + + implicitWidth: layout.implicitWidth + 30 + implicitHeight: layout.implicitHeight + sunset.height + 40 + + RowLayout { + id: layout + + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: 15 + spacing: 10 + + // Speaker volume + CustomMouseArea { + implicitWidth: Config.osd.sliderWidth + implicitHeight: Config.osd.sliderLength + + function onWheel(event: WheelEvent) { + if (event.angleDelta.y > 0) + Audio.increaseVolume(); + else if (event.angleDelta.y < 0) + Audio.decreaseVolume(); + } + + acceptedButtons: Qt.RightButton + onClicked: Audio.sink.audio.muted = !Audio.muted + + CustomFilledSlider { + anchors.fill: parent + + color: Audio.muted ? Config.colors.error : Config.colors.volume + icon: Icons.getVolumeIcon(value, Audio.muted) + value: Audio.volume + onMoved: Audio.setVolume(value) + + Behavior on color { + CAnim { + duration: Config.anim.durations.small + } + } + } + } + + // Microphone + CustomMouseArea { + implicitWidth: Config.osd.sliderWidth + implicitHeight: Config.osd.sliderLength + + function onWheel(event: WheelEvent) { + if (event.angleDelta.y > 0) + Audio.incrementSourceVolume(); + else if (event.angleDelta.y < 0) + Audio.decrementSourceVolume(); + } + + acceptedButtons: Qt.RightButton + onClicked: Audio.source.audio.muted = !Audio.sourceMuted + + CustomFilledSlider { + anchors.fill: parent + + color: Audio.sourceMuted ? Config.colors.error : Config.colors.mic + icon: Icons.getMicVolumeIcon(value, Audio.sourceMuted) + value: Audio.sourceVolume + onMoved: Audio.setSourceVolume(value) + + Behavior on color { + CAnim { + duration: Config.anim.durations.small + } + } + } + } + + // Brightness + CustomMouseArea { + implicitWidth: Config.osd.sliderWidth + implicitHeight: Config.osd.sliderLength + + function onWheel(event: WheelEvent) { + const monitor = root.monitor; + if (!monitor) + return; + if (event.angleDelta.y > 0) + monitor.setBrightness(monitor.brightness + 0.1); + else if (event.angleDelta.y < 0) + monitor.setBrightness(monitor.brightness - 0.1); + } + + CustomFilledSlider { + anchors.fill: parent + + color: Config.colors.brightness + icon: Icons.getBrightnessIcon(value) + value: root.monitor?.brightness ?? 0 + onMoved: root.monitor?.setBrightness(value) + } + } + } + + CustomRect { + id: sunset + + anchors.top: layout.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: 10 + + width: layout.width + height: 40 + + color: Hyprsunset.active ? Config.colors.brightness : Config.colors.container + radius: 7 + + Behavior on color { + CAnim { duration: Config.anim.durations.small } + } + + StateLayer { + anchors.fill: parent + radius: parent.radius + color: Config.colors.secondary + + function onClicked() { + if (Hyprsunset.active) + Hyprsunset.disable(); + else + Hyprsunset.enable(); + } + } + + MaterialIcon { + anchors.centerIn: parent + + text: "dark_mode" + font.pointSize: Config.font.size.large + color: Hyprsunset.active ? Config.colors.primaryDark : Config.colors.secondary + fill: Hyprsunset.active ? 1 : 0 + + Behavior on color { + CAnim { duration: Config.anim.durations.small } + } + } + } +} diff --git a/modules/osd/Interactions.qml b/modules/osd/Interactions.qml new file mode 100644 index 0000000..3036a35 --- /dev/null +++ b/modules/osd/Interactions.qml @@ -0,0 +1,54 @@ +import qs.services +import qs.config +import Quickshell +import QtQuick + +Scope { + id: root + + required property PersistentProperties uiState + required property ShellScreen screen + required property bool hovered + required property bool suppressed + readonly property Brightness.Monitor monitor: Brightness.getMonitorForScreen(screen) + + function show(): void { + if (!root.suppressed) { + root.uiState.osd = true; + timer.restart(); + } + } + + Connections { + target: Audio + + function onMutedChanged(): void { + if (root.uiState.osdVolumeReact) + root.show(); + } + + function onVolumeChanged(): void { + if (root.uiState.osdVolumeReact) + root.show(); + } + } + + Connections { + target: root.monitor + + function onBrightnessChanged(): void { + if (root.uiState.osdBrightnessReact) + root.show(); + } + } + + Timer { + id: timer + + interval: Config.osd.hideDelay + onTriggered: { + if (!root.hovered) + root.uiState.osd = false; + } + } +} diff --git a/modules/osd/Wrapper.qml b/modules/osd/Wrapper.qml new file mode 100644 index 0000000..109909c --- /dev/null +++ b/modules/osd/Wrapper.qml @@ -0,0 +1,66 @@ +import qs.config +import qs.custom +import qs.services +import Quickshell +import QtQuick + +Item { + id: root + + required property var uiState + required property ShellScreen screen + + visible: width > 0 + implicitWidth: 0 + implicitHeight: content.implicitHeight + + states: State { + name: "visible" + when: root.uiState.osd + + PropertyChanges { + root.implicitWidth: content.implicitWidth + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: Config.anim.curves.emphasized + } + } + ] + + Background { + id: background + visible: false + wrapper: root + } + + GlowEffect { + source: background + glowColor: Config.colors.osd + } + + Content { + id: content + + uiState: root.uiState + monitor: Brightness.getMonitorForScreen(root.screen) + } +} diff --git a/modules/session/Background.qml b/modules/session/Background.qml new file mode 100644 index 0000000..1049f32 --- /dev/null +++ b/modules/session/Background.qml @@ -0,0 +1,51 @@ +import qs.config +import QtQuick +import QtQuick.Shapes + +Shape { + id: root + + required property Item wrapper + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + + ShapePath { + startX: -root.rounding + 0.5 + startY: -0.5 + strokeWidth: -1 + fillColor: Config.colors.bg + + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.roundingY * 2 + } + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: root.wrapper.width - root.rounding * 2 + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: root.rounding + radiusX: root.rounding + radiusY: root.rounding + } + PathLine { + relativeX: 0 + relativeY: -root.wrapper.height - root.rounding + } + } +} diff --git a/modules/session/Content.qml b/modules/session/Content.qml new file mode 100644 index 0000000..00e9f47 --- /dev/null +++ b/modules/session/Content.qml @@ -0,0 +1,131 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import qs.services +import qs.util +import Quickshell +import QtQuick + +Row { + id: root + required property PersistentProperties uiState + + padding: 12 + spacing: 12 + + SessionButton { + id: sleep + + icon: "bedtime" + iconColor: Config.colors.violet + command: Config.session.sleep + + KeyNavigation.right: lock + + Component.onCompleted: forceActiveFocus() + } + + SessionButton { + id: lock + + icon: "lock" + iconColor: Config.colors.brown + command: Config.session.lock + + KeyNavigation.left: sleep + KeyNavigation.right: logout + } + + SessionButton { + id: logout + + icon: "logout" + iconColor: Config.colors.cyan + command: Config.session.logout + + KeyNavigation.left: lock + KeyNavigation.right: reboot + } + + SessionButton { + id: reboot + + icon: "cached" + iconColor: Config.colors.yellow + command: Config.session.reboot + + KeyNavigation.left: logout + KeyNavigation.right: shutdown + } + + SessionButton { + id: shutdown + + icon: "power_settings_new" + iconColor: Config.colors.red + command: Config.session.shutdown + + KeyNavigation.left: reboot + } + + component SessionButton: CustomRect { + id: button + + required property string icon + required property color iconColor + required property list command + + implicitWidth: Config.session.buttonSize + implicitHeight: Config.session.buttonSize + + radius: 22 + color: button.activeFocus ? Config.colors.containerAlt : Config.colors.container + + Behavior on color { + CAnim {} + } + + Keys.onEnterPressed: layer.onClicked() + Keys.onReturnPressed: layer.onClicked() + Keys.onPressed: event => { + if (event.modifiers & Qt.ControlModifier) { + if (event.key === Qt.Key_L && KeyNavigation.right) { + KeyNavigation.right.focus = true; + event.accepted = true; + } else if (event.key === Qt.Key_H && KeyNavigation.left) { + KeyNavigation.left.focus = true; + event.accepted = true; + } + } else if (event.key === Qt.Key_Tab && KeyNavigation.right) { + KeyNavigation.right.focus = true; + event.accepted = true; + } else if (event.key === Qt.Key_Backtab || (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) { + if (KeyNavigation.left) { + KeyNavigation.left.focus = true; + event.accepted = true; + } + } + } + + StateLayer { + id: layer + anchors.fill: parent + radius: parent.radius + + function onClicked(): void { + root.uiState.session = false; + Quickshell.execDetached(button.command); + } + } + + MaterialIcon { + anchors.centerIn: parent + + text: button.icon + color: button.iconColor + font.pointSize: Config.font.size.largest + font.weight: 500 + } + } +} diff --git a/modules/session/Wrapper.qml b/modules/session/Wrapper.qml new file mode 100644 index 0000000..003ec25 --- /dev/null +++ b/modules/session/Wrapper.qml @@ -0,0 +1,80 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import Quickshell +import QtQuick + +Item { + id: root + + required property PersistentProperties uiState + required property var panels + readonly property real nonAnimHeight: content.implicitHeight + + visible: height > 0 + implicitWidth: content.implicitWidth + implicitHeight: 0 + + states: State { + name: "visible" + when: root.uiState.session + + PropertyChanges { + root.implicitHeight: root.nonAnimHeight + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitHeight" + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitHeight" + easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial + } + } + ] + + Binding { + target: root.uiState + property: "blockScreen" + value: true + when: root.uiState.session + } + + Background { + id: background + visible: false + wrapper: root + } + + GlowEffect { + source: background + glowColor: Config.colors.power + } + + Loader { + id: content + + anchors.bottom: parent.bottom + + Component.onCompleted: active = Qt.binding(() => root.uiState.session || root.visible) + + sourceComponent: Content { + uiState: root.uiState + } + } +} diff --git a/modules/ui/Border.qml b/modules/ui/Border.qml new file mode 100644 index 0000000..8fced0d --- /dev/null +++ b/modules/ui/Border.qml @@ -0,0 +1,45 @@ + +import QtQuick +import QtQuick.Effects +import Quickshell +import qs.config +import qs.custom + +Item { + id: root + + anchors.fill: parent + + CustomRect { + id: rect + + anchors.fill: parent + color: Config.colors.bg + visible: false + } + + Item { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + anchors.fill: parent + anchors.margins: Config.border.thickness + anchors.topMargin: Config.bar.height + radius: Config.border.rounding + } + } + + MultiEffect { + anchors.fill: parent + maskEnabled: true + maskInverted: true + maskSource: mask + source: rect + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } +} diff --git a/modules/ui/Exclusion.qml b/modules/ui/Exclusion.qml new file mode 100644 index 0000000..8d6caef --- /dev/null +++ b/modules/ui/Exclusion.qml @@ -0,0 +1,36 @@ +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import Quickshell +import QtQuick + +Scope { + id: root + + required property ShellScreen screen + + ExclusionZone { + anchors.left: true + } + + ExclusionZone { + anchors.top: true + exclusiveZone: Config.bar.height + } + + ExclusionZone { + anchors.right: true + } + + ExclusionZone { + anchors.bottom: true + } + + component ExclusionZone: CustomWindow { + screen: root.screen + name: "border-exclusion" + exclusiveZone: Config.border.thickness + mask: Region {} + } +} diff --git a/modules/ui/Interactions.qml b/modules/ui/Interactions.qml new file mode 100644 index 0000000..980dae2 --- /dev/null +++ b/modules/ui/Interactions.qml @@ -0,0 +1,163 @@ +import qs.config +import qs.custom +import qs.services +import qs.modules.bar.popouts as BarPopouts +import qs.modules.osd as Osd +import Quickshell +import QtQuick + +CustomMouseArea { + id: root + + required property ShellScreen screen + required property PersistentProperties uiState + required property Panels panels + required property Item bar + + readonly property BarPopouts.Wrapper popouts: panels.popouts + + property bool osdHovered + property bool osdShortcutActive + property bool dashboardShortcutActive + + anchors.fill: parent + hoverEnabled: true + + function withinPanelWidth(panel: Item, x: real): bool { + const panelX = panel.x; + return x >= panelX - Config.border.rounding && x <= panelX + panel.width + Config.border.rounding; + } + + function withinPanelHeight(panel: Item, y: real): bool { + const panelY = panel.y; + return y >= panelY + Config.bar.height - Config.border.rounding + && y <= panelY + panel.height + Config.bar.height + Config.border.rounding; + } + + function inBottomPanel(panel: Item, x: real, y: real): bool { + return y > root.height - Config.border.thickness - panel.height - Config.border.rounding && withinPanelWidth(panel, x); + } + + function inLeftPanel(panel: Item, x: real, y: real): bool { + return x < Config.border.thickness + panel.x + panel.width && withinPanelHeight(panel, y); + } + + function inRightPanel(panel: Item, x: real, y: real): bool { + return x > Config.border.thickness + panel.x && withinPanelHeight(panel, y); + } + + // Handling Mouse Input + + property point dragStart + onPressed: event => dragStart = Qt.point(event.x, event.y) + + onPositionChanged: event => { + const x = event.x; + const y = event.y; + + // Show bar popouts on hover + if (y < Config.bar.height && !popoutsSuppressed && !popouts.persistent) { + bar.checkPopout(x); + } + + // Show osd on hover + const showOsd = inRightPanel(panels.osd, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!osdShortcutActive) { + uiState.osd = showOsd && !osdSuppressed; + osdHovered = showOsd && !osdSuppressed; + } else if (showOsd) { + // If hovering over OSD area while in shortcut mode, transition to hover control + osdShortcutActive = false; + osdHovered = true; + } + + // Show dashboard on hover + const showDashboard = !dashboardSuppressed && inLeftPanel(panels.dashboard, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!dashboardShortcutActive) { + uiState.dashboard = showDashboard; + } else if (showDashboard) { + // If hovering over dashboard area while in shortcut mode, transition to hover control + dashboardShortcutActive = false; + } + + // Show launcher on drag + if (pressed && inBottomPanel(panels.launcher, dragStart.x, dragStart.y) && withinPanelWidth(panels.launcher, x, y)) { + const dragY = y - dragStart.y; + if (dragY < -Config.launcher.dragThreshold && !launcherSuppressed) + uiState.launcher = true; + else if (dragY > Config.launcher.dragThreshold) + uiState.launcher = false; + } + } + + Connections { + target: root.uiState + + function onOsdChanged() { + if (root.uiState.osd) { + // OSD became visible, immediately check if this should be shortcut mode + const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); + if (!inOsdArea) { + root.osdShortcutActive = true; + } + } else { + // OSD hidden, clear shortcut flag + root.osdShortcutActive = false; + } + } + } + + Osd.Interactions { + uiState: root.uiState + screen: root.screen + hovered: root.osdHovered + suppressed: root.osdSuppressed + } + + onContainsMouseChanged: { + if (!containsMouse) { + if (!popouts.persistent) + popouts.hasCurrent = false; + + if(!osdShortcutActive) { + uiState.osd = false; + osdHovered = false; + } + + if (!dashboardShortcutActive) + uiState.dashboard = false; + } + } + + // Suppressing Panels + + property bool popoutsSuppressed: uiState.dashboard || uiState.session + property bool dashboardSuppressed: uiState.launcher + property bool launcherSuppressed: uiState.dashboard + property bool osdSuppressed: popouts.hasCurrent + + onPopoutsSuppressedChanged: { + if (popoutsSuppressed && popouts.hasCurrent) { + popouts.hasCurrent = false; + } + } + onDashboardSuppressed: { + if (dashboardSuppressed && uiState.dashboard) { + uiState.dashboard = false; + } + } + onLauncherSuppressedChanged: { + if (launcherSuppressed && uiState.launcher) { + uiState.launcher = false; + } + } + onOsdSuppressedChanged: { + if (osdSuppressed && uiState.osd) { + uiState.osd = false; + } + } +} diff --git a/modules/ui/Panels.qml b/modules/ui/Panels.qml new file mode 100644 index 0000000..f808fa2 --- /dev/null +++ b/modules/ui/Panels.qml @@ -0,0 +1,86 @@ +import qs.config +import qs.services +import qs.modules.bar.popouts as BarPopouts +import qs.modules.osd as Osd +import qs.modules.notifications as Notifications +import qs.modules.dashboard as Dashboard +import qs.modules.launcher as Launcher +import qs.modules.session as Session +import Quickshell +import QtQuick + +Item { + id: root + + required property PersistentProperties uiState + required property ShellScreen screen + required property Item bar + + readonly property alias popouts: popouts + readonly property alias osd: osd + readonly property alias notifications: notifications + readonly property alias dashboard: dashboard + readonly property alias launcher: launcher + readonly property alias session: session + + anchors.fill: parent + anchors.margins: Config.border.thickness + anchors.topMargin: Config.bar.height + + BarPopouts.Wrapper { + id: popouts + + uiState: root.uiState + screen: root.screen + } + + Osd.Wrapper { + id: osd + + uiState: root.uiState + screen: root.screen + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + } + + Notifications.Wrapper { + id: notifications + + uiState: root.uiState + panels: root + + anchors.right: parent.right + anchors.bottom: parent.bottom + } + + Dashboard.Wrapper { + id: dashboard + + uiState: root.uiState + popouts: popouts + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Launcher.Wrapper { + id: launcher + + uiState: root.uiState + panels: root + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + } + + Session.Wrapper { + id: session + + uiState: root.uiState + panels: root + + anchors.top: parent.top + anchors.right: parent.right + } +} diff --git a/modules/ui/UI.qml b/modules/ui/UI.qml new file mode 100644 index 0000000..7eb2f11 --- /dev/null +++ b/modules/ui/UI.qml @@ -0,0 +1,122 @@ +import qs.config +import qs.custom +import qs.modules.bar +import QtQuick +import Quickshell +import Quickshell.Wayland + +Variants { + model: Quickshell.screens + + Scope { + id: scope + required property ShellScreen modelData + + Exclusion { + screen: scope.modelData + } + + CustomWindow { + id: window + name: "ui" + screen: scope.modelData + + anchors.top: true + anchors.left: true + anchors.bottom: true + anchors.right: true + + // UI State + + UIState { + id: uiState + screen: scope.modelData + } + + // Exclusion + + exclusionMode: ExclusionMode.Ignore + mask: uiState.uiState.blockScreen ? exclusionBlock : exclusion + WlrLayershell.keyboardFocus: uiState.uiState.blockScreen ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + + Region { + id: exclusionBlock + intersection: Intersection.Xor + } + + Region { + id: exclusion + x: Config.border.thickness + y: Config.bar.height + width: window.width - 2 * Config.border.thickness + height: window.height - Config.bar.height - Config.border.thickness + intersection: Intersection.Xor + + regions: regions.instances + } + + Variants { + id: regions + + model: panels.children + + Region { + required property Item modelData + + x: modelData.x + Config.border.thickness + y: modelData.y + Config.bar.height + width: modelData.width + height: modelData.height + intersection: Intersection.Subtract + } + } + + // Visual Content + + CustomRect { + anchors.fill: parent + color: Config.colors.overlay + opacity: uiState.uiState.blockScreen ? 0.5 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + GlowEffect { + source: border + blurMax: 25 + blurMultiplier: 0 + glowColor: Config.colors.highlight + } + + Interactions { + uiState: uiState.uiState + bar: bar + panels: panels + screen: scope.modelData + + Panels { + id: panels + + uiState: uiState.uiState + screen: scope.modelData + bar: bar + } + } + + Border { + id: border + } + + Bar { + id: bar + + uiState: uiState.uiState + screen: scope.modelData + popouts: panels.popouts + } + } + } +} diff --git a/modules/ui/UIState.qml b/modules/ui/UIState.qml new file mode 100644 index 0000000..52c8e5e --- /dev/null +++ b/modules/ui/UIState.qml @@ -0,0 +1,96 @@ + +import qs.services +import qs.util +import QtQuick +import Quickshell +import Quickshell.Hyprland + +Scope { + id: root + + required property ShellScreen screen + property alias uiState: uiState + + PersistentProperties { + id: uiState + reloadableId: `uiState-${QsWindow.window.screen.name}` + + // Open panels + property bool dashboard + property bool launcher + property bool osd + property bool session + + property bool blockScreen + + // Other state + property ListModel workspaces + property int dashboardTab: 0 + property bool osdVolumeReact: true + property bool osdBrightnessReact: true + + Component.onCompleted: { + workspaces = listModelComp.createObject(this); + States.load(root.screen, this); + } + } + + // Workspace Handling + + Component { + id: listModelComp + ListModel {} + } + + // Initialize workspace list + Timer { + running: true + interval: Hypr.arbitraryRaceConditionDelay + onTriggered: { + // NOTE: Reinitialize workspace list on reload because persistence doesn't work + uiState.workspaces = listModelComp.createObject(uiState); + Hypr.workspaces.values.forEach(w => { + if (w.monitor === Hypr.monitorFor(root.screen)) + uiState.workspaces.append({"workspace": w}); + }) + } + } + + // Remove deleted workspaces + Connections { + target: Hypr.workspaces + function onObjectRemovedPost(workspace, index) { + root.removeWorkspace(workspace); + } + } + + // Update workspaces moved between monitors + // (also handles initialized workspaces) + Instantiator { + model: Hypr.workspaces + delegate: Connections { + required property HyprlandWorkspace modelData + target: modelData + function onMonitorChanged() { + if (modelData.monitor === Hypr.monitorFor(root.screen)) { + uiState.workspaces.append({"workspace": modelData}); + } else { + root.removeWorkspace(modelData); + } + } + } + } + + function removeWorkspace(workspace: HyprlandWorkspace): void { + let i = 0; + while (i < uiState.workspaces.count) { + const w = uiState.workspaces.get(i).workspace; + if (w === workspace) { + uiState.workspaces.remove(i); + } else { + i++; + } + } + } + +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..7a8f1df --- /dev/null +++ b/package.nix @@ -0,0 +1,72 @@ +{ + lib, + stdenv, + quickshell, + makeWrapper, + makeFontsConf, + qt6, + + fonts ? [], + + hyprland, + ddcutil, + brightnessctl, + networkmanager, + lm_sensors, + hyprsunset +}: +let + pname = "toki-quickshell"; + version = "0.1.0"; + + runtimeDeps = [ + hyprland + ddcutil + brightnessctl + networkmanager + lm_sensors + hyprsunset + ]; + + fontconfig = makeFontsConf { + fontDirectories = fonts; + }; +in stdenv.mkDerivation { + inherit pname version; + src = ./.; + + nativeBuildInputs = [ makeWrapper qt6.wrapQtAppsHook ]; + buildInputs = [ quickshell qt6.qtbase ] ++ runtimeDeps; + + # Inhibit file watching + prePatch = '' + substituteInPlace shell.qml \ + --replace-fail 'ShellRoot {' 'ShellRoot { settings.watchFiles: false' + ''; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin + mkdir -p $out/share + + cp -R . $out/share/${pname} + + makeWrapper ${quickshell}/bin/qs $out/bin/${pname} \ + --prefix PATH : "${lib.makeBinPath runtimeDeps}" \ + --set FONTCONFIG_FILE "${fontconfig}" \ + --add-flags "-p $out/share/${pname}" + + runHook postInstall + ''; + + meta = { + description = "My personal desktop shell, made using Quickshell"; + homepage = "https://git.tokinanpa.dev/toki/quickshell"; + license = lib.licenses.gpl3Only; + mainProgram = pname; + }; +} diff --git a/services/Audio.qml b/services/Audio.qml new file mode 100644 index 0000000..2c38010 --- /dev/null +++ b/services/Audio.qml @@ -0,0 +1,85 @@ +pragma Singleton + +import qs.config +import qs.custom +import Quickshell +import Quickshell.Services.Pipewire + +Singleton { + id: root + + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property PwNode source: Pipewire.defaultAudioSource + + PwObjectTracker { + objects: [root.sink, root.source] + } + + readonly property bool muted: !!sink?.audio?.muted + readonly property real volume: sink?.audio?.volume ?? 0 + + readonly property bool sourceMuted: !!source?.audio?.muted + readonly property real sourceVolume: source?.audio?.volume ?? 0 + + function setVolume(newVolume: real, s: PwNode): void { + if (!s) s = sink; + if (s?.ready && s?.audio) { + s.audio.volume = Math.max(0, Math.min(1, newVolume)); + } + } + + function increaseVolume(amount: real, s: PwNode): void { + setVolume(volume + (amount || Config.osd.volumeIncrement), s); + } + + function decreaseVolume(amount: real, s: PwNode): void { + setVolume(volume - (amount || Config.osd.volumeIncrement), s); + } + + CustomShortcut { + name: "volumeUp" + description: "Increase volume" + onPressed: root.increaseVolume() + } + + CustomShortcut { + name: "volumeDown" + description: "Decrease volume" + onPressed: root.decreaseVolume() + } + + CustomShortcut { + name: "mute" + description: "Toggle muting of output (Pipewire default audio sink)" + onPressed: root.sink.audio.muted = !root.muted + } + + CustomShortcut { + name: "muteMic" + description: "Toggle muting of input (Pipewire default audio source)" + onPressed: root.source.audio.muted = !root.sourceMuted + } + + function setSourceVolume(newVolume: real, s: PwNode): void { + if (!s) s = source; + if (s?.ready && s?.audio) { + s.audio.volume = Math.max(0, Math.min(1, newVolume)); + } + } + + function incrementSourceVolume(amount: real, s: PwNode): void { + setSourceVolume(sourceVolume + (amount || Config.osd.micIncrement), s); + } + + function decrementSourceVolume(amount: real, s: PwNode): void { + setSourceVolume(sourceVolume - (amount || Config.osd.micIncrement), s); + } + + function setAudioSink(newSink: PwNode): void { + Pipewire.preferredDefaultAudioSink = newSink; + } + + function setAudioSource(newSource: PwNode): void { + Pipewire.preferredDefaultAudioSource = newSource; + } +} diff --git a/services/Bluetooth.qml b/services/Bluetooth.qml new file mode 100644 index 0000000..0769095 --- /dev/null +++ b/services/Bluetooth.qml @@ -0,0 +1,104 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property bool powered + property bool discovering + readonly property list devices: [] + + Process { + running: true + command: ["bluetoothctl"] + stdout: SplitParser { + onRead: { + getInfo.running = true; + getDevices.running = true; + } + } + } + + Process { + id: getInfo + + running: true + command: ["bluetoothctl", "show"] + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: StdioCollector { + onStreamFinished: { + root.powered = text.includes("Powered: yes"); + root.discovering = text.includes("Discovering: yes"); + } + } + } + + Process { + id: getDevices + + running: true + command: ["fish", "-c", ` + for a in (bluetoothctl devices) + if string match -q 'Device *' $a + bluetoothctl info $addr (string split ' ' $a)[2] + echo + end + end`] + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: StdioCollector { + onStreamFinished: { + const devices = text.trim().split("\n\n").map(d => ({ + name: d.match(/Name: (.*)/)[1], + alias: d.match(/Alias: (.*)/)[1], + address: d.match(/Device ([0-9A-Z:]{17})/)[1], + icon: d.match(/Icon: (.*)/)[1], + connected: d.includes("Connected: yes"), + paired: d.includes("Paired: yes"), + trusted: d.includes("Trusted: yes") + })); + const rDevices = root.devices; + + const destroyed = rDevices.filter(rd => !devices.find(d => d.address === rd.address)); + for (const device of destroyed) + rDevices.splice(rDevices.indexOf(device), 1).forEach(d => d.destroy()); + + for (const device of devices) { + const match = rDevices.find(d => d.address === device.address); + if (match) { + match.lastIpcObject = device; + } else { + rDevices.push(deviceComp.createObject(root, { + lastIpcObject: device + })); + } + } + } + } + } + + component Device: QtObject { + required property var lastIpcObject + readonly property string name: lastIpcObject.name + readonly property string alias: lastIpcObject.alias + readonly property string address: lastIpcObject.address + readonly property string icon: lastIpcObject.icon + readonly property bool connected: lastIpcObject.connected + readonly property bool paired: lastIpcObject.paired + readonly property bool trusted: lastIpcObject.trusted + } + + Component { + id: deviceComp + + Device {} + } +} diff --git a/services/Brightness.qml b/services/Brightness.qml new file mode 100644 index 0000000..b369d2f --- /dev/null +++ b/services/Brightness.qml @@ -0,0 +1,153 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.custom +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + reloadableId: "brightness" + + property list ddcMonitors: [] + readonly property list monitors: variants.instances + property bool appleDisplayPresent: false + + function getMonitorForScreen(screen: ShellScreen): var { + return monitors.find(m => m.modelData === screen); + } + + function increaseBrightness(): void { + const focusedName = Hypr.focusedMonitor.name; + const monitor = monitors.find(m => focusedName === m.modelData.name); + if (monitor) + monitor.setBrightness(monitor.brightness + Config.osd.brightnessIncrement); + } + + function decreaseBrightness(): void { + const focusedName = Hypr.focusedMonitor.name; + const monitor = monitors.find(m => focusedName === m.modelData.name); + if (monitor) + monitor.setBrightness(monitor.brightness - Config.osd.brightnessIncrement); + } + + onMonitorsChanged: { + ddcMonitors = []; + ddcProc.running = true; + } + + Variants { + id: variants + + model: Quickshell.screens + + Monitor {} + } + + Process { + running: true + command: ["sh", "-c", "asdbctl get"] // To avoid warnings if asdbctl is not installed + stdout: StdioCollector { + onStreamFinished: root.appleDisplayPresent = text.trim().length > 0 + } + } + + Process { + id: ddcProc + + command: ["ddcutil", "detect", "--brief"] + stdout: StdioCollector { + onStreamFinished: root.ddcMonitors = text.trim().split("\n\n").filter(d => d.startsWith("Display ")).map(d => ({ + model: d.match(/Monitor:.*:(.*):.*/)[1], + busNum: d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/)[1] + })) + } + } + + CustomShortcut { + name: "brightnessUp" + description: "Increase brightness" + onPressed: root.increaseBrightness() + } + + CustomShortcut { + name: "brightnessDown" + description: "Decrease brightness" + onPressed: root.decreaseBrightness() + } + + component Monitor: QtObject { + id: monitor + + required property ShellScreen modelData + readonly property bool isDdc: root.ddcMonitors.some(m => m.model === modelData.model) + readonly property string busNum: root.ddcMonitors.find(m => m.model === modelData.model)?.busNum ?? "" + readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay") + property real brightness + property real queuedBrightness: NaN + + readonly property Process initProc: Process { + stdout: StdioCollector { + onStreamFinished: { + if (monitor.isAppleDisplay) { + const val = parseInt(text.trim()); + monitor.brightness = val / 101; + } else { + const [, , , cur, max] = text.split(" "); + monitor.brightness = parseInt(cur) / parseInt(max); + } + } + } + } + + readonly property Timer timer: Timer { + interval: 500 + onTriggered: { + if (!isNaN(monitor.queuedBrightness)) { + monitor.setBrightness(monitor.queuedBrightness); + monitor.queuedBrightness = NaN; + } + } + } + + function setBrightness(value: real): void { + value = Math.max(0, Math.min(1, value)); + const rounded = Math.round(value * 100); + if (Math.round(brightness * 100) === rounded) + return; + + if (isDdc && timer.running) { + queuedBrightness = value; + return; + } + + brightness = value; + + if (isAppleDisplay) + Quickshell.execDetached(["asdbctl", "set", rounded]); + else if (isDdc) + Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded]); + else + Quickshell.execDetached(["brightnessctl", "s", `${rounded}%`]); + + if (isDdc) + timer.restart(); + } + + function initBrightness(): void { + if (isAppleDisplay) + initProc.command = ["asdbctl", "get"]; + else if (isDdc) + initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"]; + else + initProc.command = ["sh", "-c", "echo a b c $(brightnessctl g) $(brightnessctl m)"]; + + initProc.running = true; + } + + onBusNumChanged: initBrightness() + Component.onCompleted: initBrightness() + } +} diff --git a/services/Hypr.qml b/services/Hypr.qml new file mode 100644 index 0000000..94975d3 --- /dev/null +++ b/services/Hypr.qml @@ -0,0 +1,86 @@ +pragma Singleton + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property var toplevels: Hyprland.toplevels + readonly property var workspaces: Hyprland.workspaces + readonly property var monitors: Hyprland.monitors + property HyprlandToplevel activeToplevel: null + readonly property HyprlandWorkspace focusedWorkspace: Hyprland.focusedWorkspace + readonly property HyprlandMonitor focusedMonitor: Hyprland.focusedMonitor + property string kbLayout: "?" + + readonly property int arbitraryRaceConditionDelay: 50 + + function dispatch(request: string): void { + Hyprland.dispatch(request); + } + + function monitorFor(screen: ShellScreen): HyprlandMonitor { + return Hyprland.monitorFor(screen); + } + + Connections { + target: Hyprland + + function onRawEvent(event: HyprlandEvent): void { + const n = event.name; + if (n.endsWith("v2")) + return; + + if (n === "activelayout") { + root.kbLayout = event.parse(2)[1].slice(0, 2).toLowerCase(); + } else if (["workspace", "moveworkspace", "activespecial", "focusedmon"].includes(n)) { + Hyprland.refreshWorkspaces(); + Hyprland.refreshMonitors(); + } else if (["openwindow", "closewindow", "movewindow"].includes(n)) { + Hyprland.refreshToplevels(); + Hyprland.refreshWorkspaces(); + } else if (n.includes("mon")) { + Hyprland.refreshMonitors(); + } else if (n.includes("workspace")) { + Hyprland.refreshWorkspaces(); + } else if (n.includes("window") || n.includes("group") || ["pin", "fullscreen", "changefloatingmode", "minimize"].includes(n)) { + Hyprland.refreshToplevels(); + } + } + + function onActiveToplevelChanged() { + toplevelTimer.start(); + } + } + + onFocusedWorkspaceChanged: toplevelTimer.start() + + // Delay update to account for Hyprland's processing delay + // (Prevent false null reports) + Timer { + id: toplevelTimer + interval: root.arbitraryRaceConditionDelay + repeat: false + + onTriggered: { + const toplevel = Hyprland.activeToplevel; + // Invalidate active toplevel if in different workspace + if (toplevel && toplevel?.workspace === focusedWorkspace) { + root.activeToplevel = toplevel; + } else { + root.activeToplevel = null; + } + } + } + + Process { + running: true + command: ["hyprctl", "-j", "devices"] + stdout: StdioCollector { + onStreamFinished: root.kbLayout = JSON.parse(text).keyboards.find(k => k.main).active_keymap.slice(0, 2).toLowerCase() + } + } +} diff --git a/services/Hyprsunset.qml b/services/Hyprsunset.qml new file mode 100644 index 0000000..52b486d --- /dev/null +++ b/services/Hyprsunset.qml @@ -0,0 +1,103 @@ +pragma Singleton + +import qs.config +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property var manualActive + property string from: Config.services.sunsetFrom + property string to: Config.services.sunsetTo + property bool automatic: from !== to + property bool shouldBeOn + property bool firstEvaluation: true + property bool active: false + + property int fromHour: Number(from.split(":")[0]) + property int fromMinute: Number(from.split(":")[1]) + property int toHour: Number(to.split(":")[0]) + property int toMinute: Number(to.split(":")[1]) + + property int clockHour: Time.hours + property int clockMinute: Time.minutes + + onClockMinuteChanged: reEvaluate() + onAutomaticChanged: { + root.manualActive = undefined; + root.firstEvaluation = true; + reEvaluate(); + } + function reEvaluate() { + const t = clockHour * 60 + clockMinute; + const from = fromHour * 60 + fromMinute; + const to = toHour * 60 + toMinute; + + if (from < to) { + root.shouldBeOn = t >= from && t <= to; + } else { + // Wrapped around midnight + root.shouldBeOn = t >= from || t <= to; + } + if (firstEvaluation) { + firstEvaluation = false; + root.ensureState(); + } + } + + onShouldBeOnChanged: ensureState() + function ensureState() { + if (!root.automatic || root.manualActive !== undefined) + return; + if (root.shouldBeOn) { + root.enable(); + } else { + root.disable(); + } + } + + function load() { } // Dummy to force init + + function enable() { + root.active = true; + Quickshell.execDetached(["hyprsunset", "--temperature", Config.services.sunsetTemperature]); + } + + function disable() { + root.active = false; + Quickshell.execDetached(["pkill", "hyprsunset"]); + } + + function fetchState() { + fetchProc.running = true; + } + + Process { + id: fetchProc + running: true + command: ["hyprctl", "hyprsunset", "temperature"] + stdout: StdioCollector { + id: stateCollector + onStreamFinished: { + const output = stateCollector.text.trim(); + if (output.length == 0 || output.startsWith("Couldn't")) + root.active = false; + else + root.active = (output != "6500"); + } + } + } + + function toggle() { + if (root.manualActive === undefined) + root.manualActive = root.active; + + root.manualActive = !root.manualActive; + if (root.manualActive) { + root.enable(); + } else { + root.disable(); + } + } +} diff --git a/services/Idle.qml b/services/Idle.qml new file mode 100644 index 0000000..794e715 --- /dev/null +++ b/services/Idle.qml @@ -0,0 +1,47 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + property alias inhibit: inhibitor.running + property bool inhibitPipewire + + function toggleInhibitPipewire() { + root.inhibitPipewire = !root.inhibitPipewire; + pipewireInhibitor.running = true; + } + + // Idle Inhibitor + + Process { + id: inhibitor + command: ["wayland-idle-inhibitor"] + } + + // Idle Inhibit on Pipewire + + readonly property string pipewireInhibitorService: "wayland-pipewire-idle-inhibit.service" + + Timer { + id: pipewireInhibitorTimer + running: true + repeat: true + triggeredOnStart: true + onTriggered: pipewireInhibitorCheck.running = true + } + + Process { + id: pipewireInhibitorCheck + command: ["systemctl", "status", "--user", root.pipewireInhibitorService] + onExited: (code, _) => root.inhibitPipewire = (code === 0) + } + + Process { + id: pipewireInhibitor + command: ["systemctl", root.inhibitPipewire ? "start" : "stop", "--user", root.pipewireInhibitorService] + } +} diff --git a/services/Network.qml b/services/Network.qml new file mode 100644 index 0000000..870f5c0 --- /dev/null +++ b/services/Network.qml @@ -0,0 +1,192 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property list networks: [] + readonly property AccessPoint active: networks.find(n => n.active) ?? null + property bool wifiEnabled: true + readonly property bool scanning: rescanProc.running + + reloadableId: "network" + + function enableWifi(enabled: bool): void { + const cmd = enabled ? "on" : "off"; + enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]); + } + + function toggleWifi(): void { + const cmd = wifiEnabled ? "off" : "on"; + enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]); + } + + function rescanWifi(): void { + rescanProc.running = true; + } + + function connectToNetwork(ssid: string, password: string): void { + // TODO: Implement password + connectProc.exec(["nmcli", "conn", "up", ssid]); + } + + function disconnectFromNetwork(): void { + if (active) { + disconnectProc.exec(["nmcli", "connection", "down", active.ssid]); + } + } + + function getWifiStatus(): void { + wifiStatusProc.running = true; + } + + Process { + running: true + command: ["nmcli", "m"] + stdout: SplitParser { + onRead: getNetworks.running = true + } + } + + Process { + id: wifiStatusProc + + running: true + command: ["nmcli", "radio", "wifi"] + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: StdioCollector { + onStreamFinished: { + root.wifiEnabled = text.trim() === "enabled"; + } + } + } + + Process { + id: enableWifiProc + + onExited: { + root.getWifiStatus(); + getNetworks.running = true; + } + } + + Process { + id: rescanProc + + command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"] + onExited: { + getNetworks.running = true; + } + } + + Process { + id: connectProc + + stdout: SplitParser { + onRead: getNetworks.running = true + } + stderr: StdioCollector { + onStreamFinished: console.warn("Network connection error:", text) + } + } + + Process { + id: disconnectProc + + stdout: SplitParser { + onRead: getNetworks.running = true + } + } + + Process { + id: getNetworks + + running: true + command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"] + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: StdioCollector { + onStreamFinished: { + const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED"; + const rep = new RegExp("\\\\:", "g"); + const rep2 = new RegExp(PLACEHOLDER, "g"); + + const allNetworks = text.trim().split("\n").map(n => { + const net = n.replace(rep, PLACEHOLDER).split(":"); + return { + active: net[0] === "yes", + strength: parseInt(net[1]), + frequency: parseInt(net[2]), + ssid: net[3], + bssid: net[4]?.replace(rep2, ":") ?? "", + security: net[5] || "" + }; + }).filter(n => n.ssid && n.ssid.length > 0); + + // Group networks by SSID and prioritize connected ones + const networkMap = new Map(); + for (const network of allNetworks) { + const existing = networkMap.get(network.ssid); + if (!existing) { + networkMap.set(network.ssid, network); + } else { + // Prioritize active/connected networks + if (network.active && !existing.active) { + networkMap.set(network.ssid, network); + } else if (!network.active && !existing.active) { + // If both are inactive, keep the one with better signal + if (network.strength > existing.strength) { + networkMap.set(network.ssid, network); + } + } + // If existing is active and new is not, keep existing + } + } + + const networks = Array.from(networkMap.values()); + + const rNetworks = root.networks; + + const destroyed = rNetworks.filter(rn => !networks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid)); + for (const network of destroyed) + rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy()); + + for (const network of networks) { + const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid); + if (match) { + match.lastIpcObject = network; + } else { + rNetworks.push(apComp.createObject(root, { + lastIpcObject: network + })); + } + } + } + } + } + + component AccessPoint: QtObject { + required property var lastIpcObject + readonly property string ssid: lastIpcObject.ssid + readonly property string bssid: lastIpcObject.bssid + readonly property int strength: lastIpcObject.strength + readonly property int frequency: lastIpcObject.frequency + readonly property bool active: lastIpcObject.active + readonly property string security: lastIpcObject.security + readonly property bool isSecure: security.length > 0 + } + + Component { + id: apComp + + AccessPoint {} + } +} diff --git a/services/NixOS.qml b/services/NixOS.qml new file mode 100644 index 0000000..13e2738 --- /dev/null +++ b/services/NixOS.qml @@ -0,0 +1,60 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + readonly property string nixVersion: nixVersionProc.nixVersion + readonly property list generations: nixosGenerationsProc.generations + readonly property Generation currentGen: generations.find(g => g.current) ?? null + + Timer { + running: true + repeat: true + triggeredOnStart: true + interval: 60000 + onTriggered: { + nixVersionProc.running = true; + nixosGenerationsProc.running = true; + } + } + + Process { + id: nixVersionProc + command: ["nix", "--version"] + property string nixVersion: "" + stdout: StdioCollector { + onStreamFinished: nixVersionProc.nixVersion = this.text.split(" ")[2].trim() + } + } + + Process { + id: nixosGenerationsProc + command: ["nixos-rebuild", "list-generations", "--json"] + property list generations: [] + stdout: StdioCollector { + onStreamFinished: { + const json = JSON.parse(this.text); + nixosGenerationsProc.generations = json.map(o => genComp.createObject(root, { ipcObject: o })); + } + } + } + + component Generation: QtObject { + required property var ipcObject + readonly property int id: ipcObject.generation + readonly property date date: ipcObject.date + readonly property string nixosVersion: ipcObject.nixosVersion + readonly property string kernelVersion: ipcObject.kernelVersion + readonly property string revision: ipcObject.configurationRevision + readonly property bool current: ipcObject.current + } + + Component { + id: genComp + Generation {} + } +} diff --git a/services/Notifs.qml b/services/Notifs.qml new file mode 100644 index 0000000..52a20ca --- /dev/null +++ b/services/Notifs.qml @@ -0,0 +1,132 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.config +import qs.custom +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Quickshell.Services.Notifications +import QtQuick + +Singleton { + id: root + + readonly property list list: [] + + property bool dnd: false + function toggleDnd(): void { + dnd = !dnd + } + + NotificationServer { + id: server + + keepOnReload: false + actionsSupported: true + bodyHyperlinksSupported: true + bodyImagesSupported: true + bodyMarkupSupported: true + imageSupported: true + persistenceSupported: true + + onNotification: notif => { + notif.tracked = true; + + root.list.push(notifComp.createObject(root, { + popup: root.dnd ? null : Hypr.focusedMonitor, + notification: notif + })); + } + } + + CustomShortcut { + name: "clearNotifs" + description: "Clear all notifications" + onPressed: { + for (const notif of root.list) + notif.popup = null; + } + } + + CustomShortcut { + name: "dismissNotifs" + description: "Dismiss all notifications" + onPressed: { + for (const notif of root.list) + notif.popup = null; + } + } + + IpcHandler { + target: "notifs" + + function clear(): void { + for (const notif of root.list) + notif.popup = null; + } + } + + component Notif: QtObject { + id: notif + + // Monitor to display popup in (typically the focused monitor when received) + property HyprlandMonitor popup: null + readonly property date time: new Date() + readonly property string timeStr: Qt.formatTime(time, "hh:mm:ss") + readonly property string timeSince: { + const diff = Time.date.getTime() - time.getTime(); + const m = Math.floor(diff / 60000); + const h = Math.floor(m / 60); + const d = Math.floor(h / 24); + + if (m < 1) + return "now"; + if (h < 1) + return `${m}m`; + if (d < 1) + return `${h}h`; + return `${d}d`; + } + + required property Notification notification + readonly property string summary: notification.summary + readonly property string appIcon: notification.appIcon + readonly property string appName: notification.appName + readonly property string image: notification.image + readonly property int urgency: notification.urgency + readonly property list actions: notification.actions + + // Split body text into lines parseable by StyledText format + readonly property string body: notification.body.replace("\n", "
") + // One-line version (for non-expanded notifications) + readonly property string bodyOneLine: notification.body.replace("\n", " ") + + readonly property Timer timer: Timer { + running: true + interval: notif.notification.expireTimeout > 0 ? notif.notification.expireTimeout : Config.notifs.defaultExpireTimeout + onTriggered: { + if (Config.notifs.expire) + notif.popup = null; + } + } + + readonly property Connections conn: Connections { + target: notif.notification.Retainable + + function onDropped(): void { + root.list.splice(root.list.indexOf(notif), 1); + } + + function onAboutToDestroy(): void { + notif.destroy(); + } + } + } + + Component { + id: notifComp + + Notif {} + } +} diff --git a/services/Players.qml b/services/Players.qml new file mode 100644 index 0000000..38e68b3 --- /dev/null +++ b/services/Players.qml @@ -0,0 +1,123 @@ +pragma Singleton + +import qs.config +import qs.custom +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris + +Singleton { + id: root + + readonly property list list: Mpris.players.values + readonly property MprisPlayer active: manualActive ?? list.find(p => getIdentity(p) === Config.services.defaultPlayer) ?? list[0] ?? null + property MprisPlayer manualActive + + // Alias IDs (for looking up icons) + readonly property list idAliases: [ + { + "from": "Mozilla firefox", + "to": "Firefox" + } + ] + // Alias names (for displaying) + readonly property list nameAliases: [ + { + "from": "com.github.th_ch.youtube_music", + "to": "YT Music" + } + ] + + function getIdentity(player: MprisPlayer): string { + const alias = idAliases.find(a => a.from === player.identity); + return alias?.to ?? player.identity; + } + + function getName(player: MprisPlayer): string { + const alias = nameAliases.find(a => a.from === player.identity); + return alias?.to ?? getIdentity(player); + } + + CustomShortcut { + name: "mediaToggle" + description: "Toggle media playback" + onPressed: { + const active = root.active; + if (active && active.canTogglePlaying) + active.togglePlaying(); + } + } + + CustomShortcut { + name: "mediaPrev" + description: "Previous track" + onPressed: { + const active = root.active; + if (active && active.canGoPrevious) + active.previous(); + } + } + + CustomShortcut { + name: "mediaNext" + description: "Next track" + onPressed: { + const active = root.active; + if (active && active.canGoNext) + active.next(); + } + } + + CustomShortcut { + name: "mediaStop" + description: "Stop media playback" + onPressed: root.active?.stop() + } + + IpcHandler { + target: "mpris" + + function getActive(prop: string): string { + const active = root.active; + return active ? active[prop] ?? "Invalid property" : "No active player"; + } + + function list(): string { + return root.list.map(p => root.get(p)).join("\n"); + } + + function play(): void { + const active = root.active; + if (active?.canPlay) + active.play(); + } + + function pause(): void { + const active = root.active; + if (active?.canPause) + active.pause(); + } + + function playPause(): void { + const active = root.active; + if (active?.canTogglePlaying) + active.togglePlaying(); + } + + function previous(): void { + const active = root.active; + if (active?.canGoPrevious) + active.previous(); + } + + function next(): void { + const active = root.active; + if (active?.canGoNext) + active.next(); + } + + function stop(): void { + root.active?.stop(); + } + } +} diff --git a/services/Requests.qml b/services/Requests.qml new file mode 100644 index 0000000..37e9c41 --- /dev/null +++ b/services/Requests.qml @@ -0,0 +1,36 @@ +pragma Singleton + +import qs.config +import qs.util +import Quickshell + +Singleton { + id: root + + function get(url: string, callback: var): void { + const xhr = new XMLHttpRequest(); + + const cleanup = () => { + xhr.abort(); + xhr.onreadystatechange = null; + xhr.onerror = null; + }; + + xhr.open("GET", url, true); + xhr.onreadystatechange = () => { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) + callback(xhr.responseText); + else + console.warn(`[REQUESTS] GET request to ${url} failed with status ${xhr.status}`); + cleanup(); + } + }; + xhr.onerror = () => { + console.warn(`[REQUESTS] GET request to ${url} failed`); + cleanup(); + }; + + xhr.send(); + } +} diff --git a/services/SystemUsage.qml b/services/SystemUsage.qml new file mode 100644 index 0000000..bd02da3 --- /dev/null +++ b/services/SystemUsage.qml @@ -0,0 +1,222 @@ +pragma Singleton + +import qs.config +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property real cpuPerc + property real cpuTemp + readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType + property string autoGpuType: "NONE" + property real gpuPerc + property real gpuTemp + property real memUsed + property real memTotal + readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0 + property real storageUsed + property real storageTotal + property real storagePerc: storageTotal > 0 ? storageUsed / storageTotal : 0 + + property real lastCpuIdle + property real lastCpuTotal + + property int refCount + + function formatKib(kib: real): var { + const mib = 1024; + const gib = 1024 ** 2; + const tib = 1024 ** 3; + + if (kib >= tib) + return { + value: kib / tib, + unit: "TiB" + }; + if (kib >= gib) + return { + value: kib / gib, + unit: "GiB" + }; + if (kib >= mib) + return { + value: kib / mib, + unit: "MiB" + }; + return { + value: kib, + unit: "KiB" + }; + } + + Timer { + running: root.refCount > 0 + interval: 3000 + repeat: true + triggeredOnStart: true + onTriggered: { + stat.reload(); + meminfo.reload(); + storage.running = true; + gpuUsage.running = true; + sensors.running = true; + } + } + + FileView { + id: stat + + path: "/proc/stat" + onLoaded: { + const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/); + if (data) { + const stats = data.slice(1).map(n => parseInt(n, 10)); + const total = stats.reduce((a, b) => a + b, 0); + const idle = stats[3] + (stats[4] ?? 0); + + const totalDiff = total - root.lastCpuTotal; + const idleDiff = idle - root.lastCpuIdle; + root.cpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0; + + root.lastCpuTotal = total; + root.lastCpuIdle = idle; + } + } + } + + FileView { + id: meminfo + + path: "/proc/meminfo" + onLoaded: { + const data = text(); + root.memTotal = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1; + root.memUsed = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0; + } + } + + Process { + id: storage + + command: ["sh", "-c", "df | grep '^/dev/' | awk '{print $1, $3, $4}'"] + stdout: StdioCollector { + onStreamFinished: { + const deviceMap = new Map(); + + for (const line of text.trim().split("\n")) { + if (line.trim() === "") + continue; + + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + const device = parts[0]; + const used = parseInt(parts[1], 10) || 0; + const avail = parseInt(parts[2], 10) || 0; + + // Only keep the entry with the largest total space for each device + if (!deviceMap.has(device) || (used + avail) > (deviceMap.get(device).used + deviceMap.get(device).avail)) { + deviceMap.set(device, { + used: used, + avail: avail + }); + } + } + } + + let totalUsed = 0; + let totalAvail = 0; + + for (const [device, stats] of deviceMap) { + totalUsed += stats.used; + totalAvail += stats.avail; + } + + root.storageUsed = totalUsed; + root.storageTotal = totalUsed + totalAvail; + } + } + } + + Process { + id: gpuTypeCheck + + running: !Config.services.gpuType + command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; then echo NVIDIA; elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC; else echo NONE; fi"] + stdout: StdioCollector { + onStreamFinished: root.autoGpuType = text.trim() + } + } + + Process { + id: gpuUsage + + command: root.gpuType === "GENERIC" ? ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent"] : root.gpuType === "NVIDIA" ? ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"] : ["echo"] + stdout: StdioCollector { + onStreamFinished: { + if (root.gpuType === "GENERIC") { + const percs = text.trim().split("\n"); + const sum = percs.reduce((acc, d) => acc + parseInt(d, 10), 0); + root.gpuPerc = sum / percs.length / 100; + } else if (root.gpuType === "NVIDIA") { + const [usage, temp] = text.trim().split(","); + root.gpuPerc = parseInt(usage, 10) / 100; + root.gpuTemp = parseInt(temp, 10); + } else { + root.gpuPerc = 0; + root.gpuTemp = 0; + } + } + } + } + + Process { + id: sensors + + command: ["sensors"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + stdout: StdioCollector { + onStreamFinished: { + let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/); + if (!cpuTemp) + // If AMD Tdie pattern failed, try fallback on Tctl + cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/); + + if (cpuTemp) + root.cpuTemp = parseFloat(cpuTemp[1]); + + if (root.gpuType !== "GENERIC") + return; + + let eligible = false; + let sum = 0; + let count = 0; + + for (const line of text.trim().split("\n")) { + if (line === "Adapter: PCI adapter") + eligible = true; + else if (line === "") + eligible = false; + else if (eligible) { + let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + if (!match) + // Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs) + match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + + if (match) { + sum += parseFloat(match[2]); + count++; + } + } + } + + root.gpuTemp = count > 0 ? sum / count : 0; + } + } + } +} diff --git a/services/Time.qml b/services/Time.qml new file mode 100644 index 0000000..70666f8 --- /dev/null +++ b/services/Time.qml @@ -0,0 +1,52 @@ +pragma Singleton + +import Quickshell + +Singleton { + property alias enabled: clock.enabled + readonly property date date: clock.date + readonly property int hours: clock.hours + readonly property int minutes: clock.minutes + readonly property int seconds: clock.seconds + + SystemClock { + id: clock + precision: SystemClock.Seconds + } + + function format(fmt: string): string { + return Qt.formatDateTime(clock.date, fmt); + } + + function formatSeconds(s: int, includeSecs = false): string { + let min = Math.floor(s / 60); + let hr = Math.floor(min / 60); + let day = Math.floor(hr / 24); + let week = Math.floor(day / 7); + let year = Math.floor(day / 365); + s = s % 60; + min = min % 60; + hr = hr % 24; + day = day % 7; + week = week % 52; + + let comps = []; + if (year > 0) + comps.push(`${year}y`); + if (week > 0) + comps.push(`${week}w`); + if (day > 0) + comps.push(`${day}d`); + if (hr > 0) + comps.push(`${hr}h`); + if (min > 0) + comps.push(`${min}m`); + if (includeSecs && s > 0) + comps.push(`${s}s`); + + if (comps.length === 0) + return ""; + else + return comps.join(" "); + } +} diff --git a/services/User.qml b/services/User.qml new file mode 100644 index 0000000..35e7b26 --- /dev/null +++ b/services/User.qml @@ -0,0 +1,36 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + + readonly property string user: userProcess.user + readonly property string uptime: fileUptime.uptime + + Timer { + running: true + repeat: true + interval: 15000 + triggeredOnStart: true + onTriggered: fileUptime.reload() + } + + FileView { + id: fileUptime + property string uptime: "" + path: "/proc/uptime" + onLoaded: uptime = Time.formatSeconds(parseInt(text().split(" ")[0] ?? 0)); + } + + Process { + id: userProcess + property string user: "" + running: true + command: ["whoami"] + stdout: StdioCollector { + onStreamFinished: userProcess.user = this.text.trim() + } + } +} diff --git a/services/Weather.qml b/services/Weather.qml new file mode 100644 index 0000000..36a1c4e --- /dev/null +++ b/services/Weather.qml @@ -0,0 +1,42 @@ +pragma Singleton + +import qs.config +import qs.util +import Quickshell +import QtQuick + +Singleton { + id: root + + property string city + property var cc + property var forecast + readonly property bool available: !!cc + readonly property string icon: available ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" + readonly property color iconColor: available ? Icons.weatherIconColors[icon] : Config.colors.inactive + readonly property string description: cc?.weatherDesc[0].value ?? qsTr("No weather") + readonly property string temp: Config.services.useFahrenheit ? `${cc?.temp_F ?? 0}°F` : `${cc?.temp_C ?? 0}°C` + readonly property string feelsLike: Config.services.useFahrenheit ? `${cc?.FeelsLikeF ?? 0}°F` : `${cc?.FeelsLikeC ?? 0}°C` + readonly property real humidity: (cc?.humidity ?? 0) / 100 + readonly property string humidityIcon: available ? Icons.getHumidityIcon(humidity) : "humidity_low" + + function reload(): void { + if (Config.services.weatherLocation) + city = Config.services.weatherLocation; + else if (!city || timer.elapsed() > 900) + Requests.get("https://ipinfo.io/json", text => { + city = JSON.parse(text).city ?? ""; + timer.restart(); + }); + } + + onCityChanged: Requests.get(`https://wttr.in/${city}?format=j1`, text => { + const json = JSON.parse(text); + cc = json.current_condition[0]; + forecast = json.weather; + }) + + ElapsedTimer { + id: timer + } +} diff --git a/shell.qml b/shell.qml new file mode 100644 index 0000000..0338c85 --- /dev/null +++ b/shell.qml @@ -0,0 +1,10 @@ +//@ pragma UseQApplication + +import Quickshell +import qs.modules +import qs.modules.ui + +ShellRoot { + UI {} + Commands {} +} diff --git a/util/Color.qml b/util/Color.qml new file mode 100644 index 0000000..6722414 --- /dev/null +++ b/util/Color.qml @@ -0,0 +1,15 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + function mute(color: color, s: real, v: real): color { + if (!s) s = 1.2; + if (!v) v = 1.2; + const hue = color.hsvHue; + const saturation = color.hsvSaturation; + const value = color.hsvValue; + return Qt.hsva(hue, saturation * s, value / v, 1); + } +} diff --git a/util/Icons.qml b/util/Icons.qml new file mode 100644 index 0000000..8975f1c --- /dev/null +++ b/util/Icons.qml @@ -0,0 +1,268 @@ +pragma Singleton + +import qs.config +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Services.Notifications + +Singleton { + id: root + + readonly property var weatherIcons: ({ + "113": "clear_day", + "116": "partly_cloudy_day", + "119": "cloud", + "122": "cloud", + "143": "foggy", + "176": "rainy_light", + "179": "rainy_snow", + "182": "rainy_snow", + "185": "rainy_snow", + "200": "electric_bolt", + "227": "snowing", + "230": "snowing_heavy", + "248": "foggy", + "260": "foggy", + "263": "rainy_light", + "266": "rainy_light", + "281": "rainy_snow", + "284": "rainy_snow", + "293": "rainy_light", + "296": "rainy_light", + "299": "rainy_heavy", + "302": "rainy_heavy", + "305": "rainy_heavy", + "308": "rainy_heavy", + "311": "rainy_snow", + "314": "rainy_snow", + "317": "rainy_snow", + "320": "snowing", + "323": "rainy_snow", + "326": "rainy_snow", + "329": "snowing_heavy", + "332": "snowing_heavy", + "335": "snowing", + "338": "snowing_heavy", + "350": "rainy_snow", + "353": "rainy_light", + "356": "rainy_heavy", + "359": "rainy_heavy", + "362": "rainy_snow", + "365": "rainy_snow", + "368": "snowing", + "371": "snowing_heavy", + "374": "rainy_snow", + "377": "rainy_snow", + "386": "electric_bolt", + "389": "electric_bolt", + "392": "electric_bolt", + "395": "snowing_heavy" + }) + + readonly property var weatherIconColors: ({ + "clear_day": Config.colors.yellow, + "partly_cloudy_day": Config.colors.primary, + "cloud": Config.colors.tertiary, + "foggy": Config.colors.tertiary, + "electric_bolt": Config.colors.yellow, + "rainy_light": Config.colors.blue, + "rainy_heavy": Config.colors.blue, + "rainy_snow": Config.colors.blue, + "snowing": Config.colors.cyan, + "snowing_heavy": Config.colors.cyan, + "air": Config.colors.primary + }) + + readonly property var desktopEntrySubs: ({ + "gimp-3.0": ["gimp"], + "discord": ["discord", "discord-canary"] + }) + + readonly property var categoryIcons: ({ + WebBrowser: "language", + Printing: "print", + Security: "security", + Network: "business_messages", + Archiving: "archive", + Compression: "archive", + Development: "code", + IDE: "code", + TextEditor: "edit_note", + Audio: "music_note", + Music: "music_note", + Player: "music_note", + Recorder: "mic", + Game: "sports_esports", + FileTools: "files", + FileManager: "files", + Filesystem: "files", + FileTransfer: "files", + Settings: "settings", + DesktopSettings: "settings", + HardwareSettings: "settings", + TerminalEmulator: "terminal", + ConsoleOnly: "terminal", + Utility: "build", + Monitor: "monitor_heart", + Midi: "graphic_eq", + Mixer: "graphic_eq", + AudioVideoEditing: "video_settings", + AudioVideo: "music_video", + Video: "videocam", + Building: "construction", + Graphics: "photo_library", + "2DGraphics": "photo_library", + RasterGraphics: "photo_library", + TV: "tv", + System: "host", + Office: "content_paste" + }) + + function getDesktopEntry(name: string): DesktopEntry { + name = name.toLowerCase().replace(/ /g, "-"); + + let names = []; + if (desktopEntrySubs.hasOwnProperty(name)) + names = desktopEntrySubs[name]; + else + names = [name]; + + return DesktopEntries.applications.values.find(a => names.includes(a.id.toLowerCase())) ?? null; + } + + function getAppIcon(name: string, fallback: string): string { + return Quickshell.iconPath(getDesktopEntry(name)?.icon, fallback); + } + + function getAppCategoryIcon(name: string, fallback: string): string { + const categories = getDesktopEntry(name)?.categories; + + if (categories) + for (const [key, value] of Object.entries(categoryIcons)) + if (categories.includes(key)) + return value; + return fallback; + } + + // App icon precedence + // Used to preferentially display apps in a workspace + readonly property var appIconPrec: ({ + sports_esports: 10, + code: 9, + music_note: 9, + content_paste: 9, + graphic_eq: 8, + tv: 8, + edit_note: 6, + language: 5, + business_messages: 5, + files: 4, + host: 4, + mic: 4, + construction: 4, + monitor_heart: 3, + security: 3, + archive: 3, + settings: 2, + build: 2, + photo_library: 1, + terminal: 1 + }) + + function getWorkspaceIcon(workspace: HyprlandWorkspace): string { + if (!workspace || workspace.toplevels.values.length === 0) return "add"; + return [...workspace.toplevels.values] + .map(tl => getAppCategoryIcon(tl.lastIpcObject.class, "terminal")) + .reduce((a, b) => appIconPrec[a] > appIconPrec[b] ? a : b); + } + + function getNetworkIcon(strength: int): string { + if (strength >= 80) + return "signal_wifi_4_bar"; + if (strength >= 60) + return "network_wifi_3_bar"; + if (strength >= 40) + return "network_wifi_2_bar"; + if (strength >= 20) + return "network_wifi_1_bar"; + return "signal_wifi_0_bar"; + } + + function getBluetoothIcon(icon: string): string { + if (icon.includes("headset") || icon.includes("headphones")) + return "headphones"; + if (icon.includes("audio")) + return "speaker"; + if (icon.includes("phone")) + return "smartphone"; + if (icon.includes("mouse")) + return "mouse"; + return "bluetooth"; + } + + function getWeatherIcon(code: string): string { + if (weatherIcons.hasOwnProperty(code)) + return weatherIcons[code]; + return "air"; + } + + function getHumidityIcon(humidity: real): string { + if (humidity >= 0.66) + return "humidity_high"; + if (humidity >= 0.33) + return "humidity_mid"; + return "humidity_low"; + } + + function getVolumeIcon(volume: real, isMuted: bool): string { + if (isMuted) + return "no_sound"; + if (volume >= 0.5) + return "volume_up"; + if (volume > 0) + return "volume_down"; + return "volume_mute"; + } + + function getMicVolumeIcon(volume: real, isMuted: bool): string { + if (!isMuted && volume > 0) + return "mic"; + return "mic_off"; + } + + function getBrightnessIcon(brightness: real): string { + return `brightness_${(Math.round(brightness * 6) + 1)}`; + } + + function getNotifIcon(summary: string, urgency: int): string { + summary = summary.toLowerCase(); + if (summary.includes("reboot")) + return "restart_alt"; + if (summary.includes("record")) + return "screen_record"; + if (summary.includes("battery")) + return "power"; + if (summary.includes("screenshot")) + return "screenshot_monitor"; + if (summary.includes("welcome")) + return "waving_hand"; + if (summary.includes("time") || summary.includes("a break")) + return "schedule"; + if (summary.includes("installed")) + return "download"; + if (summary.includes("update")) + return "update"; + if (summary.includes("unable to")) + return "deployed_code_alert"; + if (summary.includes("profile")) + return "person"; + if (summary.includes("image")) + return "image"; + if (summary.includes("file")) + return "folder_copy"; + if (urgency === NotificationUrgency.Critical) + return "release_alert"; + return "chat"; + } +} diff --git a/util/Searcher.qml b/util/Searcher.qml new file mode 100644 index 0000000..053b73b --- /dev/null +++ b/util/Searcher.qml @@ -0,0 +1,56 @@ +import Quickshell + +import "scripts/fzf.js" as Fzf +import "scripts/fuzzysort.js" as Fuzzy +import QtQuick + +Singleton { + required property list list + property string key: "name" + property bool useFuzzy: false + property var extraOpts: ({}) + + // Extra stuff for fuzzy + property list keys: [key] + property list weights: [1] + + readonly property var fzf: useFuzzy ? [] : new Fzf.Finder(list, Object.assign({ + selector + }, extraOpts)) + readonly property list fuzzyPrepped: useFuzzy ? list.map(e => { + const obj = { + _item: e + }; + for (const k of keys) + obj[k] = Fuzzy.prepare(e[k]); + return obj; + }) : [] + + function transformSearch(search: string): string { + return search; + } + + function selector(item: var): string { + // Only for fzf + return item[key]; + } + + function query(search: string): list { + search = transformSearch(search); + if (!search) + return [...list]; + + if (useFuzzy) + return Fuzzy.go(search, fuzzyPrepped, Object.assign({ + all: true, + keys, + scoreFn: r => weights.reduce((a, w, i) => a + r[i].score * w, 0) + }, extraOpts)).map(r => r.obj._item); + + return fzf.find(search).sort((a, b) => { + if (a.score === b.score) + return selector(a.item).trim().length - selector(b.item).trim().length; + return b.score - a.score; + }).map(r => r.item); + } +} diff --git a/util/States.qml b/util/States.qml new file mode 100644 index 0000000..ef5cc85 --- /dev/null +++ b/util/States.qml @@ -0,0 +1,16 @@ +pragma Singleton + +import Quickshell +import qs.services + +Singleton { + property var screens: new Map() + + function load(screen: ShellScreen, uiState: var): void { + screens.set(Hypr.monitorFor(screen), uiState); + } + + function getForActive(): PersistentProperties { + return screens.get(Hypr.focusedMonitor); + } +} diff --git a/util/scripts/fuzzysort.js b/util/scripts/fuzzysort.js new file mode 100644 index 0000000..7d4521d --- /dev/null +++ b/util/scripts/fuzzysort.js @@ -0,0 +1,704 @@ +.pragma library + +/* +https://github.com/farzher/fuzzysort + +MIT License + +Copyright (c) 2018 Stephen Kamenar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +var single = (search, target) => { + if(!search || !target) return NULL + + var preparedSearch = getPreparedSearch(search) + if(!isPrepared(target)) target = getPrepared(target) + + var searchBitflags = preparedSearch.bitflags + if((searchBitflags & target._bitflags) !== searchBitflags) return NULL + + return algorithm(preparedSearch, target) +} + +var go = (search, targets, options) => { + if(!search) return options?.all ? all(targets, options) : noResults + + var preparedSearch = getPreparedSearch(search) + var searchBitflags = preparedSearch.bitflags + var containsSpace = preparedSearch.containsSpace + + var threshold = denormalizeScore( options?.threshold || 0 ) + var limit = options?.limit || INFINITY + + var resultsLen = 0; var limitedCount = 0 + var targetsLen = targets.length + + function push_result(result) { + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result._score > q.peek()._score) q.replaceTop(result) + } + } + + // This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys] + + // options.key + if(options?.key) { + var key = options.key + for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + var target = getValue(obj, key) + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + result.obj = obj + push_result(result) + } + + // options.keys + } else if(options?.keys) { + var keys = options.keys + var keysLen = keys.length + + outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + + { // early out based on bitflags + var keysBitflags = 0 + for (var keyI = 0; keyI < keysLen; ++keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { tmpTargets[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + tmpTargets[keyI] = target + + keysBitflags |= target._bitflags + } + + if((searchBitflags & keysBitflags) !== searchBitflags) continue + } + + if(containsSpace) for(let i=0; i -1000) { + if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) { + var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/ + if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp + } + } + if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i] + } + } + + if(containsSpace) { + for(let i=0; i -1000) { + if(score > NEGATIVE_INFINITY) { + var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/ + if(tmp > score) score = tmp + } + } + if(result._score > score) score = result._score + } + } + + objResults.obj = obj + objResults._score = score + if(options?.scoreFn) { + score = options.scoreFn(objResults) + if(!score) continue + score = denormalizeScore(score) + objResults._score = score + } + + if(score < threshold) continue + push_result(objResults) + } + + // no keys + } else { + for(var i = 0; i < targetsLen; ++i) { var target = targets[i] + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + push_result(result) + } + } + + if(resultsLen === 0) return noResults + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + return results +} + + +// this is written as 1 function instead of 2 for minification. perf seems fine ... +// except when minified. the perf is very slow +var highlight = (result, open='', close='') => { + var callback = typeof open === 'function' ? open : undefined + + var target = result.target + var targetLen = target.length + var indexes = result.indexes + var highlighted = '' + var matchI = 0 + var indexesI = 0 + var opened = false + var parts = [] + + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(indexes[indexesI] === i) { + ++indexesI + if(!opened) { opened = true + if(callback) { + parts.push(highlighted); highlighted = '' + } else { + highlighted += open + } + } + + if(indexesI === indexes.length) { + if(callback) { + highlighted += char + parts.push(callback(highlighted, matchI++)); highlighted = '' + parts.push(target.substr(i+1)) + } else { + highlighted += char + close + target.substr(i+1) + } + break + } + } else { + if(opened) { opened = false + if(callback) { + parts.push(callback(highlighted, matchI++)); highlighted = '' + } else { + highlighted += close + } + } + } + highlighted += char + } + + return callback ? parts : highlighted +} + + +var prepare = (target) => { + if(typeof target === 'number') target = ''+target + else if(typeof target !== 'string') target = '' + var info = prepareLowerInfo(target) + return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags}) +} + +var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() } + + +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code + + +class Result { + get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) } + set ['indexes'](indexes) { return this._indexes = indexes } + ['highlight'](open, close) { return highlight(this, open, close) } + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +class KeysResult extends Array { + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +var new_result = (target, options) => { + const result = new Result() + result['target'] = target + result['obj'] = options.obj ?? NULL + result._score = options._score ?? NEGATIVE_INFINITY + result._indexes = options._indexes ?? [] + result._targetLower = options._targetLower ?? '' + result._targetLowerCodes = options._targetLowerCodes ?? NULL + result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL + result._bitflags = options._bitflags ?? 0 + return result +} + + +var normalizeScore = score => { + if(score === NEGATIVE_INFINITY) return 0 + if(score > 1) return score + return Math.E ** ( ((-score + 1)**.04307 - 1) * -2) +} +var denormalizeScore = normalizedScore => { + if(normalizedScore === 0) return NEGATIVE_INFINITY + if(normalizedScore > 1) return normalizedScore + return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307) +} + + +var prepareSearch = (search) => { + if(typeof search === 'number') search = ''+search + else if(typeof search !== 'string') search = '' + search = search.trim() + var info = prepareLowerInfo(search) + + var spaceSearches = [] + if(info.containsSpace) { + var searches = search.split(/\s+/) + searches = [...new Set(searches)] // distinct + for(var i=0; i { + if(target.length > 999) return prepare(target) // don't cache huge targets + var targetPrepared = preparedCache.get(target) + if(targetPrepared !== undefined) return targetPrepared + targetPrepared = prepare(target) + preparedCache.set(target, targetPrepared) + return targetPrepared +} +var getPreparedSearch = (search) => { + if(search.length > 999) return prepareSearch(search) // don't cache huge searches + var searchPrepared = preparedSearchCache.get(search) + if(searchPrepared !== undefined) return searchPrepared + searchPrepared = prepareSearch(search) + preparedSearchCache.set(search, searchPrepared) + return searchPrepared +} + + +var all = (targets, options) => { + var results = []; results.total = targets.length // this total can be wrong if some targets are skipped + + var limit = options?.limit || INFINITY + + if(options?.key) { + for(var i=0;i= limit) return results + } + } else if(options?.keys) { + for(var i=0;i= 0; --keyI) { + var target = getValue(obj, options.keys[keyI]) + if(!target) { objResults[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + target._score = NEGATIVE_INFINITY + target._indexes.len = 0 + objResults[keyI] = target + } + objResults.obj = obj + objResults._score = NEGATIVE_INFINITY + results.push(objResults); if(results.length >= limit) return results + } + } else { + for(var i=0;i= limit) return results + } + } + + return results +} + + +var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => { + if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch) + + var searchLower = preparedSearch._lower + var searchLowerCodes = preparedSearch.lowerCodes + var searchLowerCode = searchLowerCodes[0] + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[searchI] + } + ++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI + } + + var searchI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target) + targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + var backtrackCount = 0 + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) break // We failed to push chars forward for a better match + + ++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + // check if it's a substring match + var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow + var isSubstring = !!~substringIndex + var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex + + // if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score + if(isSubstring && !isSubstringBeginning) { + for(var i=0; i { + var score = 0 + + var extraMatchGroupCount = 0 + for(var i = 1; i < searchLen; ++i) { + if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount} + } + var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1) + + score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups + + if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning + + if(!successStrict) { + score *= 1000 + } else { + // successStrict on a target with too many beginning indexes loses points for being a bad target + var uniqueBeginningIndexes = 1 + for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes + + if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ... + } + + score -= (targetLen - searchLen)/2 // penality for longer targets + + if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring + if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex + + score -= (targetLen - searchLen)/2 // penality for longer targets + + return score + } + + if(!successStrict) { + if(isSubstring) for(var i=0; i { + var seen_indexes = new Set() + var score = 0 + var result = NULL + + var first_seen_index_last_search = 0 + var searches = preparedSearch.spaceSearches + var searchesLen = searches.length + var changeslen = 0 + + // Return _nextBeginningIndexes back to its normal state + var resetNextBeginningIndexes = () => { + for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1] + } + + var hasAtLeast1Match = false + for(var i=0; i=0; i--) { + if(toReplace !== target._nextBeginningIndexes[i]) break + target._nextBeginningIndexes[i] = newBeginningIndex + nextBeginningIndexesChanges[changeslen*2 + 0] = i + nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace + changeslen++ + } + } + } + + score += result._score / searchesLen + allowPartialMatchScores[i] = result._score / searchesLen + + // dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h + if(result._indexes[0] < first_seen_index_last_search) { + score -= (first_seen_index_last_search - result._indexes[0]) * 2 + } + first_seen_index_last_search = result._indexes[0] + + for(var j=0; j score) { + if(allowPartialMatch) { + for(var i=0; i str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '') + +var prepareLowerInfo = (str) => { + str = remove_accents(str) + var strLen = str.length + var lower = str.toLowerCase() + var lowerCodes = [] // new Array(strLen) sparse array is too slow + var bitflags = 0 + var containsSpace = false // space isn't stored in bitflags because of how searching with a space works + + for(var i = 0; i < strLen; ++i) { + var lowerCode = lowerCodes[i] = lower.charCodeAt(i) + + if(lowerCode === 32) { + containsSpace = true + continue // it's important that we don't set any bitflags for space + } + + var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet + : lowerCode>=48&&lowerCode<=57 ? 26 // numbers + // 3 bits available + : lowerCode<=127 ? 30 // other ascii + : 31 // other utf8 + bitflags |= 1< { + var targetLen = target.length + var beginningIndexes = []; var beginningIndexesLen = 0 + var wasUpper = false + var wasAlphanum = false + for(var i = 0; i < targetLen; ++i) { + var targetCode = target.charCodeAt(i) + var isUpper = targetCode>=65&&targetCode<=90 + var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57 + var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum + wasUpper = isUpper + wasAlphanum = isAlphanum + if(isBeginning) beginningIndexes[beginningIndexesLen++] = i + } + return beginningIndexes +} +var prepareNextBeginningIndexes = (target) => { + target = remove_accents(target) + var targetLen = target.length + var beginningIndexes = prepareBeginningIndexes(target) + var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow + var lastIsBeginning = beginningIndexes[0] + var lastIsBeginningI = 0 + for(var i = 0; i < targetLen; ++i) { + if(lastIsBeginning > i) { + nextBeginningIndexes[i] = lastIsBeginning + } else { + lastIsBeginning = beginningIndexes[++lastIsBeginningI] + nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning + } + } + return nextBeginningIndexes +} + +var preparedCache = new Map() +var preparedSearchCache = new Map() + +// the theory behind these being globals is to reduce garbage collection by not making new arrays +var matchesSimple = []; var matchesStrict = [] +var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search +var keysSpacesBestScores = []; var allowPartialMatchScores = [] +var tmpTargets = []; var tmpResults = [] + +// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop] +// prop = 'key1.key2' 10ms +// prop = ['key1', 'key2'] 27ms +// prop = obj => obj.tags.join() ??ms +var getValue = (obj, prop) => { + var tmp = obj[prop]; if(tmp !== undefined) return tmp + if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower + var segs = prop + if(!Array.isArray(prop)) segs = prop.split('.') + var len = segs.length + var i = -1 + while (obj && (++i < len)) obj = obj[segs[i]] + return obj +} + +var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' } +var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY +var noResults = []; noResults.total = 0 +var NULL = null + +var noTarget = prepare('') + +// Hacked version of https://github.com/lemire/FastPriorityQueue.js +var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a} +var q = fastpriorityqueue() // reuse this diff --git a/util/scripts/fzf.js b/util/scripts/fzf.js new file mode 100644 index 0000000..995a093 --- /dev/null +++ b/util/scripts/fzf.js @@ -0,0 +1,1307 @@ +.pragma library + +/* +https://github.com/ajitid/fzf-for-js + +BSD 3-Clause License + +Copyright (c) 2021, Ajit +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +const normalized = { + 216: "O", + 223: "s", + 248: "o", + 273: "d", + 295: "h", + 305: "i", + 320: "l", + 322: "l", + 359: "t", + 383: "s", + 384: "b", + 385: "B", + 387: "b", + 390: "O", + 392: "c", + 393: "D", + 394: "D", + 396: "d", + 398: "E", + 400: "E", + 402: "f", + 403: "G", + 407: "I", + 409: "k", + 410: "l", + 412: "M", + 413: "N", + 414: "n", + 415: "O", + 421: "p", + 427: "t", + 429: "t", + 430: "T", + 434: "V", + 436: "y", + 438: "z", + 477: "e", + 485: "g", + 544: "N", + 545: "d", + 549: "z", + 564: "l", + 565: "n", + 566: "t", + 567: "j", + 570: "A", + 571: "C", + 572: "c", + 573: "L", + 574: "T", + 575: "s", + 576: "z", + 579: "B", + 580: "U", + 581: "V", + 582: "E", + 583: "e", + 584: "J", + 585: "j", + 586: "Q", + 587: "q", + 588: "R", + 589: "r", + 590: "Y", + 591: "y", + 592: "a", + 593: "a", + 595: "b", + 596: "o", + 597: "c", + 598: "d", + 599: "d", + 600: "e", + 603: "e", + 604: "e", + 605: "e", + 606: "e", + 607: "j", + 608: "g", + 609: "g", + 610: "G", + 613: "h", + 614: "h", + 616: "i", + 618: "I", + 619: "l", + 620: "l", + 621: "l", + 623: "m", + 624: "m", + 625: "m", + 626: "n", + 627: "n", + 628: "N", + 629: "o", + 633: "r", + 634: "r", + 635: "r", + 636: "r", + 637: "r", + 638: "r", + 639: "r", + 640: "R", + 641: "R", + 642: "s", + 647: "t", + 648: "t", + 649: "u", + 651: "v", + 652: "v", + 653: "w", + 654: "y", + 655: "Y", + 656: "z", + 657: "z", + 663: "c", + 665: "B", + 666: "e", + 667: "G", + 668: "H", + 669: "j", + 670: "k", + 671: "L", + 672: "q", + 686: "h", + 867: "a", + 868: "e", + 869: "i", + 870: "o", + 871: "u", + 872: "c", + 873: "d", + 874: "h", + 875: "m", + 876: "r", + 877: "t", + 878: "v", + 879: "x", + 7424: "A", + 7427: "B", + 7428: "C", + 7429: "D", + 7431: "E", + 7432: "e", + 7433: "i", + 7434: "J", + 7435: "K", + 7436: "L", + 7437: "M", + 7438: "N", + 7439: "O", + 7440: "O", + 7441: "o", + 7442: "o", + 7443: "o", + 7446: "o", + 7447: "o", + 7448: "P", + 7449: "R", + 7450: "R", + 7451: "T", + 7452: "U", + 7453: "u", + 7454: "u", + 7455: "m", + 7456: "V", + 7457: "W", + 7458: "Z", + 7522: "i", + 7523: "r", + 7524: "u", + 7525: "v", + 7834: "a", + 7835: "s", + 8305: "i", + 8341: "h", + 8342: "k", + 8343: "l", + 8344: "m", + 8345: "n", + 8346: "p", + 8347: "s", + 8348: "t", + 8580: "c" +}; +for (let i = "\u0300".codePointAt(0); i <= "\u036F".codePointAt(0); ++i) { + const diacritic = String.fromCodePoint(i); + for (const asciiChar of "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") { + const withDiacritic = (asciiChar + diacritic).normalize(); + const withDiacriticCodePoint = withDiacritic.codePointAt(0); + if (withDiacriticCodePoint > 126) { + normalized[withDiacriticCodePoint] = asciiChar; + } + } +} +const ranges = { + a: [7844, 7863], + e: [7870, 7879], + o: [7888, 7907], + u: [7912, 7921] +}; +for (const lowerChar of Object.keys(ranges)) { + const upperChar = lowerChar.toUpperCase(); + for (let i = ranges[lowerChar][0]; i <= ranges[lowerChar][1]; ++i) { + normalized[i] = i % 2 === 0 ? upperChar : lowerChar; + } +} +function normalizeRune(rune) { + if (rune < 192 || rune > 8580) { + return rune; + } + const normalizedChar = normalized[rune]; + if (normalizedChar !== void 0) + return normalizedChar.codePointAt(0); + return rune; +} +function toShort(number) { + return number; +} +function toInt(number) { + return number; +} +function maxInt16(num1, num2) { + return num1 > num2 ? num1 : num2; +} +const strToRunes = (str) => str.split("").map((s) => s.codePointAt(0)); +const runesToStr = (runes) => runes.map((r) => String.fromCodePoint(r)).join(""); +const whitespaceRunes = new Set( + " \f\n\r \v\xA0\u1680\u2028\u2029\u202F\u205F\u3000\uFEFF".split("").map((v) => v.codePointAt(0)) +); +for (let codePoint = "\u2000".codePointAt(0); codePoint <= "\u200A".codePointAt(0); codePoint++) { + whitespaceRunes.add(codePoint); +} +const isWhitespace = (rune) => whitespaceRunes.has(rune); +const whitespacesAtStart = (runes) => { + let whitespaces = 0; + for (const rune of runes) { + if (isWhitespace(rune)) + whitespaces++; + else + break; + } + return whitespaces; +}; +const whitespacesAtEnd = (runes) => { + let whitespaces = 0; + for (let i = runes.length - 1; i >= 0; i--) { + if (isWhitespace(runes[i])) + whitespaces++; + else + break; + } + return whitespaces; +}; +const MAX_ASCII = "\x7F".codePointAt(0); +const CAPITAL_A_RUNE = "A".codePointAt(0); +const CAPITAL_Z_RUNE = "Z".codePointAt(0); +const SMALL_A_RUNE = "a".codePointAt(0); +const SMALL_Z_RUNE = "z".codePointAt(0); +const NUMERAL_ZERO_RUNE = "0".codePointAt(0); +const NUMERAL_NINE_RUNE = "9".codePointAt(0); +function indexAt(index, max, forward) { + if (forward) { + return index; + } + return max - index - 1; +} +const SCORE_MATCH = 16, SCORE_GAP_START = -3, SCORE_GAP_EXTENTION = -1, BONUS_BOUNDARY = SCORE_MATCH / 2, BONUS_NON_WORD = SCORE_MATCH / 2, BONUS_CAMEL_123 = BONUS_BOUNDARY + SCORE_GAP_EXTENTION, BONUS_CONSECUTIVE = -(SCORE_GAP_START + SCORE_GAP_EXTENTION), BONUS_FIRST_CHAR_MULTIPLIER = 2; +function createPosSet(withPos) { + if (withPos) { + return /* @__PURE__ */ new Set(); + } + return null; +} +function alloc16(offset, slab2, size) { + if (slab2 !== null && slab2.i16.length > offset + size) { + const subarray = slab2.i16.subarray(offset, offset + size); + return [offset + size, subarray]; + } + return [offset, new Int16Array(size)]; +} +function alloc32(offset, slab2, size) { + if (slab2 !== null && slab2.i32.length > offset + size) { + const subarray = slab2.i32.subarray(offset, offset + size); + return [offset + size, subarray]; + } + return [offset, new Int32Array(size)]; +} +function charClassOfAscii(rune) { + if (rune >= SMALL_A_RUNE && rune <= SMALL_Z_RUNE) { + return 1; + } else if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + return 2; + } else if (rune >= NUMERAL_ZERO_RUNE && rune <= NUMERAL_NINE_RUNE) { + return 4; + } else { + return 0; + } +} +function charClassOfNonAscii(rune) { + const char = String.fromCodePoint(rune); + if (char !== char.toUpperCase()) { + return 1; + } else if (char !== char.toLowerCase()) { + return 2; + } else if (char.match(/\p{Number}/gu) !== null) { + return 4; + } else if (char.match(/\p{Letter}/gu) !== null) { + return 3; + } + return 0; +} +function charClassOf(rune) { + if (rune <= MAX_ASCII) { + return charClassOfAscii(rune); + } + return charClassOfNonAscii(rune); +} +function bonusFor(prevClass, currClass) { + if (prevClass === 0 && currClass !== 0) { + return BONUS_BOUNDARY; + } else if (prevClass === 1 && currClass === 2 || prevClass !== 4 && currClass === 4) { + return BONUS_CAMEL_123; + } else if (currClass === 0) { + return BONUS_NON_WORD; + } + return 0; +} +function bonusAt(input, idx) { + if (idx === 0) { + return BONUS_BOUNDARY; + } + return bonusFor(charClassOf(input[idx - 1]), charClassOf(input[idx])); +} +function trySkip(input, caseSensitive, char, from) { + let rest = input.slice(from); + let idx = rest.indexOf(char); + if (idx === 0) { + return from; + } + if (!caseSensitive && char >= SMALL_A_RUNE && char <= SMALL_Z_RUNE) { + if (idx > 0) { + rest = rest.slice(0, idx); + } + const uidx = rest.indexOf(char - 32); + if (uidx >= 0) { + idx = uidx; + } + } + if (idx < 0) { + return -1; + } + return from + idx; +} +function isAscii(runes) { + for (const rune of runes) { + if (rune >= 128) { + return false; + } + } + return true; +} +function asciiFuzzyIndex(input, pattern, caseSensitive) { + if (!isAscii(input)) { + return 0; + } + if (!isAscii(pattern)) { + return -1; + } + let firstIdx = 0, idx = 0; + for (let pidx = 0; pidx < pattern.length; pidx++) { + idx = trySkip(input, caseSensitive, pattern[pidx], idx); + if (idx < 0) { + return -1; + } + if (pidx === 0 && idx > 0) { + firstIdx = idx - 1; + } + idx++; + } + return firstIdx; +} +const fuzzyMatchV2 = (caseSensitive, normalize, forward, input, pattern, withPos, slab2) => { + const M = pattern.length; + if (M === 0) { + return [{ start: 0, end: 0, score: 0 }, createPosSet(withPos)]; + } + const N = input.length; + if (slab2 !== null && N * M > slab2.i16.length) { + return fuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos); + } + const idx = asciiFuzzyIndex(input, pattern, caseSensitive); + if (idx < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let offset16 = 0, offset32 = 0, H0 = null, C0 = null, B = null, F = null; + [offset16, H0] = alloc16(offset16, slab2, N); + [offset16, C0] = alloc16(offset16, slab2, N); + [offset16, B] = alloc16(offset16, slab2, N); + [offset32, F] = alloc32(offset32, slab2, M); + const [, T] = alloc32(offset32, slab2, N); + for (let i = 0; i < T.length; i++) { + T[i] = input[i]; + } + let maxScore = toShort(0), maxScorePos = 0; + let pidx = 0, lastIdx = 0; + const pchar0 = pattern[0]; + let pchar = pattern[0], prevH0 = toShort(0), prevCharClass = 0, inGap = false; + let Tsub = T.subarray(idx); + let H0sub = H0.subarray(idx).subarray(0, Tsub.length), C0sub = C0.subarray(idx).subarray(0, Tsub.length), Bsub = B.subarray(idx).subarray(0, Tsub.length); + for (let [off, char] of Tsub.entries()) { + let charClass = null; + if (char <= MAX_ASCII) { + charClass = charClassOfAscii(char); + if (!caseSensitive && charClass === 2) { + char += 32; + } + } else { + charClass = charClassOfNonAscii(char); + if (!caseSensitive && charClass === 2) { + char = String.fromCodePoint(char).toLowerCase().codePointAt(0); + } + if (normalize) { + char = normalizeRune(char); + } + } + Tsub[off] = char; + const bonus = bonusFor(prevCharClass, charClass); + Bsub[off] = bonus; + prevCharClass = charClass; + if (char === pchar) { + if (pidx < M) { + F[pidx] = toInt(idx + off); + pidx++; + pchar = pattern[Math.min(pidx, M - 1)]; + } + lastIdx = idx + off; + } + if (char === pchar0) { + const score = SCORE_MATCH + bonus * BONUS_FIRST_CHAR_MULTIPLIER; + H0sub[off] = score; + C0sub[off] = 1; + if (M === 1 && (forward && score > maxScore || !forward && score >= maxScore)) { + maxScore = score; + maxScorePos = idx + off; + if (forward && bonus === BONUS_BOUNDARY) { + break; + } + } + inGap = false; + } else { + if (inGap) { + H0sub[off] = maxInt16(prevH0 + SCORE_GAP_EXTENTION, 0); + } else { + H0sub[off] = maxInt16(prevH0 + SCORE_GAP_START, 0); + } + C0sub[off] = 0; + inGap = true; + } + prevH0 = H0sub[off]; + } + if (pidx !== M) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + if (M === 1) { + const result = { + start: maxScorePos, + end: maxScorePos + 1, + score: maxScore + }; + if (!withPos) { + return [result, null]; + } + const pos2 = /* @__PURE__ */ new Set(); + pos2.add(maxScorePos); + return [result, pos2]; + } + const f0 = F[0]; + const width = lastIdx - f0 + 1; + let H = null; + [offset16, H] = alloc16(offset16, slab2, width * M); + { + const toCopy = H0.subarray(f0, lastIdx + 1); + for (const [i, v] of toCopy.entries()) { + H[i] = v; + } + } + let [, C] = alloc16(offset16, slab2, width * M); + { + const toCopy = C0.subarray(f0, lastIdx + 1); + for (const [i, v] of toCopy.entries()) { + C[i] = v; + } + } + const Fsub = F.subarray(1); + const Psub = pattern.slice(1).slice(0, Fsub.length); + for (const [off, f] of Fsub.entries()) { + let inGap2 = false; + const pchar2 = Psub[off], pidx2 = off + 1, row = pidx2 * width, Tsub2 = T.subarray(f, lastIdx + 1), Bsub2 = B.subarray(f).subarray(0, Tsub2.length), Csub = C.subarray(row + f - f0).subarray(0, Tsub2.length), Cdiag = C.subarray(row + f - f0 - 1 - width).subarray(0, Tsub2.length), Hsub = H.subarray(row + f - f0).subarray(0, Tsub2.length), Hdiag = H.subarray(row + f - f0 - 1 - width).subarray(0, Tsub2.length), Hleft = H.subarray(row + f - f0 - 1).subarray(0, Tsub2.length); + Hleft[0] = 0; + for (const [off2, char] of Tsub2.entries()) { + const col = off2 + f; + let s1 = 0, s2 = 0, consecutive = 0; + if (inGap2) { + s2 = Hleft[off2] + SCORE_GAP_EXTENTION; + } else { + s2 = Hleft[off2] + SCORE_GAP_START; + } + if (pchar2 === char) { + s1 = Hdiag[off2] + SCORE_MATCH; + let b = Bsub2[off2]; + consecutive = Cdiag[off2] + 1; + if (b === BONUS_BOUNDARY) { + consecutive = 1; + } else if (consecutive > 1) { + b = maxInt16(b, maxInt16(BONUS_CONSECUTIVE, B[col - consecutive + 1])); + } + if (s1 + b < s2) { + s1 += Bsub2[off2]; + consecutive = 0; + } else { + s1 += b; + } + } + Csub[off2] = consecutive; + inGap2 = s1 < s2; + const score = maxInt16(maxInt16(s1, s2), 0); + if (pidx2 === M - 1 && (forward && score > maxScore || !forward && score >= maxScore)) { + maxScore = score; + maxScorePos = col; + } + Hsub[off2] = score; + } + } + const pos = createPosSet(withPos); + let j = f0; + if (withPos && pos !== null) { + let i = M - 1; + j = maxScorePos; + let preferMatch = true; + while (true) { + const I = i * width, j0 = j - f0, s = H[I + j0]; + let s1 = 0, s2 = 0; + if (i > 0 && j >= F[i]) { + s1 = H[I - width + j0 - 1]; + } + if (j > F[i]) { + s2 = H[I + j0 - 1]; + } + if (s > s1 && (s > s2 || s === s2 && preferMatch)) { + pos.add(j); + if (i === 0) { + break; + } + i--; + } + preferMatch = C[I + j0] > 1 || I + width + j0 + 1 < C.length && C[I + width + j0 + 1] > 0; + j--; + } + } + return [{ start: j, end: maxScorePos + 1, score: maxScore }, pos]; +}; +function calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, withPos) { + let pidx = 0, score = 0, inGap = false, consecutive = 0, firstBonus = toShort(0); + const pos = createPosSet(withPos); + let prevCharClass = 0; + if (sidx > 0) { + prevCharClass = charClassOf(text[sidx - 1]); + } + for (let idx = sidx; idx < eidx; idx++) { + let rune = text[idx]; + const charClass = charClassOf(rune); + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + if (normalize) { + rune = normalizeRune(rune); + } + if (rune === pattern[pidx]) { + if (withPos && pos !== null) { + pos.add(idx); + } + score += SCORE_MATCH; + let bonus = bonusFor(prevCharClass, charClass); + if (consecutive === 0) { + firstBonus = bonus; + } else { + if (bonus === BONUS_BOUNDARY) { + firstBonus = bonus; + } + bonus = maxInt16(maxInt16(bonus, firstBonus), BONUS_CONSECUTIVE); + } + if (pidx === 0) { + score += bonus * BONUS_FIRST_CHAR_MULTIPLIER; + } else { + score += bonus; + } + inGap = false; + consecutive++; + pidx++; + } else { + if (inGap) { + score += SCORE_GAP_EXTENTION; + } else { + score += SCORE_GAP_START; + } + inGap = true; + consecutive = 0; + firstBonus = 0; + } + prevCharClass = charClass; + } + return [score, pos]; +} +function fuzzyMatchV1(caseSensitive, normalize, forward, text, pattern, withPos, slab2) { + if (pattern.length === 0) { + return [{ start: 0, end: 0, score: 0 }, null]; + } + if (asciiFuzzyIndex(text, pattern, caseSensitive) < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let pidx = 0, sidx = -1, eidx = -1; + const lenRunes = text.length; + const lenPattern = pattern.length; + for (let index = 0; index < lenRunes; index++) { + let rune = text[indexAt(index, lenRunes, forward)]; + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + if (normalize) { + rune = normalizeRune(rune); + } + const pchar = pattern[indexAt(pidx, lenPattern, forward)]; + if (rune === pchar) { + if (sidx < 0) { + sidx = index; + } + pidx++; + if (pidx === lenPattern) { + eidx = index + 1; + break; + } + } + } + if (sidx >= 0 && eidx >= 0) { + pidx--; + for (let index = eidx - 1; index >= sidx; index--) { + const tidx = indexAt(index, lenRunes, forward); + let rune = text[tidx]; + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + const pidx_ = indexAt(pidx, lenPattern, forward); + const pchar = pattern[pidx_]; + if (rune === pchar) { + pidx--; + if (pidx < 0) { + sidx = index; + break; + } + } + } + if (!forward) { + const sidxTemp = sidx; + sidx = lenRunes - eidx; + eidx = lenRunes - sidxTemp; + } + const [score, pos] = calculateScore( + caseSensitive, + normalize, + text, + pattern, + sidx, + eidx, + withPos + ); + return [{ start: sidx, end: eidx, score }, pos]; + } + return [{ start: -1, end: -1, score: 0 }, null]; +}; +const exactMatchNaive = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + if (pattern.length === 0) { + return [{ start: 0, end: 0, score: 0 }, null]; + } + const lenRunes = text.length; + const lenPattern = pattern.length; + if (lenRunes < lenPattern) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + if (asciiFuzzyIndex(text, pattern, caseSensitive) < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let pidx = 0; + let bestPos = -1, bonus = toShort(0), bestBonus = toShort(-1); + for (let index = 0; index < lenRunes; index++) { + const index_ = indexAt(index, lenRunes, forward); + let rune = text[index_]; + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + if (normalize) { + rune = normalizeRune(rune); + } + const pidx_ = indexAt(pidx, lenPattern, forward); + const pchar = pattern[pidx_]; + if (pchar === rune) { + if (pidx_ === 0) { + bonus = bonusAt(text, index_); + } + pidx++; + if (pidx === lenPattern) { + if (bonus > bestBonus) { + bestPos = index; + bestBonus = bonus; + } + if (bonus === BONUS_BOUNDARY) { + break; + } + index -= pidx - 1; + pidx = 0; + bonus = 0; + } + } else { + index -= pidx; + pidx = 0; + bonus = 0; + } + } + if (bestPos >= 0) { + let sidx = 0, eidx = 0; + if (forward) { + sidx = bestPos - lenPattern + 1; + eidx = bestPos + 1; + } else { + sidx = lenRunes - (bestPos + 1); + eidx = lenRunes - (bestPos - lenPattern + 1); + } + const [score] = calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, false); + return [{ start: sidx, end: eidx, score }, null]; + } + return [{ start: -1, end: -1, score: 0 }, null]; +}; +const prefixMatch = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + if (pattern.length === 0) { + return [{ start: 0, end: 0, score: 0 }, null]; + } + let trimmedLen = 0; + if (!isWhitespace(pattern[0])) { + trimmedLen = whitespacesAtStart(text); + } + if (text.length - trimmedLen < pattern.length) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + for (const [index, r] of pattern.entries()) { + let rune = text[trimmedLen + index]; + if (!caseSensitive) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + if (normalize) { + rune = normalizeRune(rune); + } + if (rune !== r) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + } + const lenPattern = pattern.length; + const [score] = calculateScore( + caseSensitive, + normalize, + text, + pattern, + trimmedLen, + trimmedLen + lenPattern, + false + ); + return [{ start: trimmedLen, end: trimmedLen + lenPattern, score }, null]; +}; +const suffixMatch = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + const lenRunes = text.length; + let trimmedLen = lenRunes; + if (pattern.length === 0 || !isWhitespace(pattern[pattern.length - 1])) { + trimmedLen -= whitespacesAtEnd(text); + } + if (pattern.length === 0) { + return [{ start: trimmedLen, end: trimmedLen, score: 0 }, null]; + } + const diff = trimmedLen - pattern.length; + if (diff < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + for (const [index, r] of pattern.entries()) { + let rune = text[index + diff]; + if (!caseSensitive) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + if (normalize) { + rune = normalizeRune(rune); + } + if (rune !== r) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + } + const lenPattern = pattern.length; + const sidx = trimmedLen - lenPattern; + const eidx = trimmedLen; + const [score] = calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, false); + return [{ start: sidx, end: eidx, score }, null]; +}; +const equalMatch = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + const lenPattern = pattern.length; + if (lenPattern === 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let trimmedLen = 0; + if (!isWhitespace(pattern[0])) { + trimmedLen = whitespacesAtStart(text); + } + let trimmedEndLen = 0; + if (!isWhitespace(pattern[lenPattern - 1])) { + trimmedEndLen = whitespacesAtEnd(text); + } + if (text.length - trimmedLen - trimmedEndLen != lenPattern) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let match = true; + if (normalize) { + const runes = text; + for (const [idx, pchar] of pattern.entries()) { + let rune = runes[trimmedLen + idx]; + if (!caseSensitive) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + if (normalizeRune(pchar) !== normalizeRune(rune)) { + match = false; + break; + } + } + } else { + let runesStr = runesToStr(text).substring(trimmedLen, text.length - trimmedEndLen); + if (!caseSensitive) { + runesStr = runesStr.toLowerCase(); + } + match = runesStr === runesToStr(pattern); + } + if (match) { + return [ + { + start: trimmedLen, + end: trimmedLen + lenPattern, + score: (SCORE_MATCH + BONUS_BOUNDARY) * lenPattern + (BONUS_FIRST_CHAR_MULTIPLIER - 1) * BONUS_BOUNDARY + }, + null + ]; + } + return [{ start: -1, end: -1, score: 0 }, null]; +}; +const SLAB_16_SIZE = 100 * 1024; +const SLAB_32_SIZE = 2048; +function makeSlab(size16, size32) { + return { + i16: new Int16Array(size16), + i32: new Int32Array(size32) + }; +} +const slab = makeSlab(SLAB_16_SIZE, SLAB_32_SIZE); +var TermType = /* @__PURE__ */ ((TermType2) => { + TermType2[TermType2["Fuzzy"] = 0] = "Fuzzy"; + TermType2[TermType2["Exact"] = 1] = "Exact"; + TermType2[TermType2["Prefix"] = 2] = "Prefix"; + TermType2[TermType2["Suffix"] = 3] = "Suffix"; + TermType2[TermType2["Equal"] = 4] = "Equal"; + return TermType2; +})(TermType || {}); +const termTypeMap = { + [0]: fuzzyMatchV2, + [1]: exactMatchNaive, + [2]: prefixMatch, + [3]: suffixMatch, + [4]: equalMatch +}; +function buildPatternForExtendedMatch(fuzzy, caseMode, normalize, str) { + let cacheable = true; + str = str.trimLeft(); + { + const trimmedAtRightStr = str.trimRight(); + if (trimmedAtRightStr.endsWith("\\") && str[trimmedAtRightStr.length] === " ") { + str = trimmedAtRightStr + " "; + } else { + str = trimmedAtRightStr; + } + } + let sortable = false; + let termSets = []; + termSets = parseTerms(fuzzy, caseMode, normalize, str); + Loop: + for (const termSet of termSets) { + for (const [idx, term] of termSet.entries()) { + if (!term.inv) { + sortable = true; + } + if (!cacheable || idx > 0 || term.inv || fuzzy && term.typ !== 0 || !fuzzy && term.typ !== 1) { + cacheable = false; + if (sortable) { + break Loop; + } + } + } + } + return { + str, + termSets, + sortable, + cacheable, + fuzzy + }; +} +function parseTerms(fuzzy, caseMode, normalize, str) { + str = str.replace(/\\ /g, " "); + const tokens = str.split(/ +/); + const sets = []; + let set = []; + let switchSet = false; + let afterBar = false; + for (const token of tokens) { + let typ = 0, inv = false, text = token.replace(/\t/g, " "); + const lowerText = text.toLowerCase(); + const caseSensitive = caseMode === "case-sensitive" || caseMode === "smart-case" && text !== lowerText; + const normalizeTerm = normalize && lowerText === runesToStr(strToRunes(lowerText).map(normalizeRune)); + if (!caseSensitive) { + text = lowerText; + } + if (!fuzzy) { + typ = 1; + } + if (set.length > 0 && !afterBar && text === "|") { + switchSet = false; + afterBar = true; + continue; + } + afterBar = false; + if (text.startsWith("!")) { + inv = true; + typ = 1; + text = text.substring(1); + } + if (text !== "$" && text.endsWith("$")) { + typ = 3; + text = text.substring(0, text.length - 1); + } + if (text.startsWith("'")) { + if (fuzzy && !inv) { + typ = 1; + } else { + typ = 0; + } + text = text.substring(1); + } else if (text.startsWith("^")) { + if (typ === 3) { + typ = 4; + } else { + typ = 2; + } + text = text.substring(1); + } + if (text.length > 0) { + if (switchSet) { + sets.push(set); + set = []; + } + let textRunes = strToRunes(text); + if (normalizeTerm) { + textRunes = textRunes.map(normalizeRune); + } + set.push({ + typ, + inv, + text: textRunes, + caseSensitive, + normalize: normalizeTerm + }); + switchSet = true; + } + } + if (set.length > 0) { + sets.push(set); + } + return sets; +} +const buildPatternForBasicMatch = (query, casing, normalize) => { + let caseSensitive = false; + switch (casing) { + case "smart-case": + if (query.toLowerCase() !== query) { + caseSensitive = true; + } + break; + case "case-sensitive": + caseSensitive = true; + break; + case "case-insensitive": + query = query.toLowerCase(); + caseSensitive = false; + break; + } + let queryRunes = strToRunes(query); + if (normalize) { + queryRunes = queryRunes.map(normalizeRune); + } + return { + queryRunes, + caseSensitive + }; +}; +function iter(algoFn, tokens, caseSensitive, normalize, forward, pattern, slab2) { + for (const part of tokens) { + const [res, pos] = algoFn(caseSensitive, normalize, forward, part.text, pattern, true, slab2); + if (res.start >= 0) { + const sidx = res.start + part.prefixLength; + const eidx = res.end + part.prefixLength; + if (pos !== null) { + const newPos = /* @__PURE__ */ new Set(); + pos.forEach((v) => newPos.add(part.prefixLength + v)); + return [[sidx, eidx], res.score, newPos]; + } + return [[sidx, eidx], res.score, pos]; + } + } + return [[-1, -1], 0, null]; +} +function computeExtendedMatch(text, pattern, fuzzyAlgo, forward) { + const input = [ + { + text, + prefixLength: 0 + } + ]; + const offsets = []; + let totalScore = 0; + const allPos = /* @__PURE__ */ new Set(); + for (const termSet of pattern.termSets) { + let offset = [0, 0]; + let currentScore = 0; + let matched = false; + for (const term of termSet) { + let algoFn = termTypeMap[term.typ]; + if (term.typ === TermType.Fuzzy) { + algoFn = fuzzyAlgo; + } + const [off, score, pos] = iter( + algoFn, + input, + term.caseSensitive, + term.normalize, + forward, + term.text, + slab + ); + const sidx = off[0]; + if (sidx >= 0) { + if (term.inv) { + continue; + } + offset = off; + currentScore = score; + matched = true; + if (pos !== null) { + pos.forEach((v) => allPos.add(v)); + } else { + for (let idx = off[0]; idx < off[1]; ++idx) { + allPos.add(idx); + } + } + break; + } else if (term.inv) { + offset = [0, 0]; + currentScore = 0; + matched = true; + continue; + } + } + if (matched) { + offsets.push(offset); + totalScore += currentScore; + } + } + return { offsets, totalScore, allPos }; +} +function getResultFromScoreMap(scoreMap, limit) { + const scoresInDesc = Object.keys(scoreMap).map((v) => parseInt(v, 10)).sort((a, b) => b - a); + let result = []; + for (const score of scoresInDesc) { + result = result.concat(scoreMap[score]); + if (result.length >= limit) { + break; + } + } + return result; +} +function getBasicMatchIter(scoreMap, queryRunes, caseSensitive) { + return (idx) => { + const itemRunes = this.runesList[idx]; + if (queryRunes.length > itemRunes.length) + return; + let [match, positions] = this.algoFn( + caseSensitive, + this.opts.normalize, + this.opts.forward, + itemRunes, + queryRunes, + true, + slab + ); + if (match.start === -1) + return; + if (this.opts.fuzzy === false) { + positions = /* @__PURE__ */ new Set(); + for (let position = match.start; position < match.end; ++position) { + positions.add(position); + } + } + const scoreKey = this.opts.sort ? match.score : 0; + if (scoreMap[scoreKey] === void 0) { + scoreMap[scoreKey] = []; + } + scoreMap[scoreKey].push(Object.assign({ + item: this.items[idx], + positions: positions != null ? positions : /* @__PURE__ */ new Set() + }, match)); + }; +} +function getExtendedMatchIter(scoreMap, pattern) { + return (idx) => { + const runes = this.runesList[idx]; + const match = computeExtendedMatch(runes, pattern, this.algoFn, this.opts.forward); + if (match.offsets.length !== pattern.termSets.length) + return; + let sidx = -1, eidx = -1; + if (match.allPos.size > 0) { + sidx = Math.min(...match.allPos); + eidx = Math.max(...match.allPos) + 1; + } + const scoreKey = this.opts.sort ? match.totalScore : 0; + if (scoreMap[scoreKey] === void 0) { + scoreMap[scoreKey] = []; + } + scoreMap[scoreKey].push({ + score: match.totalScore, + item: this.items[idx], + positions: match.allPos, + start: sidx, + end: eidx + }); + }; +} +function basicMatch(query) { + const { queryRunes, caseSensitive } = buildPatternForBasicMatch( + query, + this.opts.casing, + this.opts.normalize + ); + const scoreMap = {}; + const iter2 = getBasicMatchIter.bind(this)( + scoreMap, + queryRunes, + caseSensitive + ); + for (let i = 0, len = this.runesList.length; i < len; ++i) { + iter2(i); + } + return getResultFromScoreMap(scoreMap, this.opts.limit); +} +function extendedMatch(query) { + const pattern = buildPatternForExtendedMatch( + Boolean(this.opts.fuzzy), + this.opts.casing, + this.opts.normalize, + query + ); + const scoreMap = {}; + const iter2 = getExtendedMatchIter.bind(this)(scoreMap, pattern); + for (let i = 0, len = this.runesList.length; i < len; ++i) { + iter2(i); + } + return getResultFromScoreMap(scoreMap, this.opts.limit); +} +const defaultOpts = { + limit: Infinity, + selector: (v) => v, + casing: "smart-case", + normalize: true, + fuzzy: "v2", + tiebreakers: [], + sort: true, + forward: true, + match: basicMatch +}; +class Finder { + constructor(list, ...optionsTuple) { + this.opts = Object.assign(defaultOpts, optionsTuple[0]); + this.items = list; + this.runesList = list.map((item) => strToRunes(this.opts.selector(item).normalize())); + this.algoFn = exactMatchNaive; + switch (this.opts.fuzzy) { + case "v2": + this.algoFn = fuzzyMatchV2; + break; + case "v1": + this.algoFn = fuzzyMatchV1; + break; + } + } + find(query) { + if (query.length === 0 || this.items.length === 0) + return this.items.slice(0, this.opts.limit).map(createResultItemWithEmptyPos); + query = query.normalize(); + let result = this.opts.match.bind(this)(query); + return postProcessResultItems(result, this.opts); + } +} +function createResultItemWithEmptyPos(item) { + return ({ + item, + start: -1, + end: -1, + score: 0, + positions: /* @__PURE__ */ new Set() + }) +}; +function postProcessResultItems(result, opts) { + if (opts.sort) { + const { selector } = opts; + result.sort((a, b) => { + if (a.score === b.score) { + for (const tiebreaker of opts.tiebreakers) { + const diff = tiebreaker(a, b, selector); + if (diff !== 0) { + return diff; + } + } + } + return 0; + }); + } + if (Number.isFinite(opts.limit)) { + result.splice(opts.limit); + } + return result; +} +function byLengthAsc(a, b, selector) { + return selector(a.item).length - selector(b.item).length; +} +function byStartAsc(a, b) { + return a.start - b.start; +}