update
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
Ax-Shell: A hackable shell for Hyprland, powered by Fabric.
|
||||
Copyright (C) 2025 Adriano Tisera (Axenide)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
You can contact me through my e-mail or my socials: https://zaap.bio/Axenide
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 711 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Ax-Shell configuration package.
|
||||
"""
|
||||
# Import only specific names actually defined in data.py
|
||||
# This prevents circular imports by not importing everything
|
||||
from .data import APP_NAME, APP_NAME_CAP, CACHE_DIR, CONFIG_FILE
|
||||
@@ -0,0 +1,36 @@
|
||||
[general]
|
||||
bars = 24
|
||||
|
||||
sensitivity = 100
|
||||
framerate = 60
|
||||
lower_cutoff_freq = 50
|
||||
higher_cutoff_freq = 8000
|
||||
autosens = 1
|
||||
|
||||
# these parameters should not be changed
|
||||
# bar width and spacing goes wrong with raw output
|
||||
bar_width = 1
|
||||
bar_spacing = 0
|
||||
|
||||
[output]
|
||||
method = raw
|
||||
raw_target = /tmp/cava.fifo
|
||||
bit_format = 16bit
|
||||
channels = stereo
|
||||
|
||||
[smoothing]
|
||||
gravity = 200
|
||||
integral = 70
|
||||
ignore = 0
|
||||
monstercat = 1
|
||||
|
||||
[eq]
|
||||
1 = 1.00
|
||||
2 = 1.00
|
||||
3 = 1.00
|
||||
4 = 1.00
|
||||
5 = 1.00
|
||||
6 = 1.00
|
||||
7 = 1.00
|
||||
8 = 1.00
|
||||
9 = 1.00
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _configure_sys_path_for_direct_execution():
|
||||
"""
|
||||
Ajusta sys.path si este script se ejecuta directamente,
|
||||
para asegurar que las importaciones relativas dentro del paquete 'config' funcionen.
|
||||
Esto permite ejecutar `python config/config.py` desde cualquier directorio.
|
||||
"""
|
||||
if __name__ == "__main__":
|
||||
current_file_dir = Path(__file__).resolve().parent
|
||||
project_root = current_file_dir.parent
|
||||
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
_configure_sys_path_for_direct_execution()
|
||||
|
||||
import shutil
|
||||
|
||||
from fabric import Application
|
||||
|
||||
if __name__ == "__main__" and not __package__:
|
||||
from config.data import APP_NAME, APP_NAME_CAP
|
||||
from config.settings_gui import HyprConfGUI
|
||||
from config.settings_utils import load_bind_vars
|
||||
else:
|
||||
from .data import APP_NAME, APP_NAME_CAP
|
||||
from .settings_gui import HyprConfGUI
|
||||
from .settings_utils import load_bind_vars
|
||||
|
||||
|
||||
def open_config():
|
||||
"""
|
||||
Entry point for opening the configuration GUI using Fabric Application.
|
||||
"""
|
||||
load_bind_vars()
|
||||
|
||||
show_lock_checkbox = True
|
||||
dest_lock = os.path.expanduser("~/.config/hypr/hyprlock.conf")
|
||||
src_lock = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/hyprlock.conf")
|
||||
if not os.path.exists(dest_lock) and os.path.exists(src_lock):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest_lock), exist_ok=True)
|
||||
shutil.copy(src_lock, dest_lock)
|
||||
show_lock_checkbox = False
|
||||
print(f"Copied default hyprlock config to {dest_lock}")
|
||||
except Exception as e:
|
||||
print(f"Error copying default hyprlock config: {e}")
|
||||
show_lock_checkbox = os.path.exists(src_lock)
|
||||
|
||||
show_idle_checkbox = True
|
||||
dest_idle = os.path.expanduser("~/.config/hypr/hypridle.conf")
|
||||
src_idle = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/hypridle.conf")
|
||||
if not os.path.exists(dest_idle) and os.path.exists(src_idle):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest_idle), exist_ok=True)
|
||||
shutil.copy(src_idle, dest_idle)
|
||||
show_idle_checkbox = False
|
||||
print(f"Copied default hypridle config to {dest_idle}")
|
||||
except Exception as e:
|
||||
print(f"Error copying default hypridle config: {e}")
|
||||
show_idle_checkbox = os.path.exists(src_idle)
|
||||
|
||||
app = Application(f"{APP_NAME}-settings")
|
||||
window = HyprConfGUI(
|
||||
show_lock_checkbox=show_lock_checkbox,
|
||||
show_idle_checkbox=show_idle_checkbox,
|
||||
application=app,
|
||||
on_destroy=lambda *_: app.quit()
|
||||
)
|
||||
app.add_window(window)
|
||||
|
||||
window.show_all()
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
open_config()
|
||||
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
from gi.repository import Gdk, GLib
|
||||
|
||||
APP_NAME_CAP = "Ax-Shell"
|
||||
APP_NAME = APP_NAME_CAP.lower()
|
||||
|
||||
CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}"
|
||||
|
||||
USERNAME = os.getlogin()
|
||||
HOSTNAME = os.uname().nodename
|
||||
HOME_DIR = os.path.expanduser("~")
|
||||
|
||||
CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}")
|
||||
|
||||
screen = Gdk.Screen.get_default()
|
||||
CURRENT_WIDTH = screen.get_width()
|
||||
CURRENT_HEIGHT = screen.get_height()
|
||||
|
||||
CONFIG_FILE = get_relative_path("../config/config.json")
|
||||
MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen")
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load the configuration from config.json"""
|
||||
config_path = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/config.json")
|
||||
config = {}
|
||||
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading config: {e}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Import defaults from settings_constants to avoid duplication
|
||||
from .settings_constants import DEFAULTS
|
||||
|
||||
# Load configuration once and use throughout the module
|
||||
config = {}
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
with open(CONFIG_FILE, "r") as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading config file: {e}")
|
||||
|
||||
|
||||
def get_default(setting_str: str):
|
||||
return DEFAULTS[setting_str] if setting_str in DEFAULTS else ""
|
||||
|
||||
|
||||
def _get_config_var(setting_str: str):
|
||||
return config.get(setting_str, get_default(setting_str))
|
||||
|
||||
|
||||
# Set configuration values using defaults from settings_constants
|
||||
WALLPAPERS_DIR = _get_config_var("wallpapers_dir")
|
||||
BAR_POSITION = _get_config_var("bar_position")
|
||||
VERTICAL = BAR_POSITION in ["Left", "Right"]
|
||||
CENTERED_BAR = _get_config_var("centered_bar")
|
||||
DATETIME_12H_FORMAT = _get_config_var("datetime_12h_format")
|
||||
TERMINAL_COMMAND = _get_config_var("terminal_command")
|
||||
DOCK_ENABLED = _get_config_var("dock_enabled")
|
||||
DOCK_ALWAYS_SHOW = _get_config_var("dock_always_show")
|
||||
DOCK_ICON_SIZE = _get_config_var("dock_icon_size")
|
||||
BAR_WORKSPACE_SHOW_NUMBER = _get_config_var("bar_workspace_show_number")
|
||||
BAR_WORKSPACE_USE_CHINESE_NUMERALS = _get_config_var(
|
||||
"bar_workspace_use_chinese_numerals"
|
||||
)
|
||||
BAR_HIDE_SPECIAL_WORKSPACE = _get_config_var("bar_hide_special_workspace")
|
||||
BAR_THEME = _get_config_var("bar_theme")
|
||||
DOCK_THEME = _get_config_var("dock_theme")
|
||||
PANEL_THEME = _get_config_var("panel_theme")
|
||||
PANEL_POSITION = _get_config_var("panel_position")
|
||||
NOTIF_POS = _get_config_var("notif_pos")
|
||||
|
||||
BAR_COMPONENTS_VISIBILITY = {
|
||||
"button_apps": _get_config_var("bar_button_apps_visible"),
|
||||
"systray": _get_config_var("bar_systray_visible"),
|
||||
"control": _get_config_var("bar_control_visible"),
|
||||
"network": _get_config_var("bar_network_visible"),
|
||||
"button_tools": _get_config_var("bar_button_tools_visible"),
|
||||
"sysprofiles": _get_config_var("bar_sysprofiles_visible"),
|
||||
"button_overview": _get_config_var("bar_button_overview_visible"),
|
||||
"ws_container": _get_config_var("bar_ws_container_visible"),
|
||||
"weather": _get_config_var("bar_weather_visible"),
|
||||
"battery": _get_config_var("bar_battery_visible"),
|
||||
"metrics": _get_config_var("bar_metrics_visible"),
|
||||
"language": _get_config_var("bar_language_visible"),
|
||||
"date_time": _get_config_var("bar_date_time_visible"),
|
||||
"button_power": _get_config_var("bar_button_power_visible"),
|
||||
}
|
||||
|
||||
BAR_METRICS_DISKS = _get_config_var("bar_metrics_disks")
|
||||
METRICS_VISIBLE = _get_config_var("metrics_visible")
|
||||
METRICS_SMALL_VISIBLE = _get_config_var("metrics_small_visible")
|
||||
SELECTED_MONITORS = _get_config_var("selected_monitors")
|
||||
@@ -0,0 +1,27 @@
|
||||
general {
|
||||
lock_cmd = pidof hyprlock || hyprlock # avoid starting multiple hyprlock instances.
|
||||
before_sleep_cmd = loginctl lock-session # lock before suspend.
|
||||
after_sleep_cmd = hyprctl dispatch dpms on # to avoid having to press a key twice to turn on the display.
|
||||
}
|
||||
|
||||
listener {
|
||||
timeout = 150 # 2.5min.
|
||||
on-timeout = brightnessctl -s set 10 # set monitor backlight to minimum, avoid 0 on OLED monitor.
|
||||
on-resume = brightnessctl -r # monitor backlight restore.
|
||||
}
|
||||
|
||||
listener {
|
||||
timeout = 300 # 5min
|
||||
on-timeout = loginctl lock-session # lock screen when timeout has passed
|
||||
}
|
||||
|
||||
listener {
|
||||
timeout = 330 # 5.5min
|
||||
on-timeout = hyprctl dispatch dpms off # screen off when timeout has passed
|
||||
on-resume = hyprctl dispatch dpms on # screen on when activity is detected after timeout has fired.
|
||||
}
|
||||
|
||||
listener {
|
||||
timeout = 1800 # 30min
|
||||
on-timeout = systemctl suspend # suspend pc
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
source = ~/.config/Ax-Shell/config/hypr/colors.conf
|
||||
|
||||
# BACKGROUND
|
||||
background {
|
||||
monitor =
|
||||
path = ~/.current.wall #path to background image
|
||||
blur_passes = 3
|
||||
blur_size = 3
|
||||
contrast = 1.0
|
||||
brightness = 0.5
|
||||
vibrancy = 0.0
|
||||
vibrancy_darkness = 0.0
|
||||
}
|
||||
|
||||
# GENERAL
|
||||
general {
|
||||
grace = 0
|
||||
hide_cursor = true
|
||||
}
|
||||
|
||||
# INPUT FIELD
|
||||
input-field {
|
||||
monitor =
|
||||
size = 256, 48
|
||||
outline_thickness = 0
|
||||
dots_size = 0.2 # Scale of input-field height, 0.2 - 0.8
|
||||
dots_spacing = 0.5 # Scale of dots' absolute size, 0.0 - 1.0
|
||||
dots_center = true
|
||||
outer_color = rgba(00000000)
|
||||
inner_color = rgba(0, 0, 0, 1)
|
||||
font_color = rgb($foreground)
|
||||
fail_color = rgb($error)
|
||||
check_color = rgb($tertiary)
|
||||
capslock_color = rgb($secondary)
|
||||
fade_on_empty = false
|
||||
font_family = Iosevka Nerd Font
|
||||
placeholder_text = ... #text for input password
|
||||
hide_input = false
|
||||
position = 0, -100
|
||||
halign = center
|
||||
valign = center
|
||||
shadow_passes = 1
|
||||
shadow_size = 5
|
||||
shadow_boost = 0.5
|
||||
}
|
||||
|
||||
# TIME
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:1000] echo "$(date +"%H:%M:%S")"
|
||||
color = rgb($foreground)
|
||||
font_size = 14
|
||||
font_family = Iosevka Nerd Font Bold
|
||||
position = 0, -150
|
||||
halign = center
|
||||
valign = center
|
||||
shadow_passes = 1
|
||||
shadow_size = 5
|
||||
shadow_boost = 0.5
|
||||
}
|
||||
|
||||
# USER
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:1000] echo "$USER@$(hostname)"
|
||||
color = rgb($foreground)
|
||||
font_size = 14
|
||||
font_family = Iosevka Nerd Font Bold Italic
|
||||
position = 0, -50
|
||||
halign = center
|
||||
valign = center
|
||||
shadow_passes = 1
|
||||
shadow_size = 5
|
||||
shadow_boost = 0.5
|
||||
}
|
||||
|
||||
# PICTURE
|
||||
image {
|
||||
path = .face.icon
|
||||
size = 200
|
||||
position = 0, 75
|
||||
halign = center
|
||||
valign = center
|
||||
border_size = 3
|
||||
border_color = rgb($primary)
|
||||
shadow_passes = 1
|
||||
shadow_size = 5
|
||||
shadow_boost = 0.5
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
:vars {
|
||||
--foreground: {{colors.on_background.default.hex}};
|
||||
--background: {{colors.background.default.hex}};
|
||||
--cursor: {{colors.on_background.default.hex}};
|
||||
--primary: {{colors.primary.default.hex}};
|
||||
--on-primary: {{colors.on_primary.default.hex}};
|
||||
--secondary: {{colors.secondary.default.hex}};
|
||||
--on-secondary: {{colors.on_secondary.default.hex}};
|
||||
--tertiary: {{colors.tertiary.default.hex}};
|
||||
--on-tertiary: {{colors.on_tertiary.default.hex}};
|
||||
--surface: {{colors.surface.default.hex}};
|
||||
--surface-bright: {{colors.surface_bright.default.hex}};
|
||||
--error: {{colors.error.default.hex}};
|
||||
--error-dim: {{colors.error.default.hex | set_lightness: -10.0}};
|
||||
--on-error: {{colors.on_error.default.hex}};
|
||||
--error-container: {{colors.error_container.default.hex}};
|
||||
--outline: {{colors.outline.default.hex}};
|
||||
--shadow: {{colors.shadow.default.hex}};
|
||||
--red: {{colors.red.default.hex}};
|
||||
--red-dim: {{colors.red.default.hex | set_lightness: -10.0}};
|
||||
--green: {{colors.green.default.hex}};
|
||||
--green-dim: {{colors.green.default.hex | set_lightness: -10.0}};
|
||||
--yellow: {{colors.yellow.default.hex}};
|
||||
--yellow-dim: {{colors.yellow.default.hex | set_lightness: -10.0}};
|
||||
--blue: {{colors.blue.default.hex}};
|
||||
--blue-dim: {{colors.blue.default.hex | set_lightness: -10.0}};
|
||||
--magenta: {{colors.magenta.default.hex}};
|
||||
--magenta-dim: {{colors.magenta.default.hex | set_lightness: -10.0}};
|
||||
--cyan: {{colors.cyan.default.hex}};
|
||||
--cyan-dim: {{colors.cyan.default.hex | set_lightness: -10.0}};
|
||||
--white: {{colors.white.default.hex}};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
$wallpaper = {{image}}
|
||||
$background = {{colors.background.default.hex_stripped}}
|
||||
$foreground = {{colors.on_background.default.hex_stripped}}
|
||||
|
||||
$primary = {{colors.primary.default.hex_stripped}}
|
||||
$secondary = {{colors.secondary.default.hex_stripped}}
|
||||
$tertiary = {{colors.tertiary.default.hex_stripped}}
|
||||
$surface = {{colors.surface.default.hex_stripped}}
|
||||
$surface_bright = {{colors.surface_bright.default.hex_stripped}}
|
||||
$outline = {{colors.outline.default.hex_stripped}}
|
||||
$error = {{colors.error.default.hex_stripped | set_lightness: -20.0}}
|
||||
|
||||
$shadow = {{colors.shadow.default.hex_stripped}}
|
||||
|
||||
$red = {{colors.red.default.hex_stripped}}
|
||||
$green = {{colors.green.default.hex_stripped}}
|
||||
$yellow = {{colors.yellow.default.hex_stripped}}
|
||||
$blue = {{colors.blue.default.hex_stripped}}
|
||||
$magenta = {{colors.magenta.default.hex_stripped}}
|
||||
$cyan = {{colors.cyan.default.hex_stripped}}
|
||||
$white = {{colors.white.default.hex_stripped}}
|
||||
$red_dim = {{colors.red.default.hex_stripped | set_lightness: -20.0}}
|
||||
$green_dim = {{colors.green.default.hex_stripped | set_lightness: -20.0}}
|
||||
$yellow_dim = {{colors.yellow.default.hex_stripped | set_lightness: -20.0}}
|
||||
$blue_dim = {{colors.blue.default.hex_stripped | set_lightness: -20.0}}
|
||||
$magenta_dim = {{colors.magenta.default.hex_stripped | set_lightness: -20.0}}
|
||||
$cyan_dim = {{colors.cyan.default.hex_stripped | set_lightness: -20.0}}
|
||||
@@ -0,0 +1,103 @@
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
|
||||
from .data import (
|
||||
APP_NAME,
|
||||
APP_NAME_CAP,
|
||||
)
|
||||
|
||||
SOURCE_STRING = f"""
|
||||
# {APP_NAME_CAP}
|
||||
source = ~/.config/{APP_NAME_CAP}/config/hypr/{APP_NAME}.conf
|
||||
"""
|
||||
|
||||
DEFAULTS = {
|
||||
"prefix_restart": "SUPER ALT",
|
||||
"suffix_restart": "B",
|
||||
"prefix_axmsg": "SUPER",
|
||||
"suffix_axmsg": "A",
|
||||
"prefix_dash": "SUPER",
|
||||
"suffix_dash": "D",
|
||||
"prefix_bluetooth": "SUPER",
|
||||
"suffix_bluetooth": "B",
|
||||
"prefix_pins": "SUPER",
|
||||
"suffix_pins": "Q",
|
||||
"prefix_kanban": "SUPER",
|
||||
"suffix_kanban": "N",
|
||||
"prefix_launcher": "SUPER",
|
||||
"suffix_launcher": "R",
|
||||
"prefix_tmux": "SUPER",
|
||||
"suffix_tmux": "T",
|
||||
"prefix_cliphist": "SUPER",
|
||||
"suffix_cliphist": "V",
|
||||
"prefix_toolbox": "SUPER",
|
||||
"suffix_toolbox": "S",
|
||||
"prefix_overview": "SUPER",
|
||||
"suffix_overview": "TAB",
|
||||
"prefix_wallpapers": "SUPER",
|
||||
"suffix_wallpapers": "COMMA",
|
||||
"prefix_randwall": "SUPER SHIFT",
|
||||
"suffix_randwall": "COMMA",
|
||||
"prefix_mixer": "SUPER",
|
||||
"suffix_mixer": "M",
|
||||
"prefix_emoji": "SUPER",
|
||||
"suffix_emoji": "PERIOD",
|
||||
"prefix_power": "SUPER",
|
||||
"suffix_power": "ESCAPE",
|
||||
"prefix_caffeine": "SUPER SHIFT",
|
||||
"suffix_caffeine": "M",
|
||||
"prefix_toggle": "SUPER CTRL",
|
||||
"suffix_toggle": "B",
|
||||
"prefix_css": "SUPER SHIFT",
|
||||
"suffix_css": "B",
|
||||
"wallpapers_dir": get_relative_path("../assets/wallpapers_example"),
|
||||
"prefix_restart_inspector": "SUPER CTRL ALT",
|
||||
"suffix_restart_inspector": "B",
|
||||
"bar_position": "Top",
|
||||
"vertical": False,
|
||||
"centered_bar": False,
|
||||
"datetime_12h_format": False,
|
||||
"terminal_command": "kitty -e",
|
||||
"auto_append_hyprland": True,
|
||||
"dock_enabled": True,
|
||||
"dock_icon_size": 28,
|
||||
"dock_always_show": False,
|
||||
"bar_workspace_show_number": False,
|
||||
"bar_workspace_use_chinese_numerals": False,
|
||||
"bar_hide_special_workspace": True,
|
||||
"bar_theme": "Pills",
|
||||
"dock_theme": "Pills",
|
||||
"panel_theme": "Notch",
|
||||
"panel_position": "Center",
|
||||
"notif_pos": "Top",
|
||||
"bar_button_apps_visible": True,
|
||||
"bar_systray_visible": True,
|
||||
"bar_control_visible": True,
|
||||
"bar_network_visible": True,
|
||||
"bar_button_tools_visible": True,
|
||||
"bar_sysprofiles_visible": True,
|
||||
"bar_button_overview_visible": True,
|
||||
"bar_ws_container_visible": True,
|
||||
"bar_weather_visible": True,
|
||||
"bar_battery_visible": True,
|
||||
"bar_metrics_visible": True,
|
||||
"bar_language_visible": True,
|
||||
"bar_date_time_visible": True,
|
||||
"bar_button_power_visible": True,
|
||||
"corners_visible": True,
|
||||
"bar_metrics_disks": ["/"],
|
||||
"metrics_visible": {
|
||||
"cpu": True,
|
||||
"ram": True,
|
||||
"disk": True,
|
||||
"gpu": True,
|
||||
},
|
||||
"metrics_small_visible": {
|
||||
"cpu": True,
|
||||
"ram": True,
|
||||
"disk": True,
|
||||
"gpu": True,
|
||||
},
|
||||
"limited_apps_history": ["Spotify"],
|
||||
"history_ignored_apps": ["Hyprshot"],
|
||||
"selected_monitors": [],
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
import toml
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from fabric.utils.helpers import exec_shell_command_async
|
||||
from gi.repository import GLib
|
||||
|
||||
# Importar settings_constants para DEFAULTS
|
||||
from . import settings_constants
|
||||
from .data import ( # CONFIG_DIR, HOME_DIR no se usan aquí directamente
|
||||
APP_NAME,
|
||||
APP_NAME_CAP,
|
||||
get_default,
|
||||
)
|
||||
|
||||
# Global variable to store binding variables, managed by this module
|
||||
bind_vars = {} # Se inicializa vacío, load_bind_vars lo poblará
|
||||
|
||||
|
||||
def get_bind_var(setting_str: str):
|
||||
return bind_vars.get(setting_str, get_default(setting_str))
|
||||
|
||||
|
||||
def deep_update(target: dict, update: dict) -> dict:
|
||||
"""
|
||||
Recursively update a nested dictionary with values from another dictionary.
|
||||
Modifies target in-place.
|
||||
"""
|
||||
for key, value in update.items():
|
||||
if isinstance(value, dict) and key in target and isinstance(target[key], dict):
|
||||
# Si el valor es un diccionario y la clave ya existe en target como diccionario,
|
||||
# entonces actualiza recursivamente.
|
||||
deep_update(target[key], value)
|
||||
else:
|
||||
# De lo contrario, simplemente establece/sobrescribe el valor.
|
||||
target[key] = value
|
||||
return target # Aunque modifica in-place, devolverlo es una convención común
|
||||
|
||||
|
||||
def ensure_matugen_config():
|
||||
"""
|
||||
Ensure that the matugen configuration file exists and is updated
|
||||
with the expected settings.
|
||||
"""
|
||||
expected_config = {
|
||||
"config": {
|
||||
"reload_apps": True,
|
||||
"wallpaper": {
|
||||
"command": "awww",
|
||||
"arguments": [
|
||||
"img",
|
||||
"-t",
|
||||
"fade",
|
||||
"--transition-duration",
|
||||
"0.5",
|
||||
"--transition-step",
|
||||
"255",
|
||||
"--transition-fps",
|
||||
"60",
|
||||
"-f",
|
||||
"Nearest",
|
||||
],
|
||||
"set": True,
|
||||
},
|
||||
"custom_colors": {
|
||||
"red": {"color": "#FF0000", "blend": True},
|
||||
"green": {"color": "#00FF00", "blend": True},
|
||||
"yellow": {"color": "#FFFF00", "blend": True},
|
||||
"blue": {"color": "#0000FF", "blend": True},
|
||||
"magenta": {"color": "#FF00FF", "blend": True},
|
||||
"cyan": {"color": "#00FFFF", "blend": True},
|
||||
"white": {"color": "#FFFFFF", "blend": True},
|
||||
},
|
||||
},
|
||||
"templates": {
|
||||
"hyprland": {
|
||||
"input_path": f"~/.config/{APP_NAME_CAP}/config/matugen/templates/hyprland-colors.conf",
|
||||
"output_path": f"~/.config/{APP_NAME_CAP}/config/hypr/colors.conf",
|
||||
},
|
||||
f"{APP_NAME}": {
|
||||
"input_path": f"~/.config/{APP_NAME_CAP}/config/matugen/templates/{APP_NAME}.css",
|
||||
"output_path": f"~/.config/{APP_NAME_CAP}/styles/colors.css",
|
||||
"post_hook": f"fabric-cli exec {APP_NAME} 'app.set_css()' &",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config_path = os.path.expanduser("~/.config/matugen/config.toml")
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
|
||||
existing_config = {}
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
existing_config = toml.load(f)
|
||||
shutil.copyfile(config_path, config_path + ".bak")
|
||||
except toml.TomlDecodeError:
|
||||
print(
|
||||
f"Warning: Could not decode TOML from {config_path}. A new default config will be created."
|
||||
)
|
||||
existing_config = {} # Resetear si está corrupto
|
||||
except Exception as e:
|
||||
print(f"Error reading or backing up {config_path}: {e}")
|
||||
# existing_config podría estar parcialmente cargado o vacío.
|
||||
# Continuar para intentar fusionar con defaults.
|
||||
|
||||
# Usamos una copia de existing_config para deep_update si no queremos modificarlo directamente
|
||||
# o asegurarse que deep_update no lo haga si no es deseado.
|
||||
# La implementación actual de deep_update modifica 'target'.
|
||||
# Para ser más seguros, podemos pasar una copia si existing_config no debe cambiar.
|
||||
# merged_config = deep_update(existing_config.copy(), expected_config)
|
||||
# O si existing_config puede ser modificado:
|
||||
merged_config = deep_update(
|
||||
existing_config, expected_config
|
||||
) # existing_config se modifica in-place
|
||||
|
||||
try:
|
||||
with open(config_path, "w") as f:
|
||||
toml.dump(merged_config, f)
|
||||
except Exception as e:
|
||||
print(f"Error writing matugen config to {config_path}: {e}")
|
||||
|
||||
current_wall = os.path.expanduser("~/.current.wall")
|
||||
hypr_colors = os.path.expanduser(
|
||||
f"~/.config/{APP_NAME_CAP}/config/hypr/colors.conf"
|
||||
)
|
||||
css_colors = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/styles/colors.css")
|
||||
|
||||
if (
|
||||
not os.path.exists(current_wall)
|
||||
or not os.path.exists(hypr_colors)
|
||||
or not os.path.exists(css_colors)
|
||||
):
|
||||
os.makedirs(os.path.dirname(hypr_colors), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(css_colors), exist_ok=True)
|
||||
|
||||
image_path = ""
|
||||
if not os.path.exists(current_wall):
|
||||
example_wallpaper_path = os.path.expanduser(
|
||||
f"~/.config/{APP_NAME_CAP}/assets/wallpapers_example/example-1.jpg"
|
||||
)
|
||||
if os.path.exists(example_wallpaper_path):
|
||||
try:
|
||||
# Si ya existe (posiblemente un enlace roto o archivo regular), eliminar y re-enlazar
|
||||
if os.path.lexists(
|
||||
current_wall
|
||||
): # lexists para no seguir el enlace si es uno
|
||||
os.remove(current_wall)
|
||||
os.symlink(example_wallpaper_path, current_wall)
|
||||
image_path = example_wallpaper_path
|
||||
except Exception as e:
|
||||
print(f"Error creating symlink for wallpaper: {e}")
|
||||
else:
|
||||
image_path = (
|
||||
os.path.realpath(current_wall)
|
||||
if os.path.islink(current_wall)
|
||||
else current_wall
|
||||
)
|
||||
|
||||
if image_path and os.path.exists(image_path):
|
||||
print(f"Generating color theme from wallpaper: {image_path}")
|
||||
try:
|
||||
matugen_cmd = f"matugen image '{image_path}'"
|
||||
exec_shell_command_async(matugen_cmd)
|
||||
print("Matugen color theme generation initiated.")
|
||||
except FileNotFoundError:
|
||||
print("Error: matugen command not found. Please install matugen.")
|
||||
except Exception as e:
|
||||
print(f"Error initiating matugen: {e}")
|
||||
elif not image_path:
|
||||
print(
|
||||
"Warning: No wallpaper path determined to generate matugen theme from."
|
||||
)
|
||||
else: # image_path existe pero el archivo no
|
||||
print(
|
||||
f"Warning: Wallpaper at {image_path} not found. Cannot generate matugen theme."
|
||||
)
|
||||
|
||||
|
||||
def load_bind_vars():
|
||||
"""
|
||||
Load saved key binding variables from JSON, if available.
|
||||
Populates the global `bind_vars` in-place.
|
||||
"""
|
||||
global bind_vars # Necesario para modificar el objeto global bind_vars
|
||||
|
||||
# 1. Limpiar el diccionario bind_vars existente.
|
||||
bind_vars.clear()
|
||||
# 2. Actualizarlo con una copia de DEFAULTS.
|
||||
bind_vars.update(
|
||||
settings_constants.DEFAULTS.copy()
|
||||
) # Usar .copy() para no modificar DEFAULTS accidentalmente
|
||||
|
||||
config_json = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/config.json")
|
||||
if os.path.exists(config_json):
|
||||
try:
|
||||
with open(config_json, "r") as f:
|
||||
saved_vars = json.load(f)
|
||||
# 3. Usar deep_update para fusionar saved_vars en el bind_vars existente.
|
||||
deep_update(bind_vars, saved_vars)
|
||||
|
||||
# La lógica para asegurar la estructura de diccionarios anidados
|
||||
# como 'metrics_visible' y 'metrics_small_visible'
|
||||
# debe operar sobre el 'bind_vars' ya actualizado.
|
||||
for vis_key in ["metrics_visible", "metrics_small_visible"]:
|
||||
# Asegurar que la clave exista en DEFAULTS como referencia de estructura
|
||||
if vis_key in settings_constants.DEFAULTS:
|
||||
default_sub_dict = settings_constants.DEFAULTS[vis_key]
|
||||
# Si la clave no está en bind_vars o no es un diccionario después de deep_update,
|
||||
# restaurarla desde una copia de DEFAULTS para esa clave.
|
||||
if not isinstance(bind_vars.get(vis_key), dict):
|
||||
bind_vars[vis_key] = default_sub_dict.copy()
|
||||
else:
|
||||
# Si es un diccionario, asegurar que todas las sub-claves de DEFAULTS estén presentes.
|
||||
current_sub_dict = bind_vars[vis_key]
|
||||
for m_key, m_val in default_sub_dict.items():
|
||||
if m_key not in current_sub_dict:
|
||||
current_sub_dict[m_key] = m_val
|
||||
except json.JSONDecodeError:
|
||||
print(
|
||||
f"Warning: Could not decode JSON from {config_json}. Using defaults (already initialized)."
|
||||
)
|
||||
# bind_vars ya está poblado con DEFAULTS, no se necesita acción adicional aquí.
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error loading config from {config_json}: {e}. Using defaults (already initialized)."
|
||||
)
|
||||
# bind_vars ya está poblado con DEFAULTS.
|
||||
# else:
|
||||
# Si config_json no existe, bind_vars ya está poblado con DEFAULTS.
|
||||
# print(f"Config file {config_json} not found. Using defaults (already initialized).")
|
||||
|
||||
|
||||
def generate_hyprconf() -> str:
|
||||
"""
|
||||
Generate the Hypr configuration string using the current bind_vars.
|
||||
"""
|
||||
home = os.path.expanduser("~")
|
||||
# Determine animation type based on bar position
|
||||
bar_position = get_bind_var("bar_position")
|
||||
is_vertical = bar_position in ["Left", "Right"]
|
||||
animation_type = "slidefadevert" if is_vertical else "slidefade"
|
||||
|
||||
return f"""exec-once = uwsm-app $(python {home}/.config/{APP_NAME_CAP}/main.py)
|
||||
exec = pgrep -x "hypridle" > /dev/null || uwsm app -- hypridle
|
||||
exec = uwsm app -- awww-daemon
|
||||
exec-once = wl-paste --type text --watch cliphist store
|
||||
exec-once = wl-paste --type image --watch cliphist store
|
||||
|
||||
$fabricSend = fabric-cli exec {APP_NAME}
|
||||
$axMessage = notify-send "Axenide" "FIRE IN THE HOLE‼️🗣️🔥🕳️" -i "{home}/.config/{APP_NAME_CAP}/assets/ax.png" -A "🗣️" -A "🔥" -A "🕳️" -a "Source Code"
|
||||
|
||||
bind = {get_bind_var("prefix_restart")}, {get_bind_var("suffix_restart")}, exec, killall {APP_NAME}; uwsm-app $(python {home}/.config/{APP_NAME_CAP}/main.py) # Reload {APP_NAME_CAP}
|
||||
bind = {get_bind_var("prefix_axmsg")}, {get_bind_var("suffix_axmsg")}, exec, $axMessage # Message
|
||||
bind = {get_bind_var("prefix_dash")}, {get_bind_var("suffix_dash")}, exec, $fabricSend 'notch.open_notch("dashboard")' # Dashboard
|
||||
bind = {get_bind_var("prefix_bluetooth")}, {get_bind_var("suffix_bluetooth")}, exec, $fabricSend 'notch.open_notch("bluetooth")' # Bluetooth
|
||||
bind = {get_bind_var("prefix_pins")}, {get_bind_var("suffix_pins")}, exec, $fabricSend 'notch.open_notch("pins")' # Pins
|
||||
bind = {get_bind_var("prefix_kanban")}, {get_bind_var("suffix_kanban")}, exec, $fabricSend 'notch.open_notch("kanban")' # Kanban
|
||||
bind = {get_bind_var("prefix_launcher")}, {get_bind_var("suffix_launcher")}, exec, $fabricSend 'notch.open_notch("launcher")' # App Launcher
|
||||
bind = {get_bind_var("prefix_tmux")}, {get_bind_var("suffix_tmux")}, exec, $fabricSend 'notch.open_notch("tmux")' # Tmux
|
||||
bind = {get_bind_var("prefix_cliphist")}, {get_bind_var("suffix_cliphist")}, exec, $fabricSend 'notch.open_notch("cliphist")' # Clipboard History
|
||||
bind = {get_bind_var("prefix_toolbox")}, {get_bind_var("suffix_toolbox")}, exec, $fabricSend 'notch.open_notch("tools")' # Toolbox
|
||||
bind = {get_bind_var("prefix_overview")}, {get_bind_var("suffix_overview")}, exec, $fabricSend 'notch.open_notch("overview")' # Overview
|
||||
bind = {get_bind_var("prefix_wallpapers")}, {get_bind_var("suffix_wallpapers")}, exec, $fabricSend 'notch.open_notch("wallpapers")' # Wallpapers
|
||||
bind = {get_bind_var("prefix_randwall")}, {get_bind_var("suffix_randwall")}, exec, $fabricSend 'notch.dashboard.wallpapers.set_random_wallpaper(None, external=True)' # Random Wallpaper
|
||||
bind = {get_bind_var("prefix_mixer")}, {get_bind_var("suffix_mixer")}, exec, $fabricSend 'notch.open_notch("mixer")' # Audio Mixer
|
||||
bind = {get_bind_var("prefix_emoji")}, {get_bind_var("suffix_emoji")}, exec, $fabricSend 'notch.open_notch("emoji")' # Emoji Picker
|
||||
bind = {get_bind_var("prefix_power")}, {get_bind_var("suffix_power")}, exec, $fabricSend 'notch.open_notch("power")' # Power Menu
|
||||
bind = {get_bind_var("prefix_caffeine")}, {get_bind_var("suffix_caffeine")}, exec, $fabricSend 'notch.dashboard.widgets.buttons.caffeine_button.toggle_inhibit(external=True)' # Toggle Caffeine
|
||||
bind = {get_bind_var("prefix_toggle")}, {get_bind_var("suffix_toggle")}, exec, $fabricSend 'from utils.global_keybinds import get_global_keybind_handler; get_global_keybind_handler().toggle_bar()' # Toggle Bar
|
||||
bind = {get_bind_var("prefix_css")}, {get_bind_var("suffix_css")}, exec, $fabricSend 'app.set_css()' # Reload CSS
|
||||
bind = {get_bind_var("prefix_restart_inspector")}, {get_bind_var("suffix_restart_inspector")}, exec, killall {APP_NAME}; uwsm-app $(GTK_DEBUG=interactive python {home}/.config/{APP_NAME_CAP}/main.py) # Restart with inspector
|
||||
|
||||
# Wallpapers directory: {get_bind_var("wallpapers_dir")}
|
||||
|
||||
source = {home}/.config/{APP_NAME_CAP}/config/hypr/colors.conf
|
||||
|
||||
layerrule = noanim, fabric
|
||||
|
||||
exec = cp $wallpaper ~/.current.wall
|
||||
|
||||
general {{
|
||||
col.active_border = rgb($primary)
|
||||
col.inactive_border = rgb($surface)
|
||||
gaps_in = 2
|
||||
gaps_out = 4
|
||||
border_size = 2
|
||||
layout = dwindle
|
||||
}}
|
||||
|
||||
cursor {{
|
||||
no_warps=true
|
||||
}}
|
||||
|
||||
decoration {{
|
||||
blur {{
|
||||
enabled = yes
|
||||
size = 1
|
||||
passes = 3
|
||||
new_optimizations = yes
|
||||
contrast = 1
|
||||
brightness = 1
|
||||
}}
|
||||
rounding = 14
|
||||
shadow {{
|
||||
enabled = true
|
||||
range = 10
|
||||
render_power = 2
|
||||
color = rgba(0, 0, 0, 0.25)
|
||||
}}
|
||||
}}
|
||||
|
||||
animations {{
|
||||
enabled = yes
|
||||
bezier = myBezier, 0.4, 0.0, 0.2, 1.0
|
||||
animation = windows, 1, 2.5, myBezier, popin 80%
|
||||
animation = border, 1, 2.5, myBezier
|
||||
animation = fade, 1, 2.5, myBezier
|
||||
animation = workspaces, 1, 2.5, myBezier, {animation_type} 20%
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def ensure_face_icon():
|
||||
"""
|
||||
Ensure the face icon exists. If not, copy the default icon.
|
||||
"""
|
||||
face_icon_path = os.path.expanduser("~/.face.icon")
|
||||
default_icon_path = os.path.expanduser(
|
||||
f"~/.config/{APP_NAME_CAP}/assets/default.png"
|
||||
)
|
||||
if not os.path.exists(face_icon_path) and os.path.exists(default_icon_path):
|
||||
try:
|
||||
shutil.copy(default_icon_path, face_icon_path)
|
||||
except Exception as e:
|
||||
print(f"Error copying default face icon: {e}")
|
||||
|
||||
|
||||
def backup_and_replace(src: str, dest: str, config_name: str):
|
||||
"""
|
||||
Backup the existing configuration file and replace it with a new one.
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(dest):
|
||||
backup_path = dest + ".bak"
|
||||
# Asegurarse que el directorio de backup existe si es diferente
|
||||
# os.makedirs(os.path.dirname(backup_path), exist_ok=True)
|
||||
shutil.copy(dest, backup_path)
|
||||
print(f"{config_name} config backed up to {backup_path}")
|
||||
os.makedirs(
|
||||
os.path.dirname(dest), exist_ok=True
|
||||
) # Ensure dest directory exists
|
||||
shutil.copy(src, dest)
|
||||
print(f"{config_name} config replaced from {src}")
|
||||
except Exception as e:
|
||||
print(f"Error backing up/replacing {config_name} config: {e}")
|
||||
|
||||
|
||||
def start_config():
|
||||
"""
|
||||
Run final configuration steps: ensure necessary configs, write the hyprconf, and reload.
|
||||
"""
|
||||
print(f"{time.time():.4f}: start_config: Ensuring matugen config...")
|
||||
ensure_matugen_config()
|
||||
print(f"{time.time():.4f}: start_config: Ensuring face icon...")
|
||||
ensure_face_icon()
|
||||
print(f"{time.time():.4f}: start_config: Generating hypr conf...")
|
||||
|
||||
hypr_config_dir = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/")
|
||||
os.makedirs(hypr_config_dir, exist_ok=True)
|
||||
# Usar APP_NAME para el nombre del archivo .conf para que coincida con SOURCE_STRING corregido
|
||||
hypr_conf_path = os.path.join(hypr_config_dir, f"{APP_NAME}.conf")
|
||||
try:
|
||||
with open(hypr_conf_path, "w") as f:
|
||||
f.write(generate_hyprconf())
|
||||
print(f"Generated Hyprland config at {hypr_conf_path}")
|
||||
except Exception as e:
|
||||
print(f"Error writing Hyprland config: {e}")
|
||||
print(f"{time.time():.4f}: start_config: Finished generating hypr conf.")
|
||||
|
||||
print(f"{time.time():.4f}: start_config: Initiating hyprctl reload...")
|
||||
try:
|
||||
# subprocess.run(["hyprctl", "reload"], check=True, capture_output=True, text=True)
|
||||
exec_shell_command_async("hyprctl reload") # Mantener async para no bloquear
|
||||
print(
|
||||
f"{time.time():.4f}: start_config: Hyprland configuration reload initiated."
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print("Error: hyprctl command not found. Cannot reload Hyprland.")
|
||||
except (
|
||||
subprocess.CalledProcessError
|
||||
) as e: # Si usáramos subprocess.run con check=True
|
||||
print(
|
||||
f"Error reloading Hyprland with hyprctl: {e}\nOutput:\n{e.stdout}\n{e.stderr}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"An error occurred initiating hyprctl reload: {e}")
|
||||
print(f"{time.time():.4f}: start_config: Finished initiating hyprctl reload.")
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit immediately if a command fails
|
||||
set -u # Treat unset variables as errors
|
||||
set -o pipefail # Prevent errors in a pipeline from being masked
|
||||
|
||||
REPO_URL="https://github.com/Axenide/Ax-Shell.git"
|
||||
INSTALL_DIR="$HOME/.config/Ax-Shell"
|
||||
PACKAGES=(
|
||||
awww-git
|
||||
brightnessctl
|
||||
cava
|
||||
cliphist
|
||||
ddcutil
|
||||
fabric-cli-git
|
||||
gnome-bluetooth-3.0
|
||||
gobject-introspection
|
||||
gpu-screen-recorder
|
||||
hypridle
|
||||
hyprlock
|
||||
hyprpicker
|
||||
hyprshot
|
||||
hyprsunset
|
||||
imagemagick
|
||||
libnotify
|
||||
matugen-bin
|
||||
network-manager-applet
|
||||
networkmanager
|
||||
nm-connection-editor
|
||||
noto-fonts-emoji
|
||||
nvtop
|
||||
playerctl
|
||||
power-profiles-daemon
|
||||
python-fabric-git
|
||||
python-gobject
|
||||
python-ijson
|
||||
python-numpy
|
||||
python-pillow
|
||||
python-psutil
|
||||
python-pywayland
|
||||
python-requests
|
||||
python-setproctitle
|
||||
python-toml
|
||||
python-watchdog
|
||||
swappy
|
||||
tesseract
|
||||
tesseract-data-eng
|
||||
tesseract-data-spa
|
||||
tmux
|
||||
ttf-nerd-fonts-symbols-mono
|
||||
unzip
|
||||
upower
|
||||
uwsm
|
||||
vte3
|
||||
webp-pixbuf-loader
|
||||
wl-clipboard
|
||||
)
|
||||
|
||||
# Prevent running as root
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "Please do not run this script as root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
aur_helper="yay"
|
||||
|
||||
# Check if paru exists, otherwise use yay
|
||||
if command -v paru &>/dev/null; then
|
||||
aur_helper="paru"
|
||||
elif ! command -v yay &>/dev/null; then
|
||||
echo "Installing yay-bin..."
|
||||
tmpdir=$(mktemp -d)
|
||||
git clone --depth=1 https://aur.archlinux.org/yay-bin.git "$tmpdir/yay-bin"
|
||||
(cd "$tmpdir/yay-bin" && makepkg -si --noconfirm)
|
||||
rm -rf "$tmpdir"
|
||||
fi
|
||||
|
||||
# Clone or update the repository
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "Updating Ax-Shell..."
|
||||
git -C "$INSTALL_DIR" pull
|
||||
else
|
||||
echo "Cloning Ax-Shell..."
|
||||
git clone --depth=1 "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Install required packages using the detected AUR helper (only if missing)
|
||||
echo "Installing required packages..."
|
||||
$aur_helper -Syy --needed --devel --noconfirm "${PACKAGES[@]}" || true
|
||||
|
||||
echo "Installing gray-git..."
|
||||
yes | $aur_helper -Syy --needed --devel --noconfirm gray-git || true
|
||||
|
||||
echo "Installing required fonts..."
|
||||
|
||||
FONT_URL="https://github.com/zed-industries/zed-fonts/releases/download/1.2.0/zed-sans-1.2.0.zip"
|
||||
FONT_DIR="$HOME/.fonts/zed-sans"
|
||||
TEMP_ZIP="/tmp/zed-sans-1.2.0.zip"
|
||||
|
||||
# Check if fonts are already installed
|
||||
if [ ! -d "$FONT_DIR" ]; then
|
||||
echo "Downloading fonts from $FONT_URL..."
|
||||
curl -L -o "$TEMP_ZIP" "$FONT_URL"
|
||||
|
||||
echo "Extracting fonts to $FONT_DIR..."
|
||||
mkdir -p "$FONT_DIR"
|
||||
unzip -o "$TEMP_ZIP" -d "$FONT_DIR"
|
||||
|
||||
echo "Cleaning up..."
|
||||
rm "$TEMP_ZIP"
|
||||
else
|
||||
echo "Fonts are already installed. Skipping download and extraction."
|
||||
fi
|
||||
|
||||
# Network services handling
|
||||
echo "Configuring network services..."
|
||||
|
||||
# Disable iwd if enabled/active
|
||||
if systemctl is-enabled --quiet iwd 2>/dev/null || systemctl is-active --quiet iwd 2>/dev/null; then
|
||||
echo "Disabling iwd..."
|
||||
sudo systemctl disable --now iwd
|
||||
else
|
||||
echo "iwd is already disabled."
|
||||
fi
|
||||
|
||||
# Enable NetworkManager if not enabled
|
||||
if ! systemctl is-enabled --quiet NetworkManager 2>/dev/null; then
|
||||
echo "Enabling NetworkManager..."
|
||||
sudo systemctl enable NetworkManager
|
||||
else
|
||||
echo "NetworkManager is already enabled."
|
||||
fi
|
||||
|
||||
# Start NetworkManager if not running
|
||||
if ! systemctl is-active --quiet NetworkManager 2>/dev/null; then
|
||||
echo "Starting NetworkManager..."
|
||||
sudo systemctl start NetworkManager
|
||||
else
|
||||
echo "NetworkManager is already running."
|
||||
fi
|
||||
|
||||
# Copy local fonts if not already present
|
||||
if [ ! -d "$HOME/.fonts/tabler-icons" ]; then
|
||||
echo "Copying local fonts to $HOME/.fonts/tabler-icons..."
|
||||
mkdir -p "$HOME/.fonts/tabler-icons"
|
||||
cp -r "$INSTALL_DIR/assets/fonts/"* "$HOME/.fonts"
|
||||
else
|
||||
echo "Local fonts are already installed. Skipping copy."
|
||||
fi
|
||||
|
||||
python "$INSTALL_DIR/config/config.py"
|
||||
echo "Starting Ax-Shell..."
|
||||
killall ax-shell 2>/dev/null || true
|
||||
uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 &
|
||||
disown
|
||||
|
||||
echo "Installation complete."
|
||||
@@ -0,0 +1,131 @@
|
||||
@import url("./styles/applets.css");
|
||||
@import url("./styles/bar.css");
|
||||
@import url("./styles/buttons.css");
|
||||
@import url("./styles/calendar.css");
|
||||
@import url("./styles/colors.css");
|
||||
@import url("./styles/controls.css");
|
||||
@import url("./styles/dashboard.css");
|
||||
@import url("./styles/dock.css");
|
||||
@import url("./styles/emoji.css");
|
||||
@import url("./styles/extras.css");
|
||||
@import url("./styles/kanban.css");
|
||||
@import url("./styles/launcher.css");
|
||||
@import url("./styles/metrics.css");
|
||||
@import url("./styles/notch.css");
|
||||
@import url("./styles/notifications.css");
|
||||
@import url("./styles/overview.css");
|
||||
@import url("./styles/pins.css");
|
||||
@import url("./styles/player.css");
|
||||
@import url("./styles/power.css");
|
||||
@import url("./styles/shadows.css");
|
||||
@import url("./styles/tools.css");
|
||||
@import url("./styles/wallpapers.css");
|
||||
@import url("./styles/systemprofiles.css");
|
||||
@import url("./styles/workspaces.css");
|
||||
|
||||
* {
|
||||
all: unset;
|
||||
color: var(--foreground);
|
||||
font-size: unset;
|
||||
font-family: unset;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
#applet-stack,
|
||||
#calendar,
|
||||
#header,
|
||||
#player,
|
||||
#metrics,
|
||||
#pin-cell-box,
|
||||
#kanban-header {
|
||||
background-color: alpha(black, 0.5);
|
||||
}
|
||||
|
||||
#update-window {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary) 0%,
|
||||
var(--primary) 1%,
|
||||
var(--shadow) 1%,
|
||||
var(--shadow) 2%,
|
||||
var(--outline) 2%,
|
||||
var(--outline) 3%,
|
||||
var(--shadow) 3%,
|
||||
var(--shadow) 4%,
|
||||
var(--surface-bright) 4%,
|
||||
var(--surface-bright) 5%,
|
||||
var(--shadow) 5%,
|
||||
var(--shadow) 6%,
|
||||
var(--surface) 6%,
|
||||
var(--surface) 7%,
|
||||
var(--shadow) 7%,
|
||||
var(--shadow) 8%,
|
||||
var(--shadow) 93%,
|
||||
var(--surface) 93%,
|
||||
var(--surface) 94%,
|
||||
var(--shadow) 94%,
|
||||
var(--shadow) 95%,
|
||||
var(--surface-bright) 95%,
|
||||
var(--surface-bright) 96%,
|
||||
var(--shadow) 96%,
|
||||
var(--shadow) 97%,
|
||||
var(--outline) 97%,
|
||||
var(--outline) 98%,
|
||||
var(--shadow) 98%,
|
||||
var(--shadow) 99%,
|
||||
var(--primary) 99%,
|
||||
var(--primary) 100%
|
||||
);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#update-button {
|
||||
background-color: var(--primary);
|
||||
border-radius: 16px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#update-button:hover {
|
||||
background-color: var(--foreground);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#update-button:active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#update-button label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#later-button {
|
||||
background-color: var(--surface-bright);
|
||||
border-radius: 16px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#later-button:hover {
|
||||
background-color: var(--outline);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#later-button:active {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#toggle-updater-button {
|
||||
background-color: var(--surface-bright);
|
||||
border-radius: 16px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#toggle-updater-button:hover {
|
||||
background-color: var(--outline);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import os
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("GLib", "2.0")
|
||||
import setproctitle
|
||||
from fabric import Application
|
||||
from fabric.utils import exec_shell_command_async, get_relative_path
|
||||
from gi.repository import GLib
|
||||
|
||||
from config.data import APP_NAME, APP_NAME_CAP, CACHE_DIR, CONFIG_FILE, HOME_DIR
|
||||
from modules.bar import Bar
|
||||
from modules.corners import Corners
|
||||
from modules.dock import Dock
|
||||
from modules.notch import Notch
|
||||
from modules.notifications import NotificationPopup
|
||||
from modules.updater import run_updater
|
||||
|
||||
fonts_updated_file = f"{CACHE_DIR}/fonts_updated"
|
||||
|
||||
if __name__ == "__main__":
|
||||
setproctitle.setproctitle(APP_NAME)
|
||||
|
||||
if not os.path.isfile(CONFIG_FILE):
|
||||
config_script_path = get_relative_path("config/config.py")
|
||||
exec_shell_command_async(f"python {config_script_path}")
|
||||
|
||||
current_wallpaper = os.path.expanduser("~/.current.wall")
|
||||
if not os.path.exists(current_wallpaper):
|
||||
example_wallpaper = os.path.expanduser(
|
||||
f"~/.config/{APP_NAME_CAP}/assets/wallpapers_example/example-1.jpg"
|
||||
)
|
||||
os.symlink(example_wallpaper, current_wallpaper)
|
||||
|
||||
# Load configuration
|
||||
from config.data import load_config
|
||||
|
||||
config = load_config()
|
||||
|
||||
GLib.idle_add(run_updater)
|
||||
# Every hour
|
||||
GLib.timeout_add(3600000, run_updater)
|
||||
|
||||
# Initialize multi-monitor services
|
||||
try:
|
||||
from utils.monitor_manager import get_monitor_manager
|
||||
from services.monitor_focus import get_monitor_focus_service
|
||||
from utils.global_keybinds import init_global_keybind_objects
|
||||
|
||||
monitor_manager = get_monitor_manager()
|
||||
monitor_focus_service = get_monitor_focus_service()
|
||||
monitor_manager.set_monitor_focus_service(monitor_focus_service)
|
||||
init_global_keybind_objects()
|
||||
|
||||
# Get all available monitors
|
||||
all_monitors = monitor_manager.get_monitors()
|
||||
multi_monitor_enabled = True
|
||||
except ImportError:
|
||||
# Fallback to single monitor mode
|
||||
all_monitors = [{'id': 0, 'name': 'default'}]
|
||||
monitor_manager = None
|
||||
multi_monitor_enabled = False
|
||||
|
||||
# Filter monitors based on selected_monitors configuration
|
||||
selected_monitors_config = config.get("selected_monitors", [])
|
||||
|
||||
# If selected_monitors is empty, show on all monitors (current behavior)
|
||||
if not selected_monitors_config:
|
||||
monitors = all_monitors
|
||||
print("Ax-Shell: No specific monitors selected, showing on all monitors")
|
||||
else:
|
||||
# Filter monitors to only include selected ones
|
||||
monitors = []
|
||||
selected_monitor_names = set(selected_monitors_config)
|
||||
|
||||
for monitor in all_monitors:
|
||||
monitor_name = monitor.get('name', f'monitor-{monitor.get("id", 0)}')
|
||||
if monitor_name in selected_monitor_names:
|
||||
monitors.append(monitor)
|
||||
print(f"Ax-Shell: Including monitor '{monitor_name}' (selected)")
|
||||
else:
|
||||
print(f"Ax-Shell: Excluding monitor '{monitor_name}' (not selected)")
|
||||
|
||||
# Fallback: if no valid monitors found, use all monitors
|
||||
if not monitors:
|
||||
print("Ax-Shell: No valid selected monitors found, falling back to all monitors")
|
||||
monitors = all_monitors
|
||||
|
||||
# Create application components list
|
||||
app_components = []
|
||||
corners = None
|
||||
notification = None
|
||||
|
||||
# Create components for each monitor
|
||||
for monitor in monitors:
|
||||
monitor_id = monitor['id']
|
||||
|
||||
# Create corners only for the first monitor (shared across all)
|
||||
if monitor_id == 0:
|
||||
corners = Corners()
|
||||
# Set corners visibility based on config
|
||||
corners_visible = config.get("corners_visible", True)
|
||||
corners.set_visible(corners_visible)
|
||||
app_components.append(corners)
|
||||
|
||||
# Create monitor-specific components
|
||||
if multi_monitor_enabled:
|
||||
bar = Bar(monitor_id=monitor_id)
|
||||
notch = Notch(monitor_id=monitor_id)
|
||||
dock = Dock(monitor_id=monitor_id)
|
||||
else:
|
||||
# Single monitor fallback
|
||||
bar = Bar()
|
||||
notch = Notch()
|
||||
dock = Dock()
|
||||
|
||||
# Connect bar and notch
|
||||
bar.notch = notch
|
||||
notch.bar = bar
|
||||
|
||||
# Create notification popup for the first monitor only
|
||||
if monitor_id == 0:
|
||||
notification = NotificationPopup(widgets=notch.dashboard.widgets)
|
||||
app_components.append(notification)
|
||||
|
||||
# Register instances in monitor manager if available
|
||||
if multi_monitor_enabled and monitor_manager:
|
||||
monitor_manager.register_monitor_instances(monitor_id, {
|
||||
'bar': bar,
|
||||
'notch': notch,
|
||||
'dock': dock,
|
||||
'corners': corners if monitor_id == 0 else None
|
||||
})
|
||||
|
||||
# Add components to app list
|
||||
app_components.extend([bar, notch, dock])
|
||||
|
||||
# Create the application with all components
|
||||
app = Application(f"{APP_NAME}", *app_components)
|
||||
|
||||
def set_css():
|
||||
app.set_stylesheet_from_file(
|
||||
get_relative_path("main.css"),
|
||||
)
|
||||
|
||||
app.set_css = set_css
|
||||
|
||||
app.set_css()
|
||||
|
||||
app.run()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Ax-Shell modules package.
|
||||
Contains UI components and functionality modules.
|
||||
"""
|
||||
@@ -0,0 +1,627 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from fabric.hyprland.service import HyprlandEvent
|
||||
from fabric.hyprland.widgets import HyprlandLanguage as Language
|
||||
from fabric.hyprland.widgets import HyprlandWorkspaces as Workspaces
|
||||
from fabric.hyprland.widgets import WorkspaceButton, get_hyprland_connection
|
||||
from fabric.utils.helpers import exec_shell_command_async
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.datetime import DateTime
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
from modules.controls import ControlSmall
|
||||
from modules.dock import Dock
|
||||
from modules.metrics import Battery, MetricsSmall, NetworkApplet
|
||||
from modules.systemprofiles import Systemprofiles
|
||||
from modules.systemtray import SystemTray
|
||||
from modules.weather import Weather
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
CHINESE_NUMERALS = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "〇"]
|
||||
|
||||
# Tooltips
|
||||
tooltip_apps = f"""<b><u>Launcher</u></b>
|
||||
<b>• Apps:</b> Type to search.
|
||||
|
||||
<b>• Calculator [Prefix "="]:</b> Solve a math expression.
|
||||
e.g. "=2+2"
|
||||
|
||||
<b>• Converter [Prefix ";"]:</b> Convert between units.
|
||||
e.g. ";100 USD to EUR", ";10 km to miles"
|
||||
|
||||
<b>• Special Commands [Prefix ":"]:</b>
|
||||
:update - Open {data.APP_NAME_CAP}'s updater.
|
||||
:d - Open Dashboard.
|
||||
:w - Open Wallpapers."""
|
||||
|
||||
tooltip_power = """<b>Power Menu</b>"""
|
||||
tooltip_tools = """<b>Toolbox</b>"""
|
||||
tooltip_overview = """<b>Overview</b>"""
|
||||
|
||||
|
||||
class Bar(Window):
|
||||
def __init__(self, monitor_id: int = 0, **kwargs):
|
||||
self.monitor_id = monitor_id
|
||||
|
||||
super().__init__(
|
||||
name="bar",
|
||||
layer="top",
|
||||
exclusivity="auto",
|
||||
visible=True,
|
||||
all_visible=True,
|
||||
monitor=monitor_id,
|
||||
)
|
||||
|
||||
self.anchor_var = ""
|
||||
self.margin_var = ""
|
||||
|
||||
match data.BAR_POSITION:
|
||||
case "Top":
|
||||
self.anchor_var = "left top right"
|
||||
case "Bottom":
|
||||
self.anchor_var = "left bottom right"
|
||||
case "Left":
|
||||
self.anchor_var = "left" if data.CENTERED_BAR else "left top bottom"
|
||||
case "Right":
|
||||
self.anchor_var = "right" if data.CENTERED_BAR else "top right bottom"
|
||||
case _:
|
||||
self.anchor_var = "left top right"
|
||||
|
||||
if data.VERTICAL:
|
||||
match data.BAR_THEME:
|
||||
case "Edge":
|
||||
self.margin_var = "-8px -8px -8px -8px"
|
||||
case _:
|
||||
self.margin_var = "-4px -8px -4px -4px"
|
||||
else:
|
||||
match data.BAR_THEME:
|
||||
case "Edge":
|
||||
self.margin_var = "-8px -8px -8px -8px"
|
||||
case _:
|
||||
if data.BAR_POSITION == "Bottom":
|
||||
self.margin_var = "-8px -4px -4px -4px"
|
||||
else:
|
||||
self.margin_var = "-4px -4px -8px -4px"
|
||||
|
||||
self.set_anchor(self.anchor_var)
|
||||
self.set_margin(self.margin_var)
|
||||
|
||||
self.notch = kwargs.get("notch", None)
|
||||
self.component_visibility = data.BAR_COMPONENTS_VISIBILITY
|
||||
|
||||
self.dock_instance = None
|
||||
self.integrated_dock_widget = None
|
||||
|
||||
# Calculate workspace range based on monitor_id
|
||||
# Monitor 0: workspaces 1-10, Monitor 1: workspaces 11-20, etc.
|
||||
start_workspace = self.monitor_id * 10 + 1
|
||||
end_workspace = start_workspace + 10
|
||||
workspace_range = range(start_workspace, end_workspace)
|
||||
|
||||
self.workspaces = Workspaces(
|
||||
name="workspaces",
|
||||
invert_scroll=True,
|
||||
empty_scroll=True,
|
||||
v_align="fill",
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
spacing=8,
|
||||
buttons=[
|
||||
WorkspaceButton(
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
id=i,
|
||||
label=None,
|
||||
style_classes=["vertical"] if data.VERTICAL else None,
|
||||
)
|
||||
for i in workspace_range
|
||||
],
|
||||
buttons_factory=(
|
||||
None
|
||||
if data.BAR_HIDE_SPECIAL_WORKSPACE
|
||||
else Workspaces.default_buttons_factory
|
||||
),
|
||||
)
|
||||
|
||||
self.workspaces_num = Workspaces(
|
||||
name="workspaces-num",
|
||||
invert_scroll=True,
|
||||
empty_scroll=True,
|
||||
v_align="fill",
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
spacing=0 if not data.BAR_WORKSPACE_USE_CHINESE_NUMERALS else 4,
|
||||
buttons=[
|
||||
WorkspaceButton(
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
id=i,
|
||||
label=(
|
||||
CHINESE_NUMERALS[(i - start_workspace)]
|
||||
if data.BAR_WORKSPACE_USE_CHINESE_NUMERALS
|
||||
and 0 <= (i - start_workspace) < len(CHINESE_NUMERALS)
|
||||
else str(i)
|
||||
),
|
||||
)
|
||||
for i in workspace_range
|
||||
],
|
||||
buttons_factory=(
|
||||
None
|
||||
if data.BAR_HIDE_SPECIAL_WORKSPACE
|
||||
else Workspaces.default_buttons_factory
|
||||
),
|
||||
)
|
||||
|
||||
self.ws_container = Box(
|
||||
name="workspaces-container",
|
||||
children=(
|
||||
self.workspaces
|
||||
if not data.BAR_WORKSPACE_SHOW_NUMBER
|
||||
else self.workspaces_num
|
||||
),
|
||||
)
|
||||
|
||||
self.button_tools = Button(
|
||||
name="button-bar",
|
||||
tooltip_markup=tooltip_tools,
|
||||
on_clicked=lambda *_: self.tools_menu(),
|
||||
child=Label(name="button-bar-label", markup=icons.toolbox),
|
||||
)
|
||||
|
||||
self.connection = get_hyprland_connection()
|
||||
self.button_tools.connect("enter_notify_event", self.on_button_enter)
|
||||
self.button_tools.connect("leave_notify_event", self.on_button_leave)
|
||||
|
||||
self.systray = SystemTray()
|
||||
|
||||
self.weather = Weather()
|
||||
self.sysprofiles = Systemprofiles()
|
||||
|
||||
self.network = NetworkApplet()
|
||||
|
||||
self.lang_label = Label(name="lang-label")
|
||||
self.language = Button(
|
||||
name="language", h_align="center", v_align="center", child=self.lang_label
|
||||
)
|
||||
self.on_language_switch()
|
||||
self.connection.connect("event::activelayout", self.on_language_switch)
|
||||
|
||||
# Determine date-time format based on the new setting
|
||||
if data.DATETIME_12H_FORMAT:
|
||||
time_format_horizontal = "%I:%M %p"
|
||||
time_format_vertical = "%I\n%M\n%p"
|
||||
else:
|
||||
time_format_horizontal = "%H:%M"
|
||||
time_format_vertical = "%H\n%M"
|
||||
|
||||
self.date_time = DateTime(
|
||||
name="date-time",
|
||||
formatters=(
|
||||
[time_format_horizontal]
|
||||
if not data.VERTICAL
|
||||
else [time_format_vertical]
|
||||
),
|
||||
h_align="center" if not data.VERTICAL else "fill",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
style_classes=["vertical"] if data.VERTICAL else [],
|
||||
)
|
||||
|
||||
self.button_apps = Button(
|
||||
name="button-bar",
|
||||
tooltip_markup=tooltip_apps,
|
||||
on_clicked=lambda *_: self.search_apps(),
|
||||
child=Label(name="button-bar-label", markup=icons.apps),
|
||||
)
|
||||
self.button_apps.connect("enter_notify_event", self.on_button_enter)
|
||||
self.button_apps.connect("leave_notify_event", self.on_button_leave)
|
||||
|
||||
self.button_power = Button(
|
||||
name="button-bar",
|
||||
tooltip_markup=tooltip_power,
|
||||
on_clicked=lambda *_: self.power_menu(),
|
||||
child=Label(name="button-bar-label", markup=icons.shutdown),
|
||||
)
|
||||
self.button_power.connect("enter_notify_event", self.on_button_enter)
|
||||
self.button_power.connect("leave_notify_event", self.on_button_leave)
|
||||
|
||||
self.button_overview = Button(
|
||||
name="button-bar",
|
||||
tooltip_markup=tooltip_overview,
|
||||
on_clicked=lambda *_: self.overview(),
|
||||
child=Label(name="button-bar-label", markup=icons.windows),
|
||||
)
|
||||
self.button_overview.connect("enter_notify_event", self.on_button_enter)
|
||||
self.button_overview.connect("leave_notify_event", self.on_button_leave)
|
||||
|
||||
self.control = ControlSmall()
|
||||
self.metrics = MetricsSmall()
|
||||
self.battery = Battery()
|
||||
|
||||
self.apply_component_props()
|
||||
|
||||
self.rev_right = [
|
||||
self.metrics,
|
||||
self.control,
|
||||
]
|
||||
|
||||
self.revealer_right = Revealer(
|
||||
name="bar-revealer",
|
||||
transition_type="slide-left",
|
||||
child_revealed=True,
|
||||
child=Box(
|
||||
name="bar-revealer-box",
|
||||
orientation="h",
|
||||
spacing=4,
|
||||
children=self.rev_right if not data.VERTICAL else None,
|
||||
),
|
||||
)
|
||||
|
||||
self.boxed_revealer_right = Box(
|
||||
name="boxed-revealer",
|
||||
children=[
|
||||
self.revealer_right,
|
||||
],
|
||||
)
|
||||
|
||||
self.rev_left = [
|
||||
self.weather,
|
||||
self.sysprofiles,
|
||||
self.network,
|
||||
]
|
||||
|
||||
self.revealer_left = Revealer(
|
||||
name="bar-revealer",
|
||||
transition_type="slide-right",
|
||||
child_revealed=True,
|
||||
child=Box(
|
||||
name="bar-revealer-box",
|
||||
orientation="h",
|
||||
spacing=4,
|
||||
children=self.rev_left if not data.VERTICAL else None,
|
||||
),
|
||||
)
|
||||
|
||||
self.boxed_revealer_left = Box(
|
||||
name="boxed-revealer",
|
||||
children=[
|
||||
self.revealer_left,
|
||||
],
|
||||
)
|
||||
|
||||
self.h_start_children = [
|
||||
self.button_apps,
|
||||
self.ws_container,
|
||||
self.button_overview,
|
||||
self.boxed_revealer_left,
|
||||
]
|
||||
|
||||
self.h_end_children = [
|
||||
self.boxed_revealer_right,
|
||||
self.battery,
|
||||
self.systray,
|
||||
self.button_tools,
|
||||
self.language,
|
||||
self.date_time,
|
||||
self.button_power,
|
||||
]
|
||||
|
||||
self.v_start_children = [
|
||||
self.button_apps,
|
||||
self.systray,
|
||||
self.control,
|
||||
self.sysprofiles,
|
||||
self.network,
|
||||
self.button_tools,
|
||||
]
|
||||
|
||||
self.v_center_children = [
|
||||
self.button_overview,
|
||||
self.ws_container,
|
||||
self.weather,
|
||||
]
|
||||
|
||||
self.v_end_children = [
|
||||
self.battery,
|
||||
self.metrics,
|
||||
self.language,
|
||||
self.date_time,
|
||||
self.button_power,
|
||||
]
|
||||
|
||||
self.v_all_children = []
|
||||
self.v_all_children.extend(self.v_start_children)
|
||||
self.v_all_children.extend(self.v_center_children)
|
||||
self.v_all_children.extend(self.v_end_children)
|
||||
|
||||
# Create embedded dock when bar is in center position (regardless of DOCK_ENABLED setting)
|
||||
should_embed_dock = (
|
||||
data.BAR_POSITION == "Bottom"
|
||||
or (data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Top", "Bottom"])
|
||||
)
|
||||
|
||||
if should_embed_dock:
|
||||
if not data.VERTICAL:
|
||||
self.dock_instance = Dock(integrated_mode=True)
|
||||
self.integrated_dock_widget = self.dock_instance.wrapper
|
||||
|
||||
is_centered_bar = data.VERTICAL and getattr(data, "CENTERED_BAR", False)
|
||||
|
||||
bar_center_actual_children = None
|
||||
|
||||
if self.integrated_dock_widget is not None:
|
||||
bar_center_actual_children = self.integrated_dock_widget
|
||||
elif data.VERTICAL:
|
||||
bar_center_actual_children = Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=4,
|
||||
children=(
|
||||
self.v_all_children if is_centered_bar else self.v_center_children
|
||||
),
|
||||
)
|
||||
|
||||
self.bar_inner = CenterBox(
|
||||
name="bar-inner",
|
||||
orientation=(
|
||||
Gtk.Orientation.HORIZONTAL
|
||||
if not data.VERTICAL
|
||||
else Gtk.Orientation.VERTICAL
|
||||
),
|
||||
h_align="fill",
|
||||
v_align="fill",
|
||||
start_children=(
|
||||
None
|
||||
if is_centered_bar
|
||||
else Box(
|
||||
name="start-container",
|
||||
spacing=4,
|
||||
orientation=(
|
||||
Gtk.Orientation.HORIZONTAL
|
||||
if not data.VERTICAL
|
||||
else Gtk.Orientation.VERTICAL
|
||||
),
|
||||
children=(
|
||||
self.h_start_children
|
||||
if not data.VERTICAL
|
||||
else self.v_start_children
|
||||
),
|
||||
)
|
||||
),
|
||||
center_children=bar_center_actual_children,
|
||||
end_children=(
|
||||
None
|
||||
if is_centered_bar
|
||||
else Box(
|
||||
name="end-container",
|
||||
spacing=4,
|
||||
orientation=(
|
||||
Gtk.Orientation.HORIZONTAL
|
||||
if not data.VERTICAL
|
||||
else Gtk.Orientation.VERTICAL
|
||||
),
|
||||
children=(
|
||||
self.h_end_children
|
||||
if not data.VERTICAL
|
||||
else self.v_end_children
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
self.children = self.bar_inner
|
||||
|
||||
self.hidden = False
|
||||
|
||||
self.themed_children = [
|
||||
self.button_apps,
|
||||
self.button_overview,
|
||||
self.button_power,
|
||||
self.button_tools,
|
||||
self.language,
|
||||
self.date_time,
|
||||
self.ws_container,
|
||||
self.weather,
|
||||
self.network,
|
||||
self.battery,
|
||||
self.metrics,
|
||||
self.systray,
|
||||
self.control,
|
||||
]
|
||||
if self.integrated_dock_widget:
|
||||
self.themed_children.append(self.integrated_dock_widget)
|
||||
|
||||
current_theme = data.BAR_THEME
|
||||
theme_classes = ["pills", "dense", "edge", "edgecenter"]
|
||||
for tc in theme_classes:
|
||||
self.bar_inner.remove_style_class(tc)
|
||||
|
||||
self.style = None
|
||||
match current_theme:
|
||||
case "Pills":
|
||||
self.style = "pills"
|
||||
case "Dense":
|
||||
self.style = "dense"
|
||||
case "Edge":
|
||||
if data.VERTICAL and data.CENTERED_BAR:
|
||||
self.style = "edgecenter"
|
||||
else:
|
||||
self.style = "edge"
|
||||
case _:
|
||||
self.style = "pills"
|
||||
|
||||
self.bar_inner.add_style_class(self.style)
|
||||
|
||||
if self.integrated_dock_widget and hasattr(
|
||||
self.integrated_dock_widget, "add_style_class"
|
||||
):
|
||||
for theme_class_to_remove in ["pills", "dense", "edge"]:
|
||||
style_context = self.integrated_dock_widget.get_style_context()
|
||||
if style_context.has_class(theme_class_to_remove):
|
||||
self.integrated_dock_widget.remove_style_class(
|
||||
theme_class_to_remove
|
||||
)
|
||||
self.integrated_dock_widget.add_style_class(self.style)
|
||||
|
||||
if data.BAR_THEME == "Dense" or data.BAR_THEME == "Edge":
|
||||
for child in self.themed_children:
|
||||
if hasattr(child, "add_style_class"):
|
||||
child.add_style_class("invert")
|
||||
|
||||
match data.BAR_POSITION:
|
||||
case "Top":
|
||||
self.bar_inner.add_style_class("top")
|
||||
case "Bottom":
|
||||
self.bar_inner.add_style_class("bottom")
|
||||
case "Left":
|
||||
self.bar_inner.add_style_class("left")
|
||||
case "Right":
|
||||
self.bar_inner.add_style_class("right")
|
||||
case _:
|
||||
self.bar_inner.add_style_class("top")
|
||||
|
||||
if data.VERTICAL:
|
||||
self.bar_inner.add_style_class("vertical")
|
||||
|
||||
self.systray._update_visibility()
|
||||
self.chinese_numbers()
|
||||
|
||||
def apply_component_props(self):
|
||||
components = {
|
||||
"button_apps": self.button_apps,
|
||||
"systray": self.systray,
|
||||
"control": self.control,
|
||||
"network": self.network,
|
||||
"button_tools": self.button_tools,
|
||||
"button_overview": self.button_overview,
|
||||
"ws_container": self.ws_container,
|
||||
"weather": self.weather,
|
||||
"battery": self.battery,
|
||||
"metrics": self.metrics,
|
||||
"language": self.language,
|
||||
"date_time": self.date_time,
|
||||
"button_power": self.button_power,
|
||||
"sysprofiles": self.sysprofiles,
|
||||
}
|
||||
|
||||
for component_name, widget in components.items():
|
||||
if component_name in self.component_visibility:
|
||||
widget.set_visible(self.component_visibility[component_name])
|
||||
|
||||
def toggle_component_visibility(self, component_name):
|
||||
components = {
|
||||
"button_apps": self.button_apps,
|
||||
"systray": self.systray,
|
||||
"control": self.control,
|
||||
"network": self.network,
|
||||
"button_tools": self.button_tools,
|
||||
"button_overview": self.button_overview,
|
||||
"ws_container": self.ws_container,
|
||||
"weather": self.weather,
|
||||
"battery": self.battery,
|
||||
"metrics": self.metrics,
|
||||
"language": self.language,
|
||||
"date_time": self.date_time,
|
||||
"button_power": self.button_power,
|
||||
"sysprofiles": self.sysprofiles,
|
||||
}
|
||||
|
||||
if component_name in components and component_name in self.component_visibility:
|
||||
self.component_visibility[component_name] = not self.component_visibility[
|
||||
component_name
|
||||
]
|
||||
components[component_name].set_visible(
|
||||
self.component_visibility[component_name]
|
||||
)
|
||||
|
||||
config_file = os.path.expanduser(
|
||||
f"~/.config/{data.APP_NAME}/config/config.json"
|
||||
)
|
||||
if os.path.exists(config_file):
|
||||
try:
|
||||
with open(config_file, "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
config[f"bar_{component_name}_visible"] = self.component_visibility[
|
||||
component_name
|
||||
]
|
||||
|
||||
with open(config_file, "w") as f:
|
||||
json.dump(config, f, indent=4)
|
||||
except Exception as e:
|
||||
print(f"Error updating config file: {e}")
|
||||
|
||||
return self.component_visibility[component_name]
|
||||
|
||||
return None
|
||||
|
||||
def on_button_enter(self, widget, event):
|
||||
window = widget.get_window()
|
||||
if window:
|
||||
window.set_cursor(Gdk.Cursor.new_from_name(widget.get_display(), "hand2"))
|
||||
|
||||
def on_button_leave(self, widget, event):
|
||||
window = widget.get_window()
|
||||
if window:
|
||||
window.set_cursor(None)
|
||||
|
||||
def on_button_clicked(self, *args):
|
||||
exec_shell_command_async("notify-send 'Botón presionado' '¡Funciona!'")
|
||||
|
||||
def search_apps(self):
|
||||
if self.notch:
|
||||
self.notch.open_notch("launcher")
|
||||
|
||||
def overview(self):
|
||||
if self.notch:
|
||||
self.notch.open_notch("overview")
|
||||
|
||||
def power_menu(self):
|
||||
if self.notch:
|
||||
self.notch.open_notch("power")
|
||||
|
||||
def tools_menu(self):
|
||||
if self.notch:
|
||||
self.notch.open_notch("tools")
|
||||
|
||||
def on_language_switch(self, _=None, event: HyprlandEvent = None):
|
||||
try:
|
||||
lang_data = (
|
||||
event.data[1]
|
||||
if event and event.data and len(event.data) > 1
|
||||
else Language().get_label()
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
lang_data = "UNK" # Fallback to default language label
|
||||
self.language.set_tooltip_text(lang_data)
|
||||
if not data.VERTICAL:
|
||||
self.lang_label.set_label(lang_data[:3].upper())
|
||||
else:
|
||||
self.lang_label.add_style_class("icon")
|
||||
self.lang_label.set_markup(icons.keyboard)
|
||||
|
||||
def toggle_hidden(self):
|
||||
self.hidden = not self.hidden
|
||||
if self.hidden:
|
||||
self.bar_inner.add_style_class("hidden")
|
||||
else:
|
||||
self.bar_inner.remove_style_class("hidden")
|
||||
# Ensure notch is above bar when bar is shown
|
||||
if self.notch:
|
||||
# Focus the notch window to bring it to front
|
||||
GLib.idle_add(lambda: exec_shell_command_async("hyprctl dispatch focuswindow class:notch") if self.notch else None)
|
||||
|
||||
def chinese_numbers(self):
|
||||
if data.BAR_WORKSPACE_USE_CHINESE_NUMERALS:
|
||||
self.workspaces_num.add_style_class("chinese")
|
||||
else:
|
||||
self.workspaces_num.remove_style_class("chinese")
|
||||
@@ -0,0 +1,160 @@
|
||||
from fabric.bluetooth import BluetoothClient, BluetoothDevice
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
|
||||
import modules.icons as icons
|
||||
|
||||
|
||||
class BluetoothDeviceSlot(CenterBox):
|
||||
def __init__(self, device: BluetoothDevice, **kwargs):
|
||||
super().__init__(name="bluetooth-device", **kwargs)
|
||||
self.device = device
|
||||
self.device.connect("changed", self.on_changed)
|
||||
self.device.connect(
|
||||
"notify::closed", lambda *_: self.device.closed and self.destroy()
|
||||
)
|
||||
|
||||
self.connection_label = Label(name="bluetooth-connection", markup=icons.bluetooth_disconnected)
|
||||
self.connect_button = Button(
|
||||
name="bluetooth-connect",
|
||||
label="Connect",
|
||||
on_clicked=lambda *_: self.device.set_connecting(not self.device.connected),
|
||||
style_classes=["connected"] if self.device.connected else None,
|
||||
)
|
||||
|
||||
self.start_children = [
|
||||
Box(
|
||||
spacing=8,
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
children=[
|
||||
Image(icon_name=device.icon_name + "-symbolic", size=16),
|
||||
Label(label=device.name, h_expand=True, h_align="start", ellipsization="end"),
|
||||
self.connection_label,
|
||||
],
|
||||
)
|
||||
]
|
||||
self.end_children = self.connect_button
|
||||
|
||||
self.device.emit("changed")
|
||||
|
||||
def on_changed(self, *_):
|
||||
self.connection_label.set_markup(
|
||||
icons.bluetooth_connected if self.device.connected else icons.bluetooth_disconnected
|
||||
)
|
||||
if self.device.connecting:
|
||||
self.connect_button.set_label(
|
||||
"Connecting..." if not self.device.connecting else "..."
|
||||
)
|
||||
else:
|
||||
self.connect_button.set_label(
|
||||
"Connect" if not self.device.connected else "Disconnect"
|
||||
)
|
||||
if self.device.connected:
|
||||
self.connect_button.add_style_class("connected")
|
||||
else:
|
||||
self.connect_button.remove_style_class("connected")
|
||||
return
|
||||
|
||||
class BluetoothConnections(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="bluetooth",
|
||||
spacing=4,
|
||||
orientation="vertical",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.widgets = kwargs["widgets"]
|
||||
|
||||
self.buttons = self.widgets.buttons.bluetooth_button
|
||||
self.bt_status_text = self.buttons.bluetooth_status_text
|
||||
self.bt_status_button = self.buttons.bluetooth_status_button
|
||||
self.bt_icon = self.buttons.bluetooth_icon
|
||||
self.bt_label = self.buttons.bluetooth_label
|
||||
self.bt_menu_button = self.buttons.bluetooth_menu_button
|
||||
self.bt_menu_label = self.buttons.bluetooth_menu_label
|
||||
|
||||
self.client = BluetoothClient(on_device_added=self.on_device_added)
|
||||
self.scan_label = Label(name="bluetooth-scan-label", markup=icons.radar)
|
||||
self.scan_button = Button(
|
||||
name="bluetooth-scan",
|
||||
child=self.scan_label,
|
||||
tooltip_text="Scan for Bluetooth devices",
|
||||
on_clicked=lambda *_: self.client.toggle_scan()
|
||||
)
|
||||
self.back_button = Button(
|
||||
name="bluetooth-back",
|
||||
child=Label(name="bluetooth-back-label", markup=icons.chevron_left),
|
||||
on_clicked=lambda *_: self.widgets.show_notif()
|
||||
)
|
||||
|
||||
self.client.connect("notify::enabled", lambda *_: self.status_label())
|
||||
self.client.connect(
|
||||
"notify::scanning",
|
||||
lambda *_: self.update_scan_label()
|
||||
)
|
||||
|
||||
self.paired_box = Box(spacing=2, orientation="vertical")
|
||||
self.available_box = Box(spacing=2, orientation="vertical")
|
||||
|
||||
content_box = Box(spacing=4, orientation="vertical")
|
||||
content_box.add(self.paired_box)
|
||||
content_box.add(Label(name="bluetooth-section", label="Available"))
|
||||
content_box.add(self.available_box)
|
||||
|
||||
self.children = [
|
||||
CenterBox(
|
||||
name="bluetooth-header",
|
||||
start_children=self.back_button,
|
||||
center_children=Label(name="bluetooth-text", label="Bluetooth Devices"),
|
||||
end_children=self.scan_button
|
||||
),
|
||||
ScrolledWindow(
|
||||
name="bluetooth-devices",
|
||||
min_content_size=(-1, -1),
|
||||
child=content_box,
|
||||
v_expand=True,
|
||||
propagate_width=False,
|
||||
propagate_height=False,
|
||||
),
|
||||
]
|
||||
|
||||
self.client.notify("scanning")
|
||||
self.client.notify("enabled")
|
||||
|
||||
def status_label(self):
|
||||
print(self.client.enabled)
|
||||
if self.client.enabled:
|
||||
self.bt_status_text.set_label("Enabled")
|
||||
for i in [self.bt_status_button, self.bt_status_text, self.bt_icon, self.bt_label, self.bt_menu_button, self.bt_menu_label]:
|
||||
i.remove_style_class("disabled")
|
||||
self.bt_icon.set_markup(icons.bluetooth)
|
||||
else:
|
||||
self.bt_status_text.set_label("Disabled")
|
||||
for i in [self.bt_status_button, self.bt_status_text, self.bt_icon, self.bt_label, self.bt_menu_button, self.bt_menu_label]:
|
||||
i.add_style_class("disabled")
|
||||
self.bt_icon.set_markup(icons.bluetooth_off)
|
||||
|
||||
def on_device_added(self, client: BluetoothClient, address: str):
|
||||
if not (device := client.get_device(address)):
|
||||
return
|
||||
slot = BluetoothDeviceSlot(device)
|
||||
|
||||
if device.paired:
|
||||
return self.paired_box.add(slot)
|
||||
return self.available_box.add(slot)
|
||||
|
||||
def update_scan_label(self):
|
||||
if self.client.scanning:
|
||||
self.scan_label.add_style_class("scanning")
|
||||
self.scan_button.add_style_class("scanning")
|
||||
self.scan_button.set_tooltip_text("Stop scanning for Bluetooth devices")
|
||||
else:
|
||||
self.scan_label.remove_style_class("scanning")
|
||||
self.scan_button.remove_style_class("scanning")
|
||||
self.scan_button.set_tooltip_text("Scan for Bluetooth devices")
|
||||
@@ -0,0 +1,472 @@
|
||||
import subprocess
|
||||
|
||||
import gi
|
||||
from fabric.utils.helpers import exec_shell_command_async
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
import config.data as data
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
import modules.icons as icons
|
||||
from services.network import NetworkClient
|
||||
|
||||
|
||||
def add_hover_cursor(widget):
|
||||
widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
|
||||
widget.connect("enter-notify-event", lambda w, e: w.get_window().set_cursor(Gdk.Cursor.new_from_name(w.get_display(), "pointer")) if w.get_window() else None)
|
||||
widget.connect("leave-notify-event", lambda w, e: w.get_window().set_cursor(None) if w.get_window() else None)
|
||||
|
||||
class NetworkButton(Box):
|
||||
def __init__(self, **kwargs):
|
||||
self.widgets_instance = kwargs.pop("widgets")
|
||||
self.network_client = NetworkClient()
|
||||
self._animation_timeout_id = None
|
||||
self._animation_step = 0
|
||||
self._animation_direction = 1
|
||||
|
||||
self.network_icon = Label(
|
||||
name="network-icon",
|
||||
markup=None,
|
||||
)
|
||||
self.network_label = Label(
|
||||
name="network-label",
|
||||
label="Wi-Fi",
|
||||
justification="left",
|
||||
)
|
||||
self.network_label_box = Box(children=[self.network_label, Box(h_expand=True)])
|
||||
self.network_ssid = Label(
|
||||
name="network-ssid",
|
||||
justification="left",
|
||||
)
|
||||
self.network_ssid_box = Box(children=[self.network_ssid, Box(h_expand=True)])
|
||||
self.network_text = Box(
|
||||
name="network-text",
|
||||
orientation="v",
|
||||
h_align="start",
|
||||
v_align="center",
|
||||
children=[self.network_label_box, self.network_ssid_box],
|
||||
)
|
||||
self.network_status_box = Box(
|
||||
h_align="start",
|
||||
v_align="center",
|
||||
spacing=10,
|
||||
|
||||
children=[self.network_icon, self.network_text],
|
||||
)
|
||||
self.network_status_button = Button(
|
||||
name="network-status-button",
|
||||
h_expand=True,
|
||||
child=self.network_status_box,
|
||||
on_clicked=lambda *_: self.network_client.wifi_device.toggle_wifi() if self.network_client.wifi_device else None,
|
||||
)
|
||||
add_hover_cursor(self.network_status_button)
|
||||
|
||||
self.network_menu_label = Label(
|
||||
name="network-menu-label",
|
||||
markup=icons.chevron_right,
|
||||
)
|
||||
self.network_menu_button = Button(
|
||||
name="network-menu-button",
|
||||
child=self.network_menu_label,
|
||||
on_clicked=lambda *_: self.widgets_instance.show_network_applet(),
|
||||
)
|
||||
add_hover_cursor(self.network_menu_button)
|
||||
|
||||
super().__init__(
|
||||
name="network-button",
|
||||
orientation="h",
|
||||
h_align="fill",
|
||||
v_align="fill",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
spacing=0,
|
||||
children=[self.network_status_button, self.network_menu_button],
|
||||
)
|
||||
|
||||
self.widgets_list_internal = [self, self.network_icon, self.network_label,
|
||||
self.network_ssid, self.network_status_button,
|
||||
self.network_menu_button, self.network_menu_label]
|
||||
|
||||
self.network_client.connect('device-ready', self._on_wifi_ready)
|
||||
|
||||
GLib.idle_add(self._initial_update)
|
||||
|
||||
def _initial_update(self):
|
||||
self.update_state()
|
||||
return False
|
||||
|
||||
def _on_wifi_ready(self, *args):
|
||||
if self.network_client.wifi_device:
|
||||
self.network_client.wifi_device.connect('notify::enabled', self.update_state)
|
||||
self.network_client.wifi_device.connect('notify::ssid', self.update_state)
|
||||
self.update_state()
|
||||
|
||||
def _animate_searching(self):
|
||||
"""Animate wifi icon when searching for networks"""
|
||||
wifi_icons = [icons.wifi_0, icons.wifi_1, icons.wifi_2, icons.wifi_3, icons.wifi_2, icons.wifi_1]
|
||||
|
||||
wifi = self.network_client.wifi_device
|
||||
if not self.network_icon or not wifi or not wifi.enabled:
|
||||
self._stop_animation()
|
||||
return False
|
||||
|
||||
if wifi.state == "activated" and wifi.ssid != "Disconnected":
|
||||
self._stop_animation()
|
||||
return False
|
||||
|
||||
GLib.idle_add(self.network_icon.set_markup, wifi_icons[self._animation_step])
|
||||
|
||||
self._animation_step = (self._animation_step + 1) % len(wifi_icons)
|
||||
|
||||
return True
|
||||
|
||||
def _start_animation(self):
|
||||
if self._animation_timeout_id is None:
|
||||
self._animation_step = 0
|
||||
self._animation_direction = 1
|
||||
|
||||
self._animation_timeout_id = GLib.timeout_add(500, self._animate_searching)
|
||||
|
||||
def _stop_animation(self):
|
||||
if self._animation_timeout_id is not None:
|
||||
GLib.source_remove(self._animation_timeout_id)
|
||||
self._animation_timeout_id = None
|
||||
|
||||
def update_state(self, *args):
|
||||
"""Update the button state based on network status"""
|
||||
wifi = self.network_client.wifi_device
|
||||
ethernet = self.network_client.ethernet_device
|
||||
|
||||
if wifi and not wifi.enabled:
|
||||
self._stop_animation()
|
||||
self.network_icon.set_markup(icons.wifi_off)
|
||||
for widget in self.widgets_list_internal:
|
||||
widget.add_style_class("disabled")
|
||||
self.network_ssid.set_label("Disabled")
|
||||
return
|
||||
|
||||
for widget in self.widgets_list_internal:
|
||||
widget.remove_style_class("disabled")
|
||||
|
||||
if wifi and wifi.enabled:
|
||||
if wifi.state == "activated" and wifi.ssid != "Disconnected":
|
||||
self._stop_animation()
|
||||
self.network_ssid.set_label(wifi.ssid)
|
||||
|
||||
if wifi.strength > 0:
|
||||
strength = wifi.strength
|
||||
if strength < 25:
|
||||
self.network_icon.set_markup(icons.wifi_0)
|
||||
elif strength < 50:
|
||||
self.network_icon.set_markup(icons.wifi_1)
|
||||
elif strength < 75:
|
||||
self.network_icon.set_markup(icons.wifi_2)
|
||||
else:
|
||||
self.network_icon.set_markup(icons.wifi_3)
|
||||
else:
|
||||
self.network_ssid.set_label("Enabled")
|
||||
self._start_animation()
|
||||
|
||||
try:
|
||||
primary_device = self.network_client.primary_device
|
||||
except AttributeError:
|
||||
primary_device = "wireless"
|
||||
|
||||
if primary_device == "wired":
|
||||
self._stop_animation()
|
||||
if ethernet and ethernet.internet == "activated":
|
||||
self.network_icon.set_markup(icons.world)
|
||||
else:
|
||||
self.network_icon.set_markup(icons.world_off)
|
||||
else:
|
||||
if not wifi:
|
||||
self._stop_animation()
|
||||
self.network_icon.set_markup(icons.wifi_off)
|
||||
elif wifi.state == "activated" and wifi.ssid != "Disconnected" and wifi.strength > 0:
|
||||
self._stop_animation()
|
||||
strength = wifi.strength
|
||||
if strength < 25:
|
||||
self.network_icon.set_markup(icons.wifi_0)
|
||||
elif strength < 50:
|
||||
self.network_icon.set_markup(icons.wifi_1)
|
||||
elif strength < 75:
|
||||
self.network_icon.set_markup(icons.wifi_2)
|
||||
else:
|
||||
self.network_icon.set_markup(icons.wifi_3)
|
||||
else:
|
||||
self._start_animation()
|
||||
|
||||
class BluetoothButton(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="bluetooth-button",
|
||||
orientation="h",
|
||||
h_align="fill",
|
||||
v_align="fill",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
self.widgets = kwargs["widgets"]
|
||||
|
||||
self.bluetooth_icon = Label(
|
||||
name="bluetooth-icon",
|
||||
markup=icons.bluetooth,
|
||||
)
|
||||
self.bluetooth_label = Label(
|
||||
name="bluetooth-label",
|
||||
label="Bluetooth",
|
||||
justification="left",
|
||||
)
|
||||
self.bluetooth_label_box = Box(children=[self.bluetooth_label, Box(h_expand=True)])
|
||||
self.bluetooth_status_text = Label(
|
||||
name="bluetooth-status",
|
||||
label="Disabled",
|
||||
justification="left",
|
||||
)
|
||||
self.bluetooth_status_box = Box(children=[self.bluetooth_status_text, Box(h_expand=True)])
|
||||
self.bluetooth_text = Box(
|
||||
orientation="v",
|
||||
h_align="start",
|
||||
v_align="center",
|
||||
children=[self.bluetooth_label_box, self.bluetooth_status_box],
|
||||
)
|
||||
self.bluetooth_status_container = Box(
|
||||
h_align="start",
|
||||
v_align="center",
|
||||
spacing=10,
|
||||
children=[self.bluetooth_icon, self.bluetooth_text],
|
||||
)
|
||||
self.bluetooth_status_button = Button(
|
||||
name="bluetooth-status-button",
|
||||
h_expand=True,
|
||||
child=self.bluetooth_status_container,
|
||||
on_clicked=lambda *_: self.widgets.bluetooth.client.toggle_power(),
|
||||
)
|
||||
add_hover_cursor(self.bluetooth_status_button)
|
||||
self.bluetooth_menu_label = Label(
|
||||
name="bluetooth-menu-label",
|
||||
markup=icons.chevron_right,
|
||||
)
|
||||
self.bluetooth_menu_button = Button(
|
||||
name="bluetooth-menu-button",
|
||||
on_clicked=lambda *_: self.widgets.show_bt(),
|
||||
child=self.bluetooth_menu_label,
|
||||
)
|
||||
add_hover_cursor(self.bluetooth_menu_button)
|
||||
|
||||
self.add(self.bluetooth_status_button)
|
||||
self.add(self.bluetooth_menu_button)
|
||||
|
||||
class NightModeButton(Button):
|
||||
def __init__(self):
|
||||
self.night_mode_icon = Label(
|
||||
name="night-mode-icon",
|
||||
markup=icons.night,
|
||||
)
|
||||
self.night_mode_label = Label(
|
||||
name="night-mode-label",
|
||||
label="Night Mode",
|
||||
justification="left",
|
||||
)
|
||||
self.night_mode_label_box = Box(children=[self.night_mode_label, Box(h_expand=True)])
|
||||
self.night_mode_status = Label(
|
||||
name="night-mode-status",
|
||||
label="Enabled",
|
||||
justification="left",
|
||||
)
|
||||
self.night_mode_status_box = Box(children=[self.night_mode_status, Box(h_expand=True)])
|
||||
self.night_mode_text = Box(
|
||||
name="night-mode-text",
|
||||
orientation="v",
|
||||
h_align="start",
|
||||
v_align="center",
|
||||
children=[self.night_mode_label_box, self.night_mode_status_box],
|
||||
)
|
||||
self.night_mode_box = Box(
|
||||
h_align="start",
|
||||
v_align="center",
|
||||
spacing=10,
|
||||
children=[self.night_mode_icon, self.night_mode_text],
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
name="night-mode-button",
|
||||
h_expand=True,
|
||||
child=self.night_mode_box,
|
||||
on_clicked=self.toggle_hyprsunset,
|
||||
)
|
||||
add_hover_cursor(self)
|
||||
|
||||
self.widgets = [self, self.night_mode_label, self.night_mode_status, self.night_mode_icon]
|
||||
self.check_hyprsunset()
|
||||
|
||||
def toggle_hyprsunset(self, *args):
|
||||
"""
|
||||
Toggle the 'hyprsunset' process:
|
||||
- If running, kill it and mark as 'Disabled'.
|
||||
- If not running, start it and mark as 'Enabled'.
|
||||
"""
|
||||
GLib.Thread.new("hyprsunset-toggle", self._toggle_hyprsunset_thread, None)
|
||||
|
||||
def _toggle_hyprsunset_thread(self, user_data):
|
||||
"""Background thread to check and toggle hyprsunset without blocking UI."""
|
||||
try:
|
||||
subprocess.check_output(["pgrep", "hyprsunset"])
|
||||
exec_shell_command_async("pkill hyprsunset")
|
||||
GLib.idle_add(self.night_mode_status.set_label, "Disabled")
|
||||
GLib.idle_add(self._add_disabled_style)
|
||||
except subprocess.CalledProcessError:
|
||||
exec_shell_command_async("hyprsunset -t 3500")
|
||||
GLib.idle_add(self.night_mode_status.set_label, "Enabled")
|
||||
GLib.idle_add(self._remove_disabled_style)
|
||||
|
||||
def _add_disabled_style(self):
|
||||
"""Helper to add disabled style to all widgets."""
|
||||
for widget in self.widgets:
|
||||
widget.add_style_class("disabled")
|
||||
|
||||
def _remove_disabled_style(self):
|
||||
"""Helper to remove disabled style from all widgets."""
|
||||
for widget in self.widgets:
|
||||
widget.remove_style_class("disabled")
|
||||
|
||||
def check_hyprsunset(self, *args):
|
||||
"""
|
||||
Update the button state based on whether hyprsunset is running.
|
||||
"""
|
||||
GLib.Thread.new("hyprsunset-check", self._check_hyprsunset_thread, None)
|
||||
|
||||
def _check_hyprsunset_thread(self, user_data):
|
||||
"""Background thread to check hyprsunset status without blocking UI."""
|
||||
try:
|
||||
subprocess.check_output(["pgrep", "hyprsunset"])
|
||||
GLib.idle_add(self.night_mode_status.set_label, "Enabled")
|
||||
GLib.idle_add(self._remove_disabled_style)
|
||||
except subprocess.CalledProcessError:
|
||||
GLib.idle_add(self.night_mode_status.set_label, "Disabled")
|
||||
GLib.idle_add(self._add_disabled_style)
|
||||
|
||||
class CaffeineButton(Button):
|
||||
def __init__(self):
|
||||
self.caffeine_icon = Label(
|
||||
name="caffeine-icon",
|
||||
markup=icons.coffee,
|
||||
)
|
||||
self.caffeine_label = Label(
|
||||
name="caffeine-label",
|
||||
label="Caffeine",
|
||||
justification="left",
|
||||
)
|
||||
self.caffeine_label_box = Box(children=[self.caffeine_label, Box(h_expand=True)])
|
||||
self.caffeine_status = Label(
|
||||
name="caffeine-status",
|
||||
label="Enabled",
|
||||
justification="left",
|
||||
)
|
||||
self.caffeine_status_box = Box(children=[self.caffeine_status, Box(h_expand=True)])
|
||||
self.caffeine_text = Box(
|
||||
name="caffeine-text",
|
||||
orientation="v",
|
||||
h_align="start",
|
||||
v_align="center",
|
||||
children=[self.caffeine_label_box, self.caffeine_status_box],
|
||||
)
|
||||
self.caffeine_box = Box(
|
||||
h_align="start",
|
||||
v_align="center",
|
||||
spacing=10,
|
||||
children=[self.caffeine_icon, self.caffeine_text],
|
||||
)
|
||||
super().__init__(
|
||||
name="caffeine-button",
|
||||
h_expand=True,
|
||||
child=self.caffeine_box,
|
||||
on_clicked=self.toggle_inhibit,
|
||||
)
|
||||
add_hover_cursor(self)
|
||||
|
||||
self.widgets = [self, self.caffeine_label, self.caffeine_status, self.caffeine_icon]
|
||||
self.check_inhibit()
|
||||
|
||||
def toggle_inhibit(self, *args, external=False):
|
||||
"""
|
||||
Toggle the 'ax-inhibit' process:
|
||||
- If running, kill it and mark as 'Disabled' (add 'disabled' class).
|
||||
- If not running, start it and mark as 'Enabled' (remove 'disabled' class).
|
||||
"""
|
||||
GLib.Thread.new("caffeine-toggle", self._toggle_inhibit_thread, external)
|
||||
|
||||
def _toggle_inhibit_thread(self, external):
|
||||
"""Background thread to toggle inhibit without blocking UI."""
|
||||
try:
|
||||
subprocess.check_output(["pgrep", "ax-inhibit"])
|
||||
exec_shell_command_async("pkill ax-inhibit")
|
||||
GLib.idle_add(self.caffeine_status.set_label, "Disabled")
|
||||
GLib.idle_add(self._add_disabled_style)
|
||||
except subprocess.CalledProcessError:
|
||||
exec_shell_command_async(f"python {data.HOME_DIR}/.config/{data.APP_NAME_CAP}/scripts/inhibit.py")
|
||||
GLib.idle_add(self.caffeine_status.set_label, "Enabled")
|
||||
GLib.idle_add(self._remove_disabled_style)
|
||||
|
||||
if external:
|
||||
# Different if enabled or disabled
|
||||
status = "Disabled" if self.caffeine_status.get_label() == "Disabled" else "Enabled"
|
||||
message = "Disabled 💤" if status == "Disabled" else "Enabled ☀️"
|
||||
exec_shell_command_async(f"notify-send '☕ Caffeine' '{message}' -a '{data.APP_NAME_CAP}' -e")
|
||||
|
||||
def _add_disabled_style(self):
|
||||
"""Helper to add disabled style to all widgets."""
|
||||
for widget in self.widgets:
|
||||
widget.add_style_class("disabled")
|
||||
|
||||
def _remove_disabled_style(self):
|
||||
"""Helper to remove disabled style from all widgets."""
|
||||
for widget in self.widgets:
|
||||
widget.remove_style_class("disabled")
|
||||
|
||||
def check_inhibit(self, *args):
|
||||
GLib.Thread.new("caffeine-check", self._check_inhibit_thread, None)
|
||||
|
||||
def _check_inhibit_thread(self, user_data):
|
||||
"""Background thread to check inhibit status without blocking UI."""
|
||||
try:
|
||||
subprocess.check_output(["pgrep", "ax-inhibit"])
|
||||
GLib.idle_add(self.caffeine_status.set_label, "Enabled")
|
||||
GLib.idle_add(self._remove_disabled_style)
|
||||
except subprocess.CalledProcessError:
|
||||
GLib.idle_add(self.caffeine_status.set_label, "Disabled")
|
||||
GLib.idle_add(self._add_disabled_style)
|
||||
|
||||
class Buttons(Gtk.Grid):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="buttons-grid")
|
||||
self.set_row_homogeneous(True)
|
||||
self.set_column_homogeneous(True)
|
||||
self.set_row_spacing(4)
|
||||
self.set_column_spacing(4)
|
||||
self.set_vexpand(False)
|
||||
|
||||
self.widgets = kwargs["widgets"]
|
||||
|
||||
self.network_button = NetworkButton(widgets=self.widgets)
|
||||
self.bluetooth_button = BluetoothButton(widgets=self.widgets)
|
||||
self.night_mode_button = NightModeButton()
|
||||
self.caffeine_button = CaffeineButton()
|
||||
|
||||
if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]):
|
||||
|
||||
self.attach(self.network_button, 0, 0, 1, 1)
|
||||
self.attach(self.bluetooth_button, 1, 0, 1, 1)
|
||||
self.attach(self.night_mode_button, 0, 1, 1, 1)
|
||||
self.attach(self.caffeine_button, 1, 1, 1, 1)
|
||||
else:
|
||||
|
||||
self.attach(self.network_button, 0, 0, 1, 1)
|
||||
self.attach(self.bluetooth_button, 1, 0, 1, 1)
|
||||
self.attach(self.night_mode_button, 2, 0, 1, 1)
|
||||
self.attach(self.caffeine_button, 3, 0, 1, 1)
|
||||
|
||||
self.show_all()
|
||||
@@ -0,0 +1,374 @@
|
||||
import calendar
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import gi
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.label import Label
|
||||
|
||||
import modules.icons as icons
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import GLib, Gtk, Gio
|
||||
|
||||
|
||||
class Calendar(Gtk.Box):
|
||||
def __init__(self, view_mode="month"):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8, name="calendar")
|
||||
self.view_mode = view_mode
|
||||
self.first_weekday = 0 # Default: Monday, will be updated async
|
||||
|
||||
self.set_halign(Gtk.Align.CENTER)
|
||||
self.set_hexpand(False)
|
||||
|
||||
self.current_day_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if self.view_mode == "month":
|
||||
self.current_shown_date = self.current_day_date.replace(day=1)
|
||||
self.current_year = self.current_shown_date.year
|
||||
self.current_month = self.current_shown_date.month
|
||||
self.current_day = self.current_day_date.day # Solo para resaltar en create_month_view
|
||||
self.previous_key = (self.current_year, self.current_month)
|
||||
elif self.view_mode == "week":
|
||||
# current_shown_date es el primer día (según locale) de la semana actual
|
||||
days_to_subtract = (self.current_day_date.weekday() - self.first_weekday + 7) % 7
|
||||
self.current_shown_date = self.current_day_date - timedelta(days=days_to_subtract)
|
||||
self.current_year = self.current_shown_date.year # Para el header
|
||||
self.current_month = self.current_shown_date.month # Para el header
|
||||
iso_year, iso_week, _ = self.current_shown_date.isocalendar()
|
||||
self.previous_key = (iso_year, iso_week)
|
||||
self.set_halign(Gtk.Align.FILL)
|
||||
self.set_hexpand(True)
|
||||
self.set_valign(Gtk.Align.CENTER)
|
||||
self.set_vexpand(False)
|
||||
|
||||
self.cache_threshold = 3 # Umbral para mantener vistas en caché
|
||||
|
||||
self.month_views = {} # Reutilizado para vistas de semana también
|
||||
|
||||
self.prev_button = Gtk.Button( # Nombre genérico del botón
|
||||
name="prev-month-button",
|
||||
child=Label(name="month-button-label", markup=icons.chevron_left) # CSS puede ser genérico
|
||||
)
|
||||
self.prev_button.connect("clicked", self.on_prev_clicked)
|
||||
|
||||
self.month_label = Gtk.Label(name="month-label") # El nombre es histórico, pero muestra mes/año
|
||||
|
||||
self.next_button = Gtk.Button( # Nombre genérico del botón
|
||||
name="next-month-button",
|
||||
child=Label(name="month-button-label", markup=icons.chevron_right) # CSS puede ser genérico
|
||||
)
|
||||
self.next_button.connect("clicked", self.on_next_clicked)
|
||||
|
||||
self.header = CenterBox(
|
||||
spacing=4,
|
||||
name="header",
|
||||
start_children=[self.prev_button],
|
||||
center_children=[self.month_label],
|
||||
end_children=[self.next_button],
|
||||
)
|
||||
|
||||
self.add(self.header)
|
||||
|
||||
self.weekday_row = Gtk.Box(spacing=4, name="weekday-row")
|
||||
self.pack_start(self.weekday_row, False, False, 0)
|
||||
|
||||
self.stack = Gtk.Stack(name="calendar-stack")
|
||||
self.stack.set_transition_duration(250)
|
||||
self.pack_start(self.stack, True, True, 0)
|
||||
|
||||
self.update_header() # Llamar antes de update_calendar para que el primer header sea correcto
|
||||
self.update_calendar()
|
||||
self.setup_periodic_update()
|
||||
self.setup_dbus_listeners()
|
||||
|
||||
# Initialize locale settings asynchronously
|
||||
GLib.Thread.new("calendar-locale", self._init_locale_settings_thread, None)
|
||||
|
||||
def _init_locale_settings_thread(self, user_data):
|
||||
"""Background thread to initialize locale settings without blocking UI."""
|
||||
try:
|
||||
origin_date_str = subprocess.check_output(["locale", "week-1stday"], text=True).strip()
|
||||
first_weekday_val = int(subprocess.check_output(["locale", "first_weekday"], text=True).strip())
|
||||
|
||||
origin_date = datetime.fromisoformat(origin_date_str)
|
||||
# Esta lógica calcula el día de la semana (0-6, Lunes=0) que es considerado el primero
|
||||
# según la configuración regional combinada de week-1stday y first_weekday.
|
||||
date_of_first_day_of_week_config = origin_date + timedelta(days=first_weekday_val - 1)
|
||||
new_first_weekday = date_of_first_day_of_week_config.weekday() # Lunes=0, ..., Domingo=6
|
||||
|
||||
# Update the first_weekday on main thread and refresh calendar if needed
|
||||
GLib.idle_add(self._update_first_weekday, new_first_weekday)
|
||||
except Exception as e:
|
||||
print(f"Error getting locale first weekday: {e}")
|
||||
# Keep default value (0 = Monday)
|
||||
|
||||
def _update_first_weekday(self, new_first_weekday):
|
||||
"""Update first weekday setting and refresh calendar if changed."""
|
||||
if self.first_weekday != new_first_weekday:
|
||||
self.first_weekday = new_first_weekday
|
||||
# Clear cache and refresh calendar with new locale settings
|
||||
self.month_views.clear()
|
||||
# Remove all current stack children to force regeneration
|
||||
for child in self.stack.get_children():
|
||||
self.stack.remove(child)
|
||||
# Update header (which includes weekday labels) and calendar
|
||||
self.update_header()
|
||||
self.update_calendar()
|
||||
return False # Don't repeat this idle callback
|
||||
|
||||
def setup_periodic_update(self):
|
||||
# Check for date changes every second
|
||||
GLib.timeout_add(1000, self.check_date_change)
|
||||
|
||||
def setup_dbus_listeners(self):
|
||||
# Listen for system suspend/resume events
|
||||
bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
|
||||
bus.signal_subscribe(
|
||||
None, # sender
|
||||
'org.freedesktop.login1.Manager', # interface
|
||||
'PrepareForSleep', # signal
|
||||
'/org/freedesktop/login1', # path
|
||||
None, # arg0
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
self.on_suspend_resume, # callback
|
||||
None # user_data
|
||||
)
|
||||
|
||||
def check_date_change(self):
|
||||
now = datetime.now()
|
||||
current_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if current_date != self.current_day_date:
|
||||
self.on_midnight()
|
||||
return True # Continue the timer
|
||||
|
||||
def on_suspend_resume(self, connection, sender_name, object_path, interface_name, signal_name, parameters, user_data):
|
||||
# Check date when resuming from suspend
|
||||
self.check_date_change()
|
||||
|
||||
def on_midnight(self):
|
||||
now = datetime.now()
|
||||
self.current_day_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
key_to_remove_for_today_highlight = None
|
||||
if self.view_mode == "month":
|
||||
# Actualizar la fecha base para la vista de mes si es necesario (aunque usualmente no cambia a medianoche)
|
||||
self.current_shown_date = self.current_day_date.replace(day=1)
|
||||
self.current_year = self.current_shown_date.year
|
||||
self.current_month = self.current_shown_date.month
|
||||
self.current_day = self.current_day_date.day # Actualizar el día actual
|
||||
key_to_remove_for_today_highlight = (self.current_year, self.current_month)
|
||||
elif self.view_mode == "week":
|
||||
days_to_subtract = (self.current_day_date.weekday() - self.first_weekday + 7) % 7
|
||||
self.current_shown_date = self.current_day_date - timedelta(days=days_to_subtract)
|
||||
self.current_year = self.current_shown_date.year # Para el header
|
||||
self.current_month = self.current_shown_date.month # Para el header
|
||||
iso_year, iso_week, _ = self.current_shown_date.isocalendar()
|
||||
key_to_remove_for_today_highlight = (iso_year, iso_week)
|
||||
|
||||
# Eliminar la vista actual de la caché para forzar la regeneración con el nuevo "hoy" resaltado
|
||||
if key_to_remove_for_today_highlight and key_to_remove_for_today_highlight in self.month_views:
|
||||
widget = self.month_views.pop(key_to_remove_for_today_highlight)
|
||||
self.stack.remove(widget)
|
||||
# Si la vista eliminada era la actual, previous_key podría quedar desactualizado
|
||||
# pero update_calendar lo corregirá al establecer la nueva vista.
|
||||
|
||||
self.update_calendar() # Esto regenerará la vista si fue eliminada y actualizará el resaltado
|
||||
return False # Importante para que el timeout no se repita automáticamente
|
||||
|
||||
def update_header(self):
|
||||
# self.current_shown_date es el primer día del mes (modo mes) o el primer día de la semana (modo semana)
|
||||
# El encabezado siempre muestra el mes y año de self.current_shown_date
|
||||
self.month_label.set_text(
|
||||
self.current_shown_date.strftime("%B %Y").capitalize()
|
||||
)
|
||||
|
||||
for child in self.weekday_row.get_children():
|
||||
self.weekday_row.remove(child)
|
||||
|
||||
day_initials = self.get_weekday_initials()
|
||||
for day_initial in day_initials:
|
||||
label = Gtk.Label(label=day_initial.upper(), name="weekday-label")
|
||||
self.weekday_row.pack_start(label, True, True, 0)
|
||||
self.weekday_row.show_all()
|
||||
|
||||
def update_calendar(self):
|
||||
new_key = None
|
||||
child_name = "" # Renombrado de child_name_prefix
|
||||
view_widget = None
|
||||
|
||||
if self.view_mode == "month":
|
||||
new_key = (self.current_year, self.current_month)
|
||||
child_name = f"{self.current_year}_{self.current_month}"
|
||||
if new_key not in self.month_views:
|
||||
view_widget = self.create_month_view(self.current_year, self.current_month)
|
||||
elif self.view_mode == "week":
|
||||
iso_year, iso_week, _ = self.current_shown_date.isocalendar()
|
||||
new_key = (iso_year, iso_week)
|
||||
child_name = f"{iso_year}_w{iso_week}"
|
||||
if new_key not in self.month_views:
|
||||
# Pasar self.current_shown_date directamente a create_week_view
|
||||
view_widget = self.create_week_view(self.current_shown_date)
|
||||
|
||||
if new_key is None: return
|
||||
|
||||
if new_key > self.previous_key:
|
||||
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT)
|
||||
elif new_key < self.previous_key:
|
||||
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT)
|
||||
# else: no transition if key is the same (e.g. on_midnight for same month/week)
|
||||
|
||||
self.previous_key = new_key
|
||||
|
||||
if view_widget: # Si se creó una nueva vista
|
||||
self.month_views[new_key] = view_widget
|
||||
self.stack.add_titled(view_widget, child_name, child_name)
|
||||
|
||||
self.stack.set_visible_child_name(child_name)
|
||||
# El encabezado se actualiza ANTES de llamar a update_calendar en __init__ y on_clicked,
|
||||
# y también en on_midnight si es necesario.
|
||||
# Pero si la vista cambia (ej. de Enero a Febrero), el encabezado debe reflejarlo.
|
||||
self.update_header() # Asegurar que el header está sincronizado con la vista actual
|
||||
self.stack.show_all()
|
||||
|
||||
self.prune_cache()
|
||||
|
||||
def prune_cache(self):
|
||||
def get_key_index(key_tuple):
|
||||
year, num = key_tuple # num es month o week_number
|
||||
if self.view_mode == "month": # Asumiendo que la clave es (año, mes)
|
||||
return year * 12 + (num - 1)
|
||||
else: # Asumiendo que la clave es (año_iso, semana_iso)
|
||||
return year * 53 + num # Usar 53 para cubrir años con 53 semanas ISO
|
||||
|
||||
current_index = get_key_index(self.previous_key) # previous_key es la clave de la vista actual
|
||||
keys_to_remove = []
|
||||
for key_iter in self.month_views:
|
||||
if abs(get_key_index(key_iter) - current_index) > self.cache_threshold:
|
||||
keys_to_remove.append(key_iter)
|
||||
for key_to_remove in keys_to_remove:
|
||||
widget = self.month_views.pop(key_to_remove)
|
||||
self.stack.remove(widget)
|
||||
|
||||
def create_month_view(self, year, month):
|
||||
grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=False, name="calendar-grid")
|
||||
cal = calendar.Calendar(firstweekday=self.first_weekday)
|
||||
month_days = cal.monthdayscalendar(year, month)
|
||||
|
||||
while len(month_days) < 6: # Asegurar 6 filas para consistencia visual
|
||||
month_days.append([0] * 7) # [0] representa un día vacío
|
||||
|
||||
for row, week in enumerate(month_days):
|
||||
for col, day_num in enumerate(week):
|
||||
day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box")
|
||||
top_spacer = Gtk.Box(hexpand=True, vexpand=True)
|
||||
middle_box = Gtk.Box(hexpand=True, vexpand=True)
|
||||
bottom_spacer = Gtk.Box(hexpand=True, vexpand=True)
|
||||
|
||||
if day_num == 0:
|
||||
label = Label(name="day-empty", markup=icons.dot)
|
||||
else:
|
||||
label = Gtk.Label(label=str(day_num), name="day-label")
|
||||
day_date_obj = datetime(year, month, day_num)
|
||||
if day_date_obj == self.current_day_date:
|
||||
label.get_style_context().add_class("current-day")
|
||||
|
||||
middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0)
|
||||
middle_box.pack_start(label, False, False, 0)
|
||||
middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0)
|
||||
|
||||
day_box.pack_start(top_spacer, True, True, 0)
|
||||
day_box.pack_start(middle_box, True, True, 0)
|
||||
day_box.pack_start(bottom_spacer, True, True, 0)
|
||||
grid.attach(day_box, col, row, 1, 1)
|
||||
grid.show_all()
|
||||
return grid
|
||||
|
||||
def create_week_view(self, first_day_of_week_to_display):
|
||||
grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=False, name="calendar-grid-week-view") # Podría tener estilo diferente
|
||||
|
||||
# El mes de referencia para atenuar es el mes de first_day_of_week_to_display
|
||||
# que es self.current_shown_date, y su mes es self.current_month (actualizado en nav)
|
||||
reference_month_for_dimming = first_day_of_week_to_display.month
|
||||
|
||||
for col in range(7):
|
||||
current_day_in_loop = first_day_of_week_to_display + timedelta(days=col)
|
||||
|
||||
day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box") # Reusar estilo de day-box
|
||||
top_spacer = Gtk.Box(hexpand=True, vexpand=True)
|
||||
middle_box = Gtk.Box(hexpand=True, vexpand=True)
|
||||
bottom_spacer = Gtk.Box(hexpand=True, vexpand=True)
|
||||
|
||||
label = Gtk.Label(label=str(current_day_in_loop.day), name="day-label")
|
||||
|
||||
if current_day_in_loop == self.current_day_date:
|
||||
label.get_style_context().add_class("current-day")
|
||||
|
||||
if current_day_in_loop.month != reference_month_for_dimming:
|
||||
label.get_style_context().add_class("dim-label") # Necesita CSS: .dim-label { opacity: 0.5; } o similar
|
||||
|
||||
middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0)
|
||||
middle_box.pack_start(label, False, False, 0)
|
||||
middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0)
|
||||
|
||||
day_box.pack_start(top_spacer, True, True, 0)
|
||||
day_box.pack_start(middle_box, True, True, 0)
|
||||
day_box.pack_start(bottom_spacer, True, True, 0)
|
||||
|
||||
grid.attach(day_box, col, 0, 1, 1) # Todos los días en la fila 0
|
||||
|
||||
# Para mantener una altura similar a la vista mensual, se podrían añadir filas vacías.
|
||||
# Esto es opcional y depende del diseño deseado.
|
||||
# for r_idx in range(1, 6): # Añadir 5 filas vacías
|
||||
# empty_row_placeholder = Gtk.Box(name="day-empty-placeholder", hexpand=True, vexpand=True, height_request=20) # Ajustar altura
|
||||
# grid.attach(empty_row_placeholder, 0, r_idx, 7, 1) # Abarca las 7 columnas
|
||||
|
||||
grid.show_all()
|
||||
return grid
|
||||
|
||||
def get_weekday_initials(self):
|
||||
# Genera las iniciales de los días de la semana comenzando por self.first_weekday
|
||||
# datetime(2024, 1, 1) es Lunes. Su weekday() es 0.
|
||||
# Si self.first_weekday es 0 (Lunes), queremos que el primer día sea Lunes.
|
||||
# i=0: datetime(2024, 1, 1 + 0) -> Lunes
|
||||
# Si self.first_weekday es 6 (Domingo), queremos que el primer día sea Domingo.
|
||||
# i=0: datetime(2024, 1, 1 + 6) -> Domingo
|
||||
# Esta lógica es correcta.
|
||||
return [(datetime(2024, 1, 1) + timedelta(days=(self.first_weekday + i) % 7)).strftime("%a")[:1] for i in range(7)]
|
||||
|
||||
|
||||
def on_prev_clicked(self, widget):
|
||||
if self.view_mode == "month":
|
||||
current_month_val = self.current_shown_date.month
|
||||
current_year_val = self.current_shown_date.year
|
||||
if current_month_val == 1:
|
||||
self.current_shown_date = self.current_shown_date.replace(year=current_year_val - 1, month=12)
|
||||
else:
|
||||
self.current_shown_date = self.current_shown_date.replace(month=current_month_val - 1)
|
||||
self.current_year = self.current_shown_date.year
|
||||
self.current_month = self.current_shown_date.month
|
||||
elif self.view_mode == "week":
|
||||
self.current_shown_date -= timedelta(days=7)
|
||||
self.current_year = self.current_shown_date.year # Actualizar para el header
|
||||
self.current_month = self.current_shown_date.month # Actualizar para el header y dimming
|
||||
|
||||
# self.update_header() # Se llama dentro de update_calendar
|
||||
self.update_calendar()
|
||||
|
||||
def on_next_clicked(self, widget):
|
||||
if self.view_mode == "month":
|
||||
current_month_val = self.current_shown_date.month
|
||||
current_year_val = self.current_shown_date.year
|
||||
if current_month_val == 12:
|
||||
self.current_shown_date = self.current_shown_date.replace(year=current_year_val + 1, month=1)
|
||||
else:
|
||||
self.current_shown_date = self.current_shown_date.replace(month=current_month_val + 1)
|
||||
self.current_year = self.current_shown_date.year
|
||||
self.current_month = self.current_shown_date.month
|
||||
elif self.view_mode == "week":
|
||||
self.current_shown_date += timedelta(days=7)
|
||||
self.current_year = self.current_shown_date.year # Actualizar para el header
|
||||
self.current_month = self.current_shown_date.month # Actualizar para el header y dimming
|
||||
|
||||
# self.update_header() # Se llama dentro de update_calendar
|
||||
self.update_calendar()
|
||||
@@ -0,0 +1,350 @@
|
||||
import configparser
|
||||
import ctypes
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
from math import pi
|
||||
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def get_bars(file_path):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(file_path)
|
||||
return int(config['general']['bars'])
|
||||
|
||||
CAVA_CONFIG = get_relative_path("../config/cavalcade/cava.ini")
|
||||
|
||||
bars = get_bars(CAVA_CONFIG)
|
||||
|
||||
def set_death_signal():
|
||||
"""
|
||||
Set the death signal of the child process to SIGTERM so that if the parent
|
||||
process is killed, the child (cava) is automatically terminated.
|
||||
"""
|
||||
libc = ctypes.CDLL("libc.so.6")
|
||||
PR_SET_PDEATHSIG = 1
|
||||
libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
|
||||
|
||||
class Cava:
|
||||
"""
|
||||
CAVA wrapper.
|
||||
Launch cava process with certain settings and read output.
|
||||
"""
|
||||
NONE = 0
|
||||
RUNNING = 1
|
||||
RESTARTING = 2
|
||||
CLOSING = 3
|
||||
|
||||
def data_handler(self, *a, **kw):
|
||||
"""Call all registered handlers with the provided arguments."""
|
||||
for h in self._handlers:
|
||||
h(*a, **kw)
|
||||
|
||||
def register_handler(self, handler):
|
||||
self._handlers.append(handler)
|
||||
|
||||
def __init__(self):
|
||||
self.bars = bars
|
||||
self.path = "/tmp/cava.fifo"
|
||||
|
||||
self.cava_config_file = CAVA_CONFIG
|
||||
self._handlers = []
|
||||
self._started = False
|
||||
self.command = ["cava", "-p", self.cava_config_file]
|
||||
self.state = self.NONE
|
||||
self.process = None
|
||||
|
||||
self.env = dict(os.environ)
|
||||
self.env["LC_ALL"] = "en_US.UTF-8" # not sure if it's necessary
|
||||
|
||||
is_16bit = True
|
||||
self.byte_type, self.byte_size, self.byte_norm = ("H", 2, 65535) if is_16bit else ("B", 1, 255)
|
||||
|
||||
if not os.path.exists(self.path):
|
||||
os.mkfifo(self.path)
|
||||
|
||||
self.fifo_fd = None
|
||||
self.fifo_dummy_fd = None
|
||||
self.io_watch_id = None
|
||||
|
||||
def _run_process(self):
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
self.command,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=self.env,
|
||||
preexec_fn=set_death_signal # Ensure cava gets killed when the parent dies.
|
||||
)
|
||||
self.state = self.RUNNING
|
||||
except Exception:
|
||||
logger.exception("Fail to launch cava")
|
||||
|
||||
def _start_io_reader(self):
|
||||
# Open FIFO in non-blocking mode for reading
|
||||
self.fifo_fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
# Open dummy write end to prevent getting an EOF on our FIFO
|
||||
self.fifo_dummy_fd = os.open(self.path, os.O_WRONLY | os.O_NONBLOCK)
|
||||
self.io_watch_id = GLib.io_add_watch(self.fifo_fd, GLib.IO_IN, self._io_callback)
|
||||
|
||||
def _io_callback(self, source, condition):
|
||||
chunk = self.byte_size * self.bars # number of bytes for given format
|
||||
try:
|
||||
if self.fifo_fd is None:
|
||||
return False
|
||||
|
||||
data = os.read(self.fifo_fd, chunk)
|
||||
except OSError as e:
|
||||
if e.errno == 11: # EAGAIN - would block, normal for non-blocking
|
||||
return True
|
||||
elif e.errno == 9: # EBADF - bad file descriptor
|
||||
GLib.idle_add(self.restart)
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# When no data is read, do not remove the IO watch immediately.
|
||||
if len(data) < chunk:
|
||||
if len(data) == 0:
|
||||
# No data available, continue watching
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
|
||||
try:
|
||||
fmt = self.byte_type * self.bars # format string for struct.unpack
|
||||
sample = [i / self.byte_norm for i in struct.unpack(fmt, data)]
|
||||
GLib.idle_add(self.data_handler, sample)
|
||||
except (struct.error, Exception):
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def _on_stop(self):
|
||||
if self.state == self.RESTARTING:
|
||||
self.start()
|
||||
elif self.state == self.RUNNING:
|
||||
self.state = self.NONE
|
||||
|
||||
def start(self):
|
||||
"""Launch cava"""
|
||||
if self._started:
|
||||
return
|
||||
self._start_io_reader()
|
||||
self._run_process()
|
||||
self._started = True
|
||||
|
||||
def restart(self):
|
||||
"""Restart cava process"""
|
||||
if self.state == self.RUNNING:
|
||||
self.state = self.RESTARTING
|
||||
if self.process and self.process.poll() is None:
|
||||
self.process.kill()
|
||||
elif self.state == self.NONE:
|
||||
self.start()
|
||||
|
||||
def close(self):
|
||||
"""Stop cava process"""
|
||||
self.state = self.CLOSING
|
||||
|
||||
# Stop IO watch first
|
||||
if self.io_watch_id:
|
||||
GLib.source_remove(self.io_watch_id)
|
||||
self.io_watch_id = None
|
||||
|
||||
# Close file descriptors safely
|
||||
if self.fifo_fd is not None:
|
||||
try:
|
||||
os.close(self.fifo_fd)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
self.fifo_fd = None
|
||||
|
||||
if self.fifo_dummy_fd is not None:
|
||||
try:
|
||||
os.close(self.fifo_dummy_fd)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
self.fifo_dummy_fd = None
|
||||
|
||||
# Kill process if still running
|
||||
if self.process and self.process.poll() is None:
|
||||
try:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=2.0) # Wait up to 2 seconds
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove FIFO file
|
||||
if os.path.exists(self.path):
|
||||
try:
|
||||
os.remove(self.path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
class AttributeDict(dict):
|
||||
"""Dictionary with keys as attributes. Does nothing but easy reading"""
|
||||
def __getattr__(self, attr):
|
||||
return self.get(attr, 3)
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
self[attr] = value
|
||||
|
||||
class Spectrum:
|
||||
"""Spectrum drawing"""
|
||||
def __init__(self):
|
||||
self.silence_value = 0
|
||||
self.audio_sample = []
|
||||
self.color = None
|
||||
self._cached_color = None
|
||||
self._color_file_mtime = 0
|
||||
|
||||
self.area = Gtk.DrawingArea()
|
||||
self.area.connect("draw", self.redraw)
|
||||
self.area.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
|
||||
self.sizes = AttributeDict()
|
||||
self.sizes.area = AttributeDict()
|
||||
self.sizes.bar = AttributeDict()
|
||||
|
||||
self.silence = 10
|
||||
self.max_height = 12
|
||||
|
||||
self.area.connect("configure-event", self.size_update)
|
||||
self.color_update()
|
||||
|
||||
def is_silence(self, value):
|
||||
"""Check if volume level critically low during last iterations"""
|
||||
self.silence_value = 0 if value > 0 else self.silence_value + 1
|
||||
return self.silence_value > self.silence
|
||||
|
||||
def update(self, data):
|
||||
"""Audio data processing"""
|
||||
self.color_update_cached()
|
||||
self.audio_sample = data
|
||||
if not self.is_silence(self.audio_sample[0]):
|
||||
self.area.queue_draw()
|
||||
elif self.silence_value == (self.silence + 1):
|
||||
self.audio_sample = [0] * self.sizes.number
|
||||
self.area.queue_draw()
|
||||
|
||||
def redraw(self, widget, cr):
|
||||
"""Draw spectrum graph"""
|
||||
cr.set_source_rgba(*self.color)
|
||||
dx = 3
|
||||
|
||||
center_y = self.sizes.area.height / 2 # center vertical of the drawing area
|
||||
for i, value in enumerate(self.audio_sample):
|
||||
width = self.sizes.area.width / self.sizes.number - self.sizes.padding
|
||||
radius = width / 2
|
||||
height = max(self.sizes.bar.height * min(value, 1), self.sizes.zero) / 2
|
||||
if height == self.sizes.zero / 2 + 1:
|
||||
height *= 0.5
|
||||
|
||||
height = min(height, self.max_height)
|
||||
|
||||
# Draw rectangle and arcs for rounded ends
|
||||
cr.rectangle(dx, center_y - height, width, height * 2)
|
||||
cr.arc(dx + radius, center_y - height, radius, 0, 2 * pi)
|
||||
cr.arc(dx + radius, center_y + height, radius, 0, 2 * pi)
|
||||
|
||||
cr.close_path()
|
||||
dx += width + self.sizes.padding
|
||||
cr.fill()
|
||||
|
||||
def size_update(self, *args):
|
||||
"""Update drawing geometry"""
|
||||
self.sizes.number = bars
|
||||
self.sizes.padding = 100 / bars
|
||||
self.sizes.zero = 0
|
||||
|
||||
self.sizes.area.width = self.area.get_allocated_width()
|
||||
self.sizes.area.height = self.area.get_allocated_height() - 2
|
||||
|
||||
tw = self.sizes.area.width - self.sizes.padding * (self.sizes.number - 1)
|
||||
self.sizes.bar.width = max(int(tw / self.sizes.number), 1)
|
||||
self.sizes.bar.height = self.sizes.area.height
|
||||
|
||||
def color_update_cached(self):
|
||||
"""Set drawing color with caching to avoid file reads on every frame"""
|
||||
color_file = get_relative_path("../styles/colors.css")
|
||||
try:
|
||||
# Check if file has been modified
|
||||
current_mtime = os.path.getmtime(color_file)
|
||||
if current_mtime != self._color_file_mtime or self._cached_color is None:
|
||||
self._color_file_mtime = current_mtime
|
||||
|
||||
color = "#a5c8ff" # default value
|
||||
with open(color_file, "r") as f:
|
||||
content = f.read()
|
||||
m = re.search(r"--primary:\s*(#[0-9a-fA-F]{6})", content)
|
||||
if m:
|
||||
color = m.group(1)
|
||||
|
||||
red = int(color[1:3], 16) / 255
|
||||
green = int(color[3:5], 16) / 255
|
||||
blue = int(color[5:7], 16) / 255
|
||||
self._cached_color = Gdk.RGBA(red=red, green=green, blue=blue, alpha=1.0)
|
||||
|
||||
self.color = self._cached_color
|
||||
except Exception:
|
||||
if self._cached_color is None:
|
||||
# Fallback to default color
|
||||
self._cached_color = Gdk.RGBA(red=0.647, green=0.784, blue=1.0, alpha=1.0)
|
||||
self.color = self._cached_color
|
||||
|
||||
def color_update(self):
|
||||
"""Set drawing color according to current settings by reading primary color from CSS"""
|
||||
color = "#a5c8ff" # default value
|
||||
try:
|
||||
with open(get_relative_path("../styles/colors.css"), "r") as f:
|
||||
content = f.read()
|
||||
m = re.search(r"--primary:\s*(#[0-9a-fA-F]{6})", content)
|
||||
if m:
|
||||
color = m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
red = int(color[1:3], 16) / 255
|
||||
green = int(color[3:5], 16) / 255
|
||||
blue = int(color[5:7], 16) / 255
|
||||
self.color = Gdk.RGBA(red=red, green=green, blue=blue, alpha=1.0)
|
||||
|
||||
|
||||
_instances = {}
|
||||
|
||||
|
||||
def getCava() -> Cava:
|
||||
if "cava" not in _instances:
|
||||
_instances["cava"] = Cava()
|
||||
return _instances["cava"]
|
||||
|
||||
|
||||
class SpectrumRender:
|
||||
def __init__(self, mode=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.mode = mode
|
||||
|
||||
self.draw = Spectrum()
|
||||
self.cava = getCava()
|
||||
self.cava.register_handler(self.draw.update)
|
||||
|
||||
self.cava.start()
|
||||
|
||||
def get_spectrum_box(self):
|
||||
# Get the spectrum box
|
||||
box = Overlay(name="cavalcade", h_align='center', v_align='center')
|
||||
box.set_size_request(180, 40)
|
||||
box.add_overlay(self.draw.area)
|
||||
return box
|
||||
@@ -0,0 +1,519 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from fabric.utils import idle_add, remove_handler
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.entry import Entry
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib
|
||||
|
||||
import modules.icons as icons
|
||||
|
||||
|
||||
class ClipHistory(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="clip-history",
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-")
|
||||
self.image_cache = {}
|
||||
|
||||
self.notch = kwargs["notch"]
|
||||
self.selected_index = -1
|
||||
self._arranger_handler = 0
|
||||
self.clipboard_items = []
|
||||
self._loading = False
|
||||
self._pending_updates = False
|
||||
|
||||
self.viewport = Box(name="viewport", spacing=4, orientation="v")
|
||||
self.search_entry = Entry(
|
||||
name="search-entry",
|
||||
placeholder="Search Clipboard History...",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
notify_text=self.filter_items,
|
||||
on_activate=lambda entry, *_: self.use_selected_item(),
|
||||
on_key_press_event=self.on_search_entry_key_press,
|
||||
)
|
||||
self.search_entry.props.xalign = 0.5
|
||||
|
||||
self.scrolled_window = ScrolledWindow(
|
||||
name="scrolled-window",
|
||||
spacing=10,
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
h_align="fill",
|
||||
v_align="fill",
|
||||
child=self.viewport,
|
||||
propagate_width=False,
|
||||
propagate_height=False,
|
||||
)
|
||||
|
||||
self.header_box = Box(
|
||||
name="header_box",
|
||||
spacing=10,
|
||||
orientation="h",
|
||||
children=[
|
||||
Button(
|
||||
name="clear-button",
|
||||
child=Label(name="clear-label", markup=icons.trash),
|
||||
on_clicked=lambda *_: self.clear_history(),
|
||||
),
|
||||
self.search_entry,
|
||||
Button(
|
||||
name="close-button",
|
||||
child=Label(name="close-label", markup=icons.cancel),
|
||||
tooltip_text="Exit",
|
||||
on_clicked=lambda *_: self.close()
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self.history_box = Box(
|
||||
name="launcher-box",
|
||||
spacing=10,
|
||||
h_expand=True,
|
||||
orientation="v",
|
||||
children=[
|
||||
self.header_box,
|
||||
self.scrolled_window,
|
||||
],
|
||||
)
|
||||
|
||||
self.add(self.history_box)
|
||||
self.show_all()
|
||||
|
||||
def close(self):
|
||||
"""Close the clipboard history panel"""
|
||||
self.viewport.children = []
|
||||
self.selected_index = -1
|
||||
self.notch.close_notch()
|
||||
|
||||
def open(self):
|
||||
"""Open the clipboard history panel and load items"""
|
||||
if self._loading:
|
||||
return
|
||||
self._loading = True
|
||||
self.search_entry.set_text("")
|
||||
self.search_entry.grab_focus()
|
||||
|
||||
# Use GLib.Thread for proper async execution
|
||||
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
|
||||
|
||||
def _load_clipboard_items_thread(self, user_data):
|
||||
"""Background thread worker for loading clipboard items"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["cliphist", "list"],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
# Decode stdout with error handling
|
||||
stdout_str = result.stdout.decode('utf-8', errors='replace')
|
||||
lines = stdout_str.strip().split('\n')
|
||||
new_items = []
|
||||
for line in lines:
|
||||
if not line or "<meta http-equiv" in line:
|
||||
continue
|
||||
new_items.append(line)
|
||||
# Update UI from main thread
|
||||
GLib.idle_add(self._update_items, new_items)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error loading clipboard history: {e}", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}", file=sys.stderr)
|
||||
finally:
|
||||
GLib.idle_add(self._loading_finished)
|
||||
|
||||
def _loading_finished(self):
|
||||
"""Handle loading completion on main thread"""
|
||||
self._loading = False
|
||||
if self._pending_updates:
|
||||
self._pending_updates = False
|
||||
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
|
||||
return False
|
||||
|
||||
def _update_items(self, new_items):
|
||||
"""Update the items list from main thread"""
|
||||
self.clipboard_items = new_items
|
||||
self.display_clipboard_items()
|
||||
|
||||
def display_clipboard_items(self, filter_text=""):
|
||||
"""Display clipboard items in the viewport"""
|
||||
remove_handler(self._arranger_handler) if self._arranger_handler else None
|
||||
self.viewport.children = []
|
||||
self.selected_index = -1
|
||||
|
||||
|
||||
filtered_items = []
|
||||
for item in self.clipboard_items:
|
||||
|
||||
content = item.split('\t', 1)[1] if '\t' in item else item
|
||||
if filter_text.lower() in content.lower():
|
||||
filtered_items.append(item)
|
||||
|
||||
|
||||
if not filtered_items:
|
||||
|
||||
container = Box(
|
||||
name="no-clip-container",
|
||||
orientation="v",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True
|
||||
)
|
||||
|
||||
|
||||
label = Label(
|
||||
name="no-clip",
|
||||
markup=icons.clipboard,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
container.add(label)
|
||||
self.viewport.add(container)
|
||||
return
|
||||
|
||||
|
||||
self._display_items_batch(filtered_items, 0, 10)
|
||||
|
||||
def _display_items_batch(self, items, start, batch_size):
|
||||
"""Display items in batches to keep UI responsive"""
|
||||
end = min(start + batch_size, len(items))
|
||||
|
||||
for i in range(start, end):
|
||||
item = items[i]
|
||||
self.viewport.add(self.create_clipboard_item(item))
|
||||
|
||||
|
||||
if end < len(items):
|
||||
GLib.idle_add(self._display_items_batch, items, end, batch_size)
|
||||
else:
|
||||
|
||||
if self.search_entry.get_text() and self.viewport.get_children():
|
||||
self.update_selection(0)
|
||||
|
||||
def create_clipboard_item(self, item):
|
||||
"""Create a button for a clipboard item"""
|
||||
|
||||
parts = item.split('\t', 1)
|
||||
item_id = parts[0] if len(parts) > 1 else "0"
|
||||
content = parts[1] if len(parts) > 1 else item
|
||||
|
||||
|
||||
display_text = content.strip()
|
||||
if len(display_text) > 100:
|
||||
display_text = display_text[:97] + "..."
|
||||
|
||||
|
||||
is_image = self.is_image_data(content)
|
||||
|
||||
if is_image:
|
||||
|
||||
button = Button(
|
||||
name="slot-button",
|
||||
child=Box(
|
||||
name="slot-box",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[
|
||||
Image(name="clip-icon", h_align="start"),
|
||||
Label(
|
||||
name="clip-label",
|
||||
label="[Image]",
|
||||
ellipsization="end",
|
||||
v_align="center",
|
||||
h_align="start",
|
||||
h_expand=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip_text="Image in clipboard",
|
||||
on_clicked=lambda *_, id=item_id: self.paste_item(id),
|
||||
)
|
||||
|
||||
self._load_image_preview_async(item_id, button)
|
||||
else:
|
||||
|
||||
button = self.create_text_item_button(item_id, display_text)
|
||||
|
||||
|
||||
button.connect("key-press-event", lambda widget, event, id=item_id: self.on_item_key_press(widget, event, id))
|
||||
|
||||
|
||||
button.set_can_focus(True)
|
||||
button.add_events(Gdk.EventMask.KEY_PRESS_MASK)
|
||||
|
||||
return button
|
||||
|
||||
def _load_image_preview_async(self, item_id, button):
|
||||
"""Load image preview asynchronously using background thread"""
|
||||
GLib.Thread.new("image-preview", self._load_image_preview_thread, (item_id, button))
|
||||
|
||||
def _load_image_preview_thread(self, data):
|
||||
"""Background thread worker for loading image preview"""
|
||||
item_id, button = data
|
||||
try:
|
||||
if item_id in self.image_cache:
|
||||
pixbuf = self.image_cache[item_id]
|
||||
GLib.idle_add(self._update_image_button, button, pixbuf)
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
["cliphist", "decode", item_id],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
loader = GdkPixbuf.PixbufLoader()
|
||||
loader.write(result.stdout)
|
||||
loader.close()
|
||||
pixbuf = loader.get_pixbuf()
|
||||
width, height = pixbuf.get_width(), pixbuf.get_height()
|
||||
max_size = 72
|
||||
if width > height:
|
||||
new_width = max_size
|
||||
new_height = int(height * (max_size / width))
|
||||
else:
|
||||
new_height = max_size
|
||||
new_width = int(width * (max_size / height))
|
||||
pixbuf = pixbuf.scale_simple(new_width, new_height, GdkPixbuf.InterpType.BILINEAR)
|
||||
self.image_cache[item_id] = pixbuf
|
||||
|
||||
GLib.idle_add(self._update_image_button, button, pixbuf)
|
||||
except Exception as e:
|
||||
print(f"Error loading image preview: {e}", file=sys.stderr)
|
||||
|
||||
def _update_image_button(self, button, pixbuf):
|
||||
"""Update the button with the loaded image preview"""
|
||||
box = button.get_child()
|
||||
if box and len(box.get_children()) > 0:
|
||||
image_widget = box.get_children()[0]
|
||||
if isinstance(image_widget, Image):
|
||||
image_widget.set_from_pixbuf(pixbuf)
|
||||
|
||||
def create_text_item_button(self, item_id, display_text):
|
||||
"""Create a button for a text clipboard item"""
|
||||
return Button(
|
||||
name="slot-button",
|
||||
child=Box(
|
||||
name="slot-box",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[
|
||||
Label(
|
||||
name="clip-icon",
|
||||
markup=icons.clip_text,
|
||||
h_align="start",
|
||||
),
|
||||
Label(
|
||||
name="clip-label",
|
||||
label=display_text,
|
||||
ellipsization="end",
|
||||
v_align="center",
|
||||
h_align="start",
|
||||
h_expand=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip_text=display_text,
|
||||
on_clicked=lambda *_: self.paste_item(item_id),
|
||||
)
|
||||
|
||||
def is_image_data(self, content):
|
||||
"""Determine if clipboard content is likely an image"""
|
||||
|
||||
return (
|
||||
content.startswith("data:image/") or
|
||||
content.startswith("\x89PNG") or
|
||||
content.startswith("GIF8") or
|
||||
content.startswith("\xff\xd8\xff") or
|
||||
re.match(r'^\s*<img\s+', content) is not None or
|
||||
"binary" in content.lower() and any(ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"])
|
||||
)
|
||||
|
||||
def paste_item(self, item_id):
|
||||
"""Copy the selected item to the clipboard and close (async)"""
|
||||
GLib.Thread.new("paste-item", self._paste_item_thread, item_id)
|
||||
|
||||
def _paste_item_thread(self, item_id):
|
||||
"""Background thread worker for pasting clipboard item"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["cliphist", "decode", item_id],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["wl-copy"],
|
||||
input=result.stdout,
|
||||
check=True
|
||||
)
|
||||
GLib.idle_add(self.close)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error pasting clipboard item: {e}", file=sys.stderr)
|
||||
|
||||
def delete_item(self, item_id):
|
||||
"""Delete the selected clipboard item (async)"""
|
||||
GLib.Thread.new("delete-item", self._delete_item_thread, item_id)
|
||||
|
||||
def _delete_item_thread(self, item_id):
|
||||
"""Background thread worker for deleting clipboard item"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["cliphist", "delete", item_id],
|
||||
check=True
|
||||
)
|
||||
self._pending_updates = True
|
||||
if not self._loading:
|
||||
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error deleting clipboard item: {e}", file=sys.stderr)
|
||||
|
||||
def clear_history(self):
|
||||
"""Clear all clipboard history (async)"""
|
||||
GLib.Thread.new("clear-history", self._clear_history_thread, None)
|
||||
|
||||
def _clear_history_thread(self, user_data):
|
||||
"""Background thread worker for clearing clipboard history"""
|
||||
try:
|
||||
subprocess.run(["cliphist", "wipe"], check=True)
|
||||
self._pending_updates = True
|
||||
if not self._loading:
|
||||
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error clearing clipboard history: {e}", file=sys.stderr)
|
||||
|
||||
def filter_items(self, entry, *_):
|
||||
"""Filter clipboard items based on search text"""
|
||||
self.display_clipboard_items(entry.get_text())
|
||||
|
||||
def on_search_entry_key_press(self, widget, event):
|
||||
"""Handle key presses in the search entry"""
|
||||
if event.keyval == Gdk.KEY_Down:
|
||||
self.move_selection(1)
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Up:
|
||||
self.move_selection(-1)
|
||||
return True
|
||||
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
||||
self.use_selected_item()
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Delete:
|
||||
self.delete_selected_item()
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Escape:
|
||||
self.close()
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_selection(self, new_index):
|
||||
"""Update the selected item in the viewport"""
|
||||
children = self.viewport.get_children()
|
||||
|
||||
|
||||
if self.selected_index != -1 and self.selected_index < len(children):
|
||||
current_button = children[self.selected_index]
|
||||
current_button.get_style_context().remove_class("selected")
|
||||
|
||||
|
||||
if new_index != -1 and new_index < len(children):
|
||||
new_button = children[new_index]
|
||||
new_button.get_style_context().add_class("selected")
|
||||
self.selected_index = new_index
|
||||
self.scroll_to_selected(new_button)
|
||||
else:
|
||||
self.selected_index = -1
|
||||
|
||||
def move_selection(self, delta):
|
||||
"""Move the selection up or down"""
|
||||
children = self.viewport.get_children()
|
||||
if not children:
|
||||
return
|
||||
|
||||
|
||||
if self.selected_index == -1 and delta == 1:
|
||||
new_index = 0
|
||||
else:
|
||||
new_index = self.selected_index + delta
|
||||
|
||||
new_index = max(0, min(new_index, len(children) - 1))
|
||||
self.update_selection(new_index)
|
||||
|
||||
def scroll_to_selected(self, button):
|
||||
"""Scroll to ensure the selected item is visible"""
|
||||
def scroll():
|
||||
adj = self.scrolled_window.get_vadjustment()
|
||||
alloc = button.get_allocation()
|
||||
if alloc.height == 0:
|
||||
return False
|
||||
|
||||
y = alloc.y
|
||||
height = alloc.height
|
||||
page_size = adj.get_page_size()
|
||||
current_value = adj.get_value()
|
||||
|
||||
visible_top = current_value
|
||||
visible_bottom = current_value + page_size
|
||||
|
||||
if y < visible_top:
|
||||
|
||||
adj.set_value(y)
|
||||
elif y + height > visible_bottom:
|
||||
|
||||
new_value = y + height - page_size
|
||||
adj.set_value(new_value)
|
||||
return False
|
||||
GLib.idle_add(scroll)
|
||||
|
||||
def use_selected_item(self):
|
||||
"""Use (paste) the selected clipboard item"""
|
||||
children = self.viewport.get_children()
|
||||
if not children or self.selected_index == -1 or self.selected_index >= len(self.clipboard_items):
|
||||
return
|
||||
|
||||
|
||||
item_line = self.clipboard_items[self.selected_index]
|
||||
item_id = item_line.split('\t', 1)[0]
|
||||
self.paste_item(item_id)
|
||||
|
||||
def delete_selected_item(self):
|
||||
"""Delete the selected clipboard item"""
|
||||
children = self.viewport.get_children()
|
||||
if not children or self.selected_index == -1:
|
||||
return
|
||||
|
||||
|
||||
item_line = self.clipboard_items[self.selected_index]
|
||||
item_id = item_line.split('\t', 1)[0]
|
||||
self.delete_item(item_id)
|
||||
|
||||
def on_item_key_press(self, widget, event, item_id):
|
||||
"""Handle key press events on clipboard items"""
|
||||
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
||||
|
||||
self.paste_item(item_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def __del__(self):
|
||||
"""Clean up temporary files on destruction"""
|
||||
try:
|
||||
if hasattr(self, 'tmp_dir') and os.path.exists(self.tmp_dir):
|
||||
import shutil
|
||||
shutil.rmtree(self.tmp_dir)
|
||||
self.image_cache.clear()
|
||||
except Exception as e:
|
||||
print(f"Error cleaning up temporary files: {e}", file=sys.stderr)
|
||||
@@ -0,0 +1,872 @@
|
||||
from fabric.audio.service import Audio
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.scale import Scale
|
||||
from gi.repository import Gdk, GLib
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
from services.brightness import Brightness
|
||||
|
||||
|
||||
class VolumeSlider(Scale):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="control-slider",
|
||||
orientation="h",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
has_origin=True,
|
||||
increments=(0.01, 0.1),
|
||||
**kwargs,
|
||||
)
|
||||
self.audio = Audio()
|
||||
self.audio.connect("notify::speaker", self.on_new_speaker)
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.connect("changed", self.on_speaker_changed)
|
||||
self.connect("change-value", self.on_change_value)
|
||||
self.add_style_class("vol")
|
||||
self._pending_value = None
|
||||
self._update_source_id = None
|
||||
self._debounce_timeout = 100
|
||||
self._updating_from_audio = False
|
||||
self.on_speaker_changed()
|
||||
|
||||
def on_new_speaker(self, *args):
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.connect("changed", self.on_speaker_changed)
|
||||
self.on_speaker_changed()
|
||||
|
||||
def on_change_value(self, widget, scroll, value):
|
||||
if self._updating_from_audio:
|
||||
return False
|
||||
if self.audio.speaker:
|
||||
self._pending_value = value * 100
|
||||
if self._update_source_id is not None:
|
||||
GLib.source_remove(self._update_source_id)
|
||||
self._update_source_id = GLib.timeout_add(
|
||||
self._debounce_timeout, self._update_volume_callback
|
||||
)
|
||||
return False
|
||||
|
||||
def _update_volume_callback(self):
|
||||
if self._pending_value is not None and self.audio.speaker:
|
||||
self.audio.speaker.volume = self._pending_value
|
||||
self._pending_value = None
|
||||
self._update_source_id = None
|
||||
return False
|
||||
|
||||
def on_speaker_changed(self, *_):
|
||||
if not self.audio.speaker:
|
||||
return
|
||||
self._updating_from_audio = True
|
||||
self.value = self.audio.speaker.volume / 100
|
||||
self._updating_from_audio = False
|
||||
|
||||
if self.audio.speaker.muted:
|
||||
self.add_style_class("muted")
|
||||
else:
|
||||
self.remove_style_class("muted")
|
||||
|
||||
|
||||
class MicSlider(Scale):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="control-slider",
|
||||
orientation="h",
|
||||
h_expand=True,
|
||||
has_origin=True,
|
||||
increments=(0.01, 0.1),
|
||||
**kwargs,
|
||||
)
|
||||
self.audio = Audio()
|
||||
self.audio.connect("notify::microphone", self.on_new_microphone)
|
||||
if self.audio.microphone:
|
||||
self.audio.microphone.connect("changed", self.on_microphone_changed)
|
||||
self.connect("change-value", self.on_change_value)
|
||||
self.add_style_class("mic")
|
||||
self._updating_from_audio = False
|
||||
self.on_microphone_changed()
|
||||
|
||||
def on_new_microphone(self, *args):
|
||||
if self.audio.microphone:
|
||||
self.audio.microphone.connect("changed", self.on_microphone_changed)
|
||||
self.on_microphone_changed()
|
||||
|
||||
def on_change_value(self, widget, scroll, value):
|
||||
if self._updating_from_audio:
|
||||
return False
|
||||
if self.audio.microphone:
|
||||
self.audio.microphone.volume = value * 100
|
||||
return False
|
||||
|
||||
def on_microphone_changed(self, *_):
|
||||
if not self.audio.microphone:
|
||||
return
|
||||
self._updating_from_audio = True
|
||||
self.value = self.audio.microphone.volume / 100
|
||||
self._updating_from_audio = False
|
||||
|
||||
if self.audio.microphone.muted:
|
||||
self.add_style_class("muted")
|
||||
else:
|
||||
self.remove_style_class("muted")
|
||||
|
||||
|
||||
class BrightnessSlider(Scale):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="control-slider",
|
||||
orientation="h",
|
||||
h_expand=True,
|
||||
has_origin=True,
|
||||
increments=(5, 10),
|
||||
**kwargs,
|
||||
)
|
||||
self.client = Brightness.get_initial()
|
||||
if self.client.screen_brightness == -1:
|
||||
self.destroy()
|
||||
return
|
||||
|
||||
self.set_range(0, self.client.max_screen)
|
||||
self.set_value(self.client.screen_brightness)
|
||||
self.add_style_class("brightness")
|
||||
|
||||
self._pending_value = None
|
||||
self._update_source_id = None
|
||||
self._updating_from_brightness = False
|
||||
self._debounce_timeout = 100
|
||||
|
||||
self.connect("change-value", self.on_scale_move)
|
||||
self.client.connect("screen", self.on_brightness_changed)
|
||||
|
||||
def on_scale_move(self, widget, scroll, moved_pos):
|
||||
if self._updating_from_brightness:
|
||||
return False
|
||||
self._pending_value = moved_pos
|
||||
if self._update_source_id is not None:
|
||||
GLib.source_remove(self._update_source_id)
|
||||
self._update_source_id = GLib.timeout_add(
|
||||
self._debounce_timeout, self._update_brightness_callback
|
||||
)
|
||||
return False
|
||||
|
||||
def _update_brightness_callback(self):
|
||||
if self._pending_value is not None:
|
||||
value_to_set = self._pending_value
|
||||
self._pending_value = None
|
||||
if value_to_set != self.client.screen_brightness:
|
||||
self.client.screen_brightness = value_to_set
|
||||
self._update_source_id = None
|
||||
return False
|
||||
else:
|
||||
self._update_source_id = None
|
||||
return False
|
||||
|
||||
def on_brightness_changed(self, client, _):
|
||||
self._updating_from_brightness = True
|
||||
self.set_value(self.client.screen_brightness)
|
||||
self._updating_from_brightness = False
|
||||
percentage = int((self.client.screen_brightness / self.client.max_screen) * 100)
|
||||
self.set_tooltip_text(f"{percentage}%")
|
||||
|
||||
def destroy(self):
|
||||
if self._update_source_id is not None:
|
||||
GLib.source_remove(self._update_source_id)
|
||||
super().destroy()
|
||||
|
||||
|
||||
class BrightnessSmall(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="button-bar-brightness", **kwargs)
|
||||
self.brightness = Brightness.get_initial()
|
||||
if self.brightness.screen_brightness == -1:
|
||||
self.destroy()
|
||||
return
|
||||
|
||||
self.progress_bar = CircularProgressBar(
|
||||
name="button-brightness",
|
||||
size=28,
|
||||
line_width=2,
|
||||
start_angle=150,
|
||||
end_angle=390,
|
||||
)
|
||||
self.brightness_label = Label(
|
||||
name="brightness-label", markup=icons.brightness_high
|
||||
)
|
||||
self.brightness_button = Button(child=self.brightness_label)
|
||||
self.event_box = EventBox(
|
||||
events=["scroll", "smooth-scroll"],
|
||||
child=Overlay(child=self.progress_bar, overlays=self.brightness_button),
|
||||
)
|
||||
self.event_box.connect("scroll-event", self.on_scroll)
|
||||
self.add(self.event_box)
|
||||
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
|
||||
|
||||
self._updating_from_brightness = False
|
||||
self._pending_value = None
|
||||
self._update_source_id = None
|
||||
self._debounce_timeout = 100
|
||||
|
||||
# Don't connect to progress_bar value changes - only use scroll events
|
||||
self.brightness.connect("screen", self.on_brightness_changed)
|
||||
self.on_brightness_changed()
|
||||
|
||||
def on_scroll(self, widget, event):
|
||||
if self.brightness.max_screen == -1:
|
||||
return
|
||||
|
||||
step_size = 5
|
||||
current_brightness = self.brightness.screen_brightness
|
||||
|
||||
if event.delta_y < 0:
|
||||
new_brightness = min(
|
||||
current_brightness + step_size, self.brightness.max_screen
|
||||
)
|
||||
elif event.delta_y > 0:
|
||||
new_brightness = max(current_brightness - step_size, 0)
|
||||
else:
|
||||
return
|
||||
|
||||
# Directly update brightness, don't touch progress_bar
|
||||
self._pending_value = new_brightness
|
||||
if self._update_source_id is not None:
|
||||
GLib.source_remove(self._update_source_id)
|
||||
self._update_source_id = GLib.timeout_add(
|
||||
self._debounce_timeout, self._update_brightness_callback
|
||||
)
|
||||
|
||||
def _update_brightness_callback(self):
|
||||
if (
|
||||
self._pending_value is not None
|
||||
and self._pending_value != self.brightness.screen_brightness
|
||||
):
|
||||
self.brightness.screen_brightness = self._pending_value
|
||||
self._pending_value = None
|
||||
self._update_source_id = None
|
||||
return False
|
||||
|
||||
def on_brightness_changed(self, *args):
|
||||
if self.brightness.max_screen == -1:
|
||||
return
|
||||
normalized = (
|
||||
self.brightness.screen_brightness / self.brightness.max_screen
|
||||
if self.brightness.max_screen > 0
|
||||
else 0
|
||||
)
|
||||
self._updating_from_brightness = True
|
||||
self.progress_bar.value = normalized
|
||||
self._updating_from_brightness = False
|
||||
|
||||
brightness_percentage = int(normalized * 100)
|
||||
if brightness_percentage >= 75:
|
||||
self.brightness_label.set_markup(icons.brightness_high)
|
||||
elif brightness_percentage >= 24:
|
||||
self.brightness_label.set_markup(icons.brightness_medium)
|
||||
else:
|
||||
self.brightness_label.set_markup(icons.brightness_low)
|
||||
self.set_tooltip_text(f"{brightness_percentage}%")
|
||||
|
||||
def destroy(self):
|
||||
if self._update_source_id is not None:
|
||||
GLib.source_remove(self._update_source_id)
|
||||
super().destroy()
|
||||
|
||||
|
||||
class VolumeSmall(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="button-bar-vol", **kwargs)
|
||||
self.audio = Audio()
|
||||
self.progress_bar = CircularProgressBar(
|
||||
name="button-volume",
|
||||
size=28,
|
||||
line_width=2,
|
||||
start_angle=150,
|
||||
end_angle=390,
|
||||
)
|
||||
self.vol_label = Label(name="vol-label", markup=icons.vol_high)
|
||||
self.vol_button = Button(on_clicked=self.toggle_mute, child=self.vol_label)
|
||||
self.event_box = EventBox(
|
||||
events=["scroll", "smooth-scroll"],
|
||||
child=Overlay(child=self.progress_bar, overlays=self.vol_button),
|
||||
)
|
||||
self.audio.connect("notify::speaker", self.on_new_speaker)
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.connect("changed", self.on_speaker_changed)
|
||||
self.event_box.connect("scroll-event", self.on_scroll)
|
||||
self.add(self.event_box)
|
||||
self.on_speaker_changed()
|
||||
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
|
||||
|
||||
def on_new_speaker(self, *args):
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.connect("changed", self.on_speaker_changed)
|
||||
self.on_speaker_changed()
|
||||
|
||||
def toggle_mute(self, event):
|
||||
current_stream = self.audio.speaker
|
||||
if current_stream:
|
||||
current_stream.muted = not current_stream.muted
|
||||
if current_stream.muted:
|
||||
self.on_speaker_changed()
|
||||
self.progress_bar.add_style_class("muted")
|
||||
self.vol_label.add_style_class("muted")
|
||||
else:
|
||||
self.on_speaker_changed()
|
||||
self.progress_bar.remove_style_class("muted")
|
||||
self.vol_label.remove_style_class("muted")
|
||||
|
||||
def on_scroll(self, _, event):
|
||||
if not self.audio.speaker:
|
||||
return
|
||||
if event.direction == Gdk.ScrollDirection.SMOOTH:
|
||||
if abs(event.delta_y) > 0:
|
||||
self.audio.speaker.volume -= event.delta_y
|
||||
if abs(event.delta_x) > 0:
|
||||
self.audio.speaker.volume += event.delta_x
|
||||
|
||||
def on_speaker_changed(self, *_):
|
||||
if not self.audio.speaker:
|
||||
return
|
||||
|
||||
vol_high_icon = icons.vol_high
|
||||
vol_medium_icon = icons.vol_medium
|
||||
vol_mute_icon = icons.vol_off
|
||||
vol_off_icon = icons.vol_mute
|
||||
|
||||
if "bluetooth" in self.audio.speaker.icon_name:
|
||||
vol_high_icon = icons.bluetooth_connected
|
||||
vol_medium_icon = icons.bluetooth
|
||||
vol_mute_icon = icons.bluetooth_off
|
||||
vol_off_icon = icons.bluetooth_disconnected
|
||||
|
||||
self.progress_bar.value = self.audio.speaker.volume / 100
|
||||
|
||||
if self.audio.speaker.muted:
|
||||
self.vol_button.get_child().set_markup(vol_mute_icon)
|
||||
self.progress_bar.add_style_class("muted")
|
||||
self.vol_label.add_style_class("muted")
|
||||
self.set_tooltip_text("Muted")
|
||||
return
|
||||
else:
|
||||
self.progress_bar.remove_style_class("muted")
|
||||
self.vol_label.remove_style_class("muted")
|
||||
self.set_tooltip_text(f"{round(self.audio.speaker.volume)}%")
|
||||
if self.audio.speaker.volume > 74:
|
||||
self.vol_button.get_child().set_markup(vol_high_icon)
|
||||
elif self.audio.speaker.volume > 0:
|
||||
self.vol_button.get_child().set_markup(vol_medium_icon)
|
||||
else:
|
||||
self.vol_button.get_child().set_markup(vol_off_icon)
|
||||
|
||||
|
||||
class MicSmall(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="button-bar-mic", **kwargs)
|
||||
self.audio = Audio()
|
||||
self.progress_bar = CircularProgressBar(
|
||||
name="button-mic",
|
||||
size=28,
|
||||
line_width=2,
|
||||
start_angle=150,
|
||||
end_angle=390,
|
||||
)
|
||||
self.mic_label = Label(name="mic-label", markup=icons.mic)
|
||||
self.mic_button = Button(on_clicked=self.toggle_mute, child=self.mic_label)
|
||||
self.event_box = EventBox(
|
||||
events=["scroll", "smooth-scroll"],
|
||||
child=Overlay(child=self.progress_bar, overlays=self.mic_button),
|
||||
)
|
||||
self.audio.connect("notify::microphone", self.on_new_microphone)
|
||||
if self.audio.microphone:
|
||||
self.audio.microphone.connect("changed", self.on_microphone_changed)
|
||||
self.event_box.connect("scroll-event", self.on_scroll)
|
||||
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
|
||||
self.add(self.event_box)
|
||||
self.on_microphone_changed()
|
||||
|
||||
def on_new_microphone(self, *args):
|
||||
if self.audio.microphone:
|
||||
self.audio.microphone.connect("changed", self.on_microphone_changed)
|
||||
self.on_microphone_changed()
|
||||
|
||||
def toggle_mute(self, event):
|
||||
current_stream = self.audio.microphone
|
||||
if current_stream:
|
||||
current_stream.muted = not current_stream.muted
|
||||
if current_stream.muted:
|
||||
self.mic_button.get_child().set_markup(icons.mic_mute)
|
||||
self.progress_bar.add_style_class("muted")
|
||||
self.mic_label.add_style_class("muted")
|
||||
else:
|
||||
self.on_microphone_changed()
|
||||
self.progress_bar.remove_style_class("muted")
|
||||
self.mic_label.remove_style_class("muted")
|
||||
|
||||
def on_scroll(self, _, event):
|
||||
if not self.audio.microphone:
|
||||
return
|
||||
if event.direction == Gdk.ScrollDirection.SMOOTH:
|
||||
if abs(event.delta_y) > 0:
|
||||
self.audio.microphone.volume -= event.delta_y
|
||||
if abs(event.delta_x) > 0:
|
||||
self.audio.microphone.volume += event.delta_x
|
||||
|
||||
def on_microphone_changed(self, *_):
|
||||
if not self.audio.microphone:
|
||||
return
|
||||
if self.audio.microphone.muted:
|
||||
self.mic_button.get_child().set_markup(icons.mic_mute)
|
||||
self.progress_bar.add_style_class("muted")
|
||||
self.mic_label.add_style_class("muted")
|
||||
self.set_tooltip_text("Muted")
|
||||
return
|
||||
else:
|
||||
self.progress_bar.remove_style_class("muted")
|
||||
self.mic_label.remove_style_class("muted")
|
||||
self.progress_bar.value = self.audio.microphone.volume / 100
|
||||
self.set_tooltip_text(f"{round(self.audio.microphone.volume)}%")
|
||||
if self.audio.microphone.volume >= 1:
|
||||
self.mic_button.get_child().set_markup(icons.mic)
|
||||
else:
|
||||
self.mic_button.get_child().set_markup(icons.mic_mute)
|
||||
|
||||
|
||||
class BrightnessIcon(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="brightness-icon", **kwargs)
|
||||
self.brightness = Brightness.get_initial()
|
||||
if self.brightness.screen_brightness == -1:
|
||||
self.destroy()
|
||||
return
|
||||
|
||||
self.brightness_label = Label(
|
||||
name="brightness-label-dash",
|
||||
markup=icons.brightness_high,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
self.brightness_button = Button(
|
||||
child=self.brightness_label,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
self.event_box = EventBox(
|
||||
events=["scroll", "smooth-scroll"],
|
||||
child=self.brightness_button,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
self.event_box.connect("scroll-event", self.on_scroll)
|
||||
self.add(self.event_box)
|
||||
|
||||
self._pending_value = None
|
||||
self._update_source_id = None
|
||||
self._updating_from_brightness = False
|
||||
|
||||
self.brightness.connect("screen", self.on_brightness_changed)
|
||||
self.on_brightness_changed()
|
||||
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
|
||||
|
||||
def on_scroll(self, _, event):
|
||||
if self.brightness.max_screen == -1:
|
||||
return
|
||||
|
||||
step_size = 5
|
||||
current_brightness = self.brightness.screen_brightness
|
||||
|
||||
if event.direction == Gdk.ScrollDirection.SMOOTH:
|
||||
if event.delta_y < 0:
|
||||
new_brightness = min(
|
||||
current_brightness + step_size, self.brightness.max_screen
|
||||
)
|
||||
elif event.delta_y > 0:
|
||||
new_brightness = max(current_brightness - step_size, 0)
|
||||
else:
|
||||
return
|
||||
else:
|
||||
if event.direction == Gdk.ScrollDirection.UP:
|
||||
new_brightness = min(
|
||||
current_brightness + step_size, self.brightness.max_screen
|
||||
)
|
||||
elif event.direction == Gdk.ScrollDirection.DOWN:
|
||||
new_brightness = max(current_brightness - step_size, 0)
|
||||
else:
|
||||
return
|
||||
|
||||
self._pending_value = new_brightness
|
||||
if self._update_source_id is None:
|
||||
self._update_source_id = GLib.timeout_add(
|
||||
100, self._update_brightness_callback
|
||||
)
|
||||
|
||||
def _update_brightness_callback(self):
|
||||
if (
|
||||
self._pending_value is not None
|
||||
and self._pending_value != self.brightness.screen_brightness
|
||||
):
|
||||
self.brightness.screen_brightness = self._pending_value
|
||||
self._pending_value = None
|
||||
return True
|
||||
else:
|
||||
self._update_source_id = None
|
||||
return False
|
||||
|
||||
def on_brightness_changed(self, *args):
|
||||
if self.brightness.max_screen == -1:
|
||||
return
|
||||
|
||||
self._updating_from_brightness = True
|
||||
normalized = self.brightness.screen_brightness / self.brightness.max_screen
|
||||
brightness_percentage = int(normalized * 100)
|
||||
|
||||
if brightness_percentage >= 75:
|
||||
self.brightness_label.set_markup("")
|
||||
elif brightness_percentage >= 24:
|
||||
self.brightness_label.set_markup("")
|
||||
else:
|
||||
self.brightness_label.set_markup("")
|
||||
self.set_tooltip_text(f"{brightness_percentage}%")
|
||||
self._updating_from_brightness = False
|
||||
|
||||
def destroy(self):
|
||||
if self._update_source_id is not None:
|
||||
GLib.source_remove(self._update_source_id)
|
||||
super().destroy()
|
||||
|
||||
|
||||
class VolumeIcon(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="vol-icon", **kwargs)
|
||||
self.audio = Audio()
|
||||
|
||||
self.vol_label = Label(
|
||||
name="vol-label-dash",
|
||||
markup="",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
self.vol_button = Button(
|
||||
on_clicked=self.toggle_mute,
|
||||
child=self.vol_label,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
self.event_box = EventBox(
|
||||
events=["scroll", "smooth-scroll"],
|
||||
child=self.vol_button,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
self.event_box.connect("scroll-event", self.on_scroll)
|
||||
self.add(self.event_box)
|
||||
|
||||
self._pending_value = None
|
||||
self._update_source_id = None
|
||||
self._periodic_update_source_id = None
|
||||
|
||||
self.audio.connect("notify::speaker", self.on_new_speaker)
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.connect("changed", self.on_speaker_changed)
|
||||
|
||||
self._periodic_update_source_id = GLib.timeout_add_seconds(
|
||||
2, self.update_device_icon
|
||||
)
|
||||
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
|
||||
|
||||
def on_scroll(self, _, event):
|
||||
if not self.audio.speaker:
|
||||
return
|
||||
|
||||
step_size = 5
|
||||
current_volume = self.audio.speaker.volume
|
||||
|
||||
if event.direction == Gdk.ScrollDirection.SMOOTH:
|
||||
if event.delta_y < 0:
|
||||
new_volume = min(current_volume + step_size, 100)
|
||||
elif event.delta_y > 0:
|
||||
new_volume = max(current_volume - step_size, 0)
|
||||
else:
|
||||
return
|
||||
else:
|
||||
if event.direction == Gdk.ScrollDirection.UP:
|
||||
new_volume = min(current_volume + step_size, 100)
|
||||
elif event.direction == Gdk.ScrollDirection.DOWN:
|
||||
new_volume = max(current_volume - step_size, 0)
|
||||
else:
|
||||
return
|
||||
|
||||
self._pending_value = new_volume
|
||||
if self._update_source_id is None:
|
||||
self._update_source_id = GLib.timeout_add(100, self._update_volume_callback)
|
||||
|
||||
def _update_volume_callback(self):
|
||||
if (
|
||||
self._pending_value is not None
|
||||
and self._pending_value != self.audio.speaker.volume
|
||||
):
|
||||
self.audio.speaker.volume = self._pending_value
|
||||
self._pending_value = None
|
||||
return True
|
||||
else:
|
||||
self._update_source_id = None
|
||||
return False
|
||||
|
||||
def on_new_speaker(self, *args):
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.connect("changed", self.on_speaker_changed)
|
||||
self.on_speaker_changed()
|
||||
|
||||
def toggle_mute(self, event):
|
||||
current_stream = self.audio.speaker
|
||||
if current_stream:
|
||||
current_stream.muted = not current_stream.muted
|
||||
|
||||
self.on_speaker_changed()
|
||||
|
||||
def on_speaker_changed(self, *_):
|
||||
if not self.audio.speaker:
|
||||
self.vol_label.set_markup("")
|
||||
self.remove_style_class("muted")
|
||||
self.vol_label.remove_style_class("muted")
|
||||
self.vol_button.remove_style_class("muted")
|
||||
self.set_tooltip_text("No audio device")
|
||||
return
|
||||
|
||||
if self.audio.speaker.muted:
|
||||
self.vol_label.set_markup(icons.headphones)
|
||||
self.add_style_class("muted")
|
||||
self.vol_label.add_style_class("muted")
|
||||
self.vol_button.add_style_class("muted")
|
||||
self.set_tooltip_text("Muted")
|
||||
else:
|
||||
self.remove_style_class("muted")
|
||||
self.vol_label.remove_style_class("muted")
|
||||
self.vol_button.remove_style_class("muted")
|
||||
|
||||
self.update_device_icon()
|
||||
self.set_tooltip_text(f"{round(self.audio.speaker.volume)}%")
|
||||
|
||||
def update_device_icon(self):
|
||||
if not self.audio.speaker:
|
||||
self.vol_label.set_markup("")
|
||||
|
||||
return True
|
||||
|
||||
if self.audio.speaker.muted:
|
||||
return True
|
||||
|
||||
try:
|
||||
device_type = self.audio.speaker.port.type
|
||||
if device_type == "headphones":
|
||||
self.vol_label.set_markup(icons.headphones)
|
||||
elif device_type == "speaker":
|
||||
self.vol_label.set_markup(icons.headphones)
|
||||
else:
|
||||
self.vol_label.set_markup(icons.headphones)
|
||||
|
||||
except AttributeError:
|
||||
self.vol_label.set_markup(icons.headphones)
|
||||
|
||||
return True
|
||||
|
||||
def destroy(self):
|
||||
if self._update_source_id is not None:
|
||||
GLib.source_remove(self._update_source_id)
|
||||
|
||||
if (
|
||||
hasattr(self, "_periodic_update_source_id")
|
||||
and self._periodic_update_source_id is not None
|
||||
):
|
||||
GLib.source_remove(self._periodic_update_source_id)
|
||||
super().destroy()
|
||||
|
||||
|
||||
class MicIcon(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="mic-icon", **kwargs)
|
||||
self.audio = Audio()
|
||||
|
||||
self.mic_label = Label(
|
||||
name="mic-label-dash",
|
||||
markup=icons.mic,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
self.mic_button = Button(
|
||||
on_clicked=self.toggle_mute,
|
||||
child=self.mic_label,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
self.event_box = EventBox(
|
||||
events=["scroll", "smooth-scroll"],
|
||||
child=self.mic_button,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
self.event_box.connect("scroll-event", self.on_scroll)
|
||||
self.add(self.event_box)
|
||||
|
||||
self._pending_value = None
|
||||
self._update_source_id = None
|
||||
|
||||
self.audio.connect("notify::microphone", self.on_new_microphone)
|
||||
if self.audio.microphone:
|
||||
self.audio.microphone.connect("changed", self.on_microphone_changed)
|
||||
self.on_microphone_changed()
|
||||
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
|
||||
|
||||
def on_scroll(self, _, event):
|
||||
if not self.audio.microphone:
|
||||
return
|
||||
|
||||
step_size = 5
|
||||
current_volume = self.audio.microphone.volume
|
||||
|
||||
if event.direction == Gdk.ScrollDirection.SMOOTH:
|
||||
if event.delta_y < 0:
|
||||
new_volume = min(current_volume + step_size, 100)
|
||||
elif event.delta_y > 0:
|
||||
new_volume = max(current_volume - step_size, 0)
|
||||
else:
|
||||
return
|
||||
else:
|
||||
if event.direction == Gdk.ScrollDirection.UP:
|
||||
new_volume = min(current_volume + step_size, 100)
|
||||
elif event.direction == Gdk.ScrollDirection.DOWN:
|
||||
new_volume = max(current_volume - step_size, 0)
|
||||
else:
|
||||
return
|
||||
|
||||
self._pending_value = new_volume
|
||||
if self._update_source_id is None:
|
||||
self._update_source_id = GLib.timeout_add(100, self._update_volume_callback)
|
||||
|
||||
def _update_volume_callback(self):
|
||||
if (
|
||||
self._pending_value is not None
|
||||
and self._pending_value != self.audio.microphone.volume
|
||||
):
|
||||
self.audio.microphone.volume = self._pending_value
|
||||
self._pending_value = None
|
||||
return True
|
||||
else:
|
||||
self._update_source_id = None
|
||||
return False
|
||||
|
||||
def on_new_microphone(self, *args):
|
||||
if self.audio.microphone:
|
||||
self.audio.microphone.connect("changed", self.on_microphone_changed)
|
||||
self.on_microphone_changed()
|
||||
|
||||
def toggle_mute(self, event):
|
||||
current_stream = self.audio.microphone
|
||||
if current_stream:
|
||||
current_stream.muted = not current_stream.muted
|
||||
if current_stream.muted:
|
||||
self.mic_button.get_child().set_markup("")
|
||||
self.mic_label.add_style_class("muted")
|
||||
self.mic_button.add_style_class("muted")
|
||||
else:
|
||||
self.on_microphone_changed()
|
||||
self.mic_label.remove_style_class("muted")
|
||||
self.mic_button.remove_style_class("muted")
|
||||
|
||||
def on_microphone_changed(self, *_):
|
||||
if not self.audio.microphone:
|
||||
return
|
||||
if self.audio.microphone.muted:
|
||||
self.mic_button.get_child().set_markup("")
|
||||
self.add_style_class("muted")
|
||||
self.mic_label.add_style_class("muted")
|
||||
self.set_tooltip_text("Muted")
|
||||
return
|
||||
else:
|
||||
self.remove_style_class("muted")
|
||||
self.mic_label.remove_style_class("muted")
|
||||
|
||||
self.set_tooltip_text(f"{round(self.audio.microphone.volume)}%")
|
||||
if self.audio.microphone.volume >= 1:
|
||||
self.mic_button.get_child().set_markup("")
|
||||
else:
|
||||
self.mic_button.get_child().set_markup("")
|
||||
|
||||
def destroy(self):
|
||||
if self._update_source_id is not None:
|
||||
GLib.source_remove(self._update_source_id)
|
||||
super().destroy()
|
||||
|
||||
|
||||
class ControlSliders(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="control-sliders",
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
brightness = Brightness.get_initial()
|
||||
|
||||
if brightness.screen_brightness != -1:
|
||||
brightness_row = Box(
|
||||
orientation="h", spacing=0, h_expand=True, h_align="fill"
|
||||
)
|
||||
brightness_row.add(BrightnessIcon())
|
||||
brightness_row.add(BrightnessSlider())
|
||||
self.add(brightness_row)
|
||||
|
||||
volume_row = Box(orientation="h", spacing=0, h_expand=True, h_align="fill")
|
||||
volume_row.add(VolumeIcon())
|
||||
volume_row.add(VolumeSlider())
|
||||
self.add(volume_row)
|
||||
|
||||
mic_row = Box(orientation="h", spacing=0, h_expand=True, h_align="fill")
|
||||
mic_row.add(MicIcon())
|
||||
mic_row.add(MicSlider())
|
||||
self.add(mic_row)
|
||||
|
||||
self.show_all()
|
||||
|
||||
|
||||
class ControlSmall(Box):
|
||||
def __init__(self, **kwargs):
|
||||
brightness = Brightness.get_initial()
|
||||
children = []
|
||||
if brightness.screen_brightness != -1:
|
||||
children.append(BrightnessSmall())
|
||||
children.extend([VolumeSmall(), MicSmall()])
|
||||
super().__init__(
|
||||
name="control-small",
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
spacing=4,
|
||||
children=children,
|
||||
**kwargs,
|
||||
)
|
||||
self.show_all()
|
||||
@@ -0,0 +1,71 @@
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.shapes import Corner
|
||||
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
|
||||
class MyCorner(Box):
|
||||
def __init__(self, corner):
|
||||
super().__init__(
|
||||
name="corner-container",
|
||||
children=Corner(
|
||||
name="corner",
|
||||
orientation=corner,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
size=20,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Corners(Window):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="corners",
|
||||
layer="bottom",
|
||||
anchor="top bottom left right",
|
||||
exclusivity="normal",
|
||||
# pass_through=True,
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
)
|
||||
|
||||
self.all_corners = Box(
|
||||
name="all-corners",
|
||||
orientation="v",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
h_align="fill",
|
||||
v_align="fill",
|
||||
children=[
|
||||
Box(
|
||||
name="top-corners",
|
||||
orientation="h",
|
||||
h_align="fill",
|
||||
children=[
|
||||
MyCorner("top-left"),
|
||||
Box(h_expand=True),
|
||||
MyCorner("top-right"),
|
||||
],
|
||||
),
|
||||
Box(v_expand=True),
|
||||
Box(
|
||||
name="bottom-corners",
|
||||
orientation="h",
|
||||
h_align="fill",
|
||||
children=[
|
||||
MyCorner("bottom-left"),
|
||||
Box(h_expand=True),
|
||||
MyCorner("bottom-right"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self.add(self.all_corners)
|
||||
|
||||
self.show_all()
|
||||
@@ -0,0 +1,158 @@
|
||||
import random
|
||||
|
||||
import gi
|
||||
from fabric.utils import get_relative_path
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.stack import Stack
|
||||
|
||||
import config.data as data
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk
|
||||
|
||||
import modules.icons as icons
|
||||
from modules.kanban import Kanban
|
||||
from modules.mixer import Mixer
|
||||
from modules.pins import Pins
|
||||
from modules.wallpapers import WallpaperSelector
|
||||
from modules.widgets import Widgets
|
||||
|
||||
|
||||
class Dashboard(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="dashboard",
|
||||
orientation="v",
|
||||
spacing=8,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
visible=True,
|
||||
all_visible=True,
|
||||
)
|
||||
|
||||
self.notch = kwargs["notch"]
|
||||
|
||||
self.widgets = Widgets(notch=self.notch)
|
||||
self.pins = Pins()
|
||||
self.kanban = Kanban()
|
||||
self.wallpapers = WallpaperSelector()
|
||||
self.mixer = Mixer()
|
||||
|
||||
self.stack = Stack(
|
||||
name="stack",
|
||||
transition_type="slide-left-right",
|
||||
transition_duration=500,
|
||||
v_expand=True,
|
||||
v_align="fill",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
)
|
||||
|
||||
self.stack.set_homogeneous(False)
|
||||
|
||||
self.switcher = Gtk.StackSwitcher(
|
||||
name="switcher",
|
||||
spacing=8,
|
||||
)
|
||||
|
||||
self.stack.add_titled(self.widgets, "widgets", "Widgets")
|
||||
self.stack.add_titled(self.pins, "pins", "Pins")
|
||||
self.stack.add_titled(self.kanban, "kanban", "Kanban")
|
||||
self.stack.add_titled(self.wallpapers, "wallpapers", "Wallpapers")
|
||||
self.stack.add_titled(self.mixer, "mixer", "Mixer")
|
||||
|
||||
self.switcher.set_stack(self.stack)
|
||||
self.switcher.set_hexpand(True)
|
||||
self.switcher.set_homogeneous(True)
|
||||
self.switcher.set_can_focus(True)
|
||||
|
||||
self.stack.connect("notify::visible-child", self.on_visible_child_changed)
|
||||
|
||||
self.add(self.switcher)
|
||||
self.add(self.stack)
|
||||
|
||||
if data.PANEL_THEME == "Panel" and (
|
||||
data.BAR_POSITION in ["Left", "Right"]
|
||||
or data.PANEL_POSITION in ["Start", "End"]
|
||||
):
|
||||
GLib.idle_add(self._setup_switcher_icons)
|
||||
|
||||
# Close on right click if the event isn't handled
|
||||
self.connect(
|
||||
"button-release-event",
|
||||
lambda widget, event: (event.button == 3 and self.notch.close_notch()),
|
||||
)
|
||||
self.show_all()
|
||||
|
||||
def _setup_switcher_icons(self):
|
||||
icon_details_map = {
|
||||
"Widgets": {"icon": icons.widgets, "name": "widgets"},
|
||||
"Pins": {"icon": icons.pins, "name": "pins"},
|
||||
"Kanban": {"icon": icons.kanban, "name": "kanban"},
|
||||
"Wallpapers": {"icon": icons.wallpapers, "name": "wallpapers"},
|
||||
"Mixer": {"icon": icons.speaker, "name": "mixer"},
|
||||
}
|
||||
|
||||
buttons = self.switcher.get_children()
|
||||
for btn in buttons:
|
||||
if isinstance(btn, Gtk.ToggleButton):
|
||||
original_gtk_label = None
|
||||
for child_widget in btn.get_children():
|
||||
if isinstance(child_widget, Gtk.Label):
|
||||
original_gtk_label = child_widget
|
||||
break
|
||||
|
||||
if original_gtk_label:
|
||||
label_text = original_gtk_label.get_text()
|
||||
if label_text in icon_details_map:
|
||||
details = icon_details_map[label_text]
|
||||
icon_markup = details["icon"]
|
||||
css_name_suffix = details["name"]
|
||||
|
||||
btn.remove(original_gtk_label)
|
||||
|
||||
new_icon_label = Label(
|
||||
name=f"switcher-icon-{css_name_suffix}", markup=icon_markup
|
||||
)
|
||||
btn.add(new_icon_label)
|
||||
new_icon_label.show_all()
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def go_to_next_child(self):
|
||||
children = self.stack.get_children()
|
||||
current_index = self.get_current_index(children)
|
||||
next_index = (current_index + 1) % len(children)
|
||||
self.stack.set_visible_child(children[next_index])
|
||||
|
||||
def go_to_previous_child(self):
|
||||
children = self.stack.get_children()
|
||||
current_index = self.get_current_index(children)
|
||||
previous_index = (current_index - 1 + len(children)) % len(children)
|
||||
self.stack.set_visible_child(children[previous_index])
|
||||
|
||||
def get_current_index(self, children):
|
||||
current_child = self.stack.get_visible_child()
|
||||
return children.index(current_child) if current_child in children else -1
|
||||
|
||||
def on_visible_child_changed(self, stack, param):
|
||||
visible = stack.get_visible_child()
|
||||
if visible == self.wallpapers:
|
||||
self.wallpapers.search_entry.set_text("")
|
||||
self.wallpapers.search_entry.grab_focus()
|
||||
|
||||
def go_to_section(self, section_name):
|
||||
"""Navigate to a specific section in the dashboard."""
|
||||
if section_name == "widgets":
|
||||
self.stack.set_visible_child(self.widgets)
|
||||
elif section_name == "pins":
|
||||
self.stack.set_visible_child(self.pins)
|
||||
elif section_name == "kanban":
|
||||
self.stack.set_visible_child(self.kanban)
|
||||
elif section_name == "wallpapers":
|
||||
self.stack.set_visible_child(self.wallpapers)
|
||||
elif section_name == "mixer":
|
||||
self.stack.set_visible_child(self.mixer)
|
||||
@@ -0,0 +1,857 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import cairo
|
||||
from fabric.hyprland.widgets import get_hyprland_connection
|
||||
from fabric.utils import (exec_shell_command, exec_shell_command_async,
|
||||
get_relative_path, idle_add, remove_handler)
|
||||
from fabric.utils.helpers import get_desktop_applications
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
import config.data as data
|
||||
from modules.corners import MyCorner
|
||||
from utils.icon_resolver import IconResolver
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
|
||||
def read_config():
|
||||
"""Read and return the full configuration from the JSON file, handling missing file."""
|
||||
config_path = get_relative_path("../config/dock.json")
|
||||
try:
|
||||
with open(config_path, "r") as file:
|
||||
config_data = json.load(file)
|
||||
|
||||
if "pinned_apps" in config_data and config_data["pinned_apps"] and isinstance(config_data["pinned_apps"][0], str):
|
||||
all_apps = get_desktop_applications()
|
||||
app_map = {app.name: app for app in all_apps if app.name}
|
||||
|
||||
old_pinned = config_data["pinned_apps"]
|
||||
config_data["pinned_apps"] = []
|
||||
|
||||
for app_id in old_pinned:
|
||||
app = app_map.get(app_id)
|
||||
if app:
|
||||
app_data_obj = {
|
||||
"name": app.name,
|
||||
"display_name": app.display_name,
|
||||
"window_class": app.window_class,
|
||||
"executable": app.executable,
|
||||
"command_line": app.command_line
|
||||
}
|
||||
config_data["pinned_apps"].append(app_data_obj)
|
||||
else:
|
||||
config_data["pinned_apps"].append({"name": app_id})
|
||||
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
config_data = {"pinned_apps": []}
|
||||
return config_data
|
||||
|
||||
def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
|
||||
alloc = widget.get_allocation()
|
||||
surface = cairo.ImageSurface(
|
||||
cairo.Format.ARGB32,
|
||||
alloc.width,
|
||||
alloc.height,
|
||||
)
|
||||
cr = cairo.Context(surface)
|
||||
cr.set_source_rgba(255, 255, 255, 0)
|
||||
cr.rectangle(0, 0, alloc.width, alloc.height)
|
||||
cr.fill()
|
||||
widget.draw(cr)
|
||||
return surface
|
||||
|
||||
class Dock(Window):
|
||||
_instances = []
|
||||
|
||||
def __init__(self, monitor_id: int = 0, integrated_mode: bool = False, **kwargs):
|
||||
self.monitor_id = monitor_id
|
||||
|
||||
self.integrated_mode = integrated_mode
|
||||
self.icon_size = 20 if self.integrated_mode else data.DOCK_ICON_SIZE
|
||||
self.effective_occlusion_size = 36 + self.icon_size
|
||||
self.always_show = data.DOCK_ALWAYS_SHOW if not self.integrated_mode else False
|
||||
|
||||
anchor_to_set: str
|
||||
revealer_transition_type: str
|
||||
|
||||
self.actual_dock_is_horizontal: bool
|
||||
main_box_orientation_val: Gtk.Orientation
|
||||
main_box_h_align_val: str
|
||||
dock_wrapper_orientation_val: Gtk.Orientation
|
||||
|
||||
if not self.integrated_mode:
|
||||
|
||||
self.actual_dock_is_horizontal = not data.VERTICAL
|
||||
|
||||
if self.actual_dock_is_horizontal:
|
||||
anchor_to_set = "bottom"
|
||||
revealer_transition_type = "slide-up"
|
||||
main_box_orientation_val = Gtk.Orientation.VERTICAL
|
||||
main_box_h_align_val = "center"
|
||||
dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL
|
||||
else:
|
||||
|
||||
if data.BAR_POSITION == "Left":
|
||||
anchor_to_set = "right"
|
||||
revealer_transition_type = "slide-left"
|
||||
elif data.BAR_POSITION == "Right":
|
||||
anchor_to_set = "left"
|
||||
revealer_transition_type = "slide-right"
|
||||
else:
|
||||
anchor_to_set = "right"
|
||||
revealer_transition_type = "slide-left"
|
||||
|
||||
main_box_orientation_val = Gtk.Orientation.HORIZONTAL
|
||||
main_box_h_align_val = "end" if anchor_to_set == "right" else "start"
|
||||
dock_wrapper_orientation_val = Gtk.Orientation.VERTICAL
|
||||
|
||||
super().__init__(
|
||||
name="dock-window",
|
||||
layer="top",
|
||||
anchor=anchor_to_set,
|
||||
margin="0px 0px 0px 0px",
|
||||
exclusivity="auto" if self.always_show else "none",
|
||||
monitor=monitor_id,
|
||||
**kwargs,
|
||||
)
|
||||
Dock._instances.append(self)
|
||||
else:
|
||||
self.actual_dock_is_horizontal = True
|
||||
dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL
|
||||
|
||||
anchor_to_set = "bottom"
|
||||
revealer_transition_type = "slide-up"
|
||||
main_box_orientation_val = Gtk.Orientation.VERTICAL
|
||||
main_box_h_align_val = "center"
|
||||
|
||||
if not self.integrated_mode:
|
||||
match data.BAR_POSITION:
|
||||
case "Top":
|
||||
self.set_margin("-8px 0px 0px 0px")
|
||||
case "Bottom":
|
||||
self.set_margin("0px 0px 0px 0px")
|
||||
case "Left":
|
||||
self.set_margin("0px 0px 0px -8px")
|
||||
case "Right":
|
||||
self.set_margin("0px -8px 0px 0px")
|
||||
case _:
|
||||
self.set_margin("0px 0px 0px 0px")
|
||||
|
||||
self.config = read_config()
|
||||
self.conn = get_hyprland_connection()
|
||||
self.icon_resolver = IconResolver()
|
||||
self.pinned = self.config.get("pinned_apps", [])
|
||||
self.config_path = get_relative_path("../config/dock.json")
|
||||
self.app_map = {}
|
||||
self._all_apps = get_desktop_applications()
|
||||
self.app_identifiers = self._build_app_identifiers_map()
|
||||
|
||||
self.hide_id = None
|
||||
self._arranger_handler = None
|
||||
self._drag_in_progress = False
|
||||
self.is_mouse_over_dock_area = False
|
||||
self._prevent_occlusion = False
|
||||
self._forced_occlusion = False
|
||||
|
||||
self.view = Box(name="viewport", spacing=4)
|
||||
self.wrapper = Box(name="dock", children=[self.view], style_classes=["left"] if data.BAR_POSITION == "Right" else [])
|
||||
|
||||
self.wrapper.set_orientation(dock_wrapper_orientation_val)
|
||||
self.view.set_orientation(dock_wrapper_orientation_val)
|
||||
|
||||
if self.integrated_mode:
|
||||
self.wrapper.add_style_class("integrated")
|
||||
else:
|
||||
|
||||
if dock_wrapper_orientation_val == Gtk.Orientation.VERTICAL:
|
||||
self.wrapper.add_style_class("vertical")
|
||||
else:
|
||||
self.wrapper.remove_style_class("vertical")
|
||||
|
||||
match data.DOCK_THEME:
|
||||
case "Pills":
|
||||
self.wrapper.add_style_class("pills")
|
||||
case "Dense":
|
||||
self.wrapper.add_style_class("dense")
|
||||
case "Edge":
|
||||
self.wrapper.add_style_class("edge")
|
||||
case _:
|
||||
self.wrapper.add_style_class("pills")
|
||||
|
||||
if not self.integrated_mode:
|
||||
self.dock_eventbox = EventBox()
|
||||
self.dock_eventbox.add(self.wrapper)
|
||||
self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter)
|
||||
self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave)
|
||||
|
||||
self.corner_left = Box()
|
||||
self.corner_right = Box()
|
||||
self.corner_top = Box()
|
||||
self.corner_bottom = Box()
|
||||
|
||||
if self.actual_dock_is_horizontal:
|
||||
self.corner_left = Box(
|
||||
name="dock-corner-left", orientation=Gtk.Orientation.VERTICAL, h_align="start",
|
||||
children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-right")]
|
||||
)
|
||||
self.corner_right = Box(
|
||||
name="dock-corner-right", orientation=Gtk.Orientation.VERTICAL, h_align="end",
|
||||
children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-left")]
|
||||
)
|
||||
self.dock_full = Box(
|
||||
name="dock-full", orientation=Gtk.Orientation.HORIZONTAL, h_expand=True, h_align="fill",
|
||||
children=[self.corner_left, self.dock_eventbox, self.corner_right]
|
||||
)
|
||||
else:
|
||||
|
||||
|
||||
|
||||
|
||||
if anchor_to_set == "right":
|
||||
self.corner_top = Box(
|
||||
name="dock-corner-top", orientation=Gtk.Orientation.HORIZONTAL, v_align="start",
|
||||
children=[Box(h_expand=True, h_align="fill"), MyCorner("bottom-right")]
|
||||
)
|
||||
self.corner_bottom = Box(
|
||||
name="dock-corner-bottom", orientation=Gtk.Orientation.HORIZONTAL, v_align="end",
|
||||
children=[Box(h_expand=True, h_align="fill"), MyCorner("top-right")]
|
||||
)
|
||||
else:
|
||||
self.corner_top = Box(
|
||||
name="dock-corner-top", orientation=Gtk.Orientation.HORIZONTAL, v_align="start",
|
||||
children=[MyCorner("bottom-left"), Box(h_expand=True, h_align="fill")]
|
||||
)
|
||||
self.corner_bottom = Box(
|
||||
name="dock-corner-bottom", orientation=Gtk.Orientation.HORIZONTAL, v_align="end",
|
||||
children=[MyCorner("top-left"), Box(h_expand=True, h_align="fill")]
|
||||
)
|
||||
|
||||
self.dock_full = Box(
|
||||
name="dock-full", orientation=Gtk.Orientation.VERTICAL, v_expand=True, v_align="fill",
|
||||
children=[self.corner_top, self.dock_eventbox, self.corner_bottom]
|
||||
)
|
||||
|
||||
self.dock_revealer = Revealer(
|
||||
name="dock-revealer",
|
||||
transition_type=revealer_transition_type,
|
||||
transition_duration=250,
|
||||
child_revealed=False,
|
||||
child=self.dock_full
|
||||
)
|
||||
|
||||
self.hover_activator = EventBox()
|
||||
|
||||
self.hover_activator.set_size_request(-1 if self.actual_dock_is_horizontal else 1, 1 if self.actual_dock_is_horizontal else -1)
|
||||
self.hover_activator.connect("enter-notify-event", self._on_hover_enter)
|
||||
self.hover_activator.connect("leave-notify-event", self._on_hover_leave)
|
||||
|
||||
self.main_box = Box(
|
||||
orientation=main_box_orientation_val,
|
||||
children=[self.hover_activator, self.dock_revealer] if data.BAR_POSITION != "Right" else [self.dock_revealer, self.hover_activator],
|
||||
h_align=main_box_h_align_val,
|
||||
)
|
||||
self.add(self.main_box)
|
||||
|
||||
if data.DOCK_THEME in ["Edge", "Dense"]:
|
||||
for corner in [self.corner_left, self.corner_right, self.corner_top, self.corner_bottom]:
|
||||
corner.set_visible(False)
|
||||
|
||||
|
||||
# Hide normal dock when it should be embedded in the bar OR when dock is disabled
|
||||
should_be_embedded = (data.BAR_POSITION == "Bottom") or (data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Top", "Bottom"])
|
||||
if should_be_embedded or not data.DOCK_ENABLED:
|
||||
self.set_visible(False)
|
||||
|
||||
if self.always_show:
|
||||
self.dock_full.add_style_class("occluded")
|
||||
|
||||
self.view.drag_source_set(
|
||||
Gdk.ModifierType.BUTTON1_MASK,
|
||||
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
|
||||
Gdk.DragAction.MOVE
|
||||
)
|
||||
self.view.drag_dest_set(
|
||||
Gtk.DestDefaults.ALL,
|
||||
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
|
||||
Gdk.DragAction.MOVE
|
||||
)
|
||||
self.view.connect("drag-data-get", self.on_drag_data_get)
|
||||
self.view.connect("drag-data-received", self.on_drag_data_received)
|
||||
self.view.connect("drag-begin", self.on_drag_begin)
|
||||
self.view.connect("drag-end", self.on_drag_end)
|
||||
|
||||
if self.conn.ready:
|
||||
self.update_dock()
|
||||
if not self.integrated_mode: GLib.timeout_add(500, self.check_occlusion_state)
|
||||
else:
|
||||
self.conn.connect("event::ready", self.update_dock)
|
||||
if not self.integrated_mode: self.conn.connect("event::ready", lambda *args: GLib.timeout_add(250, self.check_occlusion_state))
|
||||
|
||||
# Listen to window events to update dock when apps open/close
|
||||
self.conn.connect("event::openwindow", self.update_dock)
|
||||
self.conn.connect("event::closewindow", self.update_dock)
|
||||
|
||||
if not self.integrated_mode:
|
||||
self.conn.connect("event::workspace", self.check_hide)
|
||||
|
||||
GLib.timeout_add_seconds(2, self.check_config_change)
|
||||
|
||||
def _build_app_identifiers_map(self):
|
||||
identifiers = {}
|
||||
for app in self._all_apps:
|
||||
if app.name: identifiers[app.name.lower()] = app
|
||||
if app.display_name: identifiers[app.display_name.lower()] = app
|
||||
if app.window_class: identifiers[app.window_class.lower()] = app
|
||||
if app.executable: identifiers[app.executable.split('/')[-1].lower()] = app
|
||||
if app.command_line: identifiers[app.command_line.split()[0].split('/')[-1].lower()] = app
|
||||
return identifiers
|
||||
|
||||
def _normalize_window_class(self, class_name):
|
||||
if not class_name: return ""
|
||||
normalized = class_name.lower()
|
||||
suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"]
|
||||
for suffix in suffixes:
|
||||
if normalized.endswith(suffix):
|
||||
normalized = normalized[:-len(suffix)]
|
||||
return normalized
|
||||
|
||||
def _classes_match(self, class1, class2):
|
||||
if not class1 or not class2: return False
|
||||
norm1 = self._normalize_window_class(class1)
|
||||
norm2 = self._normalize_window_class(class2)
|
||||
return norm1 == norm2
|
||||
|
||||
def on_drag_begin(self, widget, drag_context):
|
||||
self._drag_in_progress = True
|
||||
Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget))
|
||||
|
||||
def _on_hover_enter(self, *args):
|
||||
if self.integrated_mode: return
|
||||
self.is_mouse_over_dock_area = True
|
||||
if self.hide_id:
|
||||
GLib.source_remove(self.hide_id)
|
||||
self.hide_id = None
|
||||
self.dock_revealer.set_reveal_child(True)
|
||||
if not self.always_show:
|
||||
self.dock_full.remove_style_class("occluded")
|
||||
|
||||
def _on_hover_leave(self, *args):
|
||||
if self.integrated_mode: return
|
||||
self.is_mouse_over_dock_area = False
|
||||
if self._forced_occlusion:
|
||||
self.dock_revealer.set_reveal_child(False)
|
||||
else:
|
||||
self.delay_hide()
|
||||
|
||||
def _on_dock_enter(self, widget, event):
|
||||
if self.integrated_mode: return True
|
||||
self.is_mouse_over_dock_area = True
|
||||
if self.hide_id:
|
||||
GLib.source_remove(self.hide_id)
|
||||
self.hide_id = None
|
||||
self.dock_revealer.set_reveal_child(True)
|
||||
if not self.always_show:
|
||||
self.dock_full.remove_style_class("occluded")
|
||||
return True
|
||||
|
||||
def _on_dock_leave(self, widget, event):
|
||||
if self.integrated_mode: return True
|
||||
if event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
|
||||
self.is_mouse_over_dock_area = False
|
||||
|
||||
if self._forced_occlusion:
|
||||
self.dock_revealer.set_reveal_child(False)
|
||||
else:
|
||||
self.delay_hide()
|
||||
|
||||
if not self.always_show:
|
||||
self.dock_full.add_style_class("occluded")
|
||||
return True
|
||||
|
||||
def find_app(self, app_identifier):
|
||||
if not app_identifier: return None
|
||||
if isinstance(app_identifier, dict):
|
||||
for key in ["window_class", "executable", "command_line", "name", "display_name"]:
|
||||
if key in app_identifier and app_identifier[key]:
|
||||
app = self.find_app_by_key(app_identifier[key])
|
||||
if app: return app
|
||||
return None
|
||||
return self.find_app_by_key(app_identifier)
|
||||
|
||||
def find_app_by_key(self, key_value):
|
||||
if not key_value: return None
|
||||
normalized_id = str(key_value).lower()
|
||||
if normalized_id in self.app_identifiers:
|
||||
return self.app_identifiers[normalized_id]
|
||||
for app in self._all_apps:
|
||||
if app.name and normalized_id in app.name.lower(): return app
|
||||
if app.display_name and normalized_id in app.display_name.lower(): return app
|
||||
if app.window_class and normalized_id in app.window_class.lower(): return app
|
||||
if app.executable and normalized_id in app.executable.lower(): return app
|
||||
if app.command_line and normalized_id in app.command_line.lower(): return app
|
||||
return None
|
||||
|
||||
def update_app_map(self):
|
||||
self._all_apps = get_desktop_applications()
|
||||
self.app_map = {app.name: app for app in self._all_apps if app.name}
|
||||
self.app_identifiers = self._build_app_identifiers_map()
|
||||
|
||||
def create_button(self, app_identifier, instances):
|
||||
desktop_app = self.find_app(app_identifier)
|
||||
icon_img = None
|
||||
display_name = None
|
||||
|
||||
if desktop_app:
|
||||
icon_img = desktop_app.get_icon_pixbuf(size=self.icon_size)
|
||||
display_name = desktop_app.display_name or desktop_app.name
|
||||
|
||||
id_value = app_identifier["name"] if isinstance(app_identifier, dict) else app_identifier
|
||||
|
||||
if not icon_img:
|
||||
icon_img = self.icon_resolver.get_icon_pixbuf(id_value, self.icon_size)
|
||||
|
||||
if not icon_img:
|
||||
icon_img = self.icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", self.icon_size)
|
||||
if not icon_img:
|
||||
icon_img = self.icon_resolver.get_icon_pixbuf("image-missing", self.icon_size)
|
||||
|
||||
items = [Image(pixbuf=icon_img)]
|
||||
tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown")
|
||||
if not display_name and instances and instances[0].get("title"):
|
||||
tooltip = instances[0]["title"]
|
||||
|
||||
button = Button(
|
||||
child= Box(name="dock-icon", orientation="v", h_align="center", children=items),
|
||||
on_clicked=lambda *a: self.handle_app(app_identifier, instances, desktop_app),
|
||||
tooltip_text=tooltip, name="dock-app-button",
|
||||
)
|
||||
button.app_identifier = app_identifier
|
||||
button.desktop_app = desktop_app
|
||||
button.instances = instances
|
||||
if instances: button.add_style_class("instance")
|
||||
|
||||
button.drag_source_set(
|
||||
Gdk.ModifierType.BUTTON1_MASK,
|
||||
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
|
||||
Gdk.DragAction.MOVE
|
||||
)
|
||||
button.connect("drag-begin", self.on_drag_begin)
|
||||
button.connect("drag-end", self.on_drag_end)
|
||||
button.drag_dest_set(
|
||||
Gtk.DestDefaults.ALL,
|
||||
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
|
||||
Gdk.DragAction.MOVE
|
||||
)
|
||||
button.connect("drag-data-get", self.on_drag_data_get)
|
||||
button.connect("drag-data-received", self.on_drag_data_received)
|
||||
button.connect("enter-notify-event", self._on_child_enter)
|
||||
return button
|
||||
|
||||
def handle_app(self, app_identifier, instances, desktop_app=None):
|
||||
if not instances:
|
||||
if not desktop_app: desktop_app = self.find_app(app_identifier)
|
||||
if desktop_app:
|
||||
launch_success = desktop_app.launch()
|
||||
if not launch_success:
|
||||
if desktop_app.command_line: exec_shell_command_async(f"nohup {desktop_app.command_line} &")
|
||||
elif desktop_app.executable: exec_shell_command_async(f"nohup {desktop_app.executable} &")
|
||||
else:
|
||||
cmd_to_run = None
|
||||
if isinstance(app_identifier, dict):
|
||||
if "command_line" in app_identifier and app_identifier["command_line"]: cmd_to_run = app_identifier['command_line']
|
||||
elif "executable" in app_identifier and app_identifier["executable"]: cmd_to_run = app_identifier['executable']
|
||||
elif "name" in app_identifier and app_identifier["name"]: cmd_to_run = app_identifier['name']
|
||||
elif isinstance(app_identifier, str): cmd_to_run = app_identifier
|
||||
if cmd_to_run: exec_shell_command_async(f"nohup {cmd_to_run} &")
|
||||
else:
|
||||
focused = self.get_focused()
|
||||
idx = next((i for i, inst in enumerate(instances) if inst["address"] == focused), -1)
|
||||
next_inst = instances[(idx + 1) % len(instances)]
|
||||
exec_shell_command(f"hyprctl dispatch focuswindow address:{next_inst['address']}")
|
||||
|
||||
def _on_child_enter(self, widget, event):
|
||||
if self.integrated_mode: return False
|
||||
self.is_mouse_over_dock_area = True
|
||||
if self.hide_id:
|
||||
GLib.source_remove(self.hide_id)
|
||||
self.hide_id = None
|
||||
return False
|
||||
|
||||
def delay_hide(self):
|
||||
if self.integrated_mode: return
|
||||
if self.hide_id:
|
||||
GLib.source_remove(self.hide_id)
|
||||
self.hide_id = GLib.timeout_add(250, self.hide_dock_if_not_hovered)
|
||||
|
||||
def hide_dock_if_not_hovered(self):
|
||||
if self.integrated_mode:
|
||||
return False
|
||||
self.hide_id = None
|
||||
if not self.is_mouse_over_dock_area and not self._drag_in_progress and not self._prevent_occlusion:
|
||||
if not self.always_show:
|
||||
self.dock_revealer.set_reveal_child(False)
|
||||
return False
|
||||
|
||||
def check_hide(self, *args):
|
||||
if self.integrated_mode:
|
||||
return
|
||||
if self.is_mouse_over_dock_area or self._drag_in_progress or self._prevent_occlusion:
|
||||
return
|
||||
|
||||
clients = self.get_clients()
|
||||
current_ws = self.get_workspace()
|
||||
ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws]
|
||||
|
||||
if self.always_show:
|
||||
if not self.dock_revealer.get_reveal_child():
|
||||
self.dock_revealer.set_reveal_child(True)
|
||||
self.dock_full.remove_style_class("occluded")
|
||||
else:
|
||||
if self.dock_revealer.get_reveal_child():
|
||||
self.dock_revealer.set_reveal_child(False)
|
||||
self.dock_full.add_style_class("occluded")
|
||||
|
||||
def update_dock(self, *args):
|
||||
self.update_app_map()
|
||||
arranger_handler = getattr(self, "_arranger_handler", None)
|
||||
if arranger_handler: remove_handler(arranger_handler)
|
||||
clients = self.get_clients()
|
||||
|
||||
running_windows = {}
|
||||
for c in clients:
|
||||
window_id = None
|
||||
if class_name := c.get("initialClass", "").lower(): window_id = class_name
|
||||
elif class_name := c.get("class", "").lower(): window_id = class_name
|
||||
elif title := c.get("title", "").lower():
|
||||
possible_name = title.split(" - ")[0].strip()
|
||||
if possible_name and len(possible_name) > 1: window_id = possible_name
|
||||
else: window_id = title
|
||||
if not window_id: window_id = "unknown-app"
|
||||
running_windows.setdefault(window_id, []).append(c)
|
||||
normalized_id = self._normalize_window_class(window_id)
|
||||
if normalized_id != window_id:
|
||||
running_windows.setdefault(normalized_id, []).extend(running_windows[window_id])
|
||||
|
||||
pinned_buttons = []
|
||||
used_window_classes = set()
|
||||
|
||||
for app_data_item in self.pinned:
|
||||
app = self.find_app(app_data_item)
|
||||
instances = []
|
||||
matched_class = None
|
||||
possible_identifiers = []
|
||||
|
||||
if isinstance(app_data_item, dict):
|
||||
for key in ["window_class", "executable", "command_line", "name", "display_name"]:
|
||||
if key in app_data_item and app_data_item[key]: possible_identifiers.append(app_data_item[key].lower())
|
||||
elif isinstance(app_data_item, str): possible_identifiers.append(app_data_item.lower())
|
||||
|
||||
if app:
|
||||
if app.window_class: possible_identifiers.append(app.window_class.lower())
|
||||
if app.executable: possible_identifiers.append(app.executable.split('/')[-1].lower())
|
||||
if app.command_line:
|
||||
cmd_parts = app.command_line.split()
|
||||
if cmd_parts: possible_identifiers.append(cmd_parts[0].split('/')[-1].lower())
|
||||
if app.name: possible_identifiers.append(app.name.lower())
|
||||
if app.display_name: possible_identifiers.append(app.display_name.lower())
|
||||
|
||||
possible_identifiers = list(set(possible_identifiers))
|
||||
|
||||
for identifier in possible_identifiers:
|
||||
if identifier in running_windows:
|
||||
instances = running_windows[identifier]; matched_class = identifier; break
|
||||
normalized = self._normalize_window_class(identifier)
|
||||
if normalized in running_windows:
|
||||
instances = running_windows[normalized]; matched_class = normalized; break
|
||||
for window_class_key in running_windows:
|
||||
if len(identifier) >= 3 and identifier in window_class_key:
|
||||
instances = running_windows[window_class_key]; matched_class = window_class_key
|
||||
break
|
||||
if matched_class: break
|
||||
|
||||
if matched_class:
|
||||
used_window_classes.add(matched_class)
|
||||
used_window_classes.add(self._normalize_window_class(matched_class))
|
||||
|
||||
pinned_buttons.append(self.create_button(app_data_item, instances))
|
||||
|
||||
open_buttons = []
|
||||
for class_name, instances in running_windows.items():
|
||||
if class_name not in used_window_classes:
|
||||
app = None
|
||||
app = self.app_identifiers.get(class_name)
|
||||
if not app:
|
||||
norm_class = self._normalize_window_class(class_name)
|
||||
app = self.app_identifiers.get(norm_class)
|
||||
if not app: app = self.find_app_by_key(class_name)
|
||||
if not app and instances and instances[0].get("title"):
|
||||
title = instances[0].get("title", "")
|
||||
potential_name = title.split(" - ")[0].strip()
|
||||
if len(potential_name) > 2: app = self.find_app_by_key(potential_name)
|
||||
|
||||
if app:
|
||||
app_data_obj = {
|
||||
"name": app.name, "display_name": app.display_name,
|
||||
"window_class": app.window_class, "executable": app.executable,
|
||||
"command_line": app.command_line
|
||||
}
|
||||
identifier = app_data_obj
|
||||
else: identifier = class_name
|
||||
open_buttons.append(self.create_button(identifier, instances))
|
||||
|
||||
children = pinned_buttons
|
||||
separator_orientation = Gtk.Orientation.VERTICAL if self.view.get_orientation() == Gtk.Orientation.HORIZONTAL else Gtk.Orientation.HORIZONTAL
|
||||
if pinned_buttons and open_buttons:
|
||||
children += [Box(orientation=separator_orientation, v_expand=False, h_expand=False, h_align="center", v_align="center", name="dock-separator")]
|
||||
children += open_buttons
|
||||
|
||||
self.view.children = children
|
||||
if not self.integrated_mode:
|
||||
idle_add(self._update_size)
|
||||
self._drag_in_progress = False
|
||||
if not self.integrated_mode:
|
||||
self.check_occlusion_state()
|
||||
|
||||
def _update_size(self):
|
||||
if self.integrated_mode: return False
|
||||
width, _ = self.view.get_preferred_width()
|
||||
self.set_size_request(width, -1)
|
||||
return False
|
||||
|
||||
def get_clients(self):
|
||||
try: return json.loads(self.conn.send_command("j/clients").reply.decode())
|
||||
except json.JSONDecodeError: return []
|
||||
|
||||
def get_focused(self):
|
||||
try: return json.loads(self.conn.send_command("j/activewindow").reply.decode()).get("address", "")
|
||||
except json.JSONDecodeError: return ""
|
||||
|
||||
def get_workspace(self):
|
||||
try: return json.loads(self.conn.send_command("j/activeworkspace").reply.decode()).get("id", 0)
|
||||
except json.JSONDecodeError: return 0
|
||||
|
||||
def check_occlusion_state(self):
|
||||
if self.integrated_mode:
|
||||
return False
|
||||
|
||||
# When forced occlusion is active, only show on hover
|
||||
if self._forced_occlusion:
|
||||
if self.is_mouse_over_dock_area:
|
||||
if not self.dock_revealer.get_reveal_child():
|
||||
self.dock_revealer.set_reveal_child(True)
|
||||
self.dock_full.remove_style_class("occluded")
|
||||
else:
|
||||
if self.dock_revealer.get_reveal_child():
|
||||
self.dock_revealer.set_reveal_child(False)
|
||||
self.dock_full.add_style_class("occluded")
|
||||
return True
|
||||
|
||||
if self.is_mouse_over_dock_area or self._drag_in_progress or self._prevent_occlusion:
|
||||
if not self.dock_revealer.get_reveal_child():
|
||||
self.dock_revealer.set_reveal_child(True)
|
||||
if not self.always_show:
|
||||
self.dock_full.remove_style_class("occluded")
|
||||
return True
|
||||
|
||||
if self.always_show:
|
||||
if not self.dock_revealer.get_reveal_child():
|
||||
self.dock_revealer.set_reveal_child(True)
|
||||
self.dock_full.remove_style_class("occluded")
|
||||
else:
|
||||
if self.dock_revealer.get_reveal_child():
|
||||
self.dock_revealer.set_reveal_child(False)
|
||||
self.dock_full.add_style_class("occluded")
|
||||
|
||||
return True
|
||||
|
||||
def _find_drag_target(self, widget):
|
||||
children = self.view.get_children()
|
||||
while widget is not None and widget not in children:
|
||||
widget = widget.get_parent() if hasattr(widget, "get_parent") else None
|
||||
return widget
|
||||
|
||||
def on_drag_data_get(self, widget, drag_context, data_obj, info, time):
|
||||
target = self._find_drag_target(widget.get_parent() if isinstance(widget, Box) else widget)
|
||||
if target is not None:
|
||||
index = self.view.get_children().index(target)
|
||||
data_obj.set_text(str(index), -1)
|
||||
|
||||
def on_drag_data_received(self, widget, drag_context, x, y, data_obj, info, time):
|
||||
target = self._find_drag_target(widget.get_parent() if isinstance(widget, Box) else widget)
|
||||
if target is None: return
|
||||
try: source_index = int(data_obj.get_text())
|
||||
except (TypeError, ValueError): return
|
||||
|
||||
children = self.view.get_children()
|
||||
try: target_index = children.index(target)
|
||||
except ValueError: return
|
||||
|
||||
if source_index != target_index:
|
||||
separator_index = -1
|
||||
for i, child_item_loop in enumerate(children):
|
||||
if child_item_loop.get_name() == "dock-separator":
|
||||
separator_index = i; break
|
||||
cross_section_drag = (separator_index != -1 and
|
||||
((source_index < separator_index and target_index > separator_index) or
|
||||
(source_index > separator_index and target_index < separator_index)))
|
||||
|
||||
child_item_to_move = children.pop(source_index)
|
||||
children.insert(target_index, child_item_to_move)
|
||||
self.view.children = children
|
||||
self.update_pinned_apps(skip_update=not cross_section_drag)
|
||||
if cross_section_drag: GLib.idle_add(self.update_dock)
|
||||
|
||||
def on_drag_end(self, widget, drag_context):
|
||||
if not self._drag_in_progress:
|
||||
return
|
||||
|
||||
def process_drag_end():
|
||||
display = Gdk.Display.get_default()
|
||||
_, x, y, _ = display.get_pointer()
|
||||
|
||||
# Get the widget's allocation to check if drag ended outside
|
||||
alloc = self.view.get_allocation()
|
||||
widget_x = alloc.x
|
||||
widget_y = alloc.y
|
||||
widget_width = alloc.width
|
||||
widget_height = alloc.height
|
||||
|
||||
# Check if pointer is outside the dock area
|
||||
if not (widget_x <= x <= widget_x + widget_width and widget_y <= y <= widget_y + widget_height):
|
||||
app_id_dragged = widget.app_identifier
|
||||
instances_dragged = widget.instances
|
||||
|
||||
# Remove pinned app
|
||||
app_index_dragged = -1
|
||||
for i, pinned_app_item in enumerate(self.pinned):
|
||||
if isinstance(app_id_dragged, dict) and isinstance(pinned_app_item, dict):
|
||||
if app_id_dragged.get("name") == pinned_app_item.get("name"):
|
||||
app_index_dragged = i
|
||||
break
|
||||
elif app_id_dragged == pinned_app_item:
|
||||
app_index_dragged = i
|
||||
break
|
||||
|
||||
if app_index_dragged >= 0:
|
||||
self.pinned.pop(app_index_dragged)
|
||||
self.config["pinned_apps"] = self.pinned
|
||||
self.update_pinned_apps_file()
|
||||
self.update_dock()
|
||||
elif instances_dragged:
|
||||
address = instances_dragged[0].get("address")
|
||||
if address:
|
||||
exec_shell_command(f"hyprctl dispatch focuswindow address:{address}")
|
||||
|
||||
self._drag_in_progress = False
|
||||
if not self.integrated_mode:
|
||||
self.check_occlusion_state()
|
||||
|
||||
GLib.idle_add(process_drag_end)
|
||||
def check_config_change(self):
|
||||
new_config = read_config()
|
||||
if not self.integrated_mode:
|
||||
new_always_show = data.DOCK_ALWAYS_SHOW
|
||||
if self.always_show != new_always_show:
|
||||
self.always_show = new_always_show
|
||||
self.check_occlusion_state()
|
||||
|
||||
if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []):
|
||||
self.config = new_config
|
||||
self.pinned = self.config.get("pinned_apps", [])
|
||||
self.update_app_map()
|
||||
self.update_dock()
|
||||
return True
|
||||
|
||||
def update_pinned_apps_file(self):
|
||||
config_path = get_relative_path("../config/dock.json")
|
||||
try:
|
||||
with open(config_path, "w") as file:
|
||||
json.dump(self.config, file, indent=4)
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write dock config: {e}")
|
||||
return False
|
||||
|
||||
def update_pinned_apps(self, skip_update=False):
|
||||
pinned_children_data = []
|
||||
for child_widget in self.view.get_children():
|
||||
if child_widget.get_name() == "dock-separator": break
|
||||
if hasattr(child_widget, "app_identifier"):
|
||||
if hasattr(child_widget, "desktop_app") and child_widget.desktop_app:
|
||||
app = child_widget.desktop_app
|
||||
app_data_obj = {
|
||||
"name": app.name, "display_name": app.display_name,
|
||||
"window_class": app.window_class, "executable": app.executable,
|
||||
"command_line": app.command_line
|
||||
}
|
||||
pinned_children_data.append(app_data_obj)
|
||||
else:
|
||||
pinned_children_data.append(child_widget.app_identifier)
|
||||
|
||||
self.config["pinned_apps"] = pinned_children_data
|
||||
self.pinned = pinned_children_data
|
||||
file_updated = self.update_pinned_apps_file()
|
||||
if file_updated and not skip_update:
|
||||
self.update_dock()
|
||||
|
||||
@staticmethod
|
||||
def notify_config_change():
|
||||
for dock_instance in Dock._instances:
|
||||
GLib.idle_add(dock_instance.check_config_change_immediate)
|
||||
|
||||
def check_config_change_immediate(self):
|
||||
new_config = read_config()
|
||||
|
||||
if not self.integrated_mode:
|
||||
previous_always_show = self.always_show
|
||||
self.always_show = data.DOCK_ALWAYS_SHOW
|
||||
|
||||
if previous_always_show != self.always_show:
|
||||
self.check_occlusion_state()
|
||||
|
||||
if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []):
|
||||
self.config = new_config
|
||||
self.pinned = self.config.get("pinned_apps", [])
|
||||
self.update_app_map()
|
||||
self.update_dock()
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def update_visibility(visible):
|
||||
for dock in Dock._instances:
|
||||
dock.set_visible(visible)
|
||||
if visible:
|
||||
GLib.idle_add(dock.check_occlusion_state)
|
||||
else:
|
||||
if hasattr(dock, 'dock_revealer') and dock.dock_revealer.get_reveal_child():
|
||||
dock.dock_revealer.set_reveal_child(False)
|
||||
|
||||
def force_occlusion(self):
|
||||
"""Force dock to hide and act as if always_show is False."""
|
||||
if self.integrated_mode:
|
||||
return
|
||||
# Save current always_show state
|
||||
self._saved_always_show = self.always_show
|
||||
# Set to False to enable hover behavior
|
||||
self.always_show = False
|
||||
self._forced_occlusion = True
|
||||
if not self.is_mouse_over_dock_area:
|
||||
self.dock_revealer.set_reveal_child(False)
|
||||
|
||||
def restore_from_occlusion(self):
|
||||
"""Restore dock to its previous always_show state."""
|
||||
if self.integrated_mode:
|
||||
return
|
||||
self._forced_occlusion = False
|
||||
# Restore saved always_show state
|
||||
if hasattr(self, '_saved_always_show'):
|
||||
self.always_show = self._saved_always_show
|
||||
delattr(self, '_saved_always_show')
|
||||
self.check_occlusion_state()
|
||||
@@ -0,0 +1,321 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import ijson
|
||||
from fabric.utils import remove_handler
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.entry import Entry
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.stack import Stack
|
||||
from gi.repository import Gdk
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
|
||||
vertical_mode = data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"])
|
||||
|
||||
emoji_rows = 3 if not vertical_mode else 9
|
||||
emoji_columns = 9 if not vertical_mode else 5
|
||||
|
||||
class EmojiPicker(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="emoji",
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.notch = kwargs["notch"]
|
||||
self.selected_index = -1
|
||||
self.emojis_per_page = emoji_columns * emoji_rows
|
||||
self.current_page_index = 0
|
||||
self.filtered_emojis = []
|
||||
self.total_pages = 0
|
||||
|
||||
self._arranger_handler: int = 0
|
||||
self._all_emojis = self._load_emoji_data()
|
||||
|
||||
self.stack = Stack(
|
||||
name="viewport",
|
||||
spacing=4,
|
||||
orientation="v",
|
||||
transition_type="slide-up-down",
|
||||
transition_duration=200,
|
||||
)
|
||||
self.search_entry = Entry(
|
||||
name="search-entry",
|
||||
placeholder="Search Emojis...",
|
||||
h_expand=True,
|
||||
notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
|
||||
on_activate=lambda entry, *_: self.on_search_entry_activate(entry.get_text()),
|
||||
on_key_press_event=self.on_search_entry_key_press,
|
||||
)
|
||||
self.search_entry.props.xalign = 0.5
|
||||
self.header_box = Box(
|
||||
name="header_box",
|
||||
spacing=10,
|
||||
orientation="h",
|
||||
children=[
|
||||
self.search_entry,
|
||||
Button(
|
||||
name="close-button",
|
||||
child=Label(name="close-label", markup=icons.cancel),
|
||||
tooltip_text="Exit",
|
||||
on_clicked=lambda *_: self.close_picker()
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self.picker_box = Box(
|
||||
name="picker-box",
|
||||
spacing=10,
|
||||
h_expand=True,
|
||||
orientation="v",
|
||||
children=[
|
||||
self.header_box,
|
||||
self.stack,
|
||||
],
|
||||
)
|
||||
|
||||
self.resize_viewport()
|
||||
|
||||
self.add(self.picker_box)
|
||||
self.show_all()
|
||||
|
||||
def _load_emoji_data(self):
|
||||
emoji_data = {}
|
||||
emoji_file_path = get_relative_path("../assets/emoji.json")
|
||||
if not os.path.exists(emoji_file_path):
|
||||
print(f"Emoji JSON file not found at: {emoji_file_path}")
|
||||
return {}
|
||||
|
||||
with open(emoji_file_path, 'r') as f:
|
||||
for emoji_char, emoji_info in ijson.kvitems(f, ''):
|
||||
emoji_data[emoji_char] = emoji_info
|
||||
return emoji_data
|
||||
|
||||
def close_picker(self):
|
||||
self.stack.children = []
|
||||
self.selected_index = -1
|
||||
self.notch.close_notch()
|
||||
|
||||
def open_picker(self):
|
||||
self.search_entry.set_text("")
|
||||
self.current_page_index = 0
|
||||
self.arrange_viewport()
|
||||
self.search_entry.grab_focus()
|
||||
|
||||
def arrange_viewport(self, query: str = ""):
|
||||
remove_handler(self._arranger_handler) if self._arranger_handler else None
|
||||
self.stack.children = []
|
||||
self.selected_index = -1
|
||||
self.current_page_index = 0
|
||||
|
||||
self.filtered_emojis = [
|
||||
(emoji_char, emoji_info)
|
||||
for emoji_char, emoji_info in self._all_emojis.items()
|
||||
if query.casefold() in (emoji_info.get("name", "") + " " + emoji_info.get("group", "")).casefold()
|
||||
]
|
||||
self.total_pages = (len(self.filtered_emojis) + self.emojis_per_page - 1) // self.emojis_per_page if self.filtered_emojis else 0
|
||||
|
||||
self.load_page(self.current_page_index)
|
||||
|
||||
should_resize = not query
|
||||
|
||||
if should_resize:
|
||||
self.resize_viewport()
|
||||
if query.strip() != "" and self.get_all_emoji_buttons():
|
||||
self.update_selection(0)
|
||||
|
||||
def load_page(self, page_index):
|
||||
self.update_selection(-1)
|
||||
page_box = Box(name=f"page-box-{page_index}", orientation="v", spacing=4)
|
||||
start_index = page_index * self.emojis_per_page
|
||||
end_index = min((page_index + 1) * self.emojis_per_page, len(self.filtered_emojis))
|
||||
page_emojis = self.filtered_emojis[start_index:end_index]
|
||||
|
||||
grid_box = Box(name="emoji-grid-box", orientation="v", spacing=2)
|
||||
|
||||
row_box = None
|
||||
for i, (emoji_char, emoji_info) in enumerate(page_emojis):
|
||||
if i % emoji_columns == 0:
|
||||
row_box = Box(name="emoji-row-box", orientation="h", spacing=2)
|
||||
grid_box.add(row_box)
|
||||
if row_box is not None:
|
||||
row_box.add(self.bake_emoji_slot(emoji_char, emoji_info))
|
||||
page_box.add(grid_box)
|
||||
self.stack.add_named(page_box, f"page-{page_index}")
|
||||
self.stack.set_visible_child_name(f"page-{page_index}")
|
||||
page_box.show_all()
|
||||
|
||||
|
||||
buttons = self.get_all_emoji_buttons()
|
||||
if buttons and self.selected_index != -1:
|
||||
page_relative_index = self.selected_index % self.emojis_per_page
|
||||
if page_relative_index < len(buttons):
|
||||
self.update_selection(page_relative_index)
|
||||
else:
|
||||
self.update_selection(len(buttons) - 1)
|
||||
|
||||
|
||||
def resize_viewport(self):
|
||||
return False
|
||||
|
||||
def bake_emoji_slot(self, emoji_char: str, emoji_info: dict, **kwargs) -> Button:
|
||||
button = Button(
|
||||
name="emoji-slot-button",
|
||||
child=Box(
|
||||
name="emoji-slot-box",
|
||||
orientation="horizontal",
|
||||
halign="center",
|
||||
valign="center",
|
||||
children=[
|
||||
Label(
|
||||
name="emoji-char-label",
|
||||
label=emoji_char,
|
||||
use_markup=True,
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
css_name="emoji-char-label"
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip_text=emoji_info.get("name", "Unknown"),
|
||||
on_clicked=lambda *_: (self.copy_emoji_to_clipboard(emoji_char), self.close_picker()),
|
||||
**kwargs,
|
||||
)
|
||||
return button
|
||||
|
||||
def update_selection(self, new_index: int):
|
||||
buttons = self.get_all_emoji_buttons()
|
||||
if not buttons:
|
||||
self.selected_index = -1
|
||||
return
|
||||
|
||||
if self.selected_index != -1 and self.selected_index < len(buttons):
|
||||
current_button = buttons[self.selected_index]
|
||||
current_button.get_style_context().remove_class("selected")
|
||||
if not buttons or current_button not in buttons:
|
||||
self.selected_index = -1
|
||||
|
||||
if 0 <= new_index < len(buttons):
|
||||
new_button = buttons[new_index]
|
||||
new_button.get_style_context().add_class("selected")
|
||||
self.selected_index = new_index
|
||||
else:
|
||||
self.selected_index = -1
|
||||
|
||||
|
||||
def get_all_emoji_buttons(self):
|
||||
buttons = []
|
||||
current_page = self.stack.get_visible_child()
|
||||
if current_page and current_page.get_children():
|
||||
if current_page.get_children()[0].get_children():
|
||||
for row_box in current_page.get_children()[0].get_children():
|
||||
buttons.extend(row_box.get_children())
|
||||
return buttons
|
||||
|
||||
|
||||
def on_search_entry_activate(self, text):
|
||||
buttons = self.get_all_emoji_buttons()
|
||||
if buttons:
|
||||
if self.selected_index != -1:
|
||||
buttons[self.selected_index].clicked()
|
||||
elif buttons and text.strip() != "":
|
||||
buttons[0].clicked()
|
||||
|
||||
def on_search_entry_key_press(self, widget, event):
|
||||
if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right):
|
||||
self.move_selection_2d(event.keyval)
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Escape:
|
||||
self.close_picker()
|
||||
return True
|
||||
return False
|
||||
|
||||
def move_selection_2d(self, keyval):
|
||||
buttons = self.get_all_emoji_buttons()
|
||||
total_items_current_page = len(buttons)
|
||||
if total_items_current_page == 0:
|
||||
return
|
||||
|
||||
rows = emoji_rows
|
||||
columns = emoji_columns
|
||||
|
||||
if self.selected_index == -1:
|
||||
if keyval in (Gdk.KEY_Down, Gdk.KEY_Right):
|
||||
new_index = 0
|
||||
elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left):
|
||||
new_index = total_items_current_page - 1
|
||||
else:
|
||||
return
|
||||
else:
|
||||
current_index_page = self.selected_index
|
||||
row = current_index_page // columns
|
||||
col = current_index_page % columns
|
||||
|
||||
if keyval == Gdk.KEY_Right:
|
||||
new_col = (col + 1) % columns
|
||||
new_row = row
|
||||
if new_col == 0:
|
||||
new_row = (row + 1)
|
||||
elif keyval == Gdk.KEY_Left:
|
||||
new_col = (col - 1) % columns
|
||||
new_row = row
|
||||
if new_col == (columns - 1):
|
||||
new_row = (row - 1)
|
||||
elif keyval == Gdk.KEY_Down:
|
||||
new_row = row + 1
|
||||
new_col = col
|
||||
elif keyval == Gdk.KEY_Up:
|
||||
new_row = row - 1
|
||||
new_col = col
|
||||
else:
|
||||
return
|
||||
|
||||
if new_row >= rows:
|
||||
if self.current_page_index < self.total_pages - 1:
|
||||
current_col = col # Keep track of current column
|
||||
self.current_page_index += 1
|
||||
self.load_page(self.current_page_index)
|
||||
new_index = current_col # Try to keep the same column
|
||||
if new_index >= total_items_current_page: # if column is out of bound, select last
|
||||
new_index = total_items_current_page - 1
|
||||
self.selected_index = -1
|
||||
self.update_selection(new_index)
|
||||
return
|
||||
else:
|
||||
new_index = total_items_current_page - 1
|
||||
elif new_row < 0:
|
||||
if self.current_page_index > 0:
|
||||
current_col = col # Keep track of current column
|
||||
self.current_page_index -= 1
|
||||
self.load_page(self.current_page_index)
|
||||
new_index = (rows - 1) * columns + current_col # Select last row, same column
|
||||
if new_index >= total_items_current_page: # if column is out of bound, select last
|
||||
new_index = total_items_current_page -1
|
||||
self.selected_index = -1
|
||||
self.update_selection(new_index)
|
||||
return
|
||||
else:
|
||||
new_index = 0
|
||||
else:
|
||||
new_index = new_row * columns + new_col
|
||||
if new_index >= total_items_current_page:
|
||||
new_index = total_items_current_page - 1
|
||||
|
||||
if new_index < 0:
|
||||
new_index = 0
|
||||
elif new_index >= total_items_current_page:
|
||||
new_index = total_items_current_page - 1
|
||||
|
||||
self.update_selection(new_index)
|
||||
|
||||
def copy_emoji_to_clipboard(self, emoji_char: str):
|
||||
try:
|
||||
subprocess.run(["wl-copy"], input=emoji_char.encode('utf-8'), check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Clipboard copy failed: {e}")
|
||||
@@ -0,0 +1,197 @@
|
||||
# Parameters
|
||||
font_family: str = "tabler-icons"
|
||||
font_weight: str = "normal"
|
||||
|
||||
span: str = f"<span font-family='{font_family}' font-weight='{font_weight}'>"
|
||||
|
||||
# Panels
|
||||
apps: str = ""
|
||||
dashboard: str = ""
|
||||
chat: str = ""
|
||||
windows: str = ""
|
||||
|
||||
# Bar
|
||||
colorpicker: str = ""
|
||||
media: str = ""
|
||||
|
||||
# Toolbox
|
||||
|
||||
toolbox: str = ""
|
||||
ssfull: str = ""
|
||||
ssregion: str = ""
|
||||
sswindow: str = ""
|
||||
screenshots: str = ""
|
||||
screenrecord: str = ""
|
||||
recordings: str = ""
|
||||
ocr: str = "ﳃ"
|
||||
gamemode: str = ""
|
||||
gamemode_off: str = ""
|
||||
close: str = ""
|
||||
|
||||
# Circles
|
||||
temp: str = ""
|
||||
disk: str = ""
|
||||
battery: str = ""
|
||||
memory: str = "流"
|
||||
cpu: str = ""
|
||||
gpu: str = ""
|
||||
|
||||
# AIchat
|
||||
reload: str = ""
|
||||
detach: str = ""
|
||||
|
||||
# Wallpapers
|
||||
add: str = ""
|
||||
sort: str = ""
|
||||
circle: str = ""
|
||||
|
||||
# Chevrons
|
||||
chevron_up: str = ""
|
||||
chevron_down: str = ""
|
||||
chevron_left: str = ""
|
||||
chevron_right: str = ""
|
||||
|
||||
# Power
|
||||
lock: str = ""
|
||||
suspend: str = ""
|
||||
logout: str = ""
|
||||
reboot: str = ""
|
||||
shutdown: str = ""
|
||||
|
||||
# Power Manager
|
||||
power_saving: str = ""
|
||||
power_balanced: str = "勺"
|
||||
power_performance: str = ""
|
||||
charging: str = ""
|
||||
discharging: str = ""
|
||||
alert: str = ""
|
||||
bat_charging: str = ""
|
||||
bat_discharging: str = ""
|
||||
bat_low: str = "="
|
||||
bat_full: str = ""
|
||||
|
||||
|
||||
# Applets
|
||||
wifi_0: str = ""
|
||||
wifi_1: str = ""
|
||||
wifi_2: str = ""
|
||||
wifi_3: str = ""
|
||||
world: str = ""
|
||||
world_off: str = ""
|
||||
bluetooth: str = ""
|
||||
night: str = ""
|
||||
coffee: str = ""
|
||||
notifications: str = ""
|
||||
|
||||
wifi_off: str = ""
|
||||
bluetooth_off: str = ""
|
||||
night_off: str = ""
|
||||
notifications_off: str = ""
|
||||
|
||||
notifications_clear: str = ""
|
||||
|
||||
download: str = ""
|
||||
upload: str = ""
|
||||
|
||||
# Bluetooth
|
||||
bluetooth_connected: str = ""
|
||||
bluetooth_disconnected: str = ""
|
||||
|
||||
# Player
|
||||
pause: str = ""
|
||||
play: str = ""
|
||||
stop: str = ""
|
||||
skip_back: str = ""
|
||||
skip_forward: str = ""
|
||||
prev: str = ""
|
||||
next: str = ""
|
||||
shuffle: str = ""
|
||||
repeat: str = ""
|
||||
music: str = ""
|
||||
rewind_backward_5: str = "謹"
|
||||
rewind_forward_5: str = "難"
|
||||
|
||||
# Volume
|
||||
vol_off: str = ""
|
||||
vol_mute: str = ""
|
||||
vol_medium: str = ""
|
||||
vol_high: str = ""
|
||||
|
||||
mic: str = ""
|
||||
mic_mute: str = ""
|
||||
|
||||
speaker: str = "𐁅"
|
||||
headphones: str = "屮"
|
||||
mic_filled: str = "️"
|
||||
|
||||
# Overview
|
||||
circle_plus: str = ""
|
||||
|
||||
# Pins
|
||||
paperclip: str = ""
|
||||
|
||||
# Clipboard Manager
|
||||
clipboard: str = ""
|
||||
clip_text: str = ""
|
||||
|
||||
# Confirm
|
||||
accept: str = ""
|
||||
cancel: str = ""
|
||||
trash: str = ""
|
||||
|
||||
# Config
|
||||
config: str = ""
|
||||
|
||||
# Icons
|
||||
firefox: str = ""
|
||||
chromium: str = ""
|
||||
spotify: str = "ﺆ"
|
||||
disc: str = "𐀾"
|
||||
disc_off: str = ""
|
||||
|
||||
# Brightness
|
||||
brightness_low: str = ""
|
||||
brightness_medium: str = ""
|
||||
brightness_high: str = ""
|
||||
|
||||
brightness: str = ""
|
||||
|
||||
# Dashboard
|
||||
widgets: str = ""
|
||||
pins: str = ""
|
||||
kanban: str = ""
|
||||
wallpapers: str = ""
|
||||
sparkles: str = ""
|
||||
|
||||
# Misc
|
||||
dot: str = ""
|
||||
palette: str = ""
|
||||
cloud_off: str = ""
|
||||
loader: str = ""
|
||||
radar: str = ""
|
||||
emoji: str = ""
|
||||
keyboard: str = ""
|
||||
terminal: str = ""
|
||||
timer_off: str = ""
|
||||
timer_on: str = ""
|
||||
spy: str = ""
|
||||
|
||||
# Dice
|
||||
dice_1: str = ""
|
||||
dice_2: str = ""
|
||||
dice_3: str = ""
|
||||
dice_4: str = ""
|
||||
dice_5: str = ""
|
||||
dice_6: str = ""
|
||||
|
||||
exceptions: list[str] = ["font_family", "font_weight", "span"]
|
||||
|
||||
|
||||
def apply_span() -> None:
|
||||
global_dict = globals()
|
||||
for key in global_dict:
|
||||
if key not in exceptions and not key.startswith("__"):
|
||||
global_dict[key] = f"{span}{global_dict[key]}</span>"
|
||||
|
||||
|
||||
apply_span()
|
||||
@@ -0,0 +1,364 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gdk, GLib, GObject, Gtk
|
||||
|
||||
|
||||
def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
|
||||
alloc = widget.get_allocation()
|
||||
surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height)
|
||||
cr = cairo.Context(surface)
|
||||
|
||||
cr.set_source_rgba(0, 0, 0, 0)
|
||||
cr.rectangle(0, 0, alloc.width, alloc.height)
|
||||
cr.fill()
|
||||
widget.draw(cr)
|
||||
return surface
|
||||
|
||||
class InlineEditor(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
'confirmed': (GObject.SignalFlags.RUN_LAST, None, (str,)),
|
||||
'canceled': (GObject.SignalFlags.RUN_LAST, None, ())
|
||||
}
|
||||
|
||||
def __init__(self, initial_text=""):
|
||||
super().__init__(name="inline-editor", spacing=4)
|
||||
|
||||
self.text_view = Gtk.TextView()
|
||||
self.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||
buffer = self.text_view.get_buffer()
|
||||
buffer.set_text(initial_text)
|
||||
|
||||
self.text_view.connect("key-press-event", self.on_key_press)
|
||||
|
||||
confirm_btn = Gtk.Button(name="kanban-btn", child=Label(name="kanban-btn-label", markup=icons.accept))
|
||||
confirm_btn.connect("clicked", self.on_confirm)
|
||||
confirm_btn.get_style_context().add_class("flat")
|
||||
|
||||
cancel_btn = Gtk.Button(name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.cancel))
|
||||
cancel_btn.connect("clicked", self.on_cancel)
|
||||
cancel_btn.get_style_context().add_class("flat")
|
||||
|
||||
|
||||
sw = ScrolledWindow(name="scrolled-window", propagate_height=False, propagate_width=False)
|
||||
sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
sw.set_min_content_height(50)
|
||||
sw.add(self.text_view)
|
||||
|
||||
self.button_box = Box(children=[confirm_btn, cancel_btn], spacing=4)
|
||||
self.center_box = CenterBox(center_children=[self.button_box], orientation="v")
|
||||
|
||||
self.pack_start(sw, True, True, 0)
|
||||
self.pack_start(self.center_box, False, False, 0)
|
||||
self.show_all()
|
||||
|
||||
def on_confirm(self, widget):
|
||||
buffer = self.text_view.get_buffer()
|
||||
start, end = buffer.get_bounds()
|
||||
text = buffer.get_text(start, end, True).strip()
|
||||
if text:
|
||||
self.emit('confirmed', text)
|
||||
else:
|
||||
self.emit('canceled')
|
||||
|
||||
def on_cancel(self, widget):
|
||||
self.emit('canceled')
|
||||
|
||||
def on_key_press(self, widget, event):
|
||||
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self.emit('canceled')
|
||||
return True
|
||||
|
||||
|
||||
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
||||
state = event.get_state()
|
||||
if state & Gdk.ModifierType.SHIFT_MASK:
|
||||
|
||||
buffer = self.text_view.get_buffer()
|
||||
cursor_iter = buffer.get_iter_at_mark(buffer.get_insert())
|
||||
buffer.insert(cursor_iter, "\n")
|
||||
return True
|
||||
else:
|
||||
|
||||
self.on_confirm(widget)
|
||||
return True
|
||||
return False
|
||||
|
||||
class KanbanNote(Gtk.EventBox):
|
||||
__gsignals__ = {
|
||||
'changed': (GObject.SignalFlags.RUN_LAST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self, text):
|
||||
super().__init__()
|
||||
self.text = text
|
||||
|
||||
self.setup_ui()
|
||||
self.setup_dnd()
|
||||
self.connect("button-press-event", self.on_button_press)
|
||||
|
||||
def setup_ui(self):
|
||||
self.box = Gtk.Box(name="kanban-note", spacing=4)
|
||||
self.label = Gtk.Label(label=self.text)
|
||||
self.label.set_line_wrap(True)
|
||||
|
||||
self.label.set_line_wrap_mode(Gtk.WrapMode.WORD)
|
||||
|
||||
self.delete_btn = Gtk.Button(name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.trash))
|
||||
self.delete_btn.connect("clicked", self.on_delete_clicked)
|
||||
|
||||
self.center_btn = CenterBox(orientation="v", start_children=[self.delete_btn])
|
||||
|
||||
self.box.pack_start(self.label, True, True, 0)
|
||||
self.box.pack_start(self.center_btn, False, False, 0)
|
||||
self.add(self.box)
|
||||
self.show_all()
|
||||
|
||||
def setup_dnd(self):
|
||||
self.drag_source_set(
|
||||
Gdk.ModifierType.BUTTON1_MASK,
|
||||
[Gtk.TargetEntry.new('UTF8_STRING', Gtk.TargetFlags.SAME_APP, 0)],
|
||||
Gdk.DragAction.MOVE
|
||||
)
|
||||
self.connect("drag-data-get", self.on_drag_data_get)
|
||||
self.connect("drag-data-delete", self.on_drag_data_delete)
|
||||
|
||||
self.connect("drag-begin", self.on_drag_begin)
|
||||
|
||||
def on_button_press(self, widget, event):
|
||||
if event.type != Gdk.EventType._2BUTTON_PRESS:
|
||||
return True
|
||||
self.start_edit()
|
||||
return False
|
||||
|
||||
def on_drag_begin(self, widget, context):
|
||||
surface = createSurfaceFromWidget(self)
|
||||
Gtk.drag_set_icon_surface(context, surface)
|
||||
|
||||
def on_drag_data_get(self, widget, drag_context, data, info, time):
|
||||
data.set_text(self.label.get_text(), -1)
|
||||
|
||||
def on_drag_data_delete(self, widget, drag_context):
|
||||
self.get_parent().destroy()
|
||||
|
||||
def on_delete_clicked(self, button):
|
||||
self.get_parent().destroy()
|
||||
|
||||
|
||||
def start_edit(self):
|
||||
row = self.get_parent()
|
||||
editor = InlineEditor(self.label.get_text())
|
||||
|
||||
def on_confirmed(editor, text):
|
||||
self.label.set_text(text)
|
||||
row.remove(editor)
|
||||
row.add(self)
|
||||
row.show_all()
|
||||
self.emit('changed')
|
||||
|
||||
def on_canceled(editor):
|
||||
row.remove(editor)
|
||||
row.add(self)
|
||||
row.show_all()
|
||||
|
||||
editor.connect('confirmed', on_confirmed)
|
||||
editor.connect('canceled', on_canceled)
|
||||
|
||||
row.remove(self)
|
||||
row.add(editor)
|
||||
row.show_all()
|
||||
|
||||
GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False))
|
||||
|
||||
class KanbanColumn(Gtk.Frame):
|
||||
__gsignals__ = {
|
||||
'changed': (GObject.SignalFlags.RUN_LAST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self, title):
|
||||
super().__init__(name="kanban-column")
|
||||
self.title = title
|
||||
self.setup_ui()
|
||||
self.setup_dnd()
|
||||
self.set_hexpand(True)
|
||||
self.set_vexpand(True)
|
||||
|
||||
def setup_ui(self):
|
||||
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
self.listbox = Gtk.ListBox()
|
||||
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
|
||||
self.add_btn = Gtk.Button(name="kanban-btn-add", child=Label(name="kanban-btn-label", markup=icons.add))
|
||||
header = CenterBox(name="kanban-header", center_children=[Label(name="column-header", label=self.title)], end_children=[self.add_btn])
|
||||
self.box.pack_start(header, False, False, 0)
|
||||
|
||||
self.add_btn.connect("clicked", self.on_add_clicked)
|
||||
|
||||
self.scroller = ScrolledWindow(name="scrolled-window", propagate_height=False, propagate_width=False)
|
||||
self.scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
self.scroller.add(self.listbox)
|
||||
self.scroller.set_vexpand(True)
|
||||
|
||||
self.box.pack_start(self.scroller, True, True, 0)
|
||||
self.box.pack_start(self.add_btn, False, False, 0)
|
||||
self.add(self.box)
|
||||
self.show_all()
|
||||
|
||||
def setup_dnd(self):
|
||||
self.listbox.drag_dest_set(
|
||||
Gtk.DestDefaults.ALL,
|
||||
[Gtk.TargetEntry.new('UTF8_STRING', Gtk.TargetFlags.SAME_APP, 0)],
|
||||
Gdk.DragAction.MOVE
|
||||
)
|
||||
|
||||
self.listbox.connect("drag-data-received", self.on_drag_data_received)
|
||||
self.listbox.connect("drag-motion", self.on_drag_motion)
|
||||
self.listbox.connect("drag-leave", self.on_drag_leave)
|
||||
|
||||
def on_add_clicked(self, button):
|
||||
editor = InlineEditor()
|
||||
row = Gtk.ListBoxRow(name="kanban-row")
|
||||
row.add(editor)
|
||||
self.listbox.add(row)
|
||||
self.listbox.show_all()
|
||||
editor.text_view.grab_focus()
|
||||
|
||||
def on_confirmed(editor, text):
|
||||
note = KanbanNote(text)
|
||||
note.connect('changed', lambda x: self.emit('changed'))
|
||||
row.remove(editor)
|
||||
row.add(note)
|
||||
self.listbox.show_all()
|
||||
self.emit('changed')
|
||||
|
||||
def on_canceled(editor):
|
||||
row.destroy()
|
||||
|
||||
def scroll_to_bottom():
|
||||
adj = self.scroller.get_vadjustment()
|
||||
adj.set_value(adj.get_upper())
|
||||
|
||||
editor.connect('confirmed', on_confirmed)
|
||||
editor.connect('canceled', on_canceled)
|
||||
|
||||
GLib.idle_add(scroll_to_bottom) # ensure this is called after row is loaded
|
||||
|
||||
def add_note(self, text, suppress_signal=False):
|
||||
note = KanbanNote(text)
|
||||
note.connect('changed', lambda x: self.emit('changed'))
|
||||
row = Gtk.ListBoxRow(name="kanban-row")
|
||||
row.add(note)
|
||||
row.connect('destroy', lambda x: self.emit('changed'))
|
||||
self.listbox.add(row)
|
||||
self.listbox.show_all()
|
||||
if not suppress_signal:
|
||||
self.emit('changed')
|
||||
|
||||
def get_notes(self):
|
||||
return [
|
||||
row.get_children()[0].label.get_text()
|
||||
for row in self.listbox.get_children()
|
||||
if isinstance(row.get_children()[0], KanbanNote)
|
||||
]
|
||||
|
||||
def clear_notes(self, suppress_signal=False):
|
||||
for row in self.listbox.get_children():
|
||||
row.destroy()
|
||||
if not suppress_signal:
|
||||
self.emit('changed')
|
||||
|
||||
def on_drag_data_received(self, widget, drag_context, x, y, data, info, time):
|
||||
text = data.get_text()
|
||||
if text:
|
||||
row = self.listbox.get_row_at_y(y)
|
||||
new_note = KanbanNote(text)
|
||||
new_note.connect('changed', lambda x: self.emit('changed'))
|
||||
new_row = Gtk.ListBoxRow(name="kanban-row")
|
||||
new_row.add(new_note)
|
||||
new_row.connect('destroy', lambda x: self.emit('changed'))
|
||||
|
||||
if row:
|
||||
self.listbox.insert(new_row, row.get_index())
|
||||
else:
|
||||
self.listbox.add(new_row)
|
||||
|
||||
self.listbox.show_all()
|
||||
drag_context.finish(True, False, time)
|
||||
self.emit('changed')
|
||||
|
||||
def on_drag_motion(self, widget, drag_context, x, y, time):
|
||||
Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time)
|
||||
return True
|
||||
|
||||
def on_drag_leave(self, widget, drag_context, time):
|
||||
widget.get_parent().get_parent().drag_unhighlight()
|
||||
|
||||
class Kanban(Gtk.Box):
|
||||
STATE_FILE = Path(os.path.expanduser("~/.kanban.json"))
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="kanban")
|
||||
|
||||
self.grid = Gtk.Grid(column_spacing=4, column_homogeneous=True, row_spacing=4, row_homogeneous=True)
|
||||
self.grid.set_vexpand(True)
|
||||
self.add(self.grid)
|
||||
|
||||
self.columns = [
|
||||
KanbanColumn("To Do"),
|
||||
KanbanColumn("In Progress"),
|
||||
KanbanColumn("Done")
|
||||
]
|
||||
|
||||
vertical_mode = True if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]) else False
|
||||
|
||||
for i, column in enumerate(self.columns):
|
||||
if vertical_mode == False:
|
||||
self.grid.attach(column, i, 0, 1, 1)
|
||||
else:
|
||||
self.grid.attach(column, 0, i, 1, 1)
|
||||
column.connect('changed', lambda x: self.save_state())
|
||||
|
||||
self.load_state()
|
||||
self.show_all()
|
||||
|
||||
def save_state(self):
|
||||
state = {
|
||||
"columns": [
|
||||
{"title": col.title, "notes": col.get_notes()}
|
||||
for col in self.columns
|
||||
]
|
||||
}
|
||||
try:
|
||||
with open(self.STATE_FILE, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error saving state: {e}")
|
||||
|
||||
def load_state(self):
|
||||
try:
|
||||
with open(self.STATE_FILE, "r") as f:
|
||||
state = json.load(f)
|
||||
for col_data in state["columns"]:
|
||||
for column in self.columns:
|
||||
if column.title == col_data["title"]:
|
||||
column.clear_notes(suppress_signal=True)
|
||||
for note_text in col_data["notes"]:
|
||||
column.add_note(note_text, suppress_signal=True)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error loading state: {e}")
|
||||
@@ -0,0 +1,787 @@
|
||||
import json
|
||||
import math
|
||||
import operator
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Iterator
|
||||
|
||||
import numpy as np
|
||||
from fabric.utils import (DesktopApp, exec_shell_command_async,
|
||||
get_desktop_applications, idle_add, remove_handler)
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.entry import Entry
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from gi.repository import Gdk, GLib
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
from modules.dock import Dock
|
||||
from modules.updater import run_updater
|
||||
from utils.conversion import Conversion
|
||||
|
||||
tooltip_settings = f"<b>Open {data.APP_NAME_CAP} Settings</b>"
|
||||
tooltip_close = "<b>Close</b>"
|
||||
|
||||
class AppLauncher(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="app-launcher",
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.notch = kwargs["notch"]
|
||||
self.selected_index = -1
|
||||
|
||||
self._arranger_handler: int = 0
|
||||
self._all_apps = get_desktop_applications()
|
||||
|
||||
|
||||
self.converter = Conversion()
|
||||
self.calc_history_path = f"{data.CACHE_DIR}/calc.json"
|
||||
if os.path.exists(self.calc_history_path):
|
||||
with open(self.calc_history_path, "r") as f:
|
||||
self.calc_history = json.load(f)
|
||||
else:
|
||||
self.calc_history = []
|
||||
|
||||
self.conversion_history_path = f"{data.CACHE_DIR}/conversion.json"
|
||||
if os.path.exists(self.conversion_history_path):
|
||||
with open(self.conversion_history_path, "r") as f:
|
||||
self.conversion_history = json.load(f)
|
||||
else:
|
||||
self.conversion_history = []
|
||||
|
||||
self.viewport = Box(name="viewport", spacing=4, orientation="v")
|
||||
self.search_entry = Entry(
|
||||
name="search-entry",
|
||||
placeholder="Search Applications...",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
notify_text=self.notify_text,
|
||||
on_activate=lambda entry, *_: self.on_search_entry_activate(entry.get_text()),
|
||||
on_key_press_event=self.on_search_entry_key_press,
|
||||
)
|
||||
self.search_entry.props.xalign = 0.5
|
||||
self.scrolled_window = ScrolledWindow(
|
||||
name="scrolled-window",
|
||||
spacing=10,
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
h_align="fill",
|
||||
v_align="fill",
|
||||
child=self.viewport,
|
||||
propagate_width=False,
|
||||
propagate_height=False,
|
||||
)
|
||||
|
||||
self.header_box = Box(
|
||||
name="header_box",
|
||||
spacing=10,
|
||||
orientation="h",
|
||||
children=[
|
||||
Button(
|
||||
name="config-button",
|
||||
tooltip_markup=tooltip_settings,
|
||||
child=Label(name="config-label", markup=icons.config),
|
||||
on_clicked=lambda *_: (exec_shell_command_async(f"python {get_relative_path('../config/config.py')}"), self.close_launcher()),
|
||||
),
|
||||
self.search_entry,
|
||||
Button(
|
||||
name="close-button",
|
||||
tooltip_markup=tooltip_close,
|
||||
child=Label(name="close-label", markup=icons.cancel),
|
||||
tooltip_text="Exit",
|
||||
on_clicked=lambda *_: self.close_launcher()
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self.launcher_box = Box(
|
||||
name="launcher-box",
|
||||
spacing=10,
|
||||
h_expand=True,
|
||||
orientation="v",
|
||||
children=[
|
||||
self.header_box,
|
||||
self.scrolled_window,
|
||||
],
|
||||
)
|
||||
|
||||
self.resize_viewport()
|
||||
|
||||
self.add(self.launcher_box)
|
||||
self.show_all()
|
||||
|
||||
def close_launcher(self):
|
||||
self.viewport.children = []
|
||||
self.selected_index = -1
|
||||
self.notch.close_notch()
|
||||
|
||||
def open_launcher(self):
|
||||
self._all_apps = get_desktop_applications()
|
||||
self.arrange_viewport()
|
||||
|
||||
|
||||
def clear_selection():
|
||||
|
||||
entry = self.search_entry
|
||||
if entry.get_text():
|
||||
pos = len(entry.get_text())
|
||||
entry.set_position(pos)
|
||||
entry.select_region(pos, pos)
|
||||
return False
|
||||
|
||||
|
||||
GLib.idle_add(clear_selection)
|
||||
|
||||
def ensure_initialized(self):
|
||||
"""Make sure the launcher is initialized with apps list before opening"""
|
||||
if not hasattr(self, '_initialized'):
|
||||
|
||||
self._all_apps = get_desktop_applications()
|
||||
self._initialized = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def arrange_viewport(self, query: str = ""):
|
||||
if query.startswith("="):
|
||||
|
||||
self.update_calculator_viewport()
|
||||
return
|
||||
if query.startswith(";"):
|
||||
# In conversion mode, update history view once (not per keystroke)
|
||||
self.update_conversion_viewport()
|
||||
return
|
||||
remove_handler(self._arranger_handler) if self._arranger_handler else None
|
||||
self.viewport.children = []
|
||||
self.selected_index = -1
|
||||
|
||||
def extract_command_name(command_line):
|
||||
"""Extract base command name from command line, removing paths and arguments"""
|
||||
if not command_line:
|
||||
return ""
|
||||
# Remove common shell wrappers
|
||||
if command_line.startswith("/bin/sh -c"):
|
||||
# Handle wrapped commands like "/bin/sh -c "\$SHELL -i -c scrcpy""
|
||||
return ""
|
||||
# Split by spaces and take first part (the command)
|
||||
cmd = command_line.split()[0] if command_line.split() else ""
|
||||
# Extract just the command name from full paths
|
||||
if "/" in cmd:
|
||||
cmd = cmd.split("/")[-1]
|
||||
return cmd
|
||||
|
||||
filtered_apps_iter = iter(
|
||||
sorted(
|
||||
[
|
||||
app
|
||||
for app in self._all_apps
|
||||
if query.casefold()
|
||||
in (
|
||||
(app.display_name or "")
|
||||
+ (" " + app.name + " ")
|
||||
+ (app.generic_name or "")
|
||||
+ (" " + (app.command_line or "") + " ")
|
||||
+ (" " + (app.executable or "") + " ")
|
||||
+ (" " + extract_command_name(app.command_line) + " ")
|
||||
).casefold()
|
||||
],
|
||||
key=lambda app: (app.display_name or "").casefold(),
|
||||
)
|
||||
)
|
||||
should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps)
|
||||
|
||||
self._arranger_handler = idle_add(
|
||||
lambda apps_iter: self.add_next_application(apps_iter) or self.handle_arrange_complete(should_resize, query),
|
||||
filtered_apps_iter,
|
||||
pin=True,
|
||||
)
|
||||
|
||||
def handle_arrange_complete(self, should_resize, query):
|
||||
if query.strip() != "" and self.viewport.get_children():
|
||||
self.update_selection(0)
|
||||
return False
|
||||
|
||||
def add_next_application(self, apps_iter: Iterator[DesktopApp]):
|
||||
if not (app := next(apps_iter, None)):
|
||||
return False
|
||||
self.viewport.add(self.bake_application_slot(app))
|
||||
return True
|
||||
|
||||
def resize_viewport(self):
|
||||
# Removed set_min_content_width to prevent size retention issues
|
||||
# when switching between modules in the notch stack
|
||||
pass
|
||||
|
||||
def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button:
|
||||
button = Button(
|
||||
name="slot-button",
|
||||
child=Box(
|
||||
name="slot-box",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[
|
||||
Image(name="app-icon", pixbuf=app.get_icon_pixbuf(size=24), h_align="start"),
|
||||
Label(
|
||||
name="app-label",
|
||||
label=app.display_name or "Unknown",
|
||||
ellipsization="end",
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
),
|
||||
Label(
|
||||
name="app-desc",
|
||||
label=app.description or "",
|
||||
ellipsization="end",
|
||||
v_align="center",
|
||||
h_align="start",
|
||||
h_expand=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip_text=app.description,
|
||||
on_clicked=lambda *_: (app.launch(), self.close_launcher()),
|
||||
**kwargs,
|
||||
)
|
||||
return button
|
||||
|
||||
def update_selection(self, new_index: int):
|
||||
|
||||
if self.selected_index != -1 and self.selected_index < len(self.viewport.get_children()):
|
||||
current_button = self.viewport.get_children()[self.selected_index]
|
||||
current_button.get_style_context().remove_class("selected")
|
||||
|
||||
if new_index != -1 and new_index < len(self.viewport.get_children()):
|
||||
new_button = self.viewport.get_children()[new_index]
|
||||
new_button.get_style_context().add_class("selected")
|
||||
self.selected_index = new_index
|
||||
self.scroll_to_selected(new_button)
|
||||
else:
|
||||
self.selected_index = -1
|
||||
|
||||
def scroll_to_selected(self, button):
|
||||
def scroll():
|
||||
adj = self.scrolled_window.get_vadjustment()
|
||||
alloc = button.get_allocation()
|
||||
if alloc.height == 0:
|
||||
return False
|
||||
|
||||
y = alloc.y
|
||||
height = alloc.height
|
||||
page_size = adj.get_page_size()
|
||||
current_value = adj.get_value()
|
||||
|
||||
visible_top = current_value
|
||||
visible_bottom = current_value + page_size
|
||||
|
||||
if y < visible_top:
|
||||
|
||||
adj.set_value(y)
|
||||
elif y + height > visible_bottom:
|
||||
|
||||
new_value = y + height - page_size
|
||||
adj.set_value(new_value)
|
||||
|
||||
return False
|
||||
GLib.idle_add(scroll)
|
||||
|
||||
def on_search_entry_activate(self, text):
|
||||
if text.startswith("="):
|
||||
|
||||
if self.selected_index == -1:
|
||||
self.evaluate_calculator_expression(text)
|
||||
return
|
||||
if text.startswith(";"):
|
||||
# If in calculator mode and no history item is selected, evaluate new expression.
|
||||
if self.selected_index == -1:
|
||||
self.evaluate_calculator_expression(text)
|
||||
return
|
||||
match text:
|
||||
case ":w":
|
||||
self.notch.open_notch("wallpapers")
|
||||
case ":d":
|
||||
self.notch.open_notch("dashboard")
|
||||
case ":p":
|
||||
self.notch.open_notch("power")
|
||||
case ":update":
|
||||
GLib.idle_add(lambda: run_updater(force=True))
|
||||
case ":settings":
|
||||
exec_shell_command_async(f"python {get_relative_path('../config/config.py')}")
|
||||
self.close_launcher()
|
||||
case ":config":
|
||||
exec_shell_command_async(f"python {get_relative_path('../config/config.py')}")
|
||||
self.close_launcher()
|
||||
case _:
|
||||
children = self.viewport.get_children()
|
||||
if children:
|
||||
|
||||
if text.strip() == "" and self.selected_index == -1:
|
||||
return
|
||||
selected_index = self.selected_index if self.selected_index != -1 else 0
|
||||
if 0 <= selected_index < len(children):
|
||||
children[selected_index].clicked()
|
||||
|
||||
def on_search_entry_key_press(self, widget, event):
|
||||
text = widget.get_text()
|
||||
|
||||
|
||||
if text.startswith("="):
|
||||
if event.keyval == Gdk.KEY_Down:
|
||||
self.move_selection(1)
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Up:
|
||||
self.move_selection(-1)
|
||||
return True
|
||||
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
||||
|
||||
if self.selected_index != -1 and self.selected_index < len(self.calc_history):
|
||||
if event.state & Gdk.ModifierType.SHIFT_MASK:
|
||||
|
||||
self.delete_selected_calc_history()
|
||||
else:
|
||||
|
||||
selected_text = self.calc_history[self.selected_index]
|
||||
self.copy_text_to_clipboard(selected_text)
|
||||
|
||||
self.selected_index = -1
|
||||
else:
|
||||
|
||||
self.selected_index = -1
|
||||
|
||||
self.evaluate_calculator_expression(text)
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Escape:
|
||||
self.close_launcher()
|
||||
return True
|
||||
return False
|
||||
if text.startswith(";"):
|
||||
if event.keyval == Gdk.KEY_Down:
|
||||
self.move_selection(1)
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Up:
|
||||
self.move_selection(-1)
|
||||
return True
|
||||
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
||||
# In conversion mode, if a history item is highlighted:
|
||||
if self.selected_index != -1 and self.selected_index < len(self.conversion_history):
|
||||
if event.state & Gdk.ModifierType.SHIFT_MASK:
|
||||
# Shift+Enter deletes the selected calculator history item
|
||||
self.delete_selected_conversion_history()
|
||||
else:
|
||||
# Normal Enter copies the result
|
||||
selected_text = self.conversion_history[self.selected_index]
|
||||
self.copy_text_to_clipboard(selected_text)
|
||||
# Clear selection so new expressions are evaluated on further Return presses
|
||||
self.selected_index = -1
|
||||
else:
|
||||
# Force reset selection index
|
||||
self.selected_index = -1
|
||||
# No item selected, evaluate the expression
|
||||
self.evaluate_conversion_expression(text)
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Escape:
|
||||
self.close_launcher()
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
|
||||
if event.keyval == Gdk.KEY_Down:
|
||||
self.move_selection(1)
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Up:
|
||||
self.move_selection(-1)
|
||||
return True
|
||||
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter) and (event.state & Gdk.ModifierType.SHIFT_MASK):
|
||||
|
||||
self.add_selected_app_to_dock()
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Escape:
|
||||
self.close_launcher()
|
||||
return True
|
||||
return False
|
||||
|
||||
def notify_text(self, entry, *_):
|
||||
"""Handle text changes in the search entry"""
|
||||
text = entry.get_text()
|
||||
if text.startswith("="):
|
||||
self.update_calculator_viewport()
|
||||
|
||||
self.selected_index = -1
|
||||
elif text.startswith(";"):
|
||||
self.update_conversion_viewport()
|
||||
# Always reset selection when typing a new expression
|
||||
self.selected_index = -1
|
||||
else:
|
||||
self.arrange_viewport(text)
|
||||
|
||||
def add_selected_app_to_dock(self):
|
||||
"""Adds the currently selected application to the dock.json file with comprehensive metadata."""
|
||||
children = self.viewport.get_children()
|
||||
if not children or self.selected_index == -1 or self.selected_index >= len(children):
|
||||
return
|
||||
|
||||
selected_button = children[self.selected_index]
|
||||
|
||||
selected_app = next((app for app in self._all_apps if app.display_name == selected_button.get_child().get_children()[1].props.label), None)
|
||||
if not selected_app:
|
||||
return
|
||||
|
||||
app_data = {k: v for k, v in {
|
||||
"name": selected_app.name,
|
||||
"display_name": selected_app.display_name,
|
||||
"window_class": selected_app.window_class,
|
||||
"executable": selected_app.executable,
|
||||
"command_line": selected_app.command_line,
|
||||
"icon_name": selected_app.icon_name
|
||||
}.items() if v is not None}
|
||||
|
||||
config_path = get_relative_path("../config/dock.json")
|
||||
try:
|
||||
with open(config_path, "r") as file:
|
||||
data = json.load(file)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {"pinned_apps": []}
|
||||
|
||||
already_pinned = False
|
||||
for pinned_app in data.get("pinned_apps", []):
|
||||
if isinstance(pinned_app, dict) and pinned_app.get("name") == app_data["name"]:
|
||||
already_pinned = True
|
||||
|
||||
pinned_app.update(app_data)
|
||||
break
|
||||
elif isinstance(pinned_app, str) and pinned_app == app_data["name"]:
|
||||
already_pinned = True
|
||||
|
||||
data["pinned_apps"].remove(pinned_app)
|
||||
data["pinned_apps"].append(app_data)
|
||||
break
|
||||
|
||||
if not already_pinned:
|
||||
data.setdefault("pinned_apps", []).append(app_data)
|
||||
|
||||
|
||||
with open(config_path, "w") as file:
|
||||
json.dump(data, file, indent=4)
|
||||
|
||||
|
||||
Dock.notify_config_change()
|
||||
|
||||
def move_selection(self, delta: int):
|
||||
children = self.viewport.get_children()
|
||||
if not children:
|
||||
return
|
||||
|
||||
if self.selected_index == -1 and delta == 1:
|
||||
new_index = 0
|
||||
else:
|
||||
new_index = self.selected_index + delta
|
||||
new_index = max(0, min(new_index, len(children) - 1))
|
||||
self.update_selection(new_index)
|
||||
|
||||
def save_calc_history(self):
|
||||
with open(self.calc_history_path, "w") as f:
|
||||
json.dump(self.calc_history, f)
|
||||
|
||||
def save_conversion_history(self):
|
||||
with open(self.conversion_history_path, "w") as f:
|
||||
json.dump(self.conversion_history, f)
|
||||
|
||||
def evaluate_calculator_expression(self, text: str):
|
||||
|
||||
print(f"Evaluating calculator expression: {text}")
|
||||
|
||||
|
||||
expr = text.lstrip("=").strip()
|
||||
if not expr:
|
||||
return
|
||||
|
||||
|
||||
replacements = {
|
||||
"^": "**",
|
||||
"×": "*",
|
||||
"÷": "/",
|
||||
"π": "np.pi",
|
||||
"pi": "np.pi",
|
||||
"e": "np.e",
|
||||
"sin(": "np.sin(",
|
||||
"cos(": "np.cos(",
|
||||
"tan(": "np.tan(",
|
||||
"log(": "np.log10(",
|
||||
"ln(": "np.log(",
|
||||
"sqrt(": "np.sqrt(",
|
||||
"abs(": "np.abs(",
|
||||
"exp(": "np.exp("
|
||||
}
|
||||
|
||||
|
||||
for old, new in replacements.items():
|
||||
expr = expr.replace(old, new)
|
||||
|
||||
|
||||
expr = re.sub(r'(\d+)!', r'np.factorial(\1)', expr)
|
||||
|
||||
|
||||
for old, new in [("[", "("), ("]", ")"), ("{", "("), ("}", ")")]:
|
||||
expr = expr.replace(old, new)
|
||||
|
||||
|
||||
safe_dict = {
|
||||
'np': np,
|
||||
'math': math,
|
||||
'arange': np.arange,
|
||||
'linspace': np.linspace,
|
||||
'array': np.array
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
result = eval(expr, {"__builtins__": None}, safe_dict)
|
||||
|
||||
|
||||
if isinstance(result, np.ndarray):
|
||||
if result.size > 10:
|
||||
result_str = f"Array of shape {result.shape}"
|
||||
else:
|
||||
result_str = str(result)
|
||||
elif isinstance(result, (int, float, np.number)):
|
||||
|
||||
if isinstance(result, (int, np.integer)) or result.is_integer():
|
||||
result_str = str(int(result))
|
||||
else:
|
||||
result_str = f"{float(result):.10g}"
|
||||
else:
|
||||
result_str = str(result)
|
||||
|
||||
except Exception as e:
|
||||
result_str = f"Error: {str(e)}"
|
||||
|
||||
|
||||
self.calc_history.insert(0, f"{text} => {result_str}")
|
||||
self.save_calc_history()
|
||||
self.update_calculator_viewport()
|
||||
|
||||
def evaluate_conversion_expression(self, text: str):
|
||||
print(f"Evaluating conversion expression: {text}")
|
||||
expr = text.lstrip(";").strip()
|
||||
if not expr:
|
||||
return
|
||||
|
||||
# Add loading entry
|
||||
loading_entry = f"{text} => Loading..."
|
||||
self.conversion_history.insert(0, loading_entry)
|
||||
self.update_conversion_viewport()
|
||||
|
||||
# Perform conversion in thread
|
||||
def do_conversion():
|
||||
try:
|
||||
result_value, result_type = self.converter.parse_input_and_convert(expr)
|
||||
if result_type is None:
|
||||
result_str = f"{result_value:.2f}"
|
||||
else:
|
||||
result_str = f"{result_value:.2f} {result_type}"
|
||||
except:
|
||||
result_str = "Error: Invalid conversion expression"
|
||||
|
||||
# Update the history entry
|
||||
GLib.idle_add(self._update_conversion_result, text, result_str)
|
||||
|
||||
GLib.Thread.new("conversion", do_conversion, None)
|
||||
|
||||
def _update_conversion_result(self, text, result_str):
|
||||
# Replace the loading entry with the result
|
||||
if self.conversion_history and self.conversion_history[0].startswith(f"{text} => Loading"):
|
||||
self.conversion_history[0] = f"{text} => {result_str}"
|
||||
else:
|
||||
# Fallback: insert new
|
||||
self.conversion_history.insert(0, f"{text} => {result_str}")
|
||||
self.save_conversion_history()
|
||||
self.update_conversion_viewport()
|
||||
|
||||
def update_calculator_viewport(self):
|
||||
self.viewport.children = []
|
||||
for item in self.calc_history:
|
||||
btn = self.create_calc_history_button(item)
|
||||
self.viewport.add(btn)
|
||||
|
||||
if self.selected_index >= len(self.calc_history):
|
||||
self.selected_index = -1
|
||||
|
||||
def update_conversion_viewport(self):
|
||||
self.viewport.children = []
|
||||
for item in self.conversion_history:
|
||||
btn = self.create_conversion_history_button(item)
|
||||
self.viewport.add(btn)
|
||||
# Don't reset selection index here automatically
|
||||
# Ensure selection state stays valid
|
||||
if self.selected_index >= len(self.conversion_history):
|
||||
self.selected_index = -1
|
||||
|
||||
def create_calc_history_button(self, text: str) -> Button:
|
||||
|
||||
if "=>" in text:
|
||||
parts = text.split("=>")
|
||||
expression = parts[0].strip()
|
||||
result = parts[1].strip()
|
||||
|
||||
|
||||
display_text = text
|
||||
if len(result) > 50:
|
||||
display_text = f"{expression} => {result[:47]}..."
|
||||
|
||||
btn = Button(
|
||||
name="slot-button",
|
||||
child=Box(
|
||||
name="calc-slot-box",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[
|
||||
Label(
|
||||
name="calc-label",
|
||||
label=display_text,
|
||||
ellipsization="end",
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip_text=text,
|
||||
on_clicked=lambda *_: self.copy_text_to_clipboard(text),
|
||||
)
|
||||
else:
|
||||
|
||||
btn = Button(
|
||||
name="slot-button",
|
||||
child=Box(
|
||||
name="calc-slot-box",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[
|
||||
Label(
|
||||
name="calc-label",
|
||||
label=text,
|
||||
ellipsization="end",
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip_text=text,
|
||||
on_clicked=lambda *_: self.copy_text_to_clipboard(text),
|
||||
)
|
||||
return btn
|
||||
|
||||
def create_conversion_history_button(self, text: str) -> Button:
|
||||
# Parse the result to create a more readable display
|
||||
if "=>" in text:
|
||||
parts = text.split("=>")
|
||||
expression = parts[0].strip()
|
||||
result = parts[1].strip()
|
||||
|
||||
# For very long results, truncate for display but keep full in tooltip
|
||||
display_text = text
|
||||
if len(result) > 50: # Truncate long results
|
||||
display_text = f"{expression} => {result[:47]}..."
|
||||
|
||||
btn = Button(
|
||||
name="slot-button", # reuse existing CSS styling
|
||||
child=Box(
|
||||
name="calc-slot-box",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[
|
||||
Label(
|
||||
name="calc-label",
|
||||
label=display_text,
|
||||
ellipsization="end",
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip_text=text,
|
||||
on_clicked=lambda *_: self.copy_text_to_clipboard(text),
|
||||
)
|
||||
else:
|
||||
# Fallback for non-calculation entries
|
||||
btn = Button(
|
||||
name="slot-button",
|
||||
child=Box(
|
||||
name="calc-slot-box",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[
|
||||
Label(
|
||||
name="calc-label",
|
||||
label=text,
|
||||
ellipsization="end",
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip_text=text,
|
||||
on_clicked=lambda *_: self.copy_text_to_clipboard(text),
|
||||
)
|
||||
return btn
|
||||
|
||||
def copy_text_to_clipboard(self, text: str):
|
||||
|
||||
parts = text.split("=>", 1)
|
||||
copy_text = parts[1].strip() if len(parts) > 1 else text
|
||||
try:
|
||||
subprocess.run(["wl-copy"], input=copy_text.encode(), check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Clipboard copy failed: {e}")
|
||||
|
||||
def delete_selected_calc_history(self):
|
||||
if self.selected_index != -1 and self.selected_index < len(self.calc_history):
|
||||
|
||||
current_index = self.selected_index
|
||||
|
||||
|
||||
del self.calc_history[current_index]
|
||||
self.save_calc_history()
|
||||
|
||||
|
||||
new_index = 0 if current_index == 0 else current_index - 1
|
||||
|
||||
|
||||
self.selected_index = -1
|
||||
|
||||
|
||||
self.update_calculator_viewport()
|
||||
|
||||
|
||||
if len(self.calc_history) > 0:
|
||||
self.update_selection(min(new_index, len(self.calc_history) - 1))
|
||||
|
||||
def delete_selected_conversion_history(self):
|
||||
if self.selected_index != -1 and self.selected_index < len(self.conversion_history):
|
||||
# Store the current index before deletion
|
||||
current_index = self.selected_index
|
||||
|
||||
# Delete the item
|
||||
del self.conversion_history[current_index]
|
||||
self.save_conversion_history()
|
||||
|
||||
# Determine the new selection index
|
||||
# If we deleted the first item, stay at index 0
|
||||
# Otherwise, move to the previous item
|
||||
new_index = 0 if current_index == 0 else current_index - 1
|
||||
|
||||
# Reset selection before updating viewport
|
||||
self.selected_index = -1
|
||||
|
||||
# Update the viewport
|
||||
self.update_conversion_viewport()
|
||||
|
||||
# If we still have items, select the determined index
|
||||
if len(self.conversion_history) > 0:
|
||||
self.update_selection(min(new_index, len(self.conversion_history) - 1))
|
||||
@@ -0,0 +1,717 @@
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import psutil
|
||||
from fabric.core.fabricator import Fabricator
|
||||
from fabric.utils.helpers import invoke_repeater
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from fabric.widgets.scale import Scale
|
||||
from gi.repository import GLib
|
||||
|
||||
import config.data as data
|
||||
from modules.upower.upower import UPowerManager
|
||||
import modules.icons as icons
|
||||
from services.network import NetworkClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MetricsProvider:
|
||||
"""
|
||||
Class responsible for obtaining centralized CPU, memory, disk usage, and battery metrics.
|
||||
It updates periodically so that all widgets querying it display the same values.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.gpu = []
|
||||
self.cpu = 0.0
|
||||
self.mem = 0.0
|
||||
self.disk = []
|
||||
|
||||
self.upower = UPowerManager()
|
||||
self.display_device = self.upower.get_display_device()
|
||||
self.bat_percent = 0.0
|
||||
self.bat_charging = None
|
||||
self.bat_time = 0
|
||||
|
||||
self._gpu_update_running = False
|
||||
self._gpu_update_counter = 0
|
||||
|
||||
GLib.timeout_add_seconds(2, self._update)
|
||||
|
||||
def _update(self):
|
||||
self.cpu = psutil.cpu_percent(interval=0)
|
||||
self.mem = psutil.virtual_memory().percent
|
||||
self.disk = [psutil.disk_usage(path).percent for path in data.BAR_METRICS_DISKS]
|
||||
|
||||
self._gpu_update_counter += 1
|
||||
if self._gpu_update_counter >= 5: # Update GPU every 10 seconds (5 * 2s)
|
||||
self._gpu_update_counter = 0
|
||||
if not self._gpu_update_running:
|
||||
self._start_gpu_update_async()
|
||||
|
||||
battery = self.upower.get_full_device_information(self.display_device)
|
||||
if battery is None:
|
||||
self.bat_percent = 0.0
|
||||
self.bat_charging = None
|
||||
self.bat_time = 0
|
||||
else:
|
||||
self.bat_percent = battery['Percentage']
|
||||
self.bat_charging = battery['State'] == 1
|
||||
self.bat_time = battery['TimeToFull'] if self.bat_charging else battery['TimeToEmpty']
|
||||
|
||||
return True
|
||||
|
||||
def _start_gpu_update_async(self):
|
||||
"""Starts a new GLib thread to run nvtop in the background."""
|
||||
self._gpu_update_running = True
|
||||
|
||||
GLib.Thread.new("nvtop-thread", lambda _: self._run_nvtop_in_thread(), None)
|
||||
|
||||
def _run_nvtop_in_thread(self):
|
||||
"""Runs nvtop via subprocess in a separate GLib thread."""
|
||||
output = None
|
||||
error_message = None
|
||||
try:
|
||||
result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=10)
|
||||
output = result
|
||||
except FileNotFoundError:
|
||||
error_message = "nvtop command not found."
|
||||
logger.warning(error_message)
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_message = f"nvtop failed with exit code {e.returncode}: {e.stderr.strip()}"
|
||||
logger.error(error_message)
|
||||
except subprocess.TimeoutExpired:
|
||||
error_message = "nvtop command timed out."
|
||||
logger.error(error_message)
|
||||
except Exception as e:
|
||||
error_message = f"Unexpected error running nvtop: {e}"
|
||||
logger.error(error_message)
|
||||
|
||||
GLib.idle_add(self._process_gpu_output, output, error_message)
|
||||
self._gpu_update_running = False
|
||||
|
||||
def _process_gpu_output(self, output, error_message):
|
||||
"""Process nvtop JSON output on the main loop."""
|
||||
try:
|
||||
if error_message:
|
||||
logger.error(f"GPU update failed: {error_message}")
|
||||
self.gpu = []
|
||||
elif output:
|
||||
info = json.loads(output)
|
||||
try:
|
||||
self.gpu = [
|
||||
(
|
||||
int(v["gpu_util"].strip("%"))
|
||||
if v["gpu_util"] is not None
|
||||
else 0
|
||||
)
|
||||
for v in info
|
||||
]
|
||||
except (KeyError, ValueError, TypeError) as e:
|
||||
logger.error(f"Failed parsing nvtop JSON: {e}")
|
||||
self.gpu = []
|
||||
else:
|
||||
logger.warning("nvtop returned no output.")
|
||||
self.gpu = []
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON decode error: {e}")
|
||||
self.gpu = []
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing nvtop output: {e}")
|
||||
self.gpu = []
|
||||
|
||||
return False
|
||||
|
||||
def get_metrics(self):
|
||||
return (self.cpu, self.mem, self.disk, self.gpu)
|
||||
|
||||
def get_battery(self):
|
||||
return (self.bat_percent, self.bat_charging, self.bat_time)
|
||||
|
||||
def get_gpu_info(self):
|
||||
try:
|
||||
result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=5)
|
||||
return json.loads(result)
|
||||
except FileNotFoundError:
|
||||
logger.warning("nvtop not found; GPU info unavailable.")
|
||||
return []
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"nvtop init sync failed: {e}")
|
||||
return []
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("nvtop init call timed out.")
|
||||
return []
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Init JSON parse error: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during GPU init: {e}")
|
||||
return []
|
||||
|
||||
shared_provider = MetricsProvider()
|
||||
|
||||
class SingularMetric:
|
||||
def __init__(self, id, name, icon):
|
||||
self.usage = Scale(
|
||||
name=f"{id}-usage",
|
||||
value=0.25,
|
||||
orientation='v',
|
||||
inverted=True,
|
||||
v_align='fill',
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
self.label = Label(
|
||||
name=f"{id}-label",
|
||||
markup=icon,
|
||||
)
|
||||
|
||||
self.box = Box(
|
||||
name=f"{id}-box",
|
||||
orientation='v',
|
||||
spacing=8,
|
||||
children=[
|
||||
self.usage,
|
||||
self.label,
|
||||
]
|
||||
)
|
||||
|
||||
self.box.set_tooltip_markup(f"{icon} {name}")
|
||||
|
||||
class Metrics(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="metrics",
|
||||
spacing=8,
|
||||
h_align="center",
|
||||
v_align="fill",
|
||||
visible=True,
|
||||
all_visible=True,
|
||||
)
|
||||
|
||||
visible = getattr(data, "METRICS_VISIBLE", {'cpu': True, 'ram': True, 'disk': True, 'gpu': True})
|
||||
disks = [SingularMetric("disk", f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", icons.disk)
|
||||
for path in data.BAR_METRICS_DISKS] if visible.get('disk', True) else []
|
||||
|
||||
gpu_info = shared_provider.get_gpu_info()
|
||||
gpus = [SingularMetric(f"gpu", f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", icons.gpu)
|
||||
for v in gpu_info] if visible.get('gpu', True) else []
|
||||
|
||||
self.cpu = SingularMetric("cpu", "CPU", icons.cpu) if visible.get('cpu', True) else None
|
||||
self.ram = SingularMetric("ram", "RAM", icons.memory) if visible.get('ram', True) else None
|
||||
self.disk = disks
|
||||
self.gpu = gpus
|
||||
|
||||
self.scales = []
|
||||
if self.disk: self.scales.extend([v.box for v in self.disk])
|
||||
if self.ram: self.scales.append(self.ram.box)
|
||||
if self.cpu: self.scales.append(self.cpu.box)
|
||||
if self.gpu: self.scales.extend([v.box for v in self.gpu])
|
||||
|
||||
if self.cpu: self.cpu.usage.set_sensitive(False)
|
||||
if self.ram: self.ram.usage.set_sensitive(False)
|
||||
for disk in self.disk:
|
||||
disk.usage.set_sensitive(False)
|
||||
for gpu in self.gpu:
|
||||
gpu.usage.set_sensitive(False)
|
||||
|
||||
for x in self.scales:
|
||||
self.add(x)
|
||||
|
||||
GLib.timeout_add_seconds(2, self.update_status)
|
||||
|
||||
def update_status(self):
|
||||
cpu, mem, disks, gpus = shared_provider.get_metrics()
|
||||
|
||||
if self.cpu:
|
||||
self.cpu.usage.value = cpu / 100.0
|
||||
if self.ram:
|
||||
self.ram.usage.value = mem / 100.0
|
||||
for i, disk in enumerate(self.disk):
|
||||
|
||||
if i < len(disks):
|
||||
disk.usage.value = disks[i] / 100.0
|
||||
for i, gpu in enumerate(self.gpu):
|
||||
|
||||
if i < len(gpus):
|
||||
gpu.usage.value = gpus[i] / 100.0
|
||||
return True
|
||||
|
||||
class SingularMetricSmall:
|
||||
def __init__(self, id, name, icon):
|
||||
self.name_markup = name
|
||||
self.icon_markup = icon
|
||||
|
||||
self.icon = Label(name="metrics-icon", markup=icon)
|
||||
self.circle = CircularProgressBar(
|
||||
name="metrics-circle",
|
||||
value=0,
|
||||
size=28,
|
||||
line_width=2,
|
||||
start_angle=150,
|
||||
end_angle=390,
|
||||
style_classes=id,
|
||||
child=self.icon,
|
||||
)
|
||||
|
||||
self.level = Label(name="metrics-level", style_classes=id, label="0%")
|
||||
self.revealer = Revealer(
|
||||
name=f"metrics-{id}-revealer",
|
||||
transition_duration=250,
|
||||
transition_type="slide-left",
|
||||
child=self.level,
|
||||
child_revealed=False,
|
||||
)
|
||||
|
||||
self.box = Box(
|
||||
name=f"metrics-{id}-box",
|
||||
orientation="h",
|
||||
spacing=0,
|
||||
children=[self.circle, self.revealer],
|
||||
)
|
||||
|
||||
def markup(self):
|
||||
return f"{self.icon_markup} {self.name_markup}" if not data.VERTICAL else f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}"
|
||||
|
||||
class MetricsSmall(Button):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="metrics-small", **kwargs)
|
||||
|
||||
main_box = Box(
|
||||
|
||||
spacing=0,
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
visible=True,
|
||||
all_visible=True,
|
||||
)
|
||||
|
||||
visible = getattr(data, "METRICS_SMALL_VISIBLE", {'cpu': True, 'ram': True, 'disk': True, 'gpu': True})
|
||||
disks = [SingularMetricSmall("disk", f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", icons.disk)
|
||||
for path in data.BAR_METRICS_DISKS] if visible.get('disk', True) else []
|
||||
|
||||
gpu_info = shared_provider.get_gpu_info()
|
||||
gpus = [SingularMetricSmall(f"gpu", f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", icons.gpu)
|
||||
for v in gpu_info] if visible.get('gpu', True) else []
|
||||
|
||||
self.cpu = SingularMetricSmall("cpu", "CPU", icons.cpu) if visible.get('cpu', True) else None
|
||||
self.ram = SingularMetricSmall("ram", "RAM", icons.memory) if visible.get('ram', True) else None
|
||||
self.disk = disks
|
||||
self.gpu = gpus
|
||||
|
||||
for disk in self.disk:
|
||||
main_box.add(disk.box)
|
||||
main_box.add(Box(name="metrics-sep"))
|
||||
if self.ram:
|
||||
main_box.add(self.ram.box)
|
||||
main_box.add(Box(name="metrics-sep"))
|
||||
if self.cpu:
|
||||
main_box.add(self.cpu.box)
|
||||
for gpu in self.gpu:
|
||||
main_box.add(Box(name="metrics-sep"))
|
||||
main_box.add(gpu.box)
|
||||
|
||||
self.add(main_box)
|
||||
|
||||
self.connect("enter-notify-event", self.on_mouse_enter)
|
||||
self.connect("leave-notify-event", self.on_mouse_leave)
|
||||
|
||||
GLib.timeout_add_seconds(2, self.update_metrics)
|
||||
|
||||
self.hide_timer = None
|
||||
self.hover_counter = 0
|
||||
|
||||
def _format_percentage(self, value: int) -> str:
|
||||
"""Formato natural del porcentaje sin forzar ancho fijo."""
|
||||
return f"{value}%"
|
||||
|
||||
def on_mouse_enter(self, widget, event):
|
||||
if not data.VERTICAL:
|
||||
self.hover_counter += 1
|
||||
if self.hide_timer is not None:
|
||||
GLib.source_remove(self.hide_timer)
|
||||
self.hide_timer = None
|
||||
|
||||
if self.cpu: self.cpu.revealer.set_reveal_child(True)
|
||||
if self.ram: self.ram.revealer.set_reveal_child(True)
|
||||
for disk in self.disk:
|
||||
disk.revealer.set_reveal_child(True)
|
||||
for gpu in self.gpu:
|
||||
gpu.revealer.set_reveal_child(True)
|
||||
return False
|
||||
|
||||
def on_mouse_leave(self, widget, event):
|
||||
if not data.VERTICAL:
|
||||
if self.hover_counter > 0:
|
||||
self.hover_counter -= 1
|
||||
if self.hover_counter == 0:
|
||||
if self.hide_timer is not None:
|
||||
GLib.source_remove(self.hide_timer)
|
||||
self.hide_timer = GLib.timeout_add(500, self.hide_revealer)
|
||||
return False
|
||||
|
||||
def hide_revealer(self):
|
||||
if not data.VERTICAL:
|
||||
if self.cpu: self.cpu.revealer.set_reveal_child(False)
|
||||
if self.ram: self.ram.revealer.set_reveal_child(False)
|
||||
for disk in self.disk:
|
||||
disk.revealer.set_reveal_child(False)
|
||||
for gpu in self.gpu:
|
||||
gpu.revealer.set_reveal_child(False)
|
||||
self.hide_timer = None
|
||||
return False
|
||||
|
||||
def update_metrics(self):
|
||||
cpu, mem, disks, gpus = shared_provider.get_metrics()
|
||||
|
||||
if self.cpu:
|
||||
self.cpu.circle.set_value(cpu / 100.0)
|
||||
self.cpu.level.set_label(self._format_percentage(int(cpu)))
|
||||
if self.ram:
|
||||
self.ram.circle.set_value(mem / 100.0)
|
||||
self.ram.level.set_label(self._format_percentage(int(mem)))
|
||||
for i, disk in enumerate(self.disk):
|
||||
|
||||
if i < len(disks):
|
||||
disk.circle.set_value(disks[i] / 100.0)
|
||||
disk.level.set_label(self._format_percentage(int(disks[i])))
|
||||
for i, gpu in enumerate(self.gpu):
|
||||
|
||||
if i < len(gpus):
|
||||
gpu.circle.set_value(gpus[i] / 100.0)
|
||||
gpu.level.set_label(self._format_percentage(int(gpus[i])))
|
||||
|
||||
tooltip_metrics = []
|
||||
if self.disk: tooltip_metrics.extend(self.disk)
|
||||
if self.ram: tooltip_metrics.append(self.ram)
|
||||
if self.cpu: tooltip_metrics.append(self.cpu)
|
||||
if self.gpu: tooltip_metrics.extend(self.gpu)
|
||||
self.set_tooltip_markup((" - " if not data.VERTICAL else "\n").join([v.markup() for v in tooltip_metrics]))
|
||||
|
||||
return True
|
||||
|
||||
class Battery(Button):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="metrics-small", **kwargs)
|
||||
|
||||
main_box = Box(
|
||||
|
||||
spacing=0,
|
||||
orientation="h",
|
||||
visible=True,
|
||||
all_visible=True,
|
||||
)
|
||||
|
||||
self.bat_icon = Label(name="metrics-icon", markup=icons.battery)
|
||||
self.bat_circle = CircularProgressBar(
|
||||
name="metrics-circle",
|
||||
value=0,
|
||||
size=28,
|
||||
line_width=2,
|
||||
start_angle=150,
|
||||
end_angle=390,
|
||||
style_classes="bat",
|
||||
child=self.bat_icon,
|
||||
)
|
||||
self.bat_level = Label(name="metrics-level", style_classes="bat", label="100%")
|
||||
self.bat_revealer = Revealer(
|
||||
name="metrics-bat-revealer",
|
||||
transition_duration=250,
|
||||
transition_type="slide-left",
|
||||
child=self.bat_level,
|
||||
child_revealed=False,
|
||||
)
|
||||
self.bat_box = Box(
|
||||
name="metrics-bat-box",
|
||||
orientation="h",
|
||||
spacing=0,
|
||||
children=[self.bat_circle, self.bat_revealer],
|
||||
)
|
||||
|
||||
main_box.add(self.bat_box)
|
||||
|
||||
self.add(main_box)
|
||||
|
||||
self.connect("enter-notify-event", self.on_mouse_enter)
|
||||
self.connect("leave-notify-event", self.on_mouse_leave)
|
||||
|
||||
self.batt_fabricator = Fabricator(
|
||||
poll_from=lambda v: shared_provider.get_battery(),
|
||||
on_changed=lambda f, v: self.update_battery,
|
||||
interval=1000,
|
||||
stream=False,
|
||||
default_value=0
|
||||
)
|
||||
self.batt_fabricator.changed.connect(self.update_battery)
|
||||
GLib.idle_add(self.update_battery, None, shared_provider.get_battery())
|
||||
|
||||
self.hide_timer = None
|
||||
self.hover_counter = 0
|
||||
|
||||
def _format_percentage(self, value: int) -> str:
|
||||
"""Formato natural del porcentaje sin forzar ancho fijo."""
|
||||
return f"{value}%"
|
||||
|
||||
def on_mouse_enter(self, widget, event):
|
||||
if not data.VERTICAL:
|
||||
self.hover_counter += 1
|
||||
if self.hide_timer is not None:
|
||||
GLib.source_remove(self.hide_timer)
|
||||
self.hide_timer = None
|
||||
|
||||
self.bat_revealer.set_reveal_child(True)
|
||||
return False
|
||||
|
||||
def on_mouse_leave(self, widget, event):
|
||||
if not data.VERTICAL:
|
||||
if self.hover_counter > 0:
|
||||
self.hover_counter -= 1
|
||||
if self.hover_counter == 0:
|
||||
if self.hide_timer is not None:
|
||||
GLib.source_remove(self.hide_timer)
|
||||
self.hide_timer = GLib.timeout_add(500, self.hide_revealer)
|
||||
return False
|
||||
|
||||
def hide_revealer(self):
|
||||
if not data.VERTICAL:
|
||||
self.bat_revealer.set_reveal_child(False)
|
||||
self.hide_timer = None
|
||||
return False
|
||||
|
||||
def update_battery(self, sender, battery_data):
|
||||
value, charging, time = battery_data
|
||||
if value == 0:
|
||||
self.set_visible(False)
|
||||
else:
|
||||
self.set_visible(True)
|
||||
self.bat_circle.set_value(value / 100)
|
||||
percentage = int(value)
|
||||
self.bat_level.set_label(self._format_percentage(percentage))
|
||||
|
||||
if percentage <= 15:
|
||||
self.bat_icon.add_style_class("alert")
|
||||
self.bat_circle.add_style_class("alert")
|
||||
else:
|
||||
self.bat_icon.remove_style_class("alert")
|
||||
self.bat_circle.remove_style_class("alert")
|
||||
|
||||
if time < 60:
|
||||
time_status = f"{int(time)}sec"
|
||||
elif time < 60 * 60:
|
||||
time_status = f"{int(time / 60)}min"
|
||||
else:
|
||||
time_status = f"{int(time / 60 / 60)}h"
|
||||
|
||||
if percentage == 100 and charging == False:
|
||||
self.bat_icon.set_markup(icons.battery)
|
||||
charging_status = f"{icons.bat_full} Fully Charged - {time_status} left"
|
||||
elif percentage == 100 and charging == True:
|
||||
self.bat_icon.set_markup(icons.battery)
|
||||
charging_status = f"{icons.bat_full} Fully Charged"
|
||||
elif charging == True:
|
||||
self.bat_icon.set_markup(icons.charging)
|
||||
charging_status = f"{icons.bat_charging} Charging - {time_status} left"
|
||||
elif percentage <= 15 and charging == False:
|
||||
self.bat_icon.set_markup(icons.alert)
|
||||
charging_status = f"{icons.bat_low} Low Battery - {time_status} left"
|
||||
elif charging == False:
|
||||
self.bat_icon.set_markup(icons.discharging)
|
||||
charging_status = f"{icons.bat_discharging} Discharging - {time_status} left"
|
||||
else:
|
||||
self.bat_icon.set_markup(icons.battery)
|
||||
charging_status = "Battery"
|
||||
|
||||
self.set_tooltip_markup(f"{charging_status}" if not data.VERTICAL else f"{charging_status}: {percentage}%")
|
||||
|
||||
class NetworkApplet(Button):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="button-bar", **kwargs)
|
||||
self.download_label = Label(name="download-label", markup="Download: 0 B/s")
|
||||
self.network_client = NetworkClient()
|
||||
self.upload_label = Label(name="upload-label", markup="Upload: 0 B/s")
|
||||
self.wifi_label = Label(name="network-icon-label", markup="WiFi: Unknown")
|
||||
|
||||
self.is_mouse_over = False
|
||||
self.downloading = False
|
||||
self.uploading = False
|
||||
|
||||
self.download_icon = Label(name="download-icon-label", markup=icons.download, v_align="center", h_align="center", h_expand=True, v_expand=True)
|
||||
self.upload_icon = Label(name="upload-icon-label", markup=icons.upload, v_align="center", h_align="center", h_expand=True, v_expand=True)
|
||||
|
||||
self.download_box = Box(
|
||||
children=[self.download_icon, self.download_label],
|
||||
)
|
||||
|
||||
self.upload_box = Box(
|
||||
children=[self.upload_label, self.upload_icon],
|
||||
)
|
||||
|
||||
self.download_revealer = Revealer(child=self.download_box, transition_type = "slide-right" if not data.VERTICAL else "slide-down", child_revealed=False)
|
||||
self.upload_revealer = Revealer(child=self.upload_box, transition_type="slide-left" if not data.VERTICAL else "slide-up",child_revealed=False)
|
||||
|
||||
self.children = Box(
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
children=[self.upload_revealer, self.wifi_label, self.download_revealer],
|
||||
)
|
||||
|
||||
if data.VERTICAL:
|
||||
self.download_label.set_visible(False)
|
||||
self.upload_label.set_visible(False)
|
||||
self.upload_icon.set_margin_top(4)
|
||||
self.download_icon.set_margin_bottom(4)
|
||||
|
||||
self.last_counters = psutil.net_io_counters()
|
||||
self.last_time = time.time()
|
||||
invoke_repeater(1000, self.update_network)
|
||||
|
||||
self.connect("enter-notify-event", self.on_mouse_enter)
|
||||
self.connect("leave-notify-event", self.on_mouse_leave)
|
||||
|
||||
def update_network(self):
|
||||
current_time = time.time()
|
||||
elapsed = current_time - self.last_time
|
||||
current_counters = psutil.net_io_counters()
|
||||
download_speed = (current_counters.bytes_recv - self.last_counters.bytes_recv) / elapsed
|
||||
upload_speed = (current_counters.bytes_sent - self.last_counters.bytes_sent) / elapsed
|
||||
download_str = self.format_speed(download_speed)
|
||||
upload_str = self.format_speed(upload_speed)
|
||||
self.download_label.set_markup(download_str)
|
||||
self.upload_label.set_markup(upload_str)
|
||||
|
||||
self.downloading = (download_speed >= 10e6)
|
||||
self.uploading = (upload_speed >= 2e6)
|
||||
|
||||
if not self.is_mouse_over:
|
||||
if self.downloading:
|
||||
self.download_urgent()
|
||||
elif self.uploading:
|
||||
self.upload_urgent()
|
||||
else:
|
||||
self.remove_urgent()
|
||||
|
||||
show_download = self.downloading or (self.is_mouse_over and not data.VERTICAL)
|
||||
show_upload = self.uploading or (self.is_mouse_over and not data.VERTICAL)
|
||||
self.download_revealer.set_reveal_child(show_download)
|
||||
self.upload_revealer.set_reveal_child(show_upload)
|
||||
|
||||
primary_device = None
|
||||
if self.network_client:
|
||||
primary_device = self.network_client.primary_device
|
||||
|
||||
tooltip_base = ""
|
||||
tooltip_vertical = ""
|
||||
|
||||
if primary_device == "wired" and self.network_client.ethernet_device:
|
||||
ethernet_state = self.network_client.ethernet_device.internet
|
||||
|
||||
if ethernet_state == "activated":
|
||||
self.wifi_label.set_markup(icons.world)
|
||||
elif ethernet_state == "activating":
|
||||
self.wifi_label.set_markup(icons.world)
|
||||
else:
|
||||
self.wifi_label.set_markup(icons.world_off)
|
||||
|
||||
tooltip_base = "Ethernet Connection"
|
||||
tooltip_vertical = f"SSID: Ethernet\nUpload: {upload_str}\nDownload: {download_str}"
|
||||
|
||||
elif self.network_client and self.network_client.wifi_device:
|
||||
if self.network_client.wifi_device.ssid != "Disconnected":
|
||||
strength = self.network_client.wifi_device.strength
|
||||
|
||||
if strength >= 75:
|
||||
self.wifi_label.set_markup(icons.wifi_3)
|
||||
elif strength >= 50:
|
||||
self.wifi_label.set_markup(icons.wifi_2)
|
||||
elif strength >= 25:
|
||||
self.wifi_label.set_markup(icons.wifi_1)
|
||||
else:
|
||||
self.wifi_label.set_markup(icons.wifi_0)
|
||||
|
||||
tooltip_base = self.network_client.wifi_device.ssid
|
||||
tooltip_vertical = f"SSID: {self.network_client.wifi_device.ssid}\nUpload: {upload_str}\nDownload: {download_str}"
|
||||
else:
|
||||
self.wifi_label.set_markup(icons.world_off)
|
||||
tooltip_base = "Disconnected"
|
||||
tooltip_vertical = f"SSID: Disconnected\nUpload: {upload_str}\nDownload: {download_str}"
|
||||
else:
|
||||
self.wifi_label.set_markup(icons.world_off)
|
||||
tooltip_base = "Disconnected"
|
||||
tooltip_vertical = f"SSID: Disconnected\nUpload: {upload_str}\nDownload: {download_str}"
|
||||
|
||||
if data.VERTICAL:
|
||||
self.set_tooltip_text(tooltip_vertical)
|
||||
else:
|
||||
self.set_tooltip_text(tooltip_base)
|
||||
|
||||
self.last_counters = current_counters
|
||||
self.last_time = current_time
|
||||
return True
|
||||
|
||||
def format_speed(self, speed):
|
||||
if speed < 1024:
|
||||
return f"{speed:.0f} B/s"
|
||||
elif speed < 1024 * 1024:
|
||||
return f"{speed / 1024:.1f} KB/s"
|
||||
else:
|
||||
return f"{speed / (1024 * 1024):.1f} MB/s"
|
||||
|
||||
def on_mouse_enter(self, *_):
|
||||
self.is_mouse_over = True
|
||||
if not data.VERTICAL:
|
||||
|
||||
self.download_revealer.set_reveal_child(True)
|
||||
self.upload_revealer.set_reveal_child(True)
|
||||
return
|
||||
|
||||
def on_mouse_leave(self, *_):
|
||||
self.is_mouse_over = False
|
||||
if not data.VERTICAL:
|
||||
|
||||
self.download_revealer.set_reveal_child(self.downloading)
|
||||
self.upload_revealer.set_reveal_child(self.uploading)
|
||||
|
||||
if self.downloading:
|
||||
self.download_urgent()
|
||||
elif self.uploading:
|
||||
self.upload_urgent()
|
||||
else:
|
||||
self.remove_urgent()
|
||||
return
|
||||
|
||||
def upload_urgent(self):
|
||||
self.add_style_class("upload")
|
||||
self.wifi_label.add_style_class("urgent")
|
||||
self.upload_label.add_style_class("urgent")
|
||||
self.upload_icon.add_style_class("urgent")
|
||||
self.download_icon.add_style_class("urgent")
|
||||
self.download_label.add_style_class("urgent")
|
||||
self.upload_revealer.set_reveal_child(True)
|
||||
self.download_revealer.set_reveal_child(self.downloading)
|
||||
return
|
||||
|
||||
def download_urgent(self):
|
||||
self.add_style_class("download")
|
||||
self.wifi_label.add_style_class("urgent")
|
||||
self.download_label.add_style_class("urgent")
|
||||
self.download_icon.add_style_class("urgent")
|
||||
self.upload_icon.add_style_class("urgent")
|
||||
self.upload_label.add_style_class("urgent")
|
||||
self.download_revealer.set_reveal_child(True)
|
||||
self.upload_revealer.set_reveal_child(self.uploading)
|
||||
return
|
||||
|
||||
def remove_urgent(self):
|
||||
self.remove_style_class("download")
|
||||
self.remove_style_class("upload")
|
||||
self.wifi_label.remove_style_class("urgent")
|
||||
self.download_label.remove_style_class("urgent")
|
||||
self.upload_label.remove_style_class("urgent")
|
||||
self.download_icon.remove_style_class("urgent")
|
||||
self.upload_icon.remove_style_class("urgent")
|
||||
return
|
||||
@@ -0,0 +1,233 @@
|
||||
import math
|
||||
|
||||
import gi
|
||||
from fabric.audio.service import Audio
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scale import Scale
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from gi.repository import GLib
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
import config.data as data
|
||||
|
||||
vertical_mode = (
|
||||
True
|
||||
if data.PANEL_THEME == "Panel"
|
||||
and (
|
||||
data.BAR_POSITION in ["Left", "Right"]
|
||||
or data.PANEL_POSITION in ["Start", "End"]
|
||||
)
|
||||
else False
|
||||
)
|
||||
|
||||
|
||||
class MixerSlider(Scale):
|
||||
def __init__(self, stream, **kwargs):
|
||||
super().__init__(
|
||||
name="control-slider",
|
||||
orientation="h",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
has_origin=True,
|
||||
increments=(0.01, 0.1),
|
||||
style_classes=["no-icon"],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.stream = stream
|
||||
self._updating_from_stream = False
|
||||
self.set_value(stream.volume / 100)
|
||||
self.set_size_request(-1, 30) # Fixed height for sliders
|
||||
|
||||
self.connect("value-changed", self.on_value_changed)
|
||||
stream.connect("changed", self.on_stream_changed)
|
||||
|
||||
# Apply appropriate style class based on stream type
|
||||
if hasattr(stream, "type"):
|
||||
if "microphone" in stream.type.lower() or "input" in stream.type.lower():
|
||||
self.add_style_class("mic")
|
||||
else:
|
||||
self.add_style_class("vol")
|
||||
else:
|
||||
# Default to volume style
|
||||
self.add_style_class("vol")
|
||||
|
||||
# Set initial tooltip and muted state
|
||||
self.set_tooltip_text(f"{stream.volume:.0f}%")
|
||||
self.update_muted_state()
|
||||
|
||||
def on_value_changed(self, _):
|
||||
if self._updating_from_stream:
|
||||
return
|
||||
if self.stream:
|
||||
self.stream.volume = self.value * 100
|
||||
self.set_tooltip_text(f"{self.value * 100:.0f}%")
|
||||
|
||||
def on_stream_changed(self, stream):
|
||||
self._updating_from_stream = True
|
||||
self.value = stream.volume / 100
|
||||
self.set_tooltip_text(f"{stream.volume:.0f}%")
|
||||
self.update_muted_state()
|
||||
self._updating_from_stream = False
|
||||
|
||||
def update_muted_state(self):
|
||||
if self.stream.muted:
|
||||
self.add_style_class("muted")
|
||||
else:
|
||||
self.remove_style_class("muted")
|
||||
|
||||
|
||||
class MixerSection(Box):
|
||||
def __init__(self, title, **kwargs):
|
||||
super().__init__(
|
||||
name="mixer-section",
|
||||
orientation="v",
|
||||
spacing=8,
|
||||
h_expand=True,
|
||||
v_expand=False, # Prevent vertical stretching
|
||||
)
|
||||
|
||||
self.title_label = Label(
|
||||
name="mixer-section-title",
|
||||
label=title,
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
)
|
||||
|
||||
self.content_box = Box(
|
||||
name="mixer-content",
|
||||
orientation="v",
|
||||
spacing=8,
|
||||
h_expand=True,
|
||||
v_expand=False, # Prevent vertical stretching
|
||||
)
|
||||
|
||||
self.add(self.title_label)
|
||||
self.add(self.content_box)
|
||||
|
||||
def update_streams(self, streams):
|
||||
for child in self.content_box.get_children():
|
||||
self.content_box.remove(child)
|
||||
|
||||
for stream in streams:
|
||||
label_text = stream.description
|
||||
if hasattr(stream, "type") and "application" in stream.type.lower():
|
||||
label_text = getattr(stream, "name", stream.description)
|
||||
|
||||
stream_container = Box(
|
||||
orientation="v",
|
||||
spacing=4,
|
||||
h_expand=True,
|
||||
v_expand=False, # Prevent vertical stretching
|
||||
)
|
||||
|
||||
label = Label(
|
||||
name="mixer-stream-label",
|
||||
label=f"[{math.ceil(stream.volume)}%] {stream.description}",
|
||||
h_expand=True,
|
||||
h_align="start",
|
||||
v_align="center",
|
||||
ellipsization="end",
|
||||
max_chars_width=45,
|
||||
height_request=20, # Fixed height for labels
|
||||
)
|
||||
|
||||
slider = MixerSlider(stream)
|
||||
|
||||
stream_container.add(label)
|
||||
stream_container.add(slider)
|
||||
self.content_box.add(stream_container)
|
||||
|
||||
self.content_box.show_all()
|
||||
|
||||
|
||||
class Mixer(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="mixer",
|
||||
orientation="v",
|
||||
spacing=8,
|
||||
h_expand=True,
|
||||
v_expand=True, # Allow Mixer to expand to parent height
|
||||
)
|
||||
|
||||
try:
|
||||
self.audio = Audio()
|
||||
except Exception as e:
|
||||
error_label = Label(
|
||||
label=f"Audio service unavailable: {str(e)}",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
self.add(error_label)
|
||||
return
|
||||
|
||||
self.main_container = Box(
|
||||
orientation="h" if not vertical_mode else "v",
|
||||
spacing=8,
|
||||
h_expand=True,
|
||||
v_expand=True, # Allow main_container to expand
|
||||
)
|
||||
self.main_container.set_homogeneous(True) # Equal sizing for outputs and inputs
|
||||
|
||||
# ScrolledWindow for Outputs
|
||||
self.outputs_scrolled = ScrolledWindow(
|
||||
name="outputs-scrolled",
|
||||
h_expand=True,
|
||||
v_expand=False, # Prevent vertical expansion
|
||||
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, # Vertical scrollbar when needed
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER, # Disable horizontal scrollbar
|
||||
)
|
||||
self.outputs_section = MixerSection("Outputs")
|
||||
self.outputs_scrolled.add(self.outputs_section)
|
||||
self.outputs_scrolled.set_size_request(-1, 150) # Fixed height of 150px
|
||||
self.outputs_scrolled.set_max_content_height(150) # Enforce max height
|
||||
|
||||
# ScrolledWindow for Inputs
|
||||
self.inputs_scrolled = ScrolledWindow(
|
||||
name="inputs-scrolled",
|
||||
h_expand=True,
|
||||
v_expand=False, # Prevent vertical expansion
|
||||
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, # Vertical scrollbar when needed
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER, # Disable horizontal scrollbar
|
||||
)
|
||||
self.inputs_section = MixerSection("Inputs")
|
||||
self.inputs_scrolled.add(self.inputs_section)
|
||||
self.inputs_scrolled.set_size_request(-1, 150) # Fixed height of 150px
|
||||
self.inputs_scrolled.set_max_content_height(150) # Enforce max height
|
||||
|
||||
self.main_container.add(self.outputs_scrolled)
|
||||
self.main_container.add(self.inputs_scrolled)
|
||||
|
||||
self.add(self.main_container)
|
||||
self.set_size_request(-1, 300) # Optional: Set total height to 300px (150px per section)
|
||||
|
||||
self.audio.connect("changed", self.on_audio_changed)
|
||||
self.audio.connect("stream-added", self.on_audio_changed)
|
||||
self.audio.connect("stream-removed", self.on_audio_changed)
|
||||
|
||||
self.update_mixer()
|
||||
self.show_all()
|
||||
|
||||
def on_audio_changed(self, *args):
|
||||
self.update_mixer()
|
||||
|
||||
def update_mixer(self):
|
||||
outputs = []
|
||||
inputs = []
|
||||
|
||||
if self.audio.speaker:
|
||||
outputs.append(self.audio.speaker)
|
||||
outputs.extend(self.audio.applications)
|
||||
|
||||
if self.audio.microphone:
|
||||
inputs.append(self.audio.microphone)
|
||||
inputs.extend(self.audio.recorders)
|
||||
|
||||
self.outputs_section.update_streams(outputs)
|
||||
self.inputs_section.update_streams(inputs)
|
||||
@@ -0,0 +1,194 @@
|
||||
import gi
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('NM', '1.0')
|
||||
from fabric.utils import bulk_connect
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from gi.repository import NM, GLib, Gtk
|
||||
|
||||
import modules.icons as icons
|
||||
from services.network import NetworkClient
|
||||
|
||||
|
||||
class WifiAccessPointSlot(CenterBox):
|
||||
def __init__(self, ap_data: dict, network_service: NetworkClient, wifi_service, **kwargs):
|
||||
super().__init__(name="wifi-ap-slot", **kwargs)
|
||||
self.ap_data = ap_data
|
||||
self.network_service = network_service
|
||||
self.wifi_service = wifi_service
|
||||
|
||||
ssid = ap_data.get("ssid", "Unknown SSID")
|
||||
icon_name = ap_data.get("icon-name", "network-wireless-signal-none-symbolic")
|
||||
|
||||
self.is_active = False
|
||||
active_ap_details = ap_data.get("active-ap")
|
||||
if active_ap_details and hasattr(active_ap_details, 'get_bssid') and active_ap_details.get_bssid() == ap_data.get("bssid"):
|
||||
self.is_active = True
|
||||
|
||||
self.ap_icon = Image(icon_name=icon_name, size=24)
|
||||
self.ap_label = Label(label=ssid, h_expand=True, h_align="start", ellipsization="end")
|
||||
|
||||
self.connect_button = Button(
|
||||
name="wifi-connect-button",
|
||||
label="Connected" if self.is_active else "Connect",
|
||||
sensitive=not self.is_active,
|
||||
on_clicked=self._on_connect_clicked,
|
||||
style_classes=["connected"] if self.is_active else None,
|
||||
)
|
||||
|
||||
self.set_start_children([
|
||||
Box(spacing=8, h_expand=True, h_align="fill", children=[
|
||||
self.ap_icon,
|
||||
self.ap_label,
|
||||
])
|
||||
])
|
||||
self.set_end_children([self.connect_button])
|
||||
|
||||
def _on_connect_clicked(self, _):
|
||||
if not self.is_active and self.ap_data.get("bssid"):
|
||||
self.connect_button.set_label("Connecting...")
|
||||
self.connect_button.set_sensitive(False)
|
||||
self.network_service.connect_wifi_bssid(self.ap_data["bssid"])
|
||||
|
||||
|
||||
class NetworkConnections(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="network-connections",
|
||||
orientation="vertical",
|
||||
spacing=4,
|
||||
**kwargs,
|
||||
)
|
||||
self.widgets = kwargs.get("widgets")
|
||||
self.network_client = NetworkClient()
|
||||
|
||||
self.status_label = Label(label="Initializing Wi-Fi...", h_expand=True, h_align="center")
|
||||
|
||||
self.back_button = Button(
|
||||
name="network-back",
|
||||
child=Label(name="network-back-label", markup=icons.chevron_left),
|
||||
on_clicked=lambda *_: self.widgets.show_notif()
|
||||
)
|
||||
|
||||
|
||||
self.wifi_toggle_button_icon = Label(markup=icons.wifi_3)
|
||||
self.wifi_toggle_button = Button(
|
||||
name="wifi-toggle-button",
|
||||
child=self.wifi_toggle_button_icon,
|
||||
tooltip_text="Toggle Wi-Fi",
|
||||
on_clicked=self._toggle_wifi
|
||||
)
|
||||
|
||||
|
||||
self.refresh_button_icon = Label(name="network-refresh-label", markup=icons.reload)
|
||||
self.refresh_button = Button(
|
||||
name="network-refresh",
|
||||
child=self.refresh_button_icon,
|
||||
tooltip_text="Scan for Wi-Fi networks",
|
||||
on_clicked=self._refresh_access_points
|
||||
)
|
||||
|
||||
header_box = CenterBox(
|
||||
name="network-header",
|
||||
start_children=[self.back_button],
|
||||
center_children=[Label(name="network-title", label="Wi-Fi Networks")],
|
||||
end_children=[Box(orientation="horizontal", spacing=4, children=[self.refresh_button])]
|
||||
)
|
||||
|
||||
self.ap_list_box = Box(orientation="vertical", spacing=4)
|
||||
scrolled_window = ScrolledWindow(
|
||||
name="network-ap-scrolled-window",
|
||||
child=self.ap_list_box,
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
propagate_width=False,
|
||||
propagate_height=False,
|
||||
)
|
||||
|
||||
self.add(header_box)
|
||||
self.add(self.status_label)
|
||||
self.add(scrolled_window)
|
||||
|
||||
self.network_client.connect("device-ready", self._on_device_ready)
|
||||
self.wifi_toggle_button.set_sensitive(False)
|
||||
self.refresh_button.set_sensitive(False)
|
||||
|
||||
def _on_device_ready(self, _client):
|
||||
|
||||
if self.network_client.wifi_device:
|
||||
self.network_client.wifi_device.connect("changed", self._load_access_points)
|
||||
self.network_client.wifi_device.connect("notify::enabled", self._update_wifi_status_ui)
|
||||
self._update_wifi_status_ui()
|
||||
if self.network_client.wifi_device.enabled:
|
||||
self._load_access_points()
|
||||
else:
|
||||
self.status_label.set_label("Wi-Fi disabled.")
|
||||
self.status_label.set_visible(True)
|
||||
else:
|
||||
self.status_label.set_label("Wi-Fi device not available.")
|
||||
self.status_label.set_visible(True)
|
||||
self.wifi_toggle_button.set_sensitive(False)
|
||||
self.refresh_button.set_sensitive(False)
|
||||
|
||||
def _update_wifi_status_ui(self, *args):
|
||||
if self.network_client.wifi_device:
|
||||
enabled = self.network_client.wifi_device.enabled
|
||||
self.wifi_toggle_button.set_sensitive(True)
|
||||
self.refresh_button.set_sensitive(enabled)
|
||||
|
||||
if enabled:
|
||||
self.wifi_toggle_button_icon.set_markup(icons.wifi_3)
|
||||
else:
|
||||
self.wifi_toggle_button_icon.set_markup(icons.wifi_off)
|
||||
self.status_label.set_label("Wi-Fi disabled.")
|
||||
self.status_label.set_visible(True)
|
||||
self._clear_ap_list()
|
||||
|
||||
if enabled and not self.ap_list_box.get_children():
|
||||
GLib.idle_add(self._refresh_access_points)
|
||||
else:
|
||||
self.wifi_toggle_button.set_sensitive(False)
|
||||
self.refresh_button.set_sensitive(False)
|
||||
|
||||
def _toggle_wifi(self, _):
|
||||
if self.network_client.wifi_device:
|
||||
self.network_client.wifi_device.toggle_wifi()
|
||||
|
||||
def _refresh_access_points(self, _=None):
|
||||
if self.network_client.wifi_device and self.network_client.wifi_device.enabled:
|
||||
self.status_label.set_label("Scanning for Wi-Fi networks...")
|
||||
self.status_label.set_visible(True)
|
||||
self._clear_ap_list()
|
||||
self.network_client.wifi_device.scan()
|
||||
return False
|
||||
|
||||
def _clear_ap_list(self):
|
||||
for child in self.ap_list_box.get_children():
|
||||
child.destroy()
|
||||
|
||||
def _load_access_points(self, *args):
|
||||
if not self.network_client.wifi_device or not self.network_client.wifi_device.enabled:
|
||||
self._clear_ap_list()
|
||||
self.status_label.set_label("Wi-Fi disabled.")
|
||||
self.status_label.set_visible(True)
|
||||
return
|
||||
|
||||
self._clear_ap_list()
|
||||
|
||||
access_points = self.network_client.wifi_device.access_points
|
||||
|
||||
if not access_points:
|
||||
self.status_label.set_label("No Wi-Fi networks found.")
|
||||
self.status_label.set_visible(True)
|
||||
else:
|
||||
self.status_label.set_visible(False)
|
||||
sorted_aps = sorted(access_points, key=lambda x: x.get("strength", 0), reverse=True)
|
||||
for ap_data in sorted_aps:
|
||||
slot = WifiAccessPointSlot(ap_data, self.network_client, self.network_client.wifi_device)
|
||||
self.ap_list_box.add(slot)
|
||||
self.ap_list_box.show_all()
|
||||
@@ -0,0 +1,442 @@
|
||||
# Thanks to https://github.com/muhchaudhary for the original code. You are a legend.
|
||||
import json
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
from fabric.hyprland.service import Hyprland
|
||||
from fabric.utils.helpers import get_desktop_applications
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from loguru import logger
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
# WIP icon resolver (app_id to guessing the icon name)
|
||||
from utils.icon_resolver import IconResolver
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, Gtk
|
||||
|
||||
screen = Gdk.Screen.get_default()
|
||||
CURRENT_WIDTH = screen.get_width()
|
||||
CURRENT_HEIGHT = screen.get_height()
|
||||
|
||||
icon_resolver = IconResolver()
|
||||
connection = Hyprland()
|
||||
BASE_SCALE = 0.1 # Base scale factor for overview
|
||||
|
||||
# Credit to Aylur for the drag and drop code
|
||||
TARGET = [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)]
|
||||
|
||||
# Credit to Aylur for the createSurfaceFromWidget code
|
||||
def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
|
||||
alloc = widget.get_allocation()
|
||||
surface = cairo.ImageSurface(
|
||||
cairo.Format.ARGB32,
|
||||
alloc.width,
|
||||
alloc.height,
|
||||
)
|
||||
cr = cairo.Context(surface)
|
||||
cr.set_source_rgba(255, 255, 255, 0)
|
||||
cr.rectangle(0, 0, alloc.width, alloc.height)
|
||||
cr.fill()
|
||||
widget.draw(cr)
|
||||
return surface
|
||||
|
||||
|
||||
class HyprlandWindowButton(Button):
|
||||
def __init__(
|
||||
self,
|
||||
window: Box,
|
||||
title: str,
|
||||
address: str,
|
||||
app_id: str,
|
||||
size,
|
||||
transform: int = 0,
|
||||
):
|
||||
self.transform = transform % 4
|
||||
self.size = size if transform in [0, 2] else (size[1], size[0])
|
||||
self.address = address
|
||||
self.app_id = app_id
|
||||
self.title = title
|
||||
self.window: Box = window
|
||||
|
||||
# Compute dynamic icon sizes based on the button size.
|
||||
# Using the minimum dimension of the button for scaling.
|
||||
icon_size_main = int(min(self.size) * 0.5) # adjust factor as needed
|
||||
|
||||
# Enhanced icon resolution using desktop apps
|
||||
desktop_app = window.find_app(app_id)
|
||||
|
||||
# Get icon using improved method with fallbacks
|
||||
icon_pixbuf = None
|
||||
if desktop_app:
|
||||
icon_pixbuf = desktop_app.get_icon_pixbuf(size=icon_size_main)
|
||||
|
||||
if not icon_pixbuf:
|
||||
# Fallback to IconResolver
|
||||
icon_pixbuf = icon_resolver.get_icon_pixbuf(app_id, icon_size_main)
|
||||
|
||||
if not icon_pixbuf:
|
||||
# Additional fallbacks for common apps
|
||||
icon_pixbuf = icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", icon_size_main)
|
||||
if not icon_pixbuf:
|
||||
icon_pixbuf = icon_resolver.get_icon_pixbuf("image-missing", icon_size_main)
|
||||
|
||||
# Ensure icon is scaled to the correct size
|
||||
if icon_pixbuf and (icon_pixbuf.get_width() != icon_size_main or icon_pixbuf.get_height() != icon_size_main):
|
||||
icon_pixbuf = icon_pixbuf.scale_simple(
|
||||
icon_size_main,
|
||||
icon_size_main,
|
||||
gi.repository.GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
name="overview-client-box",
|
||||
image=Image(pixbuf=icon_pixbuf),
|
||||
tooltip_text=title,
|
||||
size=size,
|
||||
on_clicked=self.on_button_click,
|
||||
on_button_press_event=lambda _, event: connection.send_command(
|
||||
f"/dispatch closewindow address:{address}"
|
||||
)
|
||||
if event.button == 3
|
||||
else None,
|
||||
on_drag_data_get=lambda _s, _c, data, *_: data.set_text(
|
||||
address, len(address)
|
||||
),
|
||||
on_drag_begin=lambda _, context: Gtk.drag_set_icon_surface(
|
||||
context, createSurfaceFromWidget(self)
|
||||
),
|
||||
)
|
||||
|
||||
# Store the desktop_app for later use
|
||||
self.desktop_app = desktop_app
|
||||
|
||||
self.drag_source_set(
|
||||
start_button_mask=Gdk.ModifierType.BUTTON1_MASK,
|
||||
targets=TARGET,
|
||||
actions=Gdk.DragAction.COPY,
|
||||
)
|
||||
|
||||
self.connect("key_press_event", self.on_key_press_event)
|
||||
|
||||
def on_key_press_event(self, widget, event):
|
||||
if event.get_state() & Gdk.ModifierType.SHIFT_MASK:
|
||||
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_space):
|
||||
connection.send_command(f"/dispatch closewindow address:{self.address}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_image(self, image):
|
||||
# Compute overlay icon size dynamically.
|
||||
icon_size_overlay = int(min(self.size) * 0.5) # adjust factor as needed
|
||||
|
||||
# Enhanced icon resolution for overlay
|
||||
icon_pixbuf = None
|
||||
if hasattr(self, 'desktop_app') and self.desktop_app:
|
||||
icon_pixbuf = self.desktop_app.get_icon_pixbuf(size=icon_size_overlay)
|
||||
|
||||
if not icon_pixbuf:
|
||||
icon_pixbuf = icon_resolver.get_icon_pixbuf(self.app_id, icon_size_overlay)
|
||||
|
||||
if not icon_pixbuf:
|
||||
icon_pixbuf = icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", icon_size_overlay)
|
||||
if not icon_pixbuf:
|
||||
icon_pixbuf = icon_resolver.get_icon_pixbuf("image-missing", icon_size_overlay)
|
||||
|
||||
# Ensure icon is scaled to the correct size
|
||||
if icon_pixbuf and (icon_pixbuf.get_width() != icon_size_overlay or icon_pixbuf.get_height() != icon_size_overlay):
|
||||
icon_pixbuf = icon_pixbuf.scale_simple(
|
||||
icon_size_overlay,
|
||||
icon_size_overlay,
|
||||
gi.repository.GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
|
||||
self.set_image(
|
||||
Overlay(
|
||||
child=image,
|
||||
overlays=Image(
|
||||
name="overview-icon",
|
||||
pixbuf=icon_pixbuf,
|
||||
h_align="center",
|
||||
v_align="end",
|
||||
tooltip_text=self.title,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def on_button_click(self, *_):
|
||||
connection.send_command(f"/dispatch focuswindow address:{self.address}")
|
||||
|
||||
|
||||
class WorkspaceEventBox(EventBox):
|
||||
def __init__(self, workspace_id: int, fixed: Gtk.Fixed | None = None, monitor_width: int = None, monitor_height: int = None, monitor_scale: float = 1.0):
|
||||
self.fixed = fixed
|
||||
|
||||
# Use provided monitor dimensions or fallback to current screen
|
||||
width = monitor_width or CURRENT_WIDTH
|
||||
height = monitor_height or CURRENT_HEIGHT
|
||||
|
||||
# Workspace containers should maintain consistent size across monitors
|
||||
# Only use BASE_SCALE, don't multiply by monitor_scale for the container
|
||||
container_scale = BASE_SCALE
|
||||
|
||||
super().__init__(
|
||||
name="overview-workspace-bg",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
size=(int(width * container_scale), int(height * container_scale)),
|
||||
child=fixed
|
||||
if fixed
|
||||
else Label(
|
||||
name="overview-add-label",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
markup=icons.circle_plus,
|
||||
),
|
||||
on_drag_data_received=lambda _w, _c, _x, _y, data, *_: connection.send_command(
|
||||
f"/dispatch movetoworkspacesilent {workspace_id},address:{data.get_data().decode()}"
|
||||
),
|
||||
)
|
||||
self.drag_dest_set(
|
||||
Gtk.DestDefaults.ALL,
|
||||
TARGET,
|
||||
Gdk.DragAction.COPY,
|
||||
)
|
||||
if fixed:
|
||||
fixed.show_all()
|
||||
|
||||
|
||||
|
||||
class Overview(Box):
|
||||
def __init__(self, monitor_id: int = 0, **kwargs):
|
||||
self.monitor_id = monitor_id
|
||||
self.monitor_manager = None
|
||||
self.workspace_start = 1
|
||||
self.workspace_end = 10
|
||||
|
||||
# Get monitor manager and workspace range
|
||||
try:
|
||||
from utils.monitor_manager import get_monitor_manager
|
||||
self.monitor_manager = get_monitor_manager()
|
||||
self.workspace_start, self.workspace_end = self.monitor_manager.get_workspace_range_for_monitor(monitor_id)
|
||||
except ImportError:
|
||||
# Fallback if monitor manager not available
|
||||
pass
|
||||
|
||||
# Get monitor dimensions
|
||||
monitor_width = CURRENT_WIDTH
|
||||
monitor_height = CURRENT_HEIGHT
|
||||
|
||||
if self.monitor_manager:
|
||||
monitor_info = self.monitor_manager.get_monitor_by_id(monitor_id)
|
||||
if monitor_info:
|
||||
monitor_width = monitor_info['width']
|
||||
monitor_height = monitor_info['height']
|
||||
# Initialize as a Box instead of a PopupWindow.
|
||||
super().__init__(name="overview", orientation="v", spacing=8, **kwargs)
|
||||
self.workspace_boxes: dict[int, Box] = {}
|
||||
self.clients: dict[str, HyprlandWindowButton] = {}
|
||||
|
||||
# Initialize app registry for better icon resolution
|
||||
self._all_apps = get_desktop_applications()
|
||||
self.app_identifiers = self._build_app_identifiers_map()
|
||||
|
||||
# Remove the window_class_aliases dictionary completely
|
||||
|
||||
connection.connect("event::openwindow", self.do_update)
|
||||
connection.connect("event::closewindow", self.do_update)
|
||||
connection.connect("event::movewindow", self.do_update)
|
||||
self.update()
|
||||
|
||||
def _normalize_window_class(self, class_name):
|
||||
"""Normalize window class by removing common suffixes and lowercase."""
|
||||
if not class_name:
|
||||
return ""
|
||||
|
||||
normalized = class_name.lower()
|
||||
|
||||
# Remove common suffixes
|
||||
suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"]
|
||||
for suffix in suffixes:
|
||||
if normalized.endswith(suffix):
|
||||
normalized = normalized[:-len(suffix)]
|
||||
|
||||
return normalized
|
||||
|
||||
def _classes_match(self, class1, class2):
|
||||
"""Check if two window class names match with stricter comparison."""
|
||||
if not class1 or not class2:
|
||||
return False
|
||||
|
||||
# Normalize both classes
|
||||
norm1 = self._normalize_window_class(class1)
|
||||
norm2 = self._normalize_window_class(class2)
|
||||
|
||||
# Direct match after normalization
|
||||
if norm1 == norm2:
|
||||
return True
|
||||
|
||||
# Don't do substring matching as it's too error-prone
|
||||
# This avoids incorrectly matching flatpak apps and others
|
||||
return False
|
||||
|
||||
def _build_app_identifiers_map(self):
|
||||
"""Build a mapping of app identifiers (class names, executables, names) to DesktopApp objects"""
|
||||
identifiers = {}
|
||||
for app in self._all_apps:
|
||||
# Map by name (lowercase)
|
||||
if app.name:
|
||||
identifiers[app.name.lower()] = app
|
||||
|
||||
# Map by display name
|
||||
if app.display_name:
|
||||
identifiers[app.display_name.lower()] = app
|
||||
|
||||
# Map by window class if available
|
||||
if app.window_class:
|
||||
identifiers[app.window_class.lower()] = app
|
||||
|
||||
# Map by executable name if available
|
||||
if app.executable:
|
||||
exe_basename = app.executable.split('/')[-1].lower()
|
||||
identifiers[exe_basename] = app
|
||||
|
||||
# Map by command line if available (without parameters)
|
||||
if app.command_line:
|
||||
cmd_base = app.command_line.split()[0].split('/')[-1].lower()
|
||||
identifiers[cmd_base] = app
|
||||
|
||||
return identifiers
|
||||
|
||||
def find_app(self, app_identifier):
|
||||
"""Return the DesktopApp object by matching any app identifier."""
|
||||
if not app_identifier:
|
||||
return None
|
||||
|
||||
# Try direct lookup in our identifiers map
|
||||
normalized_id = str(app_identifier).lower()
|
||||
if normalized_id in self.app_identifiers:
|
||||
return self.app_identifiers[normalized_id]
|
||||
|
||||
# Try with normalized class name
|
||||
norm_id = self._normalize_window_class(normalized_id)
|
||||
if norm_id in self.app_identifiers:
|
||||
return self.app_identifiers[norm_id]
|
||||
|
||||
# More targeted matching with exact names only
|
||||
for app in self._all_apps:
|
||||
if app.name and app.name.lower() == normalized_id:
|
||||
return app
|
||||
if app.window_class and app.window_class.lower() == normalized_id:
|
||||
return app
|
||||
if app.display_name and app.display_name.lower() == normalized_id:
|
||||
return app
|
||||
# Try with executable basename
|
||||
if app.executable:
|
||||
exe_base = app.executable.split('/')[-1].lower()
|
||||
if exe_base == normalized_id:
|
||||
return app
|
||||
# Try with command basename
|
||||
if app.command_line:
|
||||
cmd_base = app.command_line.split()[0].split('/')[-1].lower()
|
||||
if cmd_base == normalized_id:
|
||||
return app
|
||||
|
||||
return None
|
||||
|
||||
def update(self, signal_update=False):
|
||||
self._all_apps = get_desktop_applications()
|
||||
self.app_identifiers = self._build_app_identifiers_map()
|
||||
for client in self.clients.values():
|
||||
client.destroy()
|
||||
self.clients.clear()
|
||||
for workspace in self.workspace_boxes.values():
|
||||
workspace.destroy()
|
||||
self.workspace_boxes.clear()
|
||||
|
||||
if data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Left", "Right"]:
|
||||
rows = 5
|
||||
cols = 2
|
||||
else:
|
||||
rows = 2
|
||||
cols = 5
|
||||
|
||||
self.children = [Box(spacing=8) for _ in range(rows)]
|
||||
|
||||
# Get monitor dimensions and scale for scaling
|
||||
monitor_width = CURRENT_WIDTH
|
||||
monitor_height = CURRENT_HEIGHT
|
||||
monitor_scale = 1.0
|
||||
|
||||
if self.monitor_manager:
|
||||
monitor_info = self.monitor_manager.get_monitor_by_id(self.monitor_id)
|
||||
if monitor_info:
|
||||
monitor_width = monitor_info['width']
|
||||
monitor_height = monitor_info['height']
|
||||
monitor_scale = monitor_info.get('scale', 1.0)
|
||||
|
||||
# Calculate effective scale for this monitor
|
||||
# Higher scale monitors need larger overview elements to appear the same physical size
|
||||
effective_scale = BASE_SCALE * monitor_scale
|
||||
|
||||
monitors = {
|
||||
monitor["id"]: (monitor["x"], monitor["y"], monitor["transform"])
|
||||
for monitor in json.loads(connection.send_command("j/monitors").reply.decode())
|
||||
}
|
||||
|
||||
# Filter clients to only show those in this monitor's workspace range
|
||||
for client in json.loads(connection.send_command("j/clients").reply.decode()):
|
||||
workspace_id = client["workspace"]["id"]
|
||||
if workspace_id > 0 and self.workspace_start <= workspace_id <= self.workspace_end:
|
||||
btn = HyprlandWindowButton(
|
||||
window=self,
|
||||
title=client["title"],
|
||||
address=client["address"],
|
||||
app_id=client["initialClass"],
|
||||
size=(client["size"][0] * effective_scale, client["size"][1] * effective_scale),
|
||||
transform=monitors[client["monitor"]][2],
|
||||
)
|
||||
self.clients[client["address"]] = btn
|
||||
w_id = workspace_id
|
||||
if w_id not in self.workspace_boxes:
|
||||
self.workspace_boxes[w_id] = Gtk.Fixed.new()
|
||||
self.workspace_boxes[w_id].put(
|
||||
btn,
|
||||
abs(client["at"][0] - monitors[client["monitor"]][0]) * effective_scale,
|
||||
abs(client["at"][1] - monitors[client["monitor"]][1]) * effective_scale,
|
||||
)
|
||||
|
||||
# Generate workspaces only for this monitor's range
|
||||
for w_id in range(self.workspace_start, self.workspace_end + 1):
|
||||
idx = w_id - self.workspace_start
|
||||
if rows == 2:
|
||||
row = 0 if idx < cols else 1
|
||||
else:
|
||||
row = idx // cols
|
||||
overview_row = self.children[row]
|
||||
overview_row.add(
|
||||
Box(
|
||||
name="overview-workspace-box",
|
||||
orientation="vertical",
|
||||
children=[
|
||||
Label(name="overview-workspace-label", label=f"Workspace {w_id}"),
|
||||
WorkspaceEventBox(
|
||||
w_id,
|
||||
self.workspace_boxes.get(w_id),
|
||||
monitor_width=monitor_width,
|
||||
monitor_height=monitor_height,
|
||||
monitor_scale=monitor_scale
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def do_update(self, *_):
|
||||
logger.info(f"[Overview] Updating for :{_[1].name}")
|
||||
self.update(signal_update=True)
|
||||
@@ -0,0 +1,498 @@
|
||||
import gi
|
||||
|
||||
import config.data as data
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import cairo
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
import modules.icons as icons
|
||||
|
||||
SAVE_FILE = os.path.expanduser("~/.pins.json")
|
||||
|
||||
icon_size = 80
|
||||
if data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]:
|
||||
icon_size = 36
|
||||
|
||||
def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
|
||||
alloc = widget.get_allocation()
|
||||
surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height)
|
||||
cr = cairo.Context(surface)
|
||||
cr.set_source_rgba(1, 1, 1, 0)
|
||||
cr.rectangle(0, 0, alloc.width, alloc.height)
|
||||
cr.fill()
|
||||
widget.draw(cr)
|
||||
return surface
|
||||
|
||||
def open_file(filepath):
|
||||
try:
|
||||
subprocess.Popen(["xdg-open", filepath])
|
||||
except Exception as e:
|
||||
print("Error opening file:", e)
|
||||
|
||||
def open_url(url):
|
||||
try:
|
||||
subprocess.Popen(["xdg-open", url])
|
||||
except Exception as e:
|
||||
print("Error opening URL:", e)
|
||||
|
||||
def is_url(text):
|
||||
|
||||
url_pattern = re.compile(
|
||||
r'^(https?|ftp)://'
|
||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'
|
||||
r'localhost|'
|
||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
|
||||
r'(?::\d+)?'
|
||||
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
||||
return bool(url_pattern.match(text))
|
||||
|
||||
def get_favicon_url(url):
|
||||
"""Extract the base domain from a URL and construct a favicon URL."""
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
return f"{base_url}/favicon.ico"
|
||||
|
||||
def download_favicon(url, callback):
|
||||
"""Download a favicon asynchronously and call the callback with the result."""
|
||||
favicon_url = get_favicon_url(url)
|
||||
|
||||
def do_download():
|
||||
temp_file = None
|
||||
try:
|
||||
temp_fd, temp_path = tempfile.mkstemp(suffix='.ico')
|
||||
os.close(temp_fd)
|
||||
temp_file = temp_path
|
||||
|
||||
urllib.request.urlretrieve(favicon_url, temp_path)
|
||||
|
||||
GLib.idle_add(callback, temp_path)
|
||||
except Exception as e:
|
||||
print(f"Error downloading favicon: {e}")
|
||||
|
||||
if temp_file and os.path.exists(temp_file):
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except:
|
||||
pass
|
||||
GLib.idle_add(callback, None)
|
||||
|
||||
GLib.Thread.new("favicon-download", do_download, None)
|
||||
|
||||
class FileChangeHandler(FileSystemEventHandler):
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def on_any_event(self, event):
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
for cell in self.app.cells:
|
||||
if cell.content_type == 'file' and cell.content:
|
||||
try:
|
||||
cell_real = os.path.realpath(cell.content)
|
||||
src_real = os.path.realpath(event.src_path)
|
||||
dest_real = os.path.realpath(getattr(event, 'dest_path', ''))
|
||||
if cell_real == src_real or (dest_real and cell_real == dest_real):
|
||||
GLib.idle_add(self.handle_file_event, cell, event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def handle_file_event(self, cell, event):
|
||||
if event.event_type == 'deleted':
|
||||
cell.clear_cell()
|
||||
self.app.save_state()
|
||||
elif event.event_type == 'moved':
|
||||
if hasattr(event, 'dest_path') and os.path.exists(event.dest_path):
|
||||
cell.content = event.dest_path
|
||||
cell.update_display()
|
||||
self.app.save_state()
|
||||
self.app.add_monitor_for_path(os.path.dirname(event.dest_path))
|
||||
|
||||
class Cell(Gtk.EventBox):
|
||||
def __init__(self, app, content=None, content_type=None):
|
||||
super().__init__(name="pin-cell")
|
||||
self.app = app
|
||||
self.content = content
|
||||
self.content_type = content_type
|
||||
self.box = Box(name="pin-cell-box", orientation="v", spacing=4)
|
||||
self.add(self.box)
|
||||
|
||||
|
||||
self.favicon_temp_path = None
|
||||
|
||||
target_dest = Gtk.TargetEntry.new("text/uri-list", 0, 0)
|
||||
self.drag_dest_set(Gtk.DestDefaults.ALL, [target_dest], Gdk.DragAction.COPY)
|
||||
self.connect("drag-data-received", self.on_drag_data_received)
|
||||
|
||||
targets = [
|
||||
Gtk.TargetEntry.new("text/uri-list", 0, 0),
|
||||
Gtk.TargetEntry.new("text/plain", 0, 1)
|
||||
]
|
||||
self.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, targets, Gdk.DragAction.COPY)
|
||||
self.connect("drag-data-get", self.on_drag_data_get)
|
||||
|
||||
self.connect("button-press-event", self.on_button_press)
|
||||
|
||||
self.connect("drag-begin", self.on_drag_begin)
|
||||
|
||||
self.update_display()
|
||||
|
||||
def update_display(self):
|
||||
|
||||
if self.favicon_temp_path and os.path.exists(self.favicon_temp_path):
|
||||
try:
|
||||
os.remove(self.favicon_temp_path)
|
||||
self.favicon_temp_path = None
|
||||
except Exception as e:
|
||||
print(f"Error removing temp favicon: {e}")
|
||||
|
||||
for child in self.box.get_children():
|
||||
self.box.remove(child)
|
||||
|
||||
if self.content is None:
|
||||
label = Label(name="pin-add", markup=icons.paperclip)
|
||||
self.box.pack_start(label, True, True, 0)
|
||||
else:
|
||||
if self.content_type == 'file':
|
||||
widget = self.get_file_preview(self.content)
|
||||
self.box.pack_start(widget, True, True, 0)
|
||||
label = Label(name="pin-file", label=os.path.basename(self.content), justification="center", ellipsization="middle")
|
||||
self.box.pack_start(label, False, False, 0)
|
||||
elif self.content_type == 'text':
|
||||
if is_url(self.content):
|
||||
|
||||
icon_container = Box(name="pin-icon-container", orientation="v")
|
||||
self.box.pack_start(icon_container, True, True, 0)
|
||||
|
||||
|
||||
url_icon = Label(name="pin-url-icon", markup=icons.world, style=f"font-size: {icon_size}px;")
|
||||
icon_container.pack_start(url_icon, True, True, 0)
|
||||
|
||||
|
||||
domain = re.sub(r'^https?://', '', self.content)
|
||||
domain = domain.split('/')[0]
|
||||
label = Label(name="pin-url", label=domain, justification="center", ellipsization="end")
|
||||
self.box.pack_start(label, False, False, 0)
|
||||
|
||||
|
||||
download_favicon(
|
||||
self.content,
|
||||
lambda path: self.update_favicon(icon_container, url_icon, path)
|
||||
)
|
||||
else:
|
||||
|
||||
label = Label(name="pin-text", label=self.content.split('\n')[0], justification="center", ellipsization="end", line_wrap="word-char")
|
||||
self.box.pack_start(label, True, True, 0)
|
||||
self.box.show_all()
|
||||
if not self.app.loading_state:
|
||||
self.app.save_state()
|
||||
|
||||
def update_favicon(self, container, icon_widget, favicon_path):
|
||||
"""Update the icon with the downloaded favicon or keep the default."""
|
||||
if not favicon_path or not os.path.exists(favicon_path):
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
self.favicon_temp_path = favicon_path
|
||||
|
||||
|
||||
if data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Left", "Right"]:
|
||||
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
favicon_path, width=36, height=36, preserve_aspect_ratio=True)
|
||||
else:
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
favicon_path, width=48, height=48, preserve_aspect_ratio=True)
|
||||
|
||||
|
||||
container.remove(icon_widget)
|
||||
|
||||
|
||||
img = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
img.set_name("pin-favicon")
|
||||
container.pack_start(img, True, True, 0)
|
||||
|
||||
|
||||
container.show_all()
|
||||
except Exception as e:
|
||||
print(f"Error setting favicon: {e}")
|
||||
|
||||
|
||||
def get_file_preview(self, filepath):
|
||||
try:
|
||||
file = Gio.File.new_for_path(filepath)
|
||||
info = file.query_info("standard::content-type", Gio.FileQueryInfoFlags.NONE, None)
|
||||
content_type = info.get_content_type()
|
||||
except Exception:
|
||||
content_type = None
|
||||
|
||||
icon_theme = Gtk.IconTheme.get_default()
|
||||
|
||||
if content_type == "inode/directory":
|
||||
try:
|
||||
pixbuf = icon_theme.load_icon("default-folder", icon_size, 0)
|
||||
return Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
except Exception:
|
||||
print("Error loading folder icon")
|
||||
return Gtk.Image.new_from_icon_name("default-folder", Gtk.IconSize.DIALOG)
|
||||
|
||||
if content_type and content_type.startswith("image/"):
|
||||
try:
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
filepath, width=icon_size, height=icon_size, preserve_aspect_ratio=True)
|
||||
return Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
except Exception as e:
|
||||
print("Error loading image preview:", e)
|
||||
|
||||
elif content_type and content_type.startswith("video/"):
|
||||
try:
|
||||
pixbuf = icon_theme.load_icon("video-x-generic", icon_size, 0)
|
||||
return Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
except Exception:
|
||||
print("Error loading video icon")
|
||||
return Gtk.Image.new_from_icon_name("video-x-generic", Gtk.IconSize.DIALOG)
|
||||
else:
|
||||
icon_name = "text-x-generic"
|
||||
if content_type:
|
||||
themed_icon = Gio.content_type_get_icon(content_type)
|
||||
if hasattr(themed_icon, 'get_names'):
|
||||
names = themed_icon.get_names()
|
||||
if names:
|
||||
icon_name = names[0]
|
||||
try:
|
||||
pixbuf = icon_theme.load_icon(icon_name, icon_size, 0)
|
||||
return Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
except Exception:
|
||||
print("Error loading icon", icon_name)
|
||||
return Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
|
||||
|
||||
def on_drag_data_received(self, widget, drag_context, x, y, data, info, time):
|
||||
if self.content is None and data.get_length() >= 0:
|
||||
uris = data.get_uris()
|
||||
if uris:
|
||||
try:
|
||||
filepath, _ = GLib.filename_from_uri(uris[0])
|
||||
self.content = filepath
|
||||
self.content_type = 'file'
|
||||
self.update_display()
|
||||
except Exception as e:
|
||||
print("Error getting file from URI:", e)
|
||||
drag_context.finish(True, False, time)
|
||||
|
||||
def on_drag_data_get(self, widget, drag_context, data, info, time):
|
||||
if self.content is None:
|
||||
return
|
||||
if info == 0 and self.content_type == 'file':
|
||||
uri = GLib.filename_to_uri(self.content)
|
||||
data.set_uris([uri])
|
||||
elif info == 1 and self.content_type == 'text':
|
||||
data.set_text(self.content, -1)
|
||||
|
||||
def on_drag_begin(self, widget, context):
|
||||
|
||||
if self.content_type == 'file':
|
||||
surface = createSurfaceFromWidget(self)
|
||||
Gtk.drag_set_icon_surface(context, surface)
|
||||
|
||||
def on_button_press(self, widget, event):
|
||||
if self.content is None:
|
||||
if event.button == 1:
|
||||
self.select_file()
|
||||
elif event.button == 2:
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
text = clipboard.wait_for_text()
|
||||
if text:
|
||||
self.content = text
|
||||
self.content_type = 'text'
|
||||
self.update_display()
|
||||
else:
|
||||
if self.content_type == 'file':
|
||||
if event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS:
|
||||
open_file(self.content)
|
||||
elif event.button == 3:
|
||||
self.clear_cell()
|
||||
elif self.content_type == 'text':
|
||||
if event.button == 1:
|
||||
|
||||
if is_url(self.content):
|
||||
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
clipboard.set_text(self.content, -1)
|
||||
|
||||
|
||||
if not (event.state & Gdk.ModifierType.CONTROL_MASK):
|
||||
open_url(self.content)
|
||||
else:
|
||||
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
clipboard.set_text(self.content, -1)
|
||||
elif event.button == 3:
|
||||
self.clear_cell()
|
||||
return True
|
||||
|
||||
def select_file(self):
|
||||
dialog = Gtk.FileChooserDialog(
|
||||
title="Select File",
|
||||
parent=self.get_toplevel(),
|
||||
action=Gtk.FileChooserAction.OPEN
|
||||
)
|
||||
dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
|
||||
if dialog.run() == Gtk.ResponseType.OK:
|
||||
filepath = dialog.get_filename()
|
||||
self.content = filepath
|
||||
self.content_type = 'file'
|
||||
self.update_display()
|
||||
dialog.destroy()
|
||||
|
||||
def clear_cell(self):
|
||||
|
||||
if self.favicon_temp_path and os.path.exists(self.favicon_temp_path):
|
||||
try:
|
||||
os.remove(self.favicon_temp_path)
|
||||
self.favicon_temp_path = None
|
||||
except Exception as e:
|
||||
print(f"Error removing temp favicon: {e}")
|
||||
|
||||
self.content = None
|
||||
self.content_type = None
|
||||
self.update_display()
|
||||
|
||||
class Pins(Gtk.Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
self.loading_state = True
|
||||
self.monitored_paths = set()
|
||||
self.observer = Observer()
|
||||
self.event_handler = FileChangeHandler(self)
|
||||
|
||||
self.cells = []
|
||||
|
||||
|
||||
grid = Gtk.Grid(row_spacing=8, column_spacing=8, name="pin-grid")
|
||||
grid.set_column_homogeneous(True)
|
||||
grid.set_row_homogeneous(True)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
scrolled_window = ScrolledWindow(child=grid, name="scrolled-window", style_classes="pins", propagate_width=False, propagate_height=False)
|
||||
scrolled_window.set_hexpand(True)
|
||||
scrolled_window.set_vexpand(True)
|
||||
scrolled_window.set_halign(Gtk.Align.FILL)
|
||||
scrolled_window.set_valign(Gtk.Align.FILL)
|
||||
self.pack_start(scrolled_window, True, True, 0)
|
||||
|
||||
|
||||
if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]):
|
||||
for row in range(10):
|
||||
for col in range(3):
|
||||
cell = Cell(self)
|
||||
self.cells.append(cell)
|
||||
grid.attach(cell, col, row, 1, 1)
|
||||
else:
|
||||
for row in range(6):
|
||||
for col in range(5):
|
||||
cell = Cell(self)
|
||||
self.cells.append(cell)
|
||||
grid.attach(cell, col, row, 1, 1)
|
||||
|
||||
self.load_state()
|
||||
self.loading_state = False
|
||||
self.start_file_monitoring()
|
||||
|
||||
self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
|
||||
self.connect("drag-data-received", self.on_drag_data_received)
|
||||
|
||||
def start_file_monitoring(self):
|
||||
for cell in self.cells:
|
||||
if cell.content_type == 'file' and cell.content:
|
||||
dir_path = os.path.dirname(cell.content)
|
||||
if os.path.exists(dir_path) and dir_path not in self.monitored_paths:
|
||||
self.observer.schedule(self.event_handler, dir_path, recursive=False)
|
||||
self.monitored_paths.add(dir_path)
|
||||
self.observer.start()
|
||||
|
||||
def add_monitor_for_path(self, path):
|
||||
if path not in self.monitored_paths and os.path.exists(path):
|
||||
self.observer.schedule(self.event_handler, path, recursive=False)
|
||||
self.monitored_paths.add(path)
|
||||
|
||||
def save_state(self):
|
||||
state = []
|
||||
for cell in self.cells:
|
||||
state.append({
|
||||
'content_type': cell.content_type,
|
||||
'content': cell.content
|
||||
})
|
||||
try:
|
||||
with open(SAVE_FILE, 'w') as f:
|
||||
json.dump(state, f)
|
||||
except Exception as e:
|
||||
print("Error saving state:", e)
|
||||
|
||||
def load_state(self):
|
||||
if not os.path.exists(SAVE_FILE):
|
||||
return
|
||||
try:
|
||||
with open(SAVE_FILE, 'r') as f:
|
||||
state = json.load(f)
|
||||
for i, cell_data in enumerate(state):
|
||||
if i < len(self.cells):
|
||||
content = cell_data.get('content')
|
||||
content_type = cell_data.get('content_type')
|
||||
self.cells[i].content = content
|
||||
self.cells[i].content_type = content_type
|
||||
self.cells[i].update_display()
|
||||
except Exception as e:
|
||||
print("Error loading state:", e)
|
||||
|
||||
def on_drag_data_received(self, widget, drag_context, x, y, data, info, time):
|
||||
if data.get_length() >= 0:
|
||||
uris = data.get_uris()
|
||||
for uri in uris:
|
||||
try:
|
||||
filepath, _ = GLib.filename_from_uri(uri)
|
||||
for cell in self.cells:
|
||||
if cell.content is None:
|
||||
cell.content = filepath
|
||||
cell.content_type = 'file'
|
||||
cell.update_display()
|
||||
break
|
||||
except Exception as e:
|
||||
print("Error getting file from URI:", e)
|
||||
drag_context.finish(True, False, time)
|
||||
|
||||
def stop_monitoring(self):
|
||||
|
||||
for cell in self.cells:
|
||||
if hasattr(cell, 'favicon_temp_path') and cell.favicon_temp_path and os.path.exists(cell.favicon_temp_path):
|
||||
try:
|
||||
os.remove(cell.favicon_temp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
@@ -0,0 +1,706 @@
|
||||
import os
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.stack import Stack
|
||||
from gi.repository import Gdk, Gio, GLib, Gtk
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
from modules.cavalcade import SpectrumRender
|
||||
from services.mpris import MprisPlayer, MprisPlayerManager
|
||||
from widgets.circle_image import CircleImage
|
||||
|
||||
vertical_mode = False
|
||||
if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]):
|
||||
vertical_mode = True
|
||||
|
||||
def get_player_icon_markup_by_name(player_name):
|
||||
if player_name:
|
||||
pn = player_name.lower()
|
||||
if pn == "firefox":
|
||||
return icons.firefox
|
||||
elif pn == "spotify":
|
||||
return icons.spotify
|
||||
elif pn in ("chromium", "brave"):
|
||||
return icons.chromium
|
||||
return icons.disc
|
||||
|
||||
def add_hover_cursor(widget):
|
||||
widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
|
||||
widget.connect("enter-notify-event", lambda w, event: w.get_window().set_cursor(
|
||||
Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "pointer")))
|
||||
widget.connect("leave-notify-event", lambda w, event: w.get_window().set_cursor(None))
|
||||
|
||||
class PlayerBox(Box):
|
||||
def __init__(self, mpris_player=None):
|
||||
super().__init__(orientation="v", h_align="fill", spacing=0, h_expand=False, v_expand=not vertical_mode)
|
||||
self.mpris_player = mpris_player
|
||||
self._progress_timer_id = None
|
||||
|
||||
self.cover = CircleImage(
|
||||
name="player-cover",
|
||||
image_file=os.path.expanduser("~/.current.wall"),
|
||||
size=162 if not vertical_mode else 96,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.cover_placerholder = CircleImage(
|
||||
name="player-cover",
|
||||
size=198 if not vertical_mode else 132,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.title = Label(name="player-title", h_expand=True, h_align="fill", ellipsization="end", max_chars_width=1, style_classes=["vertical"] if vertical_mode else [])
|
||||
self.album = Label(name="player-album", h_expand=True, h_align="fill", ellipsization="end", max_chars_width=1)
|
||||
self.artist = Label(name="player-artist", h_expand=True, h_align="fill", ellipsization="end", max_chars_width=1)
|
||||
self.progressbar = CircularProgressBar(
|
||||
name="player-progress",
|
||||
size=198 if not vertical_mode else 132,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
start_angle=180,
|
||||
end_angle=360,
|
||||
)
|
||||
self.time = Label(name="player-time", label="--:-- / --:--")
|
||||
self.overlay = Overlay(
|
||||
child=self.cover_placerholder,
|
||||
overlays=[self.progressbar, self.cover],
|
||||
)
|
||||
self.overlay_container = CenterBox(name="player-overlay", center_children=[self.overlay])
|
||||
self.title.set_label("Nothing Playing")
|
||||
self.album.set_label("Enjoy the silence")
|
||||
self.artist.set_label("¯\\_(ツ)_/¯")
|
||||
self.progressbar.set_value(0.0)
|
||||
self.prev = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.prev),
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.backward = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.skip_back),
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.play_pause = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.play, style_classes=["play-pause"]),
|
||||
style_classes=["play-pause"],
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.forward = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.skip_forward),
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.next = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.next),
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
add_hover_cursor(self.prev)
|
||||
add_hover_cursor(self.backward)
|
||||
add_hover_cursor(self.play_pause)
|
||||
add_hover_cursor(self.forward)
|
||||
add_hover_cursor(self.next)
|
||||
self.btn_box = CenterBox(
|
||||
name="player-btn-box",
|
||||
orientation="h",
|
||||
center_children=[
|
||||
Box(
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
children=[
|
||||
self.prev,
|
||||
self.backward,
|
||||
self.play_pause,
|
||||
self.forward,
|
||||
self.next,
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
self.p_children=[
|
||||
self.overlay_container,
|
||||
self.title,
|
||||
self.album,
|
||||
self.artist,
|
||||
self.btn_box,
|
||||
self.time,
|
||||
] if not vertical_mode else [
|
||||
self.overlay_container,
|
||||
Box(
|
||||
orientation="v",
|
||||
spacing=4,
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
v_expand=False,
|
||||
v_align="center",
|
||||
children=[
|
||||
self.title,
|
||||
self.album,
|
||||
self.btn_box,
|
||||
self.artist,
|
||||
self.time,
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
self.player_box = Box(
|
||||
name="player-box",
|
||||
orientation="v" if not vertical_mode else "h",
|
||||
v_align="center",
|
||||
spacing=4,
|
||||
children=self.p_children,
|
||||
)
|
||||
self.add(self.player_box)
|
||||
if mpris_player:
|
||||
self._apply_mpris_properties()
|
||||
self.prev.connect("clicked", self._on_prev_clicked)
|
||||
self.play_pause.connect("clicked", self._on_play_pause_clicked)
|
||||
self.backward.connect("clicked", self._on_backward_clicked)
|
||||
self.forward.connect("clicked", self._on_forward_clicked)
|
||||
self.next.connect("clicked", self._on_next_clicked)
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
else:
|
||||
self.play_pause.get_child().set_markup(icons.stop)
|
||||
self.play_pause.add_style_class("stop")
|
||||
|
||||
self.backward.add_style_class("disabled")
|
||||
self.forward.add_style_class("disabled")
|
||||
self.prev.add_style_class("disabled")
|
||||
self.next.add_style_class("disabled")
|
||||
self.progressbar.set_value(0.0)
|
||||
self.time.set_text("--:-- / --:--")
|
||||
|
||||
def _apply_mpris_properties(self):
|
||||
mp = self.mpris_player
|
||||
self.title.set_visible(bool(mp.title and mp.title.strip()))
|
||||
if mp.title and mp.title.strip():
|
||||
self.title.set_text(mp.title)
|
||||
self.album.set_visible(bool(mp.album and mp.album.strip()))
|
||||
if mp.album and mp.album.strip():
|
||||
self.album.set_text(mp.album)
|
||||
self.artist.set_visible(bool(mp.artist and mp.artist.strip()))
|
||||
if mp.artist and mp.artist.strip():
|
||||
self.artist.set_text(mp.artist)
|
||||
if mp.arturl:
|
||||
parsed = urllib.parse.urlparse(mp.arturl)
|
||||
if parsed.scheme == "file":
|
||||
local_arturl = urllib.parse.unquote(parsed.path)
|
||||
self._set_cover_image(local_arturl)
|
||||
elif parsed.scheme in ("http", "https"):
|
||||
GLib.Thread.new("download-artwork", self._download_and_set_artwork, mp.arturl)
|
||||
else:
|
||||
self._set_cover_image(mp.arturl)
|
||||
else:
|
||||
fallback = os.path.expanduser("~/.current.wall")
|
||||
self._set_cover_image(fallback)
|
||||
file_obj = Gio.File.new_for_path(fallback)
|
||||
monitor = file_obj.monitor_file(Gio.FileMonitorFlags.NONE, None)
|
||||
monitor.connect("changed", self.on_wallpaper_changed)
|
||||
self._wallpaper_monitor = monitor
|
||||
self.update_play_pause_icon()
|
||||
|
||||
self.progressbar.set_visible(True)
|
||||
self.time.set_visible(True)
|
||||
|
||||
player_name = mp.player_name.lower() if hasattr(mp, "player_name") and mp.player_name else ""
|
||||
can_seek = hasattr(mp, "can_seek") and mp.can_seek
|
||||
|
||||
if player_name == "firefox" or not can_seek:
|
||||
# Firefox and non-seekable players don't support progress tracking
|
||||
self.backward.add_style_class("disabled")
|
||||
self.forward.add_style_class("disabled")
|
||||
self.progressbar.set_value(0.0)
|
||||
self.time.set_text("--:-- / --:--")
|
||||
# Stop the timer since we can't track progress
|
||||
if self._progress_timer_id:
|
||||
GLib.source_remove(self._progress_timer_id)
|
||||
self._progress_timer_id = None
|
||||
else:
|
||||
# Enable seeking controls
|
||||
self.backward.remove_style_class("disabled")
|
||||
self.forward.remove_style_class("disabled")
|
||||
|
||||
# Use adaptive timer based on playback status instead of fixed 1-second polling
|
||||
self._start_adaptive_progress_timer()
|
||||
|
||||
if hasattr(mp, "can_go_previous") and mp.can_go_previous:
|
||||
self.prev.remove_style_class("disabled")
|
||||
else:
|
||||
self.prev.add_style_class("disabled")
|
||||
|
||||
if hasattr(mp, "can_go_next") and mp.can_go_next:
|
||||
self.next.remove_style_class("disabled")
|
||||
else:
|
||||
self.next.add_style_class("disabled")
|
||||
|
||||
def _start_adaptive_progress_timer(self):
|
||||
"""Start progress timer with adaptive interval based on playback status"""
|
||||
if self._progress_timer_id:
|
||||
GLib.source_remove(self._progress_timer_id)
|
||||
|
||||
# Use longer intervals when paused to reduce CPU usage
|
||||
if hasattr(self.mpris_player, 'playback_status') and self.mpris_player.playback_status == "playing":
|
||||
interval = 1000 # 1 second when playing
|
||||
else:
|
||||
interval = 5000 # 5 seconds when paused/stopped
|
||||
|
||||
self._progress_timer_id = GLib.timeout_add(interval, self._update_progress)
|
||||
self._update_progress() # Update immediately
|
||||
|
||||
def _set_cover_image(self, image_path):
|
||||
if image_path and os.path.isfile(image_path):
|
||||
self.cover.set_image_from_file(image_path)
|
||||
else:
|
||||
fallback = os.path.expanduser("~/.current.wall")
|
||||
self.cover.set_image_from_file(fallback)
|
||||
file_obj = Gio.File.new_for_path(fallback)
|
||||
monitor = file_obj.monitor_file(Gio.FileMonitorFlags.NONE, None)
|
||||
monitor.connect("changed", self.on_wallpaper_changed)
|
||||
self._wallpaper_monitor = monitor
|
||||
|
||||
def _download_and_set_artwork(self, arturl):
|
||||
"""
|
||||
Download the artwork from the given URL asynchronously and update the cover image
|
||||
using GLib.idle_add to ensure UI updates occur on the main thread.
|
||||
"""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(arturl)
|
||||
suffix = os.path.splitext(parsed.path)[1] or ".png"
|
||||
with urllib.request.urlopen(arturl) as response:
|
||||
data = response.read()
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
temp_file.write(data)
|
||||
temp_file.close()
|
||||
local_arturl = temp_file.name
|
||||
except Exception:
|
||||
local_arturl = os.path.expanduser("~/.current.wall")
|
||||
GLib.idle_add(self._set_cover_image, local_arturl)
|
||||
return None
|
||||
|
||||
def update_play_pause_icon(self):
|
||||
if self.mpris_player.playback_status == "playing":
|
||||
self.play_pause.get_child().set_markup(icons.pause)
|
||||
self.play_pause.add_style_class("playing")
|
||||
else:
|
||||
self.play_pause.get_child().set_markup(icons.play)
|
||||
self.play_pause.remove_style_class("playing")
|
||||
|
||||
def on_wallpaper_changed(self, monitor, file, other_file, event):
|
||||
self.cover.set_image_from_file(os.path.expanduser("~/.current.wall"))
|
||||
|
||||
def _on_prev_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.previous()
|
||||
|
||||
def _on_play_pause_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.play_pause()
|
||||
self.update_play_pause_icon()
|
||||
|
||||
def _on_backward_clicked(self, button):
|
||||
|
||||
if self.mpris_player and self.mpris_player.can_seek and "disabled" not in self.backward.get_style_context().list_classes():
|
||||
new_pos = max(0, self.mpris_player.position - 5000000)
|
||||
self.mpris_player.position = new_pos
|
||||
|
||||
def _on_forward_clicked(self, button):
|
||||
|
||||
if self.mpris_player and self.mpris_player.can_seek and "disabled" not in self.forward.get_style_context().list_classes():
|
||||
new_pos = self.mpris_player.position + 5000000
|
||||
self.mpris_player.position = new_pos
|
||||
|
||||
def _on_next_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.next()
|
||||
|
||||
def _update_progress(self):
|
||||
|
||||
if not self.mpris_player:
|
||||
|
||||
if self._progress_timer_id:
|
||||
GLib.source_remove(self._progress_timer_id)
|
||||
self._progress_timer_id = None
|
||||
return False
|
||||
|
||||
try:
|
||||
current = self.mpris_player.position
|
||||
except Exception:
|
||||
current = 0
|
||||
try:
|
||||
total = int(self.mpris_player.length or 0)
|
||||
except Exception:
|
||||
total = 0
|
||||
|
||||
if total <= 0:
|
||||
progress = 0.0
|
||||
self.time.set_text("--:-- / --:--")
|
||||
|
||||
else:
|
||||
progress = (current / total)
|
||||
self.time.set_text(f"{self._format_time(current)} / {self._format_time(total)}")
|
||||
|
||||
self.progressbar.set_value(progress)
|
||||
return True
|
||||
|
||||
def _format_time(self, us):
|
||||
seconds = int(us / 1000000)
|
||||
minutes = seconds // 60
|
||||
seconds = seconds % 60
|
||||
return f"{minutes}:{seconds:02}"
|
||||
|
||||
def _update_metadata(self):
|
||||
if not self.mpris_player:
|
||||
return False
|
||||
self._apply_mpris_properties()
|
||||
return True
|
||||
|
||||
def _on_mpris_changed(self, *args):
|
||||
|
||||
if not hasattr(self, "_update_pending") or not self._update_pending:
|
||||
self._update_pending = True
|
||||
|
||||
GLib.idle_add(self._apply_mpris_properties_debounced)
|
||||
|
||||
def _apply_mpris_properties_debounced(self):
|
||||
"""Apply MPRIS properties with debouncing and restart adaptive timer"""
|
||||
if self.mpris_player:
|
||||
self._apply_mpris_properties()
|
||||
else:
|
||||
# Clean up timer when player is removed
|
||||
if self._progress_timer_id:
|
||||
GLib.source_remove(self._progress_timer_id)
|
||||
self._progress_timer_id = None
|
||||
self._update_pending = False
|
||||
return False
|
||||
|
||||
class Player(Box):
|
||||
def __init__(self):
|
||||
super().__init__(name="player", orientation="v", h_align="fill", spacing=0, h_expand=False, v_expand=not vertical_mode)
|
||||
self.player_stack = Stack(
|
||||
name="player-stack",
|
||||
transition_type="slide-left-right",
|
||||
transition_duration=500,
|
||||
v_align="center",
|
||||
v_expand=not vertical_mode,
|
||||
)
|
||||
self.switcher = Gtk.StackSwitcher(
|
||||
name="player-switcher" if not vertical_mode else "player-switcher-vertical",
|
||||
spacing=8,
|
||||
)
|
||||
self.switcher.set_stack(self.player_stack)
|
||||
self.switcher.set_halign(Gtk.Align.CENTER)
|
||||
self.mpris_manager = MprisPlayerManager()
|
||||
players = self.mpris_manager.players
|
||||
if players:
|
||||
for p in players:
|
||||
mp = MprisPlayer(p)
|
||||
pb = PlayerBox(mpris_player=mp)
|
||||
self.player_stack.add_titled(pb, mp.player_name, mp.player_name)
|
||||
else:
|
||||
pb = PlayerBox(mpris_player=None)
|
||||
self.player_stack.add_titled(pb, "nothing", "Nothing Playing")
|
||||
self.mpris_manager.connect("player-appeared", self.on_player_appeared)
|
||||
self.mpris_manager.connect("player-vanished", self.on_player_vanished)
|
||||
self.switcher.set_visible(True)
|
||||
self.add(self.player_stack)
|
||||
self.add(self.switcher)
|
||||
GLib.idle_add(self._replace_switcher_labels)
|
||||
|
||||
def on_player_appeared(self, manager, player):
|
||||
children = self.player_stack.get_children()
|
||||
if len(children) == 1 and not getattr(children[0], "mpris_player", None):
|
||||
self.player_stack.remove(children[0])
|
||||
mp = MprisPlayer(player)
|
||||
pb = PlayerBox(mpris_player=mp)
|
||||
self.player_stack.add_titled(pb, mp.player_name, mp.player_name)
|
||||
|
||||
self.switcher.set_visible(True)
|
||||
GLib.idle_add(lambda: self._update_switcher_for_player(mp.player_name))
|
||||
GLib.idle_add(self._replace_switcher_labels)
|
||||
|
||||
def on_player_vanished(self, manager, player_name):
|
||||
for child in self.player_stack.get_children():
|
||||
if hasattr(child, "mpris_player") and child.mpris_player and child.mpris_player.player_name == player_name:
|
||||
self.player_stack.remove(child)
|
||||
break
|
||||
if not any(getattr(child, "mpris_player", None) for child in self.player_stack.get_children()):
|
||||
pb = PlayerBox(mpris_player=None)
|
||||
self.player_stack.add_titled(pb, "nothing", "Nothing Playing")
|
||||
self.switcher.set_visible(True)
|
||||
GLib.idle_add(self._replace_switcher_labels)
|
||||
|
||||
def _replace_switcher_labels(self):
|
||||
buttons = self.switcher.get_children()
|
||||
for btn in buttons:
|
||||
if isinstance(btn, Gtk.ToggleButton):
|
||||
default_label = None
|
||||
for child in btn.get_children():
|
||||
if isinstance(child, Gtk.Label):
|
||||
default_label = child
|
||||
break
|
||||
if default_label:
|
||||
label_player_name = getattr(default_label, "player_name", default_label.get_text().lower())
|
||||
icon_markup = get_player_icon_markup_by_name(label_player_name)
|
||||
btn.remove(default_label)
|
||||
new_label = Label(name="player-label", markup=icon_markup)
|
||||
new_label.player_name = label_player_name
|
||||
btn.add(new_label)
|
||||
new_label.show_all()
|
||||
return False
|
||||
|
||||
def _update_switcher_for_player(self, player_name):
|
||||
for btn in self.switcher.get_children():
|
||||
if isinstance(btn, Gtk.ToggleButton):
|
||||
default_label = None
|
||||
for child in btn.get_children():
|
||||
if isinstance(child, Gtk.Label):
|
||||
default_label = child
|
||||
break
|
||||
if default_label:
|
||||
label_player_name = getattr(default_label, "player_name", default_label.get_text().lower())
|
||||
if label_player_name == player_name.lower():
|
||||
icon_markup = get_player_icon_markup_by_name(player_name)
|
||||
btn.remove(default_label)
|
||||
new_label = Label(name="player-label", markup=icon_markup)
|
||||
new_label.player_name = player_name.lower()
|
||||
btn.add(new_label)
|
||||
new_label.show_all()
|
||||
return False
|
||||
|
||||
class PlayerSmall(CenterBox):
|
||||
def __init__(self):
|
||||
super().__init__(name="player-small", orientation="h", h_align="fill", v_align="center")
|
||||
self._show_artist = False
|
||||
self._display_options = ["cavalcade", "title", "artist"]
|
||||
self._display_index = 0
|
||||
self._current_display = "cavalcade"
|
||||
|
||||
self.mpris_icon = Button(
|
||||
name="compact-mpris-icon",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
child=Label(name="compact-mpris-icon-label", markup=icons.disc)
|
||||
)
|
||||
|
||||
self.mpris_icon.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
self.mpris_icon.connect("button-press-event", self._on_icon_button_press)
|
||||
|
||||
child = self.mpris_icon.get_child()
|
||||
child.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
child.connect("button-press-event", lambda widget, event: True)
|
||||
|
||||
add_hover_cursor(self.mpris_icon)
|
||||
|
||||
self.mpris_label = Label(
|
||||
name="compact-mpris-label",
|
||||
label="Nothing Playing",
|
||||
ellipsization="end",
|
||||
max_chars_width=26,
|
||||
h_align="center",
|
||||
)
|
||||
self.mpris_button = Button(
|
||||
name="compact-mpris-button",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
child=Label(name="compact-mpris-button-label", markup=icons.play)
|
||||
)
|
||||
self.mpris_button.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
self.mpris_button.connect("button-press-event", self._on_play_pause_button_press)
|
||||
|
||||
add_hover_cursor(self.mpris_button)
|
||||
|
||||
self.cavalcade = SpectrumRender()
|
||||
self.cavalcade_box = self.cavalcade.get_spectrum_box()
|
||||
|
||||
self.center_stack = Stack(
|
||||
name="compact-mpris",
|
||||
transition_type="crossfade",
|
||||
transition_duration=100,
|
||||
v_align="center",
|
||||
v_expand=False,
|
||||
children=[
|
||||
self.cavalcade_box,
|
||||
self.mpris_label,
|
||||
]
|
||||
)
|
||||
self.center_stack.set_visible_child(self.cavalcade_box)
|
||||
|
||||
self.mpris_small = CenterBox(
|
||||
name="compact-mpris",
|
||||
orientation="h",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
v_align="center",
|
||||
v_expand=False,
|
||||
start_children=self.mpris_icon,
|
||||
center_children=self.center_stack,
|
||||
end_children=self.mpris_button,
|
||||
)
|
||||
|
||||
self.add(self.mpris_small)
|
||||
|
||||
self.mpris_manager = MprisPlayerManager()
|
||||
self.mpris_player = None
|
||||
|
||||
self.current_index = 0
|
||||
|
||||
players = self.mpris_manager.players
|
||||
if players:
|
||||
mp = MprisPlayer(players[self.current_index])
|
||||
self.mpris_player = mp
|
||||
self._apply_mpris_properties()
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
else:
|
||||
self._apply_mpris_properties()
|
||||
|
||||
self.mpris_manager.connect("player-appeared", self.on_player_appeared)
|
||||
self.mpris_manager.connect("player-vanished", self.on_player_vanished)
|
||||
self.mpris_button.connect("clicked", self._on_play_pause_clicked)
|
||||
|
||||
def _apply_mpris_properties(self):
|
||||
if not self.mpris_player:
|
||||
self.mpris_label.set_text("Nothing Playing")
|
||||
self.mpris_button.get_child().set_markup(icons.stop)
|
||||
self.mpris_icon.get_child().set_markup(icons.disc)
|
||||
if self._current_display != "cavalcade":
|
||||
self.center_stack.set_visible_child(self.mpris_label)
|
||||
else:
|
||||
self.center_stack.set_visible_child(self.cavalcade_box)
|
||||
return
|
||||
|
||||
mp = self.mpris_player
|
||||
|
||||
player_name = mp.player_name.lower() if hasattr(mp, "player_name") and mp.player_name else ""
|
||||
icon_markup = get_player_icon_markup_by_name(player_name)
|
||||
self.mpris_icon.get_child().set_markup(icon_markup)
|
||||
self.update_play_pause_icon()
|
||||
|
||||
if self._current_display == "title":
|
||||
text = (mp.title if mp.title and mp.title.strip() else "Nothing Playing")
|
||||
self.mpris_label.set_text(text)
|
||||
self.center_stack.set_visible_child(self.mpris_label)
|
||||
elif self._current_display == "artist":
|
||||
text = (mp.artist if mp.artist else "Nothing Playing")
|
||||
self.mpris_label.set_text(text)
|
||||
self.center_stack.set_visible_child(self.mpris_label)
|
||||
else:
|
||||
self.center_stack.set_visible_child(self.cavalcade_box)
|
||||
|
||||
def _on_icon_button_press(self, widget, event):
|
||||
from gi.repository import Gdk
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
players = self.mpris_manager.players
|
||||
if not players:
|
||||
return True
|
||||
|
||||
if event.button == 2:
|
||||
self._display_index = (self._display_index + 1) % len(self._display_options)
|
||||
self._current_display = self._display_options[self._display_index]
|
||||
self._apply_mpris_properties()
|
||||
return True
|
||||
|
||||
if event.button == 1:
|
||||
self.current_index = (self.current_index + 1) % len(players)
|
||||
elif event.button == 3:
|
||||
self.current_index = (self.current_index - 1) % len(players)
|
||||
if self.current_index < 0:
|
||||
self.current_index = len(players) - 1
|
||||
|
||||
mp_new = MprisPlayer(players[self.current_index])
|
||||
self.mpris_player = mp_new
|
||||
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
self._apply_mpris_properties()
|
||||
return True
|
||||
return True
|
||||
|
||||
def _on_play_pause_button_press(self, widget, event):
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
if event.button == 1:
|
||||
if self.mpris_player:
|
||||
self.mpris_player.previous()
|
||||
self.mpris_button.get_child().set_markup(icons.prev)
|
||||
GLib.timeout_add(500, self._restore_play_pause_icon)
|
||||
elif event.button == 3:
|
||||
if self.mpris_player:
|
||||
self.mpris_player.next()
|
||||
self.mpris_button.get_child().set_markup(icons.next)
|
||||
GLib.timeout_add(500, self._restore_play_pause_icon)
|
||||
elif event.button == 2:
|
||||
if self.mpris_player:
|
||||
self.mpris_player.play_pause()
|
||||
self.update_play_pause_icon()
|
||||
return True
|
||||
return True
|
||||
|
||||
def _restore_play_pause_icon(self):
|
||||
self.update_play_pause_icon()
|
||||
return False
|
||||
|
||||
def _on_icon_clicked(self, widget):
|
||||
pass
|
||||
|
||||
def update_play_pause_icon(self):
|
||||
if self.mpris_player and self.mpris_player.playback_status == "playing":
|
||||
self.mpris_button.get_child().set_markup(icons.pause)
|
||||
else:
|
||||
self.mpris_button.get_child().set_markup(icons.play)
|
||||
|
||||
def _on_play_pause_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.play_pause()
|
||||
self.update_play_pause_icon()
|
||||
|
||||
def _on_mpris_changed(self, *args):
|
||||
|
||||
self._apply_mpris_properties()
|
||||
|
||||
def on_player_appeared(self, manager, player):
|
||||
|
||||
if not self.mpris_player:
|
||||
mp = MprisPlayer(player)
|
||||
self.mpris_player = mp
|
||||
self._apply_mpris_properties()
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
|
||||
def on_player_vanished(self, manager, player_name):
|
||||
players = self.mpris_manager.players
|
||||
if players and self.mpris_player and self.mpris_player.player_name == player_name:
|
||||
if players:
|
||||
self.current_index = self.current_index % len(players)
|
||||
new_player = MprisPlayer(players[self.current_index])
|
||||
self.mpris_player = new_player
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
else:
|
||||
self.mpris_player = None
|
||||
elif not players:
|
||||
self.mpris_player = None
|
||||
self._apply_mpris_properties()
|
||||
@@ -0,0 +1,131 @@
|
||||
from fabric.utils.helpers import exec_shell_command_async
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
|
||||
tooltip_lock = "Lock"
|
||||
tooltip_suspend = "Suspend"
|
||||
tooltip_logout = "Logout"
|
||||
tooltip_reboot = "Reboot"
|
||||
tooltip_shutdown = "Shutdown"
|
||||
|
||||
|
||||
class PowerMenu(Box):
|
||||
def __init__(self, **kwargs):
|
||||
orientation = "h"
|
||||
if data.PANEL_THEME == "Panel" and (
|
||||
data.BAR_POSITION in ["Left", "Right"]
|
||||
or data.PANEL_POSITION in ["Start", "End"]
|
||||
):
|
||||
orientation = "v"
|
||||
|
||||
super().__init__(
|
||||
name="power-menu",
|
||||
orientation=orientation,
|
||||
spacing=4,
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
visible=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.notch = kwargs["notch"]
|
||||
|
||||
self.btn_lock = Button(
|
||||
name="power-menu-button",
|
||||
tooltip_markup=tooltip_lock,
|
||||
child=Label(name="button-label", markup=icons.lock),
|
||||
on_clicked=self.lock,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_suspend = Button(
|
||||
name="power-menu-button",
|
||||
tooltip_markup=tooltip_suspend,
|
||||
child=Label(name="button-label", markup=icons.suspend),
|
||||
on_clicked=self.suspend,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_logout = Button(
|
||||
name="power-menu-button",
|
||||
tooltip_markup=tooltip_logout,
|
||||
child=Label(name="button-label", markup=icons.logout),
|
||||
on_clicked=self.logout,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_reboot = Button(
|
||||
name="power-menu-button",
|
||||
tooltip_markup=tooltip_reboot,
|
||||
child=Label(name="button-label", markup=icons.reboot),
|
||||
on_clicked=self.reboot,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_shutdown = Button(
|
||||
name="power-menu-button",
|
||||
tooltip_markup=tooltip_shutdown,
|
||||
child=Label(name="button-label", markup=icons.shutdown),
|
||||
on_clicked=self.poweroff,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.buttons = [
|
||||
self.btn_lock,
|
||||
self.btn_suspend,
|
||||
self.btn_logout,
|
||||
self.btn_reboot,
|
||||
self.btn_shutdown,
|
||||
]
|
||||
|
||||
for button in self.buttons:
|
||||
self.add(button)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def close_menu(self):
|
||||
self.notch.close_notch()
|
||||
|
||||
def lock(self, *args):
|
||||
print("Locking screen...")
|
||||
exec_shell_command_async("loginctl lock-session")
|
||||
self.close_menu()
|
||||
|
||||
def suspend(self, *args):
|
||||
print("Suspending system...")
|
||||
exec_shell_command_async("systemctl suspend")
|
||||
self.close_menu()
|
||||
|
||||
def logout(self, *args):
|
||||
print("Logging out...")
|
||||
exec_shell_command_async("hyprctl dispatch exit")
|
||||
self.close_menu()
|
||||
|
||||
def reboot(self, *args):
|
||||
print("Rebooting system...")
|
||||
exec_shell_command_async("systemctl reboot")
|
||||
self.close_menu()
|
||||
|
||||
def poweroff(self, *args):
|
||||
print("Powering off...")
|
||||
exec_shell_command_async("systemctl poweroff")
|
||||
self.close_menu()
|
||||
@@ -0,0 +1,346 @@
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum
|
||||
from typing import Literal, cast, overload
|
||||
|
||||
import gi
|
||||
import OpenGL.GL as GL
|
||||
from fabric import Property, Signal
|
||||
from fabric.widgets.widget import Widget
|
||||
from OpenGL.GL.shaders import compileProgram, compileShader
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk
|
||||
|
||||
|
||||
class ShadertoyUniformType(Enum):
|
||||
FLOAT = 1
|
||||
INTEGER = 2
|
||||
VECTOR = 3
|
||||
TEXTURE = 4
|
||||
|
||||
class ShadertoyCompileError(Exception): ...
|
||||
|
||||
class Shadertoy(Gtk.GLArea, Widget):
|
||||
@Signal
|
||||
def ready(self) -> None: ...
|
||||
|
||||
@Property(str, "read-write")
|
||||
def shader_buffer(self) -> str:
|
||||
return self._shader_buffer
|
||||
|
||||
@shader_buffer.setter
|
||||
def shader_buffer(self, shader_buffer: str) -> None:
|
||||
self._shader_buffer = shader_buffer
|
||||
if not self._ready:
|
||||
return
|
||||
self._shader_uniforms.clear()
|
||||
self.do_realize()
|
||||
self.queue_draw()
|
||||
return
|
||||
|
||||
DEFAULT_VERTEX_SHADER = """
|
||||
|
||||
in vec2 position;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
DEFAULT_FRAGMENT_UNIFORMS = """
|
||||
|
||||
uniform vec3 iResolution; // viewport resolution (in pixels)
|
||||
uniform float iTime; // shader playback time (in seconds)
|
||||
uniform float iTimeDelta; // render time (in seconds)
|
||||
uniform float iFrameRate; // shader frame rate
|
||||
uniform int iFrame; // shader playback frame
|
||||
uniform float iChannelTime[4]; // channel playback time (in seconds)
|
||||
uniform vec3 iChannelResolution[4]; // channel resolution (in pixels)
|
||||
uniform vec4 iMouse; // mouse pixel coords. xy: current (if MLB down), zw: click
|
||||
uniform sampler2D iChannel0; // input channel. XX = 2D/Cube
|
||||
uniform sampler2D iChannel1;
|
||||
uniform sampler2D iChannel2;
|
||||
uniform sampler2D iChannel3;
|
||||
uniform vec4 iDate; // (year, month, day, time in seconds)
|
||||
uniform float iSampleRate; // sound sample rate (i.e., 44100)
|
||||
|
||||
"""
|
||||
|
||||
FRAGMENT_MAIN_FUNCTION = """
|
||||
void main() {
|
||||
mainImage(gl_FragColor, gl_FragCoord.xy);
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
shader_buffer: str,
|
||||
shader_uniforms: list[
|
||||
tuple[
|
||||
str,
|
||||
ShadertoyUniformType,
|
||||
bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf,
|
||||
]
|
||||
]
|
||||
| None = None,
|
||||
name: str | None = None,
|
||||
visible: bool = True,
|
||||
all_visible: bool = False,
|
||||
style: str | None = None,
|
||||
style_classes: Iterable[str] | str | None = None,
|
||||
tooltip_text: str | None = None,
|
||||
tooltip_markup: str | None = None,
|
||||
h_align: Literal["fill", "start", "end", "center", "baseline"]
|
||||
| Gtk.Align
|
||||
| None = None,
|
||||
v_align: Literal["fill", "start", "end", "center", "baseline"]
|
||||
| Gtk.Align
|
||||
| None = None,
|
||||
h_expand: bool = False,
|
||||
v_expand: bool = False,
|
||||
size: Iterable[int] | int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
Gtk.GLArea.__init__(
|
||||
self
|
||||
)
|
||||
Widget.__init__(
|
||||
self,
|
||||
name,
|
||||
visible,
|
||||
all_visible,
|
||||
style,
|
||||
style_classes,
|
||||
tooltip_text,
|
||||
tooltip_markup,
|
||||
h_align,
|
||||
v_align,
|
||||
h_expand,
|
||||
v_expand,
|
||||
size,
|
||||
**kwargs,
|
||||
)
|
||||
self._shader_buffer = shader_buffer
|
||||
self._shader_uniforms = shader_uniforms or []
|
||||
|
||||
self.set_required_version(3, 3)
|
||||
self.set_has_depth_buffer(False)
|
||||
self.set_has_stencil_buffer(False)
|
||||
|
||||
self._ready = False
|
||||
self._program = None
|
||||
self._vao = None
|
||||
self._quad_vbo = None
|
||||
self._texture_units = {}
|
||||
|
||||
self._start_time = GLib.get_monotonic_time() / 1e6
|
||||
self._frame_time = self._start_time
|
||||
self._frame_count = 0
|
||||
|
||||
self._tick_id = self.add_tick_callback(lambda *_: (self.queue_draw(), True)[1])
|
||||
|
||||
def do_bake_program(self):
|
||||
try:
|
||||
vertex_shader = compileShader(
|
||||
self.DEFAULT_VERTEX_SHADER, GL.GL_VERTEX_SHADER
|
||||
)
|
||||
fragment_shader = compileShader(
|
||||
self.DEFAULT_FRAGMENT_UNIFORMS
|
||||
+ self._shader_buffer
|
||||
+ self.FRAGMENT_MAIN_FUNCTION,
|
||||
GL.GL_FRAGMENT_SHADER,
|
||||
)
|
||||
except Exception as e:
|
||||
raise ShadertoyCompileError(
|
||||
f"couldn't compile the provided shader, OpenGL error:\n {e}"
|
||||
)
|
||||
|
||||
return compileProgram(vertex_shader, fragment_shader)
|
||||
|
||||
def do_realize(self, *_):
|
||||
Gtk.GLArea.do_realize(self)
|
||||
if not self._ready:
|
||||
ctx = self.get_context()
|
||||
if (err := self.get_error()) or not ctx:
|
||||
raise RuntimeError(
|
||||
f"couldn't initialize the drawing context, error: {err or 'context is None'}"
|
||||
)
|
||||
|
||||
ctx.make_current()
|
||||
|
||||
if self._program:
|
||||
GL.glDeleteProgram(self._program)
|
||||
self._program = None
|
||||
self._program = self.do_bake_program()
|
||||
|
||||
GL.glEnable(GL.GL_BLEND)
|
||||
GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
self._quad_vbo = GL.glGenBuffers(1)
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._quad_vbo)
|
||||
|
||||
quad_verts = (-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0)
|
||||
array_type = GL.GLfloat * len(quad_verts)
|
||||
|
||||
GL.glBufferData(
|
||||
GL.GL_ARRAY_BUFFER,
|
||||
len(quad_verts) * 4,
|
||||
array_type(*quad_verts),
|
||||
GL.GL_STATIC_DRAW,
|
||||
)
|
||||
|
||||
self._vao = GL.glGenVertexArrays(1)
|
||||
GL.glBindVertexArray(self._vao)
|
||||
|
||||
position = GL.glGetAttribLocation(self._program, "position")
|
||||
GL.glEnableVertexAttribArray(position)
|
||||
GL.glVertexAttribPointer(position, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None)
|
||||
|
||||
for uname, utype, uvalue in self._shader_uniforms:
|
||||
self.set_uniform(uname, utype, uvalue)
|
||||
|
||||
self._ready = True
|
||||
self.ready()
|
||||
return
|
||||
|
||||
def do_get_timing(self) -> tuple[float, float, float]:
|
||||
current_time = GLib.get_monotonic_time() / 1e6
|
||||
delta_time = current_time - self._frame_time
|
||||
return current_time, delta_time, (1.0 / delta_time) if delta_time > 0 else 0.0
|
||||
|
||||
def do_post_render(self, time: float):
|
||||
self._frame_time = time
|
||||
self._frame_count += 1
|
||||
return
|
||||
|
||||
def do_render(self, ctx: Gdk.GLContext):
|
||||
if not self._program:
|
||||
if self._tick_id:
|
||||
self.remove_tick_callback(self._tick_id)
|
||||
self._tick_id = 0
|
||||
return False
|
||||
|
||||
GL.glUseProgram(self._program)
|
||||
|
||||
GL.glClear(GL.GL_COLOR_BUFFER_BIT)
|
||||
|
||||
alloc = self.get_allocation()
|
||||
width: int = alloc.width
|
||||
height: int = alloc.height
|
||||
mouse_pos = cast(tuple[int, int], self.get_pointer())
|
||||
|
||||
current_time, delta_time, frame_rate = self.do_get_timing()
|
||||
|
||||
self.set_uniform(
|
||||
"iTime", ShadertoyUniformType.FLOAT, current_time - self._start_time
|
||||
)
|
||||
self.set_uniform("iFrame", ShadertoyUniformType.INTEGER, self._frame_count)
|
||||
self.set_uniform("iTimeDelta", ShadertoyUniformType.FLOAT, delta_time)
|
||||
self.set_uniform("iFrameRate", ShadertoyUniformType.FLOAT, frame_rate)
|
||||
self.set_uniform(
|
||||
"iResolution", ShadertoyUniformType.VECTOR, (width, height, 1.0)
|
||||
)
|
||||
self.set_uniform(
|
||||
"iMouse",
|
||||
ShadertoyUniformType.VECTOR,
|
||||
(mouse_pos[0], height - mouse_pos[1], 0, 0),
|
||||
)
|
||||
|
||||
GL.glBindVertexArray(self._vao)
|
||||
GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4)
|
||||
self.do_post_render(current_time)
|
||||
return True
|
||||
|
||||
def do_resize(self, width: int, height: int):
|
||||
Gtk.GLArea.do_resize(self, width, height)
|
||||
GL.glViewport(0, 0, width, height)
|
||||
return
|
||||
|
||||
@overload
|
||||
def set_uniform(
|
||||
self, name: str, type: Literal[ShadertoyUniformType.FLOAT], value: float
|
||||
): ...
|
||||
|
||||
@overload
|
||||
def set_uniform(
|
||||
self, name: str, type: Literal[ShadertoyUniformType.INTEGER], value: int
|
||||
): ...
|
||||
|
||||
@overload
|
||||
def set_uniform(
|
||||
self,
|
||||
name: str,
|
||||
type: Literal[ShadertoyUniformType.VECTOR],
|
||||
value: tuple[float, ...],
|
||||
): ...
|
||||
|
||||
@overload
|
||||
def set_uniform(
|
||||
self,
|
||||
name: str,
|
||||
type: Literal[ShadertoyUniformType.TEXTURE],
|
||||
value: GdkPixbuf.Pixbuf,
|
||||
): ...
|
||||
|
||||
def set_uniform(
|
||||
self,
|
||||
name: str,
|
||||
type: ShadertoyUniformType,
|
||||
value: bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf,
|
||||
):
|
||||
if not self._program:
|
||||
raise RuntimeError("the shader program is not initialized")
|
||||
GL.glUseProgram(self._program)
|
||||
location = GL.glGetUniformLocation(self._program, name)
|
||||
match type:
|
||||
case ShadertoyUniformType.VECTOR:
|
||||
value = cast(tuple[float, ...], value)
|
||||
(
|
||||
GL.glUniform2f
|
||||
if (vlen := len(value)) == 2
|
||||
else GL.glUniform3f
|
||||
if vlen == 3
|
||||
else GL.glUniform4f
|
||||
)(location, *value)
|
||||
case ShadertoyUniformType.FLOAT:
|
||||
GL.glUniform1f(location, value)
|
||||
case ShadertoyUniformType.INTEGER:
|
||||
GL.glUniform1i(location, value)
|
||||
case ShadertoyUniformType.TEXTURE:
|
||||
|
||||
value = cast(GdkPixbuf.Pixbuf, value).flip(False)
|
||||
format = GL.GL_RGBA if value.get_has_alpha() else GL.GL_RGB
|
||||
|
||||
if name not in self._texture_units:
|
||||
texture = GL.glGenTextures(1)
|
||||
self._texture_units[name] = (len(self._texture_units), texture)
|
||||
else:
|
||||
texture_unit, texture = self._texture_units[name]
|
||||
|
||||
texture_unit = self._texture_units[name][0]
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0 + texture_unit)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, texture)
|
||||
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_REPEAT)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_REPEAT)
|
||||
GL.glTexParameteri(
|
||||
GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR
|
||||
)
|
||||
GL.glTexParameteri(
|
||||
GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR
|
||||
)
|
||||
|
||||
GL.glTexImage2D(
|
||||
GL.GL_TEXTURE_2D,
|
||||
0,
|
||||
format,
|
||||
value.get_width(),
|
||||
value.get_height(),
|
||||
0,
|
||||
format,
|
||||
GL.GL_UNSIGNED_BYTE,
|
||||
value.get_pixels(),
|
||||
)
|
||||
GL.glGenerateMipmap(GL.GL_TEXTURE_2D)
|
||||
|
||||
GL.glUniform1i(location, texture_unit)
|
||||
@@ -0,0 +1,154 @@
|
||||
import subprocess
|
||||
|
||||
from fabric.utils.helpers import exec_shell_command_async
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
|
||||
|
||||
class Systemprofiles(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="systemprofiles",
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
spacing=3,
|
||||
)
|
||||
|
||||
if data.BAR_THEME == "Dense" or data.BAR_THEME == "Edge":
|
||||
self.add_style_class("invert")
|
||||
|
||||
self.bat_save = None
|
||||
self.bat_balanced = None
|
||||
self.bat_perf = None
|
||||
|
||||
children = []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["powerprofilesctl", "list"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
available_profiles = result.stdout
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
available_profiles = ""
|
||||
|
||||
if "power-saver" in available_profiles:
|
||||
self.bat_save = Button(
|
||||
name="battery-save",
|
||||
child=Label(name="battery-save-label", markup=icons.power_saving),
|
||||
on_clicked=lambda *_: self.set_power_mode("power-saver"),
|
||||
tooltip_text="Power saving mode",
|
||||
)
|
||||
children.append(self.bat_save)
|
||||
|
||||
if "balanced" in available_profiles:
|
||||
self.bat_balanced = Button(
|
||||
name="battery-balanced",
|
||||
child=Label(name="battery-balanced-label", markup=icons.power_balanced),
|
||||
on_clicked=lambda *_: self.set_power_mode("balanced"),
|
||||
tooltip_text="Balanced mode",
|
||||
)
|
||||
children.append(self.bat_balanced)
|
||||
|
||||
if "performance" in available_profiles:
|
||||
self.bat_perf = Button(
|
||||
name="battery-performance",
|
||||
child=Label(
|
||||
name="battery-performance-label", markup=icons.power_performance
|
||||
),
|
||||
on_clicked=lambda *_: self.set_power_mode("performance"),
|
||||
tooltip_text="Performance mode",
|
||||
)
|
||||
children.append(self.bat_perf)
|
||||
|
||||
# Group the mode buttons into a container.
|
||||
if children:
|
||||
self.add(
|
||||
Box(
|
||||
name="power-mode-switcher",
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
spacing=4,
|
||||
children=children,
|
||||
)
|
||||
)
|
||||
|
||||
if data.BAR_COMPONENTS_VISIBILITY.get("sysprofiles", False):
|
||||
self.get_current_power_mode()
|
||||
self.hide_timer = None
|
||||
self.hover_counter = 0
|
||||
# self.set_power_mode("balanced")
|
||||
|
||||
def get_current_power_mode(self):
|
||||
try:
|
||||
# Run the command to get the current power mode
|
||||
result = subprocess.run(
|
||||
["powerprofilesctl", "get"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Get the output and strip unnecessary whitespace
|
||||
output = result.stdout.strip()
|
||||
|
||||
# Validate the output
|
||||
if output in ["power-saver", "balanced", "performance"]:
|
||||
self.current_mode = output
|
||||
else:
|
||||
self.current_mode = "balanced"
|
||||
|
||||
# Update button styles based on the current mode
|
||||
self.update_button_styles()
|
||||
|
||||
except subprocess.CalledProcessError as err:
|
||||
print(f"Command failed: {err}")
|
||||
self.current_mode = "balanced"
|
||||
|
||||
except Exception as err:
|
||||
print(f"Error retrieving current power mode: {err}")
|
||||
self.current_mode = "balanced"
|
||||
|
||||
def set_power_mode(self, mode):
|
||||
"""
|
||||
Switches power mode by running the corresponding auto-cpufreq command.
|
||||
mode: one of 'powersave', 'balanced', or 'performance'
|
||||
"""
|
||||
commands = {
|
||||
"power-saver": "powerprofilesctl set power-saver",
|
||||
"balanced": "powerprofilesctl set balanced",
|
||||
"performance": "powerprofilesctl set performance",
|
||||
}
|
||||
if mode in commands:
|
||||
try:
|
||||
exec_shell_command_async(commands[mode])
|
||||
self.current_mode = mode
|
||||
self.update_button_styles()
|
||||
except Exception as err:
|
||||
# Optionally, handle errors or display a notification.
|
||||
print(f"Error setting power mode: {err}")
|
||||
|
||||
def update_button_styles(self):
|
||||
"""
|
||||
Optionally updates button styles to reflect the current mode.
|
||||
Adjust the styling method based on your toolkit's capabilities.
|
||||
"""
|
||||
if self.bat_save:
|
||||
self.bat_save.remove_style_class("active")
|
||||
if self.bat_balanced:
|
||||
self.bat_balanced.remove_style_class("active")
|
||||
if self.bat_perf:
|
||||
self.bat_perf.remove_style_class("active")
|
||||
|
||||
if self.current_mode == "power-saver" and self.bat_save:
|
||||
self.bat_save.add_style_class("active")
|
||||
elif self.current_mode == "balanced" and self.bat_balanced:
|
||||
self.bat_balanced.add_style_class("active")
|
||||
elif self.current_mode == "performance" and self.bat_perf:
|
||||
self.bat_perf.add_style_class("active")
|
||||
@@ -0,0 +1,154 @@
|
||||
import gi
|
||||
|
||||
gi.require_version("Gray", "0.1")
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, Gray, Gtk
|
||||
|
||||
import config.data as data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SystemTray(Box):
|
||||
def __init__(self, pixel_size: int = 20, **kwargs) -> None:
|
||||
orientation = Gtk.Orientation.HORIZONTAL if not data.VERTICAL else Gtk.Orientation.VERTICAL
|
||||
super().__init__(
|
||||
name="systray",
|
||||
orientation=orientation,
|
||||
spacing=8,
|
||||
**kwargs
|
||||
)
|
||||
self.enabled = True
|
||||
super().set_visible(False)
|
||||
self.pixel_size = pixel_size
|
||||
self.buttons_by_id = {}
|
||||
self.items_by_id = {}
|
||||
|
||||
self.watcher = Gray.Watcher()
|
||||
self.watcher.connect("item-added", self.on_watcher_item_added)
|
||||
|
||||
def set_visible(self, visible: bool):
|
||||
self.enabled = visible
|
||||
self._update_visibility()
|
||||
|
||||
def _update_visibility(self):
|
||||
has = len(self.get_children()) > 0
|
||||
super().set_visible(self.enabled and has)
|
||||
|
||||
def _get_item_pixbuf(self, item: Gray.Item) -> GdkPixbuf.Pixbuf:
|
||||
try:
|
||||
pm = Gray.get_pixmap_for_pixmaps(item.get_icon_pixmaps(), self.pixel_size)
|
||||
if pm:
|
||||
return pm.as_pixbuf(self.pixel_size, GdkPixbuf.InterpType.HYPER)
|
||||
|
||||
name = item.get_icon_name()
|
||||
# If IconName is a file path, prioritize loading directly from the file
|
||||
if name and os.path.exists(name):
|
||||
try:
|
||||
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
name, self.pixel_size, self.pixel_size, True
|
||||
)
|
||||
except Exception as e:
|
||||
# The file path exists but loading fails, falling back to theme search
|
||||
logger.debug(
|
||||
f"Load icon from file failed: {e}; fallback to theme for '{name}'"
|
||||
)
|
||||
|
||||
theme = Gtk.IconTheme.new()
|
||||
path = item.get_icon_theme_path()
|
||||
if path:
|
||||
theme.prepend_search_path(path)
|
||||
return theme.load_icon(name, self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE)
|
||||
except GLib.Error as e:
|
||||
logger.debug(f"Icon load error {e}")
|
||||
return Gtk.IconTheme.get_default().load_icon(
|
||||
"image-missing", self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE
|
||||
)
|
||||
|
||||
def _refresh_item_ui(self, item: Gray.Item, button: Gtk.Button):
|
||||
pixbuf = self._get_item_pixbuf(item)
|
||||
img = button.get_image()
|
||||
if isinstance(img, Gtk.Image):
|
||||
img.set_from_pixbuf(pixbuf)
|
||||
else:
|
||||
new = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
button.set_image(new)
|
||||
new.show()
|
||||
tip = None
|
||||
if hasattr(item, 'get_tooltip_text'):
|
||||
tip = item.get_tooltip_text()
|
||||
elif hasattr(item, 'get_title'):
|
||||
tip = item.get_title()
|
||||
if tip:
|
||||
button.set_tooltip_text(tip)
|
||||
else:
|
||||
button.set_has_tooltip(False)
|
||||
|
||||
def on_watcher_item_added(self, _, identifier: str):
|
||||
item = self.watcher.get_item_for_identifier(identifier)
|
||||
if not item:
|
||||
return
|
||||
|
||||
if identifier in self.buttons_by_id:
|
||||
self.buttons_by_id[identifier].destroy()
|
||||
del self.buttons_by_id[identifier]
|
||||
del self.items_by_id[identifier]
|
||||
|
||||
btn = self.do_bake_item_button(item)
|
||||
self.buttons_by_id[identifier] = btn
|
||||
self.items_by_id[identifier] = item
|
||||
|
||||
item.connect("notify::icon-pixmaps",
|
||||
lambda itm, pspec: self._refresh_item_ui(itm, btn))
|
||||
item.connect("notify::icon-name",
|
||||
lambda itm, pspec: self._refresh_item_ui(itm, btn))
|
||||
|
||||
try:
|
||||
item.connect("icon-changed", lambda itm: self._refresh_item_ui(itm, btn))
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
item.connect("removed", lambda itm: self.on_item_instance_removed(identifier, itm))
|
||||
|
||||
self.add(btn)
|
||||
btn.show_all()
|
||||
self._update_visibility()
|
||||
|
||||
def do_bake_item_button(self, item: Gray.Item) -> Gtk.Button:
|
||||
btn = Gtk.Button()
|
||||
btn.connect("button-press-event", lambda b, e: self.on_button_click(b, item, e))
|
||||
img = Gtk.Image.new_from_pixbuf(self._get_item_pixbuf(item))
|
||||
btn.set_image(img)
|
||||
tip = item.get_tooltip_text() if hasattr(item, 'get_tooltip_text') else getattr(item, 'get_title', lambda: None)()
|
||||
if tip:
|
||||
btn.set_tooltip_text(tip)
|
||||
return btn
|
||||
|
||||
def on_item_instance_removed(self, identifier: str, removed_item: Gray.Item):
|
||||
if self.items_by_id.get(identifier) is removed_item:
|
||||
btn = self.buttons_by_id.pop(identifier, None)
|
||||
self.items_by_id.pop(identifier, None)
|
||||
if btn:
|
||||
btn.destroy()
|
||||
self._update_visibility()
|
||||
|
||||
def on_button_click(self, button: Gtk.Button, item: Gray.Item, event: Gdk.EventButton):
|
||||
if event.button == Gdk.BUTTON_PRIMARY:
|
||||
try:
|
||||
item.activate(int(event.x_root), int(event.y_root))
|
||||
except Exception as e:
|
||||
logger.error(f"Activate error: {e}")
|
||||
elif event.button == Gdk.BUTTON_SECONDARY:
|
||||
menu = getattr(item, 'get_menu', lambda: None)()
|
||||
if isinstance(menu, Gtk.Menu):
|
||||
menu.popup_at_widget(button, Gdk.Gravity.SOUTH_WEST,
|
||||
Gdk.Gravity.NORTH_WEST, event)
|
||||
else:
|
||||
cm = getattr(item, 'context_menu', None)
|
||||
if cm:
|
||||
try:
|
||||
cm(int(event.x_root), int(event.y_root))
|
||||
except Exception as e:
|
||||
logger.error(f"ContextMenu error: {e}")
|
||||
@@ -0,0 +1,553 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from fabric.utils import exec_shell_command_async, idle_add, remove_handler
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.entry import Entry
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
|
||||
|
||||
class TmuxManager(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="tmux-manager",
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.notch = kwargs["notch"]
|
||||
self.selected_index = -1 # Track the selected item index
|
||||
|
||||
self._arranger_handler: int = 0
|
||||
|
||||
self.viewport = Box(name="viewport", spacing=4, orientation="v")
|
||||
self.session_name_entry = Entry(
|
||||
name="session-name-entry",
|
||||
placeholder="Create Tmux Session...",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
on_activate=lambda entry, *_: self.create_session(entry.get_text()),
|
||||
on_key_press_event=self.on_entry_key_press,
|
||||
)
|
||||
self.session_name_entry.props.xalign = 0.5
|
||||
self.scrolled_window = ScrolledWindow(
|
||||
name="scrolled-window",
|
||||
spacing=10,
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
h_align="fill",
|
||||
v_align="fill",
|
||||
child=self.viewport,
|
||||
propagate_width=False,
|
||||
propagate_height=False,
|
||||
)
|
||||
|
||||
self.header_box = Box(
|
||||
name="header_box",
|
||||
spacing=10,
|
||||
orientation="h",
|
||||
children=[
|
||||
Button(
|
||||
name="new-session-button",
|
||||
child=Label(name="new-session-label", markup=icons.add),
|
||||
tooltip_text="Create New Session",
|
||||
on_clicked=lambda *_: self.create_session(self.session_name_entry.get_text()),
|
||||
),
|
||||
self.session_name_entry,
|
||||
Button(
|
||||
name="close-button",
|
||||
child=Label(name="close-label", markup=icons.cancel),
|
||||
tooltip_text="Exit",
|
||||
on_clicked=lambda *_: self.close_manager()
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self.tmux_box = Box(
|
||||
name="tmux-box",
|
||||
spacing=10,
|
||||
h_expand=True,
|
||||
orientation="v",
|
||||
children=[
|
||||
self.header_box,
|
||||
self.scrolled_window,
|
||||
],
|
||||
)
|
||||
|
||||
self.add(self.tmux_box)
|
||||
self.show_all()
|
||||
|
||||
def close_manager(self):
|
||||
"""Close the tmux manager"""
|
||||
self.viewport.children = []
|
||||
self.selected_index = -1 # Reset selection
|
||||
self.notch.close_notch()
|
||||
|
||||
def open_manager(self):
|
||||
"""Open the tmux manager and refresh sessions"""
|
||||
self.refresh_sessions()
|
||||
self.session_name_entry.set_text("")
|
||||
self.session_name_entry.grab_focus()
|
||||
|
||||
def refresh_sessions(self):
|
||||
"""Get tmux sessions and populate the viewport"""
|
||||
remove_handler(self._arranger_handler) if self._arranger_handler else None
|
||||
self.viewport.children = []
|
||||
self.selected_index = -1 # Clear selection when viewport changes
|
||||
|
||||
# Get tmux sessions
|
||||
sessions = self.get_tmux_sessions()
|
||||
if not sessions:
|
||||
# Create a container box to better center the message
|
||||
container = Box(
|
||||
name="no-tmux-container",
|
||||
orientation="v",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
v_expand=True
|
||||
)
|
||||
|
||||
# Show a message if no sessions
|
||||
label = Label(
|
||||
name="no-tmux",
|
||||
markup=icons.terminal,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
container.add(label)
|
||||
|
||||
self.viewport.add(container)
|
||||
return
|
||||
|
||||
# Add session slots to viewport
|
||||
for session in sessions:
|
||||
self.viewport.add(self.create_session_slot(session))
|
||||
|
||||
def get_tmux_sessions(self):
|
||||
"""Get list of tmux sessions"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "list-sessions", "-F", "#{session_name}"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()]
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Error getting tmux sessions: {e}")
|
||||
return []
|
||||
|
||||
def create_session_slot(self, session_name):
|
||||
"""Create a button for a tmux session"""
|
||||
# Create an entry for inline editing (initially hidden)
|
||||
name_entry = Entry(
|
||||
name="session-name-entry",
|
||||
text=session_name,
|
||||
visible=False,
|
||||
on_activate=lambda entry, *_: self.finish_rename(button, session_name, entry),
|
||||
on_key_press_event=self.on_rename_key_press,
|
||||
)
|
||||
|
||||
# Create the label showing the session name
|
||||
name_label = Label(
|
||||
name="app-label",
|
||||
label=session_name,
|
||||
ellipsization="end",
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
)
|
||||
|
||||
# Session slot content box
|
||||
slot_box = Box(
|
||||
name="slot-box",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[
|
||||
Label(
|
||||
name="tmux-icon",
|
||||
markup=icons.terminal, # Use existing terminal icon
|
||||
),
|
||||
name_label,
|
||||
name_entry,
|
||||
],
|
||||
)
|
||||
|
||||
button = Button(
|
||||
name="slot-button", # reuse existing CSS styling
|
||||
child=slot_box,
|
||||
tooltip_text=f"Attach to session: {session_name}",
|
||||
on_clicked=lambda *_: self.attach_to_session(session_name),
|
||||
can_focus=True, # Ensure the button can receive focus
|
||||
)
|
||||
|
||||
# Add double-click handler to start renaming
|
||||
button.connect("button-press-event", self.on_session_click, session_name, name_label, name_entry)
|
||||
|
||||
# Add key press handler for 'r' to rename
|
||||
button.connect("key-press-event", self.on_slot_key_press, session_name, name_label, name_entry)
|
||||
|
||||
# Store reference to entry and label in button for later access
|
||||
button.name_entry = name_entry
|
||||
button.name_label = name_label
|
||||
button.session_name = session_name
|
||||
|
||||
return button
|
||||
|
||||
def on_session_click(self, button, event, session_name, label, entry):
|
||||
"""Handle clicks on session buttons"""
|
||||
# Handle double-click to rename
|
||||
if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS and event.button == 1:
|
||||
self.start_rename(button, session_name, label, entry)
|
||||
return True
|
||||
# Handle right click for context menu
|
||||
elif event.button == 3:
|
||||
menu = Gtk.Menu()
|
||||
|
||||
# Rename option
|
||||
rename_item = Gtk.MenuItem(label="Rename")
|
||||
rename_item.connect("activate", lambda _: self.start_rename(button, session_name, label, entry))
|
||||
menu.append(rename_item)
|
||||
|
||||
# Kill option
|
||||
kill_item = Gtk.MenuItem(label="Kill Session")
|
||||
kill_item.connect("activate", lambda _: self.kill_session(session_name))
|
||||
menu.append(kill_item)
|
||||
|
||||
menu.show_all()
|
||||
menu.popup_at_pointer(event)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def start_rename(self, button, session_name, label, entry):
|
||||
"""Start inline renaming of a session"""
|
||||
# Hide label, show entry
|
||||
label.set_visible(False)
|
||||
entry.set_visible(True)
|
||||
|
||||
# Focus entry and select all text
|
||||
entry.grab_focus()
|
||||
entry.select_region(0, -1)
|
||||
|
||||
# Mark button as being edited
|
||||
button.get_style_context().add_class("editing")
|
||||
|
||||
def finish_rename(self, button, old_name, entry):
|
||||
"""Finish renaming a session"""
|
||||
new_name = entry.get_text().strip()
|
||||
|
||||
# Only rename if the name changed and isn't empty
|
||||
if new_name and new_name != old_name:
|
||||
self.rename_session(old_name, new_name)
|
||||
|
||||
# Reset UI state
|
||||
self.cancel_rename(button)
|
||||
|
||||
def cancel_rename(self, button):
|
||||
"""Cancel renaming operation"""
|
||||
# Restore original view
|
||||
button.name_entry.set_visible(False)
|
||||
button.name_label.set_visible(True)
|
||||
|
||||
# Remove editing style
|
||||
button.get_style_context().remove_class("editing")
|
||||
|
||||
# Return focus to session name entry
|
||||
self.session_name_entry.grab_focus()
|
||||
|
||||
def on_rename_key_press(self, entry, event):
|
||||
"""Handle key presses in the rename entry"""
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
# Find the parent button
|
||||
parent = entry.get_parent()
|
||||
while parent and not isinstance(parent, Button):
|
||||
parent = parent.get_parent()
|
||||
|
||||
if parent:
|
||||
self.cancel_rename(parent)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def on_session_right_click(self, button, event, session_name):
|
||||
"""Handle right-click on a session button to show context menu"""
|
||||
if event.button == 3: # Right click
|
||||
menu = Gtk.Menu()
|
||||
|
||||
# Rename option
|
||||
rename_item = Gtk.MenuItem(label="Rename")
|
||||
rename_item.connect("activate", lambda _: self.start_rename(
|
||||
button,
|
||||
session_name,
|
||||
button.name_label,
|
||||
button.name_entry
|
||||
))
|
||||
menu.append(rename_item)
|
||||
|
||||
# Kill option
|
||||
kill_item = Gtk.MenuItem(label="Kill Session")
|
||||
kill_item.connect("activate", lambda _: self.kill_session(session_name))
|
||||
menu.append(kill_item)
|
||||
|
||||
menu.show_all()
|
||||
menu.popup_at_pointer(event)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def on_entry_key_press(self, widget, event):
|
||||
"""Handle key press events in the entry"""
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self.close_manager()
|
||||
return True
|
||||
|
||||
# Custom navigation with UP/DOWN keys removed
|
||||
return False
|
||||
|
||||
def scroll_to_selected(self, button):
|
||||
"""Scroll to ensure the selected button is visible"""
|
||||
def scroll():
|
||||
adj = self.scrolled_window.get_vadjustment()
|
||||
alloc = button.get_allocation()
|
||||
if alloc.height == 0:
|
||||
return False # Retry if allocation isn't ready
|
||||
|
||||
y = alloc.y
|
||||
height = alloc.height
|
||||
page_size = adj.get_page_size()
|
||||
current_value = adj.get_value()
|
||||
|
||||
# Calculate visible boundaries
|
||||
visible_top = current_value
|
||||
visible_bottom = current_value + page_size
|
||||
|
||||
if y < visible_top:
|
||||
# Item above viewport - align to top
|
||||
adj.set_value(y)
|
||||
elif y + height > visible_bottom:
|
||||
# Item below viewport - align to bottom
|
||||
new_value = y + height - page_size
|
||||
adj.set_value(new_value)
|
||||
# No action if already fully visible
|
||||
return False
|
||||
GLib.idle_add(scroll)
|
||||
|
||||
def create_session(self, session_name):
|
||||
"""Create a new tmux session"""
|
||||
if not session_name:
|
||||
# Get existing session names
|
||||
existing_sessions = self.get_tmux_sessions()
|
||||
|
||||
# Find the next available number
|
||||
counter = 0
|
||||
while str(counter) in existing_sessions:
|
||||
counter += 1
|
||||
|
||||
session_name = str(counter)
|
||||
|
||||
try:
|
||||
# Clean the session name (replace spaces with underscores)
|
||||
clean_name = session_name.strip().replace(" ", "_")
|
||||
|
||||
# Create session
|
||||
subprocess.run(
|
||||
["tmux", "new-session", "-d", "-s", clean_name],
|
||||
check=True
|
||||
)
|
||||
|
||||
# Refresh the session list
|
||||
self.refresh_sessions()
|
||||
|
||||
# Clear entry
|
||||
self.session_name_entry.set_text("")
|
||||
|
||||
# Launch a terminal and attach to this session
|
||||
terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {clean_name}")
|
||||
exec_shell_command_async(terminal_cmd)
|
||||
|
||||
# Close manager
|
||||
self.close_manager()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating tmux session: {e}")
|
||||
|
||||
def attach_to_session(self, session_name):
|
||||
"""Attach to an existing tmux session"""
|
||||
try:
|
||||
# Launch a terminal and attach to this session
|
||||
terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {session_name}")
|
||||
exec_shell_command_async(terminal_cmd)
|
||||
self.close_manager()
|
||||
except Exception as e:
|
||||
print(f"Error attaching to tmux session: {e}")
|
||||
|
||||
def get_terminal_command(self, cmd):
|
||||
"""Get terminal command based on configured terminal or available terminals"""
|
||||
# First try to use the configured terminal command
|
||||
if hasattr(data, 'TERMINAL_COMMAND') and data.TERMINAL_COMMAND:
|
||||
parts = data.TERMINAL_COMMAND.split()
|
||||
terminal = parts[0]
|
||||
|
||||
try:
|
||||
# Check if the configured terminal is available
|
||||
subprocess.run(["which", terminal], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return f"{data.TERMINAL_COMMAND} {cmd}"
|
||||
except subprocess.CalledProcessError:
|
||||
# If configured terminal is not available, fall back to defaults
|
||||
pass
|
||||
|
||||
# Fallback to checking available terminals
|
||||
terminals = [
|
||||
("kitty", f"kitty -e {cmd}"),
|
||||
("alacritty", f"alacritty -e {cmd}"),
|
||||
("foot", f"foot {cmd}"),
|
||||
("gnome-terminal", f"gnome-terminal -- {cmd}"),
|
||||
("konsole", f"konsole -e {cmd}"),
|
||||
("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"),
|
||||
]
|
||||
|
||||
for term, term_cmd in terminals:
|
||||
try:
|
||||
# Check if terminal is available
|
||||
subprocess.run(["which", term], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return term_cmd
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
|
||||
# Default fallback
|
||||
return f"kitty -e {cmd}"
|
||||
|
||||
def rename_session_dialog(self, old_name):
|
||||
"""Show dialog to rename a session"""
|
||||
dialog = Gtk.Dialog(
|
||||
title="Rename Session",
|
||||
transient_for=None,
|
||||
flags=0
|
||||
)
|
||||
|
||||
dialog.add_buttons(
|
||||
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_OK, Gtk.ResponseType.OK
|
||||
)
|
||||
|
||||
content_area = dialog.get_content_area()
|
||||
entry = Gtk.Entry()
|
||||
entry.set_text(old_name)
|
||||
entry.set_activates_default(True)
|
||||
content_area.add(entry)
|
||||
|
||||
dialog.set_default_response(Gtk.ResponseType.OK)
|
||||
dialog.show_all()
|
||||
|
||||
response = dialog.run()
|
||||
if response == Gtk.ResponseType.OK:
|
||||
new_name = entry.get_text()
|
||||
if new_name and new_name != old_name:
|
||||
self.rename_session(old_name, new_name)
|
||||
|
||||
dialog.destroy()
|
||||
|
||||
def rename_session(self, old_name, new_name):
|
||||
"""Rename a tmux session"""
|
||||
try:
|
||||
# Clean the session name (replace spaces with underscores)
|
||||
clean_name = new_name.strip().replace(" ", "_")
|
||||
|
||||
# Rename session
|
||||
subprocess.run(
|
||||
["tmux", "rename-session", "-t", old_name, clean_name],
|
||||
check=True
|
||||
)
|
||||
|
||||
# Refresh the session list
|
||||
self.refresh_sessions()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error renaming tmux session: {e}")
|
||||
|
||||
def kill_session(self, session_name):
|
||||
"""Kill a tmux session"""
|
||||
try:
|
||||
# Kill session
|
||||
subprocess.run(
|
||||
["tmux", "kill-session", "-t", session_name],
|
||||
check=True
|
||||
)
|
||||
|
||||
# Refresh the session list
|
||||
self.refresh_sessions()
|
||||
|
||||
# Close the notch after killing session
|
||||
self.close_manager()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error killing tmux session: {e}")
|
||||
|
||||
# Add new method to handle key presses on session slots
|
||||
def on_slot_key_press(self, button, event, session_name, label, entry):
|
||||
"""Handle key presses on session buttons"""
|
||||
# Print debugging info
|
||||
print(f"Key pressed: {event.keyval}, State: {event.state}")
|
||||
|
||||
# Check if 'r' key was pressed for renaming
|
||||
if event.keyval == Gdk.KEY_r:
|
||||
self.start_rename(button, session_name, label, entry)
|
||||
return True
|
||||
# Check for 'K' (capital K) which indicates Shift is pressed
|
||||
elif event.keyval == Gdk.KEY_K:
|
||||
print("Shift+K detected - killing session without confirmation")
|
||||
self.kill_session(session_name)
|
||||
return True
|
||||
# Check for lowercase 'k'
|
||||
elif event.keyval == Gdk.KEY_k:
|
||||
print("Regular k detected - showing confirmation")
|
||||
self.show_kill_confirmation_menu(button, session_name)
|
||||
return True
|
||||
# Check if Delete key was pressed for killing session
|
||||
elif event.keyval == Gdk.KEY_Delete:
|
||||
self.show_kill_confirmation_menu(button, session_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def show_kill_confirmation_menu(self, button, session_name):
|
||||
"""Show a confirmation menu for killing a session"""
|
||||
menu = Gtk.Menu()
|
||||
|
||||
# Confirmation message as a disabled menu item
|
||||
msg_item = Gtk.MenuItem(label=f"Kill session '{session_name}'?")
|
||||
msg_item.set_sensitive(False)
|
||||
menu.append(msg_item)
|
||||
|
||||
# Separator
|
||||
menu.append(Gtk.SeparatorMenuItem())
|
||||
|
||||
# Confirm option
|
||||
confirm_item = Gtk.MenuItem(label="Confirm")
|
||||
confirm_item.connect("activate", lambda _: self.kill_session(session_name))
|
||||
menu.append(confirm_item)
|
||||
|
||||
# Cancel option
|
||||
cancel_item = Gtk.MenuItem(label="Cancel")
|
||||
# Close notch on cancel
|
||||
cancel_item.connect("activate", lambda _: self.close_manager())
|
||||
menu.append(cancel_item)
|
||||
|
||||
menu.show_all()
|
||||
|
||||
# Show the menu positioned at the button
|
||||
menu.popup_at_widget(
|
||||
button,
|
||||
Gdk.Gravity.SOUTH_WEST,
|
||||
Gdk.Gravity.NORTH_WEST,
|
||||
None
|
||||
)
|
||||
@@ -0,0 +1,456 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from fabric.utils.helpers import exec_shell_command_async, get_relative_path
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import Gdk, GLib
|
||||
from loguru import logger
|
||||
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
|
||||
SCREENSHOT_SCRIPT = get_relative_path("../scripts/screenshot.sh")
|
||||
POMODORO_SCRIPT = get_relative_path("../scripts/pomodoro.sh")
|
||||
OCR_SCRIPT = get_relative_path("../scripts/ocr.sh")
|
||||
GAMEMODE_SCRIPT = get_relative_path("../scripts/gamemode.sh")
|
||||
SCREENRECORD_SCRIPT = get_relative_path("../scripts/screenrecord.sh")
|
||||
|
||||
# Tooltips
|
||||
## Screenshot
|
||||
tooltip_ssregion = """<b><u>Region Screenshot</u></b>
|
||||
<b>Left Click:</b> Take a screenshot of a selected region.
|
||||
<b>Right Click:</b> Take a mockup screenshot of a selected region."""
|
||||
|
||||
tooltip_ssfull = """<b><u>Screenshot</u></b>
|
||||
<b>Left Click:</b> Take a fullscreen screenshot.
|
||||
<b>Right Click:</b> Take a mockup fullscreen screenshot."""
|
||||
|
||||
tooltip_sswindow = """<b><u>Window Screenshot</u></b>
|
||||
<b>Left Click:</b> Take a screenshot of the active window.
|
||||
<b>Right Click:</b> Take a mockup screenshot of the active window."""
|
||||
|
||||
tooltip_screenshots = "<b>Screenshots Directory</b>"
|
||||
|
||||
tooltip_screenrecord = "<b>Screen Recorder</b>"
|
||||
tooltip_recordings = "<b>Recordings Directory</b>"
|
||||
|
||||
tooltip_ocr = "<b>OCR</b>"
|
||||
tooltip_colorpicker = """<b><u>Color Picker</u></b>
|
||||
<b>Mouse:</b>
|
||||
Left Click: HEX
|
||||
Middle Click: HSV
|
||||
Right Click: RGB
|
||||
|
||||
<b>Keyboard:</b>
|
||||
Enter: HEX
|
||||
Shift+Enter: RGB
|
||||
Ctrl+Enter: HSV"""
|
||||
|
||||
tooltip_gamemode = "<b>Game Mode</b>\nDisables effects and window animations for better performance."
|
||||
tooltip_pomodoro = "<b>Pomodoro Timer</b>"
|
||||
tooltip_emoji = "<b>Emoji Picker</b>"
|
||||
|
||||
|
||||
class Toolbox(Box):
|
||||
def __init__(self, **kwargs):
|
||||
orientation = "h"
|
||||
if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]):
|
||||
orientation = "v"
|
||||
|
||||
super().__init__(
|
||||
name="toolbox",
|
||||
orientation=orientation,
|
||||
spacing=4,
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
visible=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.notch = kwargs["notch"]
|
||||
|
||||
self.btn_ssregion = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_ssregion,
|
||||
child=Label(name="button-label", markup=icons.ssregion),
|
||||
on_clicked=self.ssregion,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.btn_ssregion.set_can_focus(True)
|
||||
self.btn_ssregion.connect("button-press-event", self.on_ssregion_click)
|
||||
self.btn_ssregion.connect("key-press-event", self.on_ssregion_key)
|
||||
|
||||
self.btn_ssfull = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_ssfull,
|
||||
child=Label(name="button-label", markup=icons.ssfull),
|
||||
on_clicked=self.ssfull,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_ssfull.set_can_focus(True)
|
||||
self.btn_ssfull.connect("button-press-event", self.on_ssfull_click)
|
||||
self.btn_ssfull.connect("key-press-event", self.on_ssfull_key)
|
||||
|
||||
self.btn_sswindow = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_sswindow,
|
||||
child=Label(name="button-label", markup=icons.sswindow),
|
||||
on_clicked=self.sswindow,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_sswindow.set_can_focus(True)
|
||||
self.btn_sswindow.connect("button-press-event", self.on_sswindow_click)
|
||||
self.btn_sswindow.connect("key-press-event", self.on_sswindow_key)
|
||||
|
||||
self.btn_screenrecord = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_screenrecord,
|
||||
child=Label(name="button-label", markup=icons.screenrecord),
|
||||
on_clicked=self.screenrecord,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_ocr = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_ocr,
|
||||
child=Label(name="button-label", markup=icons.ocr),
|
||||
on_clicked=self.ocr,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_color = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_colorpicker,
|
||||
child=Label(
|
||||
name="button-bar-label",
|
||||
markup=icons.colorpicker
|
||||
),
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_gamemode = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_gamemode,
|
||||
child=Label(name="button-label", markup=icons.gamemode),
|
||||
on_clicked=self.gamemode,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_pomodoro = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_pomodoro,
|
||||
child=Label(name="button-label", markup=icons.timer_off),
|
||||
on_clicked=self.pomodoro,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_color.set_can_focus(True)
|
||||
|
||||
self.btn_color.connect("button-press-event", self.colorpicker)
|
||||
self.btn_color.connect("key_press_event", self.colorpicker_key)
|
||||
|
||||
self.btn_emoji = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_emoji,
|
||||
child=Label(name="button-label", markup=icons.emoji),
|
||||
on_clicked=self.emoji,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_screenshots_folder = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_screenshots,
|
||||
child=Label(name="button-label", markup=icons.screenshots),
|
||||
on_clicked=self.open_screenshots_folder,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_recordings_folder = Button(
|
||||
name="toolbox-button",
|
||||
tooltip_markup=tooltip_recordings,
|
||||
child=Label(name="button-label", markup=icons.recordings),
|
||||
on_clicked=self.open_recordings_folder,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.buttons = [
|
||||
self.btn_ssregion,
|
||||
self.btn_sswindow,
|
||||
self.btn_ssfull,
|
||||
self.btn_screenshots_folder,
|
||||
Box(name="tool-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"),
|
||||
self.btn_screenrecord,
|
||||
self.btn_recordings_folder,
|
||||
Box(name="tool-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"),
|
||||
self.btn_ocr,
|
||||
self.btn_color,
|
||||
Box(name="tool-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"),
|
||||
self.btn_gamemode,
|
||||
self.btn_pomodoro,
|
||||
self.btn_emoji,
|
||||
]
|
||||
|
||||
for button in self.buttons:
|
||||
self.add(button)
|
||||
|
||||
self.show_all()
|
||||
|
||||
self.recorder_timer_id = GLib.timeout_add_seconds(2, self.update_screenrecord_state)
|
||||
self.gamemode_updater = GLib.timeout_add_seconds(2, self.gamemode_check)
|
||||
self.pomodoro_updater = GLib.timeout_add_seconds(2, self.pomodoro_check)
|
||||
|
||||
def close_menu(self):
|
||||
self.notch.close_notch()
|
||||
|
||||
def ssfull(self, *args, mockup=False):
|
||||
cmd = f"bash {SCREENSHOT_SCRIPT} p"
|
||||
if mockup:
|
||||
cmd += " mockup"
|
||||
exec_shell_command_async(cmd)
|
||||
self.close_menu()
|
||||
|
||||
def on_ssfull_click(self, button, event):
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
if event.button == 1:
|
||||
self.ssfull()
|
||||
elif event.button == 3:
|
||||
self.ssfull(mockup=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_ssfull_key(self, widget, event):
|
||||
if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
|
||||
modifiers = event.get_state()
|
||||
if modifiers & Gdk.ModifierType.SHIFT_MASK:
|
||||
self.ssfull(mockup=True)
|
||||
else:
|
||||
self.ssfull()
|
||||
return True
|
||||
return False
|
||||
|
||||
def ssregion(self, *args):
|
||||
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} s")
|
||||
self.close_menu()
|
||||
|
||||
def on_ssregion_click(self, button, event):
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
if event.button == 1:
|
||||
self.ssregion()
|
||||
elif event.button == 3:
|
||||
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} s mockup")
|
||||
self.close_menu()
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_ssregion_key(self, widget, event):
|
||||
if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
|
||||
modifiers = event.get_state()
|
||||
if modifiers & Gdk.ModifierType.SHIFT_MASK:
|
||||
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} s mockup")
|
||||
self.close_menu()
|
||||
else:
|
||||
self.ssregion()
|
||||
return True
|
||||
return False
|
||||
|
||||
def sswindow(self, *args):
|
||||
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} w")
|
||||
self.close_menu()
|
||||
|
||||
def on_sswindow_click(self, button, event):
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
if event.button == 1:
|
||||
self.sswindow()
|
||||
elif event.button == 3:
|
||||
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} w mockup")
|
||||
self.close_menu()
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_sswindow_key(self, widget, event):
|
||||
if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
|
||||
modifiers = event.get_state()
|
||||
if modifiers & Gdk.ModifierType.SHIFT_MASK:
|
||||
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} w mockup")
|
||||
self.close_menu()
|
||||
else:
|
||||
self.sswindow()
|
||||
return True
|
||||
return False
|
||||
|
||||
def screenrecord(self, *args):
|
||||
|
||||
exec_shell_command_async(f"bash -c 'nohup bash {SCREENRECORD_SCRIPT} > /dev/null 2>&1 & disown'")
|
||||
self.close_menu()
|
||||
|
||||
def pomodoro(self, *args):
|
||||
exec_shell_command_async(f"bash -c 'nohup bash {POMODORO_SCRIPT} > /dev/null 2>&1 & disown'")
|
||||
self.close_menu()
|
||||
|
||||
def pomodoro_check(self):
|
||||
"""Check pomodoro status using proper background threading"""
|
||||
GLib.Thread.new("pomodoro-check", self._pomodoro_check_thread, None)
|
||||
return True
|
||||
|
||||
def _pomodoro_check_thread(self, user_data):
|
||||
"""Background thread to check pomodoro status"""
|
||||
try:
|
||||
result = subprocess.run("pgrep -f pomodoro.sh", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
running = result.returncode == 0
|
||||
except Exception:
|
||||
running = False
|
||||
|
||||
GLib.idle_add(self._update_pomodoro_ui, running)
|
||||
|
||||
def _update_pomodoro_ui(self, running):
|
||||
"""Update pomodoro UI from main thread"""
|
||||
if running:
|
||||
self.btn_pomodoro.get_child().set_markup(icons.timer_on)
|
||||
self.btn_pomodoro.add_style_class("pomodoro")
|
||||
else:
|
||||
self.btn_pomodoro.get_child().set_markup(icons.timer_off)
|
||||
self.btn_pomodoro.remove_style_class("pomodoro")
|
||||
return False
|
||||
|
||||
def ocr(self, *args):
|
||||
exec_shell_command_async(f"bash {OCR_SCRIPT} s")
|
||||
self.close_menu()
|
||||
|
||||
def gamemode(self, *args):
|
||||
exec_shell_command_async(f"bash {GAMEMODE_SCRIPT}")
|
||||
self.gamemode_check()
|
||||
self.close_menu()
|
||||
|
||||
def gamemode_check(self):
|
||||
"""Check gamemode status using proper background threading"""
|
||||
GLib.Thread.new("gamemode-check", self._gamemode_check_thread, None)
|
||||
return True
|
||||
|
||||
def _gamemode_check_thread(self, user_data):
|
||||
"""Background thread to check gamemode status"""
|
||||
try:
|
||||
result = subprocess.run(f"bash {GAMEMODE_SCRIPT} check", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
enabled = result.stdout == b't\n'
|
||||
except Exception:
|
||||
enabled = False
|
||||
|
||||
GLib.idle_add(self._update_gamemode_ui, enabled)
|
||||
|
||||
def _update_gamemode_ui(self, enabled):
|
||||
"""Update gamemode UI from main thread"""
|
||||
if enabled:
|
||||
self.btn_gamemode.get_child().set_markup(icons.gamemode_off)
|
||||
else:
|
||||
self.btn_gamemode.get_child().set_markup(icons.gamemode)
|
||||
return False
|
||||
|
||||
def colorpicker(self, button, event):
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
cmd = {
|
||||
1: "-hex",
|
||||
2: "-hsv",
|
||||
3: "-rgb"
|
||||
}.get(event.button)
|
||||
|
||||
if cmd:
|
||||
exec_shell_command_async(f"bash {get_relative_path('../scripts/hyprpicker.sh')} {cmd}")
|
||||
self.close_menu()
|
||||
|
||||
def colorpicker_key(self, widget, event):
|
||||
if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
|
||||
modifiers = event.get_state()
|
||||
cmd = "-hex"
|
||||
|
||||
match modifiers & (Gdk.ModifierType.SHIFT_MASK | Gdk.ModifierType.CONTROL_MASK):
|
||||
case Gdk.ModifierType.SHIFT_MASK:
|
||||
cmd = "-rgb"
|
||||
case Gdk.ModifierType.CONTROL_MASK:
|
||||
cmd = "-hsv"
|
||||
|
||||
exec_shell_command_async(f"bash {get_relative_path('../scripts/hyprpicker.sh')} {cmd}")
|
||||
self.close_menu()
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_screenrecord_state(self):
|
||||
"""Check screen recording status using proper background threading"""
|
||||
GLib.Thread.new("screenrecord-check", self._screenrecord_check_thread, None)
|
||||
return True
|
||||
|
||||
def _screenrecord_check_thread(self, user_data):
|
||||
"""Background thread to check screen recording status"""
|
||||
try:
|
||||
result = subprocess.run("pgrep -f gpu-screen-recorder", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
running = result.returncode == 0
|
||||
except Exception:
|
||||
running = False
|
||||
|
||||
GLib.idle_add(self._update_screenrecord_ui, running)
|
||||
|
||||
def _update_screenrecord_ui(self, running):
|
||||
"""Update screen recording UI from main thread"""
|
||||
if running:
|
||||
self.btn_screenrecord.get_child().set_markup(icons.stop)
|
||||
self.btn_screenrecord.add_style_class("recording")
|
||||
else:
|
||||
self.btn_screenrecord.get_child().set_markup(icons.screenrecord)
|
||||
self.btn_screenrecord.remove_style_class("recording")
|
||||
return False
|
||||
|
||||
def open_screenshots_folder(self, *args):
|
||||
screenshots_dir = os.path.join(os.environ.get('XDG_PICTURES_DIR',
|
||||
os.path.expanduser('~/Pictures')),
|
||||
'Screenshots')
|
||||
|
||||
os.makedirs(screenshots_dir, exist_ok=True)
|
||||
exec_shell_command_async(f"xdg-open {screenshots_dir}")
|
||||
self.close_menu()
|
||||
|
||||
def open_recordings_folder(self, *args):
|
||||
recordings_dir = os.path.join(os.environ.get('XDG_VIDEOS_DIR',
|
||||
os.path.expanduser('~/Videos')),
|
||||
'Recordings')
|
||||
|
||||
os.makedirs(recordings_dir, exist_ok=True)
|
||||
exec_shell_command_async(f"xdg-open {recordings_dir}")
|
||||
self.close_menu()
|
||||
|
||||
def emoji(self, *args):
|
||||
self.notch.open_notch("emoji")
|
||||
@@ -0,0 +1,571 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
|
||||
# Insertion for embedded VTE terminal
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Vte", "2.91")
|
||||
from gi.repository import Gdk, GLib, Gtk, Vte
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
|
||||
import config.data as data
|
||||
|
||||
# File locations
|
||||
VERSION_FILE = get_relative_path("../version.json")
|
||||
REMOTE_VERSION_FILE = "/tmp/remote_version.json"
|
||||
REMOTE_URL = "https://raw.githubusercontent.com/Axenide/Ax-Shell/refs/heads/main/version.json"
|
||||
REPO_DIR = get_relative_path("../")
|
||||
|
||||
SNOOZE_FILE_NAME = "updater_snooze.txt"
|
||||
UPDATER_DISABLE_FILE_NAME = "updater_disabled.flag"
|
||||
SNOOZE_DURATION_SECONDS = 8 * 60 * 60 # 8 hours
|
||||
|
||||
# --- Global state for standalone execution control ---
|
||||
_QUIT_GTK_IF_NO_WINDOW_STANDALONE = False
|
||||
|
||||
def get_cache_dir():
|
||||
"""Returns the cache directory path, creating it if necessary."""
|
||||
cache_dir_base = data.CACHE_DIR or os.path.expanduser(f"~/.cache/{data.APP_NAME}")
|
||||
try:
|
||||
os.makedirs(cache_dir_base, exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"Error creating cache directory {cache_dir_base}: {e}")
|
||||
return cache_dir_base
|
||||
|
||||
def get_snooze_file_path():
|
||||
"""
|
||||
Returns the path to the 'snooze' file inside ~/.cache/APP_NAME.
|
||||
"""
|
||||
return os.path.join(get_cache_dir(), SNOOZE_FILE_NAME)
|
||||
|
||||
def get_disable_file_path():
|
||||
"""
|
||||
Returns the path to the 'updater_disabled.flag' file inside ~/.cache/APP_NAME.
|
||||
"""
|
||||
return os.path.join(get_cache_dir(), UPDATER_DISABLE_FILE_NAME)
|
||||
|
||||
|
||||
def fetch_remote_version():
|
||||
"""
|
||||
Downloads the remote version JSON using curl, with timeout and error handling.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["curl", "-sL", "--connect-timeout", "10", REMOTE_URL, "-o", REMOTE_VERSION_FILE],
|
||||
check=False,
|
||||
timeout=15
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("Error: curl timed out while fetching the remote version.")
|
||||
except FileNotFoundError:
|
||||
print("Error: curl not found. Please install curl.")
|
||||
except Exception as e:
|
||||
print(f"Error fetching remote version: {e}")
|
||||
|
||||
|
||||
def get_local_version():
|
||||
"""
|
||||
Reads the local version file and returns (version, changelog).
|
||||
"""
|
||||
if os.path.exists(VERSION_FILE):
|
||||
try:
|
||||
with open(VERSION_FILE, "r") as f:
|
||||
data_content = json.load(f)
|
||||
return data_content.get("version", "0.0.0"), data_content.get("changelog", [])
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error: Invalid JSON in local file: {VERSION_FILE}")
|
||||
return "0.0.0", []
|
||||
except Exception as e:
|
||||
print(f"Error reading local version file {VERSION_FILE}: {e}")
|
||||
return "0.0.0", []
|
||||
return "0.0.0", []
|
||||
|
||||
|
||||
def get_remote_version():
|
||||
"""
|
||||
Reads the downloaded remote file and returns (version, changelog, download_url, pkg_update).
|
||||
"""
|
||||
if os.path.exists(REMOTE_VERSION_FILE):
|
||||
try:
|
||||
with open(REMOTE_VERSION_FILE, "r") as f:
|
||||
data_content = json.load(f)
|
||||
return (
|
||||
data_content.get("version", "0.0.0"),
|
||||
data_content.get("changelog", []),
|
||||
data_content.get("download_url", "#"),
|
||||
data_content.get("pkg_update", True), # Default to True if missing
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error: Invalid JSON in remote file: {REMOTE_VERSION_FILE}")
|
||||
return "0.0.0", [], "#", True
|
||||
except Exception as e:
|
||||
print(f"Error reading remote version file {REMOTE_VERSION_FILE}: {e}")
|
||||
return "0.0.0", [], "#", True
|
||||
return "0.0.0", [], "#", True
|
||||
|
||||
|
||||
def update_local_version_file():
|
||||
"""
|
||||
Replaces the local version with the remote one by moving the downloaded JSON to the local version file.
|
||||
"""
|
||||
if os.path.exists(REMOTE_VERSION_FILE):
|
||||
try:
|
||||
shutil.move(REMOTE_VERSION_FILE, VERSION_FILE)
|
||||
except Exception as e:
|
||||
print(f"Error updating local version file: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def is_connected():
|
||||
"""
|
||||
Checks basic connectivity by attempting to connect to www.google.com:80.
|
||||
"""
|
||||
try:
|
||||
socket.create_connection(("www.google.com", 80), timeout=5)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class UpdateWindow(Gtk.Window):
|
||||
def __init__(self, latest_version, changelog, pkg_update, is_standalone_mode=False):
|
||||
super().__init__(name="update-window", title=f"{data.APP_NAME_CAP} Updater")
|
||||
self.set_default_size(500, 480)
|
||||
self.set_border_width(16)
|
||||
self.set_resizable(False)
|
||||
self.set_position(Gtk.WindowPosition.CENTER)
|
||||
self.set_keep_above(True)
|
||||
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
|
||||
|
||||
self.is_standalone_mode = is_standalone_mode
|
||||
self.quit_gtk_main_on_destroy = False
|
||||
self.pkg_update = pkg_update # Store pkg_update
|
||||
|
||||
# Main vertical container
|
||||
self.main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15)
|
||||
self.add(self.main_vbox)
|
||||
|
||||
# Title
|
||||
title_label = Gtk.Label(name="update-title")
|
||||
title_label.set_markup("<span size='xx-large' weight='bold'>📦 Update Available ✨</span>")
|
||||
title_label.get_style_context().add_class("title-1")
|
||||
self.main_vbox.pack_start(title_label, False, False, 10)
|
||||
|
||||
# Version info text
|
||||
info_label = Gtk.Label(
|
||||
label=f"A new version ({latest_version}) of {data.APP_NAME_CAP} is available."
|
||||
)
|
||||
info_label.set_xalign(0)
|
||||
info_label.set_line_wrap(True)
|
||||
self.main_vbox.pack_start(info_label, False, False, 0)
|
||||
|
||||
# Changelog header
|
||||
changelog_header_label = Gtk.Label()
|
||||
changelog_header_label.set_markup("<b>Changelog:</b>")
|
||||
changelog_header_label.set_xalign(0)
|
||||
self.main_vbox.pack_start(changelog_header_label, False, False, 5)
|
||||
|
||||
# — Scrollable window for the changelog (using Gtk.Label with markup) —
|
||||
scrolled_window = Gtk.ScrolledWindow()
|
||||
scrolled_window.set_hexpand(True)
|
||||
scrolled_window.set_vexpand(True)
|
||||
scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
|
||||
if changelog:
|
||||
# Each entry may already contain Pango tags (<b>, <i>, etc.)
|
||||
joined = "\n".join(f"• {change}" for change in changelog)
|
||||
else:
|
||||
joined = "No specific changes listed for this version."
|
||||
|
||||
self.changelog_label = Gtk.Label()
|
||||
self.changelog_label.set_xalign(0)
|
||||
self.changelog_label.set_yalign(0)
|
||||
self.changelog_label.set_line_wrap(Gtk.WrapMode.WORD_CHAR) # Gtk.WrapMode instead of just True
|
||||
self.changelog_label.set_selectable(False)
|
||||
self.changelog_label.set_markup(joined)
|
||||
|
||||
scrolled_window.add(self.changelog_label)
|
||||
self.main_vbox.pack_start(scrolled_window, True, True, 0)
|
||||
|
||||
# ProgressBar (will be shown if we need to indicate status, although with VTE it remains unused)
|
||||
self.progress_bar = Gtk.ProgressBar()
|
||||
self.progress_bar.set_no_show_all(True)
|
||||
self.progress_bar.set_visible(False)
|
||||
self.main_vbox.pack_start(self.progress_bar, False, False, 5)
|
||||
|
||||
# Button container
|
||||
action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
self.main_vbox.pack_start(action_box, False, False, 10)
|
||||
|
||||
# "Disable/Enable Updater" Button (aligned left)
|
||||
self.toggle_updater_button = Gtk.Button(name="toggle-updater-button")
|
||||
self.toggle_updater_button.connect("clicked", self.on_toggle_updater_clicked)
|
||||
self._update_toggle_updater_button_label() # Set initial label
|
||||
action_box.pack_start(self.toggle_updater_button, False, False, 0)
|
||||
|
||||
# Box for right-aligned buttons
|
||||
right_aligned_buttons_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
right_aligned_buttons_box.set_halign(Gtk.Align.END)
|
||||
action_box.pack_end(right_aligned_buttons_box, True, True, 0) # This box expands
|
||||
|
||||
# Update button (will now show embedded VTE terminal)
|
||||
self.update_button = Gtk.Button(name="update-button", label="Update")
|
||||
self.update_button.get_style_context().add_class("suggested-action")
|
||||
self.update_button.connect("clicked", self.on_update_clicked)
|
||||
right_aligned_buttons_box.pack_end(self.update_button, False, False, 0)
|
||||
|
||||
# 'Later' button
|
||||
self.close_button = Gtk.Button(name="later-button", label="Later")
|
||||
self.close_button.connect("clicked", self.on_later_clicked)
|
||||
right_aligned_buttons_box.pack_end(self.close_button, False, False, 0)
|
||||
|
||||
self.connect("destroy", self.on_window_destroyed)
|
||||
|
||||
# Placeholder for embedded terminal
|
||||
self.terminal_container = None
|
||||
self.vte_terminal = None
|
||||
|
||||
def _update_toggle_updater_button_label(self):
|
||||
disable_file = get_disable_file_path()
|
||||
if os.path.exists(disable_file):
|
||||
self.toggle_updater_button.set_label("Enable Updater")
|
||||
else:
|
||||
self.toggle_updater_button.set_label("Disable Updater")
|
||||
|
||||
def on_toggle_updater_clicked(self, _widget):
|
||||
disable_file = get_disable_file_path()
|
||||
try:
|
||||
if os.path.exists(disable_file):
|
||||
os.remove(disable_file)
|
||||
print("Updater enabled.")
|
||||
else:
|
||||
with open(disable_file, "w") as f:
|
||||
pass # File content doesn't matter, its existence is the flag
|
||||
print("Updater disabled.")
|
||||
self._update_toggle_updater_button_label()
|
||||
except Exception as e:
|
||||
print(f"Error toggling updater state: {e}")
|
||||
error_dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text="Error Changing Updater Setting",
|
||||
)
|
||||
error_dialog.format_secondary_text(f"Could not change the updater setting: {e}")
|
||||
error_dialog.run()
|
||||
error_dialog.destroy()
|
||||
|
||||
def on_later_clicked(self, _widget):
|
||||
"""
|
||||
When 'Later' is clicked, create/update the snooze file and close the window.
|
||||
"""
|
||||
snooze_file_path = get_snooze_file_path()
|
||||
try:
|
||||
with open(snooze_file_path, "w") as f:
|
||||
f.write(str(time.time()))
|
||||
print(f"Update snoozed. Snooze file at: {snooze_file_path}")
|
||||
except Exception as e:
|
||||
print(f"Error creating snooze file {snooze_file_path}: {e}")
|
||||
self.destroy()
|
||||
|
||||
def on_update_clicked(self, _widget):
|
||||
"""
|
||||
When 'Update' is pressed, disable buttons, hide the progress bar,
|
||||
and create a VTE terminal to run the update command.
|
||||
"""
|
||||
# Disable the buttons so they can't be clicked again
|
||||
self.update_button.set_sensitive(False)
|
||||
self.close_button.set_sensitive(False)
|
||||
self.toggle_updater_button.set_sensitive(False) # Disable toggle button during update
|
||||
|
||||
# Hide the progress bar (we don't need it now)
|
||||
self.progress_bar.set_visible(False)
|
||||
|
||||
# If there's no container for the terminal, create it
|
||||
if self.terminal_container is None:
|
||||
# Scrollable container so the terminal can scroll
|
||||
self.terminal_container = Gtk.ScrolledWindow()
|
||||
self.terminal_container.set_hexpand(True)
|
||||
self.terminal_container.set_vexpand(True)
|
||||
self.terminal_container.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
|
||||
# Create the VTE terminal
|
||||
self.vte_terminal = Vte.Terminal()
|
||||
self.vte_terminal.set_size(120, 48)
|
||||
# Make update window larger
|
||||
self.set_default_size(720, 540)
|
||||
self.terminal_container.add(self.vte_terminal)
|
||||
# Insert the terminal at the end of main_vbox
|
||||
self.main_vbox.pack_start(self.terminal_container, True, True, 0)
|
||||
|
||||
# Show everything
|
||||
self.show_all()
|
||||
|
||||
# Command to run in the terminal
|
||||
if self.pkg_update:
|
||||
update_command = "curl -fsSL https://raw.githubusercontent.com/Axenide/Ax-Shell/main/install.sh | bash"
|
||||
else:
|
||||
# Ensure REPO_DIR is correctly defined at the top of the file.
|
||||
update_command = f"git -C \"{REPO_DIR}\" pull && echo 'Reloading in 3...' && sleep 1 && echo '2...' && sleep 1 && echo '1...' && sleep 1 && killall {data.APP_NAME} && setsid python \"{REPO_DIR}main.py\""
|
||||
|
||||
|
||||
# Spawn the process asynchronously inside the terminal
|
||||
self.vte_terminal.spawn_async(
|
||||
Vte.PtyFlags.DEFAULT,
|
||||
os.environ.get("HOME", "/"), # CWD for the command
|
||||
["/bin/bash", "-lc", update_command], # Command and args
|
||||
[], # envv
|
||||
GLib.SpawnFlags.DO_NOT_REAP_CHILD, # spawn_flags
|
||||
None, # child_setup
|
||||
None, # child_setup_data
|
||||
-1, # timeout
|
||||
None, # cancellable
|
||||
None, # callback_data for Vte.Terminal.spawn_async_wait_finish
|
||||
self.on_curl_script_exit, # callback for when process finishes
|
||||
None # user_data for callback
|
||||
)
|
||||
|
||||
def on_curl_script_exit(self, terminal, exit_status, user_data):
|
||||
"""
|
||||
Callback when the script running in the VTE terminal finishes.
|
||||
Depending on exit_status, success or failure is considered.
|
||||
"""
|
||||
# exit_status is encoded: 0 means success
|
||||
if exit_status == 0:
|
||||
# Call the success routine, which restarts the app
|
||||
GLib.idle_add(self.handle_update_success)
|
||||
else:
|
||||
# If there was an error, read the last part of the buffer to display it
|
||||
end_iter = self.vte_terminal.get_end_iter()
|
||||
start_iter = self.vte_terminal.get_iter_at_line(max(0, self.vte_terminal.get_line_count() - 5))
|
||||
error_excerpt = self.vte_terminal.get_text_range(start_iter, end_iter, False)
|
||||
GLib.idle_add(self.handle_update_failure, f"Script exited with status {exit_status}. Last lines:\n{error_excerpt}")
|
||||
|
||||
def handle_update_success(self):
|
||||
"""
|
||||
Shows a success message, updates local version.json, and restarts the application.
|
||||
"""
|
||||
# Update the local version.json with the fetched remote one
|
||||
try:
|
||||
update_local_version_file()
|
||||
print("Local version.json updated successfully.")
|
||||
except Exception as e:
|
||||
print(f"Failed to update local version.json: {e}")
|
||||
# Optionally, you could show an error message to the user here
|
||||
# For now, we'll proceed with the restart if the script itself was successful.
|
||||
|
||||
# If there was any progress bar timeout, remove it
|
||||
if hasattr(self, "pulse_timeout_id"):
|
||||
GLib.source_remove(self.pulse_timeout_id)
|
||||
delattr(self, "pulse_timeout_id")
|
||||
|
||||
# Replace the terminal (or other widget) with a brief message
|
||||
# First remove the terminal to show the progress bar and text
|
||||
if self.terminal_container:
|
||||
self.main_vbox.remove(self.terminal_container)
|
||||
|
||||
# Prepare the progress bar to indicate success
|
||||
self.progress_bar.set_visible(True)
|
||||
self.progress_bar.set_fraction(1.0)
|
||||
self.progress_bar.set_text("Update Complete. Restarting application...")
|
||||
self.progress_bar.set_show_text(True)
|
||||
|
||||
# Force it to show
|
||||
self.show_all()
|
||||
|
||||
# After 2 seconds, close and restart
|
||||
GLib.timeout_add_seconds(2, self.trigger_restart_and_close)
|
||||
|
||||
def trigger_restart_and_close(self):
|
||||
"""
|
||||
Closes the window and relaunches the application.
|
||||
"""
|
||||
self.destroy()
|
||||
try:
|
||||
print("Restarting application...")
|
||||
# Relaunch the application
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
except Exception as e:
|
||||
print(f"Error during application restart: {e}")
|
||||
# Fallback or error message if execv fails
|
||||
# For instance, you might want to just quit GTK if restart fails in standalone mode.
|
||||
if self.is_standalone_mode and self.quit_gtk_main_on_destroy:
|
||||
Gtk.main_quit()
|
||||
return False # So the timeout runs only once
|
||||
|
||||
def handle_update_failure(self, error_message):
|
||||
"""
|
||||
Shows an error dialog if the script execution fails.
|
||||
"""
|
||||
# If there was any progress bar timeout, remove it
|
||||
if hasattr(self, "pulse_timeout_id"):
|
||||
GLib.source_remove(self.pulse_timeout_id)
|
||||
delattr(self, "pulse_timeout_id")
|
||||
|
||||
# Indicate failure in the progress bar
|
||||
self.progress_bar.set_visible(True)
|
||||
self.progress_bar.set_fraction(0.0)
|
||||
self.progress_bar.set_text("Update Failed.")
|
||||
self.progress_bar.set_show_text(True)
|
||||
|
||||
# Buttons are re-enabled to retry or close
|
||||
self.update_button.set_sensitive(True)
|
||||
self.close_button.set_sensitive(True)
|
||||
self.toggle_updater_button.set_sensitive(True) # Re-enable toggle button
|
||||
|
||||
# Error dialog
|
||||
error_dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text="Update Failed",
|
||||
)
|
||||
error_dialog.format_secondary_text(error_message)
|
||||
error_dialog.run()
|
||||
error_dialog.destroy()
|
||||
|
||||
def on_window_destroyed(self, _widget):
|
||||
"""
|
||||
If the window is destroyed and we're in standalone mode, quit Gtk.main().
|
||||
"""
|
||||
if hasattr(self, "pulse_timeout_id"):
|
||||
GLib.source_remove(self.pulse_timeout_id)
|
||||
delattr(self, "pulse_timeout_id")
|
||||
|
||||
if self.quit_gtk_main_on_destroy:
|
||||
Gtk.main_quit()
|
||||
|
||||
|
||||
def _initiate_update_check_flow(is_standalone_mode, force=False): # Added force argument with default
|
||||
"""
|
||||
Logic that checks connection, snooze, and downloads the remote version.
|
||||
If there's a new version or force is True, launches the update window.
|
||||
"""
|
||||
global _QUIT_GTK_IF_NO_WINDOW_STANDALONE
|
||||
|
||||
# --- Check if updater is permanently disabled ---
|
||||
disable_file_path = get_disable_file_path()
|
||||
if os.path.exists(disable_file_path) and not force:
|
||||
print(f"Updater is disabled via {UPDATER_DISABLE_FILE_NAME}. Skipping update check.")
|
||||
if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE:
|
||||
GLib.idle_add(Gtk.main_quit)
|
||||
return
|
||||
|
||||
if not is_connected():
|
||||
print("No internet connection. Skipping update check.")
|
||||
if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE:
|
||||
GLib.idle_add(Gtk.main_quit)
|
||||
return
|
||||
|
||||
fetch_remote_version()
|
||||
latest_version, changelog, _, pkg_update = get_remote_version() # Unpack pkg_update
|
||||
|
||||
if force:
|
||||
print(f"Force update mode enabled. Opening updater for version {latest_version}.")
|
||||
if latest_version == "0.0.0" and not changelog: # And pkg_update will be True (default)
|
||||
print(f"Warning: Could not fetch remote version details for {data.APP_NAME_CAP}. Updater will show default/empty info.")
|
||||
GLib.idle_add(launch_update_window, latest_version, changelog, pkg_update, is_standalone_mode) # Pass pkg_update
|
||||
return # Exit after launching in force mode
|
||||
|
||||
# --- Regular update check flow (if not forced) ---
|
||||
snooze_file_path = get_snooze_file_path()
|
||||
if os.path.exists(snooze_file_path):
|
||||
try:
|
||||
with open(snooze_file_path, "r") as f:
|
||||
snooze_timestamp_str = f.read().strip()
|
||||
snooze_timestamp = float(snooze_timestamp_str)
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - snooze_timestamp < SNOOZE_DURATION_SECONDS:
|
||||
snooze_until_time = snooze_timestamp + SNOOZE_DURATION_SECONDS
|
||||
snooze_until_time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(snooze_until_time))
|
||||
print(f"Check postponed. It will resume after {snooze_until_time_str}.")
|
||||
if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE:
|
||||
GLib.idle_add(Gtk.main_quit)
|
||||
return
|
||||
else:
|
||||
print("Snooze period expired. Removing file and checking for updates.")
|
||||
os.remove(snooze_file_path)
|
||||
except ValueError:
|
||||
print(f"Error: invalid content in snooze file. Removing: {snooze_file_path}")
|
||||
try:
|
||||
os.remove(snooze_file_path)
|
||||
except OSError as e_remove:
|
||||
print(f"Error removing corrupt snooze file: {e_remove}")
|
||||
except Exception as e_snooze:
|
||||
print(f"Error processing snooze file {snooze_file_path}: {e_snooze}. Proceeding with check.")
|
||||
try:
|
||||
os.remove(snooze_file_path) # Attempt to remove problematic snooze file
|
||||
except OSError as e_remove_generic:
|
||||
print(f"Error removing problematic snooze file: {e_remove_generic}")
|
||||
|
||||
|
||||
current_version, _ = get_local_version()
|
||||
|
||||
# Basic version comparison (not strict semver)
|
||||
if latest_version > current_version and latest_version != "0.0.0":
|
||||
GLib.idle_add(launch_update_window, latest_version, changelog, pkg_update, is_standalone_mode) # Pass pkg_update
|
||||
else:
|
||||
print(f"{data.APP_NAME_CAP} is up to date or the remote version is invalid.")
|
||||
if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE:
|
||||
GLib.idle_add(Gtk.main_quit)
|
||||
|
||||
|
||||
def launch_update_window(latest_version, changelog, pkg_update, is_standalone_mode):
|
||||
"""
|
||||
Creates and shows the update window.
|
||||
"""
|
||||
win = UpdateWindow(latest_version, changelog, pkg_update, is_standalone_mode) # Pass pkg_update
|
||||
if is_standalone_mode:
|
||||
win.quit_gtk_main_on_destroy = True
|
||||
win.show_all()
|
||||
|
||||
|
||||
def check_for_updates():
|
||||
"""
|
||||
Entry point when called from the main application.
|
||||
Initiates an update check in a background thread without force.
|
||||
"""
|
||||
# Create wrapper function for GLib.Thread compatibility
|
||||
def _update_check_wrapper(user_data):
|
||||
_initiate_update_check_flow(False, False)
|
||||
|
||||
GLib.Thread.new("update-check", _update_check_wrapper, None)
|
||||
|
||||
|
||||
def run_updater(force=False): # Modified to accept force argument
|
||||
"""
|
||||
Standalone entry point: starts Gtk.main and the update check.
|
||||
Args:
|
||||
force (bool): If True, opens the updater even if the version isn't outdated or snoozed.
|
||||
Defaults to False.
|
||||
"""
|
||||
global _QUIT_GTK_IF_NO_WINDOW_STANDALONE
|
||||
_QUIT_GTK_IF_NO_WINDOW_STANDALONE = True
|
||||
|
||||
# Create wrapper function for GLib.Thread compatibility
|
||||
def _standalone_update_wrapper(user_data):
|
||||
_initiate_update_check_flow(True, force)
|
||||
|
||||
GLib.Thread.new("standalone-update-check", _standalone_update_wrapper, None)
|
||||
|
||||
Gtk.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example of how to run with force=True:
|
||||
# run_updater(force=True)
|
||||
# By default, runs with force=False:
|
||||
run_updater()
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 Oscar Svensson (wogscpar)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
UPower wrapper using DBus
|
||||
Copyright (c) 2017 Oscar Svensson (wogscpar)
|
||||
https://github.com/wogscpar/upower_python
|
||||
"""
|
||||
@@ -0,0 +1,152 @@
|
||||
import dbus
|
||||
|
||||
class UPowerManager():
|
||||
|
||||
def __init__(self):
|
||||
self.UPOWER_NAME = "org.freedesktop.UPower"
|
||||
self.UPOWER_PATH = "/org/freedesktop/UPower"
|
||||
|
||||
self.DBUS_PROPERTIES = "org.freedesktop.DBus.Properties"
|
||||
self.bus = dbus.SystemBus()
|
||||
|
||||
def detect_devices(self):
|
||||
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
|
||||
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME)
|
||||
|
||||
devices = upower_interface.EnumerateDevices()
|
||||
return devices
|
||||
|
||||
def get_display_device(self):
|
||||
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
|
||||
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME)
|
||||
|
||||
dispdev = upower_interface.GetDisplayDevice()
|
||||
return dispdev
|
||||
|
||||
def get_critical_action(self):
|
||||
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
|
||||
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME)
|
||||
|
||||
critical_action = upower_interface.GetCriticalAction()
|
||||
return critical_action
|
||||
|
||||
def get_device_percentage(self, battery):
|
||||
battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery)
|
||||
battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES)
|
||||
|
||||
return battery_proxy_interface.Get(self.UPOWER_NAME + ".Device", "Percentage")
|
||||
|
||||
def get_full_device_information(self, battery):
|
||||
battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery)
|
||||
battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES)
|
||||
|
||||
# Use GetAll to retrieve all properties in a single DBus call
|
||||
all_properties = battery_proxy_interface.GetAll(self.UPOWER_NAME + ".Device")
|
||||
|
||||
# Extract properties with default values for missing keys
|
||||
information_table = {
|
||||
'HasHistory': all_properties.get('HasHistory', False),
|
||||
'HasStatistics': all_properties.get('HasStatistics', False),
|
||||
'IsPresent': all_properties.get('IsPresent', False),
|
||||
'IsRechargeable': all_properties.get('IsRechargeable', False),
|
||||
'Online': all_properties.get('Online', False),
|
||||
'PowerSupply': all_properties.get('PowerSupply', False),
|
||||
'Capacity': all_properties.get('Capacity', 0.0),
|
||||
'Energy': all_properties.get('Energy', 0.0),
|
||||
'EnergyEmpty': all_properties.get('EnergyEmpty', 0.0),
|
||||
'EnergyFull': all_properties.get('EnergyFull', 0.0),
|
||||
'EnergyFullDesign': all_properties.get('EnergyFullDesign', 0.0),
|
||||
'EnergyRate': all_properties.get('EnergyRate', 0.0),
|
||||
'Luminosity': all_properties.get('Luminosity', 0.0),
|
||||
'Percentage': all_properties.get('Percentage', 0.0),
|
||||
'Temperature': all_properties.get('Temperature', 0.0),
|
||||
'Voltage': all_properties.get('Voltage', 0.0),
|
||||
'TimeToEmpty': all_properties.get('TimeToEmpty', 0),
|
||||
'TimeToFull': all_properties.get('TimeToFull', 0),
|
||||
'IconName': all_properties.get('IconName', ''),
|
||||
'Model': all_properties.get('Model', ''),
|
||||
'NativePath': all_properties.get('NativePath', ''),
|
||||
'Serial': all_properties.get('Serial', ''),
|
||||
'Vendor': all_properties.get('Vendor', ''),
|
||||
'State': all_properties.get('State', 0),
|
||||
'Technology': all_properties.get('Technology', 0),
|
||||
'Type': all_properties.get('Type', 0),
|
||||
'WarningLevel': all_properties.get('WarningLevel', 0),
|
||||
'UpdateTime': all_properties.get('UpdateTime', 0)
|
||||
}
|
||||
|
||||
return information_table
|
||||
|
||||
def is_lid_present(self):
|
||||
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
|
||||
upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES)
|
||||
|
||||
is_lid_present = bool(upower_interface.Get(self.UPOWER_NAME, 'LidIsPresent'))
|
||||
return is_lid_present
|
||||
|
||||
def is_lid_closed(self):
|
||||
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
|
||||
upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES)
|
||||
|
||||
is_lid_closed = bool(upower_interface.Get(self.UPOWER_NAME, 'LidIsClosed'))
|
||||
return is_lid_closed
|
||||
|
||||
def on_battery(self):
|
||||
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
|
||||
upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES)
|
||||
|
||||
on_battery = bool(upower_interface.Get(self.UPOWER_NAME, 'OnBattery'))
|
||||
return on_battery
|
||||
|
||||
def has_wakeup_capabilities(self):
|
||||
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH + "/Wakeups")
|
||||
upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES)
|
||||
|
||||
has_wakeup_capabilities = bool(upower_interface.Get(self.UPOWER_NAME+ '.Wakeups', 'HasCapability'))
|
||||
return has_wakeup_capabilities
|
||||
|
||||
def get_wakeups_data(self):
|
||||
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH + "/Wakeups")
|
||||
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME + '.Wakeups')
|
||||
|
||||
data = upower_interface.GetData()
|
||||
return data
|
||||
|
||||
def get_wakeups_total(self):
|
||||
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH + "/Wakeups")
|
||||
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME + '.Wakeups')
|
||||
|
||||
data = upower_interface.GetTotal()
|
||||
return data
|
||||
|
||||
def is_loading(self, battery):
|
||||
battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery)
|
||||
battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES)
|
||||
|
||||
state = int(battery_proxy_interface.Get(self.UPOWER_NAME + ".Device", "State"))
|
||||
|
||||
if (state == 1):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def get_state(self, battery):
|
||||
battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery)
|
||||
battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES)
|
||||
|
||||
state = int(battery_proxy_interface.Get(self.UPOWER_NAME + ".Device", "State"))
|
||||
|
||||
if (state == 0):
|
||||
return "Unknown"
|
||||
elif (state == 1):
|
||||
return "Loading"
|
||||
elif (state == 2):
|
||||
return "Discharging"
|
||||
elif (state == 3):
|
||||
return "Empty"
|
||||
elif (state == 4):
|
||||
return "Fully charged"
|
||||
elif (state == 5):
|
||||
return "Pending charge"
|
||||
elif (state == 6):
|
||||
return "Pending discharge"
|
||||
@@ -0,0 +1,629 @@
|
||||
import colorsys
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import os
|
||||
import random # <--- AÑADIDO
|
||||
import shutil
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from fabric.utils.helpers import exec_shell_command_async
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.entry import Entry
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango
|
||||
from PIL import Image
|
||||
|
||||
import config.config
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
|
||||
|
||||
class WallpaperSelector(Box):
|
||||
CACHE_DIR = f"{data.CACHE_DIR}/thumbs" # Changed from wallpapers to thumbs
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Delete the old cache directory if it exists
|
||||
old_cache_dir = f"{data.CACHE_DIR}/wallpapers"
|
||||
if os.path.exists(old_cache_dir):
|
||||
shutil.rmtree(old_cache_dir)
|
||||
|
||||
super().__init__(
|
||||
name="wallpapers",
|
||||
spacing=4,
|
||||
orientation="v",
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
**kwargs,
|
||||
)
|
||||
os.makedirs(self.CACHE_DIR, exist_ok=True)
|
||||
|
||||
self.files = []
|
||||
GLib.idle_add(self._load_wallpapers_async().__next__)
|
||||
self.thumbnails = []
|
||||
self.thumbnail_queue = []
|
||||
self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor
|
||||
|
||||
# Variable to control the selection (similar to AppLauncher)
|
||||
self.selected_index = -1
|
||||
|
||||
# Initialize UI components
|
||||
self.viewport = Gtk.IconView(name="wallpaper-icons")
|
||||
self.viewport.set_model(Gtk.ListStore(GdkPixbuf.Pixbuf, str))
|
||||
self.viewport.set_pixbuf_column(0)
|
||||
# Hide text column so only the image is shown
|
||||
self.viewport.set_text_column(-1)
|
||||
self.viewport.set_item_width(0)
|
||||
self.viewport.connect("item-activated", self.on_wallpaper_selected)
|
||||
# self.viewport.connect("selection-changed", self._on_selection_changed) # Removed connection
|
||||
|
||||
self.scrolled_window = ScrolledWindow(
|
||||
name="scrolled-window",
|
||||
spacing=10,
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
h_align="fill",
|
||||
v_align="fill",
|
||||
child=self.viewport,
|
||||
propagate_width=False,
|
||||
propagate_height=False,
|
||||
)
|
||||
|
||||
self.search_entry = Entry(
|
||||
name="search-entry-walls",
|
||||
placeholder="Search Wallpapers...",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
|
||||
on_key_press_event=self.on_search_entry_key_press,
|
||||
)
|
||||
self.search_entry.props.xalign = 0.5
|
||||
self.search_entry.connect("focus-out-event", self.on_search_entry_focus_out)
|
||||
|
||||
self.schemes = {
|
||||
"scheme-tonal-spot": "Tonal Spot",
|
||||
"scheme-content": "Content",
|
||||
"scheme-expressive": "Expressive",
|
||||
"scheme-fidelity": "Fidelity",
|
||||
"scheme-fruit-salad": "Fruit Salad",
|
||||
"scheme-monochrome": "Monochrome",
|
||||
"scheme-neutral": "Neutral",
|
||||
"scheme-rainbow": "Rainbow",
|
||||
}
|
||||
|
||||
self.scheme_dropdown = Gtk.ComboBoxText()
|
||||
self.scheme_dropdown.set_name("scheme-dropdown")
|
||||
self.scheme_dropdown.set_tooltip_text("Select color scheme")
|
||||
for key, display_name in self.schemes.items():
|
||||
self.scheme_dropdown.append(key, display_name)
|
||||
self.scheme_dropdown.set_active_id("scheme-tonal-spot")
|
||||
self.scheme_dropdown.connect("changed", self.on_scheme_changed)
|
||||
|
||||
# Load matugen state from the dedicated file
|
||||
self.matugen_enabled = True # Default to True
|
||||
try:
|
||||
with open(data.MATUGEN_STATE_FILE, "r") as f:
|
||||
content = f.read().strip().lower()
|
||||
if content == "false":
|
||||
self.matugen_enabled = False
|
||||
elif content == "true":
|
||||
self.matugen_enabled = True
|
||||
# Any other content defaults to True
|
||||
except FileNotFoundError:
|
||||
# File doesn't exist, keep default True and create it on first toggle
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error reading matugen state file: {e}")
|
||||
# Keep default True on error
|
||||
|
||||
# Create a switcher to enable/disable Matugen (enabled by default)
|
||||
self.matugen_switcher = Gtk.Switch(name="matugen-switcher")
|
||||
self.matugen_switcher.set_tooltip_text("Toggle dynamic colors")
|
||||
self.matugen_switcher.set_vexpand(False)
|
||||
self.matugen_switcher.set_hexpand(False)
|
||||
self.matugen_switcher.set_valign(Gtk.Align.CENTER)
|
||||
self.matugen_switcher.set_halign(Gtk.Align.CENTER)
|
||||
self.matugen_switcher.set_active(self.matugen_enabled)
|
||||
self.matugen_switcher.connect("notify::active", self.on_switch_toggled)
|
||||
|
||||
self.mat_icon = Label(name="mat-label", markup=icons.palette)
|
||||
|
||||
self.random_wall = Button(
|
||||
name="random-wall-button",
|
||||
child=Label(name="random-wall-label", markup=icons.dice_1),
|
||||
tooltip_text="Random Wallpaper",
|
||||
)
|
||||
self.random_wall.connect("clicked", self.set_random_wallpaper) # <--- AÑADIDO
|
||||
|
||||
# Add the switcher to the header_box's start_children
|
||||
self.header_box = Box(
|
||||
name="header-box",
|
||||
spacing=8,
|
||||
orientation="h",
|
||||
children=[
|
||||
self.random_wall,
|
||||
self.search_entry,
|
||||
self.scheme_dropdown,
|
||||
self.matugen_switcher,
|
||||
],
|
||||
)
|
||||
|
||||
self.add(self.header_box)
|
||||
|
||||
# Create the custom color selector components
|
||||
self.hue_slider = Gtk.Scale(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, # Changed from VERTICAL
|
||||
adjustment=Gtk.Adjustment(
|
||||
value=0, lower=0, upper=360, step_increment=1, page_increment=10
|
||||
),
|
||||
draw_value=False, # Hide the default value text
|
||||
digits=0,
|
||||
# inverted=True, # Removed inverted for horizontal
|
||||
name="hue-slider", # For CSS styling
|
||||
)
|
||||
|
||||
# Changed expand/align for horizontal orientation
|
||||
self.hue_slider.set_hexpand(True)
|
||||
self.hue_slider.set_halign(Gtk.Align.FILL)
|
||||
self.hue_slider.set_vexpand(False) # Ensure it doesn't expand vertically
|
||||
self.hue_slider.set_valign(Gtk.Align.CENTER) # Center vertically within its box
|
||||
|
||||
self.apply_color_button = Button(
|
||||
name="apply-color-button",
|
||||
child=Label(name="apply-color-label", markup=icons.accept),
|
||||
)
|
||||
self.apply_color_button.connect("clicked", self.on_apply_color_clicked)
|
||||
self.apply_color_button.set_vexpand(
|
||||
False
|
||||
) # Ensure button doesn't expand vertically
|
||||
self.apply_color_button.set_valign(Gtk.Align.CENTER) # Center button vertically
|
||||
|
||||
self.custom_color_selector_box = Box(
|
||||
orientation="h",
|
||||
spacing=5,
|
||||
name="custom-color-selector-box", # Changed orientation to horizontal
|
||||
h_align="center", # Center the horizontal box
|
||||
)
|
||||
self.custom_color_selector_box.add(self.hue_slider)
|
||||
self.custom_color_selector_box.add(self.apply_color_button)
|
||||
self.custom_color_selector_box.set_halign(Gtk.Align.FILL)
|
||||
|
||||
# Add the scrolled window (grid) and the custom color selector box directly
|
||||
# to the main WallpaperSelector box (which is already vertical)
|
||||
self.pack_start(self.scrolled_window, True, True, 0) # Add grid, expand
|
||||
self.pack_start(
|
||||
self.custom_color_selector_box, False, False, 0
|
||||
) # Add custom selector, don't expand
|
||||
|
||||
# Removed the old main_content_box and its add
|
||||
|
||||
self._start_thumbnail_thread()
|
||||
self.connect("map", self.on_map)
|
||||
self.setup_file_monitor()
|
||||
self.show_all()
|
||||
self.randomize_dice_icon()
|
||||
# Ensure the search entry gets focus when starting
|
||||
self.search_entry.grab_focus()
|
||||
|
||||
def _load_wallpapers_async(self):
|
||||
"""Non-blocking wallpaper processing."""
|
||||
|
||||
# Process old wallpapers: use os.scandir for efficiency and only loop
|
||||
# over image files that actually need renaming (they're not already lowercase
|
||||
# and with hyphens instead of spaces)
|
||||
with os.scandir(data.WALLPAPERS_DIR) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_file() and self._is_image(entry.name):
|
||||
# Check if the file needs renaming: file should be lowercase and have hyphens instead of spaces
|
||||
if entry.name != entry.name.lower() or " " in entry.name:
|
||||
new_name = entry.name.lower().replace(" ", "-")
|
||||
full_path = os.path.join(data.WALLPAPERS_DIR, entry.name)
|
||||
new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name)
|
||||
try:
|
||||
os.rename(full_path, new_full_path)
|
||||
print(
|
||||
f"Renamed old wallpaper '{full_path}' to '{new_full_path}'"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error renaming file {full_path}: {e}")
|
||||
yield
|
||||
|
||||
# Process files in small batches to keep UI responsive
|
||||
file_list = os.listdir(data.WALLPAPERS_DIR)
|
||||
batch_size = 20
|
||||
|
||||
# Process files in batches
|
||||
for i in range(0, len(file_list), batch_size):
|
||||
batch = file_list[i : i + batch_size]
|
||||
for filename in batch:
|
||||
if self._is_image(filename):
|
||||
self.files.append(filename)
|
||||
|
||||
# Sort the current batch to maintain order
|
||||
self.files.sort()
|
||||
|
||||
# Yield to let the main loop process events
|
||||
yield True
|
||||
|
||||
# Final sort of the complete list
|
||||
self.files.sort()
|
||||
|
||||
# Start thumbnail loading after files are processed
|
||||
self._start_thumbnail_thread()
|
||||
|
||||
# Return False to stop the idle callback
|
||||
yield False
|
||||
|
||||
def randomize_dice_icon(self):
|
||||
dice_icons = [
|
||||
icons.dice_1,
|
||||
icons.dice_2,
|
||||
icons.dice_3,
|
||||
icons.dice_4,
|
||||
icons.dice_5,
|
||||
icons.dice_6,
|
||||
]
|
||||
chosen_icon = random.choice(dice_icons)
|
||||
label = self.random_wall.get_child()
|
||||
if isinstance(label, Label):
|
||||
label.set_markup(chosen_icon)
|
||||
|
||||
def set_random_wallpaper(self, widget, external=False):
|
||||
if not self.files:
|
||||
print("No wallpapers available to set a random one.")
|
||||
return
|
||||
|
||||
file_name = random.choice(self.files)
|
||||
full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
|
||||
selected_scheme = self.scheme_dropdown.get_active_id()
|
||||
current_wall = os.path.expanduser(f"~/.current.wall")
|
||||
|
||||
if os.path.isfile(current_wall) or os.path.islink(
|
||||
current_wall
|
||||
): # Check for link too
|
||||
os.remove(current_wall)
|
||||
os.symlink(full_path, current_wall)
|
||||
|
||||
if self.matugen_switcher.get_active():
|
||||
exec_shell_command_async(
|
||||
f'matugen image "{full_path}" -t {selected_scheme}'
|
||||
)
|
||||
else:
|
||||
exec_shell_command_async(
|
||||
f'awww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
|
||||
)
|
||||
|
||||
print(f"Set random wallpaper: {file_name}")
|
||||
|
||||
if external:
|
||||
exec_shell_command_async(
|
||||
f"notify-send '🎲 Wallpaper' 'Setting a random wallpaper 🎨' -a '{data.APP_NAME_CAP}' -i '{full_path}' -e"
|
||||
)
|
||||
|
||||
self.randomize_dice_icon()
|
||||
|
||||
def setup_file_monitor(self):
|
||||
gfile = Gio.File.new_for_path(data.WALLPAPERS_DIR)
|
||||
self.file_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None)
|
||||
self.file_monitor.connect("changed", self.on_directory_changed)
|
||||
|
||||
def on_directory_changed(self, monitor, file, other_file, event_type):
|
||||
file_name = file.get_basename()
|
||||
if event_type == Gio.FileMonitorEvent.DELETED:
|
||||
if file_name in self.files:
|
||||
self.files.remove(file_name)
|
||||
cache_path = self._get_cache_path(file_name)
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
os.remove(cache_path)
|
||||
except Exception as e:
|
||||
print(f"Error deleting cache {cache_path}: {e}")
|
||||
self.thumbnails = [(p, n) for p, n in self.thumbnails if n != file_name]
|
||||
GLib.idle_add(self.arrange_viewport, self.search_entry.get_text())
|
||||
elif event_type == Gio.FileMonitorEvent.CREATED:
|
||||
if self._is_image(file_name):
|
||||
# Convert filename to lowercase and replace spaces with "-"
|
||||
new_name = file_name.lower().replace(" ", "-")
|
||||
full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
|
||||
new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name)
|
||||
if new_name != file_name:
|
||||
try:
|
||||
os.rename(full_path, new_full_path)
|
||||
file_name = new_name
|
||||
print(f"Renamed file '{full_path}' to '{new_full_path}')")
|
||||
except Exception as e:
|
||||
print(f"Error renaming file {full_path}: {e}")
|
||||
if file_name not in self.files:
|
||||
self.files.append(file_name)
|
||||
self.files.sort()
|
||||
self.executor.submit(self._process_file, file_name)
|
||||
elif event_type == Gio.FileMonitorEvent.CHANGED:
|
||||
if self._is_image(file_name) and file_name in self.files:
|
||||
cache_path = self._get_cache_path(file_name)
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
os.remove(cache_path)
|
||||
except Exception as e:
|
||||
print(f"Error deleting cache for changed file {file_name}: {e}")
|
||||
self.executor.submit(self._process_file, file_name)
|
||||
|
||||
def arrange_viewport(self, query: str = ""):
|
||||
model = self.viewport.get_model()
|
||||
model.clear()
|
||||
filtered_thumbnails = [
|
||||
(thumb, name)
|
||||
for thumb, name in self.thumbnails
|
||||
if query.casefold() in name.casefold()
|
||||
]
|
||||
filtered_thumbnails.sort(key=lambda x: x[1].lower())
|
||||
for pixbuf, file_name in filtered_thumbnails:
|
||||
model.append([pixbuf, file_name])
|
||||
# If the search entry is empty, no icon is selected; otherwise, select the first one.
|
||||
if query.strip() == "":
|
||||
self.viewport.unselect_all()
|
||||
self.selected_index = -1
|
||||
elif len(model) > 0:
|
||||
self.update_selection(0)
|
||||
|
||||
def on_wallpaper_selected(self, iconview, path):
|
||||
model = iconview.get_model()
|
||||
file_name = model[path][1]
|
||||
full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
|
||||
selected_scheme = self.scheme_dropdown.get_active_id()
|
||||
current_wall = os.path.expanduser(f"~/.current.wall")
|
||||
if os.path.isfile(current_wall) or os.path.islink(current_wall):
|
||||
os.remove(current_wall)
|
||||
os.symlink(full_path, current_wall)
|
||||
if self.matugen_switcher.get_active():
|
||||
# Matugen is enabled: run the normal command.
|
||||
exec_shell_command_async(
|
||||
f'matugen image "{full_path}" -t {selected_scheme}'
|
||||
)
|
||||
else:
|
||||
# Matugen is disabled: run the alternative awww command.
|
||||
exec_shell_command_async(
|
||||
f'awww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
|
||||
)
|
||||
|
||||
def on_scheme_changed(self, combo):
|
||||
selected_scheme = combo.get_active_id()
|
||||
print(f"Color scheme selected: {selected_scheme}")
|
||||
|
||||
def on_search_entry_key_press(self, widget, event):
|
||||
if event.state & Gdk.ModifierType.SHIFT_MASK:
|
||||
if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down):
|
||||
schemes_list = list(self.schemes.keys())
|
||||
current_id = self.scheme_dropdown.get_active_id()
|
||||
current_index = (
|
||||
schemes_list.index(current_id) if current_id in schemes_list else 0
|
||||
)
|
||||
new_index = (
|
||||
(current_index - 1) % len(schemes_list)
|
||||
if event.keyval == Gdk.KEY_Up
|
||||
else (current_index + 1) % len(schemes_list)
|
||||
)
|
||||
self.scheme_dropdown.set_active(new_index)
|
||||
return True
|
||||
elif event.keyval == Gdk.KEY_Right:
|
||||
self.scheme_dropdown.popup()
|
||||
return True
|
||||
|
||||
if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right):
|
||||
self.move_selection_2d(event.keyval)
|
||||
return True
|
||||
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
||||
if self.selected_index != -1:
|
||||
path = Gtk.TreePath.new_from_indices([self.selected_index])
|
||||
self.on_wallpaper_selected(self.viewport, path)
|
||||
return True
|
||||
return False
|
||||
|
||||
# Removed _on_selection_changed method
|
||||
|
||||
def move_selection_2d(self, keyval):
|
||||
model = self.viewport.get_model()
|
||||
total_items = len(model)
|
||||
if total_items == 0:
|
||||
return
|
||||
|
||||
# --- Determine Column Count ---
|
||||
columns = self.viewport.get_columns()
|
||||
|
||||
# If get_columns returns 0 or -1 (auto), try to estimate by checking item rows
|
||||
if columns <= 0 and total_items > 0:
|
||||
estimated_cols = 0
|
||||
try:
|
||||
# Check the row of the first item (should be 0)
|
||||
first_item_path = Gtk.TreePath.new_from_indices([0])
|
||||
base_row = self.viewport.get_item_row(first_item_path)
|
||||
|
||||
# Find the index of the first item in the *next* row
|
||||
for i in range(1, total_items):
|
||||
path = Gtk.TreePath.new_from_indices([i])
|
||||
row = self.viewport.get_item_row(path)
|
||||
if row > base_row:
|
||||
estimated_cols = i # The number of items in the first row
|
||||
break
|
||||
|
||||
# If loop finished without finding a new row, all items are in one row
|
||||
if estimated_cols == 0:
|
||||
estimated_cols = total_items
|
||||
|
||||
columns = max(1, estimated_cols)
|
||||
except Exception:
|
||||
# Fallback if get_item_row fails (e.g., widget not realized)
|
||||
columns = 1
|
||||
elif columns <= 0 and total_items == 0:
|
||||
columns = 1 # Should not happen due to early return, but safe
|
||||
|
||||
# Ensure columns is at least 1 after all checks
|
||||
columns = max(1, columns)
|
||||
|
||||
# --- Navigation Logic ---
|
||||
current_index = self.selected_index
|
||||
new_index = current_index
|
||||
|
||||
if current_index == -1:
|
||||
# If nothing is selected, select the first or last item based on direction
|
||||
if keyval in (Gdk.KEY_Down, Gdk.KEY_Right):
|
||||
new_index = 0
|
||||
elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left):
|
||||
new_index = total_items - 1
|
||||
if total_items == 0:
|
||||
new_index = -1 # Handle edge case
|
||||
|
||||
else:
|
||||
# Calculate potential new index based on key press
|
||||
if keyval == Gdk.KEY_Up:
|
||||
potential_new_index = current_index - columns
|
||||
# Only update if the new index is valid (>= 0)
|
||||
if potential_new_index >= 0:
|
||||
new_index = potential_new_index
|
||||
elif keyval == Gdk.KEY_Down:
|
||||
potential_new_index = current_index + columns
|
||||
# Only update if the new index is valid (< total_items)
|
||||
if potential_new_index < total_items:
|
||||
new_index = potential_new_index
|
||||
elif keyval == Gdk.KEY_Left:
|
||||
# Only update if not already in the first column (index % columns != 0)
|
||||
# and the index is greater than 0
|
||||
if current_index > 0 and current_index % columns != 0:
|
||||
new_index = current_index - 1
|
||||
elif keyval == Gdk.KEY_Right:
|
||||
# Only update if not in the last column ((index + 1) % columns != 0)
|
||||
# and not the very last item (index < total_items - 1)
|
||||
if (
|
||||
current_index < total_items - 1
|
||||
and (current_index + 1) % columns != 0
|
||||
):
|
||||
new_index = current_index + 1
|
||||
|
||||
# Only update if the index actually changed and is valid
|
||||
if new_index != self.selected_index and 0 <= new_index < total_items:
|
||||
self.update_selection(new_index)
|
||||
elif (
|
||||
total_items > 0
|
||||
and self.selected_index == -1
|
||||
and 0 <= new_index < total_items
|
||||
):
|
||||
# Handle selecting the first item when starting from -1
|
||||
self.update_selection(new_index)
|
||||
|
||||
def update_selection(self, new_index: int):
|
||||
self.viewport.unselect_all()
|
||||
path = Gtk.TreePath.new_from_indices([new_index])
|
||||
self.viewport.select_path(path)
|
||||
self.viewport.scroll_to_path(
|
||||
path, False, 0.5, 0.5
|
||||
) # Ensure the selected icon is visible
|
||||
self.selected_index = new_index
|
||||
|
||||
def _start_thumbnail_thread(self):
|
||||
thread = GLib.Thread.new("thumbnail-loader", self._preload_thumbnails, None)
|
||||
|
||||
def _preload_thumbnails(self, _data):
|
||||
futures = [
|
||||
self.executor.submit(self._process_file, file_name)
|
||||
for file_name in self.files
|
||||
]
|
||||
concurrent.futures.wait(futures)
|
||||
GLib.idle_add(self._process_batch)
|
||||
|
||||
def _process_file(self, file_name):
|
||||
full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
|
||||
cache_path = self._get_cache_path(file_name)
|
||||
if not os.path.exists(cache_path):
|
||||
try:
|
||||
with Image.open(full_path) as img:
|
||||
width, height = img.size
|
||||
side = min(width, height)
|
||||
left = (img.width - side) // 2
|
||||
top = (height - side) // 2
|
||||
right = left + side
|
||||
bottom = top + side
|
||||
img_cropped = img.crop((left, top, right, bottom))
|
||||
img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS)
|
||||
img_cropped.save(cache_path, "PNG")
|
||||
except Exception as e:
|
||||
print(f"Error processing {file_name}: {e}")
|
||||
return
|
||||
self.thumbnail_queue.append((cache_path, file_name))
|
||||
GLib.idle_add(self._process_batch)
|
||||
|
||||
def _process_batch(self):
|
||||
batch = self.thumbnail_queue[:10]
|
||||
del self.thumbnail_queue[:10]
|
||||
for cache_path, file_name in batch:
|
||||
try:
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
|
||||
self.thumbnails.append((pixbuf, file_name))
|
||||
self.viewport.get_model().append([pixbuf, file_name])
|
||||
except Exception as e:
|
||||
print(f"Error loading thumbnail {cache_path}: {e}")
|
||||
if self.thumbnail_queue:
|
||||
GLib.idle_add(self._process_batch)
|
||||
return False
|
||||
|
||||
def _get_cache_path(self, file_name: str) -> str:
|
||||
file_hash = hashlib.md5(file_name.encode("utf-8")).hexdigest()
|
||||
return os.path.join(self.CACHE_DIR, f"{file_hash}.png")
|
||||
|
||||
@staticmethod
|
||||
def _is_image(file_name: str) -> bool:
|
||||
return file_name.lower().endswith(
|
||||
(".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
||||
)
|
||||
|
||||
def on_search_entry_focus_out(self, widget, event):
|
||||
if self.get_mapped():
|
||||
widget.grab_focus()
|
||||
return False
|
||||
|
||||
def on_map(self, widget):
|
||||
"""Handles the map signal to set initial visibility of the color selector."""
|
||||
# Set visibility based on the loaded state when the widget becomes visible
|
||||
self.custom_color_selector_box.set_visible(not self.matugen_enabled)
|
||||
|
||||
def hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str:
|
||||
"""Converts HSL color value to RGB HEX string."""
|
||||
# colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0
|
||||
hue = h / 360.0
|
||||
r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S
|
||||
r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255)
|
||||
return f"#{r_int:02X}{g_int:02X}{b_int:02X}"
|
||||
|
||||
def rgba_to_hex(self, rgba: Gdk.RGBA) -> str:
|
||||
"""Converts Gdk.RGBA to a HEX color string."""
|
||||
r = int(rgba.red * 255)
|
||||
g = int(rgba.green * 255)
|
||||
b = int(rgba.blue * 255)
|
||||
return f"#{r:02X}{g:02X}{b:02X}"
|
||||
|
||||
def on_switch_toggled(self, switch, gparam):
|
||||
"""Handles the toggling of the Matugen switch."""
|
||||
is_active = switch.get_active()
|
||||
self.matugen_enabled = is_active
|
||||
# self.scheme_dropdown.set_sensitive(is_active)
|
||||
self.custom_color_selector_box.set_visible(not is_active) # Toggle visibility
|
||||
|
||||
# Save the state to the dedicated file
|
||||
try:
|
||||
with open(data.MATUGEN_STATE_FILE, "w") as f:
|
||||
f.write(str(is_active))
|
||||
except Exception as e:
|
||||
print(f"Error writing matugen state file: {e}")
|
||||
|
||||
def on_apply_color_clicked(self, button):
|
||||
"""Applies the color selected by the hue slider via matugen."""
|
||||
hue_value = self.hue_slider.get_value() # Get value from 0-360
|
||||
hex_color = self.hsl_to_rgb_hex(hue_value) # Convert HSL(hue, 1.0, 0.5) to HEX
|
||||
print(f"Applying color from slider: H={hue_value}, HEX={hex_color}")
|
||||
selected_scheme = self.scheme_dropdown.get_active_id()
|
||||
# Run matugen with the chosen hex color and selected scheme
|
||||
exec_shell_command_async(
|
||||
f'matugen color hex "{hex_color}" -t {selected_scheme}'
|
||||
)
|
||||
# Optionally save the chosen color to config if needed later
|
||||
# config.config.bind_vars["matugen_hex_color"] = hex_color
|
||||
# config.config.save_config() # Removed as save_config doesn't exist
|
||||
@@ -0,0 +1,106 @@
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
|
||||
import gi
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import GLib
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
import config.data as data
|
||||
import modules.icons as icons
|
||||
|
||||
|
||||
class Weather(Button):
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(name="weather", orientation="h", spacing=8, **kwargs)
|
||||
self.label = Label(name="weather-label", markup=icons.loader)
|
||||
self.add(self.label)
|
||||
self.show_all()
|
||||
self.enabled = False # Will be set by apply_component_props
|
||||
self.has_weather_data = False
|
||||
self.fetching = False # Prevent concurrent fetches
|
||||
# Fetch weather every 10 minutes (600 seconds)
|
||||
GLib.timeout_add_seconds(600, self.fetch_weather)
|
||||
# Delay initial fetch to allow visibility config to be applied first (runs only once)
|
||||
GLib.timeout_add(100, self._initial_fetch)
|
||||
|
||||
def set_visible(self, visible):
|
||||
"""Override to track external visibility setting"""
|
||||
self.enabled = visible
|
||||
|
||||
# If being disabled, always hide
|
||||
if not visible:
|
||||
super().set_visible(False)
|
||||
return
|
||||
|
||||
# If being enabled, only show if we have weather data
|
||||
if hasattr(self, "has_weather_data") and self.has_weather_data:
|
||||
super().set_visible(True)
|
||||
# If no weather data yet, remain hidden until fetch completes
|
||||
|
||||
def _initial_fetch(self):
|
||||
"""Initial fetch that runs only once"""
|
||||
self.fetch_weather()
|
||||
return False # Don't repeat this timeout
|
||||
|
||||
def fetch_weather(self):
|
||||
# Prevent concurrent fetches
|
||||
if self.fetching:
|
||||
return True
|
||||
|
||||
self.fetching = True
|
||||
GLib.Thread.new("weather-fetch", self._fetch_weather_thread, None)
|
||||
return True
|
||||
|
||||
def _fetch_weather_thread(self, user_data):
|
||||
url = (
|
||||
"https://wttr.in/?format=%c+%t"
|
||||
if not data.VERTICAL
|
||||
else "https://wttr.in/?format=%c"
|
||||
)
|
||||
|
||||
tooltip_url = "https://wttr.in/?format=%l:+%C,+%t+(%f),+Humidity:+%h,+Wind:+%w"
|
||||
|
||||
try:
|
||||
# Use curl to fetch weather data
|
||||
result = subprocess.run(
|
||||
["curl", "-sf", "--max-time", "5", url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=6,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout:
|
||||
weather_data = result.stdout.strip()
|
||||
if "Unknown" in weather_data:
|
||||
self.has_weather_data = False
|
||||
GLib.idle_add(self.set_visible, False)
|
||||
else:
|
||||
self.has_weather_data = True
|
||||
|
||||
# Fetch tooltip data
|
||||
tooltip_result = subprocess.run(
|
||||
["curl", "-sf", "--max-time", "5", tooltip_url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=6,
|
||||
)
|
||||
if tooltip_result.returncode == 0 and tooltip_result.stdout:
|
||||
tooltip_text = tooltip_result.stdout.strip()
|
||||
GLib.idle_add(self.set_tooltip_text, tooltip_text)
|
||||
|
||||
GLib.idle_add(self.set_visible, self.enabled)
|
||||
GLib.idle_add(self.label.set_label, weather_data.replace(" ", ""))
|
||||
else:
|
||||
self.has_weather_data = False
|
||||
GLib.idle_add(self.label.set_markup, f"{icons.cloud_off} Unavailable")
|
||||
GLib.idle_add(self.set_visible, False)
|
||||
except Exception as e:
|
||||
self.has_weather_data = False
|
||||
print(f"Error fetching weather: {e}")
|
||||
GLib.idle_add(self.label.set_markup, f"{icons.cloud_off} Error")
|
||||
GLib.idle_add(self.set_visible, False)
|
||||
finally:
|
||||
# Always reset fetching flag when done
|
||||
self.fetching = False
|
||||
@@ -0,0 +1,166 @@
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.stack import Stack
|
||||
|
||||
import config.data as data
|
||||
from modules.bluetooth import BluetoothConnections
|
||||
from modules.buttons import Buttons
|
||||
from modules.calendar import Calendar
|
||||
from modules.controls import ControlSliders
|
||||
from modules.metrics import Metrics
|
||||
from modules.network import NetworkConnections
|
||||
from modules.notifications import NotificationHistory
|
||||
from modules.player import Player
|
||||
|
||||
|
||||
class Widgets(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="dash-widgets",
|
||||
h_align="fill",
|
||||
v_align="fill",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
visible=True,
|
||||
all_visible=True,
|
||||
)
|
||||
|
||||
vertical_layout = False
|
||||
if data.PANEL_THEME == "Panel" and (
|
||||
data.BAR_POSITION in ["Left", "Right"]
|
||||
or data.PANEL_POSITION in ["Start", "End"]
|
||||
):
|
||||
vertical_layout = True
|
||||
|
||||
calendar_view_mode = "week" if vertical_layout else "month"
|
||||
|
||||
self.calendar = Calendar(view_mode=calendar_view_mode)
|
||||
|
||||
self.notch = kwargs["notch"]
|
||||
|
||||
self.buttons = Buttons(widgets=self)
|
||||
self.bluetooth = BluetoothConnections(widgets=self)
|
||||
|
||||
self.box_1 = Box(
|
||||
name="box-1",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
self.box_2 = Box(
|
||||
name="box-2",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
self.box_3 = Box(
|
||||
name="box-3",
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
self.controls = ControlSliders()
|
||||
|
||||
self.player = Player()
|
||||
|
||||
self.metrics = Metrics()
|
||||
|
||||
self.notification_history = NotificationHistory()
|
||||
|
||||
self.network_connections = NetworkConnections(widgets=self)
|
||||
|
||||
self.applet_stack = Stack(
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
transition_type="slide-left-right",
|
||||
children=[
|
||||
self.notification_history,
|
||||
self.network_connections,
|
||||
self.bluetooth,
|
||||
],
|
||||
)
|
||||
|
||||
self.applet_stack_box = Box(
|
||||
name="applet-stack",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
h_align="fill",
|
||||
children=[
|
||||
self.applet_stack,
|
||||
],
|
||||
)
|
||||
|
||||
if not vertical_layout:
|
||||
self.children_1 = [
|
||||
Box(
|
||||
name="container-sub-1",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
spacing=8,
|
||||
children=[
|
||||
self.calendar,
|
||||
self.applet_stack_box,
|
||||
],
|
||||
),
|
||||
self.metrics,
|
||||
]
|
||||
else:
|
||||
self.children_1 = [
|
||||
self.applet_stack_box,
|
||||
self.calendar, # Weekly view when vertical
|
||||
self.player,
|
||||
]
|
||||
|
||||
self.container_1 = Box(
|
||||
name="container-1",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
orientation="h" if not vertical_layout else "v",
|
||||
spacing=8,
|
||||
children=self.children_1,
|
||||
)
|
||||
|
||||
self.container_2 = Box(
|
||||
name="container-2",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
orientation="v",
|
||||
spacing=8,
|
||||
children=[
|
||||
self.buttons,
|
||||
self.controls,
|
||||
self.container_1,
|
||||
],
|
||||
)
|
||||
|
||||
if not vertical_layout:
|
||||
self.children_3 = [
|
||||
self.player,
|
||||
self.container_2,
|
||||
]
|
||||
else: # vertical_layout
|
||||
self.children_3 = [
|
||||
self.container_2,
|
||||
]
|
||||
|
||||
self.container_3 = Box(
|
||||
name="container-3",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
children=self.children_3,
|
||||
)
|
||||
|
||||
self.add(self.container_3)
|
||||
|
||||
def show_bt(self):
|
||||
self.applet_stack.set_visible_child(self.bluetooth)
|
||||
|
||||
def show_notif(self):
|
||||
self.applet_stack.set_visible_child(self.notification_history)
|
||||
|
||||
def show_network_applet(self):
|
||||
self.notch.open_notch("network_applet")
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Check if animations are disabled (game mode is active)
|
||||
check_gamemode() {
|
||||
HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}')
|
||||
if [ "$HYPRGAMEMODE" = 0 ] ; then
|
||||
echo "t"
|
||||
return 0
|
||||
else
|
||||
echo "f"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Toggle game mode state
|
||||
toggle_gamemode() {
|
||||
HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}')
|
||||
if [ "$HYPRGAMEMODE" = 1 ] ; then
|
||||
hyprctl --batch "\
|
||||
keyword animations:enabled 0;\
|
||||
keyword decoration:shadow:enabled 0;\
|
||||
keyword decoration:blur:enabled 0;\
|
||||
keyword general:gaps_in 0;\
|
||||
keyword general:gaps_out 0;\
|
||||
keyword general:border_size 1;\
|
||||
keyword decoration:rounding 0"
|
||||
exit
|
||||
fi
|
||||
hyprctl reload
|
||||
}
|
||||
|
||||
# Main script logic
|
||||
case "$1" in
|
||||
check)
|
||||
check_gamemode
|
||||
;;
|
||||
*)
|
||||
toggle_gamemode
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,71 @@
|
||||
# Configuración de Hyprland para Multi-Monitor Support en Ax-Shell
|
||||
|
||||
# Configurar monitors
|
||||
monitor=DP-1,1920x1080@60,0x0,1
|
||||
monitor=HDMI-A-1,1920x1080@60,1920x0,1
|
||||
|
||||
# Configurar workspaces por monitor
|
||||
# Monitor 0: Workspaces 1-10
|
||||
workspace=1,monitor:DP-1
|
||||
workspace=2,monitor:DP-1
|
||||
workspace=3,monitor:DP-1
|
||||
workspace=4,monitor:DP-1
|
||||
workspace=5,monitor:DP-1
|
||||
workspace=6,monitor:DP-1
|
||||
workspace=7,monitor:DP-1
|
||||
workspace=8,monitor:DP-1
|
||||
workspace=9,monitor:DP-1
|
||||
workspace=10,monitor:DP-1
|
||||
|
||||
# Monitor 1: Workspaces 11-20
|
||||
workspace=11,monitor:HDMI-A-1
|
||||
workspace=12,monitor:HDMI-A-1
|
||||
workspace=13,monitor:HDMI-A-1
|
||||
workspace=14,monitor:HDMI-A-1
|
||||
workspace=15,monitor:HDMI-A-1
|
||||
workspace=16,monitor:HDMI-A-1
|
||||
workspace=17,monitor:HDMI-A-1
|
||||
workspace=18,monitor:HDMI-A-1
|
||||
workspace=19,monitor:HDMI-A-1
|
||||
workspace=20,monitor:HDMI-A-1
|
||||
|
||||
# Keybinds globales para Ax-Shell multi-monitor
|
||||
# Estos comandos se ejecutan en el monitor con foco actual
|
||||
|
||||
# Abrir launcher en monitor con foco
|
||||
bind = SUPER, SPACE, exec, python ~/.config/Ax-Shell/scripts/toggle_launcher.py
|
||||
|
||||
# Abrir overview en monitor con foco
|
||||
bind = SUPER, TAB, exec, python ~/.config/Ax-Shell/scripts/toggle_overview.py
|
||||
|
||||
# Navegar entre workspaces del monitor actual
|
||||
bind = SUPER, 1, workspace, 1
|
||||
bind = SUPER, 2, workspace, 2
|
||||
bind = SUPER, 3, workspace, 3
|
||||
bind = SUPER, 4, workspace, 4
|
||||
bind = SUPER, 5, workspace, 5
|
||||
bind = SUPER, 6, workspace, 6
|
||||
bind = SUPER, 7, workspace, 7
|
||||
bind = SUPER, 8, workspace, 8
|
||||
bind = SUPER, 9, workspace, 9
|
||||
bind = SUPER, 0, workspace, 10
|
||||
|
||||
# Mover ventanas a workspaces del monitor actual
|
||||
bind = SUPER SHIFT, 1, movetoworkspacesilent, 1
|
||||
bind = SUPER SHIFT, 2, movetoworkspacesilent, 2
|
||||
bind = SUPER SHIFT, 3, movetoworkspacesilent, 3
|
||||
bind = SUPER SHIFT, 4, movetoworkspacesilent, 4
|
||||
bind = SUPER SHIFT, 5, movetoworkspacesilent, 5
|
||||
bind = SUPER SHIFT, 6, movetoworkspacesilent, 6
|
||||
bind = SUPER SHIFT, 7, movetoworkspacesilent, 7
|
||||
bind = SUPER SHIFT, 8, movetoworkspacesilent, 8
|
||||
bind = SUPER SHIFT, 9, movetoworkspacesilent, 9
|
||||
bind = SUPER SHIFT, 0, movetoworkspacesilent, 10
|
||||
|
||||
# Cambiar entre monitores
|
||||
bind = SUPER, bracketleft, focusmonitor, DP-1
|
||||
bind = SUPER, bracketright, focusmonitor, HDMI-A-1
|
||||
|
||||
# Mover ventana al otro monitor
|
||||
bind = SUPER SHIFT, bracketleft, movewindow, mon:DP-1
|
||||
bind = SUPER SHIFT, bracketright, movewindow, mon:HDMI-A-1
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
pick_rgb(){
|
||||
|
||||
# Execute hyprpicker with RGB format and save the output to a variable
|
||||
hyprpicker -a -n -f rgb && sleep 0.1
|
||||
|
||||
# Create a temporal 64x64 PNG file with the color in /tmp using convert
|
||||
magick -size 64x64 xc:"rgb($(wl-paste))" /tmp/color.png
|
||||
|
||||
# Send a notification using the file as an icon
|
||||
notify-send "RGB color picked" "rgb($(wl-paste))" -i /tmp/color.png -a "Hyprpicker"
|
||||
|
||||
# Remove the temporal file
|
||||
rm /tmp/color.png
|
||||
|
||||
# Exit
|
||||
exit 0
|
||||
|
||||
}
|
||||
|
||||
|
||||
pick_hex(){
|
||||
|
||||
# Execute hyprpicker and save the output to a variable
|
||||
hyprpicker -a -n -f hex && sleep 0.1
|
||||
|
||||
# Create a temporal 64x64 PNG file with the color in /tmp using convert
|
||||
magick -size 64x64 xc:"$(wl-paste)" /tmp/color.png
|
||||
|
||||
# Send a notification using the file as an icon
|
||||
notify-send "HEX color picked" "$(wl-paste)" -i /tmp/color.png -a "Hyprpicker"
|
||||
|
||||
# Remove the temporal file
|
||||
rm /tmp/color.png
|
||||
|
||||
# Exit
|
||||
exit 0
|
||||
|
||||
}
|
||||
|
||||
|
||||
pick_hsv(){
|
||||
|
||||
# Copy the color to the clipboard
|
||||
echo -n "$(hyprpicker -n -f hsv)" | wl-copy -n
|
||||
|
||||
# Create a temporal 64x64 PNG file with the color in /tmp using convert
|
||||
magick -size 64x64 xc:"hsv($(wl-paste))" /tmp/color.png
|
||||
|
||||
# Send a notification using the file as an icon
|
||||
notify-send "HSV color picked" "hsv($(wl-paste))" -i /tmp/color.png -a "Hyprpicker"
|
||||
|
||||
# Remove the temporal file
|
||||
rm /tmp/color.png
|
||||
|
||||
# Exit
|
||||
exit 0
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
case "$1" in
|
||||
-rgb)
|
||||
pick_rgb
|
||||
;;
|
||||
-hsv)
|
||||
pick_hsv
|
||||
;;
|
||||
-hex)
|
||||
pick_hex
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 [-rgb|-hex|-hsv]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,83 @@
|
||||
# From https://github.com/stwa/wayland-idle-inhibitor
|
||||
# License: WTFPL Version 2
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from signal import SIGINT, SIGTERM, signal
|
||||
from threading import Event
|
||||
|
||||
import setproctitle
|
||||
from pywayland.client.display import Display
|
||||
from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import \
|
||||
ZwpIdleInhibitManagerV1
|
||||
from pywayland.protocol.wayland.wl_compositor import WlCompositor
|
||||
from pywayland.protocol.wayland.wl_registry import WlRegistryProxy
|
||||
from pywayland.protocol.wayland.wl_surface import WlSurface
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlobalRegistry:
|
||||
surface: WlSurface | None = None
|
||||
inhibit_manager: ZwpIdleInhibitManagerV1 | None = None
|
||||
|
||||
|
||||
def handle_registry_global(
|
||||
wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int
|
||||
) -> None:
|
||||
global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry()
|
||||
|
||||
if iface_name == "wl_compositor":
|
||||
compositor = wl_registry.bind(id_num, WlCompositor, version)
|
||||
global_registry.surface = compositor.create_surface() # type: ignore
|
||||
elif iface_name == "zwp_idle_inhibit_manager_v1":
|
||||
global_registry.inhibit_manager = wl_registry.bind(
|
||||
id_num, ZwpIdleInhibitManagerV1, version
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
done = Event()
|
||||
signal(SIGINT, lambda _, __: done.set())
|
||||
signal(SIGTERM, lambda _, __: done.set())
|
||||
|
||||
global_registry = GlobalRegistry()
|
||||
|
||||
display = Display()
|
||||
display.connect()
|
||||
|
||||
registry = display.get_registry() # type: ignore
|
||||
registry.user_data = global_registry
|
||||
registry.dispatcher["global"] = handle_registry_global
|
||||
|
||||
def shutdown() -> None:
|
||||
display.dispatch()
|
||||
display.roundtrip()
|
||||
display.disconnect()
|
||||
|
||||
display.dispatch()
|
||||
display.roundtrip()
|
||||
|
||||
if global_registry.surface is None or global_registry.inhibit_manager is None:
|
||||
print("Wayland seems not to support idle_inhibit_unstable_v1 protocol.")
|
||||
shutdown()
|
||||
sys.exit(1)
|
||||
|
||||
inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore
|
||||
global_registry.surface
|
||||
)
|
||||
|
||||
display.dispatch()
|
||||
display.roundtrip()
|
||||
|
||||
print("Inhibiting idle...")
|
||||
done.wait()
|
||||
print("Shutting down...")
|
||||
|
||||
inhibitor.destroy()
|
||||
|
||||
shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
setproctitle.setproctitle("ax-inhibit")
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Captura con hyprshot (selección de región) y envía imagen RAW a stdout
|
||||
ocr_text=$(hyprshot -m region -z -r -s | tesseract -l eng - - 2>/dev/null)
|
||||
|
||||
# Comprueba si Tesseract devolvió algo
|
||||
if [[ -n "$ocr_text" ]]; then
|
||||
# Copia el texto reconocido al portapapeles
|
||||
echo -n "$ocr_text" | wl-copy
|
||||
notify-send -a "Ax-Shell" "OCR Success" "Text Copied to Clipboard"
|
||||
else
|
||||
notify-send -a "Ax-Shell" "OCR Failed" "No text recognized or operation failed"
|
||||
fi
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
WORK_MINUTES=25
|
||||
BREAK_MINUTES=5
|
||||
LONG_BREAK_MINUTES=15
|
||||
POMODOROS_PER_LONG_BREAK=4
|
||||
|
||||
# Get the PID of this script (excluding the grep process itself)
|
||||
MYPID=$$
|
||||
if pgrep -f "pomodoro.sh" | grep -qv "$MYPID"; then
|
||||
# Another instance is running - kill it
|
||||
notify-send "Pomodoro Timer" "Timer stopped" -a "Pomodoro"
|
||||
pkill -KILL -f "pomodoro.sh"
|
||||
exit
|
||||
fi
|
||||
|
||||
# Initialize counters
|
||||
pomodoro_count=0
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
# Work period
|
||||
notify-send "Pomodoro Timer" "Work time! ($WORK_MINUTES minutes)" -a "Pomodoro"
|
||||
sleep ${WORK_MINUTES}m
|
||||
|
||||
((pomodoro_count++))
|
||||
|
||||
if ((pomodoro_count % POMODOROS_PER_LONG_BREAK == 0)); then
|
||||
notify-send "Pomodoro Timer" "Great job! Take a long break ($LONG_BREAK_MINUTES minutes)" -a "Pomodoro"
|
||||
sleep ${LONG_BREAK_MINUTES}m
|
||||
else
|
||||
notify-send "Pomodoro Timer" "Good work! Take a short break ($BREAK_MINUTES minutes)" -a "Pomodoro"
|
||||
sleep ${BREAK_MINUTES}m
|
||||
fi
|
||||
|
||||
done
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if XDG_VIDEOS_DIR is not set
|
||||
if [ -z "$XDG_VIDEOS_DIR" ]; then
|
||||
XDG_VIDEOS_DIR="$HOME/Videos"
|
||||
fi
|
||||
|
||||
# Directorio donde se guardarán las grabaciones
|
||||
SAVE_DIR="$XDG_VIDEOS_DIR/Recordings"
|
||||
mkdir -p "$SAVE_DIR"
|
||||
|
||||
# Si ya está corriendo gpu-screen-recorder, se envía SIGINT para detenerlo correctamente
|
||||
if pgrep -f "gpu-screen-recorder" >/dev/null; then
|
||||
pkill -SIGINT -f "gpu-screen-recorder"
|
||||
|
||||
# Espera un momento para asegurarse de que la grabación se haya detenido y el archivo esté listo
|
||||
sleep 1
|
||||
|
||||
# Obtiene el último archivo grabado
|
||||
LAST_VIDEO=$(ls -t "$SAVE_DIR"/*.mp4 2>/dev/null | head -n 1)
|
||||
|
||||
# Notificación con acciones: "View" abre el archivo, "Open folder" abre la carpeta
|
||||
ACTION=$(notify-send -a "Ax-Shell" "⬜ Recording stopped" \
|
||||
-A "view=View" -A "open=Open folder")
|
||||
|
||||
if [ "$ACTION" = "view" ] && [ -n "$LAST_VIDEO" ]; then
|
||||
xdg-open "$LAST_VIDEO"
|
||||
elif [ "$ACTION" = "open" ]; then
|
||||
xdg-open "$SAVE_DIR"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Nombre del archivo de salida para la nueva grabación
|
||||
OUTPUT_FILE="$SAVE_DIR/$(date +%Y-%m-%d-%H-%M-%S).mp4"
|
||||
|
||||
# Iniciar la grabación
|
||||
notify-send -a "Ax-Shell" "🔴 Recording started"
|
||||
gpu-screen-recorder -w screen -q ultra -a default_output -ac opus -cr full -f 60 -o "$OUTPUT_FILE"
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
sleep 0.5
|
||||
|
||||
if [ -z "$XDG_PICTURES_DIR" ]; then
|
||||
XDG_PICTURES_DIR="$HOME/Pictures"
|
||||
fi
|
||||
|
||||
save_dir="${3:-$XDG_PICTURES_DIR/Screenshots}"
|
||||
save_file=$(date +'%y%m%d_%Hh%Mm%Ss_screenshot.png')
|
||||
full_path="$save_dir/$save_file"
|
||||
mkdir -p "$save_dir"
|
||||
|
||||
mockup_mode="$2"
|
||||
|
||||
print_error() {
|
||||
cat <<EOF
|
||||
./screenshot.sh <action> [mockup]
|
||||
...valid actions are...
|
||||
p : print selected screen
|
||||
s : snip selected region
|
||||
w : snip focused window
|
||||
EOF
|
||||
}
|
||||
|
||||
case $1 in
|
||||
p)
|
||||
hyprshot -z -s -m output -o "$save_dir" -f "$save_file"
|
||||
;;
|
||||
s)
|
||||
hyprshot -z -s -m region -o "$save_dir" -f "$save_file"
|
||||
;;
|
||||
w)
|
||||
hyprshot -s -m window -o "$save_dir" -f "$save_file";
|
||||
;;
|
||||
*)
|
||||
print_error
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -f "$full_path" ]; then
|
||||
# Copiar al portapapeles si no es mockup
|
||||
if [ "$mockup_mode" != "mockup" ]; then
|
||||
if command -v wl-copy >/dev/null 2>&1; then
|
||||
wl-copy < "$full_path"
|
||||
elif command -v xclip >/dev/null 2>&1; then
|
||||
xclip -selection clipboard -t image/png < "$full_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Procesar mockup
|
||||
if [ "$mockup_mode" = "mockup" ]; then
|
||||
temp_file="${full_path%.png}_temp.png"
|
||||
mockup_file="${full_path%.png}_mockup.png"
|
||||
mockup_success=true
|
||||
|
||||
# Redondear esquinas y transparencia
|
||||
if [ "$mockup_success" = true ]; then
|
||||
magick "$full_path" \
|
||||
\( +clone -alpha extract -draw 'fill black polygon 0,0 0,20 20,0 fill white circle 20,20 20,0' \
|
||||
\( +clone -flip \) -compose Multiply -composite \
|
||||
\( +clone -flop \) -compose Multiply -composite \
|
||||
\) -alpha off -compose CopyOpacity -composite "$temp_file" || mockup_success=false
|
||||
fi
|
||||
|
||||
# Añadir sombra
|
||||
if [ "$mockup_success" = true ]; then
|
||||
magick "$temp_file" \
|
||||
\( +clone -background black -shadow 60x20+0+10 -alpha set -channel A -evaluate multiply 1 +channel \) \
|
||||
+swap -background none -layers merge +repage "$mockup_file" || mockup_success=false
|
||||
fi
|
||||
|
||||
if [ "$mockup_success" = true ] && [ -f "$mockup_file" ]; then
|
||||
rm "$temp_file"
|
||||
mv "$mockup_file" "$full_path"
|
||||
if command -v wl-copy >/dev/null 2>&1; then
|
||||
wl-copy < "$full_path"
|
||||
elif command -v xclip >/dev/null 2>&1; then
|
||||
xclip -selection clipboard -t image/png < "$full_path"
|
||||
fi
|
||||
else
|
||||
echo "Warning: Mockup processing failed, manteniendo original." >&2
|
||||
rm -f "$temp_file" "$mockup_file"
|
||||
if [ "$mockup_mode" = "mockup" ]; then
|
||||
if command -v wl-copy >/dev/null 2>&1; then
|
||||
wl-copy < "$full_path"
|
||||
elif command -v xclip >/dev/null 2>&1; then
|
||||
xclip -selection clipboard -t image/png < "$full_path"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
ACTION=$(notify-send -a "Ax-Shell" -i "$full_path" "Screenshot saved" "in $full_path" \
|
||||
-A "view=View" -A "edit=Edit" -A "open=Open Folder")
|
||||
|
||||
case "$ACTION" in
|
||||
view) xdg-open "$full_path" ;;
|
||||
edit) swappy -f "$full_path" ;;
|
||||
open) xdg-open "$save_dir" ;;
|
||||
esac
|
||||
else
|
||||
notify-send -a "Ax-Shell" "Screenshot Aborted"
|
||||
fi
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Example script for opening the launcher on the focused monitor.
|
||||
This script uses the global keybind handler to open the launcher
|
||||
on whichever monitor currently has focus.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the Ax-Shell directory to Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from utils.global_keybinds import get_global_keybind_handler
|
||||
|
||||
handler = get_global_keybind_handler()
|
||||
success = handler.open_launcher()
|
||||
|
||||
if success:
|
||||
print("Launcher opened on focused monitor")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("Failed to open launcher")
|
||||
sys.exit(1)
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error importing Ax-Shell modules: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error opening launcher: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Example script for opening the overview on the focused monitor.
|
||||
This script uses the global keybind handler to open the overview
|
||||
on whichever monitor currently has focus.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the Ax-Shell directory to Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from utils.global_keybinds import get_global_keybind_handler
|
||||
|
||||
handler = get_global_keybind_handler()
|
||||
success = handler.open_overview()
|
||||
|
||||
if success:
|
||||
print("Overview opened on focused monitor")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("Failed to open overview")
|
||||
sys.exit(1)
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error importing Ax-Shell modules: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error opening overview: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Ax-Shell services package.
|
||||
Contains background services and utilities for the shell.
|
||||
"""
|
||||
@@ -0,0 +1,357 @@
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
import time
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import exec_shell_command_async, monitor_file
|
||||
from gi.repository import GLib
|
||||
from loguru import logger
|
||||
|
||||
import utils.functions as helpers
|
||||
from utils.colors import Colors
|
||||
|
||||
|
||||
class Brightness(Service):
|
||||
"""Service for controlling screen brightness using ddcutil or brightnessctl backends.
|
||||
|
||||
The service works with RAW values (0 to max_screen) for both backends:
|
||||
- brightnessctl: raw values are device-specific (e.g., 0-96000)
|
||||
- ddcutil: raw values are percentages (0-100)
|
||||
|
||||
The 'screen' signal emits percentage values (0-100) for UI display.
|
||||
"""
|
||||
|
||||
instance = None
|
||||
DDCUTIL_PARAMS = "--disable-dynamic-sleep --sleep-multiplier=0.05"
|
||||
MIN_CHANGE_THRESHOLD = 2 # Minimum brightness change to apply (percent)
|
||||
CACHE_INTERVAL = 3 # Cache duration in seconds
|
||||
POLL_INTERVAL = 500 # File polling interval in ms
|
||||
|
||||
@staticmethod
|
||||
def get_initial():
|
||||
"""Singleton to get Brightness service instance."""
|
||||
if Brightness.instance is None:
|
||||
Brightness.instance = Brightness()
|
||||
return Brightness.instance
|
||||
|
||||
@Signal
|
||||
def screen(self, value: int) -> None:
|
||||
"""Signal emitted when screen brightness changes (value: percentage from 0 to 100)."""
|
||||
pass
|
||||
|
||||
def __init__(self, backend=None, **kwargs):
|
||||
"""Initialize service with automatic backend detection."""
|
||||
super().__init__(**kwargs)
|
||||
self._pending_raw = None
|
||||
self._timer_id = None
|
||||
self._poll_timer_id = None
|
||||
self._lock = GLib.Mutex()
|
||||
self._last_percent = -1
|
||||
self._last_raw = -1
|
||||
self._last_update_time = 0
|
||||
self._last_file_mtime = 0
|
||||
|
||||
# Detect backend
|
||||
self.backend = self._detect_backend(backend)
|
||||
|
||||
self.max_screen = self._read_max_brightness() or 100
|
||||
|
||||
if self.backend:
|
||||
if self.backend == "ddcutil":
|
||||
# Initialize brightness cache
|
||||
GLib.timeout_add(100, lambda: self._update_brightness_cache())
|
||||
else:
|
||||
# Setup polling for brightness file
|
||||
self._setup_polling()
|
||||
|
||||
def _setup_polling(self):
|
||||
"""Setup periodic polling of brightness file."""
|
||||
try:
|
||||
file_path = f"/sys/class/backlight/{self._get_screen_device()}/brightness"
|
||||
if os.path.exists(file_path):
|
||||
# Initialize cache with current value
|
||||
with open(file_path) as f:
|
||||
self._last_raw = int(f.readline().strip())
|
||||
self._last_percent = (
|
||||
int((self._last_raw / self.max_screen) * 100)
|
||||
if self.max_screen > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
self._last_file_mtime = os.path.getmtime(file_path)
|
||||
self._poll_timer_id = GLib.timeout_add(
|
||||
self.POLL_INTERVAL, self._check_brightness_file
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up brightness polling: {e}")
|
||||
|
||||
def _check_brightness_file(self):
|
||||
"""Periodically check brightness file for changes."""
|
||||
try:
|
||||
file_path = f"/sys/class/backlight/{self._get_screen_device()}/brightness"
|
||||
if os.path.exists(file_path):
|
||||
current_mtime = os.path.getmtime(file_path)
|
||||
if current_mtime > self._last_file_mtime:
|
||||
self._last_file_mtime = current_mtime
|
||||
with open(file_path) as f:
|
||||
raw = int(f.readline().strip())
|
||||
|
||||
if raw != self._last_raw:
|
||||
self._last_raw = raw
|
||||
percent = (
|
||||
int((raw / self.max_screen) * 100)
|
||||
if self.max_screen > 0
|
||||
else 0
|
||||
)
|
||||
if (
|
||||
abs(percent - self._last_percent)
|
||||
>= self.MIN_CHANGE_THRESHOLD
|
||||
):
|
||||
self._last_percent = percent
|
||||
self.emit("screen", percent)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking brightness file: {e}")
|
||||
return True
|
||||
|
||||
def _detect_backend(self, backend):
|
||||
"""Detect appropriate backend for brightness control."""
|
||||
if backend:
|
||||
logger.info(f"Using forced backend: {backend}")
|
||||
return backend
|
||||
|
||||
# Try brightnessctl first (preferred for laptop internal displays)
|
||||
if helpers.executable_exists("brightnessctl"):
|
||||
device = self._get_screen_device()
|
||||
if device: # Non-empty string means device found
|
||||
logger.info(f"Using brightnessctl backend with device: {device}")
|
||||
return "brightnessctl"
|
||||
else:
|
||||
logger.debug(
|
||||
"brightnessctl is available but no backlight devices found in /sys/class/backlight/"
|
||||
)
|
||||
|
||||
# Try ddcutil for external monitors (via DDC/CI protocol)
|
||||
if helpers.executable_exists("ddcutil"):
|
||||
bus = self._detect_ddcutil_bus()
|
||||
if bus != -1:
|
||||
self.ddcutil_bus = bus
|
||||
logger.info(f"Using ddcutil backend with I2C bus: {bus}")
|
||||
return "ddcutil"
|
||||
else:
|
||||
logger.debug(
|
||||
"ddcutil is available but no DDC/CI capable monitors detected"
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"No available backend for brightness control - no backlight devices or DDC/CI monitors found"
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_screen_device(self):
|
||||
"""Return first backlight device from sysfs."""
|
||||
try:
|
||||
return os.listdir("/sys/class/backlight")[0]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _detect_ddcutil_bus(self):
|
||||
"""Detect I2C bus number for ddcutil."""
|
||||
try:
|
||||
process = subprocess.run(
|
||||
["ddcutil", "detect"], text=True, capture_output=True, timeout=2
|
||||
)
|
||||
if process.returncode == 0:
|
||||
match = re.search(r"I2C bus:\s*/dev/i2c-(\d+)", process.stdout)
|
||||
return int(match.group(1)) if match else -1
|
||||
return -1
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
def _read_max_brightness(self):
|
||||
"""Read maximum brightness value"""
|
||||
if self.backend:
|
||||
if self.backend == "ddcutil":
|
||||
try:
|
||||
process = subprocess.run(
|
||||
[
|
||||
"ddcutil",
|
||||
"--bus",
|
||||
str(self.ddcutil_bus),
|
||||
*self.DDCUTIL_PARAMS.split(),
|
||||
"getvcp",
|
||||
"10",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
if process.returncode == 0:
|
||||
match = re.search(
|
||||
r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)",
|
||||
process.stdout,
|
||||
)
|
||||
if match:
|
||||
return int(match.group(2))
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing ddcutil: {e}")
|
||||
else:
|
||||
try:
|
||||
with open(
|
||||
f"/sys/class/backlight/{self._get_screen_device()}/max_brightness"
|
||||
) as f:
|
||||
return int(f.readline().strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _update_brightness_cache(self):
|
||||
"""Update brightness cache with current value."""
|
||||
if self.backend == "ddcutil":
|
||||
self.screen_brightness # This will update the cache
|
||||
return False
|
||||
|
||||
@Property(int, "read-write")
|
||||
def screen_brightness(self):
|
||||
"""Getter returns current brightness in RAW value (0 to max_screen)."""
|
||||
if not self.backend:
|
||||
return -1
|
||||
|
||||
if self.backend == "brightnessctl":
|
||||
# Return cached raw value if available
|
||||
if self._last_raw != -1:
|
||||
return self._last_raw
|
||||
|
||||
try:
|
||||
with open(
|
||||
f"/sys/class/backlight/{self._get_screen_device()}/brightness"
|
||||
) as f:
|
||||
raw = int(f.readline().strip())
|
||||
self._last_raw = raw
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading brightness file: {e}")
|
||||
return -1
|
||||
elif self.backend == "ddcutil":
|
||||
# Use cached value if recent enough
|
||||
if (
|
||||
time.time() - self._last_update_time < self.CACHE_INTERVAL
|
||||
and self._last_raw != -1
|
||||
):
|
||||
return self._last_raw
|
||||
|
||||
try:
|
||||
process = subprocess.run(
|
||||
[
|
||||
"ddcutil",
|
||||
"--bus",
|
||||
str(self.ddcutil_bus),
|
||||
*self.DDCUTIL_PARAMS.split(),
|
||||
"getvcp",
|
||||
"10",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
if process.returncode == 0:
|
||||
match = re.search(
|
||||
r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)",
|
||||
process.stdout,
|
||||
)
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
# For ddcutil, raw value IS the current value (0-100)
|
||||
self._last_raw = current
|
||||
self._last_update_time = time.time()
|
||||
return current
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing ddcutil: {e}")
|
||||
|
||||
return self._last_raw if self._last_raw != -1 else -1
|
||||
|
||||
@screen_brightness.setter
|
||||
def screen_brightness(self, value: int):
|
||||
"""Setter accepts brightness value in RAW (0 to max_screen)."""
|
||||
self._lock.lock()
|
||||
try:
|
||||
# Limit value between 0 and max_screen
|
||||
value = max(0, min(value, self.max_screen))
|
||||
|
||||
# Check if change is significant enough (in percentage terms)
|
||||
current_percent = (
|
||||
int((self._last_raw / self.max_screen) * 100)
|
||||
if self._last_raw != -1 and self.max_screen > 0
|
||||
else -1
|
||||
)
|
||||
new_percent = (
|
||||
int((value / self.max_screen) * 100) if self.max_screen > 0 else 0
|
||||
)
|
||||
|
||||
if (
|
||||
abs(new_percent - current_percent) < self.MIN_CHANGE_THRESHOLD
|
||||
and self._last_raw != -1
|
||||
):
|
||||
return
|
||||
|
||||
self._pending_raw = value
|
||||
|
||||
# Use a single timer for applying changes
|
||||
if self._timer_id:
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = GLib.timeout_add(50, self._apply_brightness)
|
||||
finally:
|
||||
self._lock.unlock()
|
||||
|
||||
def _apply_brightness(self):
|
||||
"""Apply pending brightness change with optimized debouncing."""
|
||||
self._lock.lock()
|
||||
try:
|
||||
if self._pending_raw is None:
|
||||
self._timer_id = None
|
||||
return False
|
||||
|
||||
raw = self._pending_raw
|
||||
self._pending_raw = None
|
||||
self._timer_id = None
|
||||
finally:
|
||||
self._lock.unlock()
|
||||
|
||||
try:
|
||||
# Update cache before executing command for faster UI response
|
||||
self._last_raw = raw
|
||||
|
||||
# Calculate percentage for signal emission
|
||||
percent = int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0
|
||||
|
||||
if self.backend == "brightnessctl":
|
||||
self.emit("screen", percent)
|
||||
exec_shell_command_async(
|
||||
f"brightnessctl --device '{self._get_screen_device()}' set {raw}"
|
||||
)
|
||||
elif self.backend == "ddcutil":
|
||||
self._last_update_time = time.time()
|
||||
self.emit("screen", percent)
|
||||
exec_shell_command_async(
|
||||
f"ddcutil --bus {self.ddcutil_bus} {self.DDCUTIL_PARAMS} --terse setvcp 10 {raw}",
|
||||
lambda exit_code, stdout, stderr: logger.error(
|
||||
f"ddcutil error (code {exit_code}): {stderr}"
|
||||
)
|
||||
if exit_code != 0
|
||||
else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting brightness: {e}")
|
||||
return False
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources when service is stopped."""
|
||||
if self._timer_id:
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = None
|
||||
|
||||
if self._poll_timer_id:
|
||||
GLib.source_remove(self._poll_timer_id)
|
||||
self._poll_timer_id = None
|
||||
@@ -0,0 +1,233 @@
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Signal:
|
||||
"""Simple signal implementation for monitor focus service."""
|
||||
|
||||
def __init__(self):
|
||||
self._callbacks = []
|
||||
|
||||
def connect(self, callback):
|
||||
"""Connect a callback to this signal."""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def emit(self, *args, **kwargs):
|
||||
"""Emit the signal to all connected callbacks."""
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(*args, **kwargs)
|
||||
except Exception as e:
|
||||
print(f"Error in signal callback: {e}")
|
||||
|
||||
|
||||
class MonitorFocusService:
|
||||
"""
|
||||
Service to track monitor focus changes through Hyprland events.
|
||||
|
||||
Listens to 'focusedmon' and 'workspace' events and emits signals
|
||||
when monitor focus changes.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if hasattr(self, '_initialized'):
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
self._monitor_name_to_id = {}
|
||||
self._monitor_info = {} # Store rich monitor information
|
||||
self._current_workspace = 1
|
||||
self._current_monitor_name = ""
|
||||
self._listening = False
|
||||
self._thread = None
|
||||
|
||||
# Signals
|
||||
self.monitor_focused = Signal()
|
||||
self.workspace_changed = Signal()
|
||||
|
||||
self._update_monitor_mapping()
|
||||
self.start_listening()
|
||||
|
||||
def _update_monitor_mapping(self):
|
||||
"""Update the monitor name to ID mapping with rich monitor information."""
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from utils.monitor_manager import get_monitor_manager
|
||||
manager = get_monitor_manager()
|
||||
monitors = manager.get_monitors()
|
||||
|
||||
self._monitor_name_to_id = {}
|
||||
self._monitor_info = {} # Store rich monitor information
|
||||
for monitor in monitors:
|
||||
monitor_name = monitor['name']
|
||||
monitor_id = monitor['id']
|
||||
self._monitor_name_to_id[monitor_name] = monitor_id
|
||||
self._monitor_info[monitor_id] = {
|
||||
'name': monitor_name,
|
||||
'width': monitor.get('width', 1920),
|
||||
'height': monitor.get('height', 1080),
|
||||
'x': monitor.get('x', 0),
|
||||
'y': monitor.get('y', 0),
|
||||
'scale': monitor.get('scale', 1.0),
|
||||
'focused': monitor.get('focused', False)
|
||||
}
|
||||
except ImportError:
|
||||
# Fallback if monitor manager not available yet
|
||||
self._monitor_name_to_id = {}
|
||||
self._monitor_info = {}
|
||||
|
||||
def start_listening(self):
|
||||
"""Start listening to Hyprland events in a separate thread."""
|
||||
if self._listening:
|
||||
return
|
||||
|
||||
self._listening = True
|
||||
self._thread = threading.Thread(target=self._listen_to_hyprland, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop_listening(self):
|
||||
"""Stop listening to Hyprland events."""
|
||||
self._listening = False
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def _listen_to_hyprland(self):
|
||||
"""Listen to Hyprland events via socat."""
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["socat", "-U", "-", "UNIX-CONNECT:/tmp/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
while self._listening and process.poll() is None:
|
||||
if process.stdout:
|
||||
line = process.stdout.readline()
|
||||
if line:
|
||||
self._handle_hyprland_event(line.strip())
|
||||
|
||||
except (subprocess.SubprocessError, FileNotFoundError) as e:
|
||||
print(f"MonitorFocusService: Error listening to Hyprland: {e}")
|
||||
except Exception as e:
|
||||
print(f"MonitorFocusService: Unexpected error: {e}")
|
||||
|
||||
def _handle_hyprland_event(self, event_line: str):
|
||||
"""Parse and handle Hyprland event."""
|
||||
try:
|
||||
if '>>' not in event_line:
|
||||
return
|
||||
|
||||
parts = event_line.split('>>')
|
||||
if len(parts) < 2:
|
||||
return
|
||||
|
||||
event_type = parts[0]
|
||||
event_data = parts[1]
|
||||
|
||||
if event_type == "focusedmon":
|
||||
self._handle_focused_monitor(event_data)
|
||||
elif event_type == "workspace":
|
||||
self._handle_workspace_change(event_data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"MonitorFocusService: Error handling event '{event_line}': {e}")
|
||||
|
||||
def _handle_focused_monitor(self, data: str):
|
||||
"""Handle focusedmon event: monitor_name,workspace_name"""
|
||||
try:
|
||||
parts = data.split(',')
|
||||
if len(parts) >= 2:
|
||||
monitor_name = parts[0]
|
||||
workspace_name = parts[1]
|
||||
|
||||
# Update monitor mapping if needed
|
||||
if monitor_name not in self._monitor_name_to_id:
|
||||
self._update_monitor_mapping()
|
||||
|
||||
monitor_id = self._monitor_name_to_id.get(monitor_name, 0)
|
||||
|
||||
# Extract workspace ID from name
|
||||
try:
|
||||
workspace_id = int(workspace_name)
|
||||
except ValueError:
|
||||
workspace_id = 1
|
||||
|
||||
self._current_monitor_name = monitor_name
|
||||
self._current_workspace = workspace_id
|
||||
|
||||
# Emit signal
|
||||
self.monitor_focused.emit(monitor_name, monitor_id, workspace_id)
|
||||
|
||||
except Exception as e:
|
||||
print(f"MonitorFocusService: Error in _handle_focused_monitor: {e}")
|
||||
|
||||
def _handle_workspace_change(self, data: str):
|
||||
"""Handle workspace event: workspace_name"""
|
||||
try:
|
||||
workspace_name = data.strip()
|
||||
|
||||
# Extract workspace ID
|
||||
try:
|
||||
workspace_id = int(workspace_name)
|
||||
except ValueError:
|
||||
workspace_id = 1
|
||||
|
||||
self._current_workspace = workspace_id
|
||||
|
||||
# Emit signal
|
||||
self.workspace_changed.emit(workspace_id, self._current_monitor_name)
|
||||
|
||||
except Exception as e:
|
||||
print(f"MonitorFocusService: Error in _handle_workspace_change: {e}")
|
||||
|
||||
def get_current_monitor_id(self) -> int:
|
||||
"""Get current monitor ID."""
|
||||
return self._monitor_name_to_id.get(self._current_monitor_name, 0)
|
||||
|
||||
def get_current_workspace(self) -> int:
|
||||
"""Get current workspace ID."""
|
||||
return self._current_workspace
|
||||
|
||||
def get_monitor_id_by_name(self, monitor_name: str) -> Optional[int]:
|
||||
"""Get monitor ID by name."""
|
||||
return self._monitor_name_to_id.get(monitor_name)
|
||||
|
||||
def get_monitor_info(self, monitor_id: int) -> Optional[dict]:
|
||||
"""Get rich monitor information by ID."""
|
||||
return self._monitor_info.get(monitor_id)
|
||||
|
||||
def get_current_monitor_info(self) -> Optional[dict]:
|
||||
"""Get rich information for current monitor."""
|
||||
current_id = self.get_current_monitor_id()
|
||||
return self.get_monitor_info(current_id)
|
||||
|
||||
def get_monitor_scale(self, monitor_id: int) -> float:
|
||||
"""Get monitor scale factor by ID."""
|
||||
info = self.get_monitor_info(monitor_id)
|
||||
return info.get('scale', 1.0) if info else 1.0
|
||||
|
||||
def get_current_monitor_scale(self) -> float:
|
||||
"""Get current monitor scale factor."""
|
||||
return self.get_monitor_scale(self.get_current_monitor_id())
|
||||
|
||||
|
||||
# Singleton accessor
|
||||
_monitor_focus_service_instance = None
|
||||
|
||||
def get_monitor_focus_service() -> MonitorFocusService:
|
||||
"""Get the global MonitorFocusService instance."""
|
||||
global _monitor_focus_service_instance
|
||||
if _monitor_focus_service_instance is None:
|
||||
_monitor_focus_service_instance = MonitorFocusService()
|
||||
return _monitor_focus_service_instance
|
||||
@@ -0,0 +1,279 @@
|
||||
# Standard library imports
|
||||
import contextlib
|
||||
|
||||
# Third-party imports
|
||||
import gi
|
||||
from gi.repository import GLib # type: ignore
|
||||
from loguru import logger
|
||||
|
||||
# Fabric imports
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import bulk_connect
|
||||
|
||||
class PlayerctlImportError(ImportError):
|
||||
"""An error to raise when playerctl is not installed."""
|
||||
def __init__(self, *args):
|
||||
super().__init__(
|
||||
"Playerctl is not installed, please install it first",
|
||||
*args,
|
||||
)
|
||||
|
||||
# Try to import Playerctl, raise custom error if not available
|
||||
try:
|
||||
gi.require_version("Playerctl", "2.0")
|
||||
from gi.repository import Playerctl
|
||||
except ValueError:
|
||||
raise PlayerctlImportError
|
||||
|
||||
|
||||
class MprisPlayer(Service):
|
||||
"""A service to manage a mpris player."""
|
||||
|
||||
@Signal
|
||||
def exit(self, value: bool) -> bool: ...
|
||||
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
player: Playerctl.Player,
|
||||
**kwargs,
|
||||
):
|
||||
self._signal_connectors: dict = {}
|
||||
self._player: Playerctl.Player = player
|
||||
super().__init__(**kwargs)
|
||||
for sn in ["playback-status", "loop-status", "shuffle", "volume", "seeked"]:
|
||||
self._signal_connectors[sn] = self._player.connect(
|
||||
sn,
|
||||
lambda *args, sn=sn: self.notifier(sn, args),
|
||||
)
|
||||
|
||||
self._signal_connectors["exit"] = self._player.connect(
|
||||
"exit",
|
||||
self.on_player_exit,
|
||||
)
|
||||
self._signal_connectors["metadata"] = self._player.connect(
|
||||
"metadata",
|
||||
lambda *args: self.update_status(),
|
||||
)
|
||||
GLib.idle_add(lambda *args: self.update_status_once())
|
||||
|
||||
def update_status(self):
|
||||
# schedule each notifier asynchronously.
|
||||
def notify_property(prop):
|
||||
if self.get_property(prop) is not None:
|
||||
self.notifier(prop)
|
||||
for prop in [
|
||||
"metadata",
|
||||
"title",
|
||||
"artist",
|
||||
"arturl",
|
||||
"length",
|
||||
]:
|
||||
GLib.idle_add(lambda p=prop: (notify_property(p), False))
|
||||
for prop in [
|
||||
"can-seek",
|
||||
"can-pause",
|
||||
"can-shuffle",
|
||||
"can-go-next",
|
||||
"can-go-previous",
|
||||
]:
|
||||
GLib.idle_add(lambda p=prop: (self.notifier(p), False))
|
||||
|
||||
def update_status_once(self):
|
||||
# schedule notifier calls for each property
|
||||
def notify_all():
|
||||
for prop in self.list_properties(): # type: ignore
|
||||
self.notifier(prop.name)
|
||||
return False
|
||||
GLib.idle_add(notify_all, priority=GLib.PRIORITY_DEFAULT_IDLE)
|
||||
|
||||
def notifier(self, name: str, args=None):
|
||||
def notify_and_emit():
|
||||
self.notify(name)
|
||||
self.emit("changed")
|
||||
return False
|
||||
GLib.idle_add(notify_and_emit, priority=GLib.PRIORITY_DEFAULT_IDLE)
|
||||
|
||||
def on_player_exit(self, player):
|
||||
for id in list(self._signal_connectors.values()):
|
||||
with contextlib.suppress(Exception):
|
||||
self._player.disconnect(id)
|
||||
del self._signal_connectors
|
||||
GLib.idle_add(lambda: (self.emit("exit", True), False))
|
||||
del self._player
|
||||
|
||||
def toggle_shuffle(self):
|
||||
if self.can_shuffle:
|
||||
# schedule the shuffle toggle in the GLib idle loop
|
||||
GLib.idle_add(lambda: (setattr(self, 'shuffle', not self.shuffle), False))
|
||||
# else do nothing
|
||||
|
||||
def play_pause(self):
|
||||
if self.can_pause:
|
||||
GLib.idle_add(lambda: (self._player.play_pause(), False))
|
||||
|
||||
def next(self):
|
||||
if self.can_go_next:
|
||||
GLib.idle_add(lambda: (self._player.next(), False))
|
||||
|
||||
def previous(self):
|
||||
if self.can_go_previous:
|
||||
GLib.idle_add(lambda: (self._player.previous(), False))
|
||||
|
||||
# Properties
|
||||
@Property(str, "readable")
|
||||
def player_name(self) -> int:
|
||||
return self._player.get_property("player-name") # type: ignore
|
||||
|
||||
@Property(int, "read-write", default_value=0)
|
||||
def position(self) -> int:
|
||||
return self._player.get_property("position") # type: ignore
|
||||
|
||||
@position.setter
|
||||
def position(self, new_pos: int):
|
||||
self._player.set_position(new_pos)
|
||||
|
||||
@Property(object, "readable")
|
||||
def metadata(self) -> dict:
|
||||
return self._player.get_property("metadata") # type: ignore
|
||||
|
||||
@Property(str or None, "readable")
|
||||
def arturl(self) -> str | None:
|
||||
if "mpris:artUrl" in self.metadata.keys(): # type: ignore # noqa: SIM118
|
||||
return self.metadata["mpris:artUrl"] # type: ignore
|
||||
return None
|
||||
|
||||
@Property(str or None, "readable")
|
||||
def length(self) -> str | None:
|
||||
if "mpris:length" in self.metadata.keys(): # type: ignore # noqa: SIM118
|
||||
return self.metadata["mpris:length"] # type: ignore
|
||||
return None
|
||||
|
||||
@Property(str, "readable")
|
||||
def artist(self) -> str:
|
||||
artist = self._player.get_artist() # type: ignore
|
||||
if isinstance(artist, (list, tuple)):
|
||||
return ", ".join(artist)
|
||||
return artist
|
||||
|
||||
@Property(str, "readable")
|
||||
def album(self) -> str:
|
||||
return self._player.get_album() # type: ignore
|
||||
|
||||
@Property(str, "readable")
|
||||
def title(self) -> str:
|
||||
if self._player is None:
|
||||
return ""
|
||||
title_data = self._player.get_title()
|
||||
return title_data if isinstance(title_data, str) else ""
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def shuffle(self) -> bool:
|
||||
return self._player.get_property("shuffle") # type: ignore
|
||||
|
||||
@shuffle.setter
|
||||
def shuffle(self, do_shuffle: bool):
|
||||
self.notifier("shuffle")
|
||||
return self._player.set_shuffle(do_shuffle)
|
||||
|
||||
@Property(str, "readable")
|
||||
def playback_status(self) -> str:
|
||||
return {
|
||||
Playerctl.PlaybackStatus.PAUSED: "paused",
|
||||
Playerctl.PlaybackStatus.PLAYING: "playing",
|
||||
Playerctl.PlaybackStatus.STOPPED: "stopped",
|
||||
}.get(self._player.get_property("playback_status"), "unknown") # type: ignore
|
||||
|
||||
@Property(str, "read-write")
|
||||
def loop_status(self) -> str:
|
||||
return {
|
||||
Playerctl.LoopStatus.NONE: "none",
|
||||
Playerctl.LoopStatus.TRACK: "track",
|
||||
Playerctl.LoopStatus.PLAYLIST: "playlist",
|
||||
}.get(self._player.get_property("loop_status"), "unknown") # type: ignore
|
||||
|
||||
@loop_status.setter
|
||||
def loop_status(self, status: str):
|
||||
loop_status = {
|
||||
"none": Playerctl.LoopStatus.NONE,
|
||||
"track": Playerctl.LoopStatus.TRACK,
|
||||
"playlist": Playerctl.LoopStatus.PLAYLIST,
|
||||
}.get(status)
|
||||
self._player.set_loop_status(loop_status) if loop_status else None
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_go_next(self) -> bool:
|
||||
return self._player.get_property("can_go_next") # type: ignore
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_go_previous(self) -> bool:
|
||||
return self._player.get_property("can_go_previous") # type: ignore
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_seek(self) -> bool:
|
||||
return self._player.get_property("can_seek") # type: ignore
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_pause(self) -> bool:
|
||||
return self._player.get_property("can_pause") # type: ignore
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_shuffle(self) -> bool:
|
||||
try:
|
||||
self._player.set_shuffle(self._player.get_property("shuffle"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_loop(self) -> bool:
|
||||
try:
|
||||
self._player.set_shuffle(self._player.get_property("shuffle"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class MprisPlayerManager(Service):
|
||||
"""A service to manage mpris players."""
|
||||
|
||||
@Signal
|
||||
def player_appeared(self, player: Playerctl.Player) -> Playerctl.Player: ...
|
||||
|
||||
@Signal
|
||||
def player_vanished(self, player_name: str) -> str: ...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
**kwargs,
|
||||
):
|
||||
self._manager = Playerctl.PlayerManager.new()
|
||||
bulk_connect(
|
||||
self._manager,
|
||||
{
|
||||
"name-appeared": self.on_name_appeard,
|
||||
"name-vanished": self.on_name_vanished,
|
||||
},
|
||||
)
|
||||
self.add_players()
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def on_name_appeard(self, manager, player_name: Playerctl.PlayerName):
|
||||
logger.info(f"[MprisPlayer] {player_name.name} appeared")
|
||||
new_player = Playerctl.Player.new_from_name(player_name)
|
||||
manager.manage_player(new_player)
|
||||
self.emit("player-appeared", new_player) # type: ignore
|
||||
|
||||
def on_name_vanished(self, manager, player_name: Playerctl.PlayerName):
|
||||
logger.info(f"[MprisPlayer] {player_name.name} vanished")
|
||||
self.emit("player-vanished", player_name.name) # type: ignore
|
||||
|
||||
def add_players(self):
|
||||
for player in self._manager.get_property("player-names"): # type: ignore
|
||||
self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore
|
||||
|
||||
@Property(object, "readable")
|
||||
def players(self):
|
||||
return self._manager.get_property("players") # type: ignore
|
||||
@@ -0,0 +1,323 @@
|
||||
from typing import Any, List, Literal
|
||||
|
||||
import gi
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import bulk_connect, exec_shell_command_async
|
||||
from gi.repository import Gio
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
gi.require_version("NM", "1.0")
|
||||
from gi.repository import NM
|
||||
except ValueError:
|
||||
logger.error("Failed to start network manager")
|
||||
|
||||
|
||||
class Wifi(Service):
|
||||
"""A service to manage the wifi connection."""
|
||||
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def enabled(self) -> bool: ...
|
||||
|
||||
def __init__(self, client: NM.Client, device: NM.DeviceWifi, **kwargs):
|
||||
self._client: NM.Client = client
|
||||
self._device: NM.DeviceWifi = device
|
||||
self._ap: NM.AccessPoint | None = None
|
||||
self._ap_signal: int | None = None
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self._client.connect(
|
||||
"notify::wireless-enabled",
|
||||
lambda *args: self.notifier("enabled", args),
|
||||
)
|
||||
if self._device:
|
||||
bulk_connect(
|
||||
self._device,
|
||||
{
|
||||
"notify::active-access-point": lambda *args: self._activate_ap(),
|
||||
"access-point-added": lambda *args: self.emit("changed"),
|
||||
"access-point-removed": lambda *args: self.emit("changed"),
|
||||
"state-changed": lambda *args: self.ap_update(),
|
||||
},
|
||||
)
|
||||
self._activate_ap()
|
||||
|
||||
def ap_update(self):
|
||||
self.emit("changed")
|
||||
for sn in [
|
||||
"enabled",
|
||||
"internet",
|
||||
"strength",
|
||||
"frequency",
|
||||
"access-points",
|
||||
"ssid",
|
||||
"state",
|
||||
"icon-name",
|
||||
]:
|
||||
self.notify(sn)
|
||||
|
||||
def _activate_ap(self):
|
||||
if self._ap:
|
||||
self._ap.disconnect(self._ap_signal)
|
||||
self._ap = self._device.get_active_access_point()
|
||||
if not self._ap:
|
||||
return
|
||||
|
||||
self._ap_signal = self._ap.connect(
|
||||
"notify::strength", lambda *args: self.ap_update()
|
||||
) # type: ignore
|
||||
|
||||
def toggle_wifi(self):
|
||||
self._client.wireless_set_enabled(not self._client.wireless_get_enabled())
|
||||
|
||||
# def set_active_ap(self, ap):
|
||||
# self._device.access
|
||||
|
||||
def scan(self):
|
||||
self._device.request_scan_async(
|
||||
None,
|
||||
lambda device, result: [
|
||||
device.request_scan_finish(result),
|
||||
self.emit("changed"),
|
||||
],
|
||||
)
|
||||
|
||||
def notifier(self, name: str, *args):
|
||||
self.notify(name)
|
||||
self.emit("changed")
|
||||
return
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def enabled(self) -> bool: # type: ignore
|
||||
return bool(self._client.wireless_get_enabled())
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self._client.wireless_set_enabled(value)
|
||||
|
||||
@Property(int, "readable")
|
||||
def strength(self):
|
||||
return self._ap.get_strength() if self._ap else -1
|
||||
|
||||
@Property(str, "readable")
|
||||
def icon_name(self):
|
||||
if not self._ap:
|
||||
return "network-wireless-disabled-symbolic"
|
||||
|
||||
if self.internet == "activated":
|
||||
return {
|
||||
80: "network-wireless-signal-excellent-symbolic",
|
||||
60: "network-wireless-signal-good-symbolic",
|
||||
40: "network-wireless-signal-ok-symbolic",
|
||||
20: "network-wireless-signal-weak-symbolic",
|
||||
00: "network-wireless-signal-none-symbolic",
|
||||
}.get(
|
||||
min(80, 20 * round(self._ap.get_strength() / 20)),
|
||||
"network-wireless-no-route-symbolic",
|
||||
)
|
||||
if self.internet == "activating":
|
||||
return "network-wireless-acquiring-symbolic"
|
||||
|
||||
return "network-wireless-offline-symbolic"
|
||||
|
||||
@Property(int, "readable")
|
||||
def frequency(self):
|
||||
return self._ap.get_frequency() if self._ap else -1
|
||||
|
||||
@Property(int, "readable")
|
||||
def internet(self):
|
||||
return {
|
||||
NM.ActiveConnectionState.ACTIVATED: "activated",
|
||||
NM.ActiveConnectionState.ACTIVATING: "activating",
|
||||
NM.ActiveConnectionState.DEACTIVATING: "deactivating",
|
||||
NM.ActiveConnectionState.DEACTIVATED: "deactivated",
|
||||
}.get(
|
||||
self._device.get_active_connection().get_state(),
|
||||
"unknown",
|
||||
)
|
||||
|
||||
@Property(object, "readable")
|
||||
def access_points(self) -> List[object]:
|
||||
points: list[NM.AccessPoint] = self._device.get_access_points()
|
||||
|
||||
def make_ap_dict(ap: NM.AccessPoint):
|
||||
return {
|
||||
"bssid": ap.get_bssid(),
|
||||
# "address": ap.get_
|
||||
"last_seen": ap.get_last_seen(),
|
||||
"ssid": NM.utils_ssid_to_utf8(ap.get_ssid().get_data())
|
||||
if ap.get_ssid()
|
||||
else "Unknown",
|
||||
"active-ap": self._ap,
|
||||
"strength": ap.get_strength(),
|
||||
"frequency": ap.get_frequency(),
|
||||
"icon-name": {
|
||||
80: "network-wireless-signal-excellent-symbolic",
|
||||
60: "network-wireless-signal-good-symbolic",
|
||||
40: "network-wireless-signal-ok-symbolic",
|
||||
20: "network-wireless-signal-weak-symbolic",
|
||||
00: "network-wireless-signal-none-symbolic",
|
||||
}.get(
|
||||
min(80, 20 * round(ap.get_strength() / 20)),
|
||||
"network-wireless-no-route-symbolic",
|
||||
),
|
||||
}
|
||||
|
||||
return list(map(make_ap_dict, points))
|
||||
|
||||
@Property(str, "readable")
|
||||
def ssid(self):
|
||||
if not self._ap:
|
||||
return "Disconnected"
|
||||
ssid = self._ap.get_ssid().get_data()
|
||||
return NM.utils_ssid_to_utf8(ssid) if ssid else "Unknown"
|
||||
|
||||
@Property(int, "readable")
|
||||
def state(self):
|
||||
return {
|
||||
NM.DeviceState.UNMANAGED: "unmanaged",
|
||||
NM.DeviceState.UNAVAILABLE: "unavailable",
|
||||
NM.DeviceState.DISCONNECTED: "disconnected",
|
||||
NM.DeviceState.PREPARE: "prepare",
|
||||
NM.DeviceState.CONFIG: "config",
|
||||
NM.DeviceState.NEED_AUTH: "need_auth",
|
||||
NM.DeviceState.IP_CONFIG: "ip_config",
|
||||
NM.DeviceState.IP_CHECK: "ip_check",
|
||||
NM.DeviceState.SECONDARIES: "secondaries",
|
||||
NM.DeviceState.ACTIVATED: "activated",
|
||||
NM.DeviceState.DEACTIVATING: "deactivating",
|
||||
NM.DeviceState.FAILED: "failed",
|
||||
}.get(self._device.get_state(), "unknown")
|
||||
|
||||
|
||||
class Ethernet(Service):
|
||||
"""A service to manage the ethernet connection."""
|
||||
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def enabled(self) -> bool: ...
|
||||
|
||||
@Property(int, "readable")
|
||||
def speed(self) -> int:
|
||||
return self._device.get_speed()
|
||||
|
||||
@Property(str, "readable")
|
||||
def internet(self) -> str:
|
||||
return {
|
||||
NM.ActiveConnectionState.ACTIVATED: "activated",
|
||||
NM.ActiveConnectionState.ACTIVATING: "activating",
|
||||
NM.ActiveConnectionState.DEACTIVATING: "deactivating",
|
||||
NM.ActiveConnectionState.DEACTIVATED: "deactivated",
|
||||
}.get(
|
||||
self._device.get_active_connection().get_state(),
|
||||
"disconnected",
|
||||
)
|
||||
|
||||
@Property(str, "readable")
|
||||
def icon_name(self) -> str:
|
||||
network = self.internet
|
||||
if network == "activated":
|
||||
return "network-wired-symbolic"
|
||||
|
||||
elif network == "activating":
|
||||
return "network-wired-acquiring-symbolic"
|
||||
|
||||
elif self._device.get_connectivity != NM.ConnectivityState.FULL:
|
||||
return "network-wired-no-route-symbolic"
|
||||
|
||||
return "network-wired-disconnected-symbolic"
|
||||
|
||||
def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._client: NM.Client = client
|
||||
self._device: NM.DeviceEthernet = device
|
||||
|
||||
for pn in (
|
||||
"active-connection",
|
||||
"icon-name",
|
||||
"internet",
|
||||
"speed",
|
||||
"state",
|
||||
):
|
||||
self._device.connect(f"notify::{pn}", lambda *_: self.notifier(pn))
|
||||
|
||||
self._device.connect("notify::speed", lambda *_: print(_))
|
||||
|
||||
def notifier(self, pn):
|
||||
self.notify(pn)
|
||||
self.emit("changed")
|
||||
|
||||
|
||||
class NetworkClient(Service):
|
||||
"""A service to manage the network connections."""
|
||||
|
||||
@Signal
|
||||
def device_ready(self) -> None: ...
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._client: NM.Client | None = None
|
||||
self.wifi_device: Wifi | None = None
|
||||
self.ethernet_device: Ethernet | None = None
|
||||
super().__init__(**kwargs)
|
||||
NM.Client.new_async(
|
||||
cancellable=None,
|
||||
callback=self._init_network_client,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _init_network_client(self, client: NM.Client, task: Gio.Task, **kwargs):
|
||||
self._client = client
|
||||
wifi_device: NM.DeviceWifi | None = self._get_device(NM.DeviceType.WIFI) # type: ignore
|
||||
ethernet_device: NM.DeviceEthernet | None = self._get_device(
|
||||
NM.DeviceType.ETHERNET
|
||||
)
|
||||
|
||||
if wifi_device:
|
||||
self.wifi_device = Wifi(self._client, wifi_device)
|
||||
self.emit("device-ready")
|
||||
|
||||
if ethernet_device:
|
||||
self.ethernet_device = Ethernet(client=self._client, device=ethernet_device)
|
||||
self.emit("device-ready")
|
||||
|
||||
self.notify("primary-device")
|
||||
|
||||
def _get_device(self, device_type) -> Any:
|
||||
devices: List[NM.Device] = self._client.get_devices() # type: ignore
|
||||
return next(
|
||||
(
|
||||
x
|
||||
for x in devices
|
||||
if x.get_device_type() == device_type
|
||||
and x.get_active_connection() is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _get_primary_device(self) -> Literal["wifi", "wired"] | None:
|
||||
if not self._client:
|
||||
return None
|
||||
return (
|
||||
"wifi"
|
||||
if "wireless"
|
||||
in str(self._client.get_primary_connection().get_connection_type())
|
||||
else "wired"
|
||||
if "ethernet"
|
||||
in str(self._client.get_primary_connection().get_connection_type())
|
||||
else None
|
||||
)
|
||||
|
||||
def connect_wifi_bssid(self, bssid):
|
||||
# We are using nmcli here, idk im lazy
|
||||
exec_shell_command_async(
|
||||
f"nmcli device wifi connect {bssid}", lambda *args: print(args)
|
||||
)
|
||||
|
||||
@Property(str, "readable")
|
||||
def primary_device(self) -> Literal["wifi", "wired"] | None:
|
||||
return self._get_primary_device()
|
||||
@@ -0,0 +1,126 @@
|
||||
#bluetooth-header,
|
||||
#network-header {
|
||||
border: 2px solid var(--surface);
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#bluetooth-device,
|
||||
#wifi-ap-slot {
|
||||
border: 2px solid var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#bluetooth-scan,
|
||||
#bluetooth-back,
|
||||
#network-refresh,
|
||||
#network-back {
|
||||
background-color: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#bluetooth-back-label,
|
||||
#network-back-label {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#bluetooth-scan:hover,
|
||||
#bluetooth-back:hover,
|
||||
#network-refresh:hover,
|
||||
#network-back:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#bluetooth-scan label,
|
||||
#bluetooth-toggle label,
|
||||
#wifi-connect-button label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#bluetooth-text,
|
||||
#bluetooth-section {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#bluetooth-section {
|
||||
background-color: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#bluetooth-connection {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#bluetooth-connect,
|
||||
#wifi-connect-button {
|
||||
font-weight: bold;
|
||||
background-color: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#wifi-connect-button.connected {
|
||||
background-color: var(--green);
|
||||
}
|
||||
|
||||
#wifi-connect-button.connected label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#bluetooth-connect.connected {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
|
||||
#bluetooth-connect.connected label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#bluetooth-connect:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#bluetooth-paired,
|
||||
#bluetooth-available {
|
||||
background-color: var(--surface);
|
||||
border-radius: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#bt-sep {
|
||||
/*padding: 1px;*/
|
||||
border-radius: 16px;
|
||||
/*background-color: var(--surface);*/
|
||||
/*margin: 0 16px;*/
|
||||
}
|
||||
|
||||
#bluetooth-scan-label,
|
||||
#network-refresh-label {
|
||||
font-size: 20px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#bluetooth-scan.scanning {
|
||||
animation: blink 0.5s ease infinite;
|
||||
}
|
||||
|
||||
#bluetooth-scan-label.scanning {
|
||||
animation: blink 0.5s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
background-color: var(--blue);
|
||||
color: var(--shadow);
|
||||
}
|
||||
50% {
|
||||
background-color: var(--shadow);
|
||||
color: var(--blue);
|
||||
}
|
||||
100% {
|
||||
background-color: var(--blue);
|
||||
color: var(--shadow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
#bar-inner,
|
||||
#bar-inner.pills {
|
||||
margin: 8px;
|
||||
opacity: 1;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#bar-inner.dense,
|
||||
#bar-inner.edge,
|
||||
#bar-inner.edge.vertical,
|
||||
#bar-inner.edgecenter.vertical {
|
||||
padding: 4px;
|
||||
background-color: var(--shadow);
|
||||
border-style: solid;
|
||||
border-color: var(--surface);
|
||||
}
|
||||
|
||||
#bar-inner.dense {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
#bar-inner.edge {
|
||||
border-width: 0 0 2px 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* #bar-inner.edge.vertical { */
|
||||
/* border-width: 0 2px 0 0; */
|
||||
/* border-radius: 0; */
|
||||
/* } */
|
||||
|
||||
#bar-inner.edge.bottom {
|
||||
border-width: 2px 0 0 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#bar-inner.edge.vertical.right {
|
||||
border-width: 0 0 0 2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#bar-inner.edge.vertical.left {
|
||||
border-width: 0 2px 0 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#bar-inner.edgecenter.vertical {
|
||||
border-width: 2px;
|
||||
border-left-width: 0;
|
||||
border-radius: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
#bar-inner.edge.vertical.right {
|
||||
border-width: 0 0 0 2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#bar-inner.edgecenter.vertical.right {
|
||||
border-width: 2px;
|
||||
border-right-width: 0;
|
||||
border-radius: 16px 0 0 16px;
|
||||
}
|
||||
|
||||
#bar-inner.hidden {
|
||||
margin: 8px;
|
||||
margin-top: -40px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#date-time {
|
||||
background-color: var(--shadow);
|
||||
min-height: 36px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
#date-time.vertical {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
#date-time.invert {
|
||||
background-color: var(--surface);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
menu > menuitem > label,
|
||||
#date-time > label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#language {
|
||||
min-height: 36px;
|
||||
background-color: var(--shadow);
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
#language.invert {
|
||||
background-color: var(--surface);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#lang-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#lang-label.icon {
|
||||
color: var(--primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#weather {
|
||||
background-color: var(--shadow);
|
||||
padding: 0 8px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
#weather.invert {
|
||||
background-color: var(--surface);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#weather-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#systray {
|
||||
background-color: var(--shadow);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#systray.invert {
|
||||
background-color: var(--surface);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
menu {
|
||||
border: solid 1px;
|
||||
border-radius: 16px;
|
||||
border-color: var(--surface);
|
||||
background-color: var(--shadow);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
menu > menuitem {
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
menu > menuitem:hover {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
menu > menuitem:hover > label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
tooltip {
|
||||
border: solid 1px;
|
||||
border-color: var(--surface);
|
||||
background-color: var(--shadow);
|
||||
animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes tooltipShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
tooltip > * {
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#button-bar {
|
||||
padding: 4px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
background-color: var(--shadow);
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#button-bar.invert {
|
||||
background-color: var(--surface);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#button-bar-label {
|
||||
color: var(--primary);
|
||||
font-size: 20px;
|
||||
padding: 4px;
|
||||
border-radius: 40px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#button-bar:hover #button-bar-label {
|
||||
border-radius: 12px;
|
||||
background-color: var(--surface_bright);
|
||||
}
|
||||
|
||||
#button-bar:active #button-bar-label {
|
||||
border-radius: 40px;
|
||||
color: var(--shadow);
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#corner {
|
||||
background-color: var(--shadow);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#corner-container {
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
#button-corner {
|
||||
border-radius: 0px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
#notch,
|
||||
#bar {
|
||||
border-radius: 0px;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#battery {
|
||||
background-color: var(--shadow);
|
||||
padding: 4px;
|
||||
border-radius: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#battery-circle {
|
||||
color: var(--surface-bright);
|
||||
border: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
#battery-circle.alert {
|
||||
border: 2px solid var(--red-dim);
|
||||
}
|
||||
|
||||
#battery-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#battery-icon.alert {
|
||||
color: var(--red-dim);
|
||||
}
|
||||
|
||||
#battery-save,
|
||||
#battery-balanced,
|
||||
#battery-performance {
|
||||
border-radius: 12px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
#battery-save-label,
|
||||
#battery-balanced-label,
|
||||
#battery-performance-label {
|
||||
color: var(--outline);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#battery-save:hover,
|
||||
#battery-balanced:hover,
|
||||
#battery-performance:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#battery-save:hover #battery-save-label,
|
||||
#battery-balanced:hover #battery-balanced-label,
|
||||
#battery-performance:hover #battery-performance-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#battery-save.active,
|
||||
#battery-balanced.active,
|
||||
#battery-performance.active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#battery-save.active #battery-save-label,
|
||||
#battery-balanced.active #battery-balanced-label,
|
||||
#battery-performance.active #battery-performance-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#battery-level {
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/* === Botones Base === */
|
||||
#network-button,
|
||||
#bluetooth-button,
|
||||
#night-mode-button,
|
||||
#caffeine-button {
|
||||
min-height: 52px;
|
||||
border-radius: 16px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
/* Tamaños y estilos específicos */
|
||||
#network-button,
|
||||
#bluetooth-button {
|
||||
background-color: unset;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#night-mode-button,
|
||||
#caffeine-button {
|
||||
background-color: var(--primary);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#night-mode-button.disabled,
|
||||
#caffeine-button.disabled {
|
||||
background-color: var(--surface);
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
/* Botones internos (status y menú) */
|
||||
#network-status-button,
|
||||
#network-menu-button,
|
||||
#bluetooth-status-button,
|
||||
#bluetooth-menu-button {
|
||||
background-color: var(--primary);
|
||||
padding: 0 8px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#network-menu-button,
|
||||
#bluetooth-menu-button {
|
||||
border-left: 1px solid var(--outline);
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
/* Estados Hover para todos los botones relevantes */
|
||||
#network-status-button:hover,
|
||||
#network-menu-button:hover,
|
||||
#bluetooth-status-button:hover,
|
||||
#bluetooth-menu-button:hover,
|
||||
#bluetooth-button:hover,
|
||||
#night-mode-button:hover,
|
||||
#caffeine-button:hover {
|
||||
background-color: var(--foreground);
|
||||
}
|
||||
|
||||
#network-menu-button:active,
|
||||
#bluetooth-menu-button:active {
|
||||
border-radius: 0 26px 26px 0;
|
||||
}
|
||||
|
||||
/* Estados Disabled */
|
||||
#network-status-button.disabled,
|
||||
#network-menu-button.disabled,
|
||||
#bluetooth-status-button.disabled,
|
||||
#bluetooth-menu-button.disabled {
|
||||
background-color: var(--surface);
|
||||
border-radius: 26px 0 0 26px;
|
||||
}
|
||||
|
||||
#network-menu-button.disabled,
|
||||
#bluetooth-menu-button.disabled {
|
||||
border-left: 1px solid var(--surface-bright);
|
||||
border-radius: 0 26px 26px 0;
|
||||
}
|
||||
|
||||
#network-status-button.disabled:hover,
|
||||
#network-menu-button.disabled:hover,
|
||||
#bluetooth-status-button.disabled:hover,
|
||||
#bluetooth-menu-button.disabled:hover,
|
||||
#night-mode-button.disabled:hover,
|
||||
#caffeine-button.disabled:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
/* Bordes diferenciados para botones compuestos */
|
||||
#network-status-button,
|
||||
#bluetooth-status-button {
|
||||
border-radius: 16px 0 0 16px;
|
||||
}
|
||||
|
||||
#network-menu-button,
|
||||
#bluetooth-menu-button {
|
||||
border-radius: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
/* === Estilos de Iconos === */
|
||||
#network-icon,
|
||||
#bluetooth-icon,
|
||||
#night-mode-icon,
|
||||
#caffeine-icon {
|
||||
color: var(--shadow);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#network-icon.disabled,
|
||||
#bluetooth-icon.disabled,
|
||||
#night-mode-icon.disabled,
|
||||
#caffeine-icon.disabled {
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
/* === Estilos de Etiquetas de Menú === */
|
||||
#network-menu-label,
|
||||
#bluetooth-menu-label {
|
||||
color: var(--shadow);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#network-menu-label.disabled,
|
||||
#bluetooth-menu-label.disabled {
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
/* === Estilos de Etiquetas Principales === */
|
||||
#network-label,
|
||||
#bluetooth-label,
|
||||
#night-mode-label,
|
||||
#caffeine-label {
|
||||
color: var(--shadow);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
/* margin-bottom: -4px; */
|
||||
}
|
||||
|
||||
#network-label.disabled,
|
||||
#bluetooth-label.disabled,
|
||||
#night-mode-label.disabled,
|
||||
#caffeine-label.disabled {
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
/* === Estilos de Texto de Estado === */
|
||||
#network-ssid,
|
||||
#bluetooth-status,
|
||||
#night-mode-status,
|
||||
#caffeine-status {
|
||||
color: var(--surface-bright);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#network-ssid.disabled,
|
||||
#bluetooth-status.disabled,
|
||||
#night-mode-status.disabled,
|
||||
#caffeine-status.disabled {
|
||||
color: var(--outline);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#calendar {
|
||||
/* background-color: var(--shadow); */
|
||||
border-radius: 20px;
|
||||
border: 4px solid var(--surface);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#header {
|
||||
/* background-color: var(--shadow); */
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--surface);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#weekday-row {
|
||||
background-color: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#month-label,
|
||||
#weekday-label,
|
||||
#day-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#day-label,
|
||||
#day-empty {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
#day-empty {
|
||||
color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#weekday-label {
|
||||
color: var(--primary);
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
#calendar-grid {
|
||||
border-radius: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#day-label.current-day {
|
||||
background-color: var(--foreground);
|
||||
color: var(--shadow);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
#prev-month-button,
|
||||
#next-month-button {
|
||||
background-color: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#prev-month-button:hover,
|
||||
#next-month-button:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#prev-month-button:active,
|
||||
#next-month-button:active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#month-button-label {
|
||||
color: var(--primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#prev-month-button:active #month-button-label,
|
||||
#next-month-button:active #month-button-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
#control-slider {
|
||||
background-color: var(--surface);
|
||||
margin: 0px 0px 0px 0px;
|
||||
border-radius: 2px 10px 10px 2px;
|
||||
}
|
||||
|
||||
#control-slider.no-icon {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#control-slider trough {
|
||||
min-height: 32px;
|
||||
padding-right: 4px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#control-slider trough highlight {
|
||||
border-radius: 0 2px 2px 0;
|
||||
min-height: 28px;
|
||||
margin-right: 8px;
|
||||
background-color: var(--primary);
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#control-slider.no-icon trough highlight {
|
||||
border-radius: 10px 2px 2px 10px;
|
||||
}
|
||||
|
||||
#control-slider.muted trough highlight,
|
||||
#control-slider.muted slider {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#control-slider slider {
|
||||
border-radius: 2px;
|
||||
background-color: var(--primary);
|
||||
min-width: 4px;
|
||||
min-height: 40px;
|
||||
margin: -8px 0;
|
||||
box-shadow:
|
||||
-4px 0 0px 2px var(--shadow),
|
||||
4px 0 0px 2px var(--shadow);
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#control-slider slider:hover {
|
||||
background-color: var(--foreground);
|
||||
}
|
||||
|
||||
#control-small {
|
||||
padding: 4px;
|
||||
background-color: var(--shadow);
|
||||
}
|
||||
|
||||
#control-small.invert {
|
||||
background-color: var(--surface);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#button-volume,
|
||||
#button-mic,
|
||||
#button-brightness {
|
||||
color: var(--surface-bright);
|
||||
border: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
#button-volume.muted,
|
||||
#button-mic.muted {
|
||||
color: var(--surface-bright);
|
||||
border: 3px solid var(--outline);
|
||||
}
|
||||
|
||||
#vol-label,
|
||||
#mic-label,
|
||||
#brightness-label {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#vol-label.muted,
|
||||
#mic-label.muted {
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
#vol-icon,
|
||||
#mic-icon,
|
||||
#brightness-icon {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding-right: 8px;
|
||||
background-color: var(--primary);
|
||||
border-radius: 12px 0 0 12px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#vol-icon.muted,
|
||||
#mic-icon.muted {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#vol-label-dash,
|
||||
#mic-label-dash,
|
||||
#brightness-label-dash {
|
||||
color: var(--shadow);
|
||||
font-size: 20px;
|
||||
font-family: "Symbols Nerd Font Mono";
|
||||
}
|
||||
|
||||
#vol-label-dash.muted,
|
||||
#mic-label-dash.muted {
|
||||
color: var(--surface);
|
||||
}
|
||||
|
||||
#mixer-section {
|
||||
border-radius: 20px;
|
||||
border: 4px solid var(--surface);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#mixer-section-title {
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
border: 2px solid var(--surface);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#mixer-content {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
#mixer-stream-label {
|
||||
color: var(--outline);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
/* Volume & Mic Display CSS for Notch */
|
||||
|
||||
#volume-progress-bar {
|
||||
border-color: var(--magenta);
|
||||
color: alpha(var(--magenta), 0.3);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#volume-progress-bar label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
#volume-progress-bar.volume-low,
|
||||
#volume-progress-bar.volume-low label {
|
||||
border-color: var(--cyan);
|
||||
color: var(--cyan);
|
||||
}
|
||||
#volume-progress-bar.volume-low {
|
||||
color: alpha(var(--cyan), 0.3);
|
||||
}
|
||||
|
||||
#volume-progress-bar.volume-medium,
|
||||
#volume-progress-bar.volume-medium label {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
}
|
||||
#volume-progress-bar.volume-medium {
|
||||
color: alpha(var(--blue), 0.3);
|
||||
}
|
||||
|
||||
#volume-progress-bar.volume-high,
|
||||
#volume-progress-bar.volume-high label {
|
||||
border-color: var(--magenta);
|
||||
color: var(--magenta);
|
||||
}
|
||||
#volume-progress-bar.volume-high {
|
||||
color: alpha(var(--magenta), 0.3);
|
||||
}
|
||||
|
||||
#volume-progress-bar.volume-muted,
|
||||
#volume-progress-bar.volume-muted label {
|
||||
border-color: var(--outline);
|
||||
color: var(--outline);
|
||||
}
|
||||
#volume-progress-bar.volume-muted {
|
||||
color: alpha(var(--outline), 0.2);
|
||||
}
|
||||
|
||||
|
||||
progressbar,
|
||||
progressbar trough {
|
||||
min-height: 2px;
|
||||
background-color: alpha(var(--outline), 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
progressbar progress {
|
||||
min-height: 4px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--magenta);
|
||||
}
|
||||
|
||||
|
||||
#volume-display-bar progress,
|
||||
#volume-display-box.volume-high progress {
|
||||
background-color: var(--magenta);
|
||||
}
|
||||
|
||||
#volume-display-box.volume-low progress {
|
||||
background-color: var(--cyan);
|
||||
}
|
||||
|
||||
#volume-display-box.volume-medium progress {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
|
||||
#volume-display-box.volume-muted progress {
|
||||
background-color: var(--outline);
|
||||
}
|
||||
|
||||
|
||||
#volume-icon-bar {
|
||||
background-color: var(--shadow);
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
margin: 0px 2px;
|
||||
}
|
||||
|
||||
#volume-icon-bar label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
#volume-icon-bar.volume-low label {
|
||||
color: var(--cyan);
|
||||
}
|
||||
#volume-icon-bar.volume-medium label {
|
||||
color: var(--blue);
|
||||
}
|
||||
#volume-icon-bar.volume-high label {
|
||||
color: var(--magenta);
|
||||
}
|
||||
#volume-icon-bar.volume-muted label {
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
/* ===== MIC PROGRESS BAR ===== */
|
||||
#mic-display-box.mic-active progress {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
|
||||
#mic-display-box.mic-muted progress {
|
||||
background-color: var(--outline);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#box-1,
|
||||
#box-2,
|
||||
#box-3,
|
||||
#box-x {
|
||||
border-radius: 20px;
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
#container-1,
|
||||
#container-2,
|
||||
#container-3 {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
#container-sub-1 {
|
||||
/* min-width: 750px; */
|
||||
}
|
||||
|
||||
#box-3 {
|
||||
min-width: 88px;
|
||||
/*min-height: 226px;*/
|
||||
}
|
||||
|
||||
#box-x {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
#switcher.stack-switcher > * {
|
||||
/* background-color: var(--shadow); */
|
||||
padding: 4px;
|
||||
border-radius: 20px;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
#switcher.stack-switcher > *:checked {
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
#switcher.stack-switcher > *:active,
|
||||
#switcher.stack-switcher > *:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#switcher.stack-switcher > *:focus {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#switcher.stack-switcher button label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#switcher.stack-switcher > *:checked label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#coming-soon-label {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#metrics {
|
||||
border-radius: 16px;
|
||||
border: 4px solid var(--surface);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Common trough style */
|
||||
#gpu-usage trough,
|
||||
#cpu-usage trough,
|
||||
#ram-usage trough,
|
||||
#disk-usage trough {
|
||||
background-color: var(--surface);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Common highlight style */
|
||||
#gpu-usage trough highlight,
|
||||
#cpu-usage trough highlight,
|
||||
#ram-usage trough highlight,
|
||||
#disk-usage trough highlight {
|
||||
border-radius: 2px 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Highlight colors por recurso */
|
||||
#gpu-usage trough highlight,
|
||||
#gpu-usage slider,
|
||||
#cpu-usage trough highlight,
|
||||
#cpu-usage slider {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
#ram-usage trough highlight,
|
||||
#ram-usage slider {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
#disk-usage trough highlight,
|
||||
#disk-usage slider {
|
||||
background-color: var(--tertiary);
|
||||
}
|
||||
|
||||
/* Common slider style */
|
||||
#gpu-usage slider,
|
||||
#cpu-usage slider,
|
||||
#ram-usage slider,
|
||||
#disk-usage slider {
|
||||
border-radius: 4px;
|
||||
min-width: 16px;
|
||||
min-height: 2px;
|
||||
margin: 0 -4px;
|
||||
box-shadow:
|
||||
0 0 0px 2px var(--shadow),
|
||||
0 0 0px 2px var(--shadow);
|
||||
}
|
||||
|
||||
/* Common label style */
|
||||
#gpu-label,
|
||||
#cpu-label,
|
||||
#ram-label,
|
||||
#disk-label {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Label colors por recurso */
|
||||
#gpu-label,
|
||||
#cpu-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
#ram-label {
|
||||
color: var(--secondary);
|
||||
}
|
||||
#disk-label {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
#applet-stack {
|
||||
/* min-width: 420px; */
|
||||
border-radius: 20px;
|
||||
border: 4px solid var(--surface);
|
||||
padding: 4px;
|
||||
/* min-width: 478px; */
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#dock {
|
||||
background-color: var(--shadow);
|
||||
padding: 8px;
|
||||
margin: 8px 8px 0 8px;
|
||||
border-radius: 20px 20px 0 0;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#dock.vertical {
|
||||
margin: 8px 0 8px 8px;
|
||||
border-radius: 20px 0 0 20px;
|
||||
}
|
||||
|
||||
#dock.vertical.left {
|
||||
margin: 8px 8px 8px 0;
|
||||
border-radius: 0 20px 20px 0;
|
||||
}
|
||||
|
||||
#dock.dense {
|
||||
margin: 8px 8px 4px 8px;
|
||||
border-radius: 20px;
|
||||
border: 2px solid var(--surface);
|
||||
}
|
||||
|
||||
#dock.edge {
|
||||
border-radius: 20px 20px 0 0;
|
||||
border: 2px solid var(--surface);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#dock.dense.vertical {
|
||||
margin: 8px 4px 8px 8px;
|
||||
border-radius: 20px;
|
||||
border: 2px solid var(--surface);
|
||||
}
|
||||
|
||||
#dock.edge.vertical {
|
||||
border-radius: 20px 0 0 20px;
|
||||
border: 2px solid var(--surface);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
#dock.integrated {
|
||||
background-color: var(--shadow);
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#dock.integrated.edge,
|
||||
#dock.integrated.dense {
|
||||
background-color: var(--surface);
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#dock-full {
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#dock-separator {
|
||||
padding: 2px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#dock-app-button {
|
||||
padding: 4px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 0 4px alpha(var(--shadow), 0.5);
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#dock-app-button:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#dock-app-button:hover.instance {
|
||||
background-color: var(--outline);
|
||||
}
|
||||
|
||||
#dock-app-button:active,
|
||||
#dock-app-button:active.instance {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#dock-app-button.instance {
|
||||
background: var(--surface-bright);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#dock-corner-left {
|
||||
margin: 0 -8px 0 0;
|
||||
}
|
||||
|
||||
#dock-corner-right {
|
||||
margin: 0 0 0 -8px;
|
||||
}
|
||||
|
||||
#dock-corner-top {
|
||||
margin: 0 0 -8px 0;
|
||||
}
|
||||
|
||||
#dock-corner-bottom {
|
||||
margin: -8px 0 0 0;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#emoji #search-entry {
|
||||
font-weight: bold;
|
||||
background-color: var(--surface);
|
||||
color: var(--foreground);
|
||||
border-radius: 16px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#emoji #search-entry selection {
|
||||
color: var(--background);
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#emoji #close-button {
|
||||
background-color: var(--surface);
|
||||
border-radius: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#emoji #close-button:hover,
|
||||
#emoji #close-button:focus {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#emoji #close-button:active {
|
||||
background-color: var(--red-dim);
|
||||
}
|
||||
|
||||
#emoji #close-label {
|
||||
color: var(--red-dim);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#emoji #close-button:active #close-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#emoji #emoji-slot-button {
|
||||
border-radius: 40px;
|
||||
padding: 16px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes loadEmojiSlot {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#emoji #emoji-slot-button:hover,
|
||||
#emoji #emoji-slot-button:focus,
|
||||
#emoji #emoji-slot-button:selected,
|
||||
#emoji #emoji-slot-button.selected {
|
||||
border-radius: 16px;
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#emoji #emoji-slot-button:active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#emoji #emoji-name-label {
|
||||
color: var(--foreground);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#emoji #emoji-slot-button:hover #emoji-name-label,
|
||||
#emoji #emoji-slot-button:focus #emoji-name-label,
|
||||
#emoji #emoji-slot-button:selected #emoji-name-label,
|
||||
#emoji #emoji-slot-button.selected #emoji-name-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#emoji #emoji-slot-button:active #emoji-name-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#emoji #emoji-char-label {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#no-notif,
|
||||
#no-tmux,
|
||||
#no-clip {
|
||||
font-size: 96px;
|
||||
color: var(--surface);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#kanban {
|
||||
/* background-color: var(--shadow); */
|
||||
border-radius: 20px;
|
||||
border: 4px solid var(--surface);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#kanban-header {
|
||||
border: 2px solid var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#column-header {
|
||||
padding: 4px;
|
||||
font-weight: bold;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#kanban-note {
|
||||
background-color: var(--surface);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#kanban-row:first-child > * > * {
|
||||
border-radius: 16px 16px 4px 4px;
|
||||
}
|
||||
|
||||
#kanban-row:last-child > * > * {
|
||||
border-radius: 4px 4px 16px 16px;
|
||||
}
|
||||
|
||||
#inline-editor {
|
||||
border: 2px solid var(--surface-bright);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#kanban-btn,
|
||||
#kanban-btn-add {
|
||||
background-color: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#kanban-btn {
|
||||
background-color: var(--shadow);
|
||||
}
|
||||
|
||||
#kanban-btn:hover,
|
||||
#kanban-btn-add:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#kanban-btn:active,
|
||||
#kanban-btn-add:active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#kanban-btn-label,
|
||||
#kanban-btn-neg {
|
||||
font-size: 20px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#kanban-btn-neg {
|
||||
color: var(--red-dim);
|
||||
}
|
||||
|
||||
#kanban-btn:active #kanban-btn-label,
|
||||
#kanban-btn-add:active #kanban-btn-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
#search-entry,
|
||||
#session-name-entry,
|
||||
#search-entry-walls {
|
||||
font-weight: bold;
|
||||
background-color: var(--surface);
|
||||
color: var(--foreground);
|
||||
border-radius: 16px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#search-entry selection,
|
||||
#search-entry-walls selection,
|
||||
#session-name-entry selection {
|
||||
color: var(--background);
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#close-button,
|
||||
#clear-button,
|
||||
#config-button,
|
||||
#new-session-button,
|
||||
#random-wall-button {
|
||||
background-color: var(--surface);
|
||||
border-radius: 40px;
|
||||
padding: 8px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#close-button:hover,
|
||||
#close-button:focus,
|
||||
#clear-button:hover,
|
||||
#clear-button:focus,
|
||||
#config-button:hover,
|
||||
#config-button:focus,
|
||||
#new-session-button:hover,
|
||||
#new-session-button:focus,
|
||||
#random-wall-button:hover {
|
||||
background-color: var(--surface-bright);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
#close-button:active,
|
||||
#clear-button:active {
|
||||
background-color: var(--red-dim);
|
||||
border-radius: 40px;
|
||||
}
|
||||
|
||||
#close-label,
|
||||
#clear-label {
|
||||
color: var(--red-dim);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#close-button:active #close-label,
|
||||
#clear-button:active #clear-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#config-button:active,
|
||||
#new-session-button:active,
|
||||
#random-wall-button:active {
|
||||
background-color: var(--primary);
|
||||
border-radius: 40px;
|
||||
}
|
||||
|
||||
#config-label,
|
||||
#new-session-label,
|
||||
#random-wall-label {
|
||||
color: var(--primary);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#config-button:active #config-label,
|
||||
#new-session-button:active #new-session-label,
|
||||
#random-wall-button:active #random-wall-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#scrolled-window {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
#scrolled-window scrollbar,
|
||||
#bluetooth-devices scrollbar,
|
||||
#network-ap-scrolled-window scrollbar {
|
||||
border-radius: 10px;
|
||||
background-color: var(--surface);
|
||||
padding: 4px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
#scrolled-window slider,
|
||||
#bluetooth-devices slider,
|
||||
#network-ap-scrolled-window slider {
|
||||
border-radius: 8px;
|
||||
min-width: 16px;
|
||||
min-height: 48px;
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#slot-button {
|
||||
border-radius: 40px;
|
||||
padding: 0 16px;
|
||||
animation: loadSlot 0.5s ease;
|
||||
min-height: 52px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes loadSlot {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#slot-button:hover,
|
||||
#slot-button:focus,
|
||||
#slot-button:selected,
|
||||
#slot-button.selected {
|
||||
border-radius: 16px;
|
||||
background-color: var(--surface);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
#slot-button:active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#app-icon {
|
||||
margin: -8px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
#app-label {
|
||||
color: var(--foreground);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#slot-button:hover #app-label,
|
||||
#slot-button:focus #app-label,
|
||||
#slot-button:selected #app-label,
|
||||
#slot-button.selected #app-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#slot-button:active #app-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#tmux-icon,
|
||||
#clip-icon {
|
||||
font-size: 20px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#app-desc {
|
||||
color: var(--outline);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#clip-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#metrics-small {
|
||||
background-color: var(--shadow);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#metrics-small.invert {
|
||||
background-color: var(--surface);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#metrics-circle {
|
||||
color: var(--surface-bright);
|
||||
border: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
#metrics-circle.bat {
|
||||
border: 3px solid var(--green);
|
||||
}
|
||||
|
||||
#metrics-circle.alert {
|
||||
border: 3px solid var(--red-dim);
|
||||
}
|
||||
|
||||
#metrics-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#metrics-icon.alert {
|
||||
color: var(--red-dim);
|
||||
}
|
||||
|
||||
#metrics-level {
|
||||
font-weight: bold;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
#metrics-sep {
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
#network-icon-label {
|
||||
color: var(--foreground);
|
||||
font-size: 20px;
|
||||
padding: 4px;
|
||||
border-radius: 11px;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
#download-label {
|
||||
color: var(--green);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
padding: 4px;
|
||||
border-radius: 11px;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
#download-label.urgent {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#network-icon-label.urgent {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#button-bar.download {
|
||||
background-color: var(--green);
|
||||
}
|
||||
|
||||
#button-bar.upload {
|
||||
background-color: var(--yellow);
|
||||
}
|
||||
|
||||
#download-icon-label {
|
||||
color: var(--green);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#download-icon-label.urgent {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#upload-icon-label {
|
||||
color: var(--yellow);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#upload-label {
|
||||
color: var(--yellow);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
padding: 4px;
|
||||
border-radius: 11px;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
#upload-label.urgent {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#upload-icon-label.urgent {
|
||||
color: var(--shadow);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
#notch-box {
|
||||
margin: 0 20px 10px 20px;
|
||||
}
|
||||
|
||||
#notch-hover-eventbox {
|
||||
background-color: transparent;
|
||||
min-height: 4px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
#notch-hover-detection {
|
||||
background-color: transparent;
|
||||
min-height: 4px;
|
||||
}
|
||||
|
||||
#notch-wrap {
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#notch-content {
|
||||
background-color: var(--shadow);
|
||||
border-radius: 0 0 20px 20px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#notch-content.open {
|
||||
border-radius: 0 0 36px 36px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#notch-content.open.invert {
|
||||
padding: 2px;
|
||||
padding-top: 0px;
|
||||
border-radius: 0 0 36px 36px;
|
||||
}
|
||||
|
||||
#notch-content.invert {
|
||||
background-color: var(--surface);
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
#notch-compact {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#notch-corner-left {
|
||||
margin-left: -16px;
|
||||
}
|
||||
|
||||
#notch-corner-right {
|
||||
margin-right: -16px;
|
||||
}
|
||||
|
||||
#compact-mpris-icon-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#compact-mpris-icon-label,
|
||||
#compact-mpris-button-label {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#compact-mpris-icon,
|
||||
#compact-mpris-button {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
#compact-mpris-icon:hover,
|
||||
#compact-mpris-button:hover {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#compact-mpris-icon:active,
|
||||
#compact-mpris-button:active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#compact-mpris-icon:hover #compact-mpris-icon-label,
|
||||
#compact-mpris-button:hover #compact-mpris-button-label {
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
#hyprland-window label {
|
||||
margin: 0px 8px;
|
||||
}
|
||||
|
||||
#active-window-box {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
#app-launcher,
|
||||
#power-menu,
|
||||
#toolbox,
|
||||
#dashboard,
|
||||
#tmux-manager,
|
||||
#clip-history,
|
||||
#overview,
|
||||
#emoji {
|
||||
background-color: var(--shadow);
|
||||
padding: 14px;
|
||||
border-radius: 0 0 34px 34px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1);
|
||||
}
|
||||
|
||||
#notch-content.panel #app-launcher,
|
||||
#notch-content.panel #power-menu,
|
||||
#notch-content.panel #toolbox,
|
||||
#notch-content.panel #dashboard,
|
||||
#notch-content.panel #tmux-manager,
|
||||
#notch-content.panel #clip-history,
|
||||
#notch-content.panel #overview,
|
||||
#notch-content.panel #emoji {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
#notch-content.panel {
|
||||
border-radius: 36px;
|
||||
}
|
||||
|
||||
#notch-box.panel {
|
||||
margin: 6px;
|
||||
padding: 2px;
|
||||
background-color: var(--surface);
|
||||
border-radius: 36px;
|
||||
}
|
||||
|
||||
#notch-content.open.panel {
|
||||
background-color: var(--shadow);
|
||||
padding: 0px;
|
||||
border-radius: 36px;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
#action-button {
|
||||
border-radius: 16px;
|
||||
background-color: var(--surface);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#action-button:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#action-button:active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#action-button:active #button-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#notification-image image {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
#notification-summary,
|
||||
#button-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#notification-summary {
|
||||
font-weight: bold;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#notification-app-name {
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
#action-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#notification-stack-box {
|
||||
border-radius: 32px;
|
||||
padding: 16px;
|
||||
border: 2px solid var(--surface);
|
||||
background-color: var(--shadow);
|
||||
margin: 8px;
|
||||
margin-bottom: 4px;
|
||||
min-width: 330px;
|
||||
}
|
||||
|
||||
#notification-navigation {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#notif-close-button {
|
||||
background-color: var(--surface);
|
||||
border-radius: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#notif-close-button:hover,
|
||||
#notif-close-button:focus {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#notif-close-button:active {
|
||||
background-color: var(--red-dim);
|
||||
}
|
||||
|
||||
#notif-close-label {
|
||||
color: var(--red-dim);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#notif-close-button:active #notif-close-label {
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
#nav-button {
|
||||
padding: 8px;
|
||||
border-radius: 16px;
|
||||
background: var(--shadow);
|
||||
border: 2px solid var(--surface);
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
#nav-button:hover {
|
||||
background: var(--surface-bright);
|
||||
}
|
||||
|
||||
#nav-button:disabled #nav-button-label {
|
||||
color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#nav-button-label {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#nav-button-label.close {
|
||||
color: var(--red-dim);
|
||||
}
|
||||
|
||||
#nav-button:hover #nav-button-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#nav-button:hover #nav-button-label.close {
|
||||
color: var(--red-dim);
|
||||
}
|
||||
|
||||
#notification-history scrollbar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#notification-history scrollbar.vertical slider {
|
||||
background: var(--primary);
|
||||
border-radius: 8px;
|
||||
min-width: 16px;
|
||||
min-height: 48px;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
#notification-history scrollbar.vertical trough {
|
||||
background: var(--surface);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
#notification-box-hist {
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--surface);
|
||||
}
|
||||
|
||||
#notification-timestamp {
|
||||
color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#notification-history-header {
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--surface);
|
||||
padding: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#nhh {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#nhh-button {
|
||||
border-radius: 8px;
|
||||
background-color: var(--surface);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#nhh-button:hover {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#nhh-button-label {
|
||||
color: var(--red-dim);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#dnd-label {
|
||||
font-size: 20px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
#notif-sep {
|
||||
padding: 2px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--surface-bright);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
#notif-date-sep {
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
#notif-date-sep-label {
|
||||
color: var(--outline);
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#overview-icon {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#overview-client-box,
|
||||
#overview-workspace-bg,
|
||||
#overview-frame {
|
||||
border-radius: 12px;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
#overview.show {
|
||||
animation: fadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
#overview-client-box {
|
||||
background-color: var(--shadow);
|
||||
border: 3px solid var(--surface);
|
||||
}
|
||||
|
||||
#overview-client-box:hover {
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
#overview-client-box:focus {
|
||||
background-color: var(--surface);
|
||||
border: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
#overview-client-box:active {
|
||||
background-color: var(--surface-bright);
|
||||
border: 3px solid var(--surface-bright);
|
||||
}
|
||||
|
||||
#overview-workspace-bg {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
#overview-workspace-box {
|
||||
padding: 4px;
|
||||
border: 2px solid var(--surface);
|
||||
}
|
||||
|
||||
#overview-add-label {
|
||||
font-size: 24px;
|
||||
color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#overview-workspace-label {
|
||||
font-weight: bold;
|
||||
background-color: var(--surface);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pin-add {
|
||||
font-size: 24px;
|
||||
color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#pin-cell-box {
|
||||
/* background-color: var(--shadow); */
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
border: 4px solid var(--surface);
|
||||
/* min-height: 160px; */
|
||||
}
|
||||
|
||||
#pin-file,
|
||||
#pin-url {
|
||||
font-weight: bold;
|
||||
/* margin-top: -16px; */
|
||||
/* margin-bottom: 16px; */
|
||||
}
|
||||
|
||||
#pin-add:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#pin-text {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#pin-url-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||