diff --git a/assets/default.png b/assets/default.png new file mode 100644 index 0000000..774a4e0 Binary files /dev/null and b/assets/default.png differ diff --git a/assets/screenshots/home.png b/assets/screenshots/home.png deleted file mode 100644 index 134dbae..0000000 Binary files a/assets/screenshots/home.png and /dev/null differ diff --git a/assets/wallpaper/example-1.png b/assets/wallpapers_example/example-1.png similarity index 100% rename from assets/wallpaper/example-1.png rename to assets/wallpapers_example/example-1.png diff --git a/assets/wallpaper/example-2.png b/assets/wallpapers_example/example-2.png similarity index 100% rename from assets/wallpaper/example-2.png rename to assets/wallpapers_example/example-2.png diff --git a/assets/wallpaper/example-3.jpg b/assets/wallpapers_example/example-3.jpg similarity index 100% rename from assets/wallpaper/example-3.jpg rename to assets/wallpapers_example/example-3.jpg diff --git a/assets/wallpaper/example-4.jpeg b/assets/wallpapers_example/example-4.jpeg similarity index 100% rename from assets/wallpaper/example-4.jpeg rename to assets/wallpapers_example/example-4.jpeg diff --git a/assets/wallpaper/example-5.jpg b/assets/wallpapers_example/example-5.jpg similarity index 100% rename from assets/wallpaper/example-5.jpg rename to assets/wallpapers_example/example-5.jpg diff --git a/config/data.py b/config/data.py index d432d4c..0680ef1 100644 --- a/config/data.py +++ b/config/data.py @@ -12,8 +12,6 @@ APP_NAME_CAP = "Modus" ALLOWED_PLAYERS = ["vlc", "cmus", "firefox", "spotify", "chromium", "vivaldi", "brave"] -NOTIF_POS_KEY = "notif_pos" -NOTIF_POS_DEFAULT = "Top" CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}" @@ -28,9 +26,7 @@ CURRENT_WIDTH = screen.get_width() CURRENT_HEIGHT = screen.get_height() -# WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example") -WALLPAPERS_DIR_DEFAULT = os.path.join(HOME_DIR, "Pictures", "wallpapers") - +WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example") CONFIG_FILE = get_relative_path("../config/assets/config.json") MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") @@ -66,7 +62,7 @@ if os.path.exists(CONFIG_FILE): DOCK_AUTO_HIDE = config.get("dock_auto_hide", True) DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False) DOCK_THEME = config.get("dock_theme", "Pills") - DOCK_ICON_SIZE = config.get("dock_icon_size", 20) + DOCK_ICON_SIZE = config.get("dock_icon_size", 28) WORKSPACE_NUMS = config.get("workspace_nums", False) WORKSPACE_USE_CHINESE_NUMERAL = config.get("workspace_use_chinese_numerals", False) WORKSPACE_DOTS = config.get("workspace_dots", False) @@ -76,8 +72,6 @@ if os.path.exists(CONFIG_FILE): "dock_hide_special_workspace_apps", True ) - NOTIF_POS = config.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT) - DOCK_COMPONENTS_VISIBILITY = { "workspace": config.get("dock_workspace_visible", True), "metrics": config.get("dock_metrics_visible", True), @@ -105,9 +99,8 @@ else: DOCK_AUTO_HIDE = True TERMINAL_COMMAND = "kitty -e" DOCK_THEME = "Pills" - DOCK_ICON_SIZE = 20 + DOCK_ICON_SIZE = 30 WINDOW_SWITCHER_ITEMS_PER_ROW = 13 - NOTIF_POS = NOTIF_POS_DEFAULT DOCK_HIDE_SPECIAL_WORKSPACE = True DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True diff --git a/fabric/.github/FUNDING.yml b/fabric/.github/FUNDING.yml new file mode 100644 index 0000000..19dc4a5 --- /dev/null +++ b/fabric/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: kairead diff --git a/fabric/.gitignore b/fabric/.gitignore new file mode 100644 index 0000000..3df8e6e --- /dev/null +++ b/fabric/.gitignore @@ -0,0 +1,136 @@ +# vscode +.vscode +*vscode* + +# ruff formatter +.ruff*/ + +# testing files +*ignore_me* +*ignore-me* +*IGNORE_ME* + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# IPython +profile_default/ +ipython_config.py + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +venv/ +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# binary files +*.o +*.so diff --git a/fabric/CHANGELOG.md b/fabric/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/fabric/LICENSE b/fabric/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/fabric/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/fabric/PKGBUILD b/fabric/PKGBUILD new file mode 100644 index 0000000..e046d53 --- /dev/null +++ b/fabric/PKGBUILD @@ -0,0 +1,59 @@ +# Maintainer: Yousef El-Darsh + +pkgname=python-fabric-git +reponame=fabric +pkgver=0.0.1 +pkgrel=1 +pkgdesc="next-gen framework for building desktop widgets using python" +arch=(any) +url="https://github.com/Fabric-Development/fabric" +license=("AGPL-3.0-or-later") +groups=() +depends=( + gtk3 + cairo + gtk-layer-shell + libgirepository + gobject-introspection + gobject-introspection-runtime + webkit2gtk-4.1 + libdbusmenu-gtk3 + python + python-pip + python-gobject + python-cairo + python-loguru + python-click + pkgconf +) +makedepends=( + git + python-setuptools +) +optdepends=( + "python-psutil: for system stats, this package is a dependency of the bar example file" +) +provides=( + python-fabric +) +conflicts=( + python-fabric +) + +source=(git+https://github.com/Fabric-Development/$reponame.git) +sha256sums=("SKIP") + +pkgver() { + cd "$srcdir/$reponame" + printf "%s.r%s.%s" "$pkgver" "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)" +} + +build() { + cd "$srcdir/$reponame" + python setup.py build +} + +package() { + cd "$srcdir/$reponame" + python setup.py install --root="$pkgdir/" --optimize=1 +} diff --git a/fabric/README.md b/fabric/README.md new file mode 100644 index 0000000..14bd09e --- /dev/null +++ b/fabric/README.md @@ -0,0 +1,43 @@ +
+ +Ricing, Made Easy 🍚⚡ + +[ 
Wiki Pages
 
](https://wiki.ffpy.org) [ 
Discord Server
 
](https://discord.gg/3sDbYc9SZP) + +
+ +--- + +## What's Fabric? + +Fabric is a Desktop Widgets System that you can customize using Python. It offers a variety of features to make the often tedious process of creating widgets much easier and more enjoyable! + +Our goal is to provide a high-level, signal-based workflow—no polling or bash scripting required for even the **most basic** tasks. + +## Key Features + +1. **Simple** yet **Powerful** +2. Supports both **X11 and Wayland** +3. Access to **all other Python modules** +4. Excellent **Developer Experience (DevEX)** and typing support +5. **Low resource usage** + - Built-in Python code replaces the need for resource-heavy shell scripts + +## Visual Tour + +A Collection of Desktop rices that muscle-show what you are able to do using Fabric! + +- Example files + ![configs could be found under the examples/bar and examples/side-panel directories](assets/example-files-bar-showcase.png) +- [Ax-Shell](https://github.com/axenide/Ax-Shell) + ![](https://wiki.ffpy.org/showcase/axenide-ax-shell.webp) +- [HydePanel](https://github.com/rubiin/HyDePanel) + ![](https://wiki.ffpy.org/showcase/rubiin-hydepanel.webp) +- [Spacerice](https://github.com/SlumberDemon/dotfiles/tree/spacerice) + ![](https://wiki.ffpy.org/showcase/slumberdemon-spacerice.webp) +- [Fabrika - فابريكا](https://example.com) + ![](https://wiki.ffpy.org/showcase/darsh-fabrika.png) + +## Hacking and Contributing + +Interested in contributing? Check out the full guide in [our wiki pages](https://wiki.ffpy.org/contributing/hacking-guide/). diff --git a/fabric/assets/example-files-bar-showcase.png b/fabric/assets/example-files-bar-showcase.png new file mode 100644 index 0000000..cb11bcb Binary files /dev/null and b/fabric/assets/example-files-bar-showcase.png differ diff --git a/fabric/assets/example-files-vertical-bar-showcase.png b/fabric/assets/example-files-vertical-bar-showcase.png new file mode 100644 index 0000000..f242612 Binary files /dev/null and b/fabric/assets/example-files-vertical-bar-showcase.png differ diff --git a/fabric/default.nix b/fabric/default.nix new file mode 100644 index 0000000..d343f1c --- /dev/null +++ b/fabric/default.nix @@ -0,0 +1,50 @@ +{ + lib, + python312Packages, + gtk3, + gtk-layer-shell, + cairo, + gobject-introspection, + libdbusmenu-gtk3, + gdk-pixbuf, + cinnamon-desktop, + gnome-bluetooth, +}: +python312Packages.buildPythonPackage { + pname = "python-fabric"; + version = "0.0.2"; + pyproject = true; + + src = ./.; + + buildInputs = [ + gtk3 + gtk-layer-shell + cairo + gobject-introspection + libdbusmenu-gtk3 + gdk-pixbuf + cinnamon-desktop + gnome-bluetooth + ]; + + dependencies = with python312Packages; [ + setuptools + click + pycairo + pygobject3 + pygobject-stubs + loguru + psutil + ]; + + meta = { + changelog = ""; + description = '' + next-gen framework for building desktop widgets using Python (check the rewrite branch for progress) + ''; + homepage = "https://github.com/Fabric-Development/fabric"; + license = lib.licenses.agpl3Plus; + maintainers = with lib.maintainers; []; + }; +} diff --git a/fabric/examples/app-launcher/config.py b/fabric/examples/app-launcher/config.py new file mode 100644 index 0000000..dd5640e --- /dev/null +++ b/fabric/examples/app-launcher/config.py @@ -0,0 +1,149 @@ +""" +example configuration shows how to make a simple +desktop applications launcher, this example doesn't involve +any styling (except a couple of basic style properties) + + +the purpose of this configuration is to show to to use +the given utils and mainly how using lazy executors might +make the configuration way more faster than it's supposed to be +""" + +import operator +from collections.abc import Iterator +from fabric import Application +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.button import Button +from fabric.widgets.image import Image +from fabric.widgets.entry import Entry +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.wayland import WaylandWindow as Window +from fabric.utils import DesktopApp, get_desktop_applications, idle_add, remove_handler + + +class AppLauncher(Window): + def __init__(self, **kwargs): + super().__init__( + layer="top", + anchor="center", + exclusivity="none", + keyboard_mode="on-demand", + visible=False, + all_visible=False, + **kwargs, + ) + self._arranger_handler: int = 0 + self._all_apps = get_desktop_applications() + + self.viewport = Box(spacing=2, orientation="v") + self.search_entry = Entry( + placeholder="Search Applications...", + h_expand=True, + notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), + ) + self.scrolled_window = ScrolledWindow( + min_content_size=(280, 320), + max_content_size=(280 * 2, 320), + child=self.viewport, + ) + + self.add( + Box( + spacing=2, + orientation="v", + style="margin: 2px", + children=[ + # the header with the search entry + Box( + spacing=2, + orientation="h", + children=[ + self.search_entry, + Button( + image=Image(icon_name="window-close"), + tooltip_text="Exit", + on_clicked=lambda *_: self.application.quit(), + ), + ], + ), + # the actual slots holder + self.scrolled_window, + ], + ) + ) + self.show_all() + + def arrange_viewport(self, query: str = ""): + # reset everything so we can filter current viewport's slots... + # remove the old handler so we can avoid race conditions + remove_handler(self._arranger_handler) if self._arranger_handler else None + + # remove all children from the viewport + self.viewport.children = [] + + # make a new iterator containing the filtered apps + filtered_apps_iter = iter( + [ + app + for app in self._all_apps + if query.casefold() + in ( + (app.display_name or "") + + (" " + app.name + " ") + + (app.generic_name or "") + ).casefold() + ] + ) + should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps) + + # all aboard... + # start the process of adding slots with a lazy executor + # using this method makes the process of adding slots way more less + # resource expensive without blocking the main thread and resulting in a lock + self._arranger_handler = idle_add( + lambda *args: self.add_next_application(*args) + or (self.resize_viewport() if should_resize else False), + filtered_apps_iter, + pin=True, + ) + + return False + + def add_next_application(self, apps_iter: Iterator[DesktopApp]): + if not (app := next(apps_iter, None)): + return False + + self.viewport.add(self.bake_application_slot(app)) + return True + + def resize_viewport(self): + self.scrolled_window.set_min_content_width( + self.viewport.get_allocation().width # type: ignore + ) + return False + + def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button: + return Button( + child=Box( + orientation="h", + spacing=12, + children=[ + Image(pixbuf=app.get_icon_pixbuf(), h_align="start", size=32), + Label( + label=app.display_name or "Unknown", + v_align="center", + h_align="center", + ), + ], + ), + tooltip_text=app.description, + on_clicked=lambda *_: (app.launch(), self.application.quit()), + **kwargs, + ) + + +if __name__ == "__main__": + app_launcher = AppLauncher() + app = Application("app-launcher", app_launcher) + app.run() diff --git a/fabric/examples/bar/config.py b/fabric/examples/bar/config.py new file mode 100644 index 0000000..0096c57 --- /dev/null +++ b/fabric/examples/bar/config.py @@ -0,0 +1,158 @@ +import psutil +from fabric import Application, Fabricator +from fabric.widgets.box import Box +from fabric.widgets.image import Image +from fabric.widgets.eventbox import EventBox +from fabric.widgets.datetime import DateTime +from fabric.widgets.centerbox import CenterBox +from fabric.system_tray.widgets import SystemTray +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.wayland import WaylandWindow as Window +from fabric.hyprland.widgets import ( + HyprlandLanguage, + HyprlandActiveWindow, + HyprlandWorkspaces, + WorkspaceButton, +) +from fabric.utils import FormattedString, get_relative_path, bulk_replace + +AUDIO_WIDGET = True + +if AUDIO_WIDGET is True: + try: + from fabric.audio.service import Audio + except Exception as e: + AUDIO_WIDGET = False + print(e) + + +class VolumeWidget(Box): + def __init__(self, **kwargs): + self.progress_bar = CircularProgressBar( + name="volume-progress-bar", + pie=True, + child=Image(icon_name="audio-speakers-symbolic", icon_size=12), + size=24, + ) + + self.audio = Audio(notify_speaker=self.on_speaker_changed) + + super().__init__( + children=EventBox( + events="scroll", child=self.progress_bar, on_scroll_event=self.on_scroll + ), + **kwargs, + ) + + def on_scroll(self, _, event): + match event.direction: + case 0: + self.audio.speaker.volume += 8 + case 1: + self.audio.speaker.volume -= 8 + return + + def on_speaker_changed(self): + if not self.audio.speaker: + return + + self.progress_bar.value = self.audio.speaker.volume / 100 + return self.audio.speaker.bind( + "volume", "value", self.progress_bar, lambda _, v: v / 100 + ) + + +class StatusBar(Window): + def __init__( + self, + ): + super().__init__( + name="bar", + layer="top", + anchor="left top right", + margin="10px 10px -2px 10px", + exclusivity="auto", + visible=False, + ) + + self.system_status = Box( + name="system-status", + spacing=4, + orientation="h", + children=[ + # a progress bar (ram) has a child of a progress bar (cpu) that which has a child (the icon) + CircularProgressBar( + name="ram-progress-bar", + pie=True, + child=CircularProgressBar( + name="cpu-progress-bar", + pie=True, + child=Image(icon_name="cpu-symbolic", icon_size=12), + size=24, + ).build( + lambda progres: Fabricator( + interval=1000, + poll_from=lambda f: psutil.cpu_percent() / 100, + on_changed=lambda _, value: progres.set_value(value), + ) + ), + size=24, + ).build( + lambda progres: Fabricator( + interval=1000, + poll_from=lambda f: psutil.virtual_memory().percent / 100, + on_changed=lambda _, value: progres.set_value(value), + ) + ) + ] + # create a volume widget if enabled + + ([VolumeWidget()] if AUDIO_WIDGET else []), + ) + + self.children = CenterBox( + name="bar-inner", + start_children=Box( + name="start-container", + children=HyprlandWorkspaces( + name="workspaces", + spacing=4, + buttons_factory=lambda ws_id: WorkspaceButton(id=ws_id, label=None), + ), + ), + center_children=Box( + name="center-container", + children=HyprlandActiveWindow(name="hyprland-window"), + ), + end_children=Box( + name="end-container", + spacing=4, + orientation="h", + children=[ + self.system_status, + SystemTray(name="system-tray", spacing=4), + DateTime(name="date-time"), + HyprlandLanguage( + name="hyprland-window", + formatter=FormattedString( + "{replace_lang(language)}", + replace_lang=lambda lang: bulk_replace( + lang, + (r".*Eng.*", r".*Ar.*"), + ("ENG", "ARA"), + regex=True, + ), + ), + ), + ], + ), + ) + + return self.show_all() + + +if __name__ == "__main__": + bar = StatusBar() + app = Application("bar", bar) + app.set_stylesheet_from_file(get_relative_path("./style.css")) + + app.run() diff --git a/fabric/examples/bar/style.css b/fabric/examples/bar/style.css new file mode 100644 index 0000000..0f66774 --- /dev/null +++ b/fabric/examples/bar/style.css @@ -0,0 +1,165 @@ +/* we can use web-css variables, fabric compiles those to gtk-css. + global variables can be defined in the :vars selector... */ +:vars { + --background: #150d16; + --foreground: #f5dbc4; + --color0: #150d16; + --color1: #72448D; + --color2: #9C5995; + --color3: #D5719F; + --color4: #9A62F3; + --color5: #E075DF; + --color6: #F98F9F; + --color7: #f5dbc4; + --color8: #ab9989; + --color9: #72448D; + --color10: #9C5995; + --color11: #D5719F; + --color12: #9A62F3; + --color13: #E075DF; + --color14: #F98F9F; + --color15: #f5dbc4; + --window-bg: alpha(var(--background), 0.6); + --module-bg: alpha(var(--background), 0.4); + --border-color: var(--color11); + --ws-active: var(--color9); + --ws-inactive: var(--color3); + --ws-empty: var(--color8); + --ws-hover: var(--color1); + --ws-urgent: var(--color12); +} + +* { + /* unset so we can style everything from the ground up. */ + all: unset; + + /* text color */ + color: var(--foreground); + font-size: 16px; + font-family: "Jost*", sans-serif; + /* pill shape */ + border-radius: 100px; +} + +#bar-inner { + padding: 4px; + border: solid 2px; + border-color: var(--border-color); + background-color: var(--window-bg); + min-height: 28px; +} + +#workspaces { + padding: 6px; + min-width: 0px; + background-color: var(--module-bg); +} + +#workspaces>button { + padding: 0px 8px; + transition: padding 0.05s steps(8); + background-color: var(--ws-inactive); +} + +#workspaces>button>label { + font-size: 0px; +} + +#workspaces>button:hover { + background-color: var(--ws-hover); +} + +#workspaces>button.urgent { + background-color: var(--ws-urgent); +} + +#workspaces>button.active { + padding: 0px 32px; + background-color: var(--ws-active); +} + +#workspaces>button.empty { + background-color: var(--ws-empty); +} + + +#system-status { + padding: 2px; + background-color: var(--module-bg); +} + +#cpu-progress-bar, +#ram-progress-bar, +#volume-progress-bar { + color: transparent; + background-color: transparent +} + +#cpu-progress-bar { + border: solid 0px alpha(var(--color9), 0.8); +} + +#ram-progress-bar, +#volume-progress-bar { + border: solid 0px var(--border-color); +} + +#date-time, +#hyprland-language, +#hyprland-window { + background-color: var(--module-bg); + padding: 0px 8px; +} + +menu>menuitem>label, +#date-time>label, +#hyprland-language>label, +#hyprland-window>label { + font-weight: 900; +} + +/* system tray */ +#system-tray { + padding: 2px 4px; + background-color: var(--module-bg); +} + +/* menu and menu items (written for the system tray) */ +menu { + border: solid 2px; + border-radius: 10px; + border-color: var(--border-color); + background-color: var(--window-bg); +} + +menu>menuitem { + border-radius: 0px; + background-color: var(--module-bg); + padding: 6px; + margin-left: 2px; + margin-right: 2px; +} + +menu>menuitem:first-child { + margin-top: 1px; + border-radius: 8px 8px 0px 0px; +} + +menu>menuitem:last-child { + margin-bottom: 1px; + border-radius: 0px 0px 8px 8px; +} + +menu>menuitem:hover { + background-color: var(--border-color); +} + +tooltip { + border: solid 2px; + border-color: var(--border-color); + background-color: var(--window-bg); +} + +tooltip>* { + padding: 2px 4px +} \ No newline at end of file diff --git a/fabric/examples/bluetooth-widget/config.py b/fabric/examples/bluetooth-widget/config.py new file mode 100644 index 0000000..cbd3fc5 --- /dev/null +++ b/fabric/examples/bluetooth-widget/config.py @@ -0,0 +1,112 @@ +from fabric import Application +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.image import Image +from fabric.widgets.button import Button +from fabric.widgets.window import Window +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.bluetooth import BluetoothClient, BluetoothDevice + + +class BluetoothDeviceSlot(CenterBox): + def __init__(self, device: BluetoothDevice, **kwargs): + super().__init__(**kwargs) + self.device = device + self.device.connect("changed", self.on_changed) + self.device.connect( + "notify::closed", lambda *_: self.device.closed and self.destroy() + ) + + self.connection_label = Label(label="Disconnected") + self.connect_button = Button( + label="Connect", + on_clicked=lambda *_: self.device.set_connecting(not self.device.connected), + ) + + self.start_children = [ + Image(icon_name=device.icon_name + "-symbolic", size=32), + Label(label=device.name), + ] + self.center_children = self.connection_label + self.end_children = self.connect_button + + self.device.emit("changed") # to update display status + + def on_changed(self, *_): + self.connection_label.set_label( + "Connected" if self.device.connected else "Disconnected" + ) + if self.device.connecting: + self.connect_button.set_label( + "Connecting..." if not self.device.connecting else "Disconnecting..." + ) + else: + self.connect_button.set_label( + "Connect" if not self.device.connected else "Disconnect" + ) + return + + +class BluetoothConnections(Box): + def __init__(self, **kwargs): + super().__init__( + spacing=4, + orientation="vertical", + style="margin: 8px", + **kwargs, + ) + + self.client = BluetoothClient(on_device_added=self.on_device_added) + self.scan_button = Button(on_clicked=lambda *_: self.client.toggle_scan()) + self.toggle_button = Button(on_clicked=lambda *_: self.client.toggle_power()) + + self.client.connect( + "notify::enabled", + lambda *_: self.toggle_button.set_label( + "Bluetooth " + ("ON" if self.client.enabled else "OFF") + ), + ) + self.client.connect( + "notify::scanning", + lambda *_: self.scan_button.set_label( + "Stop" if self.client.scanning else "Scan" + ), + ) + + self.paired_box = Box(spacing=2, orientation="vertical") + self.available_box = Box(spacing=2, orientation="vertical") + + self.children = [ + CenterBox(start_children=self.scan_button, end_children=self.toggle_button), + Label("Paired Devices"), + ScrolledWindow(min_content_size=(400, -1), child=self.paired_box), + Label("Available Devices"), + ScrolledWindow(min_content_size=(-1, 400), child=self.available_box), + ] + + # to run notify closures thus display the status + # without having to wait until an actual change + self.client.notify("scanning") + self.client.notify("enabled") + + def on_device_added(self, client: BluetoothClient, address: str): + if not (device := client.get_device(address)): + return + slot = BluetoothDeviceSlot(device) + + if device.paired: + return self.paired_box.add(slot) + return self.available_box.add(slot) + + +if __name__ == "__main__": + bluetooth_widget = Window( + child=BluetoothConnections(), + visible=True, + all_visible=True, + on_destroy=lambda *_: app.quit(), + ) + app = Application("bluetooth-widget", bluetooth_widget) + + app.run() diff --git a/fabric/examples/desktop-widget/config.py b/fabric/examples/desktop-widget/config.py new file mode 100644 index 0000000..ac68b7f --- /dev/null +++ b/fabric/examples/desktop-widget/config.py @@ -0,0 +1,26 @@ +from fabric import Application +from fabric.widgets.box import Box +from fabric.widgets.datetime import DateTime +from fabric.widgets.wayland import WaylandWindow as Window +from fabric.utils import get_relative_path + + +if __name__ == "__main__": + desktop_widget = Window( + layer="bottom", + anchor="center", # FYI: there's no anchor named "center" (anchor of "" is == to "center") + exclusivity="none", + child=Box( + orientation="v", + children=[ + DateTime(formatters=["%A. %d %B"], interval=10000, name="date"), + DateTime(formatters=["%I:%M"], name="clock"), + ], + ), + all_visible=True, + ) + + app = Application("desktop-widget", desktop_widget) + app.set_stylesheet_from_file(get_relative_path("./style.css")) + + app.run() diff --git a/fabric/examples/desktop-widget/style.css b/fabric/examples/desktop-widget/style.css new file mode 100644 index 0000000..936c1e4 --- /dev/null +++ b/fabric/examples/desktop-widget/style.css @@ -0,0 +1,38 @@ +:vars { + --background: #150d16; + --foreground: #f5dbc4; + --color0: #150d16; + --color1: #72448D; + --color2: #9C5995; + --color3: #D5719F; + --color4: #9A62F3; + --color5: #E075DF; + --color6: #F98F9F; + --color7: #f5dbc4; + --color8: #ab9989; + --color9: #72448D; + --color10: #9C5995; + --color11: #D5719F; + --color12: #9A62F3; + --color13: #E075DF; + --color14: #F98F9F; + --color15: #f5dbc4; +} + +* { + all: unset; + transition: color 0.5s ease; + color: alpha(var(--foreground), 0.8); + font-family: "Jost*", sans-serif; + font-weight: 700; +} + +#clock>label { + font-size: 86px; + font-weight: 900; +} + +#date>label { + font-size: 24px; + margin-bottom: -18px; +} diff --git a/fabric/examples/icon-browser/config.py b/fabric/examples/icon-browser/config.py new file mode 100644 index 0000000..482eb97 --- /dev/null +++ b/fabric/examples/icon-browser/config.py @@ -0,0 +1,105 @@ +from fabric import Application +from collections.abc import Iterator +from fabric.widgets.box import Box +from fabric.widgets.entry import Entry +from fabric.widgets.image import Image +from fabric.widgets.button import Button +from fabric.widgets.window import Window +from fabric.widgets.flowbox import FlowBox +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.utils import idle_add, remove_handler + +from gi.repository import Gtk + + +class IconBrowser(Window): + def __init__(self, **kwargs): + super().__init__( + layer="top", + anchor="center", + exclusivity="none", + keyboard_mode="on-demand", + visible=False, + all_visible=False, + on_destroy=lambda *_: app.quit(), + **kwargs, + ) + self._arranger_handler: int = 0 + self._icon_theme = Gtk.IconTheme.get_default() + self._all_icons: list[str] = self._icon_theme.list_icons() + + self.viewport = FlowBox(orientation="v") + self.search_entry = Entry( + placeholder="Search Icons...", + h_expand=True, + notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), + ) + self.scrolled_window = ScrolledWindow( + min_content_size=(280, 320), child=self.viewport + ) + + self.add( + Box( + spacing=2, + orientation="v", + style="margin: 2px", + children=[ + # the header with the search entry + Box( + spacing=2, + orientation="h", + children=[ + self.search_entry, + Button( + image=Image(icon_name="window-close"), + tooltip_text="Exit", + on_clicked=lambda *_: self.application.quit(), + ), + ], + ), + # the actual slots holder + self.scrolled_window, + ], + ) + ) + self.show_all() + + def arrange_viewport(self, query: str = ""): + if self._arranger_handler: + remove_handler(self._arranger_handler) + + self.viewport.children = [] + self._arranger_handler = idle_add( + self.add_next_icon, + iter( + sorted( + icon_name + for icon_name in self._all_icons + if query.casefold() in icon_name.casefold() + ) + ), + pin=True, + ) + + return False + + def add_next_icon(self, icons_iter: Iterator[str]): + if not (icon_name := next(icons_iter, None)): + return False + + self.viewport.add( + Button( + child=Image( + icon_name=icon_name, icon_size=32, h_align="center", size=32 + ), + tooltip_text=icon_name, + ) + ) + return True + + +if __name__ == "__main__": + icon_browser = IconBrowser() + app = Application("icon-browser", icon_browser) + + app.run() diff --git a/fabric/examples/notifications/config.py b/fabric/examples/notifications/config.py new file mode 100644 index 0000000..de4995a --- /dev/null +++ b/fabric/examples/notifications/config.py @@ -0,0 +1,162 @@ +from typing import cast + +from fabric import Application +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.image import Image +from fabric.widgets.button import Button +from fabric.widgets.wayland import WaylandWindow +from fabric.notifications import Notifications, Notification +from fabric.utils import invoke_repeater, get_relative_path + +from gi.repository import GdkPixbuf + + +NOTIFICATION_WIDTH = 360 +NOTIFICATION_IMAGE_SIZE = 64 +NOTIFICATION_TIMEOUT = 10 * 1000 # 10 seconds + + +class NotificationWidget(Box): + def __init__(self, notification: Notification, **kwargs): + super().__init__( + size=(NOTIFICATION_WIDTH, -1), + name="notification", + spacing=8, + orientation="v", + **kwargs, + ) + + self._notification = notification + + body_container = Box(spacing=4, orientation="h") + + if image_pixbuf := self._notification.image_pixbuf: + body_container.add( + Image( + pixbuf=image_pixbuf.scale_simple( + NOTIFICATION_IMAGE_SIZE, + NOTIFICATION_IMAGE_SIZE, + GdkPixbuf.InterpType.BILINEAR, + ) + ) + ) + + body_container.add( + Box( + spacing=4, + orientation="v", + children=[ + # a box for holding both the "summary" label and the "close" button + Box( + orientation="h", + children=[ + Label( + label=self._notification.summary, + ellipsization="middle", + ) + .build() + .add_style_class("summary") + .unwrap(), + ], + h_expand=True, + v_expand=True, + ) + # add the "close" button + .build( + lambda box, _: box.pack_end( + Button( + image=Image( + icon_name="close-symbolic", + icon_size=18, + ), + v_align="center", + h_align="end", + on_clicked=lambda *_: self._notification.close(), + ), + False, + False, + 0, + ) + ), + Label( + label=self._notification.body, + line_wrap="word-char", + v_align="start", + h_align="start", + ) + .build() + .add_style_class("body") + .unwrap(), + ], + h_expand=True, + v_expand=True, + ) + ) + + self.add(body_container) + + if actions := self._notification.actions: + self.add( + Box( + spacing=4, + orientation="h", + children=[ + Button( + h_expand=True, + v_expand=True, + label=action.label, + on_clicked=lambda *_, action=action: action.invoke(), + ) + for action in actions + ], + ) + ) + + # destroy this widget once the notification is closed + self._notification.connect( + "closed", + lambda *_: ( + parent.remove(self) if (parent := self.get_parent()) else None, # type: ignore + self.destroy(), + ), + ) + + # automatically close the notification after the timeout period + invoke_repeater( + NOTIFICATION_TIMEOUT, + lambda: self._notification.close("expired"), + initial_call=False, + ) + + +if __name__ == "__main__": + app = Application( + "notifications", + WaylandWindow( + margin="8px 8px 8px 8px", + anchor="top right", + child=Box( + size=2, # so it's not ignored by the compositor + spacing=4, + orientation="v", + ).build( + lambda viewport, _: Notifications( + on_notification_added=lambda notifs_service, nid: viewport.add( + NotificationWidget( + cast( + Notification, + notifs_service.get_notification_from_id(nid), + ) + ) + ) + ) + ), + visible=True, + all_visible=True, + ), + ) + + app.set_stylesheet_from_file(get_relative_path("./style.css")) + + app.run() diff --git a/fabric/examples/notifications/style.css b/fabric/examples/notifications/style.css new file mode 100644 index 0000000..2954097 --- /dev/null +++ b/fabric/examples/notifications/style.css @@ -0,0 +1,40 @@ +:vars { + --foreground: rgb(255, 255, 255); + --background: rgb(26, 28, 30); + --border-color: rgb(42, 43, 45); + --button-color: var(--border-color); +} + +* { + all: unset; + color: var(--foreground); + font-family: "Jost*", "Cantarell", "Helvetica", "Arial", sans-serif; +} + +#notification { + padding: 0.8rem; + border: solid 1px var(--border-color); + border-radius: 1rem; + background-color: var(--background); +} + +#notification .summary { + font-size: 20px; + font-weight: bold; +} + +#notification .body { + color: darker(var(--foreground)); + font-weight: normal; +} + +button { + padding: 0.6rem; + font-weight: 600; + border-radius: 3rem; + background-color: var(--button-color); +} + +button:hover { + background-color: lighter(var(--button-color)); +} diff --git a/fabric/examples/side-panel/config.py b/fabric/examples/side-panel/config.py new file mode 100644 index 0000000..b2127a5 --- /dev/null +++ b/fabric/examples/side-panel/config.py @@ -0,0 +1,174 @@ +"""side panel example, contains info about the system""" + +import os +import time +import psutil +from loguru import logger +from fabric import Application +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.datetime import DateTime +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.wayland import WaylandWindow as Window +from fabric.utils import invoke_repeater, get_relative_path + + +def get_profile_picture_path() -> str | None: + path = os.path.expanduser("~/Pictures/Other/face.jpg") + if not os.path.exists(path): + path = os.path.expanduser("~/.face") + if not os.path.exists(path): + logger.warning( + "can't fetch a user profile picture, add a profile picture image at ~/.face or at ~/Pictures/Other/profile.jpg" + ) + path = None + return path + + +class SidePanel(Window): + @staticmethod + def bake_progress_bar(name: str = "progress-bar", size: int = 64, **kwargs): + return CircularProgressBar( + name=name, min_value=0, max_value=100, size=size, **kwargs + ) + + @staticmethod + def bake_progress_icon(**kwargs): + return Label(**kwargs).build().add_style_class("progress-icon").unwrap() + + def __init__(self, **kwargs): + super().__init__( + layer="overlay", + title="fabric-overlay", + anchor="top right", + margin="10px 10px 10px 0px", + exclusivity="none", + visible=False, + all_visible=False, + **kwargs, + ) + + self.profile_pic = Box( + name="profile-pic", + style=f'background-image: url("file://{get_profile_picture_path() or ""}")', + ) + self.uptime_label = Label(label=f"{self.get_current_uptime()}") + + self.header = Box( + spacing=14, + name="header", + orientation="h", + children=[ + self.profile_pic, + Box( + orientation="v", + children=[ + DateTime( + name="date-time", + style="margin-top: 4px; min-width: 180px;", + ), + self.uptime_label, + ], + ), + ], + ) + + self.greeter_label = Label( + label=f"Good {'Morning' if time.localtime().tm_hour < 12 else 'Afternoon'}, {os.getlogin().title()}!", + style="font-size: 20px;", + ) + + self.cpu_progress = self.bake_progress_bar() + self.ram_progress = self.bake_progress_bar() + self.bat_circular = self.bake_progress_bar().build().set_value(42).unwrap() + + self.progress_container = Box( + name="progress-bar-container", + spacing=12, + children=[ + Box( + children=[ + Overlay( + child=self.cpu_progress, + overlays=[ + self.bake_progress_icon( + label="", + style="margin-right: 8px; text-shadow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff;", + ) + ], + ), + ], + ), + Box(name="progress-bar-sep"), + Box( + children=[ + Overlay( + child=self.ram_progress, + overlays=[ + self.bake_progress_icon( + label="󰘚", + style="margin-right: 4px; text-shadow: 0 0 10px #fff;", + ) + ], + ) + ] + ), + Box(name="progress-bar-sep"), + Box( + children=[ + Overlay( + child=self.bat_circular, + overlays=[ + self.bake_progress_icon( + label="󱊣", + style="margin-right: 0px; text-shadow: 0 0 10px #fff, 0 0 18px #fff;", + ) + ], + ), + ], + ), + ], + ) + + self.update_status() + invoke_repeater( + 15 * 60 * 1000, # every 15min + lambda: (self.uptime_label.set_label(self.get_current_uptime()), True)[1], + ) + invoke_repeater(1000, self.update_status) + + self.add( + Box( + name="window-inner", + orientation="v", + spacing=24, + children=[self.header, self.greeter_label, self.progress_container], + ), + ) + self.show_all() + + def update_status(self): + self.cpu_progress.value = psutil.cpu_percent() + self.ram_progress.value = psutil.virtual_memory().percent + if not (bat_sen := psutil.sensors_battery()): + self.bat_circular.value = 42 + else: + self.bat_circular.value = bat_sen.percent + + return True + + def get_current_uptime(self): + uptime = time.time() - psutil.boot_time() + uptime_days, remainder = divmod(uptime, 86400) + uptime_hours, remainder = divmod(remainder, 3600) + # uptime_minutes, _ = divmod(remainder, 60) + return f"{int(uptime_days)} {'days' if uptime_days > 1 else 'day'}, {int(uptime_hours)} {'hours' if uptime_hours > 1 else 'hour'}" + + +if __name__ == "__main__": + side_panel = SidePanel() + app = Application("side-panel", side_panel) + app.set_stylesheet_from_file(get_relative_path("./style.css")) + + app.run() diff --git a/fabric/examples/side-panel/style.css b/fabric/examples/side-panel/style.css new file mode 100644 index 0000000..dd46df6 --- /dev/null +++ b/fabric/examples/side-panel/style.css @@ -0,0 +1,83 @@ +:vars { + --background: #150d16; + --foreground: #f5dbc4; + --color0: #150d16; + --color1: #72448D; + --color2: #9C5995; + --color3: #D5719F; + --color4: #9A62F3; + --color5: #E075DF; + --color6: #F98F9F; + --color7: #f5dbc4; + --color8: #ab9989; + --color9: #72448D; + --color10: #9C5995; + --color11: #D5719F; + --color12: #9A62F3; + --color13: #E075DF; + --color14: #F98F9F; + --color15: #f5dbc4; + --window-bg: alpha(var(--background), 0.6); + --module-bg: alpha(var(--background), 0.4); + --border-color: var(--color11); +} + +* { + all: unset; + font-family: "Jost*"; + font-weight: 500; + font-size: 18px; + color: var(--foreground); +} + +#window-inner { + border: solid 2px; + border-color: var(--color11); + background-color: var(--window-bg); + border-radius: 100px; + border-radius: 12px; + padding: 10px; +} + +#date-time>label { + font-weight: 900; + font-size: 28px; +} + +#header { + border: solid 1px var(--border-color); + border-radius: 8px; + box-shadow: 0px 18px 23px -6px rgba(0, 0, 0, 0.75); + background: linear-gradient(90deg, + alpha(var(--color11), 0.2), + alpha(var(--background), 0.2), + var(--module-bg)); +} + +#profile-pic { + background-color: alpha(var(--color11), 0.2); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border-radius: 6px; + padding: 32px; + margin: 4px; +} + +#progress-bar { + color: var(--module-bg); + border: solid 10px var(--border-color); +} + +#progress-bar-sep { + padding: 4px; + margin: 16px 0px; + border-radius: 100px; + background-color: var(--module-bg); +} + +#progress-bar-container { + background-color: var(--module-bg); + border-radius: 100px; + padding: 8px; +} diff --git a/fabric/fabric/__init__.py b/fabric/fabric/__init__.py new file mode 100644 index 0000000..4101365 --- /dev/null +++ b/fabric/fabric/__init__.py @@ -0,0 +1,5 @@ +from fabric.core.service import Service, Signal, Property, Builder +from fabric.core.application import Application +from fabric.core.fabricator import Fabricator + +__all__ = ["Service", "Signal", "Property", "Builder", "Application", "Fabricator"] diff --git a/fabric/fabric/__main__.py b/fabric/fabric/__main__.py new file mode 100644 index 0000000..8a0c40f --- /dev/null +++ b/fabric/fabric/__main__.py @@ -0,0 +1,218 @@ +import click +from json import dumps as serialize_json +from gi.repository import GLib, Gio + +FABRIC_DBUS_INTERFACE_NAME = "org.Fabric.fabric" +FABRIC_DBUS_OBJECT_PATH = "/org/Fabric/fabric" + + +def get_dbus_names() -> GLib.Variant: + return ( + Gio.bus_get_sync(Gio.BusType.SESSION) + .call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "ListNames", + GLib.Variant("()", ()), + GLib.VariantType("(as)"), # type: ignore + Gio.DBusCallFlags.NONE, + -1, + None, + ) + .get_child_value(0) + ) + + +def name_running(name: str) -> bool: + return ( + Gio.bus_get_sync(Gio.BusType.SESSION) + .call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + GLib.Variant("(s)", (name,)), + GLib.VariantType("(b)"), # type: ignore + Gio.DBusCallFlags.NONE, + -1, + None, + ) + .get_child_value(0) + .get_boolean() + ) + + +def get_instance_proxy(iface_name: str) -> Gio.DBusProxy: + return Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.NONE, + None, + iface_name, + FABRIC_DBUS_OBJECT_PATH, + FABRIC_DBUS_INTERFACE_NAME, + None, + ) + + +def check_and_get_instance_proxy(config_name: str, json: bool = False) -> Gio.DBusProxy: + if config_name.startswith(FABRIC_DBUS_INTERFACE_NAME): + iface_name = config_name + else: + iface_name = FABRIC_DBUS_INTERFACE_NAME + ( + f".{config_name}" if config_name else "" + ) + if not name_running(iface_name): + message = f"couldn't find a running Fabric instance with the name {config_name}" + if not json: + click.echo(message) + else: + click.echo({"error": message}) + exit(1) + return get_instance_proxy(iface_name) + + +def command( + name: str, + help: str = "", + needs_instance: bool = True, + with_json: bool = True, + *extra, +): + def decorator(func) -> click.Command: + if with_json: + command = click.command(name=name, help=help)( + lambda *args, json=False, **kwargs: ( + rval := func(*args, json=json, **kwargs), + click.echo(serialize_json(rval) if json else rval) + if rval + else None, + ) + ) + else: + command = click.command(name=name, help=help)(func) + + if needs_instance: + command = click.argument("instance")(command) + + for ext in extra: + command = ext(command) # type: ignore + + if with_json: + command = click.option( + "--json", "-j", is_flag=True, help="to return the output in json format" + )(command) + + return command + + return decorator + + +@command( + name="list-all", + help="list all currently running fabric instances", + needs_instance=False, +) +def list_all(json: bool = False): + filtered_names = filter( + lambda x: x.startswith(FABRIC_DBUS_INTERFACE_NAME), # type: ignore + get_dbus_names(), # type: ignore + ) + if json: + return {"instances-dbus-names": list(filtered_names)} + for dbus_name in filtered_names: + config_name: str = dbus_name.removeprefix(FABRIC_DBUS_INTERFACE_NAME + ".") + proxy = get_instance_proxy(dbus_name) + click.echo( + f"{config_name}: {str(proxy.get_cached_property('File').unpack())}" + ) if proxy is not None else None + return + + +@command( + name="list-actions", + help="list actions in a currently running fabric instance", + needs_instance=True, +) +def list_actions(instance: str, json: bool = False): + bus_object = check_and_get_instance_proxy(instance, json) + actions: dict[str, list[str]] = dict(bus_object.get_cached_property("Actions")) # type: ignore + + if json: + return actions + + for name, args in actions.items(): + click.echo(f"{name} ({', '.join(args)})") + return + + +@command( + "invoke-action", + "invoke an action within a running fabric instance", + True, + True, + click.argument("action-name"), + click.argument("arguments", nargs=-1), +) +def invoke_action( + instance: str, action_name: str, arguments: tuple[str, ...], json: bool = False +): + bus_object = check_and_get_instance_proxy(instance, json) + err, msg = bus_object.InvokeAction("(sas)", action_name, arguments) + + if json: + return {"error": err, "message": msg} + + if err: + return f"couldn't invoke action\nerror: {msg}" + + return f"action invoked\nreturn message: {msg}" + + +@command( + "execute", + "executes a python code within the running fabric instance", + True, + True, + click.argument("source"), +) +def execute(instance: str, source: str, json: bool = False): + bus_object = check_and_get_instance_proxy(instance, json) + exc = bus_object.Execute("(s)", source) + if json: + return {"source": source, "exception": exc} + if exc: + return f"exception: {exc}" + return + + +@command( + "evaluate", + "evaluate a python code within a running fabric instance and return the result", + True, + True, + click.argument("code"), +) +def evaluate(instance: str, code: str, json: bool = False): + bus_object = check_and_get_instance_proxy(instance, json) + result, exc = bus_object.Evaluate("(s)", code) + + if json: + return {"code": code, "result": result, "exception": exc} + + return "result: " + (result + "\nexception: " + exc if exc != "" else result) + + +@click.group() +def main(): + pass + + +if __name__ == "__main__": + main.add_command(list_all) + main.add_command(list_actions) + main.add_command(execute) + main.add_command(evaluate) + main.add_command(invoke_action) + + main() diff --git a/fabric/fabric/audio/__init__.py b/fabric/fabric/audio/__init__.py new file mode 100644 index 0000000..4424044 --- /dev/null +++ b/fabric/fabric/audio/__init__.py @@ -0,0 +1 @@ +from .service import Audio, AudioStream diff --git a/fabric/fabric/audio/service.py b/fabric/fabric/audio/service.py new file mode 100644 index 0000000..5e2317e --- /dev/null +++ b/fabric/fabric/audio/service.py @@ -0,0 +1,344 @@ +import gi +from loguru import logger +from typing import Literal +from fabric.core.service import Service, Signal, Property +from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case + + +class CvcImportError(ImportError): + def __init__(self, *args): + super().__init__( + "Cvc is not installed, please install it first, you can use automated installer in the git repository", + *args, + ) + + +try: + gi.require_version("Cvc", "1.0") + from gi.repository import Cvc +except Exception: + raise CvcImportError() + + +class AudioStream(Service): + @Signal + def changed(self) -> None: ... + + @Signal + def closed(self) -> None: ... + + # TODO: implement a interface for getting the icon as a Gdk.Pixbuf (from stream.get_gicon()) + @Property(str, "readable") + def icon_name(self) -> str: + return self._stream.get_icon_name() + + @Property(int, "readable") + def id(self) -> int: + return self._stream.get_id() + + @Property(str, "readable") + def name(self) -> str: + return self._stream.get_name() + + @Property(str, "readable") + def description(self) -> str: + return self._stream.get_description() + + @Property(str, "readable") + def application_id(self) -> str: + return self._stream.get_application_id() + + @Property(str, "readable") + def state(self) -> str: + return snake_case_to_kebab_case( + get_enum_member_name(self._stream.get_state(), default="unknown") + ) + + @Property(str, "readable") + def control_state(self) -> str: + return snake_case_to_kebab_case( + get_enum_member_name(self._control.get_state(), default="unknown") + ) + + @Property(Cvc.MixerStream, "readable") + def stream(self) -> Cvc.MixerStream: + return self._stream + + @Property(float, "read-write") + def volume(self) -> float: + return float( + (self._stream.get_volume() / self._control.get_vol_max_norm()) * 100 + ) + + @volume.setter + def volume(self, value: float): + value = 0 if value < 0 else value + value = self._parent.max_volume if value > self._parent.max_volume else value + self._old_vol = self._stream.get_volume() + self._stream.set_volume(int((value * self._control.get_vol_max_norm()) / 100)) + self._stream.push_volume() # type: ignore + return self.changed() + + @Property(bool, "read-write", "is-muted", default_value=False) + def muted(self) -> bool: + return self._stream.get_is_muted() + + @muted.setter + def muted(self, value: bool): + self._stream.set_is_muted(value) + self._stream.change_is_muted(value) # type: ignore + return self.changed() + + @Property(str, "readable") + def type(self) -> str: + return Audio.get_stream_type(self.stream, default="unknown") # type: ignore + + def __init__( + self, + stream: Cvc.MixerStream, + control: Cvc.MixerControl, + parent: "Audio", + **kwargs, + ): + super().__init__(**kwargs) + self._old_vol = 0 + self._stream = stream + self._control = control + self._parent = parent + for sn in [ + "application-id", + "description", + "icon-name", + "is-muted", + "volume", + "state", + "id", + ]: # A PYRAMID + self._stream.connect( + f"notify::{sn}", lambda *args, sn=sn: self.notifier(sn, args) + ) + # ^^^^^ python bug, i guess. + + def notifier(self, name: str, *args): + self.notify(name) + self.changed() + return + + def close(self): + return self.closed() + + def __del__(self): + # hacking into the guts of python's garbage collector + return self.close() + + +# TODO: cleanup a little bit more... +class Audio(Service): + @Signal + def changed(self) -> None: ... + @Signal + def speaker_changed(self) -> None: ... + @Signal + def microphone_changed(self) -> None: ... + @Signal + def stream_added(self) -> None: ... + @Signal + def stream_removed(self) -> None: ... + + @Property(AudioStream, "readable") + def speaker(self) -> AudioStream: + return self._speaker + + @Property(list[AudioStream], "readable") + def speakers(self) -> list[AudioStream]: + return self.do_list_stream_type(Cvc.MixerSink) + + @Property(AudioStream, "readable") + def microphone(self) -> AudioStream: + return self._microphone + + @Property(list[AudioStream], "readable") + def microphones(self) -> list[AudioStream]: + return self.do_list_stream_type(Cvc.MixerSource) + + @Property(list[AudioStream], "readable") + def applications(self) -> list[AudioStream]: + return self.do_list_stream_type(Cvc.MixerSinkInput) + + @Property(list[AudioStream], "readable") + def recorders(self) -> list[AudioStream]: + return self.do_list_stream_type(Cvc.MixerSourceOutput) + + @Property(int, "read-write") + def max_volume(self) -> int: + return self._max_volume + + @max_volume.setter + def max_volume(self, value: int): + self._max_volume = value + return + + @Property(str, "readable") + def state(self) -> str: + return snake_case_to_kebab_case( + get_enum_member_name(self._control.get_state(), default="unknown") + ) + + def __init__( + self, + max_volume: int = 100, + controller_name: str = "fabric audio control", + **kwargs, + ): + super().__init__(**kwargs) + self._control = Cvc.MixerControl(name=controller_name) + self._max_volume = max_volume + + self._streams: dict[int, AudioStream] = {} + self._stream_connectors: dict[int, int] = {} + + self._speaker: AudioStream | None = None + self._speaker_connection: int | None = None + + self._microphone: AudioStream | None = None + self._microphone_connection: int | None = None + + bulk_connect( + self._control, + { + "stream-added": self.on_stream_added, + "stream-removed": self.on_stream_removed, + "default-sink-changed": lambda _, id: self.on_default_stream_changed( + id, "speaker" + ), + "default-source-changed": lambda _, id: self.on_default_stream_changed( + id, "microphone" + ), + }, + ) + + # all aboard... + self._control.open() + + def do_list_stream_type( + self, + stream_type: Literal[ + "source", + "source-output", + "sink", + "sink-input", + ] + | type[Cvc.MixerSource] + | type[Cvc.MixerSourceOutput] + | type[Cvc.MixerSink] + | type[Cvc.MixerSinkInput] + | None = None, + ) -> list[AudioStream]: + if not stream_type: + return list(self._streams.values()) + + # get_enum_member won't help here :P + stream_type = ( + { + "source": Cvc.MixerSource, + "source-output": Cvc.MixerSourceOutput, + "sink": Cvc.MixerSink, + "sink-input": Cvc.MixerSinkInput, + }.get(stream_type) + if isinstance(stream_type, str) + else stream_type + ) + + rlist = [] + for _, strm in self._streams.items(): + if not stream_type or not isinstance(strm.stream, stream_type): + continue + rlist.append(strm) + return rlist + + def on_default_stream_changed(self, id: int, type: str): + logger.info(f"[Audio][{type.title()}] Changing default {type} to {id}") + + if (old_strm := self.__getattribute__(f"_{type}")) is not None: + logger.info(f"[Audio][{type.title()}] Removing old {type} stream") + try: + hndlr_id = self.__getattribute__(f"_{type}_connection") + old_strm.handler_disconnect(hndlr_id) + except Exception as e: + logger.warning( + f"[Audio][{type.title()}] tried to remove a stream of type {type} but failed because of error ({e}), skipping..." + ) + + strm = self._streams.get(id) + if not strm: + return + + self.__setattr__(f"_{type}", strm) + self.__setattr__( + f"_{type}_connection", + strm.connect("changed", lambda *args: self.emit(f"{type}-changed")), + ) + + self.emit(f"{type}-changed") + self.notify(type) + + return self.changed() + + def on_stream_added(self, _, stream_id: int): + stream = ( + self._control.lookup_stream_id(stream_id) + or self._control.lookup_output_id(stream_id) + or self._control.lookup_input_id(stream_id) + ) + + if ( + self._streams.get(stream_id) is not None + or not Audio.get_stream_type(stream, None) # type: ignore + or not stream + ): + return # No. + + audio_stream = AudioStream(stream, self._control, self) + self._streams[stream_id] = audio_stream + self._stream_connectors[stream_id] = audio_stream.connect( + "changed", lambda *_: self.changed() + ) + + logger.info( + f"[Audio][{audio_stream.type.title()}] Adding stream {stream_id} with name {audio_stream.name}" + ) + self.do_notify_streams(stream) + return self.changed() + + def on_stream_removed(self, _, stream_id: int): + if not self._streams.get(stream_id): + return + + audio_stream = self._streams.pop(stream_id) + audio_stream.handler_disconnect(self._stream_connectors.pop(stream_id)) # type: ignore + + logger.info( + f"[Audio][{audio_stream.type.title()}] Removing stream {stream_id} with name {audio_stream.name}" + ) + self.do_notify_streams(audio_stream.stream) + audio_stream.close() + return self.changed() + + def do_notify_streams(self, stream: Cvc.MixerStream): + stream_type = Audio.get_stream_type(stream) + if not stream_type: + return + return self.notify(stream_type) + + @staticmethod + def get_stream_type( + stream: Cvc.MixerStream, + default=None, + ) -> str | None: + # intended for internal usage only + return { + Cvc.MixerSink: "speakers", + Cvc.MixerSinkInput: "applications", + Cvc.MixerSource: "microphones", + Cvc.MixerSourceOutput: "recorders", + }.get(type(stream), default) # type: ignore diff --git a/fabric/fabric/bluetooth/__init__.py b/fabric/fabric/bluetooth/__init__.py new file mode 100644 index 0000000..aae744e --- /dev/null +++ b/fabric/fabric/bluetooth/__init__.py @@ -0,0 +1 @@ +from .service import BluetoothClient, BluetoothDevice diff --git a/fabric/fabric/bluetooth/service.py b/fabric/fabric/bluetooth/service.py new file mode 100644 index 0000000..106cbe8 --- /dev/null +++ b/fabric/fabric/bluetooth/service.py @@ -0,0 +1,332 @@ +import gi +from loguru import logger +from collections.abc import Callable +from typing import ParamSpec, Concatenate, Any +from fabric.core.service import Service, Signal, Property +from fabric.utils.helpers import ( + bulk_connect, + get_enum_member_name, + snake_case_to_kebab_case, + bridge_signal, +) + +from gi.repository import Gio + +try: + gi.require_version("GnomeBluetooth", "3.0") + from gi.repository import GnomeBluetooth +except Exception: + raise ImportError("gnome-bluetooth-3 is not installed, please install it first") + + +P = ParamSpec("P") + + +class BluetoothDevice(Service): + @Signal + def changed(self) -> None: ... + + @Property(GnomeBluetooth.Device, "readable") + def device(self) -> GnomeBluetooth.Device: + return self._device + + @Property(bool, "read-write", "is-connected", default_value=False) + def connected(self) -> bool: + return self._device.get_property("connected") # type: ignore + + @connected.setter + def connected(self, value: bool): + self.connecting = value + return + + @Property(bool, "read-write", "is-connecting", default_value=False) + def connecting(self) -> bool: + return self._connecting + + @connecting.setter + def connecting(self, value: bool): + self._connecting = True + + def request_callback(succeed: bool): + self._connecting = False + self.notifier("connected") + self.notifier("connecting") + return + + return self.connect_device(value, request_callback) + + @Property(bool, "readable", "is-closed", default_value=False) + def closed(self) -> bool: + return self._closed + + @Property(bool, "read-write", "is-paired", default_value=False) + def paired(self) -> bool: + return self._device.get_property("paired") # type: ignore + + @paired.setter + def paired(self, value: bool): + return self._device.set_property("paired", value) # type: ignore + + @Property(bool, "readable", "is-trusted", default_value=False) + def trusted(self) -> bool: + return self._device.get_property("trusted") # type: ignore + + @Property(str, "readable") + def address(self) -> str: + return self._device.get_property("address") # type: ignore + + @Property(str, "readable") + def name(self) -> str: + return self._device.get_property("name") # type: ignore + + @Property(str, "readable") + def alias(self) -> str: + return self._device.get_property("alias") # type: ignore + + @Property(str, "readable") + def icon_name(self) -> str: + return self._device.get_property("icon") # type: ignore + + @Property(str, "readable") + def type(self) -> str: + return GnomeBluetooth.type_to_string(self._device.get_property("type")) # type: ignore + + @Property(int, "readable") + def battery_level(self) -> int: + return self._device.get_property("battery-level") # type: ignore + + @Property(float, "readable") + def battery_percentage(self) -> float: + return self._device.get_property("battery-percentage") # type: ignore + + def __init__( + self, device: GnomeBluetooth.Device, client: "BluetoothClient", **kwargs + ): + super().__init__(**kwargs) + self._device: GnomeBluetooth.Device = device + self._client: BluetoothClient = client + self._signal_connectors: list[int] = [] + + for pn in ( + "battery-percentage", + "battery-level", + "connected", + "trusted", + "address", + "paired", + "alias", + "icon", + "name", + ): + self._device.connect(f"notify::{pn}", lambda *_: self.emit("changed")) + self._signal_connectors.append( + bridge_signal( + self._device, + pn, + self, + "icon-name" if pn not in ("icon",) else pn, + notify=True, + ) + ) + + self._closed = self._connecting = False + + def connect_device( + self, + connect: bool = True, + callback: Callable[Concatenate[bool, P], Any] | None = None, + *args: P.args, + **kwargs: P.kwargs, + ): + return self._client.connect_device(self, connect, callback, *args, **kwargs) + + def close(self): + for id in self._signal_connectors: + try: + self._device.disconnect_handler(id) # type: ignore + except Exception: + pass + self._closed = True + self.notifier("closed") + return + + def notifier(self, name: str, args=None): + self.notify(name) + self.emit("changed") + return + + +class BluetoothClient(Service): + @Signal + def changed(self) -> None: ... + + @Signal + def closed(self) -> None: ... + + @Signal + def device_added(self, address: str) -> None: ... + + @Signal + def device_removed(self, address: str) -> None: ... + + @Property(list[BluetoothDevice], "readable") + def devices(self) -> list[BluetoothDevice]: + return list(self._devices.values()) + + @Property(list[BluetoothDevice], "readable") + def connected_devices(self) -> list[BluetoothDevice]: + return [dev for dev in self._devices.values() if dev.connected] + + @Property(str, "readable") + def state(self) -> str: + return snake_case_to_kebab_case( + get_enum_member_name( + self._client.get_property("default-adapter-state"), # type: ignore + default="unknown", + ) + ) + + @Property(bool, "read-write", default_value=False) + def scanning(self) -> bool: + return self._client.get_property("default-adapter-setup-mode") # type: ignore + + @scanning.setter + def scanning(self, value: bool): + return self._client.set_property("default-adapter-setup-mode", value) # type: ignore + + @Property(bool, "read-write", default_value=False) + def enabled(self) -> bool: + return self.state in ("on", "turning-on") + + @enabled.setter + def enabled(self, value: bool): + self.powered = value + return + + @Property(bool, "read-write", "is-powered", default_value=False) + def powered(self) -> bool: + return self._client.get_property("default-adapter-powered") # type: ignore + + @powered.setter + def powered(self, value: bool): + return self._client.set_property("default-adapter-powered", value) # type: ignore + + @Property(str, "readable") + def address(self) -> str: + return self._client.get_property("default-adapter-address") + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._client: GnomeBluetooth.Client = GnomeBluetooth.Client.new() # type: ignore + self._devices: dict[str, BluetoothDevice] = {} + + bulk_connect( + self._client, + { + "device-added": self.on_device_added, + "device-removed": self.on_device_removed, + "notify::default-adapter-state": lambda *args: self.notifier("state"), + "notify::default-adapter-powered": lambda *args: self.notifier( + "enabled" + ), + "notify::default-adapter-setup-mode": lambda *args: self.notifier( + "scanning" + ), + "notify::default-adapter-address": lambda *args: self.notifier( + "address" + ), + }, + ) + for device in self.do_get_raw_devices(): + self.on_device_added(self._client, device) # type: ignore + + def scan(self): + self.scanning = True + return + + def toggle_power(self): + self.powered = not self.powered + return + + def toggle_scan(self): + self.scanning = not self.scanning + return + + def do_get_raw_devices(self) -> list[GnomeBluetooth.Device]: + all_devs: list[GnomeBluetooth.Device] = self._client.get_devices() # type: ignore + return [ + dev + for dev in all_devs + if dev.get_paired() or dev.get_trusted() # type: ignore + ] + + def on_device_added(self, _, device: GnomeBluetooth.Device): + addr: str = device.props.address # type: ignore + if self._devices.get(addr, None): + return + + # this may be a mistake, it fixed an issue with me but might not be the right choice + # we should probably let the user choose what to do with devices with no name + + # RE: it's a mistake, users are responsible about their devices + + # if device.props.name is None: + # return + logger.info(f"[Bluetooth] Adding device: {addr}") + + bluetooth_device: BluetoothDevice = BluetoothDevice( + device, + self, + notify_connected=lambda *_: self.notifier("connected-devices"), + on_changed=lambda *_: self.emit("changed"), + ) + + self._devices[addr] = bluetooth_device + self.emit("device-added", addr) + self.notifier("devices") + + def on_device_removed(self, _, object_path: str): + addr = object_path.split("/")[-1][4:].replace("_", ":") + if not (device := self._devices.pop(addr, None)): + return logger.warning( + f"[Bluetooth] tried to remove a unknown device with the address {addr}" + ) + + logger.info(f"[Bluetooth] Removing device: {addr}") + + self.emit("device-removed", addr) + if device.connected: + self.notifier("connected-devices") + self.notifier("devices") + return device.close() + + def get_device(self, address: str) -> BluetoothDevice | None: + return self._devices.get(address, None) + + def connect_device( + self, + device: BluetoothDevice, + connect: bool = True, + callback: Callable[Concatenate[bool, P], Any] | None = None, + *args: P.args, + **kwargs: P.kwargs, + ): + def inner_callback(client: GnomeBluetooth.Client, res: Gio.AsyncResult): + if not callback: + return + try: + finish = client.connect_service_finish(res) + logger.info(f"[Bluetooth] Connected to device {device.address}") + callback(finish, *args, **kwargs) + except Exception: + logger.warning(f"[Bluetooth] Failed to connect device {device.address}") + callback(False, *args, **kwargs) + + return self._client.connect_service( + device.device.get_object_path(), connect, None, inner_callback + ) + + def notifier(self, name: str, *args): + self.notify(name) + self.emit("changed") + return diff --git a/fabric/fabric/core/__init__.py b/fabric/fabric/core/__init__.py new file mode 100644 index 0000000..42e0434 --- /dev/null +++ b/fabric/fabric/core/__init__.py @@ -0,0 +1,5 @@ +from .service import Service, Signal, Property, Builder +from .application import Application +from .fabricator import Fabricator + +__all__ = ["Service", "Signal", "Property", "Builder", "Application", "Fabricator"] diff --git a/fabric/fabric/core/application.py b/fabric/fabric/core/application.py new file mode 100644 index 0000000..6662d96 --- /dev/null +++ b/fabric/fabric/core/application.py @@ -0,0 +1,538 @@ +import gi +import re +import os +import atexit +import inspect +from loguru import logger +from types import FrameType +from inspect import FrameInfo +from typing import overload, Literal, Self +from collections.abc import Iterable, Callable +from fabric.core.service import Service, Property +from fabric.utils.helpers import ( + load_dbus_xml, + snake_case_to_kebab_case, + get_function_annotations, + compile_css, +) + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GLib, Gio + + +FABRIC_DBUS_INTERFACE_NAME = "org.Fabric.fabric" +FABRIC_DBUS_OBJECT_PATH = "/org/Fabric/fabric" +FABRIC_DBUS_INTERFACE_NODES = load_dbus_xml( + f"../dbus_assets/{FABRIC_DBUS_INTERFACE_NAME}.xml" +) + + +class FileHook: + file_path: str + frame: FrameType + frame_info: FrameInfo + global_scope: dict + local_scope: dict + + @classmethod + def from_here(cls, depth: int = -1) -> Self: + instance = cls.__new__(cls) + instance.__init__(inspect.stack()[depth]) + return instance + + def __init__(self, frame_info: FrameInfo, **kwargs): + super().__init__(**kwargs) + self.frame_info: FrameInfo = frame_info + self.frame: FrameType = frame_info.frame + self.global_scope: dict = self.frame.f_globals if self.frame is not None else {} + self.local_scope = self.frame.f_locals if self.frame is not None else {} + self.file_path: str = ( + ( + frame_info.filename + if frame_info is not None and frame_info.filename is not None + else None + ) + or ( + self.global_scope.get("__file__", "unknown") + if self.global_scope is not None + else None + ) + or "unknown" + ) + + def execute( + self, source: str, raise_on_exception: bool = False + ) -> Exception | None: + error = None + try: + exec(source, self.global_scope, self.local_scope) + except Exception as e: + error = e + if raise_on_exception is True and error not in (None, ""): + raise error + return error + + def evaluate(self, code: str, raise_on_exception: bool = False): + result = None + error = None + try: + result = eval( + code, + self.global_scope, + self.local_scope, + ) + except Exception as e: + error = e + if raise_on_exception is True and error not in (None, ""): + raise error + return result, error + + def is_class(self, class_name: str | object): + return inspect.isclass(self.global_scope.get(class_name, None)) + + +class DBusClient: + def __init__( + self, + application: "Application", + connection: Gio.DBusConnection, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.application = application + self.hook = FileHook.from_here() + self.connection: Gio.DBusConnection = connection + self.do_register() + + def do_register(self): + for interface in FABRIC_DBUS_INTERFACE_NODES.interfaces: + if interface.name == FABRIC_DBUS_INTERFACE_NAME: + self.connection.register_object( + FABRIC_DBUS_OBJECT_PATH, + interface, + self.do_handle_bus_call, # type: ignore + ) + return self.connection.flush() + + def do_handle_bus_call( + self, + conn: Gio.DBusConnection, + sender: str, + path: str, + interface: str, + target: str, + params: tuple, + invocation: Gio.DBusMethodInvocation, + user_data: object = None, + ) -> None: + # property accessing + match target: + case "Get": + prop_name = params[1] if len(params) >= 1 else None + match prop_name: + case "File": + invocation.return_value( + GLib.Variant( + "(v)", (GLib.Variant("s", self.hook.file_path),) + ) + ) + case "Windows": + invocation.return_value( + GLib.Variant( + "(v)", + (GLib.Variant("a{sb}", self.do_serialize_windows()),), + ) + ) + case "Actions": + invocation.return_value( + GLib.Variant( + "(v)", + (GLib.Variant("a{sas}", self.do_serialize_actions()),), + ) + ) + case _: + invocation.return_value(None) + + case "GetAll": + all_properties = { + "File": GLib.Variant("s", self.hook.file_path), + "Windows": GLib.Variant("a{sb}", self.do_serialize_windows()), + "Actions": GLib.Variant("a{sas}", self.do_serialize_actions()), + } + + invocation.return_value(GLib.Variant("(a{sv})", (all_properties,))) + + # interface methods + case "Log": + level, message = params + match level: + case 0: + logger.debug(message) + case 1: + logger.info(message) + case 2: + logger.warning(message) + case 3: + logger.error(message) + invocation.return_value(None) + case "Execute": + source: str = params[0] + exc = self.hook.execute(source) + invocation.return_value( + GLib.Variant("(s)", (exc.__repr__() if exc is not None else "",)) + ) + case "Evaluate": + code: str = params[0] + result, exc = self.hook.evaluate(code) + invocation.return_value( + GLib.Variant( + "(ss)", + ( + str(result), + exc.__repr__() if exc is not None else "", + ), + ) + ) + case "InvokeAction": + action_name: str = params[0] + arguments: list[str] = params[1] + if not (action := self.application.actions.get(action_name, None)): + invocation.return_value( + GLib.Variant( + "(bs)", + (True, f"couldn't find action with name `{action_name}`"), + ) + ) + else: + try: + invocation.return_value( + GLib.Variant("(bs)", (False, str(action[0](*arguments)))) + ) + except Exception as e: + invocation.return_value( + GLib.Variant("(bs)", (False, e.__repr__())) + ) + case _: + invocation.return_value(None) + + return conn.flush() + + def do_serialize_windows(self) -> dict[str, bool]: + windows: dict[str, bool] = {} + for window in self.application.windows: + windows[window.get_name()] = window.is_visible() + return windows + + def do_serialize_actions(self) -> dict[str, tuple[str, ...]]: + rd: dict[str, tuple[str, ...]] = {} + for name, value in self.application.actions.items(): + rd[name] = value[1] + return rd + + +class Application(Gtk.Application, Service): + _actions: dict[str, tuple[Callable, tuple[str, ...]]] = {} + + activated = Property(bool, flags="read-write", default_value=False) + name = Property(str, flags="read-write") + + @Property(dict[str, tuple[Callable, tuple[str, ...]]]) + def actions(self) -> dict[str, tuple[Callable, tuple[str, ...]]]: + return self._actions + + @Property(list[Gtk.Window]) + def windows(self) -> list[Gtk.Window]: + return self._windows + + @windows.setter + def windows(self, windows: list[Gtk.Window]): + for window in self._windows: + self.remove_window(window) + return self._windows.extend(windows) + + @Property(list[Gtk.StyleProvider], "readable") + def style_providers(self) -> list[Gtk.StyleProvider]: + return self._style_providers + + @overload + def __init__( + self, + name: str = "default", + *windows: Gtk.Window, + open_client: bool = True, + open_inspector: bool = False, + **kwargs, + ): ... + + @overload + def __init__( + self, + *windows: Gtk.Window, + open_client: bool = True, + open_inspector: bool = False, + **kwargs, + ): ... + + def __init__( + self, + *args: str | Gtk.Window, + open_client: bool = True, + open_inspector: bool = False, + **kwargs, + ): + name: str + windows: Iterable[Gtk.Window] + + name = "default" + if len(args) < 1: + windows = () + elif isinstance(args[0], str): + name, *windows = args # type: ignore + elif isinstance(args[0], Gtk.Window): + windows = args # type: ignore + else: + raise ValueError # FIXME: add a error message + + application_id = FABRIC_DBUS_INTERFACE_NAME + f".{name or 'default'}" + + if not self.validate_name(name): + raise ValueError( + f"`{name}` is an invalid name, consider using only English letters, numbers, and hyphens in the name" + ) + + if self.name_running(application_id): + raise ValueError( + f"there's already a Fabric application instance running with the name `{name}`" + ) + + Gtk.Application.__init__( + self, # type: ignore + application_id=application_id, + ) + + Service.__init__( + self, + **kwargs, + ) + self._windows: list[Gtk.Window] = [] + self._open_client = open_client + self._style_providers = [] + + self.name = name + self.windows = [window for window in windows] + GLib.set_application_name(name) if name else None + + self.open_inspector() if open_inspector else None + + def remove_window(self, window: Gtk.Window): + self._windows.remove(window) + if self.activated: + super().remove_window(window) + return + + def add_window(self, window: Gtk.Window): + self._windows.append(window) + if self.activated: + super().add_window(window) + return + + def dispatch_windows(self): + if not self.activated: + return + for window in self.windows: + super().add_window(window) + return + + def do_activate(self): + if self.activated: + return + self.activated = True + self.hold() + self.dispatch_windows() + + if self._open_client: + logger.debug( + f"[Fabric] opening a DBus client for Application with name {self.name}" + ) + self.dbus_client = DBusClient(self, self.get_dbus_connection()) + else: + self.dbus_client = None + + def quit(self, *_): + self.activated = False + return super().quit() + + def run(self, *args, **kwargs): + def on_exit(): + # assuming clean exit (e.g. segfaults are considered dirty) + return logger.info("[Fabric] exiting...") + + atexit.register(on_exit) + try: + super().run(*args, **kwargs) + finally: + # the application has quit (somehow in a bad way...) + # we do nothing, just return thus print the at exit message + return + + def open_inspector(self): + return Gtk.Window.set_interactive_debugging(True) # type: ignore + + @staticmethod + def validate_name(name: str) -> bool: + pattern = re.compile(r"^[A-Za-z0-9-]+$") + return bool(pattern.match(name)) + + @staticmethod + def name_running(name: str) -> bool: + return ( + Gio.bus_get_sync(Gio.BusType.SESSION) + .call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + GLib.Variant("(s)", (name,)), + GLib.VariantType("(b)"), # type: ignore + Gio.DBusCallFlags.NONE, + -1, + None, + ) + .get_child_value(0) + .get_boolean() + ) + + @staticmethod + def get_dbus_proxy(config_name: str | None = None) -> Gio.DBusProxy | None: + proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.NONE, + None, + FABRIC_DBUS_INTERFACE_NAME + + (f".{config_name}" if config_name is not None else ""), + FABRIC_DBUS_OBJECT_PATH, + FABRIC_DBUS_INTERFACE_NAME, + None, + ) + return proxy + + def add_style_provider( + self, + provider: Gtk.StyleProvider, + priority: Literal["fallback", "theme", "settings", "application", "user"] + | int = Gtk.STYLE_PROVIDER_PRIORITY_USER, + ) -> None: + # well, i have to manually bind this + self._style_providers.append(provider) + self.notify("style-providers") + return Gtk.StyleContext.add_provider_for_screen( # type: ignore + Gdk.Screen.get_default(), + provider, + ( + { + "fallback": Gtk.STYLE_PROVIDER_PRIORITY_FALLBACK, + "theme": Gtk.STYLE_PROVIDER_PRIORITY_THEME, + "settings": Gtk.STYLE_PROVIDER_PRIORITY_SETTINGS, + "application": Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + "user": Gtk.STYLE_PROVIDER_PRIORITY_USER, + }.get(priority, Gtk.STYLE_PROVIDER_PRIORITY_USER) + if isinstance(priority, str) + else priority + ), + ) + + def remove_style_provider(self, provider: Gtk.StyleProvider) -> None: + screen = Gdk.Screen.get_default() + return Gtk.StyleContext.remove_provider_for_screen(screen, provider) # type: ignore + + def reset_styles(self) -> None: + for style_provider in self.style_providers: + self.remove_style_provider(style_provider) + self._style_providers.remove(style_provider) + return + + def set_style_provider( + self, + provider: Gtk.StyleProvider, + priority: Literal["fallback", "theme", "settings", "application", "user"] + | int = Gtk.STYLE_PROVIDER_PRIORITY_USER, + ) -> None: + self.reset_styles() + return self.add_style_provider(provider, priority) + + def set_stylesheet_from_file( + self, + file_path: str, + compile: bool = True, + append: bool = False, + *args, + **kwargs, + ) -> None: + if compile: + with open(file_path, "r") as f: + self.set_stylesheet_from_string( + f.read(), + compile, + append, + base_path=os.path.dirname(file_path), + *args, + **kwargs, + ) + return + provider = Gtk.CssProvider() + provider.load_from_path(file_path) + + return (self.set_style_provider if not append else self.add_style_provider)( + provider, *args, **kwargs + ) + + def set_stylesheet_from_string( + self, + style_string: str, + compile: bool = True, + append: bool = False, + *args, + **kwargs, + ) -> None: + provider = Gtk.CssProvider() + provider.load_from_data( + bytearray( + compile_css(style_string, *args, **kwargs) if compile else style_string, + "utf-8", + ) # type: ignore + ) + + return (self.set_style_provider if not append else self.add_style_provider)( + provider + ) + + @staticmethod + def action(name: str | None = None): + """ + An action is an alternative way for calling userspace defined functions through the dbus client (or cli) + actions make it easier for calling functions defined in modules rather than your config's entry point + + :param name: a name to register this action with, if none, use the function's name converted to kebab case, defaults to None + :type name: str | None, optional + """ + + def wrapper(func: Callable): + nonlocal name + + name = name or snake_case_to_kebab_case(func.__name__) + if oa := Application._actions.get(name, None): + logger.error( + f"[Action] a different action is already registered with the name `{name}` ({oa})" + ) + return func + + logger.info(f"[Action] registering action with name {name}") + + Application._actions[name] = ( + func, + tuple(get_function_annotations(func).arguments.keys()), + ) + return func + + return wrapper diff --git a/fabric/fabric/core/fabricator.py b/fabric/fabric/core/fabricator.py new file mode 100644 index 0000000..df51e66 --- /dev/null +++ b/fabric/fabric/core/fabricator.py @@ -0,0 +1,180 @@ +from loguru import logger +from collections.abc import Callable +from typing import ( + cast, + overload, + Concatenate, + ParamSpec, + Generic, + TypeVar, + Self, + Any, +) +from fabric.core.service import Service, Signal, Property +from fabric.utils.helpers import invoke_repeater, exec_shell_command_async, idle_add +from gi.repository import GLib, Gio + +T = TypeVar("T") +P = ParamSpec("P") +MISSING = TypeVar("MISSING") + + +# TODO: improve typing... +class Fabricator(Service, Generic[T]): + @Signal + def changed(self, value: object) -> None: ... + + @Signal + def polling_done(self, last_value: object) -> None: ... + + @Property(type[T], flags="read-write") + def value(self) -> type[T]: + return self._value # type: ignore + + @value.setter + def value(self, value): + self._value = value + return self.changed(value) + + @overload + def __init__( + self, + poll_from: Callable[Concatenate[Self, P], T], + interval: int = 1000, + stream: bool = False, + default_value: T | Any = None, + initial_poll: bool = True, + *data, + **kwargs, + ): ... + + @overload + def __init__( + self, + poll_from: Callable[Concatenate[Self, P], T], + interval: int = 1000, + stream: bool = True, + default_value: T | Any = None, + initial_poll: bool = True, + *data, + **kwargs, + ): ... + + @overload + def __init__( + self, + poll_from: str, + interval: int = 1000, + stream: bool = False, + default_value: str | Any = None, + initial_poll: bool = True, + *args, + **kwargs, + ): ... + + @overload + def __init__( + self, + poll_from: str, + interval: int = 1000, + stream: bool = True, + default_value: str | Any = None, + initial_poll: bool = True, + *args, + **kwargs, + ): ... + + def __init__( + self, + poll_from: Callable[Concatenate[Self, P], T] | str, + interval: int = 1000, + stream: bool = False, + default_value: T | str | Any = None, + initial_poll: bool = True, + *data, + **kwargs, + ): + super().__init__(**kwargs) + + self._value = default_value + self._poll_from = poll_from + self._interval = interval + self._initial_poll = initial_poll + self._data = data + self._poll = True + self._stream = stream + self._stream_thread: GLib.Thread | None = None + + self.start() if initial_poll is True else None + + def start(self): + self._poll = True + is_callable = callable(self._poll_from) + if is_callable and not self._stream: + invoke_repeater(self._interval, self.do_invoke_function) + + elif is_callable and self._stream: + self._stream_thread = GLib.Thread.new( + f"fabricator-thread-{self.__str__()}", + self.do_read_function_stream, + *self._data, + ) + + elif not is_callable and not self._stream: + invoke_repeater(self._interval, self.do_read_shell_command_io) + + elif not is_callable and self._stream: + self.do_read_shell_command_io() + + else: + self.emit("polling-done", None) + logger.warning( + "[Fabricator] polling mode is unknown, probably because of the passed arguments begin wrong, skipping..." + ) + + def stop(self) -> None: + if self._poll is True: + self._poll = False + if self._stream_thread: + self._stream_thread.exit() + return + return logger.warning( + f"[Fabricator] Fabricator ({self}) is already not polling" + ) + + def do_read_shell_command_io(self): + data = None + + def result_handler(output: str): + nonlocal data + if not self._poll: + self.emit("polling-done", None) + return cast(Gio.Subprocess, process).force_exit() # sorry + data = output + self.value = data + + process, _ = exec_shell_command_async( + cast(str, self._poll_from), result_handler + ) + process.wait_async( + None, lambda *_: self.emit("polling-done", data) + ) if process and self._stream else None + return True + + def do_invoke_function(self): + if self._poll is not True: + self.emit("polling-done", None) + return False + value = cast(Callable, self._poll_from)(self, *self._data) + self.value = value + return True + + def do_read_function_stream(self, *func_data): + # this should get called in a new thread, else the main thread will be blocked + data = None + for data in cast(Callable, self._poll_from)(self, *func_data): + if not self._poll: + break + idle_add(self.set_value, data) + idle_add(self.emit, "polling-done", data) + return False diff --git a/fabric/fabric/core/service.py b/fabric/fabric/core/service.py new file mode 100644 index 0000000..6f02f56 --- /dev/null +++ b/fabric/fabric/core/service.py @@ -0,0 +1,604 @@ +# GObject must be imported first so the overrides can be imported +from gi.repository import GObject + +# all aboard... +import gi.overrides +import gi._signalhelper +import gi._propertyhelper +import gi.overrides.GObject + +from dataclasses import dataclass +from collections.abc import Callable +from typing import ( + overload, + Concatenate, + ParamSpec, + Generator, + Optional, + Generic, + TypeVar, + Literal, + Union, + Self, + Any, +) +from fabric.utils.helpers import ( + get_enum_member, + snake_case_to_kebab_case, + kebab_case_to_snake_case, + get_function_annotations, + make_arguments_ignorable, +) + +OldSignal = gi._signalhelper.Signal +OldProperty = gi._propertyhelper.Property +OldGObject = gi.overrides.GObject.Object + +old_signal_installer = gi._signalhelper.install_signals +old_property_installer = gi._propertyhelper.install_properties + + +P = ParamSpec("P") +R = TypeVar("R") +T = TypeVar("T") +G = TypeVar("G", bound=GObject.Object) + + +def override(parent: object, child_name: str): + def inner(obj: T) -> T: + setattr(parent, child_name, obj) + return obj + + return inner + + +# TODO: improve the structure of this class +@override(GObject, "Property") +@override(gi._propertyhelper, "Property") +class Property(OldProperty, Generic[T]): + """ + replace Python's `property` built-in decorator + with this decorator to get properties in your service + """ + + name: str + flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE + install: bool = True + + def __init__( + self, + type: type[T], + flags: Union[ + Literal[ + "r", + "w", + "rw", + "readable", + "writable", + "read-write", + "construct", + "construct-only", + "lax-validation", + "static-name", + "private", + "static-nick", + "static-blurb", + "explicit-notify", + "deprecated", + ], + GObject.ParamFlags, + ] = GObject.ParamFlags.READWRITE, + nickname: Optional[str] = None, + description: str = "", + # boolean types + default_value: Optional[T] = None, + # int types + minimum: Optional[int] = None, + maximum: Optional[int] = None, + getter: Optional[Callable] = None, + setter: Optional[Callable] = None, + install: bool = True, + **kwargs, + ): + self.install = install + flags = get_enum_member( + GObject.ParamFlags, + flags, + { + "r": "readable", + "w": "writeable", + "rw": "readwrite", + "read-write": "readwrite", + }, + ) + + if not self.install: + self.__setattr__( + "_setter_middle_gate", + lambda instance, value: self.fset(instance, value), # type: ignore + ) + try: + _type = type if issubclass(type, (bool, int, float, str)) else object + except TypeError: + _type = object + + super().__init__( + type=_type, + default=default_value, + nick=nickname or "", + blurb=description, + flags=flags, + getter=getter, + setter=setter, + minimum=minimum, + maximum=maximum, + **kwargs, + ) + + def _default_getter(self, instance) -> T: + return getattr(instance, ("_property_helper_" + self.name), self.default) + + def _default_setter(self, instance, value: T) -> None: + return setattr(instance, ("_property_helper_" + self.name), value) + + def getter(self, fget: Callable[..., T]) -> Self: + return super().getter(fget) + + def setter(self, fset: Callable) -> Self: + return super().setter(fset) + + def __call__(self, fget: Callable[..., T]) -> Self: + return self.getter(fget) + + def __get__(self, instance, klass=None) -> T: + # Property -> get current value of self + return super().__get__(instance, klass) # type: ignore + + def __set__(self, instance, value) -> None: + # Property = X -> set X as the current value of the property + return self._setter_middle_gate(instance, value) + + def _setter_middle_gate(self, instance, value) -> None: + return super().__set__(instance, value) + + @staticmethod + @override(gi._propertyhelper, "install_properties") + def installer(klass): + klass_dict: dict[str, Any] = klass.__dict__.copy() + klass_properties: dict[str, tuple] = klass_dict.get("__gproperties__", {}) + scanned_properties: list[OldProperty] = [] + + for iklass in reversed( + klass.__mro__ + ): # fix what pygobject missed, inheriting properties + for name, value in iklass.__dict__.copy().items(): + if not isinstance(value, OldProperty): + continue + + if isinstance(value, Property) and not value.install: + continue + + if not value.name: + value.name = snake_case_to_kebab_case(name) + + # we will encounter the same property multiple times in case of + # custom setter methods + if value.name in klass_properties: + if klass_properties[value.name] == value.get_pspec_args(): + continue + raise ValueError( + f"Property {value.name} was already found in __gproperties__" + ) + klass_properties[value.name] = value.get_pspec_args() + scanned_properties.append(value) + + for prop in scanned_properties: + if prop.fget is prop._default_getter or prop.fset is prop._default_setter: + raise TypeError( + f"GObject subclass {klass.__name__} defines do_get/set_property" + + " and it also uses a property with a custom setter" + + " or getter. This is not allowed" + ) + + setattr( + klass, + f"get_{kebab_case_to_snake_case(prop.name)}", # type: ignore + lambda self, *args, _prop=prop, **kwargs: Service.get_property( + self, + _prop.name, # type: ignore + *args, + **kwargs, + ), + ) + setattr( + klass, + f"set_{kebab_case_to_snake_case(prop.name)}", # type: ignore + lambda self, value, *args, _prop=prop, **kwargs: Service.set_property( + self, + _prop.name, # type: ignore + value, + *args, + **kwargs, + ), + ) + + # all aboard... + def property_do_get(self, pspec): + return getattr(self, kebab_case_to_snake_case(pspec.name), None) + + def property_do_set(self, pspec, value): + prop = getattr(klass, kebab_case_to_snake_case(pspec.name), None) + prop.fset(self, value) if prop is not None else None + + setattr(klass, "do_get_property", property_do_get) + setattr(klass, "do_set_property", property_do_set) + setattr(klass, "__gproperties__", klass_properties) + + +@dataclass +class SignalWrapper(Generic[G, P, R]): + name: str + instance: G + func: Optional[Callable[Concatenate[G, P], R]] = None + + def __call__(self, /, *args: P.args, **kwargs: P.kwargs) -> Optional[R]: + return self.emit(*args, **kwargs) + + def emit(self, /, *args: P.args, **kwargs: P.kwargs) -> Optional[R]: + rvalue: Optional[R] = ( + self.func(self.instance, *args, **kwargs) if self.func is not None else None + ) + self.instance.emit(self.name, *args, **kwargs) # type: ignore + return rvalue + + # TODO: make it hint self's instance for the callback + def connect( + self, + callback: Callable[Concatenate[G, P], Any] | Callable, + *args, + ignore_missing: bool = True, + ) -> int: + return Service.connect( + self.instance, # type: ignore + self.name, + callback, + *args, + ignore_missing=ignore_missing, + ) + + +class Signal(Generic[G, P, R]): + name: str + flags: GObject.SignalFlags = GObject.SignalFlags.RUN_FIRST + arg_types: tuple = () + rtype: Any = None + install: bool = True + + # private, kinda + func: Optional[Callable[Concatenate[G, P], R]] = None + + def __init__( + self, + name: Union[str, Callable[Concatenate[G, P], R]], + flags: Union[ + Literal[ + "run-last", + "run-first", + "run-cleanup", + "no-recurse", + "detailed", + "action", + "no-hooks", + "must-collect", + "deprecated", + "accumulator-first-run", + ], + GObject.SignalFlags, + ] = GObject.SignalFlags.RUN_FIRST, + rtype: Any = None, + arg_types: tuple = (), + install: bool = True, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.flags = get_enum_member(GObject.SignalFlags, flags) + self.install = install + if callable(name): + self.func = name + self.name = snake_case_to_kebab_case(self.func.__name__) + fanno = get_function_annotations(self.func) + self.arg_types, self.rtype = ( + tuple(filter(lambda x: x is not Any, fanno[0].values())), + fanno[1], + ) + return + self.name = name + self.arg_types, self.rtype = arg_types, rtype + return + + def __call__(self, func: Callable[Concatenate[G, P], R]) -> "Signal[G, P, R]": + rsignal = Signal(func, self.flags, install=self.install) + rsignal.name = self.name or rsignal.name + return rsignal + + def __get__(self, instance: G, owner: type[G]) -> SignalWrapper[G, P, R]: + # looks like we have an initialized object now... + return SignalWrapper( + self.name, + instance, + self.func, + ) + + def detail(self, detail_name) -> "Signal[G, P, R]": + # a decorator for creating detailed signals + if (GObject.SignalFlags.DETAILED & self.flags) != GObject.SignalFlags.DETAILED: # type: ignore + self.flags = self.flags | GObject.SignalFlags.DETAILED # type: ignore + rsignal = Signal( + self.name + "::" + detail_name, + self.flags, + self.arg_types, + self.rtype, + False, + ) + rsignal.func = self.func + return rsignal + + def serialize( + self, + ) -> dict[str, tuple[GObject.SignalFlags, Any, tuple[Any]]]: + if not self.name: + return {} # i don't even know + return {f"{self.name}": (self.flags, self.rtype, self.arg_types)} + + @staticmethod + @override(gi._signalhelper, "install_signals") + def installer(klass): + klass_dict: dict[str, Any] = klass.__dict__.copy() + klass_signals: dict[str, tuple] = klass_dict.get("__gsignals__", {}) + scanned_signals: list[Signal] = [] + for name, value in klass_dict.items(): + if isinstance(value, OldSignal): + # for compatibility with legacy modules + old_signal_installer(klass) + continue + if not isinstance(value, Signal) or not value.install: + continue + + if not value.name: + # the name is missing (for some reason) + # we add it manually from the variable name + value.name = snake_case_to_kebab_case(name) + if value.name in klass_signals: + raise ValueError(f"Signal {value.name} has already been registered.") + klass_signals = klass_signals | value.serialize() + scanned_signals.append(value) + + for signal in scanned_signals: + if not signal.func: + continue + fname = "do_" + name + if not hasattr(klass, fname): + setattr(klass, fname, signal.func) + + # all aboard... + setattr(klass, "__gsignals__", klass_signals) + return + + +@dataclass +class Builder(Generic[G]): + parent: G + + def unwrap(self) -> G: + return self.parent + + def __getattr__(self, name: str): + return lambda *args, **kwargs: ( + getattr(self.parent, name)(*args, **kwargs), + self, + )[1] + + +@override(GObject, "Object") +@override(gi.overrides.GObject, "Object") +class Service(GObject.Object, Generic[P, T]): + """ + Base service class + + Handles constructor connections, bindings and builders + """ + + __gsignals__ = {} + __gproperties__ = {} + props: list[Any] + + def __init__(self, **kwargs): + """Default service constructor (base constructor) + + :param **kwargs: mapped to signal connections (e.g. `on_clicked=lambda *_: ...` connects the given function to the signal "clicked", `notify_my_property=my_func` connects the given function to the signal "notify::my-property") + """ + super().__init__(**self.filter_kwargs(kwargs)) + self._builder: Optional[Builder] = None + self.do_connect_kwargs(kwargs) + + @overload + def build( + self, + callback: None = None, + *args: P.args, + **kwargs: P.kwargs, + ) -> Builder[Self]: ... + + @overload + def build( + self, + callback: Callable[Concatenate[Self, Builder[Self], P], Any] + | Callable[Concatenate[Self, P], Any] + | Callable[P, Any], + *args: P.args, + **kwargs: P.kwargs, + ) -> Self: ... + + def build( + self, + callback: Optional[ + Callable[Concatenate[Self, Builder[Self], P], Any] + | Callable[Concatenate[Self, P], Any] + | Callable[P, Any] + ] = None, + *args: P.args, + **kwargs: P.kwargs, + ) -> Union[Builder[Self], Self]: + """Get an instance of a `Builder` that holds a reference to this service + + Builders enable you of doing extra configuration after the initialization + of a service with ease, it makes you able to either have a callback function + that does all the extra setup or chain all your setup calls at once + + **Examples:** + + .. code-block:: python + + my_service = ( + MyService() + .build() + # beginning of method chaining + .useful_method() + .other_useful_method() + .set_something("something") + .unwrap() # unwrap self (the service) and get a reference to it + ) + + # when passing a setup function it returns self by default + my_service = MyService().build(lambda self, builder: self.do_something()) + my_service = MyService().build( + lambda self, builder: ( + builder.useful_method() + .other_useful_method() + .set_something("something") + # no need to do `.unwrap()` since we don't really need the value of self + ) + ) + + + :param callback: an optional callback to use instead of chaining method calls, defaults to None + :type callback: Optional[Callable[Concatenate[Self, Builder[Self], P], Any] | Callable[Concatenate[Self, P], Any] | Callable[P, Any]], optional + :return: a newly created builder (or a cached one if found) + :rtype: Union[Builder[Self], Self] + """ + if not self._builder: + self._builder = Builder(self) + if callable(callback): + make_arguments_ignorable(callback)(self, self._builder, *args, **kwargs) + return self + return self._builder + + def bind( + self, + source_property: str, + target_property: str, + target_object: GObject.Object, + transform_to: Callable[[GObject.Binding, Any], Any] | None = None, + transform_from: Callable[[GObject.Binding, Any], Any] | None = None, + flags: Literal["default", "bidirectional", "sync-create", "invert-boolean"] + | GObject.BindingFlags = GObject.BindingFlags.DEFAULT, + ): + return self.bind_property( + source_property, + target_object, + target_property, + get_enum_member( + GObject.BindingFlags, flags, default=GObject.BindingFlags.DEFAULT + ), + transform_to or (lambda _, v: v), + transform_from or (lambda _, v: v), + ) + + def emit(self, signal_name: str, *args, **kwargs) -> None: + return super().emit(signal_name, *args, **kwargs) # type: ignore + + def connect( + self, + signal_name: str, + callback: Callable[Concatenate[Self, P], Any] | Callable, + *args, + ignore_missing: bool = True, + ) -> int: + return super().connect( + signal_name, + make_arguments_ignorable(callback) if ignore_missing else callback, + *args, + ) # type: ignore + + @staticmethod + def filter_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + return { + k: v + for k, v in kwargs.items() + if not k.lower().startswith(("on_", "notify_")) + } + + @staticmethod + def get_connectables_for_kwargs( + kwargs: dict[str, Any], + ) -> Generator[tuple[str, Callable[..., Any]], None, None]: + for key, value in zip(kwargs.keys(), kwargs.values()): + if key.startswith("on_"): + yield snake_case_to_kebab_case(key[3:]), value + elif key.startswith("notify_"): + # yield a connectable property + yield f"notify::{snake_case_to_kebab_case(key[7:])}", value + return + + def do_connect_kwargs(self, kwargs: dict[str, Any]) -> None: + for name, callback in self.get_connectables_for_kwargs(kwargs): + self.connect(name, callback) # type: ignore + return + + # overrides + def set_property(self, property_name: str, value: Any) -> None: + return super().set_property(property_name, value) # type: ignore + + def get_property(self, property_name: str) -> Any: + return super().get_property(property_name) # type: ignore + + def set_properties(self, *args, **kwargs) -> None: + return super().set_properties(*args, **kwargs) # type: ignore + + def get_signal_names(self) -> list[str]: + return GObject.signal_list_names(self) # type: ignore + + def get_signal_ids(self) -> list[int]: + return GObject.signal_list_ids(self) # type: ignore + + def get_properties(self) -> list[GObject.ParamSpec]: + return GObject.list_properties(self) # type: ignore + + def notify(self, *properties: str): + for prop in properties: + super().notify(prop) + return + + def notify_all(self): + for pspec in self.get_properties(): + self.notify(pspec.get_nick() or pspec.get_name()) + return + + # set/get properties via [] accessor + def __getitem__(self, key: str) -> Any: + assert isinstance(key, str) + return self.get_property(key) + + def __setitem__(self, key: str, value: Any) -> None: + assert isinstance(key, str) + return self.set_property(key, value) + + # extras + def __len__(self) -> int: + return len(self.get_properties()) + len(self.get_signal_names()) + + def __int__(self) -> int: + return self.__len__() + + +__all__ = ["Signal", "Property", "Builder", "Service"] diff --git a/fabric/fabric/core/widgets/__init__.py b/fabric/fabric/core/widgets/__init__.py new file mode 100644 index 0000000..57e1ff2 --- /dev/null +++ b/fabric/fabric/core/widgets/__init__.py @@ -0,0 +1,3 @@ +from .wm import WorkspaceButton, Workspaces, ActiveWindow, Language + +__all__ = ["WorkspaceButton", "Workspaces", "ActiveWindow", "Language"] diff --git a/fabric/fabric/core/widgets/wm.py b/fabric/fabric/core/widgets/wm.py new file mode 100644 index 0000000..71eb939 --- /dev/null +++ b/fabric/fabric/core/widgets/wm.py @@ -0,0 +1,287 @@ +import gi +import re +from loguru import logger +from typing import Literal +from collections.abc import Iterable, Callable +from fabric.core.service import Signal, Property +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.eventbox import EventBox +from fabric.utils.helpers import FormattedString, truncate + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + + +class WorkspaceButton(Button): + @Property(int, "readable") + def id(self) -> int: + return self._id + + @Property(bool, "read-write", default_value=False) + def active(self) -> bool: + return self._active + + @active.setter + def active(self, value: bool): + self._active = value + if value is True: + self.urgent = False + (self.remove_style_class if not value else self.add_style_class)("active") + return self.do_bake_label() + + @Property(bool, "read-write", default_value=False) + def urgent(self) -> bool: + return self._urgent + + @urgent.setter + def urgent(self, value: bool): + self._urgent = value + (self.remove_style_class if not value else self.add_style_class)("urgent") + return self.do_bake_label() + + @Property(bool, "read-write", default_value=True) + def empty(self) -> bool: + return self._empty + + @empty.setter + def empty(self, value: bool): + self._empty = value + (self.remove_style_class if not value else self.add_style_class)("empty") + return self.do_bake_label() + + def __init__( + self, + id: int, + label: FormattedString | str | None = None, + image: Gtk.Image | None = None, + child: Gtk.Widget | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + super().__init__( + None, + image, + child, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self._id: int = id + self._label: FormattedString | None = ( + FormattedString(label) if isinstance(label, str) else label + ) + self._active: bool = False + self._urgent: bool = False + self._empty: bool = True + + self.active = False + self.urgent = False + self.empty = True + + def do_bake_label(self): + if not self._label: + return + return self.set_label(self._label.format(button=self)) + + +class Workspaces(EventBox): + @staticmethod + def default_buttons_factory(workspace_id: int): + return WorkspaceButton(id=workspace_id, label=str(workspace_id)) + + @Signal + def workspace_activated(self, workspace_id: int): + if workspace_id == self._active_workspace: + return + + if self._active_workspace is not None and ( + old_btn := self._buttons.get(self._active_workspace) + ): + old_btn.active = False + + self._active_workspace = workspace_id + if not (btn := self.lookup_or_bake_button(workspace_id)): + return + + btn.urgent = False + btn.active = True + + if btn in self._container.children: + return + return self.insert_button(btn) + + @Signal + def workspace_created(self, workspace_id: int): + if not (btn := self.lookup_or_bake_button(workspace_id)): + return + + btn.empty = False + if btn in self._buttons_preset: + return + return self.insert_button(btn) + + @Signal + def workspace_destroyed(self, workspace_id: int): + if not (btn := self._buttons.get(workspace_id)): + return # doesn't exist, skip + + btn.active = False + btn.urgent = False + btn.empty = True + + if btn in self._buttons_preset: + return + return self.remove_button(btn) + + @Signal + def urgent(self, workspace_id: int): + if not (btn := self._buttons.get(workspace_id)): + return # doesn't exist, skip + + btn.urgent = True + return logger.info(f"[Workspaces] workspace {workspace_id} is now urgent") + + # user interaction (e.g. scrolling) + @Signal + def action_next(self): ... + + @Signal + def action_previous(self): ... + + @Signal + def button_clicked(self, button: WorkspaceButton): ... + + def __init__( + self, + buttons: Iterable[WorkspaceButton] | None = None, + buttons_factory: Callable[[int], WorkspaceButton | None] + | None = default_buttons_factory, + invert_scroll: bool = False, + **kwargs, + ): + super().__init__(events="scroll") + + self._container = Box(**kwargs) + + self.children = self._container + + self._active_workspace: int | None = None + self._buttons: dict[int, WorkspaceButton] = {} + self._buttons_preset: list[WorkspaceButton] = [ + button for button in buttons or () + ] + self._buttons_factory = buttons_factory + self._invert_scroll = invert_scroll + + for btn in self._buttons_preset: + self.insert_button(btn) + + def do_handle_scroll(self, _, event: Gdk.EventScroll): # TODO: abstract + match event.direction: # type: ignore + case Gdk.ScrollDirection.UP: + self.action_next() if not self._invert_scroll else self.action_previous() + logger.info("[Workspaces] Moving to the next workspace") + case Gdk.ScrollDirection.DOWN: + self.action_previous() if not self._invert_scroll else self.action_next() + logger.info("[Workspaces] Moving to the previous workspace") + case _: + return logger.warning( + f"[Workspaces] Unknown scroll direction ({event.direction})" # type: ignore + ) + return + + def insert_button(self, button: WorkspaceButton) -> None: + self._buttons[button.id] = button + self._container.add(button) + button.connect("clicked", self.do_handle_button_press) + return self.reorder_buttons() + + def reorder_buttons(self): + for _, child in sorted(self._buttons.items(), key=lambda i: i[0]): + self._container.reorder_child(child, (child.id - 1)) + return + + def remove_button(self, button: WorkspaceButton) -> None: + if self._buttons.pop(button.id, None) is not None: + self._container.remove(button) + return button.destroy() + + def lookup_or_bake_button(self, workspace_id: int) -> WorkspaceButton | None: + if not (btn := self._buttons.get(workspace_id)) and self._buttons_factory: + btn = self._buttons_factory(workspace_id) + return btn + + def do_handle_button_press(self, button: WorkspaceButton): # TODO: abstract + self.button_clicked(button) + return logger.info(f"[Workspaces] Moved to workspace {button.id}") + + +class ActiveWindow(Button): + @Signal + def window_activated(self, window_class: str, window_title: str): + return self.set_label( + self.formatter.format(win_class=window_class, win_title=window_title) + ) + + def __init__( + self, + formatter: FormattedString = FormattedString( + "{'Desktop' if not win_title else truncate(win_title, 42)}", + truncate=truncate, + ), + # TODO: hint super's kwargs + **kwargs, + ): + super().__init__(**kwargs) + self.formatter = formatter + + +class Language(Button): + @Signal + def layout_changed(self, language: str, keyboard: str) -> bool: + matched: bool = False + + if re.match(self.keyboard, keyboard) and (matched := True): + self.set_label(self.formatter.format(language=language)) + + logger.debug( + f"[Language] Keyboard: {keyboard}, Language: {language}, Match: {matched}" + ) + return matched + + def __init__( + self, + keyboard: str = ".*", + formatter: FormattedString = FormattedString("{language}"), + # TODO: hint super's kwargs + **kwargs, + ): + super().__init__(**kwargs) + self.keyboard = keyboard + self.formatter = formatter diff --git a/fabric/fabric/dbus_assets/__init__.py b/fabric/fabric/dbus_assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fabric/fabric/dbus_assets/org.Fabric.fabric.xml b/fabric/fabric/dbus_assets/org.Fabric.fabric.xml new file mode 100644 index 0000000..0ec5845 --- /dev/null +++ b/fabric/fabric/dbus_assets/org.Fabric.fabric.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fabric/fabric/dbus_assets/org.freedesktop.Notifications.xml b/fabric/fabric/dbus_assets/org.freedesktop.Notifications.xml new file mode 100644 index 0000000..ed92aaf --- /dev/null +++ b/fabric/fabric/dbus_assets/org.freedesktop.Notifications.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fabric/fabric/dbus_assets/org.kde.StatusNotifierItem.xml b/fabric/fabric/dbus_assets/org.kde.StatusNotifierItem.xml new file mode 100644 index 0000000..92bdde1 --- /dev/null +++ b/fabric/fabric/dbus_assets/org.kde.StatusNotifierItem.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fabric/fabric/dbus_assets/org.kde.StatusNotifierWatcher.xml b/fabric/fabric/dbus_assets/org.kde.StatusNotifierWatcher.xml new file mode 100644 index 0000000..eee00bf --- /dev/null +++ b/fabric/fabric/dbus_assets/org.kde.StatusNotifierWatcher.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fabric/fabric/hyprland/__init__.py b/fabric/fabric/hyprland/__init__.py new file mode 100644 index 0000000..da09e9f --- /dev/null +++ b/fabric/fabric/hyprland/__init__.py @@ -0,0 +1,3 @@ +from .service import Hyprland, HyprlandEvent, HyprlandReply + +__all__ = ["Hyprland", "HyprlandEvent", "HyprlandReply"] diff --git a/fabric/fabric/hyprland/service.py b/fabric/fabric/hyprland/service.py new file mode 100644 index 0000000..00638bc --- /dev/null +++ b/fabric/fabric/hyprland/service.py @@ -0,0 +1,287 @@ +import os +from loguru import logger +from dataclasses import dataclass +from collections.abc import Callable +from typing import ParamSpec, Concatenate, Any +from fabric.core.service import Service, Signal, Property + +from fabric.utils.helpers import idle_add +from gi.repository import ( + Gio, + GLib, +) + +P = ParamSpec("P") +HYPRLAND_COMMAND_BUFFER_SIZE = 1_048_576 # 12mb -> binary bytes + + +# exceptions +# TODO: use the rest of 'em +class HyprlandError(Exception): ... + + +class HyprlandSocketError(Exception): ... + + +class HyprlandSocketNotFoundError(Exception): ... + + +# dataclasses with frozen flag +# to avoid unexpected changes +@dataclass(frozen=True) +class HyprlandEvent: + name: str + "the name of the received event" + data: list[str] + "the data gotten from event's body" + raw_data: bytes + "the data as it's from the socket's event, it may be formatted as following `event-name>>event-data1,event-data2`" + + +@dataclass(frozen=True) +class HyprlandReply: + command: str + "the passed in command" + reply: bytes + "the raw reply from Hyprland" + is_ok: bool + """ + this indicates if the ran command has returned `ok` or not + if set to `False` this means either the command execution has failed + or the command itself doesn't return a indication on if it failed or not + (i.e commands that return data from Hyprland) + """ + + +class Hyprland(Service): + """ + a connection to the Hyprland's socket + this can be used for ONLY sending commands or both sending and receiving events + """ + + EVENTS_SOCKET = COMMANDS_SOCKET = None + SOCKET_PATH = "" + + # refs + # https://wiki.hyprland.org/IPC + + @Property(bool, "readable", "is-ready", default_value=False) + def ready(self) -> bool: + return self._ready + + @Signal + def ready(self): + return self.notify("ready") + + @Signal("event", flags="detailed") + def event(self, event: object): ... + + def __init__(self, commands_only: bool = False, **kwargs): + """ + :param commands_only: set to `True` if you're going to use this connection for sending commands only, defaults to False + :type commands_only: bool, optional + + NOTE: since version v0.40.0 of Hyprland, the IPC socket path has been changed from `/tmp/hypr/` to `$XDG_RUNTIME_DIR/hypr/` + + this service is backward compaitible so this is just a friendly note + """ + super().__init__(**kwargs) + self._ready = False + self.lookup_socket() # set the above constants + + # all aboard... + if not commands_only: + self.event_socket_thread = GLib.Thread.new( + "hyprland-socket-service", self.event_socket_task, self.EVENTS_SOCKET + ) + + self._ready = True + self.ready.emit() + + @staticmethod + def lookup_socket() -> tuple[Gio.UnixSocketAddress, Gio.UnixSocketAddress, str]: + if ( + Hyprland.EVENTS_SOCKET and Hyprland.COMMANDS_SOCKET and Hyprland.SOCKET_PATH + ): # this _should_ handle "" as None + return ( + Hyprland.EVENTS_SOCKET, + Hyprland.COMMANDS_SOCKET, + Hyprland.SOCKET_PATH, + ) + + runtime_dir = os.getenv("XDG_RUNTIME_DIR") + hyprland_sig = os.getenv("HYPRLAND_INSTANCE_SIGNATURE") + + hyprland_dir = f"/tmp/hypr/{hyprland_sig}" + if not os.path.isdir(hyprland_dir): + # a new version of hyprland + hyprland_dir = f"{runtime_dir}/hypr/{hyprland_sig}" + + if not os.path.isdir(hyprland_dir): + # hyprland is not running + raise HyprlandSocketNotFoundError( + "couldn't find Hyprland socket, is Hyprland running?" + ) + + Hyprland.EVENTS_SOCKET = Gio.UnixSocketAddress.new( + f"{hyprland_dir}/.socket2.sock" + ) + Hyprland.COMMANDS_SOCKET = Gio.UnixSocketAddress.new( + f"{hyprland_dir}/.socket.sock" + ) + Hyprland.SOCKET_PATH = hyprland_dir + + return ( + Hyprland.EVENTS_SOCKET, + Hyprland.COMMANDS_SOCKET, + Hyprland.SOCKET_PATH, + ) + + @staticmethod + def send_command(command: str) -> HyprlandReply: + """ + send hyprctl-like commands over hyprland socket + + example usage: + ```python + # next workspace... + Hyprland.send_command("/dispatch workspace e+1") + ``` + + :param command: the Hyprland command to send, see Hyprland's wiki for more info + :type command: str + :return: a command reply object contains the reply data from hyprland + :rtype: HyprlandReply + """ + resp = b"" + try: + _, socket_addr, *__ = Hyprland.lookup_socket() + + # from Gio's docs: + # > GSocketClient is a lightweight object, you don't need to cache it. + # > You can just create a new one any time you need one. + client = Gio.SocketClient() + conn: Gio.SocketConnection = client.connect(socket_addr) + stream: Gio.OutputStream = conn.get_output_stream() # type: ignore + + # write command's content + stream.write(command.encode()) # type: ignore + stream.flush() # type: ignore + + # read return data + input_stream = Gio.DataInputStream.new(conn.get_input_stream()) # type: ignore + raw_data: GLib.Bytes = input_stream.read_bytes(HYPRLAND_COMMAND_BUFFER_SIZE) + resp: bytes = raw_data.get_data() # type: ignore + except Exception as e: + logger.error( + f"[HyprlandService] got error while sending command via socket ({e})" + ) + + return HyprlandReply( + command=command, + reply=resp, + is_ok=True if resp == b"ok" else False, + ) + + @staticmethod + def send_command_async( + command: str, + callback: Callable[Concatenate[HyprlandReply, P], Any], + *args: P.args, + **kwargs: P.kwargs, + ) -> None: + """send hyprctl-like commands asynchronously + this function will act same as `send_command` but in a asynchronous manner + + NOTE: `*args` and `**kwargs` are passed directly into your callback + + :param command: the Hyprland command to send, see Hyprland's wiki for more info + :type command: str + :param callback: the callback on where would you like to receive the reply of the command + :type callback: Callable[[HyprlandReply], Any] + """ + _, socket_addr, *_ = Hyprland.lookup_socket() + + # from Gio's docs: + # > GSocketClient is a lightweight object, you don't need to cache it. + # > You can just create a new one any time you need one. + client = Gio.SocketClient() + + def reader_callback( + input_stream: Gio.DataInputStream, res: Gio.AsyncResult, *_ + ): + raw_data: GLib.Bytes = input_stream.read_bytes_finish(res) + resp: bytes = raw_data.get_data() # type: ignore + print("data if any: ", resp) + callback( + HyprlandReply( + command=command, + reply=resp, + is_ok=True if resp == b"ok" else False, + ), + *args, + **kwargs, + ) + + def client_callback(client: Gio.SocketClient, res: Gio.AsyncResult, *_): + conn: Gio.SocketConnection = client.connect_finish(res) + stream: Gio.OutputStream = conn.get_output_stream() # type: ignore + input_stream = Gio.DataInputStream.new(conn.get_input_stream()) # type: ignore + + # write command's content + stream.write_async( # type: ignore + command.encode(), + HYPRLAND_COMMAND_BUFFER_SIZE, + None, # type: ignore + None, + None, + None, + ) + stream.flush_async(1, None, None, None) # type: ignore + + # the connection object must be have a living pointer + # in our case it's passed over to reader_callback + # if there's no references to the connection found + # the GC will collect the connection object and thus the input stream + # this will result in a undefined behaviour + # this is a bug with PyGObject (or at least an issue with the ref counter) + input_stream.read_bytes_async( + HYPRLAND_COMMAND_BUFFER_SIZE, 1, None, reader_callback, conn + ) + + client.connect_async(socket_addr, None, client_callback, None) + + return None + + def event_socket_task(self, socket_addr: Gio.UnixSocketAddress) -> bool: + client = Gio.SocketClient() + conn: Gio.SocketConnection = client.connect(socket_addr) + stream: Gio.InputStream = conn.get_input_stream() # type: ignore + input_stream = Gio.DataInputStream.new(stream) + + while not stream.is_closed(): + raw_data: list[bytes] = input_stream.read_line(None) # type: ignore + if b">>" not in raw_data[0]: + # hyprland broke, it happens... + logger.error( + f"[HyprlandService] hyprland returned wrong data ({raw_data})" + ) + continue + + idle_add(self.handle_raw_event, raw_data[0]) + + logger.warning("[HyprlandService] events socket thread ended") + return False + + def handle_raw_event(self, raw_event: bytes): + raw_listed = str((raw_event).decode()).split(">>") + if len(raw_listed) < 1: + return # how?.. i mean Why? + + event_name: str = raw_listed[0] + raw_event_body: str = "".join(raw_listed[1:]) + event_body: list[str] = raw_event_body.split(",") + + event = HyprlandEvent(event_name, event_body, raw_event) + + return self.emit(f"event::{event.name}", event) diff --git a/fabric/fabric/hyprland/widgets.py b/fabric/fabric/hyprland/widgets.py new file mode 100644 index 0000000..6a84f57 --- /dev/null +++ b/fabric/fabric/hyprland/widgets.py @@ -0,0 +1,271 @@ +import json +from loguru import logger +from typing import TypeVar +from collections.abc import Iterable, Callable + +from fabric.hyprland.service import Hyprland, HyprlandEvent +from fabric.core.widgets import WorkspaceButton, Workspaces, ActiveWindow, Language +from fabric.utils.helpers import FormattedString, bulk_connect, truncate + + +connection: Hyprland | None = None + + +def get_hyprland_connection() -> Hyprland: + global connection + if not connection: + connection = Hyprland() + + return connection + + +class HyprlandWorkspaces(Workspaces): + def __init__( + self, + buttons: Iterable[WorkspaceButton] | None = None, + buttons_factory: Callable[[int], WorkspaceButton | None] + | None = Workspaces.default_buttons_factory, + invert_scroll: bool = False, + empty_scroll: bool = False, + **kwargs, + ): + super().__init__(buttons, buttons_factory, invert_scroll, **kwargs) + self.connection = get_hyprland_connection() + + self._empty_scroll = empty_scroll + bulk_connect( + self.connection, + { + "event::workspacev2": self.on_workspace, + "event::focusedmonv2": self.on_monitor, + "event::createworkspacev2": self.on_create_workspace, + "event::destroyworkspacev2": self.on_destroy_workspace, + "event::urgent": self.on_urgent, + }, + ) + + # all aboard... + if self.connection.ready: + self.on_ready() + else: + self.connection.connect("event::ready", self.on_ready) + self.connect("scroll-event", self.do_handle_scroll) + + def on_ready(self): + open_workspaces: tuple[int, ...] = tuple( + workspace["id"] + for workspace in json.loads( + self.connection.send_command("j/workspaces").reply.decode() + ) + ) + active_workspace = json.loads( + self.connection.send_command("j/activeworkspace").reply.decode() + )["id"] + + for id in open_workspaces: + self.workspace_created(id) + if id == active_workspace: + self.workspace_activated(id) + return + + def on_monitor(self, _, event: HyprlandEvent): + if len(event.data) != 2: + return + return self.workspace_activated(int(event.data[1])) + + def on_workspace(self, _, event: HyprlandEvent): + if len(event.data) != 2: + return + return self.workspace_activated(int(event.data[0])) + + def on_create_workspace(self, _, event: HyprlandEvent): + if len(event.data) != 2: + return + return self.workspace_created(int(event.data[0])) + + def on_destroy_workspace(self, _, event: HyprlandEvent): + if len(event.data) != 2: + return + return self.workspace_destroyed(int(event.data[0])) + + def on_urgent(self, _, event: HyprlandEvent): + if len(event.data) != 1: + return + + clients = json.loads(self.connection.send_command("j/clients").reply.decode()) + clients_map = {client["address"]: client for client in clients} + urgent_client: dict = clients_map.get("0x" + event.data[0], {}) + if not (raw_workspace := urgent_client.get("workspace")): + return logger.warning( + f"[Workspaces] received urgent signal, but data received ({event.data[0]}) is incorrect, skipping..." + ) + return self.urgent(int(raw_workspace["id"])) + + def do_action_next(self): + return self.connection.send_command( + f"batch/dispatch workspace {'e' if not self._empty_scroll else ''}+1" + ) + + def do_action_previous(self): + return self.connection.send_command( + f"batch/dispatch workspace {'e' if not self._empty_scroll else ''}-1" + ) + + def do_button_clicked(self, button: WorkspaceButton): + return self.connection.send_command(f"batch/dispatch workspace {button.id}") + + +class HyprlandActiveWindow(ActiveWindow): + def __init__( + self, + formatter: FormattedString = FormattedString( + "{'Desktop' if not win_title else truncate(win_title, 42)}", + truncate=truncate, + ), + # TODO: hint super's kwargs + **kwargs, + ): + super().__init__(formatter, **kwargs) + + self.connection = get_hyprland_connection() + bulk_connect( + self.connection, + { + "event::activewindow": self.on_active_window, + "event::closewindow": self.on_close_window, + }, + ) + + # all aboard... + if self.connection.ready: + self.on_ready() + else: + self.connection.connect("event::ready", self.on_ready) + + def on_ready(self): + return self.do_initialize(), logger.info( + "[ActiveWindow] Connected to the hyprland socket" + ) + + def on_close_window(self, _, event: HyprlandEvent): + if len(event.data) < 1: + return + return self.do_initialize(), logger.info( + f"[ActiveWindow] Closed window 0x{event.data[0]}" + ) + + def on_active_window(self, _, event: HyprlandEvent): + if len(event.data) < 2: + return + return self.window_activated(event.data[0], event.data[1]), logger.info( + f"[ActiveWindow] Activated window {event.data[0]}, {event.data[1]}" + ) + + def do_initialize(self): + win_data: dict = json.loads( + self.connection.send_command("j/activewindow").reply.decode() + ) + win_class = win_data.get("class", "unknown") + win_title = win_data.get("title", win_class) + + return self.window_activated(win_class, win_title) + + +class HyprlandLanguage(Language): + def __init__( + self, + keyboard: str = ".*", + formatter: FormattedString = FormattedString("{language}"), + # TODO: hint super's kwargs + **kwargs, + ): + super().__init__(keyboard, formatter, **kwargs) + + self.connection = get_hyprland_connection() + self.connection.connect("event::activelayout", self.on_active_layout) + + # all aboard... + if self.connection.ready: + self.on_ready() + else: + self.connection.connect("event::ready", self.on_ready) + + def on_ready(self): + return self.do_initialize(), logger.info( + "[Language] Connected to the hyprland socket" + ) + + def on_active_layout(self, _, event: HyprlandEvent): + if len(event.data) < 2: + return logger.warning( + f"[Language] got invalid event data from hyprland, raw data is\n{event.raw_data}" + ) + keyboard, language, *_ = event.data + + return self.layout_changed(language, keyboard) + + def do_initialize(self): + devices: dict[str, list[dict[str, str]]] = json.loads( + self.connection.send_command("j/devices").reply.decode() + ) + if not devices or not (keyboards := devices.get("keyboards")): + return logger.warning( + f"[Language] coulnd't get devices from hyprctl, gotten data\n{devices}" + ) + + language: str | None = None + for kb in keyboards: + if ( + not (kb_name := kb.get("name")) + or not (language := kb.get("active_keymap")) + or not self.layout_changed(language, kb_name) + ): + continue + + logger.debug( + f"[Language] found language: {language} for keyboard {kb_name}" + ) + break + + return ( + logger.info( + f"[Language] Could not find language for keyboard: {self.keyboard}, gotten keyboards: {keyboards}" + ) + if not language + else logger.info( + f"[Language] Set language: {language} for keyboard: {self.keyboard}" + ) + ) + + +# TODO: remove in v0.0.3 +T = TypeVar("T") + + +def __bake_deprecation_message(obj: T, old_name: str) -> T: + def wrapper(): + logger.warning( + f"[{old_name}][DEPRECATION] `{old_name}` has been renamed to `{obj.__name__}`. " + "Window manager-specific widget classes are now prefixed with their corresponding window manager name. " + "Please update your imports accordingly." + ) + return obj + + return wrapper # type: ignore + + +Language = __bake_deprecation_message(HyprlandLanguage, "Language") +Workspaces = __bake_deprecation_message(HyprlandWorkspaces, "Workspaces") +ActiveWindow = __bake_deprecation_message(HyprlandActiveWindow, "ActiveWindow") + + +__all__ = [ + "HyprlandWorkspaces", + "HyprlandActiveWindow", + "HyprlandLanguage", + "WorkspaceButton", + "get_hyprland_connection", + "Language", + "Workspaces", + "ActiveWindow", +] diff --git a/fabric/fabric/notifications/__init__.py b/fabric/fabric/notifications/__init__.py new file mode 100644 index 0000000..4c9f32d --- /dev/null +++ b/fabric/fabric/notifications/__init__.py @@ -0,0 +1,18 @@ +from .service import ( + Notifications, + Notification, + NotificationAction, + NotificationImagePixmap, + NotificationCloseReason, + NotificationSerializedData, +) + + +__all__ = [ + "Notifications", + "Notification", + "NotificationAction", + "NotificationImagePixmap", + "NotificationCloseReason", + "NotificationSerializedData", +] diff --git a/fabric/fabric/notifications/service.py b/fabric/fabric/notifications/service.py new file mode 100644 index 0000000..4c30ef9 --- /dev/null +++ b/fabric/fabric/notifications/service.py @@ -0,0 +1,639 @@ +import gi +import base64 +from enum import Enum +from loguru import logger +from dataclasses import dataclass +from typing import cast, Literal, TypedDict, Any +from fabric.core.service import Service, Signal, Property +from fabric.utils.helpers import load_dbus_xml, get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gio, GLib, GdkPixbuf + + +NOTIFICATIONS_BUS_NAME = "org.freedesktop.Notifications" +NOTIFICATIONS_BUS_PATH = "/org/freedesktop/Notifications" +NOTIFICATIONS_BUS_IFACE_NODE = load_dbus_xml( + "../dbus_assets/org.freedesktop.Notifications.xml", +) + + +class NotificationCloseReason(Enum): + """A reason for which a notification was closed""" + + EXPIRED = 1 + DISMISSED_BY_USER = 2 + CLOSED_BY_APPLICATION = 3 + UNKNOWN = 4 + + +class NotificationImagePixmap: + """A class for storing image data associated with a notification""" + + @classmethod + def deserialize( + cls, data: tuple[int, int, int, bool, int, int, str] + ) -> "NotificationImagePixmap": + """Load image data from a serialized data tuple (using the `serialize` method) and return the newly created Pixmap object + + :param data: the tuple which is holding the image's data + :type data: tuple[int, int, int, bool, int, int, str] + :return: the newly loaded image pixmap + :rtype: NotificationImagePixmap + """ + self = cls.__new__(cls) + + ( + self.width, + self.height, + self.rowstride, + self.has_alpha, + self.bits_per_sample, + self.channels, + pixmap_data, + ) = data + + # if this doesn't work, please report. + loader = GdkPixbuf.PixbufLoader.new_with_type("png") + decoded_data = base64.b64decode(pixmap_data) + bytes_data = GLib.Bytes.new(decoded_data) + loader.write_bytes(bytes_data) # type: ignore + loader.close() + + self._pixbuf = loader.get_pixbuf() # type: ignore + self.byte_array = None + + return self + + def __init__(self, raw_variant: GLib.Variant): + # manually unpack children so we don't lock up the interpreter + # trying to unpack hugely sized object recursively + self.width = raw_variant.get_child_value(0).unpack() # type: ignore + self.height = raw_variant.get_child_value(1).unpack() # type: ignore + self.rowstride = raw_variant.get_child_value(2).unpack() # type: ignore + self.has_alpha = raw_variant.get_child_value(3).unpack() # type: ignore + self.bits_per_sample = raw_variant.get_child_value(4).unpack() # type: ignore + self.channels = raw_variant.get_child_value(5).unpack() # type: ignore + self.byte_array = raw_variant.get_child_value(6).get_data_as_bytes() # type: ignore + + self._pixbuf: GdkPixbuf.Pixbuf | None = None + + def as_pixbuf(self) -> GdkPixbuf.Pixbuf: + """Load a `Pixbuf` variant of this pixmap + + :return: the newly created pixbuf in which it has the contents of this pixmap + :rtype: GdkPixbuf.Pixbuf + """ + if self._pixbuf is not None: + return self._pixbuf + + self._pixbuf = GdkPixbuf.Pixbuf.new_from_bytes( + self.byte_array, + GdkPixbuf.Colorspace.RGB, + self.has_alpha, + self.bits_per_sample, + self.width, + self.height, + self.rowstride, + ) + return self._pixbuf + + def serialize(self) -> tuple[int, int, int, bool, int, int, str]: + """Serialize this pixmap image into a tuple for ease of carrying and saving + + :return: the serialized pixmap image data + :rtype: tuple[int, int, int, bool, int, int, str] + """ + return ( + self.width, + self.height, + self.rowstride, + self.has_alpha, + self.bits_per_sample, + self.channels, + base64.b64encode( + cast(bytes, self.as_pixbuf().save_to_bufferv("png", [], [])[1]) + ).decode(), + ) + + +@dataclass +class NotificationAction: + """A notification action that can be invoked""" + + identifier: str + label: str + + parent: "Notification" + + def invoke(self): + "Invoke this action" + return self.parent.invoke_action(self.identifier) + + +NotificationSerializedData = TypedDict( + "NotificationSerializedData", + { + "id": int, + "replaces-id": int, + "app-name": str, + "app-icon": str, + "summary": str, + "body": str, + "timeout": int, + "urgency": int, + "actions": list[tuple[str, str]], + "image-file": str | None, + "image-pixmap": tuple[int, int, int, bool, int, int, str] | None, + }, +) + + +class Notification(Service): + """The notification class holds all the data of a specific notification (such as actions, images and sender's info)""" + + @Signal + def closed(self, reason: object) -> None: ... + + @Signal + def action_invoked(self, action: str) -> None: ... + + @Property(str, "readable") + def app_name(self) -> str: + """The display name of the application sent this notification + + :return: sender's display name + :rtype: str + """ + return self._app_name + + @Property(str, "readable") + def app_icon(self) -> str: + """An optional application icon name + + :return: sender's named icon (or None) + :rtype: str + """ + return self._app_icon + + @Property(str, "readable") + def summary(self) -> str: + return self._summary + + @Property(str, "readable") + def body(self) -> str: + """A multi-line body of text given by the sender (might contain markup) + + :return: this notification's body text + :rtype: str + """ + return self._body + + @Property(int, "readable") + def id(self) -> int: + """The uniuqe identifier of this notification + + :return: this notification's id + :rtype: int + """ + return self._id + + @Property(int, "readable") + def replaces_id(self) -> int: + """An optional ID of an existing notification that this notification is intended to replace + + :return: the id of the targeted notification (or None) + :rtype: int + """ + return self._replaces_id + + @Property(int, "readable") + def timeout(self) -> int: + """ + Expiration timeout (in milliseconds) since the display of the notification + at which the notification should automatically get closed + + :return: timeout in milliseconds, -1 means whatever the user decides, 0 means the notification is supposed to never get closed + :rtype: int + """ + return self._timeout + + @Property(int, "readable") + def urgency(self) -> int: + """Urgency level of this notification + + :return: the urgency level as an integer, 0 for low, 1 for normal, 2 for critical + :rtype: int + """ + return self._urgency + + @Property(list[NotificationAction], "readable") + def actions(self) -> list[NotificationAction]: + """A list of all the action this notification has + + :return: a list of notification actions + :rtype: list[NotificationAction] + """ + return self._actions + + @Property(NotificationImagePixmap, "readable") + def image_pixmap(self) -> NotificationImagePixmap: + """Raw image data supplied by the sender (if any) + + :return: raw image data stored in a pixmap object + :rtype: NotificationImagePixmap + """ + return self._image_pixmap # type: ignore + + @Property(str, "readable") + def image_file(self) -> str: + """The image file path provided by the sender for this notification (if any) + + :return: the image file path + :rtype: str + """ + return self._image_file # type: ignore + + @Property(GdkPixbuf.Pixbuf, "readable") + def image_pixbuf(self) -> GdkPixbuf.Pixbuf: + """A `Pixbuf` loaded from either `image-pixmap` or the `image-file` property + + :return: the newly loaded image pixbuf + :rtype: GdkPixbuf.Pixbuf + """ + if self.image_pixmap: + return self.image_pixmap.as_pixbuf() + if self.image_file: + return GdkPixbuf.Pixbuf.new_from_file(self.image_file) + return None # type: ignore + + @classmethod + def deserialize(cls, data: NotificationSerializedData, **kwargs) -> "Notification": + """Deserialize a given serialized notification data into a newly created notification object + + :param data: the serialized data to consume and covert to an object + :type data: NotificationSerializedData + :return: the newly created notification object + :rtype: Notification + """ + self = cls.__new__(cls) + Service.__init__(self, **kwargs) + + self._id = data["id"] + self._app_name = data["app-name"] + self._replaces_id = data["replaces-id"] + self._app_icon = data["app-icon"] + + self._summary = data["summary"] + self._body = data["body"] + + self._timeout = data["timeout"] + self._urgency = data["urgency"] + + self._actions = [ + NotificationAction(action[0], action[1], self) for action in data["actions"] + ] + + self._image_file = data["image-file"] + self._image_pixmap = ( + NotificationImagePixmap.deserialize(data["image-pixmap"]) + if data["image-pixmap"] + else None + ) + + return self + + def __init__(self, id: int, raw_variant: GLib.Variant, **kwargs): + super().__init__(**kwargs) + self._id: int = id + + self._app_name: str = raw_variant.get_child_value(0).unpack() # type: ignore + self._replaces_id: int = raw_variant.get_child_value(1).unpack() # type: ignore + self._app_icon: str = raw_variant.get_child_value(2).unpack() # type: ignore + self._summary: str = raw_variant.get_child_value(3).unpack() # type: ignore + self._body: str = raw_variant.get_child_value(4).unpack() # type: ignore + + raw_actions: list[str] = raw_variant.get_child_value(5).unpack() # type: ignore + + self._actions: list[NotificationAction] = [ + NotificationAction(raw_actions[i], raw_actions[i + 1], self) + for i in range(0, len(raw_actions), 2) + ] + + self._hints: GLib.Variant = raw_variant.get_child_value(6) # type: ignore + self._timeout: int = raw_variant.get_child_value(7).unpack() # type: ignore + + self._urgency: int = self.do_get_hint_entry("urgency") or 1 # type: ignore + + self._image_file: str | None = self.do_get_hint_entry( + "image-path" + ) or self.do_get_hint_entry("image_path") # type: ignore + + self._image_pixmap: NotificationImagePixmap | None = None + if raw_image_data := ( + self.do_get_hint_entry("image-data", False) + or self.do_get_hint_entry("icon_data", False) + ): + self._image_pixmap = NotificationImagePixmap(raw_image_data) + + def do_get_hint_entry( + self, entry_key: str, unpack: bool = True + ) -> GLib.Variant | Any | None: + variant = self._hints.lookup_value(entry_key) + if not unpack or not variant: + return variant + return variant.unpack() # type: ignore + + def serialize(self) -> NotificationSerializedData: + """Serialize this notification into a dictionary that can easily get converted into JSON + + :return: the serialized notification data (dict) + :rtype: NotificationSerializedData + """ + return { + "id": self._id, + "replaces-id": self._replaces_id, + "app-name": self._app_name, + "app-icon": self._app_icon, + "summary": self._summary, + "body": self._body, + "timeout": self._timeout, + "urgency": self._urgency, + "actions": [(action.identifier, action.label) for action in self._actions], + "image-file": self._image_file, + "image-pixmap": self._image_pixmap.serialize() + if self._image_pixmap + else None, + } + + def invoke_action(self, action: str): + """Invoke an action via its name""" + return self.action_invoked(action) + + def close( + self, + reason: Literal[ + "expired", "dismissed-by-user", "closed-by-application", "unknown" + ] + | NotificationCloseReason = NotificationCloseReason.DISMISSED_BY_USER, + ): + """Close this notification and notify the sender with a reason + + :param reason: the reason behind the close of this notification, defaults to NotificationCloseReason.DISMISSED_BY_USER + :type reason: Literal["expired", "dismissed-by-user", "closed-by-application", "unknown"] | NotificationCloseReason, optional + """ + return self.closed(get_enum_member(NotificationCloseReason, reason)) + + +class Notifications(Service): + """A server for watching in-coming notifications from running applications, in order for it to work, other notification daemons must be NOT running on the system""" + + @Signal + def changed(self) -> None: ... + + @Signal + def notification_added(self, notification_id: int) -> None: + self.notify("notifications") + self.changed() + return + + @Signal + def notification_removed(self, notification_id: int) -> None: + self._notifications.pop(notification_id, None) + self.notify("notifications") + self.changed() + return + + @Signal + def notification_closed(self, notification_id: int, reason: object) -> None: + self.notification_removed(notification_id) + self.do_emit_bus_signal( + "NotificationClosed", + GLib.Variant( + "(uu)", (notification_id, cast(NotificationCloseReason, reason).value) + ), + ) + return + + @Property(dict[int, Notification], "readable") + def notifications(self) -> dict[int, Notification]: + """A list of all the notifications received by this server + + :return: the list of notifications + :rtype: dict[int, Notification] + """ + return self._notifications + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._notifications: dict[int, Notification] = {} + self._connection: Gio.DBusConnection | None = None + + self._counter = 0 + + # all aboard... + self.do_register() + + def do_register(self) -> int: # the bus id + return Gio.bus_own_name( + Gio.BusType.SESSION, + NOTIFICATIONS_BUS_NAME, + Gio.BusNameOwnerFlags.NONE, + self.on_bus_acquired, + None, + lambda *_: logger.warning( + "[Notifications] couldn't own the DBus name, another notifications daemon is probably running." + ), + ) + + def on_bus_acquired( + self, conn: Gio.DBusConnection, name: str, user_data: object = None + ) -> None: + self._connection = conn + # we now own the name + for interface in NOTIFICATIONS_BUS_IFACE_NODE.interfaces: + cast(Gio.DBusInterface, interface) + if interface.name == name: + conn.register_object( + NOTIFICATIONS_BUS_PATH, + interface, + self.do_handle_bus_call, # type: ignore + ) + return + + def do_handle_bus_call( + self, + conn: Gio.DBusConnection, + sender: str, + path: str, + interface: str, + target: str, + params: tuple, + invocation: Gio.DBusMethodInvocation, + user_data: object = None, + ) -> None: + match target: + case "Get": + prop_name = params[1] if len(params) >= 1 else None + match prop_name: + case _: + invocation.return_value(None) + case "GetAll": + invocation.return_value(GLib.Variant("(a{sv})", ({},))) + + case "CloseNotification": + notif_id = int(params[0]) + + self.notification_removed(notif_id) + + invocation.return_value(None) + + case "GetCapabilities": + invocation.return_value( + GLib.Variant( + "(as)", + ( + [ + "action-icons", + "actions", + "body", + "body-hyperlinks", + "body-images", + "body-markup", + # "icon-multi", + "icon-static", + "persistence", + # "sound", + ], + ), + ) + ) + + case "GetServerInformation": + invocation.return_value( + GLib.Variant( + "(ssss)", ("fabric", "Fabric-Development", "0.0.2", "1.2") + ) + ) + + case "Notify": + notif_id = self.new_notification_id() + + self._notifications[notif_id] = Notification( + id=notif_id, + raw_variant=cast(GLib.Variant, params), + on_closed=self.do_handle_notification_closed, + on_action_invoked=self.do_handle_notification_action_invoke, + ) + self.notification_added(notif_id) + + invocation.return_value(GLib.Variant("(u)", (notif_id,))) + + return conn.flush() + + def do_emit_bus_signal(self, signal_name: str, params: GLib.Variant) -> None: + self._connection.emit_signal( + None, + NOTIFICATIONS_BUS_PATH, + NOTIFICATIONS_BUS_NAME, + signal_name, + params, + ) if self._connection is not None else None + return + + def new_notification_id(self) -> int: + """Get the next notification id and increase the internal counter + + :return: the next id + :rtype: int + """ + self._counter += 1 + return self._counter + + def do_handle_notification_action_invoke( + self, notification: Notification, action: str + ): + # a pointer to a function is better than a new lambda on every notification + return self.invoke_notification_action(notification.id, action) + + def do_handle_notification_closed( + self, notification: Notification, reason: NotificationCloseReason + ): + return self.close_notification(notification.id, reason) + + def get_notification_from_id(self, notification_id: int) -> Notification | None: + """Lookup a notification via its identifier + + :param notification_id: the notification's id (gotten from the `notification-added` signal) + :type notification_id: int + :return: the desired notification object (if found) + :rtype: Notification | None + """ + return self._notifications.get(notification_id) + + def invoke_notification_action(self, notification_id: int, action: str): + """Invoke a named action on a notification + + :param notification_id: the notification id of in which the action should be triggered + :type notification_id: int + :param action: the name of the action + :type action: str + """ + return self.do_emit_bus_signal( + "ActionInvoked", GLib.Variant("(us)", (notification_id, action)) + ) + + def remove_notification(self, notification_id: int): + """Remove a notification (without closing it) from the server + + :param notification_id: the id of the notification + :type notification_id: int + """ + return self.notification_removed(notification_id) + + def close_notification( + self, + notification_id: int, + reason: NotificationCloseReason = NotificationCloseReason.DISMISSED_BY_USER, + ): + """Close a notification and remove it from the server + + .. note:: + Consider using `Notification.close` instead of this, this method is intended for internal usage and it is a subject to change. + + :param notification_id: the id of the notification + :type notification_id: int + :param reason: the reason behind this notification begin closed, defaults to NotificationCloseReason.DISMISSED_BY_USER + :type reason: NotificationCloseReason, optional + """ + return self.notification_closed(notification_id, reason) + + def serialize(self) -> list[NotificationSerializedData]: + """Similar to `Notification.serialize` but it serializes all the notifications in the server instead + + :return: a list with all the serialized notifications in this server + :rtype: list[NotificationSerializedData] + """ + return [notif.serialize() for notif in self._notifications.values()] + + def deserialize(self, data: list[NotificationSerializedData]): + """Load a list of serialized notifications data + + :param data: the list with serialized notifications + :type data: list[NotificationSerializedData] + """ + for notif_data in data: + self._notifications[notif_data["id"]] = Notification.deserialize( + data=notif_data, + on_closed=self.do_handle_notification_closed, + on_action_invoked=self.do_handle_notification_action_invoke, + ) + self.notification_added(notif_data["id"]) + + # just to make sure we don't break newcoming notifications + # yet this doesn't fix "overwritten" notifications in the above loop + # all "overwritten" notifications are going to meet the VOID one day or another + # so this method _shall_ be used once the class is initialized + # TODO: a fix for this might be by randomizing the notification id + self._counter += max(self._notifications.keys() or (1,)) + return diff --git a/fabric/fabric/py.typed b/fabric/fabric/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/fabric/fabric/system_tray/__init__.py b/fabric/fabric/system_tray/__init__.py new file mode 100644 index 0000000..1d1e056 --- /dev/null +++ b/fabric/fabric/system_tray/__init__.py @@ -0,0 +1,6 @@ +from .service import ( + SystemTray, + SystemTrayItem, + SystemTrayItemPixmap, + SystemTrayItemToolTip, +) diff --git a/fabric/fabric/system_tray/service.py b/fabric/fabric/system_tray/service.py new file mode 100644 index 0000000..4919cac --- /dev/null +++ b/fabric/fabric/system_tray/service.py @@ -0,0 +1,607 @@ +import gi +from loguru import logger +from typing import NamedTuple, Literal, Any, cast +from fabric.core.service import Service, Signal, Property +from fabric.utils.helpers import load_dbus_xml, bulk_connect, get_enum_member + +gi.require_version("Gtk", "3.0") +gi.require_version("DbusmenuGtk3", "0.4") +from gi.repository import ( + Gtk, + Gio, + Gdk, + GdkPixbuf, + DbusmenuGtk3, + GLib, +) + + +STATUS_NOTIFIER_WATCHER_BUS_NAME = "org.kde.StatusNotifierWatcher" +STATUS_NOTIFIER_WATCHER_BUS_PATH = "/StatusNotifierWatcher" +STATUS_NOTIFIER_WATCHER_BUS_IFACE_NODE = load_dbus_xml( + "../dbus_assets/org.kde.StatusNotifierWatcher.xml", +) + +STATUS_NOTIFIER_ITEM_BUS_NAME = "org.kde.StatusNotifierItem" +STATUS_NOTIFIER_ITEM_BUS_IFACE_NODE = load_dbus_xml( + "../dbus_assets/org.kde.StatusNotifierItem.xml" +) + + +class SystemTrayItemPixmap( + NamedTuple +): # TODO: i don't think it's good to use NamedTuple, use dataclass instead + width: int | None = None + "icon width size in pixels" + height: int | None = None + "icon height size in pixels" + data: bytearray | None = None + "the actual data representing this icon" + + def as_pixbuf( + self, + size: int | None = None, + resize_method: Literal[ + "hyper", + "bilinear", + "nearest", + "tiles", + ] = "nearest", + ) -> GdkPixbuf.Pixbuf | None: + if not (self.width and self.height and self.data): + return None + + data_bytearray = bytearray(self.data) + + for i in range(0, 4 * self.width * self.height, 4): + alpha = data_bytearray[i] + data_bytearray[i] = data_bytearray[i + 1] + data_bytearray[i + 1] = data_bytearray[i + 2] + data_bytearray[i + 2] = data_bytearray[i + 3] + data_bytearray[i + 3] = alpha + pixbuf = GdkPixbuf.Pixbuf.new_from_bytes( + GLib.Bytes.new(data_bytearray), # type: ignore + GdkPixbuf.Colorspace.RGB, + True, + 8, + self.width, + self.height, + self.width * 4, + ) + return ( + pixbuf.scale_simple( + size, + size, + { + "hyper": GdkPixbuf.InterpType.HYPER, + "bilinear": GdkPixbuf.InterpType.BILINEAR, + "nearest": GdkPixbuf.InterpType.NEAREST, + "tiles": GdkPixbuf.InterpType.TILES, + }.get(resize_method.lower(), GdkPixbuf.InterpType.NEAREST), + ) + if size is not None and pixbuf is not None + else pixbuf + ) + + +class SystemTrayItemToolTip(NamedTuple): + icon_name: str | None = None + "free-desktop compliant name for an icon" + icon_pixmap: SystemTrayItemPixmap | None = None + "icon data as a pixmap" + title: str | None = None + "title for this tooltip" + description: str | None = None + "descriptive text for this tooltip. It can contain also a subset of the HTML markup language" + + +class SystemTrayItem(Service): + @Signal + def changed(self) -> None: ... + @Signal + def removed(self) -> None: ... + + def __init__( + self, + proxy: Gio.DBusProxy, + **kwargs, + ): + super().__init__(**kwargs) + self._proxy = proxy + self._connection = proxy.get_connection() + self._bus_name = proxy.get_name() + self._bus_path = proxy.get_object_path() + self._identifier = self._bus_name + self._bus_path + + self._menu: DbusmenuGtk3.Menu | None = self.do_create_menu( + self._proxy.get_name_owner(), self.menu_object_path + ) + self._icon_theme: Gtk.IconTheme | None = None + + bulk_connect( + self._proxy, + { + "g-signal": self.on_dbus_signal, + "g-properties-changed": lambda *_: self.changed(), + "notify::g-name-owner": lambda *_: ( + self.removed() if not self._proxy.get_name_owner() else None + ), + }, + ) + + def on_dbus_signal(self, _, __, signal_name: str, signal_args: tuple[str, ...]): + self.do_cache_proxy_properties() + signal_to_prop = { + f"New{dsig}": dsig.lower() + for dsig in [ + "Title", + "Icon", + "AttentionIcon", + "OverlayIcon", + "ToolTip", + "Status", + ] + }.get(signal_name) + if not signal_to_prop: + return + + self.notify(signal_to_prop) + return self.changed() + + def get_preferred_icon_pixbuf( + self, + size: int | None = None, + resize_method: Literal[ + "hyper", + "bilinear", + "nearest", + "tiles", + ] + | GdkPixbuf.InterpType = GdkPixbuf.InterpType.BILINEAR, + ) -> GdkPixbuf.Pixbuf | None: + icon_name = self.icon_name + attention_icon_name = self.attention_icon_name + + icon_pixmap = self.icon_pixmap + attention_icon_pixmap = self.attention_icon_pixmap + + if self.status == "NeedsAttention" and ( + attention_icon_name is not None or attention_icon_pixmap is not None + ): + preferred_icon_name = attention_icon_name + preferred_icon_pixmap = attention_icon_pixmap + else: + preferred_icon_name = icon_name + preferred_icon_pixmap = icon_pixmap + + icon_theme = self.icon_theme + + icon_theme_sizes: list | None = ( + icon_theme.get_icon_sizes(preferred_icon_name) + if preferred_icon_name is not None + else None + ) # type: ignore + icon_theme_sizes = [] if not icon_theme_sizes else icon_theme_sizes + icon_theme_sizes.append(size if size is not None else 24) + + pixbuf = ( + preferred_icon_pixmap.as_pixbuf() + if preferred_icon_pixmap is not None + else icon_theme.load_icon( + preferred_icon_name, + max(icon_theme_sizes), + Gtk.IconLookupFlags.FORCE_SIZE, + ) + if preferred_icon_name is not None + else None + ) + return ( + pixbuf.scale_simple( + size, + size, + get_enum_member( + GdkPixbuf.InterpType, + resize_method, + default=GdkPixbuf.InterpType.NEAREST, + ), + ) + if size is not None and pixbuf is not None + else pixbuf + ) + + # remote properties + @Property(int, "readable") + def id(self) -> int: + return self.do_get_proxy_property("Id") + + @Property(str, "readable") + def identifier(self) -> str: + return self._identifier + + @Property(str, "readable") + def title(self) -> str: + return self.do_get_proxy_property("Title") + + @Property(str, "readable") + def status(self) -> str: + return self.do_get_proxy_property("Status") + + @Property(str, "readable") + def category(self) -> str: + return self.do_get_proxy_property("Category") + + @Property(int, "readable") + def window_id(self) -> int: + return self.do_get_proxy_property("WindowId") + + @Property(str, "readable") + def icon_theme_path(self) -> str: + return self.do_get_proxy_property("IconThemePath") + + @Property(Gtk.IconTheme, "readable") + def icon_theme(self) -> Gtk.IconTheme: + if not self._icon_theme: + self._icon_theme = Gtk.IconTheme() + search_path = self.get_icon_theme_path() + self._icon_theme.set_search_path([search_path]) if search_path not in ( + None, + "", + ) else None + return self._icon_theme + + @Property(str, "readable") + def icon_name(self) -> str: + return self.do_get_proxy_property("IconName") + + @Property(SystemTrayItemPixmap, "readable") + def icon_pixmap(self) -> SystemTrayItemPixmap: + return self.do_extract_pixmap(self.do_get_proxy_property("IconPixmap")) + + @Property(str, "readable") + def overlay_icon_name(self) -> str: + return self.do_get_proxy_property("OverlayIconName") + + @Property(SystemTrayItemPixmap, "readable") + def overlay_icon_pixmap(self) -> SystemTrayItemPixmap: + return self.do_extract_pixmap(self.do_get_proxy_property("OverlayIconPixmap")) + + @Property(str, "readable") + def attention_icon_name(self) -> str: + return self.do_get_proxy_property("AttentionIconName") + + @Property(SystemTrayItemPixmap, "readable") + def attention_icon_pixmap(self) -> SystemTrayItemPixmap: + return self.do_extract_pixmap(self.do_get_proxy_property("AttentionIconPixmap")) + + @Property(SystemTrayItemToolTip, "readable") + def tooltip(self) -> SystemTrayItemToolTip: + return self.do_unpack_tooltip(self.do_get_proxy_property("ToolTip")) + + @Property(bool, "readable", default_value=False) + def is_menu(self) -> bool: + return self.do_get_proxy_property("ItemIsMenu") + + @Property(str, "readable") + def menu_object_path(self) -> str: + return self.do_get_proxy_property("Menu") + + @Property(DbusmenuGtk3.Menu, "readable") + def menu(self) -> DbusmenuGtk3.Menu: + if self._menu is not None: + return self._menu + self._menu = self.do_create_menu( + self._proxy.get_name_owner(), self.get_menu_object_path() + ) + return self._menu # type: ignore + + # remote methods + def context_menu(self, x: int, y: int) -> None: + """to open a server-side context menu""" + return self._proxy.ContextMenu("(ii)", x, y) + + def activate(self, x: int, y: int) -> None: + return self._proxy.Activate("(ii)", x, y) + + def secondary_activate(self, x: int, y: int) -> None: + return self._proxy.SecondaryActivate("(ii)", x, y) + + def invoke_menu_for_event(self, event: Gdk.Event) -> None: + menu = self.get_menu() + return ( + menu.popup_at_pointer(event) + if menu is not None + else self.context_menu_for_event(event) + ) + + def scroll( + self, delta: int, orientation: Literal["vertical", "horizontal"] + ) -> None: + return self._proxy.Scroll("(is)", delta, orientation) + + # event methods + def context_menu_for_event(self, event: Gdk.EventAny) -> None: + return self.context_menu(*event.get_root_coords()) + + def activate_for_event(self, event: Gdk.EventAny) -> None: + return self.activate(*event.get_root_coords()) + + def secondary_activate_for_event(self, event: Gdk.EventAny) -> None: + return self.secondary_activate(*event.get_root_coords()) + + def scroll_for_event(self, event: Gdk.EventScroll) -> None: + direction = ( + "vertical" if event.direction == 0 or event.direction == 1 else "horizontal" + ) + delta: int = event.delta_y if direction == "vertical" else event.delta_x + return self.scroll(delta, direction) + + # privates + def do_get_proxy_property(self, property_name: str) -> Any: + value = self._proxy.get_cached_property(property_name) + if value is None: + return None + return value.unpack() + + def do_extract_pixmap( + self, pixmaps: list[tuple[int, int, bytearray]] + ) -> SystemTrayItemPixmap | None: + # only use the biggest icon and ignore all others + if not pixmaps: # to handle both None and [] + return None + + sorted_pixmap = sorted(pixmaps, key=lambda x: x[0]) + return ( + SystemTrayItemPixmap(*sorted_pixmap[-1]) + if len(sorted_pixmap) >= 1 + else None + ) + + def do_unpack_tooltip( + self, tooltip: tuple[str, list[tuple[int, int, bytearray]], str, str] | None + ) -> SystemTrayItemToolTip: + if not tooltip or not len(tooltip) == 4: + return SystemTrayItemToolTip() + return SystemTrayItemToolTip( + tooltip[0], + self.do_extract_pixmap(tooltip[1]), + tooltip[2], + tooltip[3], + ) # this should be fine + + def do_create_menu(self, bus_name: str, bus_path: str) -> DbusmenuGtk3.Menu | None: + return ( + DbusmenuGtk3.Menu().new(bus_name, bus_path) + if bus_path not in (None, "") + else None + ) + + def do_cache_proxy_properties(self) -> None: + return self._connection.call( # "async" + self._proxy.get_name(), + self._proxy.get_object_path(), + "org.freedesktop.DBus.Properties", + "GetAll", + GLib.Variant("(s)", [self._proxy.get_interface_name()]), + GLib.VariantType("(a{sv})"), + Gio.DBusCallFlags.NONE, + -1, # no timeout + None, + self.do_cache_proxy_properties_finish, + ) + + def do_cache_proxy_properties_finish(self, _, result: Gio.AsyncResult) -> None: + try: + props_var: GLib.Variant = self._connection.call_finish(result) + if not props_var: + raise RuntimeError("can't get the properties variant") + except Exception as e: + return logger.warning( + f"[SystemTray][Item] can't update properties for item with identifier {self._identifier} ({e})" + ) + + def unpack_properties(variant: GLib.Variant) -> dict: + res = {} + variant = variant.get_child_value(0) + if variant.get_type_string().startswith("a{"): + for i in range(variant.n_children()): + v = variant.get_child_value(i) + res[v.get_child_value(0).unpack()] = v.get_child_value(1) + return res + + for prop_name, prop_value in unpack_properties(props_var).items(): + prop_name: str + prop_value: GLib.Variant + self._proxy.set_cached_property(prop_name, prop_value.get_variant()) + return self.changed() + + +class SystemTray(Service): + @Signal + def changed(self) -> None: ... + @Signal + def item_added(self, item_identifier: str) -> None: ... + @Signal + def item_removed(self, item_identifier: str) -> None: ... + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._items: dict[str, SystemTrayItem] = {} + self._connection: Gio.DBusConnection | None = None + + # all aboard... + self.do_register() + + @Property(dict[str, SystemTrayItem], "readable") + def items(self) -> dict[str, SystemTrayItem]: + return self._items + + def add_item(self, item: SystemTrayItem) -> None: + self._items[item._identifier] = item + self.do_notify_registered_item(item._identifier) + return + + def remove_item(self, item: SystemTrayItem) -> None: + try: + self._items.pop(item._identifier) + self.do_notify_unregistered_item(item._identifier) + except: + logger.warning( + f"[SystemTray] can't remove tray item with identifier {item._identifier}" + ) + return + + def do_register(self) -> int: # the bus id + return Gio.bus_own_name( + Gio.BusType.SESSION, + STATUS_NOTIFIER_WATCHER_BUS_NAME, + Gio.BusNameOwnerFlags.NONE, + self.on_bus_acquired, + None, + lambda *_: logger.warning( + "[SystemTray] can't own the DBus name, another bar is probably running" + ), + ) + + def on_bus_acquired( + self, conn: Gio.DBusConnection, name: str, user_data: object = None + ) -> None: + self._connection = conn + # we now own the name + for interface in STATUS_NOTIFIER_WATCHER_BUS_IFACE_NODE.interfaces: + cast(Gio.DBusInterface, interface) + if interface.name == name: + conn.register_object( + STATUS_NOTIFIER_WATCHER_BUS_PATH, + interface, + self.do_handle_bus_call, # type: ignore + ) + return + + def do_handle_bus_call( + self, + conn: Gio.DBusConnection, + sender: str, + path: str, + interface: str, + target: str, + params: tuple, + invocation: Gio.DBusMethodInvocation, + user_data: object = None, + ) -> None: + match target: + case "Get": + prop_name = params[1] if len(params) >= 1 else None + match prop_name: + case "ProtocolVersion": + invocation.return_value( + GLib.Variant("(v)", (GLib.Variant("i", 1),)) + ) + case "IsStatusNotifierHostRegistered": + invocation.return_value( + GLib.Variant("(v)", (GLib.Variant("b", True),)) + ) + case "RegisteredStatusNotifierItems": + invocation.return_value( + GLib.Variant( + "(v)", (GLib.Variant("as", self._items.keys()),) + ), + ) + case _: + invocation.return_value(None) + case "GetAll": + all_properties = { + "ProtocolVersion": GLib.Variant("i", 1), + "IsStatusNotifierHostRegistered": GLib.Variant("b", True), + "RegisteredStatusNotifierItems": GLib.Variant( + "as", self._items.keys() + ), + } + + invocation.return_value(GLib.Variant("(a{sv})", (all_properties,))) + case "RegisterStatusNotifierItem": + self.do_create_item(sender, params[0] if len(params) >= 1 else "") + invocation.return_value(None) + + return conn.flush() + + def do_create_item(self, bus_name: str, bus_path: str) -> None: + # phase 1, validate + if ( + bus_name is None + or bus_path is None + or not isinstance(bus_name, str) + or not isinstance(bus_path, str) + or self.get_items().get(bus_name + bus_path) is not None + ): + return + + if not bus_path.startswith("/"): + bus_path = "/StatusNotifierItem" + + return self.do_acquire_item_proxy(bus_name, bus_path) + + def do_acquire_item_proxy(self, bus_name: str, bus_path: str) -> None: + # phase 2, get the proxy + + # some remote objects has a huge props size + # so loading them in a async way is an ideal + return Gio.DBusProxy.new_for_bus( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.NONE, + STATUS_NOTIFIER_ITEM_BUS_IFACE_NODE.interfaces[0], + bus_name, + bus_path, + STATUS_NOTIFIER_ITEM_BUS_NAME, + None, + lambda *args: self.do_acquire_item_proxy_finish(bus_name, bus_path, *args), + None, + ) + + def do_acquire_item_proxy_finish( + self, + bus_name: str, + bus_path: str, + proxy: Gio.DBusProxy, + result: Gio.AsyncResult, + *args, + ) -> None: + # phase 3, we might have gotten a proxy, if so create a new item + proxy = proxy.new_for_bus_finish(result) + if not proxy: + return logger.warning( + f"[SystemTray] can't acquire proxy object for tray item with identifier {bus_name + bus_path}" + ) + + item = SystemTrayItem(proxy) + item.removed.connect(lambda *args: self.remove_item(item)) + + # all aboard... + self.add_item(item) + return + + def do_emit_bus_signal(self, signal_name: str, params: GLib.Variant) -> None: + self._connection.emit_signal( + None, + STATUS_NOTIFIER_WATCHER_BUS_PATH, + STATUS_NOTIFIER_WATCHER_BUS_NAME, + signal_name, + params, + ) if self._connection is not None else None + return + + def do_notify_registered_item(self, identifier: str) -> None: + self.do_emit_bus_signal( + "StatusNotifierItemRegistered", GLib.Variant("(s)", (identifier,)) + ) + self.item_added(identifier) + self.changed() + return + + def do_notify_unregistered_item(self, identifier: str) -> None: + self.changed() + self.item_removed(identifier) + self.do_emit_bus_signal( + "StatusNotifierItemUnregistered", + GLib.Variant("(s)", (identifier,)), + ) + return diff --git a/fabric/fabric/system_tray/widgets.py b/fabric/fabric/system_tray/widgets.py new file mode 100644 index 0000000..955e7c4 --- /dev/null +++ b/fabric/fabric/system_tray/widgets.py @@ -0,0 +1,94 @@ +from loguru import logger +from fabric.widgets.box import Box +from fabric.widgets.image import Image +from fabric.widgets.button import Button +from fabric.system_tray.service import ( + SystemTray as SystemTrayService, + SystemTrayItem as SystemTrayItemService, +) + + +watcher: SystemTrayService | None = None + + +def get_tray_watcher() -> SystemTrayService: + global watcher + if not watcher: + watcher = SystemTrayService() + + return watcher + + +class SystemTrayItem(Button): + def __init__(self, item: SystemTrayItemService, icon_size: int, **kwargs): + super().__init__(**kwargs) + self._item = item + self._icon_size = icon_size + self._image = Image() + self.set_image(self._image) + + self._item.changed.connect(self.do_update_properties) + self.connect("button-press-event", self.on_clicked) + + self.do_update_properties() + + def do_update_properties(self, *_): + pixbuf = self._item.get_preferred_icon_pixbuf(self._icon_size) + if pixbuf is not None: + self._image.set_from_pixbuf(pixbuf) + else: + self._image.set_from_icon_name("image-missing", self._icon_size) + + tooltip = self._item.tooltip + self.set_tooltip_markup( + tooltip.description or tooltip.title or self._item.title.title() + if self._item.title + else "Unknown" + ) + return + + def on_clicked(self, _, event): + match event.button: + case 1: + try: + self._item.activate_for_event(event) + except Exception as e: + logger.warning( + f"[SystemTrayItem] can't activate item with name {self._item.title or self._item.identifier} ({e})" + ) + case 3: + self._item.invoke_menu_for_event(event) + return + + +class SystemTray(Box): + def __init__(self, icon_size: int = 24, **kwargs): + super().__init__(**kwargs) + self._icon_size = icon_size + self._items: dict[str, SystemTrayItem] = {} + + self._watcher = get_tray_watcher() + self._watcher.connect("item-added", self.on_item_added) + self._watcher.connect("item-removed", self.on_item_removed) + + def on_item_added(self, _, item_identifier: str): + item = self._watcher.items.get(item_identifier) + if not item: + return + + item_button = SystemTrayItem(item, self._icon_size) + self.add(item_button) + self._items[item.identifier] = item_button + return + + def on_item_removed(self, _, item_identifier): + item_button = self._items.get(item_identifier) + if not item_button: + return + + self.remove(item_button) + self._items.pop(item_identifier) + return + + +__all__ = ["SystemTray", "SystemTrayItem", "get_tray_watcher"] diff --git a/fabric/fabric/utils/__init__.py b/fabric/fabric/utils/__init__.py new file mode 100644 index 0000000..b9211a7 --- /dev/null +++ b/fabric/fabric/utils/__init__.py @@ -0,0 +1 @@ +from .helpers import * diff --git a/fabric/fabric/utils/helpers.py b/fabric/fabric/utils/helpers.py new file mode 100644 index 0000000..1dbf896 --- /dev/null +++ b/fabric/fabric/utils/helpers.py @@ -0,0 +1,947 @@ +import gi +import re +import os +import time +import math +import cairo +import shlex +import string +import random +import inspect +from enum import Enum +from loguru import logger +from functools import wraps +from dataclasses import dataclass +from collections.abc import Callable, Iterable, Generator +from typing import ( + cast, + Literal, + NamedTuple, + Union, + TypeAlias, + Generic, + TypeVar, + ParamSpec, + Any, +) + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GdkPixbuf, GObject, Gio, GLib + +P = ParamSpec("P") +T = TypeVar("T") +E = TypeVar("E", bound=Enum) +MISSING = TypeVar("MISSING") +Number: TypeAlias = int | float + + +class __DeprecationHook__: + def __init__(self, deprecated_to_replacement: dict[str, str]): + self.lookup_table = deprecated_to_replacement + + def __call__(self): + if replacement := self.lookup_table.get( + (caller := inspect.currentframe().f_back.f_code.co_name), # type: ignore + None, + ): + return logger.warning( + f"the function `{caller}` is deprecated and will be removed in future versions of Fabric, consider using `{replacement}` instead" + ) + return + + +__deprecation_table = __DeprecationHook__( + { + "idlify": "idle_add", + "set_stylesheet_from_string": "Application.add_stylesheet_from_string", + "set_stylesheet_from_file": "Application.add_stylesheet_from_file", + "get_gdk_rgba": "Gdk.RGBA.parse OR parse_color", + } +) + + +class PixbufUtils: + @staticmethod + def from_cairo_surface( + surface: cairo.ImageSurface, + x_offset: int = 0, + y_offset: int = 0, + target_width: int | None = None, + target_height: int | None = None, + ) -> GdkPixbuf.Pixbuf: + return Gdk.pixbuf_get_from_surface( + surface, # type: ignore + x_offset, + y_offset, + target_width or surface.get_width(), + target_height or surface.get_height(), + ) + + @staticmethod + def set_cairo_source( + pixbuf: GdkPixbuf.Pixbuf, + cr: cairo.Context, + x_offset: int = 0, + y_offset: int = 0, + ): + return Gdk.cairo_set_source_pixbuf(cr, pixbuf, x_offset, y_offset) # type: ignore + + @staticmethod + def rotate(pixbuf: GdkPixbuf.Pixbuf, angle: float) -> GdkPixbuf.Pixbuf: + """return a rotated version of the given `GdkPixbuf.Pixbuf` to a given angle + + :param pixbuf: the input pixbuf + :type pixbuf: GdkPixbuf.Pixbuf + :param angle: the desired rotation angle in degrees + :type angle: float + :return: the newly rotated pixbuf + :rtype: GdkPixbuf.Pixbuf + """ + r = math.radians(angle) + w, h = pixbuf.get_width(), pixbuf.get_height() + + nw = round(abs(w * math.cos(r)) + abs(h * math.sin(r))) + nh = round(abs(w * math.sin(r)) + abs(h * math.cos(r))) + + surface = cairo.ImageSurface(cairo.Format.ARGB32, nw, nh) + cr = cairo.Context(surface) + + cr.translate(nw / 2, nh / 2) + cr.rotate(r) + PixbufUtils.set_cairo_source(pixbuf, cr, round(-w / 2), round(-h / 2)) + cr.paint() + + return PixbufUtils.from_cairo_surface( + surface, target_width=nw, target_height=nh + ) + + +class FormattedString: + """simple string formatter made to be baked mid-runtime""" + + class FormatDict(dict): + def __init__(self, *args, **kwargs): + super(FormattedString.FormatDict, self).__init__(*args, **kwargs) + + def __missing__(self, key): + try: + rkey = eval(key, globals(), self) + except Exception as e: + logger.warning( + f"[FormattedString] couldn't format string expression ({key}), raised exception is\n {str(e)}" + ) + rkey = key.join("{}") + return rkey + + def __init__(self, string: str, **kwargs) -> None: + self.__string__ = string + self.__format_map__ = kwargs + + def __call__(self, **kwargs) -> str: + return self.format(**kwargs) + + def format(self, **kwargs) -> str: + return self.__string__.format_map( + FormattedString.FormatDict(self.__format_map__ | kwargs) + ) + + +@dataclass(init=False) +class DesktopApp: + name: str + generic_name: str | None + display_name: str | None + description: str | None + window_class: str | None + executable: str | None + command_line: str | None + icon: ( + Gio.Icon + | Gio.ThemedIcon + | Gio.FileIcon + | Gio.LoadableIcon + | Gio.EmblemedIcon + | None + ) + icon_name: str | None + hidden: bool + + def __init__( + self, app: Gio.DesktopAppInfo, icon_theme: Gtk.IconTheme | None = None + ): + self._app: Gio.DesktopAppInfo = app + self._icon_theme = icon_theme or Gtk.IconTheme.get_default() + self._pixbuf: GdkPixbuf.Pixbuf | None = None + self.name = app.get_name() # type: ignore + self.generic_name = app.get_generic_name() # type: ignore + self.display_name = app.get_display_name() # type: ignore + self.description = app.get_description() # type: ignore + self.window_class = app.get_startup_wm_class() # type: ignore + self.executable = app.get_executable() # type: ignore + self.command_line = app.get_commandline() # type: ignore + self.icon = app.get_icon() # type: ignore + self.icon_name = self.icon.to_string() if self.icon is not None else None # type: ignore + self.hidden = app.get_is_hidden() + + def launch(self): + return self._app.launch() # type: ignore + + def get_icon_pixbuf( + self, + size: int = 48, + default_icon: str | None = "image-missing", + flags: Gtk.IconLookupFlags = Gtk.IconLookupFlags.FORCE_REGULAR + | Gtk.IconLookupFlags.FORCE_SIZE, # type: ignore + ) -> GdkPixbuf.Pixbuf | None: + """ + get a pixbuf from the icon (if any) + + :param size: the size of the icon, defaults to 48 + :type size: int, optional + :param default_icon: the name of the default icon. pass None if you want to receive None upon failing, defaults to "image-missing" + :type default_icon: str | None, optional + :param flags: the Gtk.IconLookupFlags to use when fetching the icon + :type flags: Gtk.IconLookupFlags, defaults to (Gtk.IconLookupFlags.FORCE_REGULAR | Gtk.IconLookupFlags.FORCE_SIZE), optional + :return: the pixbuf + :rtype: GdkPixbuf.Pixbuf | None + """ + if self._pixbuf: + return self._pixbuf # already loaded + try: + if not self.icon_name: + raise + self.icon + self._pixbuf = self._icon_theme.load_icon( + self.icon_name, + size, + flags, + ) + except Exception: + self._pixbuf = ( + self._icon_theme.load_icon(default_icon, size, flags) + if default_icon is not None + else None + ) + + return self._pixbuf + + +def get_desktop_applications(include_hidden: bool = False) -> list[DesktopApp]: + """ + get a list of all desktop applications + this might be useful for writing application launchers + + :param include_hidden: whether to include applications unintended to be visible to normal users, defaults to false + :type include_hidden: bool, optional + :return: a list of all desktop applications + :rtype: list[DesktopApp] + """ + icon_theme = Gtk.IconTheme.get_default() + return [ + DesktopApp(app, icon_theme) + for app in Gio.DesktopAppInfo.get_all() + if include_hidden or app.should_show() + ] + + +def parse_color(color: str | Iterable[Number]) -> Gdk.RGBA: + """parse a serialized color data over to a `Gdk.RGBA` object + + :param color: the color data, example of an iterable color; `(255, 255, 255) / (255, 255, 255, 255)`, for a list of parseable string formats head over to https://docs.gtk.org/gdk3/method.RGBA.parse.html + :type color: str | Iterable[Number] + :raises ValueError: if the passed in color data is unparsable + :return: the newly created `Gdk.RGBA` (alpha is set to opaque if the passed in color data is RGB only) + :rtype: Gdk.RGBA + """ + if ( + isinstance(color, (tuple, list)) + and (color_len := len(color)) >= 3 + and color_len <= 4 + ): + return Gdk.RGBA(*[c / 255.0 for c in color]) + elif isinstance(color, str): + rgba = Gdk.RGBA() + if rgba.parse(color): + return rgba + raise ValueError(f"{color} is an invalid color format") + + +def get_gdk_rgba(color: str | Iterable[Number]) -> Gdk.RGBA: + __deprecation_table() + return parse_color(color) + + +def compile_css( + css_string: str, + base_path: str = ".", + exposed_functions: dict[str, Callable] | Iterable[Callable] | None = None, +) -> str: + """ + preprocess and transpile a CSS string to GTK's CSS syntax. + + supports transpiling web-css like variables over to GTK's `@define-color` syntax. + + also supports having CSS macros. syntax example: + .. code-block:: css + /* define a macro */ + @define my-macro(--arg-1, --arg-2) { + /* CSS body goes here. example body.. */ + color: --arg-1; + background-color: --arg-2; + } + + #my-widget { + @apply my-macro(red, blue); + /* compiles to + color: --arg-1; + background-color: --arg-2; + */ + } + + **Note:** this function relies on a series of regular expressions for its processing, which may lead to potential issues in certain edge cases. + + :param css_string: the input CSS as a string. + :type css_string: str + :param base_path: for `@import` statements, used for relative imports. + :type base_path: str, optional + :param exposed_functions: a dictionary of macro functions or an iterable of callable functions to use as extra macros. if a dictionary is provided, the keys are macro names, and the values are the corresponding functions. + :type exposed_functions: dict[str, Callable] | Iterable[Callable] | None, optional + :return: the compiled CSS string converted to GTK's CSS syntax. + :rtype: str + """ + + import_pattern = re.compile(r'@import\s+(?:url\()?["\']?([^"\')]+)["\']?\)?\s*;') + + vars_selector_pattern = re.compile(r":vars\s*{\s*([^}]+)\s*}") + vars_declaration_pattern = re.compile(r"--([\w-]+)\s*:\s*([^;]+)\s*;") + vars_reference_pattern = re.compile(r"var\(--([\w-]+)\)") + + constant_pattern = re.compile(r"@define\s+([\w-]+)\s+([^;]+);") + constant_apply_pattern = re.compile(r"apply\(([\w-]+)\)") + + macro_pattern = re.compile(r"@define\s+([\w-]+)\(([^)]*)\)\s*{\s*([^}]+)\s*}") + macro_apply_pattern = re.compile(r"@apply\s+([\w-]+)\(([^)]*)\)\s*;?") + + functions_map: dict[str, Callable] = ( + {} + if not exposed_functions + else ( + { + snake_case_to_kebab_case(func.__name__): func + for func in ( + exposed_functions + if not isinstance(exposed_functions, Callable) + else (exposed_functions,) + ) + } + if isinstance(exposed_functions, (list, tuple, Callable)) + else exposed_functions + ) + ) # type: ignore + + def resolve_imports(css_content: str) -> str: + def import_replacement(match: re.Match) -> str: + file_path = match.group(1) + full_path = os.path.join(base_path, file_path) + + try: + with open(full_path, "r") as imported_file: + imported_content = imported_file.read() + return resolve_imports(imported_content) + except Exception as e: + logger.warning( + f"[FASS] couldn't find the imported file: {full_path}, Error: {e}" + ) + return f"/* couldn't import file: {file_path} */" + + return import_pattern.sub(import_replacement, css_content) + + # resolve @import statements before passing over to the preprocessor + css_output = resolve_imports(css_string) + + # color variables + match = vars_selector_pattern.search(css_output) + css_output = ( + f"{match.group(1)}\n\n{css_output.replace(match.group(0), '')}" + if match + else css_output + ) + + # this could be preprocessed as the original value not (a translation to Gtk's syntax) + css_output = vars_declaration_pattern.sub( + lambda m: f"@define-color {m.group(1)} {m.group(2)};", css_output + ) + css_output = vars_reference_pattern.sub(r"@\1", css_output) + + # preprocessing + constants: dict[str, str] = { + m.group(1): m.group(2) for m in constant_pattern.finditer(css_output) + } + css_output = constant_pattern.sub("", css_output) + css_output = constant_apply_pattern.sub( + lambda m: constants.get(m.group(1), m.group(0)), css_output + ) + + # keys are macro names. + # tuple's first item is a list of arguments + # and last item is the macro's body + macros: dict[str, tuple[tuple[str, ...], str]] = { + cast(str, m.group(1)): ( + tuple(args_group.split(",")) if (args_group := m.group(2)) else (), + m.group(3).strip(), + ) + for m in macro_pattern.finditer(css_output) + } + css_output = macro_pattern.sub("", css_output) + + def apply_macro_replacement(match: re.Match) -> str: + macro_name = match.group(1) + macro_func = functions_map.get(macro_name, None) + macro_args, macro_body = macros.get(macro_name, (None, "")) + if not macro_args and not macro_body and not macro_func: + logger.warning(f"[FASS] couldn't find a macro with name {macro_name}") + return match.group(0) # like nothing has happened + + logger.info(f"[FASS] applying a macro with name {macro_name}") + + # passed in parameters + passed_params: list[str] = ( + [arg_name.strip() for arg_name in args_group.split(",")] + if (args_group := match.group(2)) + else [] + ) + + if macro_func: + return macro_func(*passed_params) + + for param, arg in zip(cast(tuple[str, ...], macro_args), passed_params): + macro_body = macro_body.replace(param.strip(), arg) + + return macro_body + + css_output = macro_apply_pattern.sub(apply_macro_replacement, css_output) + + # clean up + css_output = re.sub(r"\n\s*\n", "\n", css_output).strip() + + return css_output + + +def bulk_replace( + string: str, + patterns: Iterable[str], + replacements: Iterable[str], + regex: bool = False, +) -> str: + """ + Replaces occurrences of multiple patterns in a string with corresponding replacements. + + :param string: the input string in which replacements will be made. + :type string: str + :param patterns: the patterns to be replaced, this can be a list of strings or a list of regular expressions. + :type patterns: Iterable[str] + :param replacements: the replacements for each text. + :type replacements: Iterable[str] + :param regex: Whether to interpret the patterns as regular expressions. Defaults to False. + :type regex: bool, optional + + :return: the string with replacements made. + :rtype: str + + :raises ValueError: If the lengths of patterns and replacements are not the same. + """ + if not ( + isinstance(patterns, (tuple, list)) and isinstance(replacements, (tuple, list)) + ): + return "" + + if len(patterns) != len(replacements): + raise ValueError("patterns and replacements must be the same length.") + + for text, replacement in zip(patterns, replacements): + if regex: + string = re.sub(text, replacement, string) + else: + string = string.replace(text, replacement) + + return string + + +def bulk_connect( + connectable: GObject.Object, mapping: dict[str, Callable] +) -> tuple[int, ...]: + """connects a list of signals to a list of callbacks to an object + + :param connectable: the object to connect the signals to + :type connectable: GObject.Object + :param mapping: the mapping of signals to callbacks, example: `{"signal-name": lambda *args: ...}` + :type mapping: dict[str, Callable] + :rtype: tuple[int, ...] + """ + + return tuple( + connectable.connect(signal, callback) # type: ignore + for signal, callback in mapping.items() + ) + + +def bulk_disconnect( + disconnectable: GObject.Object, + signals_or_funcs: Iterable[Union[str, Callable]], +) -> tuple[int, ...]: + """does the opposite of bulk_connect + + :param disconnectable: the object to disconnect the signals from + :type disconnectable: GObject.Object | object + :param signals_or_funcs: iterable of signals/callbacks to disconnect + :type signals: Iterable[Union[str, Callable]] + :return: a list of return values from the `disconnect` function + :rtype: tuple[int] + """ + + def disconnect(signal_or_func) -> int: + if callable(signal_or_func): + return disconnectable.disconnect_by_func(signal_or_func) # type: ignore + return disconnectable.disconnect(signal_or_func) # type: ignore + + return tuple(disconnect(x) for x in signals_or_funcs) + + +def clamp(value: Number, min_value: Number, max_value: Number) -> Number: + """ + clamp a value between a minimum and maximum value + + :param value: the value to be clamped + :type value: float or int + :param min_value: the minimum value to clamp to + :type min_value: float or int + :param max_value: the maximum value to clamp to + :type max_value: float or int + :return: the clamped value + :rtype: float or int + """ + return max(min(value, max_value), min_value) + + +def extract_css_values(css_string: str) -> tuple[int, int, int, int]: + """ + extracs and return a tuple of four CSS values from a given CSS string. + + :param css_string: the CSS string to extract the values from. + :type css_string: str + :return: a tuple of four integers representing the extracted CSS values. If the CSS string + does not contain enough values, the missing values are filled with zeros. + :rtype: tuple + """ + pattern = re.compile( + r"(-?\d+)(?:px)?(?:\s+(-?\d+)(?:px)?(?:\s+(-?\d+)(?:px)?(?:\s+(-?\d+)(?:px)?)?)?)?" + ) + matches = pattern.match(css_string) + default_values = (0, 0, 0, 0) + if matches: + values = [int(val) if val else 0 for val in matches.groups()] + values.extend([values[-1]] * (4 - len(values))) + return tuple(values) # type: ignore + else: + return default_values + + +def monitor_file( + path: str, + *callbacks: Callable, + flags: Literal[ + "none", + "watch-mounts", + "send-moved", + "watch-hard-links", + "watch-moves", + ] + | Gio.FileMonitorFlags = Gio.FileMonitorFlags.NONE, + initial_call: bool = False, +) -> Gio.FileMonitor: + """ + Monitor a specific file or directory for changes... + + :param path: path to the desired file or directory + :type path: str + :param callbacks: list of functions each assigned directly to the `"changed"` signal of the monitor + :type callbacks: Callable + :param flags: flags to configure the file monitor. Defaults to None. + :type flags: Literal["none", "watch-mounts", "send-moved", "watch-hard-links", "watch-moves"], optional + :param initial_call: whether should the given callbacks get called upon registering. Defaults to False + :type initial_call: bool, optional + :return: the monitor for the specified path + :rtype: Gio.FileMonitor + """ + file: Gio.File = Gio.File.new_for_uri( # type: ignore + ("file://" + os.path.expanduser(path)) if "://" not in path else path + ) + monitor = file.monitor( + get_enum_member(Gio.FileMonitorFlags, flags, default=Gio.FileMonitorFlags.NONE) + ) + + for f in callbacks: + monitor.connect("changed", f) + + [f() for f in callbacks] if initial_call else None + + return monitor + + +def cooldown( + cooldown_time: int | float, + error: Callable | None = None, + return_error: bool = False, +): + """ + Decorator function that adds a cooldown period to a given function + + :param cooldown_time: the time in seconds to wait before calling the function again + :type cooldown_time: int + :param error: the function to call if the cooldown period has not been reached yet. Defaults to None + :type error: Callable, optional + :rtype: decorator + """ + + def decorator(func): + last_call_delay = 0 + + @wraps(func) + def wrapper(*args, **kwargs): + nonlocal last_call_delay + current_time = time.time() + elapsed_time = current_time - last_call_delay + if elapsed_time >= cooldown_time: + result = func(*args, **kwargs) + last_call_delay = current_time + return result + else: + if return_error is True and error is not None: + return error((cooldown_time - elapsed_time), *args, **kwargs) + elif error is not None: + error((cooldown_time - elapsed_time), *args, **kwargs) + + return wrapper + + return decorator + + +def exec_shell_command(cmd: str) -> str | Literal[False]: + """ + executes a shell command and returns the output + + :param cmd: the shell command to execute + :type cmd: str + :return: the output of the command or False if an error has occurred + :rtype: str | Literal[False] + """ + if not isinstance(cmd, str): + raise ValueError # FIXME: add error message + + try: + result, output, error, status = GLib.spawn_command_line_sync(cmd) # type: ignore + if status != 0: + return error.decode() + return output.decode() + except Exception: + pass + return False # *unreachable* + + +def exec_shell_command_async( + cmd: str | list[str], + callback: Callable[[str], Any] | None = None, +) -> tuple[Gio.Subprocess | None, Gio.DataInputStream]: + """ + executes a shell command and returns the output asynchronously + + :param cmd: the shell command to execute + :type cmd: str + :param callback: a function to retrieve the result at or `None` to ignore the result + :type callback: Callable[[str], Any] | None, optional + :return: a Gio.Subprocess object which holds a reference to your process and a Gio.DataInputStream object for stdout + :rtype: tuple[Gio.Subprocess | None, Gio.DataInputStream] + """ + process = Gio.Subprocess.new( + shlex.split(cmd) if isinstance(cmd, str) else cmd, # type: ignore + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, # type: ignore + ) + + stdout = Gio.DataInputStream( + base_stream=process.get_stdout_pipe(), # type: ignore + close_base_stream=True, + ) + + def reader_loop(stdout: Gio.DataInputStream): + def read_line(stream: Gio.DataInputStream, res: Gio.AsyncResult): + output, *_ = stream.read_line_finish_utf8(res) + if isinstance(output, str): + callback(output) if callback else None + reader_loop(stream) + + stdout.read_line_async(GLib.PRIORITY_DEFAULT, None, read_line) + + reader_loop(stdout) + + return process, stdout + + +def invoke_repeater( + interval: int, func: Callable, *args, initial_call: bool = True +) -> int: + """ + invokes a function repeatedly with a given interval + + :param interval: the interval in milliseconds to invoke the function + :type interval: int + :param func: the function to invoke + :type func: Callable + :param args: list of arguments passed directly to the given function + :param initial_call: whether should the given function get called as soon as it is registered. Defaults to False + :type initial_call: bool, optional + """ + if initial_call: + func(*args) + return GLib.timeout_add(interval, func, *args) + + +def get_relative_path(path: str, level: int = 1) -> str: + """ + converts a path to a relative path according to caller's `__file__` variable + + NOTE: This function only works if the caller `__file__` variable is set. means this will work only if you're calling from a python file (not a IDLE / REPL), else it will fallback to the current working directory as `__file__` + + :param path: the path to convert + :type path: str + :param level: the stack level to get the `__file__` variable from. Defaults to 1 + :type level: int, optional + :return: the relative path + :rtype: str + """ + prev_globals = inspect.stack()[level][0].f_globals + file_var = ( + os.path.dirname(os.path.abspath(prev_globals["__file__"])) + if "__file__" in prev_globals + else os.getcwd() + ) + path = os.path.join(file_var, path) + return path + + +def load_dbus_xml(path_to_xml: str): + path_to_xml = get_relative_path(path_to_xml, level=2) + with open(path_to_xml, "r") as f: + file = f.read() + return Gio.DBusNodeInfo.new_for_xml(file) + + +def snake_case_to_pascal_case(string: str) -> str: + return string.replace("_", " ").title().replace(" ", "") + + +def pascal_case_to_snake_case(string: str) -> str: + return "_".join( + map( + str.lower, + re.findall(r"[A-Z]?[a-z]+|[A-Z]{2,}(?=[A-Z][a-z]|\d|\W|$)|\d+", string), + ) + ) + + +def snake_case_to_kebab_case(string: str) -> str: + return string.strip().lower().replace("_", "-") + + +def kebab_case_to_snake_case(string: str) -> str: + return string.replace("-", "_").lower() + + +def get_connectables_for_kwargs(kwargs: dict[str, Callable]) -> Generator: + __deprecation_table() + for key, value in zip(kwargs.keys(), kwargs.values()): + if key.startswith("on_"): + yield [snake_case_to_kebab_case(key[3:]), value] + elif key.startswith("notify_"): + # yield a connectable property + yield [f"notify::{snake_case_to_kebab_case(key[7:])}", value] + + +def get_enum_member( + enum: type[E], member: str | E, mapping: dict[str, str] = {}, default: Any = MISSING +) -> E: + if isinstance(member, enum): + return member + + if not isinstance(member, str): + raise ValueError # FIXME: add exception message + + for name, replacement in mapping.items(): + if member.casefold() == name.casefold(): + member = replacement + break + + try: + return getattr(enum, kebab_case_to_snake_case(member).upper()) + except Exception: + if default is MISSING: + raise ValueError + return default + + +def get_enum_member_name( + member: Enum | Any, mapping: dict[str, str] = {}, default: Any = MISSING +) -> str: + if isinstance(member, str): + return member + + if isinstance(member, Enum): + return member.name + + # GIR type enum... + member_name: str | None = None + if _name := getattr(member, "first_value_nick", None): + member_name = _name + elif _name := getattr(member, "value_nick", None): + member_name = _name + + if not member_name and default is MISSING: + raise ValueError + elif not member_name: + return default + + member_name = member_name.upper() + return mapping.get(member_name, member_name) + + +def bridge_signal( + source: GObject.Object, + source_signal: str, + target: GObject.Object, + target_signal: str, + notify: bool = False, +) -> int: + def signal_handler(*args, **kwargs): + return target.emit(target_signal, *args, **kwargs) # type: ignore + + def notify_handler(*args, **kwargs): + return target.notify(target_signal) # type: ignore + + return source.connect( # type: ignore + source_signal if not notify else f"notify::{source_signal}", + signal_handler if not notify else notify_handler, + ) + + +def generate_random_string(length: int = 8) -> str: + return "".join(random.choices(string.ascii_uppercase + string.digits, k=length)) + + +class FunctionAnnotations(NamedTuple, Generic[T]): + arguments: dict[str, type] + return_type: T | None + + +def get_function_annotations( + func: Callable[P, T], +) -> FunctionAnnotations[T]: + args: dict[str, Any] = {} + signature = inspect.signature(func) + return_type: T | None = ( + signature.return_annotation + if signature.return_annotation is not signature.empty + else None + ) + for arg_name, arg_pspec in signature.parameters.items(): + args[arg_name] = ( + arg_pspec.annotation if arg_pspec.annotation is not arg_pspec.empty else Any + ) + return FunctionAnnotations(args, return_type) + + +def make_arguments_ignorable(func: Callable[..., T]) -> Callable[..., T]: + params = inspect.signature(func).parameters.values() + if any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params): + return func # no + + args_len = sum( + 1 + for p in params + if p.kind + in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ) + + @wraps(func) + def wrapper(*passed_args): + return func(*passed_args[:args_len]) + + return wrapper + + +def truncate(string: str, max_length: int, suffix: str = "...") -> str: + return ( + string + if len(string) <= max_length + else string[: max_length - len(suffix)] + suffix + ) + + +def idle_add(func: Callable, *args, pin: bool = False) -> int: + """ + add a function to be invoked in a lazy manner in the main thread, useful for multi-threaded code + + :param func: the function to be queued + :type func: Callable + :param args: arguments will be passed to the given function + :param pin: whether the function should be invoked as long as it's return value is `True`, when the function returns `False` it won't be called again + :type pin: bool, optional + """ + if pin: + return GLib.idle_add(func, *args) + + def idle_executor(*largs): + func(*largs) + return False + + return GLib.idle_add( + idle_executor, *args + ) # a hack to safely pass a pointer to user's data + + +def remove_handler(handler_id: int): + return GLib.source_remove(handler_id) + + +# FIXME: deprecated (please don't use, there's a replacement of each function) +def set_stylesheet_from_file(file_path: str, compiled: bool = True) -> None: + __deprecation_table() + + provider = Gtk.CssProvider() + if compiled: + with open(file_path, "r") as f: + file = f.read() + provider.load_from_data(bytearray(compile_css(file), "utf-8")) # type: ignore + else: + provider.load_from_path(file_path) + screen = Gdk.Screen.get_default() + context = Gtk.StyleContext() + context.add_provider_for_screen(screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + return + + +def set_stylesheet_from_string(css_string: str, compiled: bool = True) -> None: + __deprecation_table() + + provider = Gtk.CssProvider() + if compiled: + provider.load_from_data(bytearray(compile_css(css_string), "utf-8")) + else: + provider.load_from_data(bytearray(css_string, "utf-8")) + screen = Gdk.Screen.get_default() + context = Gtk.StyleContext() + context.add_provider_for_screen(screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + return + + +def idlify(func: Callable, *args) -> int: + __deprecation_table() + return idle_add(func, *args) diff --git a/fabric/fabric/widgets/__init__.py b/fabric/fabric/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fabric/fabric/widgets/box.py b/fabric/fabric/widgets/box.py new file mode 100644 index 0000000..75bb832 --- /dev/null +++ b/fabric/fabric/widgets/box.py @@ -0,0 +1,67 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.container import Container +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class Box(Gtk.Box, Container): + """a box able to hold widgets as a container""" + + def __init__( + self, + spacing: int = 0, + orientation: Literal[ + "horizontal", + "vertical", + "h", + "v", + ] + | Gtk.Orientation = Gtk.Orientation.HORIZONTAL, + children: Gtk.Widget | Iterable[Gtk.Widget] | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ) -> None: + orientation = get_enum_member( + Gtk.Orientation, + orientation, + {"v": "vertical", "h": "horizontal"}, + default=Gtk.Orientation.HORIZONTAL, + ) + + Gtk.Box.__init__(self, spacing=spacing, orientation=orientation) # type: ignore + Container.__init__( + self, + children, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) diff --git a/fabric/fabric/widgets/button.py b/fabric/fabric/widgets/button.py new file mode 100644 index 0000000..3b4a4fb --- /dev/null +++ b/fabric/fabric/widgets/button.py @@ -0,0 +1,54 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.container import Container + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class Button(Gtk.Button, Container): + def __init__( + self, + label: str | None = None, + image: Gtk.Image | None = None, + child: Gtk.Widget | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Button.__init__(self) # type: ignore + Container.__init__( + self, + None, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self.set_label(label) if label is not None else None + self.set_image(image) if image is not None else None + self.add(child) if child is not None else None diff --git a/fabric/fabric/widgets/centerbox.py b/fabric/fabric/widgets/centerbox.py new file mode 100644 index 0000000..6397343 --- /dev/null +++ b/fabric/fabric/widgets/centerbox.py @@ -0,0 +1,140 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.core.service import Property +from fabric.widgets.box import Box +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class CenterBox(Box): + # using `.fset` directly isn't a good idea but it works for now + @Property(list[Gtk.Widget], "read-write") + def start_children(self) -> list[Gtk.Widget]: + return self.start_container.get_children() + + @start_children.setter + def start_children(self, value: Gtk.Widget | Iterable[Gtk.Widget]): + return Box.children.fset(self.start_container, value) # type: ignore + + @Property(list[Gtk.Widget], "read-write") + def center_children(self) -> list[Gtk.Widget]: + return self.center_container.get_children() + + @center_children.setter + def center_children(self, value: Gtk.Widget | Iterable[Gtk.Widget]): + return Box.children.fset(self.center_container, value) # type: ignore + + @Property(list[Gtk.Widget], "read-write") + def end_children(self) -> list[Gtk.Widget]: + return self.end_container.get_children() + + @end_children.setter + def end_children(self, value: Gtk.Widget | Iterable[Gtk.Widget]): + return Box.children.fset(self.end_container, value) # type: ignore + + def __init__( + self, + start_children: Gtk.Widget | Iterable[Gtk.Widget] | None = None, + center_children: Gtk.Widget | Iterable[Gtk.Widget] | None = None, + end_children: Gtk.Widget | Iterable[Gtk.Widget] | None = None, + spacing: int = 0, + orientation: Literal[ + "horizontal", + "vertical", + "h", + "v", + ] + | Gtk.Orientation = Gtk.Orientation.HORIZONTAL, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + orientation = get_enum_member( + Gtk.Orientation, + orientation, + { + "h": "horizontal", + "v": "vertical", + }, + Gtk.Orientation.VERTICAL, + ) + orientation_flipped = { + Gtk.Orientation.VERTICAL: Gtk.Orientation.HORIZONTAL, + Gtk.Orientation.HORIZONTAL: Gtk.Orientation.VERTICAL, + }.get(orientation, Gtk.Orientation.VERTICAL) + super().__init__( + spacing, + orientation_flipped, + None, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + + def box_factory(): + return Box(orientation=orientation).build( + lambda _, builder: builder.set_vexpand(True) + if orientation == Gtk.Orientation.HORIZONTAL + else builder.set_hexpand(True) + ) + + self._inner = box_factory() + + self.start_container = box_factory() + self.center_container = box_factory() + self.end_container = box_factory() + + self._inner.pack_start(self.start_container, False, False, 0) + self._inner.set_center_widget(self.center_container) + self._inner.pack_end(self.end_container, False, False, 0) + + self.start_container.children = start_children or () + self.center_container.children = center_children or () + self.end_container.children = end_children or () + + self.add(self._inner) + + def add_start(self, widget: Gtk.Widget): + return self.start_container.add(widget) + + def add_center(self, widget: Gtk.Widget): + return self.center_container.add(widget) + + def add_end(self, widget: Gtk.Widget): + return self.end_container.add(widget) + + def remove_start(self, widget: Gtk.Widget): + return self.start_container.remove(widget) + + def remove_center(self, widget: Gtk.Widget): + return self.center_container.remove(widget) + + def remove_end(self, widget: Gtk.Widget): + return self.end_container.remove(widget) diff --git a/fabric/fabric/widgets/checkbutton.py b/fabric/fabric/widgets/checkbutton.py new file mode 100644 index 0000000..c7ae801 --- /dev/null +++ b/fabric/fabric/widgets/checkbutton.py @@ -0,0 +1,51 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.button import Button + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class CheckButton(Gtk.CheckButton, Button): + def __init__( + self, + label: str | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.CheckButton.__init__(self) # type: ignore + Button.__init__( + self, + label, + None, + None, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) diff --git a/fabric/fabric/widgets/circularprogressbar.py b/fabric/fabric/widgets/circularprogressbar.py new file mode 100644 index 0000000..9ee2fed --- /dev/null +++ b/fabric/fabric/widgets/circularprogressbar.py @@ -0,0 +1,261 @@ +import gi +import math +import cairo +from typing import Literal, Iterable +from fabric.core.service import Property +from fabric.widgets.container import Container +from fabric.utils.helpers import get_enum_member, clamp + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, Gtk + + +class CircularProgressBar(Gtk.Bin, Container): + @Property(float, "read-write", default_value=0.0) + def min_value(self) -> float: + return self._min_value + + @min_value.setter + def min_value(self, value: float): + self._min_value = clamp(value, self.min_value, self.max_value) + return self.queue_draw() + + @Property(float, "read-write", default_value=1.0) + def max_value(self) -> float: + return self._max_value + + @max_value.setter + def max_value(self, value: float): + if value == 0: + raise ValueError("max_value cannot be zero") + self._max_value = value + return self.queue_draw() + + @Property(float, "read-write", default_value=1.0) + def value(self) -> float: + return self._value + + @value.setter + def value(self, value: float): + self._value = value + return self.queue_draw() + + @Property(bool, "read-write", default_value=False) + def pie(self) -> bool: + return self._pie + + @pie.setter + def pie(self, value: bool): + self._pie = value + return self.queue_draw() + + @Property(int, "read-write", default_value=4) + def line_width(self) -> int: + return self._line_width + + @line_width.setter + def line_width(self, value: int): + self._line_width = value + return self.queue_draw() + + @Property(object, "read-write") + def line_style(self) -> cairo.LineCap: + return self._line_style # type: ignore + + @line_style.setter + def line_style( + self, + line_style: Literal[ + "none", + "butt", + "round", + "square", + ] + | cairo.LineCap, + ): + self._line_style = get_enum_member(cairo.LineCap, line_style) # type: ignore + return self.queue_draw() + + @Property(float, "read-write", default_value=0.0) + def start_angle(self) -> float: + return self._start_angle + + @start_angle.setter + def start_angle(self, value: float): + self._start_angle = value + return self.queue_draw() + + @Property(float, "read-write", default_value=360.0) + def end_angle(self) -> float: + return self._end_angle + + @end_angle.setter + def end_angle(self, value: float): + self._end_angle = value + return self.queue_draw() + + @Property(bool, "read-write", default_value=False) + def invert(self) -> bool: + return self._invert + + @invert.setter + def invert(self, value: bool): + self._invert = value + return self.queue_draw() + + def __init__( + self, + value: float = 1.0, + min_value: float = 0.0, + max_value: float = 1.0, + start_angle: float | None = None, + end_angle: float | None = None, + line_width: int = 4, + line_style: Literal["none", "butt", "round", "square"] + | cairo.LineCap = cairo.LineCap.ROUND, + pie: bool = False, + invert: bool = False, # TODO: implement... + child: Gtk.Widget | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.DrawingArea.__init__(self) # type: ignore + Container.__init__( + self, + child, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self._value: float = 1.0 + self._min_value: float = 0.0 + self._max_value: float = 1.0 + self._line_width: int = 4 + self._line_style: cairo.LineCap = cairo.LineCap.ROUND + self._pie: bool = False + self._invert: bool = False + self._start_angle: float = 0.0 + self._end_angle: float = 360.0 + + self.value = value + self.min_value = min_value + self.max_value = max_value + self.line_width = line_width + self.line_style = line_style + self.pie = pie + self.invert = invert + self.start_angle = start_angle if start_angle is not None else 0.0 + self.end_angle = end_angle if end_angle is not None else 360.0 + + def do_calculate_radius(self) -> int: + alloc = self.get_allocation() + return min(alloc.width, alloc.height) // 2 + + def do_get_preferred_width(self): + return (2 * self.do_calculate_radius(),) * 2 + + def do_get_preferred_height(self): + return self.do_get_preferred_width() + + def do_draw(self, cr: cairo.Context): + state = self.get_state_flags() + style_context = self.get_style_context() + + border = style_context.get_border(state) # type: ignore + radius_color = style_context.get_color(state) # type: ignore + progress_color = style_context.get_border_color(state) # type: ignore + background_color = style_context.get_background_color(state) # type: ignore + + line_width = max( + self._line_width, + border.top, # type: ignore + border.bottom, # type: ignore + border.left, # type: ignore + border.right, # type: ignore + style_context.get_property("min-width", state), # type: ignore + style_context.get_property("min-height", state), # type: ignore + ) + + delta = 0 + center_x = self.get_allocated_width() / 2 + center_y = self.get_allocated_height() / 2 + radius = self.do_calculate_radius() + + if (radius - line_width) < 0: + line_width = radius + else: + delta = radius - line_width / 2 + + cr.save() + + cr.set_line_cap(self._line_style) + cr.set_line_width(line_width) + + # background fill + Gdk.cairo_set_source_rgba(cr, background_color) # type: ignore + cr.arc(center_x, center_y, delta + (line_width / 2), 0, 2 * math.pi) + cr.fill() + + # radius + Gdk.cairo_set_source_rgba(cr, radius_color) # type: ignore + cr.move_to(center_x, center_y) if self._pie is True else None + cr.arc( + center_x, + center_y, + delta + (self._line_width / 2) if self._pie is True else delta, + math.radians(self._start_angle), + math.radians(self._end_angle), + ) + cr.fill() if self._pie is True else cr.stroke() + + # progress + normalized_value = clamp( # normalized & clamped value + (self._value - self._min_value) / (self._max_value - self._min_value), + 0.0, + 1.0, + ) + + Gdk.cairo_set_source_rgba(cr, progress_color) # type: ignore + cr.move_to(center_x, center_y) if self._pie is True else None + cr.arc( + center_x, + center_y, + delta + (self._line_width / 2) if self._pie is True else delta, + math.radians(self._start_angle), + math.radians( + self._start_angle + + normalized_value * (self._end_angle - self._start_angle) + ), + ) + cr.fill() if self._pie is True else cr.stroke() + + # all aboard, draw child (if any) + if child := self.get_child(): + self.propagate_draw(child, cr) + + return cr.restore() diff --git a/fabric/fabric/widgets/container.py b/fabric/fabric/widgets/container.py new file mode 100644 index 0000000..9637683 --- /dev/null +++ b/fabric/fabric/widgets/container.py @@ -0,0 +1,66 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.core.service import Property +from fabric.widgets.widget import Widget + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class Container(Gtk.Container, Widget): + @Property(list[Gtk.Widget], "read-write", install=False) + def children(self) -> list[Gtk.Widget]: + return self.get_children() + + @children.setter + def children(self, value: Gtk.Widget | Iterable[Gtk.Widget]): + for old_child in self.get_children(): + self.remove(old_child) + if isinstance(value, (tuple, list)): + for widget in value: + self.add(widget) + return + self.add(value) + return + + def __init__( + self, + children: Gtk.Widget | Iterable[Gtk.Widget] | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Container.__init__(self) # type: ignore + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + if children: + self.children = children diff --git a/fabric/fabric/widgets/datetime.py b/fabric/fabric/widgets/datetime.py new file mode 100644 index 0000000..4736b3c --- /dev/null +++ b/fabric/fabric/widgets/datetime.py @@ -0,0 +1,138 @@ +import gi +import time +from loguru import logger +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.button import Button +from fabric.core.service import Property +from fabric.utils.helpers import invoke_repeater + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Gdk + + +class DateTime(Button): + @Property(tuple[str, ...], "read-write") + def formatters(self): + return self._formatters + + @formatters.setter + def formatters(self, value: str | Iterable[str]): + if isinstance(value, (tuple, list)): + self._formatters = tuple(value) + elif isinstance(value, str): + self._formatters = (value,) + if len(self._formatters) < 1: + logger.warning( + "[DateTime] passed in invalid list of formatters, using default formatters list" + ) + self._formatters = ("%I:%M %p", "%A", "%m-%d-%Y") + return + + @Property(int, "read-write") + def interval(self): + return self._interval + + @interval.setter + def interval(self, value: int): + self._interval = value + if self._repeater_id: + GLib.source_remove(self._repeater_id) + self._repeater_id = invoke_repeater(self._interval, self.do_update_label) + self.do_update_label() + return + + def __init__( + self, + formatters: str | Iterable[str] = ("%I:%M %p", "%A", "%m-%d-%Y"), + interval: int = 1000, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + super().__init__( + None, + None, + None, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self.add_events(Gdk.EventMask.SCROLL_MASK) + self._formatters: tuple[str, ...] = tuple() + self._current_index: int = 0 + self._interval: int = interval + self._repeater_id: int | None = None + + self.formatters = formatters + self.interval = interval + + self.connect( + "button-press-event", + lambda *args: self.do_handle_press(*args), # to allow overriding + ) + self.connect("scroll-event", lambda *args: self.do_handle_scroll(*args)) + + def do_format(self) -> str: + return time.strftime(self._formatters[self._current_index]) + + def do_update_label(self): + self.set_label(self.do_format()) + return True + + def do_check_invalid_index(self, index: int) -> bool: + return (index < 0) or (index > (len(self.formatters) - 1)) + + def do_cycle_next(self): + self._current_index = self._current_index + 1 + if self.do_check_invalid_index(self._current_index): + self._current_index = 0 # reset tags + + return self.do_update_label() + + def do_cycle_prev(self): + self._current_index = self._current_index - 1 + if self.do_check_invalid_index(self._current_index): + self._current_index = len(self.formatters) - 1 + + return self.do_update_label() + + def do_handle_press(self, _, event, *args): + match event.button: + case 1: # left click + self.do_cycle_next() + case 3: # right click + self.do_cycle_prev() + return + + def do_handle_scroll(self, _, event, *args): + match event.direction: + case Gdk.ScrollDirection.UP: # scrolling up + self.do_cycle_next() + case Gdk.ScrollDirection.DOWN: # scrolling down + self.do_cycle_prev() + return diff --git a/fabric/fabric/widgets/entry.py b/fabric/fabric/widgets/entry.py new file mode 100644 index 0000000..ca0aa8d --- /dev/null +++ b/fabric/fabric/widgets/entry.py @@ -0,0 +1,57 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.widget import Widget + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class Entry(Gtk.Entry, Widget): + def __init__( + self, + text: str | None = None, + placeholder: str | None = None, + editable: bool = True, + password: bool = False, + max_length: int = 0, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Entry.__init__(self) # type: ignore + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self.set_text(text) if text is not None else None + self.set_placeholder_text(placeholder) if placeholder is not None else None + self.set_max_length(max_length) + self.set_editable(editable) + self.set_visibility(not password) diff --git a/fabric/fabric/widgets/eventbox.py b/fabric/fabric/widgets/eventbox.py new file mode 100644 index 0000000..ccb5d5c --- /dev/null +++ b/fabric/fabric/widgets/eventbox.py @@ -0,0 +1,55 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.widget import EVENT_TYPE +from fabric.widgets.container import Container + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + + +class EventBox(Gtk.EventBox, Container): + def __init__( + self, + events: EVENT_TYPE + | Gdk.EventMask + | Iterable[EVENT_TYPE | Gdk.EventMask] + | None = None, + child: Gtk.Widget | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.EventBox.__init__(self) # type: ignore + Container.__init__( + self, + child, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self.add_events(events) if events is not None else None diff --git a/fabric/fabric/widgets/fixed.py b/fabric/fabric/widgets/fixed.py new file mode 100644 index 0000000..06df7e0 --- /dev/null +++ b/fabric/fabric/widgets/fixed.py @@ -0,0 +1,51 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.widget import Widget + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class Fixed(Gtk.Fixed, Widget): + def __init__( + self, + children: Iterable[tuple[Widget, tuple[int, int]]] | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Fixed.__init__(self) # type: ignore + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + + for child in children or (): + self.put(child[0], *child[1]) diff --git a/fabric/fabric/widgets/flowbox.py b/fabric/fabric/widgets/flowbox.py new file mode 100644 index 0000000..c436938 --- /dev/null +++ b/fabric/fabric/widgets/flowbox.py @@ -0,0 +1,66 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.container import Container +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class FlowBox(Gtk.FlowBox, Container): + def __init__( + self, + row_spacing: int = 0, + column_spacing: int = 0, + orientation: Literal[ + "horizontal", + "vertical", + "h", + "v", + ] + | Gtk.Orientation = Gtk.Orientation.HORIZONTAL, + children: Gtk.Widget | Iterable[Gtk.Widget] | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.FlowBox.__init__(self) # type: ignore + Container.__init__( + self, + children, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self.set_row_spacing(row_spacing) + self.set_column_spacing(column_spacing) + self.set_orientation( + get_enum_member( + Gtk.Orientation, orientation, default=Gtk.Orientation.HORIZONTAL + ) + ) diff --git a/fabric/fabric/widgets/grid.py b/fabric/fabric/widgets/grid.py new file mode 100644 index 0000000..306b78a --- /dev/null +++ b/fabric/fabric/widgets/grid.py @@ -0,0 +1,71 @@ +import gi +from collections.abc import Iterable +from typing import Literal + +from fabric.widgets.widget import Widget +from gi.repository import Gtk + + +class Grid(Gtk.Grid, Widget): + def __init__( + self, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + row_spacing: int = 0, + column_spacing: int = 0, + column_homogeneous: bool = False, + row_homogeneous: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Grid.__init__(self) + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self.set_row_spacing(row_spacing) + self.set_column_spacing(column_spacing) + self.set_column_homogeneous(column_homogeneous) + self.set_row_homogeneous(row_homogeneous) + + def attach_flow( + self, children: Iterable[Widget], columns, start_row=0, start_col=0 + ): + """ + Adds widgets to a Gtk.Grid in a flow layout. + + Args: + children (list): List of Gtk.Widget to add. + columns (int): Number of columns in the grid. + start_row (int): Optional starting row. + start_col (int): Optional starting column. + """ + for index, child in enumerate(children): + row = start_row + index // columns + col = start_col + index % columns + self.attach(child, col, row, 1, 1) diff --git a/fabric/fabric/widgets/image.py b/fabric/fabric/widgets/image.py new file mode 100644 index 0000000..78733f3 --- /dev/null +++ b/fabric/fabric/widgets/image.py @@ -0,0 +1,149 @@ +import gi +from typing import Literal, overload +from collections.abc import Iterable +from fabric.widgets.widget import Widget + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GdkPixbuf + + +class Image(Gtk.Image, Widget): + @overload + def __init__( + self, + image_file: str | None = None, + icon_name: None = None, + icon_size: int = 24, + pixbuf: None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + + @overload + def __init__( + self, + image_file: None = None, + icon_name: str | None = None, + icon_size: int = 24, + pixbuf: None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + @overload + def __init__( + self, + image_file: None = None, + icon_name: None = None, + icon_size: int = 24, + pixbuf: GdkPixbuf.Pixbuf | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + + def __init__( + self, + image_file: str | None = None, + icon_name: str | None = None, + icon_size: int = 24, + pixbuf: GdkPixbuf.Pixbuf | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Image.__init__(self) # type: ignore + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + + if image_file is not None: + alloc = self.get_size_request() + pixsize: tuple[int, int] = (alloc.width, alloc.height) # type: ignore + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(image_file, *pixsize) + elif icon_name is not None: + self.set_from_icon_name(icon_name, icon_size) + + self.set_from_pixbuf(pixbuf) if pixbuf is not None else None + + # overrides + def set_from_icon_name( + self, + icon_name: str, + icon_size: int = 24, + ): + super().set_from_icon_name( + icon_name, + Gtk.IconSize.LARGE_TOOLBAR, # type: ignore + ) + return self.set_pixel_size(icon_size) diff --git a/fabric/fabric/widgets/label.py b/fabric/fabric/widgets/label.py new file mode 100644 index 0000000..9df8891 --- /dev/null +++ b/fabric/fabric/widgets/label.py @@ -0,0 +1,174 @@ +import gi +from typing import Literal, overload +from collections.abc import Iterable +from fabric.widgets.widget import Widget +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Pango + + +class Label(Gtk.Label, Widget): + @overload + def __init__( + self, + label: str | None = None, + markup: None = None, + justification: Literal[ + "left", + "right", + "center", + "fill", + ] + | Gtk.Justification = Gtk.Justification.LEFT, + ellipsization: Literal[ + "none", + "start", + "middle", + "end", + ] + | Pango.EllipsizeMode = Pango.EllipsizeMode.NONE, + chars_width: int = -1, + max_chars_width: int = -1, + line_wrap: Literal["word", "char", "word-char"] | Pango.WrapMode | None = None, + angle: float = 0.0, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + + @overload + def __init__( + self, + label: None = None, + markup: str | None = None, + justification: Literal[ + "left", + "right", + "center", + "fill", + ] + | Gtk.Justification = Gtk.Justification.LEFT, + ellipsization: Literal[ + "none", + "start", + "middle", + "end", + ] + | Pango.EllipsizeMode = Pango.EllipsizeMode.NONE, + chars_width: int = -1, + max_chars_width: int = -1, + line_wrap: Literal["word", "char", "word-char"] | Pango.WrapMode | None = None, + angle: float = 0.0, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + + def __init__( + self, + label: str | None = None, + markup: str | None = None, + justification: Literal[ + "left", + "right", + "center", + "fill", + ] + | Gtk.Justification = Gtk.Justification.LEFT, + ellipsization: Literal[ + "none", + "start", + "middle", + "end", + ] + | Pango.EllipsizeMode = Pango.EllipsizeMode.NONE, + chars_width: int = -1, + max_chars_width: int = -1, + line_wrap: Literal["word", "char", "word-char"] | Pango.WrapMode | None = None, + angle: float = 0.0, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Label.__init__(self) # type: ignore + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self.set_label(label) if label is not None else None + self.set_markup(markup) if markup is not None else None + + self.set_justify( + get_enum_member( + Gtk.Justification, justification, default=Gtk.Justification.LEFT + ) + ) + self.set_ellipsize( + get_enum_member( + Pango.EllipsizeMode, ellipsization, default=Pango.EllipsizeMode.NONE + ) + ) + + self.set_width_chars(chars_width) + self.set_max_width_chars(max_chars_width) + if line_wrap is not None: + self.set_line_wrap(True) + self.set_line_wrap_mode( + get_enum_member(Pango.WrapMode, line_wrap, default=Pango.WrapMode.WORD) + ) + self.set_angle(angle) diff --git a/fabric/fabric/widgets/overlay.py b/fabric/fabric/widgets/overlay.py new file mode 100644 index 0000000..a72b7ae --- /dev/null +++ b/fabric/fabric/widgets/overlay.py @@ -0,0 +1,82 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.core.service import Property +from fabric.widgets.container import Container + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class Overlay(Gtk.Overlay, Container): + @Property(list[Gtk.Widget], "read-write") + def overlays(self) -> list[Gtk.Widget]: + return self._overlays + + @overlays.setter + def overlays(self, value: Gtk.Widget | Iterable[Gtk.Widget]): + for old_overlay in self._overlays: + self.remove(old_overlay) + if isinstance(value, (tuple, list)): + for widget in value: + self.add_overlay(widget) + return + self.add_overlay(value) # type: ignore + return + + def __init__( + self, + child: Gtk.Widget | None = None, + overlays: Gtk.Widget | list[Gtk.Widget] | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Overlay.__init__(self) # type: ignore + Container.__init__( + self, + child, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self._overlays = [] + if overlays is not None: + self.overlays = overlays + + # overrides + def remove_overlay(self, overlay: Gtk.Widget): + if overlay not in self._overlays: + raise ValueError(f"widget {overlay} does not exist in {self} as a overlay") + self.remove(overlay) + self._overlays.remove(overlay) + return + + def add_overlay(self, overlay: Gtk.Widget): + super().add_overlay(overlay) + self._overlays.append(overlay) + return diff --git a/fabric/fabric/widgets/revealer.py b/fabric/fabric/widgets/revealer.py new file mode 100644 index 0000000..8f0f06e --- /dev/null +++ b/fabric/fabric/widgets/revealer.py @@ -0,0 +1,119 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.core.service import Property +from fabric.widgets.container import Container +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class Revealer(Gtk.Revealer, Container): + @Property(bool, "read-write", default_value=False, install=False) + def child_revealed(self): + return self.get_reveal_child() + + @child_revealed.setter + def child_revealed(self, value: bool): + return self.set_reveal_child(value) + + @Property(bool, "readable", default_value=False) + def fully_revealed(self): + return self.get_child_revealed() + + @Property( + Gtk.RevealerTransitionType, + "read-write", + default_value=Gtk.RevealerTransitionType.NONE, + install=False, + ) + def transition_type(self) -> Gtk.RevealerTransitionType: + return self.get_transition_type() + + @transition_type.setter + def transition_type( + self, + value: Literal[ + "none", "crossfade", "slide-right", "slide-left", "slide-up", "slide-down" + ] + | Gtk.RevealerTransitionType, + ): + return self.set_transition_type( + get_enum_member(Gtk.RevealerTransitionType, value) + ) + + @Property(int, "read-write", install=False) + def transition_duration(self): + return self.get_transition_duration() + + @transition_duration.setter + def transition_duration(self, value: int): + return self.set_transition_duration(value) + + def __init__( + self, + child: Gtk.Widget | None = None, + child_revealed: bool = False, + transition_type: Literal[ + "none", + "crossfade", + "slide-right", + "slide-left", + "slide-up", + "slide-down", + # "swing-right", + # "swing-left", + # "swing-up", + # "swing-down", + ] + | Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.NONE, + transition_duration: int = 400, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Revealer.__init__(self) # type: ignore + Container.__init__( + self, + child, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self.child_revealed = child_revealed + self.transition_type = transition_type + self.transition_duration = transition_duration + + def reveal(self): + self.child_visible = True + self.child_revealed = True + return + + def unreveal(self): + self.child_revealed = False + return diff --git a/fabric/fabric/widgets/scale.py b/fabric/fabric/widgets/scale.py new file mode 100644 index 0000000..be47345 --- /dev/null +++ b/fabric/fabric/widgets/scale.py @@ -0,0 +1,142 @@ +import gi +from collections.abc import Iterable +from typing import Literal, NamedTuple +from fabric.widgets.widget import Widget +from fabric.core.service import Property +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class ScaleMark(NamedTuple): + value: float + markup: str | None = None + position: ( + Literal[ + "bottom", + "left", + "right", + "top", + ] + | Gtk.PositionType + ) = Gtk.PositionType.RIGHT + + +class Scale(Gtk.Scale, Widget): + @Property(float, "read-write", install=False) + def value(self) -> float: + return self.get_value() + + @value.setter + def value(self, value: float): + return self.set_value(value) + + @Property(float, "read-write", default_value=0.0) + def min_value(self) -> float: + return self._min_value + + @min_value.setter + def min_value(self, value: float): + self._min_value = value + return self.set_range(self._min_value, self._max_value) + + @Property(float, "read-write", default_value=1.0) + def max_value(self) -> float: + return self._max_value + + @max_value.setter + def max_value(self, value: float): + self._max_value = value + return self.set_range(self._min_value, self._max_value) + + def __init__( + self, + value: float = 0.0, + min_value: float = 0.0, + max_value: float = 1.0, + draw_value: bool = False, + value_position: Literal["left", "right", "top", "bottom"] + | Gtk.PositionType = Gtk.PositionType.TOP, + orientation: Literal[ + "horizontal", + "vertical", + "h", + "v", + ] + | Gtk.Orientation = Gtk.Orientation.HORIZONTAL, + increments: tuple[float, float] | None = None, + marks: Iterable[ScaleMark] | None = None, + digits: int = 1, + inverted: bool = False, + has_origin: bool = True, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Scale.__init__(self) # type: ignore + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + + self._min_value: float = 0 + self._max_value: float = 1.0 + + self.max_value = max_value + self.min_value = min_value + self.value = value + + self.set_orientation( + get_enum_member( + Gtk.Orientation, + orientation, + {"v": "vertical", "h": "horizontal"}, + default=Gtk.Orientation.HORIZONTAL, + ) + ) + self.set_draw_value(draw_value) + self.set_value_pos( + get_enum_member( + Gtk.PositionType, value_position, default=Gtk.PositionType.TOP + ) + ) + if increments is not None: + self.set_increments(increments[0], increments[1]) + for mark in marks or (): + self.add_mark( + mark[0], + get_enum_member( + Gtk.PositionType, mark[2], default=Gtk.PositionType.TOP + ), + mark[1], + ) + self.set_digits(digits) + self.set_inverted(inverted) + self.set_has_origin(has_origin) diff --git a/fabric/fabric/widgets/scrolledwindow.py b/fabric/fabric/widgets/scrolledwindow.py new file mode 100644 index 0000000..0b528de --- /dev/null +++ b/fabric/fabric/widgets/scrolledwindow.py @@ -0,0 +1,114 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.container import Container +from fabric.core.service import Property +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class ScrolledWindow(Gtk.ScrolledWindow, Container): + @Property(tuple[int, int], "read-write") + def min_content_size(self): + return self.get_min_content_width(), self.get_min_content_height() + + @min_content_size.setter + def min_content_size(self, value: Iterable[int]): + if isinstance(value, (tuple, list)) and len(value) == 2: + self.set_min_content_width(value[0]) + self.set_min_content_height(value[1]) + return + raise ValueError( + "the size must be tuple or a list with two values, (width, height)" + ) + + @Property(tuple[int, int], "read-write") + def max_content_size(self): + return self.get_max_content_width(), self.get_max_content_height() + + @max_content_size.setter + def max_content_size(self, value: Iterable[int]): + if isinstance(value, (tuple, list)) and len(value) == 2: + self.set_max_content_width(value[0]) + self.set_max_content_height(value[1]) + return + raise ValueError( + "the size must be tuple or a list with two values, (width, height)" + ) + + def __init__( + self, + min_content_size: Iterable[int] = (-1, -1), + max_content_size: Iterable[int] = (-1, -1), + propagate_width: bool = True, + propagate_height: bool = True, + kinetic_scroll: bool = False, + overlay_scroll: bool = False, + h_scrollbar_policy: Literal[ + "always", + "automatic", + "never", + "external", + ] + | Gtk.PolicyType = Gtk.PolicyType.AUTOMATIC, + v_scrollbar_policy: Literal[ + "always", + "automatic", + "never", + "external", + ] + | Gtk.PolicyType = Gtk.PolicyType.AUTOMATIC, + child: Gtk.Widget | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.ScrolledWindow.__init__(self) # type: ignore + Container.__init__( + self, + child, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + + self.min_content_size = min_content_size + self.max_content_size = max_content_size + self.set_propagate_natural_width(propagate_width) + self.set_propagate_natural_height(propagate_height) + self.set_kinetic_scrolling(kinetic_scroll) + self.set_overlay_scrolling(overlay_scroll) + self.set_policy( + get_enum_member( + Gtk.PolicyType, h_scrollbar_policy, default=Gtk.PolicyType.AUTOMATIC + ), + get_enum_member( + Gtk.PolicyType, v_scrollbar_policy, default=Gtk.PolicyType.AUTOMATIC + ), + ) diff --git a/fabric/fabric/widgets/separator.py b/fabric/fabric/widgets/separator.py new file mode 100644 index 0000000..14c80cc --- /dev/null +++ b/fabric/fabric/widgets/separator.py @@ -0,0 +1,60 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.widget import Widget +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class Separator(Gtk.Separator, Widget): + def __init__( + self, + orientation: Literal[ + "horizontal", + "vertical", + "h", + "v", + ] + | Gtk.Orientation = Gtk.Orientation.HORIZONTAL, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Separator.__init__(self) + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self.set_orientation( + get_enum_member( + Gtk.Orientation, orientation, default=Gtk.Orientation.HORIZONTAL + ) + ) diff --git a/fabric/fabric/widgets/shapes/__init__.py b/fabric/fabric/widgets/shapes/__init__.py new file mode 100644 index 0000000..ec3464c --- /dev/null +++ b/fabric/fabric/widgets/shapes/__init__.py @@ -0,0 +1,4 @@ +from .star import Star +from .corner import Corner, CornerOrientation + +__all__ = ["Star", "Corner", "CornerOrientation"] diff --git a/fabric/fabric/widgets/shapes/corner.py b/fabric/fabric/widgets/shapes/corner.py new file mode 100644 index 0000000..4b80712 --- /dev/null +++ b/fabric/fabric/widgets/shapes/corner.py @@ -0,0 +1,142 @@ +import gi +import cairo +from enum import Enum +from typing import Literal +from collections.abc import Iterable +from fabric.core.service import Property +from fabric.widgets.widget import Widget +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + + +class CornerOrientation(Enum): + TOP_LEFT = 1 + TOP_RIGHT = 2 + BOTTOM_LEFT = 3 + BOTTOM_RIGHT = 4 + + +class Corner(Gtk.DrawingArea, Widget): + @staticmethod + def render_shape( + cr: cairo.Context, + width: float, + height: float, + orientation: CornerOrientation = CornerOrientation.TOP_LEFT, + ): + # _this is fine_ + cr.save() + match orientation: + case CornerOrientation.TOP_LEFT: + cr.move_to(0, height) + cr.line_to(0, 0) + cr.line_to(width, 0) + cr.curve_to(0, 0, 0, height, 0, height) + case CornerOrientation.TOP_RIGHT: + cr.move_to(width, height) + cr.line_to(width, 0) + cr.line_to(0, 0) + cr.curve_to(width, 0, width, height, width, height) + case CornerOrientation.BOTTOM_LEFT: + cr.move_to(0, 0) + cr.line_to(0, height) + cr.line_to(width, height) + cr.curve_to(0, height, 0, 0, 0, 0) + case CornerOrientation.BOTTOM_RIGHT: + cr.move_to(width, 0) + cr.line_to(width, height) + cr.line_to(0, height) + cr.curve_to(width, height, width, 0, width, 0) + cr.close_path() + cr.restore() + return + + @Property(CornerOrientation, "read-write") + def orientation(self) -> CornerOrientation: + return self._orientation + + @orientation.setter + def orientation( + self, + value: Literal["top-left", "top-right", "bottom-left", "bottom-right"] + | CornerOrientation, + ): + self._orientation = get_enum_member(CornerOrientation, value) + return self.queue_draw() + + def __init__( + self, + orientation: Literal["top-left", "top-right", "bottom-left", "bottom-right"] + | CornerOrientation = CornerOrientation.TOP_RIGHT, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.DrawingArea.__init__(self) # type: ignore + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self._orientation = get_enum_member(CornerOrientation, orientation) + self.connect("draw", self.on_draw) + + def on_draw(self, _, cr: cairo.Context): + aloc: cairo.Rectangle = self.get_allocation() # type: ignore + # ^ hear me out, Gtk.Allocation == Gdk.Rectangle == cairo.Rectangle + width, height = aloc.width, aloc.height + + state = self.get_state_flags() + context: Gtk.StyleContext = self.get_style_context() + border_color: Gdk.RGBA = context.get_border_color(state) # type: ignore + border_width = ( + max( + (border := context.get_border(state)).top, # type: ignore + border.bottom, # type: ignore + border.left, # type: ignore + border.right, # type: ignore + ) + * 2 # the power of two because half the size of the border is out the bounding box + ) + + cr.save() + + self.render_shape(cr, width, height, self._orientation) + cr.clip() + Gtk.render_background(context, cr, 0, 0, width, height) + + if border_width: + Gdk.cairo_set_source_rgba(cr, border_color) # type: ignore + cr.set_line_width(border_width) + self.render_shape(cr, width, height, self._orientation) + cr.stroke() + + cr.restore() + return diff --git a/fabric/fabric/widgets/shapes/star.py b/fabric/fabric/widgets/shapes/star.py new file mode 100644 index 0000000..8ea2342 --- /dev/null +++ b/fabric/fabric/widgets/shapes/star.py @@ -0,0 +1,133 @@ +import gi +import math +import cairo +from typing import Literal +from collections.abc import Iterable +from fabric.core.service import Property +from fabric.widgets.widget import Widget + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + + +class Star(Gtk.DrawingArea, Widget): + @staticmethod + def render_shape( + cr: cairo.Context, + width: float, + height: float, + points: int = 5, + ratio: float = 0.5, + ): + a = 2 * math.pi / points + s = min(width, height) / 2 + rs = s * ratio + + cr.save() + + cr.translate(width / 2, height / 2) + cr.rotate(-math.pi / 2) + + for i in range(points): + cr.line_to(s * math.cos(i * a), s * math.sin(i * a)) + cr.line_to(rs * math.cos((i + 0.5) * a), rs * math.sin((i + 0.5) * a)) + + cr.close_path() + cr.restore() + return + + @Property(int, "read-write") + def points(self) -> int: + return self._points + + @points.setter + def points(self, value: int): + self._points = value + return self.queue_draw() + + @Property(float, "read-write") + def ratio(self) -> float: + return self._ratio + + @ratio.setter + def ratio(self, value: float): + self._ratio = value + return self.queue_draw() + + def __init__( + self, + points: int = 5, + ratio: float = 0.5, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.DrawingArea.__init__(self) # type: ignore + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self._points = points + self._ratio = ratio + + self.connect("draw", self.on_draw) + + def on_draw(self, _, cr: cairo.Context): + aloc: cairo.Rectangle = self.get_allocation() # type: ignore + width, height = aloc.width, aloc.height + + state = self.get_state_flags() + context: Gtk.StyleContext = self.get_style_context() + border_color: Gdk.RGBA = context.get_border_color(state) # type: ignore + border_width = ( + max( + (border := context.get_border(state)).top, # type: ignore + border.bottom, # type: ignore + border.left, # type: ignore + border.right, # type: ignore + ) + * 2 + ) + + cr.save() + + # render the background + self.render_shape(cr, width, height, self._points, self._ratio) + cr.clip() + Gtk.render_background(context, cr, 0, 0, width, height) + + if border_width: + # put the border + Gdk.cairo_set_source_rgba(cr, border_color) # type: ignore + cr.set_line_width(border_width) + self.render_shape(cr, width, height, self._points, self._ratio) + cr.stroke() + + cr.restore() + return diff --git a/fabric/fabric/widgets/stack.py b/fabric/fabric/widgets/stack.py new file mode 100644 index 0000000..8c4b22d --- /dev/null +++ b/fabric/fabric/widgets/stack.py @@ -0,0 +1,134 @@ +import gi +from typing import Literal +from collections.abc import Iterable +from fabric.widgets.container import Container +from fabric.core.service import Property +from fabric.utils.helpers import get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class Stack(Gtk.Stack, Container): + @Property( + Gtk.StackTransitionType, + "read-write", + default_value=Gtk.StackTransitionType.NONE, + install=False, + ) + def transition_type(self) -> Gtk.StackTransitionType: + return self.get_transition_type() + + @transition_type.setter + def transition_type( + self, + value: Literal[ + "none", + "crossfade", + "slide-right", + "slide-left", + "slide-up", + "slide-down", + "slide-left-right", + "slide-up-down", + "over-up", + "over-down", + "over-left", + "over-right", + "under-up", + "under-down", + "under-left", + "under-right", + "over-up-down", + "over-down-up", + "over-left-right", + "over-right-left", + ] + | Gtk.StackTransitionType, + ): + return self.set_transition_type(get_enum_member(Gtk.StackTransitionType, value)) + + @Property(int, "read-write", install=False) + def transition_duration(self): + return self.get_transition_duration() + + @transition_duration.setter + def transition_duration(self, value: int): + return self.set_transition_duration(value) + + @Property(bool, "read-write", default_value=False, install=False) + def interpolate_size(self): + return self.get_interpolate_size() + + @interpolate_size.setter + def interpolate_size(self, value: bool): + return self.set_interpolate_size(value) + + def __init__( + self, + transition_type: Literal[ + "none", + "crossfade", + "slide-right", + "slide-left", + "slide-up", + "slide-down", + "slide-left-right", + "slide-up-down", + "over-up", + "over-down", + "over-left", + "over-right", + "under-up", + "under-down", + "under-left", + "under-right", + "over-up-down", + "over-down-up", + "over-left-right", + "over-right-left", + ] + | Gtk.StackTransitionType = Gtk.StackTransitionType.NONE, + transition_duration: int = 400, + interpolate_size: bool = False, + children: Gtk.Widget | Iterable[Gtk.Widget] | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Stack.__init__(self) # type: ignore + Container.__init__( + self, + children, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + + self.transition_type = transition_type + self.transition_duration = transition_duration + self.interpolate_size = interpolate_size diff --git a/fabric/fabric/widgets/svg.py b/fabric/fabric/widgets/svg.py new file mode 100644 index 0000000..710ef99 --- /dev/null +++ b/fabric/fabric/widgets/svg.py @@ -0,0 +1,191 @@ +import gi +import cairo +from loguru import logger +from typing import Literal, overload +from collections.abc import Iterable +from fabric.widgets.widget import Widget +from fabric.utils import compile_css + +gi.require_version("Gtk", "3.0") +gi.require_version("Rsvg", "2.0") +from gi.repository import Rsvg, Gtk + + +class Svg(Gtk.DrawingArea, Widget): + @overload + def __init__( + self, + svg_file: str | None = None, + svg_string: None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + + @overload + def __init__( + self, + svg_file: None = None, + svg_string: str | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + + def __init__( + self, + svg_file: str | None = None, + svg_string: str | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.DrawingArea.__init__(self) # type: ignore + Widget.__init__( + self, + name, + visible, + all_visible, + None, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + if svg_string and svg_file: + raise ValueError( + "both svg_string and svg_file can't be set at the same time" + ) + + if not svg_string and not svg_file: + raise ValueError("you must provide a source to load the svg from") + + if svg_file: + self._handle = Rsvg.Handle.new_from_file(svg_file) + else: + self._handle = Rsvg.Handle.new_from_data(svg_string.encode()) # type: ignore + + self._style_compiled: str | None = None + if style: + self.set_style(style) + + def do_draw(self, cr: cairo.Context): + if not self._handle: + return + + alloc = self.get_allocation() + width: int = alloc.width # type: ignore + height: int = alloc.height # type: ignore + + rect = Rsvg.Rectangle() + rect.x = rect.y = 0 # type: ignore + rect.width = width # type: ignore + rect.height = height # type: ignore + + if self._style_compiled is not None and self._handle.set_stylesheet( + self._style_compiled.encode() # type: ignore + ): + logger.error("[Svg] failed to apply style, probably invalid style property") + self._handle.set_dpi((self.get_scale_factor() * 160)) + + cr.save() + cr.set_antialias(cairo.Antialias.BEST) + self._handle.render_document(cr, rect) # type: ignore + cr.restore() + + return + + def do_finalize_handle(self): + if not self._handle: + return + self._handle.free() + del self._handle + + self._handle = None + return + + def get_svg_size(self) -> tuple[int, int] | None: + """Get the dimensions of the loaded svg buffer (if any) + + :return: a tuple holding (width, height) values, None if couldn't find a loaded svg + :rtype: tuple[int, int] + """ + if not self._handle: + return None + return self._handle.props.width, self._handle.props.height # type: ignore + + def set_from_file(self, file: str): + self.do_finalize_handle() + self._handle = Rsvg.Handle.new_from_file(file) + return self.queue_draw() + + def set_from_string(self, string: str): + self.do_finalize_handle() + self._handle = Rsvg.Handle.new_from_data(string.encode()) # type: ignore + return self.queue_draw() + + # override + def set_style( + self, + style: str, + compiled: bool = True, + append: bool = False, # TODO: implement + add_brackets: bool = True, + ) -> None: + if compiled: + self._style_compiled = compile_css( + f"* {{ {style} }}" + if "{" not in style or "}" not in style and add_brackets is True + else style + ) + else: + self._style_compiled = style + + return self.queue_draw() diff --git a/fabric/fabric/widgets/wayland.py b/fabric/fabric/widgets/wayland.py new file mode 100644 index 0000000..0d3c686 --- /dev/null +++ b/fabric/fabric/widgets/wayland.py @@ -0,0 +1,306 @@ +import gi +import re +from enum import Enum +from typing import cast, Literal +from collections.abc import Iterable +from fabric.core.service import Property +from fabric.widgets.window import Window +from fabric.utils.helpers import extract_css_values, get_enum_member + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +try: + gi.require_version("GtkLayerShell", "0.1") + from gi.repository import GtkLayerShell +except: + raise ImportError( + "looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)" + ) + + +class WaylandWindowExclusivity(Enum): + NONE = 1 + NORMAL = 2 + AUTO = 3 + + +class WaylandWindow(Window): + @Property( + GtkLayerShell.Layer, + flags="read-write", + default_value=GtkLayerShell.Layer.TOP, + ) + def layer(self) -> GtkLayerShell.Layer: # type: ignore + return self._layer # type: ignore + + @layer.setter + def layer( + self, + value: Literal["background", "bottom", "top", "overlay"] | GtkLayerShell.Layer, + ) -> None: + self._layer = get_enum_member( + GtkLayerShell.Layer, value, default=GtkLayerShell.Layer.TOP + ) + return GtkLayerShell.set_layer(self, self._layer) + + @Property(int, "read-write") + def monitor(self) -> int: + if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))): + return -1 + display = monitor.get_display() or Gdk.Display.get_default() + for i in range(0, display.get_n_monitors()): + if display.get_monitor(i) is monitor: + return i + return -1 + + @monitor.setter + def monitor(self, monitor: int | Gdk.Monitor) -> bool: + if isinstance(monitor, int): + display = Gdk.Display().get_default() + monitor = display.get_monitor(monitor) + return ( + (GtkLayerShell.set_monitor(self, monitor), True)[1] + if monitor is not None + else False + ) + + @Property(WaylandWindowExclusivity, "read-write") + def exclusivity(self) -> WaylandWindowExclusivity: + return self._exclusivity + + @exclusivity.setter + def exclusivity( + self, value: Literal["none", "normal", "auto"] | WaylandWindowExclusivity + ) -> None: + value = get_enum_member( + WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE + ) + self._exclusivity = value + match value: + case WaylandWindowExclusivity.NORMAL: + return GtkLayerShell.set_exclusive_zone(self, True) + case WaylandWindowExclusivity.AUTO: + return GtkLayerShell.auto_exclusive_zone_enable(self) + case _: + return GtkLayerShell.set_exclusive_zone(self, False) + + @Property( + GtkLayerShell.KeyboardMode, + "read-write", + default_value=GtkLayerShell.KeyboardMode.NONE, + ) + def keyboard_mode(self) -> GtkLayerShell.KeyboardMode: + return self._keyboard_mode + + @keyboard_mode.setter + def keyboard_mode( + self, + value: Literal[ + "none", + "exclusive", + "on-demand", + "entry-number", + ] + | GtkLayerShell.KeyboardMode, + ): + self._keyboard_mode = get_enum_member( + GtkLayerShell.KeyboardMode, value, default=GtkLayerShell.KeyboardMode.NONE + ) + return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode) + + @Property(tuple[GtkLayerShell.Edge, ...], "read-write") + def anchor(self): + return tuple( + x + for x in [ + GtkLayerShell.Edge.TOP, + GtkLayerShell.Edge.RIGHT, + GtkLayerShell.Edge.BOTTOM, + GtkLayerShell.Edge.LEFT, + ] + if GtkLayerShell.get_anchor(self, x) + ) + + @anchor.setter + def anchor(self, value: str | Iterable[GtkLayerShell.Edge]) -> None: + self._anchor = value + if isinstance(value, (list, tuple)) and all( + isinstance(edge, GtkLayerShell.Edge) for edge in value + ): + for edge in [ + GtkLayerShell.Edge.TOP, + GtkLayerShell.Edge.RIGHT, + GtkLayerShell.Edge.BOTTOM, + GtkLayerShell.Edge.LEFT, + ]: + if edge not in value: + GtkLayerShell.set_anchor(self, edge, False) + GtkLayerShell.set_anchor(self, edge, True) + return + elif isinstance(value, str): + for edge, anchored in WaylandWindow.extract_edges_from_string( + value + ).items(): + GtkLayerShell.set_anchor(self, edge, anchored) + + return + + @Property(tuple[int, ...], flags="read-write") + def margin(self) -> tuple[int, ...]: + return tuple( + GtkLayerShell.get_margin(self, x) + for x in [ + GtkLayerShell.Edge.TOP, + GtkLayerShell.Edge.RIGHT, + GtkLayerShell.Edge.BOTTOM, + GtkLayerShell.Edge.LEFT, + ] + ) + + @margin.setter + def margin(self, value: str | Iterable[int]) -> None: + for edge, mrgv in WaylandWindow.extract_margin(value).items(): + GtkLayerShell.set_margin(self, edge, mrgv) + return + + @Property(object, "read-write") + def keyboard_mode(self): + kb_mode = GtkLayerShell.get_keyboard_mode(self) + if GtkLayerShell.get_keyboard_interactivity(self): + kb_mode = GtkLayerShell.KeyboardMode.EXCLUSIVE + return kb_mode + + @keyboard_mode.setter + def keyboard_mode( + self, + value: Literal["none", "exclusive", "on-demand"] | GtkLayerShell.KeyboardMode, + ): + return GtkLayerShell.set_keyboard_mode( + self, + get_enum_member( + GtkLayerShell.KeyboardMode, + value, + default=GtkLayerShell.KeyboardMode.NONE, + ), + ) + + def __init__( + self, + layer: Literal["background", "bottom", "top", "overlay"] + | GtkLayerShell.Layer = GtkLayerShell.Layer.TOP, + anchor: str = "", + margin: str | Iterable[int] = "0px 0px 0px 0px", + exclusivity: Literal["auto", "normal", "none"] + | WaylandWindowExclusivity = WaylandWindowExclusivity.NONE, + keyboard_mode: Literal["none", "exclusive", "on-demand"] + | GtkLayerShell.KeyboardMode = GtkLayerShell.KeyboardMode.NONE, + pass_through: bool = False, + monitor: int | Gdk.Monitor | None = None, + title: str = "fabric", + type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL, + child: Gtk.Widget | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Window.__init__( + self, + title, + type, + child, + pass_through, + name, + False, + False, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + self._layer = GtkLayerShell.Layer.ENTRY_NUMBER + self._keyboard_mode = GtkLayerShell.KeyboardMode.NONE + self._anchor = anchor + self._exclusivity = WaylandWindowExclusivity.NONE + + GtkLayerShell.init_for_window(self) + GtkLayerShell.set_namespace(self, title) + self.connect( + "notify::title", + lambda *_: GtkLayerShell.set_namespace(self, self.get_title()), + ) + if monitor is not None: + self.monitor = monitor + self.layer = layer + self.anchor = anchor + self.margin = margin + self.keyboard_mode = keyboard_mode + self.exclusivity = exclusivity + self.show_all() if all_visible is True else self.show() if visible is True else None + + def steal_input(self) -> None: + return GtkLayerShell.set_keyboard_interactivity(self, True) + + def return_input(self) -> None: + return GtkLayerShell.set_keyboard_interactivity(self, False) + + @staticmethod + def extract_anchor_values(string: str) -> tuple[str, ...]: + """ + extracts the geometry values from a given geometry string. + + :param string: the string containing the geometry values. + :type string: str + :return: a list of unique directions extracted from the geometry string. + :rtype: list + """ + direction_map = {"l": "left", "t": "top", "r": "right", "b": "bottom"} + pattern = re.compile(r"\b(left|right|top|bottom)\b", re.IGNORECASE) + matches = pattern.findall(string) + return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches))) + + @staticmethod + def extract_edges_from_string(string: str) -> dict["GtkLayerShell.Edge", bool]: + anchor_values = WaylandWindow.extract_anchor_values(string.lower()) + return { + GtkLayerShell.Edge.TOP: "top" in anchor_values, + GtkLayerShell.Edge.RIGHT: "right" in anchor_values, + GtkLayerShell.Edge.BOTTOM: "bottom" in anchor_values, + GtkLayerShell.Edge.LEFT: "left" in anchor_values, + } + + @staticmethod + def extract_margin(input: str | Iterable[int]) -> dict["GtkLayerShell.Edge", int]: + margins = ( + extract_css_values(input.lower()) + if isinstance(input, str) + else input + if isinstance(input, (tuple, list)) and len(input) == 4 + else (0, 0, 0, 0) + ) + return { + GtkLayerShell.Edge.TOP: margins[0], + GtkLayerShell.Edge.RIGHT: margins[1], + GtkLayerShell.Edge.BOTTOM: margins[2], + GtkLayerShell.Edge.LEFT: margins[3], + } diff --git a/fabric/fabric/widgets/webview/__init__.py b/fabric/fabric/widgets/webview/__init__.py new file mode 100644 index 0000000..7894c5a --- /dev/null +++ b/fabric/fabric/widgets/webview/__init__.py @@ -0,0 +1,3 @@ +from .webview import WebView + +__all__ = ["WebView"] diff --git a/fabric/fabric/widgets/webview/javascript_bridge.js b/fabric/fabric/widgets/webview/javascript_bridge.js new file mode 100644 index 0000000..f2b4c79 --- /dev/null +++ b/fabric/fabric/widgets/webview/javascript_bridge.js @@ -0,0 +1,106 @@ +/* + fabric javascript<->python bridge core + a bridging layer between python and javascript for GtkWebKit +*/ + +/** + * issue a bridge call to fabric + * @param {string} function_name + * @param {array} args + * @param {number} id + * @returns {any} + */ +function doInvokeWebkitBridgeCall(function_name, args, id) { + if (!window.webkit) { + throw new Error( + "window.webkit is not defined, can't call " + function_name + ); + } + return window.webkit.messageHandlers.fabricJsBridge.postMessage( + JSON.stringify([function_name, args, id]) + ); +} + +window.fabric = { + _init_called: false, + _pool: {}, + bridge: {}, + init: () => { + if (window.fabric._init_called) { + return; + } + window.fabric._init_called = true; + window.dispatchEvent(new CustomEvent("bridgeReady")); + }, + + /** + * create a bridge object for a foreign python function + * @param {Map} func_list + * {func: string, args: Array} + */ + createBridge: (func_list) => { + for (var i = 0; i < func_list.length; i++) { + var element = func_list[i]; + var func_name = element.func; + var args = element.args; + var func_hierarchy = func_name.split("."); + var function_name = func_hierarchy.pop(); + var nested_object = func_hierarchy.reduce((obj, prop) => { + if (!obj[prop]) { + obj[prop] = {}; + } + return obj[prop]; + }, window.fabric.bridge); + var func_body = ` + var __id__ = (Math.random() + "").substring(2); + + doInvokeWebkitBridgeCall("${func_name}", arguments, __id__); + var promise = new Promise( + function(resolve, reject) { + window.fabric.checkReturnValue("${func_name}", resolve, reject, __id__); + } + ); + + return promise; + `; + nested_object[function_name] = new Function(args, func_body); + window.fabric._pool[func_name] = {}; + } + }, + + /** + * check the return value of a foreign function + * @param {string} func_name + * @param {function} resolve + * @param {function} reject + * @param {number} id + */ + checkReturnValue: (func_name, resolve, reject, id) => { + // this is not a good idea, but it works anyways + // a better way to implement this is to use a state managed model + // TODO: the above... + var check = setInterval(function () { + var return_obj = window.fabric._pool[func_name][id]; + if (return_obj) { + var value = return_obj.value; + var error = return_obj.error; + + delete window.fabric._pool[func_name][id]; + clearInterval(check); + + if (error) { + var python_error = JSON.parse(value); + var error = new Error(python_error.message); + error.name = python_error.name; + error.stack = python_error.stack; + + reject(error); + } else { + resolve(JSON.parse(value)); + } + } + }, 1); + }, +}; + +window.fabric.init(); diff --git a/fabric/fabric/widgets/webview/webview.py b/fabric/fabric/widgets/webview/webview.py new file mode 100644 index 0000000..fac7e34 --- /dev/null +++ b/fabric/fabric/widgets/webview/webview.py @@ -0,0 +1,279 @@ +import gi +import json +import inspect +import traceback +from loguru import logger +from collections.abc import Iterable, Callable +from typing import Literal, overload +from fabric.widgets.widget import Widget +from fabric.utils.helpers import get_relative_path + +gi.require_version("Gtk", "3.0") +gi.require_version("WebKit2", "4.1") +from gi.repository import Gtk, GLib, WebKit2 + + +def get_javascript_bridge_code() -> str: + with open(get_relative_path("./javascript_bridge.js"), "r") as f: + return f.read() + + +JAVASCRIPT_BRIDGE_CODE = get_javascript_bridge_code() + + +class JavaScriptBridge: + def __init__( + self, + webview: "WebView", + content_manager: WebKit2.UserContentManager, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self._registered_functions: dict[str, Callable] = {} + self._bridge_initialized: bool = False + + self.webview = webview + self.content_manager = content_manager + self.content_manager.register_script_message_handler("fabricJsBridge") + self.content_manager.connect("script-message-received", self.on_javascript_call) + self.webview.connect("load_changed", self.on_load_finish) # odd signal name + + def on_load_finish(self, _: WebKit2.WebView, status: WebKit2.LoadEvent): + # inject javascript bridge as soon as the page is loaded + if status == WebKit2.LoadEvent.FINISHED: + self.webview.run_javascript( + JAVASCRIPT_BRIDGE_CODE, + callback=lambda *args: [ + self.expose_function( + func, func_name + ) # handle unregistered functions (exposed before bridge init) + for func_name, func in self._registered_functions.items() + ], + ) + self._bridge_initialized = True + return + + def expose_function(self, func: Callable, func_name: str | None = None): + name = func_name or func.__name__ + self._registered_functions[name] = func + args = list(inspect.getfullargspec(func).args) + if self._bridge_initialized: + return self.webview.run_javascript( + f"window.fabric.createBridge([{{func: '{name}', args: {args}}}])" + ) + return # on_load_finish should handle this + + def on_javascript_call( + self, + _: WebKit2.UserContentManager, + js_result: WebKit2.JavascriptResult, + ): + func_name, func_args, value_id = json.loads( + js_result.get_js_value().to_string() # a serialized json string is what we expect + ) + func_name: str + func_args: dict[str, str] + value_id: int + func = self._registered_functions.get(func_name) + if not func: + return logger.warning( + f"[WebView][JavaScriptBridge] javascript is trying to call a function ({func_name}) that doesn't exist" + ) + try: + GLib.Thread.new( + "call-from-js", + self.do_javascript_callback, + func_name, + func, + func_args, + value_id, + ) + except Exception as e: + logger.warning( + f"[WebView][JavaScriptBridge] can't create a thread for calling a function {func_name}: {e}" + ) + + def do_javascript_callback( + self, func_name: str, func: Callable, args: dict[str, str], value_id: int + ): + logger.debug( + f"[WebView][JavaScriptBridge] calling python function from javascript, function name {func_name} with args {args}" + ) + try: + result = func(*args.values()) + result = json.dumps(result).replace("\\", "\\\\").replace("'", "\\'") + code = f'window.fabric._pool["{func_name}"]["{value_id}"] = {{value: \'{result}\'}}' + except Exception as e: + print(traceback.format_exc()) + error = { + "message": str(e), + "name": type(e).__name__, + "stack": traceback.format_exc(), + } + result = json.dumps(error).replace("\\", "\\\\").replace("'", "\\'") + code = f"window.fabric._pool['{func_name}']['{value_id}'] = {{error: true, value: '{result}'}}" + return self.webview.run_javascript(code) + + +class WebView(WebKit2.WebView, Widget): + """ + a widget for loading a web page, it can load local and remote pages + it provides a bridge to communicate with python from javascript (and vice versa) + --- + ## `.bridge` + this object will hold a field for interacting with the javascript + bridge, the field name is `bridge`, it will be set only if you set `open_bridge` to `True` + otherwise it will be set to `None` + + ## exposing functions + to expose a python function you can use the method `expose_function` which's a member of the `bridge` field + + ### security warning + using the bridge with non-local pages is not recommended for security reasons + """ + + @overload + def __init__( + self, + url: None = None, + html: None = None, + open_bridge: bool = True, + open_inspector: bool = False, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + @overload + def __init__( + self, + url: str | None = None, + html: None = None, + open_bridge: bool = True, + open_inspector: bool = False, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + @overload + def __init__( + self, + url: None = None, + html: str | None = None, + open_bridge: bool = True, + open_inspector: bool = False, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): ... + + def __init__( + self, + url: str | None = None, + html: str | None = None, + open_bridge: bool = True, + open_inspector: bool = False, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + WebKit2.WebView.__init__( + self, # type: ignore + user_content_manager=WebKit2.UserContentManager(), + ) + Widget.__init__( + self, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + if url is not None and html is not None: + raise ValueError("you can't pass both url and html") + + self.bridge = ( + JavaScriptBridge(self, self.get_user_content_manager()) + if open_bridge is True + else None + ) + + if url is not None: + if self.bridge is not None and url.startswith(("http://", "https://")): + logger.warning( + "[WebView] it's not generally a good idea to expose a javascript interface for the world wide web, consider closing the javascript bridge" + ) + self.load_uri(url) + elif html is not None: + self.load_html(html, None) + + self.get_settings().set_enable_developer_extras(True) + if open_inspector is True: + self.open_inspector() + + def open_inspector(self): + self.get_inspector().show() diff --git a/fabric/fabric/widgets/widget.py b/fabric/fabric/widgets/widget.py new file mode 100644 index 0000000..c9a2260 --- /dev/null +++ b/fabric/fabric/widgets/widget.py @@ -0,0 +1,309 @@ +import gi +from typing import overload, Literal +from fabric.utils import compile_css, get_enum_member +from fabric.core.service import Service, Property +from collections.abc import Iterable + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GdkPixbuf + +# shhhh +import logging + +logging.captureWarnings(True) +logging.getLogger("gi.overrides").setLevel(logging.ERROR) + + +CURSOR_TYPE = Literal[ + "default", + "help", + "pointer", + "context-menu", + "progress", + "wait", + "cell", + "crosshair", + "text", + "vertical-text", + "alias", + "copy", + "no-drop", + "move", + "not-allowed", + "grab", + "grabbing", + "all-scroll", + "col-resize", + "row-resize", + "n-resize", + "e-resize", + "s-resize", + "w-resize", + "ne-resize", + "nw-resize", + "sw-resize", + "se-resize", + "ew-resize", + "ns-resize", + "nesw-resize", + "nwse-resize", + "zoom-in", + "zoom-out", +] + +EVENT_TYPE = Literal[ + "exposure", + "pointer-motion", + "pointer-motion-hint", + "button-motion", + "button-1-motion", + "button-2-motion", + "button-3-motion", + "button-press", + "button-release", + "key-press", + "key-release", + "enter-notify", + "leave-notify", + "focus-change", + "structure", + "property-change", + "visibility-notify", + "proximity-in", + "proximity-out", + "substructure", + "scroll", + "touch", + "smooth-scroll", + "touchpad-gesture", + "tablet-pad", + "all", +] + + +class Widget(Gtk.Widget, Service): + """the base widget, all other widgets should inherit from this class""" + + @Property(Gtk.Align, "read-write", default_value=Gtk.Align.BASELINE) + def v_align(self): + return self.get_valign() + + @v_align.setter + def v_align( + self, value: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align + ): + self.set_valign(get_enum_member(Gtk.Align, value, {}, Gtk.Align.BASELINE)) + + @Property(Gtk.Align, "read-write", default_value=Gtk.Align.BASELINE) + def h_align(self): + return self.get_halign() + + @h_align.setter + def h_align( + self, value: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align + ): + self.set_halign(get_enum_member(Gtk.Align, value, {}, Gtk.Align.BASELINE)) + + @Property(bool, default_value=False) + def v_expand(self): + return self.get_vexpand() + + @v_expand.setter + def v_expand(self, value: bool): + return self.set_vexpand(value) + + @Property(bool, default_value=False) + def h_expand(self): + return self.get_hexpand() + + @h_expand.setter + def h_expand(self, value: bool): + return self.set_hexpand(value) + + @Property(list[str], "read-write") + def style_classes(self) -> list[str]: + return self.get_style_context().list_classes() or [] + + @style_classes.setter + def style_classes(self, classes: Iterable[str] | str) -> None: + self.remove_style_class(self.style_classes) + self.add_style_class(classes) + return + + def __init__( + self, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.Widget.__init__(self) # type: ignore + Service.__init__(self, **kwargs) + + self._style_provider: Gtk.CssProvider | None = None + self._cursor: Gdk.Cursor | None = None + + self.set_name(name) if name is not None else None + self.set_tooltip_text(tooltip_text) if tooltip_text is not None else None + self.set_tooltip_markup(tooltip_markup) if tooltip_markup is not None else None + + if v_align: + self.v_align = v_align + if h_align: + self.h_align = h_align + + self.v_expand = v_expand + self.h_expand = h_expand + + if size is not None: + self.set_size_request(*((size, size) if isinstance(size, int) else size)) + + if style_classes: + self.add_style_class(style_classes) # to not override default classes + + if style: + self.set_style(style) + + self.show_all() if all_visible is True else self.show() if visible is True else None + + def set_alignment( + self, + orientation: Literal["v", "vertical", "h", "horizontal"] | Gtk.Orientation, + alignment: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align, + ): + ornt = get_enum_member( + Gtk.Orientation, orientation, {"v": "vertical", "h": "horizontal"} + ) + align = get_enum_member(Gtk.Align, alignment) + return ( + self.set_valign if ornt == Gtk.Orientation.VERTICAL else self.set_halign + )(align) + + def set_style( + self, + style: str, + compile: bool = True, + append: bool = False, + add_brackets: bool = True, + ) -> None: + """ + set the styles for this widget from a string + + :param style: the css style + :type style: str + :param compile: to compile the style, defaults to True + :type compile: bool, optional + :param append: appends this style to other loaded styles (if any), if this is set to False it will clear all other styles before applying, defaults to False + :type append: bool, optional + :param add_brackets: add brackets to the style if they are missing (e.g. `padding: 10; something-useful: unset;` was passed; will be converted to `* { padding: 10; something-useful: unset; }`), defaults to True + """ + style = ( + f"* {{ {style} }}" + if "{" not in style or "}" not in style and add_brackets is True + else style + ) + style = compile_css(style) if compile is True else style + + self.get_style_context().remove_provider( + self._style_provider + ) if self._style_provider is not None and append is False else None + + self._style_provider = Gtk.CssProvider() + self._style_provider.load_from_data(style.encode()) # type: ignore + self.get_style_context().add_provider( + self._style_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + ) + return + + @overload + def set_cursor( + self, + cursor: CURSOR_TYPE | Gdk.CursorType | Gdk.Cursor | None, + pixbuf: None = None, + x_offset: int = 0, + y_offset: int = 0, + ): ... + + @overload + def set_cursor( + self, + cursor: None, + pixbuf: GdkPixbuf.Pixbuf | None = None, + x_offset: int = 0, + y_offset: int = 0, + ): ... + + def set_cursor( + self, + cursor: CURSOR_TYPE | Gdk.CursorType | Gdk.Cursor | None, + pixbuf: GdkPixbuf.Pixbuf | None = None, + x_offset: int = 0, + y_offset: int = 0, + ): + display = Gdk.Display.get_default() + window = self.get_window() + if display is None or window is None: + raise RuntimeError( + f"can't set new cursor, one of `display` or `window` is None ({display}, {window})" + ) + elif not self.is_hovered(): + return window.set_cursor(Gdk.Cursor.new_from_name(display, "default")) + + if pixbuf is not None: + cursor = Gdk.Cursor.new_from_pixbuf(display, pixbuf, x_offset, y_offset) + elif isinstance(cursor, Gdk.Cursor): + pass + elif isinstance(cursor, str): + cursor = Gdk.Cursor.new_from_name(display, cursor) + elif isinstance(cursor, Gdk.CursorType): + cursor = Gdk.Cursor.new_for_display(display, cursor) + else: + cursor = Gdk.Cursor.new_from_name(display, "default") + + return window.set_cursor(cursor) + + def is_hovered(self, event: Gdk.EventAny | None = None) -> bool: + x, y = self.get_pointer() # type: ignore + allocation = self.get_allocation() + if event: + x, y = event.get_coords() # type: ignore + return 0 < x < allocation.width and 0 < y < allocation.height # type: ignore + + def add_style_class(self, class_name: str | Iterable[str]): + if isinstance(class_name, str): + class_name = class_name.strip().lstrip().split() + for klass in class_name: + self.get_style_context().add_class(klass) + return + + def remove_style_class(self, class_name: str | Iterable[str]): + if isinstance(class_name, str): + class_name = class_name.strip().lstrip().split() + for klass in class_name: + self.get_style_context().remove_class(klass) + return + + def add_events( + self, events: EVENT_TYPE | Gdk.EventMask | Iterable[EVENT_TYPE | Gdk.EventMask] + ): + _events: int = 0 + events_map: dict[str, str] = { + x: (x if x != "all" else "all-events") + "-mask" + for x in EVENT_TYPE.__args__ + } + for event in (events,) if not isinstance(events, (tuple, list)) else events: + _events |= get_enum_member(Gdk.EventMask, event, events_map, 0) # type: ignore + return super().add_events(_events) diff --git a/fabric/fabric/widgets/window.py b/fabric/fabric/widgets/window.py new file mode 100644 index 0000000..5a95845 --- /dev/null +++ b/fabric/fabric/widgets/window.py @@ -0,0 +1,163 @@ +import gi +import cairo +from loguru import logger +from typing import Literal, Self, Any +from collections.abc import Callable, Iterable +from fabric.core.service import Property +from fabric.core.application import Application +from fabric.utils.helpers import get_enum_member, bulk_replace +from fabric.widgets.container import Container + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib + + +class Window(Gtk.Window, Container): + @Property(Application, install=False) + def application(self) -> Application: + return self.get_application() # type: ignore + + @Property(bool, "read-write", default_value=False) + def pass_through(self) -> bool: + return self._pass_through + + @pass_through.setter + def pass_through(self, pass_through: bool = False): + self._pass_through = pass_through + + self.input_shape_combine_region( + cairo.Region(cairo.RectangleInt()) if pass_through is True else None + ) + + return + + def __init__( + self, + title: str = "fabric", + type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL, + child: Gtk.Widget | None = None, + pass_through: bool = False, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + self._pass_through = pass_through + + Gtk.Window.__init__( + self, # type: ignore + title=title, + type=get_enum_member( + Gtk.WindowType, type, {"top-level": "toplevel"}, Gtk.WindowType.TOPLEVEL + ), + ) + Container.__init__( + self, + child, + name, + visible, + all_visible, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + None, + **kwargs, + ) + + self.set_default_size( + *((size, size) if isinstance(size, int) else size) + ) if size is not None else None + + self._key_press_handler: int = 0 + self._keybinding_handlers: dict[ + str, list[tuple[Callable[[Self, Any], Any], int]] + ] = {} + + self.pass_through = pass_through + + def do_handle_key_press_event(self, _, event): + if not ( + keybind_entry := self._keybinding_handlers.get( + " ".join( + bulk_replace( + Gtk.accelerator_name(event.keyval, event.state).strip(), + ["", "", "", "", ""], + [" ", "Shift ", "Ctrl ", "Super ", "Alt "], + ).split() + ) + ) + ): + return + for kbh in keybind_entry: + kbh[0](self, event) + return + + def do_post_kebinding_removal(self): + for kbn, kbd in self._keybinding_handlers.copy().items(): + if not kbd: + self._keybinding_handlers.pop(kbn) + if not self._keybinding_handlers: + self.disconnect_by_func(self.do_handle_key_press_event) + self._key_press_handler = 0 + return + + def add_keybinding(self, keybind: str, callback: Callable[[Self, Any], Any]) -> int: + handler = GLib.random_int() + + keybind_entry = self._keybinding_handlers.setdefault(keybind, []) + keybind_entry.append((callback, handler)) + if not self._key_press_handler: + self._key_press_handler = self.connect( + "key-press-event", self.do_handle_key_press_event + ) + + return handler + + def remove_keybinding(self, reference: int | Callable | str): + if isinstance(reference, (int, Callable)): + for kbn, kbd in self._keybinding_handlers.items(): + for bindref in kbd: + if reference in bindref: + kbd.remove(bindref) + return self.do_post_kebinding_removal() + self._keybinding_handlers.pop(reference, None) + return self.do_post_kebinding_removal() + + # overrides + def do_handle_post_show_request(self) -> None: + if not self.get_children(): + logger.warning( + "[Window] showing an empty window is not recommended, some compositors might freak out." + ) + return + + def show(self): + super().show() + return self.do_handle_post_show_request() + + def show_all(self): + super().show_all() + return self.do_handle_post_show_request() + + def do_size_allocate(self, alloc): + Gtk.Window.do_size_allocate(self, alloc) # type: ignore + + return self.set_pass_through(self._pass_through) diff --git a/fabric/fabric/widgets/x11.py b/fabric/fabric/widgets/x11.py new file mode 100644 index 0000000..25f7558 --- /dev/null +++ b/fabric/fabric/widgets/x11.py @@ -0,0 +1,351 @@ +import gi +from enum import Enum +from loguru import logger +from collections.abc import Iterable +from typing import no_type_check, Literal +from fabric.core.service import Property +from fabric.widgets.window import Window +from fabric.utils import get_enum_member, extract_css_values, idle_add + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +try: + from Xlib import X as XServer + from Xlib.display import Display as XDisplay + from Xlib.xobject.drawable import Window as XWindow +except Exception: + logger.warning( + "using Fabric under X11 might require extra dependencies for extra features, consider installing the python package Xlib" + ) + + +class X11WindowLayer(Enum): + TOP = 1 + BOTTOM = 2 + + +class X11WindowGeometry(Enum): + CENTER = 1 + CENTER_AUTO = 2 + TOP = 3 + TOP_LEFT = 4 + TOP_RIGHT = 5 + BOTTOM = 6 + BOTTOM_LEFT = 7 + BOTTOM_RIGHT = 8 + LEFT = 9 + RIGHT = 10 + + +class X11Window(Window): + # it's nothing fancy, but we have to work with what we have (so far) + """ + a dockable window for X11/Xorg + + ### NOTE + none of the properties this window takes is guaranteed to work + since EMWH is not a standard protocol across all window managers + """ + + @Property(X11WindowLayer, "read-write") + def layer(self) -> X11WindowLayer: + return self._layer + + @layer.setter + def layer(self, value: Literal["top", "bottom"] | X11WindowLayer): + self._layer = get_enum_member(X11WindowLayer, value, default=X11WindowLayer.TOP) + self.do_dispatch_layer() + return + + @Property(tuple, "read-write") + def margin(self) -> tuple[int, int, int, int]: + return self._margin + + @margin.setter + def margin(self, value: str | Iterable[int]): + # (top, right, bottom, left) + self._margin = ( + extract_css_values(value) + if isinstance(value, str) + else value + if isinstance(value, (tuple, list)) and len(value) == 4 + else (0, 0, 0, 0) + ) # type: ignore + self.do_dispatch_geometry() + return + + @Property(X11WindowGeometry, "read-write") + def geometry(self) -> X11WindowGeometry: + return self._geometry + + @geometry.setter + def geometry( + self, + value: Literal[ + "center", + "center-auto", + "top-left", + "top", + "top-right", + "left", + "right", + "bottom-left", + "bottom", + "bottom-right", + ] + | X11WindowGeometry, + ): + self._geometry = get_enum_member( + X11WindowGeometry, value, default=X11WindowGeometry.TOP + ) + if self._geometry in (X11WindowGeometry.CENTER, X11WindowGeometry.CENTER_AUTO): + # don't use our way to handle window centering, gdk can do that for us + self.handler_disconnect( + self._size_allocate_hook + ) if self._size_allocate_hook is not None else None + self.set_position(Gtk.WindowPosition.CENTER_ALWAYS) + else: + self.do_dispatch_geometry() + if not self._size_allocate_hook: + self._size_allocate_hook = self.connect( + "size-allocate", lambda _, __: self.do_dispatch_geometry() + ) # type: ignore + return + + def __init__( + self, + type_hint: Literal[ + "normal", + "dialog", + "menu", + "toolbar", + "splashscreen", + "utility", + "dock", + "desktop", + "dropdown-menu", + "popup-menu", + "tooltip", + "notification", + "combo", + "dnd", + ] + | Gdk.WindowTypeHint = Gdk.WindowTypeHint.DOCK, + geometry: Literal[ + "center", + "center-auto", + "top-left", + "top", + "top-right", + "left", + "right", + "bottom-left", + "bottom", + "bottom-right", + ] + | X11WindowGeometry = X11WindowGeometry.TOP, + margin: str | Iterable[int] = "0px 0px 0px 0px", + layer: Literal["top", "bottom"] | X11WindowLayer = X11WindowLayer.TOP, + sticky: bool = True, + focusable: bool = True, + resizable: bool = False, + decorated: bool = False, + taskbar_hint: bool = False, + pager_hint: bool = False, + title: str = "fabric", + type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL, + child: Gtk.Widget | None = None, + pass_through: bool = False, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Window.__init__( + self, + title, + type, + child, + pass_through, + name, + False, + False, + style, + style_classes, + tooltip_text, + tooltip_markup, + h_align, + v_align, + h_expand, + v_expand, + size, + **kwargs, + ) + + self._margin: tuple[int, int, int, int] = (0, 0, 0, 0) + self._layer = X11WindowLayer.TOP + self._geometry = X11WindowGeometry.TOP + self._size_allocate_hook: int | None = None + + self._display: Gdk.Display + self._rectangle: Gdk.Rectangle + self._scale_factor: int + + # for extra functionality + self._xdisplay: "XDisplay | None" = None + self._xwindow: "XWindow | None" = None + self._xid: int = 0 + + self.set_type_hint( + get_enum_member( + Gdk.WindowTypeHint, type_hint, default=Gdk.WindowTypeHint.DOCK + ) + ) + self.set_accept_focus(focusable) + self.set_skip_taskbar_hint(not taskbar_hint) + self.set_skip_pager_hint(not pager_hint) + self.set_decorated(decorated) + self.set_resizable(resizable) + self.stick() if sticky else self.unstick() + # all aboard... + self.do_initialize() + self.do_initialize_x_backend() + + self.layer = layer + self.margin = margin + self.geometry = geometry + + self.show_all() if all_visible is True else self.show() if visible is True else None + + def do_initialize(self): + self._display, self._rectangle, self._scale_factor = self.do_get_display_props() + visual = self._display.get_default_screen().get_rgba_visual() + self.set_visual(visual) # to get alpha w/o a X11 compositor + self.set_app_paintable(True) + return + + def do_initialize_x_backend(self): + try: + self._xdisplay = XDisplay() + except Exception: + + def requires_xlib(*_): + raise RuntimeError( + "this method requires the python package `Xlib` to be installed" + ) + + self.steal_input_soft = requires_xlib # type: ignore + return + + def on_draw(*_): + # as of my test's results, the draw signal is + # the only signal emitted when the window is fully realized + # and that's needed so we don't face a unexpected behavior later + self._xid = self.get_window().get_xid() # type: ignore + self._xwindow = self._xdisplay.create_resource_object("window", self._xid) # type: ignore + # thank you, goodbye! + self.disconnect_by_func(on_draw) + + self.connect("draw", on_draw) + + def steal_input(self) -> bool: + if not (win := self.get_window()): + return False + idle_add(lambda *_: Gdk.keyboard_grab(win, False, Gdk.CURRENT_TIME)) + return True + + def unsteal_input(self): + return idle_add(lambda *_: Gdk.keyboard_ungrab(Gdk.CURRENT_TIME)) + + def steal_input_soft(self, can_release: bool = False) -> bool: + if ( + not (self.get_realized() and self.is_visible()) + or not self._xdisplay + or not self._xwindow + ): + return False + self._xdisplay.set_input_focus( + self._xwindow, # type: ignore + XServer.RevertToPointerRoot if can_release else XServer.RevertToNone, + XServer.CurrentTime, + ) + self._xdisplay.sync() + self._xdisplay.flush() + + return True + + def do_get_display_props(self) -> tuple[Gdk.Display, Gdk.Rectangle, int]: + display = Gdk.Display.get_default() + rectangle = display.get_primary_monitor().get_geometry() + scale_factor = display.get_primary_monitor().get_scale_factor() + return display, rectangle, scale_factor # type: ignore + + def do_dispatch_layer(self): + self.set_keep_above(self._layer == X11WindowLayer.TOP) + self.set_keep_below(self._layer == X11WindowLayer.BOTTOM) + return + + @no_type_check + def do_dispatch_geometry(self): + # move the window to match the asked geometry + # this requires us knowing the geometry and the margin + if self._rectangle is None: + self._display, self._rectangle, self._scale_factor = ( + self.do_get_display_props() + ) + aloc_size, natural_size = self.get_allocated_size() + aloc_width, aloc_height = aloc_size.width, aloc_size.height + + x = y = 0 + match self._geometry: + # case X11WindowGeometry.CENTER_AUTO: + # x = self._rectangle.width // 2 - aloc_width // 2 + # y = self._rectangle.height // 2 - aloc_height // 2 + case X11WindowGeometry.TOP: + y = 0 + x = self._rectangle.width // 2 - aloc_width // 2 + case X11WindowGeometry.TOP_LEFT: + x, y = 0, 0 + case X11WindowGeometry.TOP_RIGHT: + y = 0 + x = self._rectangle.width - aloc_width + case X11WindowGeometry.BOTTOM: + x = self._rectangle.width // 2 - aloc_width // 2 + y = self._rectangle.height - aloc_height + case X11WindowGeometry.BOTTOM_LEFT: + x = 0 + y = self._rectangle.height - aloc_height + case X11WindowGeometry.BOTTOM_RIGHT: + x = self._rectangle.width - aloc_width + y = self._rectangle.height - aloc_height + case X11WindowGeometry.LEFT: + x = 0 + y = self._rectangle.height // 2 - aloc_height // 2 + case X11WindowGeometry.RIGHT: + x = self._rectangle.width - aloc_width + y = self._rectangle.height // 2 - aloc_height // 2 + case _: + return False + + x_margin = self._margin[1] - self._margin[3] + y_margin = self._margin[0] - self._margin[2] + x += self._rectangle.x + y += self._rectangle.y + x = x + x_margin + y = y + y_margin + + return self.move(x, y) diff --git a/fabric/flake.lock b/fabric/flake.lock new file mode 100644 index 0000000..92cd665 --- /dev/null +++ b/fabric/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1733261153, + "narHash": "sha256-eq51hyiaIwtWo19fPEeE0Zr2s83DYMKJoukNLgGGpek=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b681065d0919f7eb5309a93cea2cfa84dec9aa88", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/fabric/flake.nix b/fabric/flake.nix new file mode 100644 index 0000000..5ca9bf7 --- /dev/null +++ b/fabric/flake.nix @@ -0,0 +1,71 @@ +{ + description = '' + next-gen framework for building desktop widgets using Python + (check the rewrite branch for progress) + ''; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = { + nixpkgs, + utils, + ... + }: + utils.lib.eachDefaultSystem ( + system: let + overlay = final: prev: { + pythonPackagesExtensions = + prev.pythonPackagesExtensions + ++ [ + (python-final: python-prev: { + python-fabric = prev.callPackage ./default.nix {}; + }) + ]; + }; + + pkgs = nixpkgs.legacyPackages.${system}.extend overlay; + in { + overlays.default = overlay; + formatter = pkgs.nixfmt-rfc-style; + packages = { + default = pkgs.python3Packages.python-fabric; + run-widget = pkgs.callPackage ./run-widget.nix {}; + }; + + devShells = { + default = pkgs.mkShell { + name = "fabric-shell"; + packages = with pkgs; [ + ruff + gtk3 + gtk-layer-shell + cairo + gobject-introspection + libdbusmenu-gtk3 + gdk-pixbuf + gnome-bluetooth + cinnamon-desktop + (python3.withPackages ( + ps: + with ps; [ + setuptools + wheel + build + click + pycairo + pygobject3 + pygobject-stubs + loguru + psutil + python-fabric + ] + )) + ]; + }; + }; + } + ); +} diff --git a/fabric/pyproject.toml b/fabric/pyproject.toml new file mode 100644 index 0000000..d2ec09f --- /dev/null +++ b/fabric/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "fabric" +version = "0.0.2" +dependencies = ["click", "loguru", "pycairo", "PyGObject==3.50.0"] +requires-python = ">=3.11" +authors = [ + { name = "Yousef El-Darsh", email = "yousef.eldarsh@gmail.com" }, + { name = "Kai Read", email = "ready@kairead.com" }, +] +maintainers = [ + { name = "Yousef El-Darsh", email = "yousef.eldarsh@gmail.com" }, + { name = "Kai Read", email = "ready@kairead.com" }, +] +description = "Next-Gen python framework for creating system widgets on *Nix systems!" +readme = "README.md" +keywords = [ + "widgets", + "gtk", + "linux", + "pygobject", + "bar", + "statusbar", + "panel", + "dock", +] +classifiers = ["Programming Language :: Python"] + +[project.optional-dependencies] +system-status = ["psutil"] + +[tool.setuptools.package-data] +"*" = ["*.xml", "*.js"] + +[project.urls] +"Website" = "https://ffpy.org" +"Documentation" = "https://wiki.ffpy.org" +"Website (mirror)" = "https://fabric-widgets.org" +"Documentation (mirror)" = "https://wiki.fabric-widgets.org" +"Source" = "https://github.com/Fabric-Development/fabric" +"Bug Tracker" = "https://github.com/Fabric-Development/fabric/issues" +"Changelog" = "https://github.com/Fabric-Development/fabric/blob/master/CHANGELOG.md" diff --git a/fabric/requirements.txt b/fabric/requirements.txt new file mode 100644 index 0000000..8bb9689 --- /dev/null +++ b/fabric/requirements.txt @@ -0,0 +1,6 @@ +click +loguru +psutil +pycairo +PyGObject==3.50.0 +pygobject-stubs diff --git a/fabric/ruff.toml b/fabric/ruff.toml new file mode 100644 index 0000000..a0cefdd --- /dev/null +++ b/fabric/ruff.toml @@ -0,0 +1,10 @@ +line-length = 88 +indent-width = 4 + +[lint] +# ignore gi imports not begin at the top of the file +ignore = ["E402"] + +[format] +quote-style = "double" +indent-style = "space" diff --git a/fabric/run-widget.nix b/fabric/run-widget.nix new file mode 100644 index 0000000..ebcbdfc --- /dev/null +++ b/fabric/run-widget.nix @@ -0,0 +1,58 @@ +{ + python3, + stdenv, + gtk3, + gtk-layer-shell, + cairo, + gobject-introspection, + libdbusmenu-gtk3, + gdk-pixbuf, + gnome-bluetooth, + cinnamon-desktop, + librsvg, + extraPythonPackages ? [], + extraBuildInputs ? [], +}: let + python = python3.withPackages ( + ps: + with ps; + [ + click + pycairo + pygobject3 + loguru + psutil + python-fabric + pygobject-stubs + ] + ++ extraPythonPackages + ); +in + stdenv.mkDerivation { + name = "run-widget"; + propagatedBuildInputs = + [ + gtk3 + gtk-layer-shell + cairo + gobject-introspection + libdbusmenu-gtk3 + gdk-pixbuf + librsvg + gnome-bluetooth + cinnamon-desktop + ] + ++ extraBuildInputs; + phases = ["installPhase"]; + installPhase = '' + mkdir -p $out/bin + cat > $out/bin/run-widget << EOF + #!/bin/sh + GI_TYPELIB_PATH=$GI_TYPELIB_PATH \ + GDK_PIXBUF_MODULE_FILE="$GDK_PIXBUF_MODULE_FILE" \ + ${python.interpreter} "\$@" + EOF + chmod +x $out/bin/run-widget + ''; + meta.mainProgram = "run-widget"; + } diff --git a/fabric/scripts/install_libcvc/install_libcvc.sh b/fabric/scripts/install_libcvc/install_libcvc.sh new file mode 100755 index 0000000..fedcfee --- /dev/null +++ b/fabric/scripts/install_libcvc/install_libcvc.sh @@ -0,0 +1,77 @@ +# !/usr/bin/env bash + +cinnamon_git="https://github.com/linuxmint/cinnamon-desktop" +cinnamon_git_name="cinnamon-desktop" +cwd="$(pwd)" + +pre_check_exists() { + # check if Cvc typelibs are installed + echo "checking if libcvc is already installed..." + if ls /usr/lib/girepository-1.0/Cvc-1.0.typelib 1> /dev/null 2>&1; + then + echo "typelib files are already installed, reinstall? [y/n] (default: n)"; + read answer + if [ "$answer" != "${answer#[Yy]}" ]; then + echo "do you want to remove existing files? [y/n] (default: n)" + read answer2 + if [ "$answer2" != "${answer2#[Yy]}" ]; then + sudo rm -rf /usr/lib/girepository-1.0/Cvc-1.0.typelib + sudo rm -rf /usr/share/gir-1.0/Cvc-1.0.gir + sudo rm -rf /usr/lib/libcvc.s* + fi + else + exit 0 + fi + else + echo "typelib files not found, check meson logs"; + fi +} + +prepare() { + pre_check_exists + mkdir -p ignore_me_libcvc_build + cd ignore_me_libcvc_build + git clone ${cinnamon_git} + patch -i ../$(dirname $0)/meson.build.patch ${cwd}/ignore_me_libcvc_build/${cinnamon_git_name}/meson.build + # ^ we shorten the build time by removing unused build targets +} + +build() { + # patch and build + cd ${cwd}/ignore_me_libcvc_build/${cinnamon_git_name} + meson build + cd build + ninja +} + +check_gir() { + # check if gir files got generated + cd ${cwd}/ignore_me_libcvc_build/${cinnamon_git_name}/build + if ls libcvc/*.gir 1> /dev/null 2>&1; + then echo "gir files got generated, proceeding to install"; + else + echo "gir files not found, check meson logs"; + exit 1 + fi +} + +install_gir() { + # install gir files + cd ${cwd}/ignore_me_libcvc_build/${cinnamon_git_name}/build + meson install + cd libcvc + sudo cp -r libcvc.s* /usr/lib/ + sudo cp Cvc-1.0.typelib /usr/lib/girepository-1.0/ + sudo cp Cvc-1.0.gir /usr/share/gir-1.0/ +} + +clean_exit() { + rm -rf ${cwd}/ignore_me_libcvc_build + exit 0 +} + +prepare +build +check_gir +install_gir +clean_exit diff --git a/fabric/scripts/install_libcvc/meson.build.patch b/fabric/scripts/install_libcvc/meson.build.patch new file mode 100644 index 0000000..d08b0a8 --- /dev/null +++ b/fabric/scripts/install_libcvc/meson.build.patch @@ -0,0 +1,16 @@ +diff --git a/meson.build b/meson.build +index f0bfceb..e089432 100644 +--- a/meson.build ++++ b/meson.build +@@ -110,11 +110,7 @@ configure_file( + configuration: conf + ) + +-subdir('install-scripts') +-subdir('po') +-subdir('libcinnamon-desktop') + subdir('libcvc') +-subdir('schemas') + + + pnp_message = '@0@: @1@'.format( diff --git a/fabric/setup.py b/fabric/setup.py new file mode 100644 index 0000000..c68abca --- /dev/null +++ b/fabric/setup.py @@ -0,0 +1,41 @@ +from setuptools import setup, find_packages + +setup( + name="fabric", + version="0.0.2", + description="Next-Gen python framework for creating system widgets on *Nix systems!", + long_description=open("README.md").read(), + keywords=[ + "widgets", + "gtk", + "linux", + "pygobject", + "bar", + "statusbar", + "panel", + "dock", + ], + classifiers=["Programming Language :: Python"], + url="https://github.com/Fabric-Development/fabric.git", + project_urls={ + "Website": "https://ffpy.org", + "Documentation": "https://wiki.ffpy.org", + "Website (mirror)": "https://fabric-widgets.org", + "Documentation (mirror)": "https://wiki.fabric-widgets.org", + "Source": "https://github.com/Fabric-Development/fabric", + "Bug Tracker": "https://github.com/Fabric-Development/fabric/issues", + "Changelog": "https://github.com/Fabric-Development/fabric/blob/master/CHANGELOG.md", + }, + packages=find_packages(), + install_requires=[ + "click", + "loguru", + "pycairo", + "PyGObject==3.50.0", + ], + python_requires=">=3.11", + extras_require={ + "system-status": ["psutil"], + }, + package_data={"*": ["*.xml", "*.js"]}, +) diff --git a/fabric/tests/service.1.py b/fabric/tests/service.1.py new file mode 100644 index 0000000..5dd0338 --- /dev/null +++ b/fabric/tests/service.1.py @@ -0,0 +1,22 @@ +import unittest +from fabric.core import Service + + +class TestServiceInit(unittest.TestCase): + def testInitialize(self): + service = Service() + + def testPropertiesCount(self): + service = Service() + message = "length of properties should be zero for pure Service objects" + self.assertEqual(len(service.get_properties()), 0, message) + + def testSignalsCount(self): + service = Service() + message = "length of Signals should be zero for pure Service objects" + self.assertEqual(len(service.get_signal_names()), 0, message) + self.assertEqual(len(service.get_signal_ids()), 0, message) + + +if __name__ == "__main__": + unittest.main() diff --git a/fabric/tests/service.2.py b/fabric/tests/service.2.py new file mode 100644 index 0000000..e154372 --- /dev/null +++ b/fabric/tests/service.2.py @@ -0,0 +1,201 @@ +import unittest +from typing import Any +from fabric.core import Service, Property + + +class BaseService(Service): + int_prop = Property(int) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.int_prop = 42 + + +class TestServiceProperties(unittest.TestCase): + class InheritedService(BaseService): + str_prop = Property(str) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.str_prop = "42" + + class ServiceWithProperties(Service): + int_prop = Property(int) + str_prop = Property(str) + bool_prop = Property(bool, default_value=False) + boxed_prop = Property(object) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.int_prop = 42 + self.str_prop = "42" + self.bool_prop = True + self.boxed_prop = object + self._decor_prop: str = "decor-prop" + + @Property(str) + def decor_prop(self): + return self._decor_prop + + @decor_prop.setter + def decor_prop(self, value): + self._decor_prop = value + return + + def testOrphaned(self): + int_prop = Property(int) + str_prop = Property(str) + bool_prop = Property(bool, default_value=False) + boxed_prop = Property(object) + + int_prop = Any + str_prop = Any + bool_prop = Any + boxed_prop = Any + + for prop in [int_prop, str_prop, bool_prop, boxed_prop]: + self.assertEqual(prop, Any) + + def testOrphanedDifferentTypes(self): + int_prop = Property(int) + str_prop = Property(str) + bool_prop = Property(bool, default_value=False) + boxed_prop = Property(object) + + int_prop = 42 + str_prop = "42" + bool_prop = True + boxed_prop = object + + self.assertEqual(int_prop, 42) + self.assertEqual(str_prop, "42") + self.assertEqual(bool_prop, True) + self.assertEqual(boxed_prop, object) + + def testServicePropertiesSimple(self): + service = self.ServiceWithProperties() + self.assertEqual(service.int_prop, 42) + self.assertEqual(service.str_prop, "42") + self.assertEqual(service.bool_prop, True) + self.assertEqual(service.boxed_prop, object) + + def testServicePropertiesComplex(self): + service = self.ServiceWithProperties() + self.assertEqual(service.int_prop, 42) + self.assertEqual(service.str_prop, "42") + self.assertEqual(service.bool_prop, True) + self.assertEqual(service.boxed_prop, object) + + service["int-prop"] = 4002 + self.assertEqual(service.int_prop, 4002) + service["str-prop"] = "4002" + self.assertEqual(service.str_prop, "4002") + service["bool-prop"] = False + self.assertEqual(service.bool_prop, False) + service["boxed-prop"] = None + self.assertEqual(service.boxed_prop, None) + + self.assertEqual(service["int-prop"], 4002) + self.assertEqual(service["str-prop"], "4002") + self.assertEqual(service["bool-prop"], False) + self.assertEqual(service["boxed-prop"], None) + + def testPropertyNotify(self): + notifiedSignals: list[str] = [] + expectedOutput = [ + "int-prop", + "str-prop", + "bool-prop", + "boxed-prop", + "decor-prop", + ] + + def onNotify(propertyName: str): + notifiedSignals.append(propertyName) + + service = self.ServiceWithProperties( + notify_int_prop=lambda *args: onNotify("int-prop"), + notify_str_prop=lambda *args: onNotify("str-prop"), + notify_bool_prop=lambda *args: onNotify("bool-prop"), + notify_boxed_prop=lambda *args: onNotify("boxed-prop"), + notify_decor_prop=lambda *args: onNotify("decor-prop"), + ) + service.notify("decor-prop") # decorated properties doesn't get initialized + self.assertEqual(notifiedSignals, expectedOutput) + notifiedSignals.clear() + + service["int-prop"] = 4002 + service["str-prop"] = "4002" + service["bool-prop"] = False + service["boxed-prop"] = None + service["decor-prop"] = "decor" + self.assertEqual(notifiedSignals, expectedOutput) + + def testPropertyNotifyAndCheckValue(self): + notifiedSignals: dict[str, Any] = {} + expectedOutput = { + "int-prop": 42, + "str-prop": "42", + "bool-prop": True, + "boxed-prop": object, + "decor-prop": "decor-prop", + } + expectedModifiedOutput = { + "int-prop": 4002, + "str-prop": "4002", + "bool-prop": False, + "boxed-prop": None, + "decor-prop": "decor", + } + + def onNotify(self: Service, propertyName: str): + notifiedSignals[propertyName] = self[propertyName] + + service = self.ServiceWithProperties( + notify_int_prop=lambda self, *args: onNotify(self, "int-prop"), + notify_str_prop=lambda self, *args: onNotify(self, "str-prop"), + notify_bool_prop=lambda self, *args: onNotify(self, "bool-prop"), + notify_boxed_prop=lambda self, *args: onNotify(self, "boxed-prop"), + notify_decor_prop=lambda self, *args: onNotify(self, "decor-prop"), + ) + service.notify("decor-prop") + self.assertEqual(notifiedSignals, expectedOutput) + notifiedSignals.clear() + + service["int-prop"] = 4002 + service["str-prop"] = "4002" + service["bool-prop"] = False + service["boxed-prop"] = None + service["decor-prop"] = "decor" + self.assertEqual(notifiedSignals, expectedModifiedOutput) + notifiedSignals.clear() + del service + + service = self.ServiceWithProperties() + + for propertyName, _ in expectedOutput.items(): + service.connect( + f"notify::{propertyName}", + lambda self, *args, pName=propertyName: onNotify(self, pName), + ) + + service["int-prop"] = 4002 + service["str-prop"] = "4002" + service["bool-prop"] = False + service["boxed-prop"] = None + service["decor-prop"] = "decor" + self.assertEqual(notifiedSignals, expectedModifiedOutput) + + def testInheritedProperties(self): + service = self.InheritedService() + self.assertEqual(service.int_prop, 42) + self.assertEqual(service.str_prop, "42") + + service.int_prop = 4002 + service.str_prop = "4002" + self.assertEqual(service.int_prop, 4002) + self.assertEqual(service.str_prop, "4002") + + +if __name__ == "__main__": + unittest.main() diff --git a/fabric/tests/service.3.py b/fabric/tests/service.3.py new file mode 100644 index 0000000..d18d010 --- /dev/null +++ b/fabric/tests/service.3.py @@ -0,0 +1,311 @@ +import unittest +from fabric.core import Service, Signal + + +class TestServiceSignals(unittest.TestCase): + class ServiceWithSignals(Service): + int_signal = Signal("int-signal", "run-first", None, (int,)) + str_signal = Signal("str-signal", "run-first", None, (str,)) + bool_signal = Signal("bool-signal", "run-first", None, (bool,)) + boxed_signal = Signal("boxed-signal", "run-first", None, (object,)) + + @Signal + def complex_signal( + self, intValue: int, strValue: str, boolValue: bool, boxedObject: object + ): + return "signalCalled" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def testOrphaned(self): + int_signal = Signal("int-signal", "run-first", None, (int,)) + str_signal = Signal("str-signal", "run-first", None, (str,)) + bool_signal = Signal("bool-signal", "run-first", None, (bool,)) + boxed_signal = Signal("boxed-signal", "run-first", None, (object,)) + + @Signal + def complex_signal( + self, strValue: str, intValue: int, boolValue: bool, boxedObject: object + ): ... + + def testServiceSignalsConnect1(self): + service = self.ServiceWithSignals() + service.connect("int-signal", lambda *args: ...) + service.connect("str-signal", lambda *args: ...) + service.connect("bool-signal", lambda *args: ...) + service.connect("boxed-signal", lambda *args: ...) + service.connect("complex-signal", lambda *args: ...) + + def testServiceSignalsConnect2(self): + self.ServiceWithSignals( + on_int_signal=lambda *args: ..., + on_str_signal=lambda *args: ..., + on_bool_signal=lambda *args: ..., + on_boxed_signal=lambda *args: ..., + on_complex_signal=lambda *args: ..., + ) + + def testServiceSignalsConnect3(self): + service = self.ServiceWithSignals() + service.int_signal.connect(lambda *args: ...) + service.str_signal.connect(lambda *args: ...) + service.bool_signal.connect(lambda *args: ...) + service.boxed_signal.connect(lambda *args: ...) + service.complex_signal.connect(lambda *args: ...) + + def testServiceSignalsEmit1(self): + service = self.ServiceWithSignals() + service.emit("int-signal", 42) + service.emit("str-signal", "42") + service.emit("bool-signal", True) + service.emit("boxed-signal", object) + service.emit("complex-signal", 42, "42", True, object) + + def testServiceSignalsEmit2(self): + service = self.ServiceWithSignals() + service.int_signal.emit(42) + service.str_signal.emit("42") + service.bool_signal.emit(True) + service.boxed_signal.emit(object) + service.complex_signal.emit(42, "42", True, object) + + def testServiceSignalsEmit3(self): + service = self.ServiceWithSignals() + service.int_signal(42) + service.str_signal("42") + service.bool_signal(True) + service.boxed_signal(object) + service.complex_signal(42, "42", True, object) + + def testServiceSignalsConnectAndEmit1(self): + emittedSignals: list[str] = [] + expectedOutput = [ + "int-signal", + "str-signal", + "bool-signal", + "boxed-signal", + "complex-signal", + ] + + def onSignal(signalName): + emittedSignals.append(signalName) + + service = self.ServiceWithSignals() + service.connect("int-signal", lambda *args: onSignal("int-signal")) + service.connect("str-signal", lambda *args: onSignal("str-signal")) + service.connect("bool-signal", lambda *args: onSignal("bool-signal")) + service.connect("boxed-signal", lambda *args: onSignal("boxed-signal")) + service.connect("complex-signal", lambda *args: onSignal("complex-signal")) + + service.emit("int-signal", 42) + service.emit("str-signal", "42") + service.emit("bool-signal", True) + service.emit("boxed-signal", object) + service.emit("complex-signal", 42, "42", True, object) + + self.assertEqual(emittedSignals, expectedOutput) + emittedSignals = [] + + def testServiceSignalsConnectAndEmit2(self): + emittedSignals: list[str] = [] + expectedOutput = [ + "int-signal", + "str-signal", + "bool-signal", + "boxed-signal", + "complex-signal", + ] + + def onSignal(signalName): + emittedSignals.append(signalName) + + service = self.ServiceWithSignals() + service.int_signal.connect(lambda *args: onSignal("int-signal")) + service.str_signal.connect(lambda *args: onSignal("str-signal")) + service.bool_signal.connect(lambda *args: onSignal("bool-signal")) + service.boxed_signal.connect(lambda *args: onSignal("boxed-signal")) + service.complex_signal.connect(lambda *args: onSignal("complex-signal")) + + service.int_signal(42) + service.str_signal("42") + service.bool_signal(True) + service.boxed_signal(object) + service.complex_signal(42, "42", True, object) + + self.assertEqual(emittedSignals, expectedOutput) + emittedSignals = [] + + def testServiceSignalsConnectAndEmit3(self): + emittedSignals: list[str] = [] + expectedOutput = [ + "int-signal", + "str-signal", + "bool-signal", + "boxed-signal", + "complex-signal", + ] + + def onSignal(signalName): + emittedSignals.append(signalName) + + service = self.ServiceWithSignals() + + service.int_signal.connect(lambda *args: onSignal("int-signal")) + service.str_signal.connect(lambda *args: onSignal("str-signal")) + service.bool_signal.connect(lambda *args: onSignal("bool-signal")) + service.boxed_signal.connect(lambda *args: onSignal("boxed-signal")) + service.complex_signal.connect(lambda *args: onSignal("complex-signal")) + + service.int_signal.emit(42) + service.str_signal.emit("42") + service.bool_signal.emit(True) + service.boxed_signal.emit(object) + service.complex_signal.emit(42, "42", True, object) + + self.assertEqual(emittedSignals, expectedOutput) + emittedSignals = [] + + def testServiceSignalsConnectAndEmit4(self): + emittedSignals: list[str] = [] + expectedOutput = [ + "int-signal", + "str-signal", + "bool-signal", + "boxed-signal", + "complex-signal", + ] + + def onSignal(signalName): + emittedSignals.append(signalName) + + service = self.ServiceWithSignals( + on_int_signal=lambda *args: onSignal("int-signal"), + on_str_signal=lambda *args: onSignal("str-signal"), + on_bool_signal=lambda *args: onSignal("bool-signal"), + on_boxed_signal=lambda *args: onSignal("boxed-signal"), + on_complex_signal=lambda *args: onSignal("complex-signal"), + ) + + service.emit("int-signal", 42) + service.emit("str-signal", "42") + service.emit("bool-signal", True) + service.emit("boxed-signal", object) + service.emit("complex-signal", 42, "42", True, object) + + self.assertEqual(emittedSignals, expectedOutput) + emittedSignals = [] + + def testServiceSignalsConnectAndEmit5(self): + # ignorable arguments + emittedSignals: list[str] = [] + expectedOutput = [ + "int-signal", + "str-signal", + "bool-signal", + "boxed-signal", + "complex-signal", + ] + + def onSignal(signalName): + emittedSignals.append(signalName) + + service = self.ServiceWithSignals( + on_int_signal=lambda: onSignal("int-signal"), + on_str_signal=lambda: onSignal("str-signal"), + on_bool_signal=lambda: onSignal("bool-signal"), + on_boxed_signal=lambda: onSignal("boxed-signal"), + on_complex_signal=lambda: onSignal("complex-signal"), + ) + + service.emit("int-signal", 42) + service.emit("str-signal", "42") + service.emit("bool-signal", True) + service.emit("boxed-signal", object) + service.emit("complex-signal", 42, "42", True, object) + + self.assertEqual(emittedSignals, expectedOutput) + emittedSignals = [] + + def testServiceSignalsConnectAndEmit6(self): + # ignorable arguments + emittedSignals: list[str] = [] + expectedOutput = [ + "int-signal", + "str-signal", + "bool-signal", + "boxed-signal", + "complex-signal", + ] + + def onSignal(signalName): + emittedSignals.append(signalName) + + service = self.ServiceWithSignals( + on_int_signal=lambda s: self.assertEqual(s, service) + or onSignal("int-signal"), + on_str_signal=lambda s: self.assertEqual(s, service) + or onSignal("str-signal"), + on_bool_signal=lambda s: self.assertEqual(s, service) + or onSignal("bool-signal"), + on_boxed_signal=lambda s: self.assertEqual(s, service) + or onSignal("boxed-signal"), + on_complex_signal=lambda s: self.assertEqual(s, service) + or onSignal("complex-signal"), + ) + + service.emit("int-signal", 42) + service.emit("str-signal", "42") + service.emit("bool-signal", True) + service.emit("boxed-signal", object) + service.emit("complex-signal", 42, "42", True, object) + + self.assertEqual(emittedSignals, expectedOutput) + emittedSignals = [] + + def testServiceSignalsConnectAndEmit7(self): + # ignorable arguments + emittedSignals: list[str] = [] + expectedOutput = [ + "int-signal", + "str-signal", + "bool-signal", + "boxed-signal", + "complex-signal", + ] + + def onSignal(signalName): + emittedSignals.append(signalName) + + service = self.ServiceWithSignals( + on_int_signal=lambda s, i: self.assertEqual(i, 42) + or self.assertEqual(s, service) + or onSignal("int-signal"), + on_str_signal=lambda s, i: self.assertEqual(i, "42") + or self.assertEqual(s, service) + or onSignal("str-signal"), + on_bool_signal=lambda s, i: self.assertEqual(i, True) + or self.assertEqual(s, service) + or onSignal("bool-signal"), + on_boxed_signal=lambda s, i: self.assertEqual(i, object) + or self.assertEqual(s, service) + or onSignal("boxed-signal"), + on_complex_signal=lambda s, i1, i2, i3, i4: self.assertEqual( + (i1, i2, i3, i4), (42, "42", True, object) + ) + or self.assertEqual(s, service) + or onSignal("complex-signal"), + ) + + service.emit("int-signal", 42) + service.emit("str-signal", "42") + service.emit("bool-signal", True) + service.emit("boxed-signal", object) + service.emit("complex-signal", 42, "42", True, object) + + self.assertEqual(emittedSignals, expectedOutput) + emittedSignals = [] + + +if __name__ == "__main__": + unittest.main() diff --git a/fabric/tests/service.4.py b/fabric/tests/service.4.py new file mode 100644 index 0000000..1838b2f --- /dev/null +++ b/fabric/tests/service.4.py @@ -0,0 +1,56 @@ +import unittest +from fabric.core import Service, Property, Builder + + +class TestService(Service): + @Property(str, "read-write") + def prop1(self): + return self._prop1 + + @prop1.setter + def prop1(self, value: str): + self._prop1 = value + return + + @Property(str, "read-write") + def prop2(self): + return self._prop2 + + @prop2.setter + def prop2(self, value: str): + self._prop2 = value + return + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._prop1 = "" + self._prop2 = "" + + +class TestServiceBuilder(unittest.TestCase): + def testGetBuilder(self): + service = TestService() + builder = service.build() + self.assertEqual(type(builder), Builder) + + self.assertIs(builder.set_prop1("4002").set_prop2("42").unwrap(), service) + self.assertEqual(service.prop1, "4002") + self.assertEqual(service.prop2, "42") + + def testGetBuilderCallback(self): + service = TestService() + builder = None + + def builder_callback(_, _builder: Builder): + nonlocal builder + builder = _builder + return builder.set_prop1("4002").set_prop2("42").unwrap() + + self.assertIs(service.build(builder_callback), service) + self.assertEqual(type(builder), Builder) + self.assertEqual(service.prop1, "4002") + self.assertEqual(service.prop2, "42") + + +if __name__ == "__main__": + unittest.main() diff --git a/styles/colors.css b/styles/colors.css index 0f3590e..fc3ea8a 100644 --- a/styles/colors.css +++ b/styles/colors.css @@ -1,20 +1,20 @@ :vars { - --foreground: #e3e1e9; - --background: #121318; - --cursor: #e3e1e9; - --primary: #bac3ff; - --on-primary: #222c61; - --secondary: #c3c5dd; - --on-secondary: #2c2f42; - --tertiary: #e5bad8; - --on-tertiary: #44263e; - --surface: #121318; - --surface-bright: #38393f; + --foreground: #e4e1e9; + --background: #131318; + --cursor: #e4e1e9; + --primary: #bfc2ff; + --on-primary: #272b60; + --secondary: #c5c4dd; + --on-secondary: #2e2f42; + --tertiary: #e8b9d4; + --on-tertiary: #46263b; + --surface: #131318; + --surface-bright: #39383f; --error: #ffb4ab; --error-dim: #ff8678; --on-error: #690005; --error-container: #93000a; - --outline: #90909a; + --outline: #918f9a; --shadow: #000000; --red: #ffb2b9; --red-dim: #ff7f8b; @@ -22,8 +22,8 @@ --green-dim: #70c789; --yellow: #b8cf84; --yellow-dim: #a3c15f; - --blue: #bcc2ff; - --blue-dim: #8994ff; + --blue: #bfc2ff; + --blue-dim: #8c91ff; --magenta: #e4b7f3; --magenta-dim: #d48bec; --cyan: #82d3e2;