diff --git a/.config/ags/assets/icons/ai-oxygen-symbolic.svg b/.config/ags/assets/icons/ai-oxygen-symbolic.svg deleted file mode 100644 index 5e1cc1937..000000000 --- a/.config/ags/assets/icons/ai-oxygen-symbolic.svg +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - diff --git a/.config/ags/assets/icons/ai-zukijourney.png b/.config/ags/assets/icons/ai-zukijourney.png deleted file mode 100644 index 917335e72..000000000 Binary files a/.config/ags/assets/icons/ai-zukijourney.png and /dev/null differ diff --git a/.config/ags/assets/themes/sourceviewtheme-dark-monokai-license.txt b/.config/ags/assets/themes/sourceviewtheme-dark-monokai-license.txt deleted file mode 100644 index d159169d1..000000000 --- a/.config/ags/assets/themes/sourceviewtheme-dark-monokai-license.txt +++ /dev/null @@ -1,339 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -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 -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the 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 a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE 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. - - 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 -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. diff --git a/.config/ags/assets/themes/sourceviewtheme-light.xml b/.config/ags/assets/themes/sourceviewtheme-light.xml deleted file mode 100644 index c880404dc..000000000 --- a/.config/ags/assets/themes/sourceviewtheme-light.xml +++ /dev/null @@ -1,95 +0,0 @@ - - - end_4 - <_description>Catppuccin port but very random - - ` + + `${processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "
")}` + } + + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + Hyprland.dispatch("global quickshell:sidebarRightClose") + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton // Only for hover + hoverEnabled: true + cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor + } + } + + Flickable { // Notification actions + id: actionsFlickable + Layout.fillWidth: true + implicitHeight: actionRowLayout.implicitHeight + contentWidth: actionRowLayout.implicitWidth + clip: !onlyNotification + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: actionRowLayout + Layout.alignment: Qt.AlignBottom + + NotificationActionButton { + Layout.fillWidth: true + buttonText: qsTr("Close") + urgency: notificationObject.urgency + implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) : + (contentItem.implicitWidth + leftPadding + rightPadding) + + onClicked: { + root.destroyWithAnimation() + } + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "close" + } + } + + Repeater { + id: actionRepeater + model: notificationObject.actions + NotificationActionButton { + Layout.fillWidth: true + buttonText: modelData.text + urgency: notificationObject.urgency + onClicked: { + Notifications.attemptInvokeAction(notificationObject.id, modelData.identifier); + } + } + } + + NotificationActionButton { + Layout.fillWidth: true + urgency: notificationObject.urgency + implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) : + (contentItem.implicitWidth + leftPadding + rightPadding) + + onClicked: { + Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(notificationObject.body)}'`) + copyIcon.text = "inventory" + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyIcon.text = "content_copy" + } + } + + contentItem: MaterialSymbol { + id: copyIcon + iconSize: Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "content_copy" + } + } + + } + } + } + } + } +} diff --git a/.config/quickshell/modules/common/widgets/NotificationListView.qml b/.config/quickshell/modules/common/widgets/NotificationListView.qml new file mode 100644 index 000000000..087e4a403 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/NotificationListView.qml @@ -0,0 +1,31 @@ +import "root:/" +import "root:/modules/common/" +import "root:/modules/common/widgets" +import "root:/services" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +StyledListView { // Scrollable window + id: root + property bool popup: false + + spacing: 3 + + model: ScriptModel { + values: root.popup ? Notifications.popupAppNameList : Notifications.appNameList + } + delegate: NotificationGroup { + required property int index + required property var modelData + popup: root.popup + anchors.left: parent?.left + anchors.right: parent?.right + notificationGroup: popup ? + Notifications.popupGroupsByAppName[modelData] : + Notifications.groupsByAppName[modelData] + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/PointingHandInteraction.qml b/.config/quickshell/modules/common/widgets/PointingHandInteraction.qml new file mode 100644 index 000000000..cf8b065f7 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/PointingHandInteraction.qml @@ -0,0 +1,7 @@ +import QtQuick + +MouseArea { + anchors.fill: parent + onPressed: (mouse) => mouse.accepted = false + cursorShape: Qt.PointingHandCursor +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/PrimaryTabBar.qml b/.config/quickshell/modules/common/widgets/PrimaryTabBar.qml new file mode 100644 index 000000000..cd048e8b6 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/PrimaryTabBar.qml @@ -0,0 +1,97 @@ +import "root:/modules/common" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +ColumnLayout { + id: root + spacing: 0 + required property var tabButtonList // Something like [{"icon": "notifications", "name": qsTr("Notifications")}, {"icon": "volume_up", "name": qsTr("Volume mixer")}] + required property var externalTrackedTab + property bool enableIndicatorAnimation: false + property color colIndicator: Appearance?.colors.colPrimary ?? "#65558F" + property color colBorder: Appearance?.m3colors.m3outlineVariant ?? "#C6C6D0" + signal currentIndexChanged(int index) + + property bool centerTabBar: parent.width > 500 + Layout.fillWidth: !centerTabBar + Layout.alignment: Qt.AlignHCenter + implicitWidth: Math.max(tabBar.implicitWidth, 600) + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: root.externalTrackedTab + onCurrentIndexChanged: { + root.onCurrentIndexChanged(currentIndex) + } + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: PrimaryTabButton { + selected: (index == root.externalTrackedTab) + buttonText: modelData.name + buttonIcon: modelData.icon + minimumWidth: 160 + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + Connections { + target: root + function onExternalTrackedTabChanged() { + root.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem.children[0].children[tabBar.currentIndex].tabContentWidth + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: root.colIndicator + radius: Appearance?.rounding.full ?? 9999 + + Behavior on x { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + implicitHeight: 1 + color: root.colBorder + } +} diff --git a/.config/quickshell/modules/common/widgets/PrimaryTabButton.qml b/.config/quickshell/modules/common/widgets/PrimaryTabButton.qml new file mode 100644 index 000000000..a47f108b7 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/PrimaryTabButton.qml @@ -0,0 +1,173 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell.Widgets + +TabButton { + id: button + property string buttonText + property string buttonIcon + property real minimumWidth: 110 + property bool selected: false + property int tabContentWidth: contentItem.children[0].implicitWidth + property int rippleDuration: 1200 + height: buttonBackground.height + implicitWidth: Math.max(tabContentWidth, buttonBackground.implicitWidth, minimumWidth) + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colActive: Appearance?.colors.colPrimary ?? "#65558F" + property color colInactive: Appearance?.colors.colOnLayer1 ?? "#45464F" + + component RippleAnim: NumberAnimation { + duration: rippleDuration + easing.type: Appearance?.animation.elementMoveEnter.type + easing.bezierCurve: Appearance?.animationCurves.standardDecel + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: (event) => { + const {x,y} = event + const stateY = buttonBackground.y; + rippleAnim.x = x; + rippleAnim.y = y - stateY; + + const dist = (ox,oy) => ox*ox + oy*oy + const stateEndY = stateY + buttonBackground.height + rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY))) + + rippleFadeAnim.complete(); + rippleAnim.restart(); + } + onReleased: (event) => { + button.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + target: ripple + property: "opacity" + to: 0 + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 1 + } + ParallelAnimation { + RippleAnim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + } + } + } + + background: Rectangle { + id: buttonBackground + radius: Appearance?.rounding.small + implicitHeight: 50 + color: (button.hovered ? button.colBackgroundHover : button.colBackground) + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: buttonBackground.width + height: buttonBackground.height + radius: buttonBackground.radius + } + } + + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + Item { + id: ripple + width: ripple.implicitWidth + height: ripple.implicitHeight + opacity: 0 + + property real implicitWidth: 0 + property real implicitHeight: 0 + visible: width > 0 && height > 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + RadialGradient { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: button.colRipple } + GradientStop { position: 0.3; color: button.colRipple } + GradientStop { position: 0.5 ; color: Qt.rgba(button.colRipple.r, button.colRipple.g, button.colRipple.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: Item { + anchors.centerIn: buttonBackground + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + MaterialSymbol { + visible: buttonIcon?.length > 0 + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + text: buttonIcon + iconSize: Appearance?.font.pixelSize.hugeass ?? 25 + fill: selected ? 1 : 0 + color: selected ? button.colActive : button.colInactive + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + StyledText { + id: buttonTextWidget + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small + color: selected ? button.colActive : button.colInactive + text: buttonText + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/Revealer.qml b/.config/quickshell/modules/common/widgets/Revealer.qml new file mode 100644 index 000000000..2fb5dc3a8 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/Revealer.qml @@ -0,0 +1,26 @@ +import "root:/modules/common" +import QtQuick +import Quickshell + +/** + * Recreation of GTK revealer. Expects one single child. + */ +Item { + id: root + property bool reveal + property bool vertical: false + clip: true + + implicitWidth: (reveal || vertical) ? childrenRect.width : 0 + implicitHeight: (reveal || !vertical) ? childrenRect.height : 0 + visible: reveal && width > 0 && height > 0 + + Behavior on implicitWidth { + enabled: !vertical + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + enabled: vertical + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } +} diff --git a/.config/quickshell/modules/common/widgets/RippleButton.qml b/.config/quickshell/modules/common/widgets/RippleButton.qml new file mode 100644 index 000000000..9931cd02a --- /dev/null +++ b/.config/quickshell/modules/common/widgets/RippleButton.qml @@ -0,0 +1,186 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell.Widgets + +/** + * A button with ripple effect similar to in Material Design. + */ +Button { + id: root + property bool toggled + property string buttonText + property real buttonRadius: Appearance?.rounding?.small ?? 4 + property real buttonRadiusPressed: buttonRadius + property real buttonEffectiveRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius + property int rippleDuration: 1200 + property bool rippleEnabled: true + property var downAction // When left clicking (down) + property var releaseAction // When left clicking (release) + property var altAction // When right clicking + property var middleClickAction // When middle clicking + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F" + property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C" + property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colRippleToggled: Appearance?.colors.colPrimaryActive ?? "#D6CEE2" + + property color buttonColor: root.enabled ? (root.toggled ? + (root.hovered ? colBackgroundToggledHover : + colBackgroundToggled) : + (root.hovered ? colBackgroundHover : + colBackground)) : colBackground + property color rippleColor: root.toggled ? colRippleToggled : colRipple + + function startRipple(x, y) { + const stateY = buttonBackground.y; + rippleAnim.x = x; + rippleAnim.y = y - stateY; + + const dist = (ox,oy) => ox*ox + oy*oy + const stateEndY = stateY + buttonBackground.height + rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY))) + + rippleFadeAnim.complete(); + rippleAnim.restart(); + } + + component RippleAnim: NumberAnimation { + duration: rippleDuration + easing.type: Appearance?.animation.elementMoveEnter.type + easing.bezierCurve: Appearance?.animationCurves.standardDecel + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onPressed: (event) => { + if(event.button === Qt.RightButton) { + if (root.altAction) root.altAction(); + return; + } + if(event.button === Qt.MiddleButton) { + if (root.middleClickAction) root.middleClickAction(); + return; + } + root.down = true + if (root.downAction) root.downAction(); + if (!root.rippleEnabled) return; + const {x,y} = event + startRipple(x, y) + } + onReleased: (event) => { + root.down = false + if (event.button != Qt.LeftButton) return; + if (root.releaseAction) root.releaseAction(); + root.click() // Because the MouseArea already consumed the event + if (!root.rippleEnabled) return; + rippleFadeAnim.restart(); + } + onCanceled: (event) => { + root.down = false + if (!root.rippleEnabled) return; + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + target: ripple + property: "opacity" + to: 0 + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 1 + } + ParallelAnimation { + RippleAnim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + } + } + } + + background: Rectangle { + id: buttonBackground + radius: root.buttonEffectiveRadius + implicitHeight: 50 + + color: root.buttonColor + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: buttonBackground.width + height: buttonBackground.height + radius: root.buttonEffectiveRadius + } + } + + Item { + id: ripple + width: ripple.implicitWidth + height: ripple.implicitHeight + opacity: 0 + visible: width > 0 && height > 0 + + property real implicitWidth: 0 + property real implicitHeight: 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + RadialGradient { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: root.rippleColor } + GradientStop { position: 0.3; color: root.rippleColor } + GradientStop { position: 0.5; color: Qt.rgba(root.rippleColor.r, root.rippleColor.g, root.rippleColor.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: StyledText { + text: root.buttonText + } +} diff --git a/.config/quickshell/modules/common/widgets/RoundCorner.qml b/.config/quickshell/modules/common/widgets/RoundCorner.qml new file mode 100644 index 000000000..c9a2827a6 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/RoundCorner.qml @@ -0,0 +1,64 @@ +import QtQuick 2.9 + +Item { + id: root + + property int size: 25 + property color color: "#000000" + + onColorChanged: { + canvas.requestPaint(); + } + + property QtObject cornerEnum: QtObject { + property int topLeft: 0 + property int topRight: 1 + property int bottomLeft: 2 + property int bottomRight: 3 + } + + property int corner: cornerEnum.topLeft // Default to TopLeft + + width: size + height: size + + Canvas { + id: canvas + + anchors.fill: parent + antialiasing: true + + onPaint: { + var ctx = getContext("2d"); + var r = root.size; + + ctx.beginPath(); + switch (root.corner) { + case cornerEnum.topLeft: + ctx.arc(r, r, r, Math.PI, 3 * Math.PI / 2); + ctx.lineTo(0, 0); + break; + case cornerEnum.topRight: + ctx.arc(0, r, r, 3 * Math.PI / 2, 2 * Math.PI); + ctx.lineTo(r, 0); + break; + case cornerEnum.bottomLeft: + ctx.arc(r, 0, r, Math.PI / 2, Math.PI); + ctx.lineTo(0, r); + break; + case cornerEnum.bottomRight: + ctx.arc(0, 0, r, 0, Math.PI / 2); + ctx.lineTo(r, r); + break; + } + ctx.closePath(); + ctx.fillStyle = root.color; + ctx.fill(); + } + } + + Behavior on size { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + +} diff --git a/.config/quickshell/modules/common/widgets/SecondaryTabButton.qml b/.config/quickshell/modules/common/widgets/SecondaryTabButton.qml new file mode 100644 index 000000000..1d3b6381f --- /dev/null +++ b/.config/quickshell/modules/common/widgets/SecondaryTabButton.qml @@ -0,0 +1,163 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell.Widgets + +TabButton { + id: root + property string buttonText + property string buttonIcon + property bool selected: false + property int rippleDuration: 1200 + height: buttonBackground.height + property int tabContentWidth: buttonBackground.width - buttonBackground.radius*2 + + property color colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + property color colBackgroundHover: Appearance.colors.colLayer1Hover + property color colRipple: Appearance.colors.colLayer1Active + + PointingHandInteraction {} + + component RippleAnim: NumberAnimation { + duration: rippleDuration + easing.type: Appearance.animation.elementMoveEnter.type + easing.bezierCurve: Appearance.animationCurves.standardDecel + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: (event) => { + const {x,y} = event + const stateY = buttonBackground.y; + rippleAnim.x = x; + rippleAnim.y = y - stateY; + + const dist = (ox,oy) => ox*ox + oy*oy + const stateEndY = stateY + buttonBackground.height + rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY))) + + rippleFadeAnim.complete(); + rippleAnim.restart(); + } + onReleased: (event) => { + root.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + target: ripple + property: "opacity" + to: 0 + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 1 + } + ParallelAnimation { + RippleAnim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + } + } + } + + background: Rectangle { + id: buttonBackground + radius: Appearance?.rounding.small ?? 7 + implicitHeight: 37 + color: (root.hovered ? root.colBackgroundHover : root.colBackground) + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: buttonBackground.width + height: buttonBackground.height + radius: buttonBackground.radius + } + } + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + Rectangle { + id: ripple + + radius: Appearance.rounding.full + color: root.colRipple + opacity: 0 + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: Item { + anchors.centerIn: buttonBackground + RowLayout { + anchors.centerIn: parent + spacing: 0 + + Loader { + id: iconLoader + active: buttonIcon?.length > 0 + sourceComponent: buttonIcon?.length > 0 ? materialSymbolComponent : null + Layout.rightMargin: 5 + } + + Component { + id: materialSymbolComponent + MaterialSymbol { + verticalAlignment: Text.AlignVCenter + text: buttonIcon + iconSize: Appearance.font.pixelSize.huge + fill: selected ? 1 : 0 + color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1 + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + StyledText { + id: buttonTextWidget + verticalAlignment: Text.AlignVCenter + font.pixelSize: Appearance.font.pixelSize.small + color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1 + text: buttonText + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/SelectionDialog.qml b/.config/quickshell/modules/common/widgets/SelectionDialog.qml new file mode 100644 index 000000000..9cf0940ed --- /dev/null +++ b/.config/quickshell/modules/common/widgets/SelectionDialog.qml @@ -0,0 +1,129 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Item { + id: root + property real dialogPadding: 15 + property real dialogMargin: 30 + property string titleText: "Selection Dialog" + property alias items: choiceModel.values + property int selectedId: choiceListView.currentIndex + property var defaultChoice + + signal canceled(); + signal selected(var result); + + Rectangle { // Scrim + id: scrimOverlay + anchors.fill: parent + radius: Appearance.rounding.small + color: Appearance.colors.colScrim + MouseArea { + hoverEnabled: true + anchors.fill: parent + preventStealing: true + propagateComposedEvents: false + } + } + + Rectangle { // The dialog + id: dialog + color: Appearance.colors.colSurfaceContainerHigh + radius: Appearance.rounding.normal + anchors.fill: parent + anchors.margins: dialogMargin + implicitHeight: dialogColumnLayout.implicitHeight + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + id: dialogTitle + Layout.topMargin: dialogPadding + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: root.titleText + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + } + + ListView { + id: choiceListView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1 + + model: ScriptModel { + id: choiceModel + } + + delegate: StyledRadioButton { + id: radioButton + required property var modelData + required property int index + anchors { + left: parent?.left + right: parent?.right + leftMargin: root.dialogPadding + rightMargin: root.dialogPadding + } + + description: modelData.toString() + checked: index === choiceListView.currentIndex + + onCheckedChanged: { + if (checked) { + choiceListView.currentIndex = index; + } + } + } + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + } + + RowLayout { + id: dialogButtonsRowLayout + Layout.bottomMargin: dialogPadding + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + Layout.alignment: Qt.AlignRight + + DialogButton { + buttonText: qsTr("Cancel") + onClicked: root.canceled() + } + DialogButton { + buttonText: qsTr("OK") + onClicked: root.selected( + root.selectedId === -1 ? null : + root.items[root.selectedId] + ) + } + } + } + } +} diff --git a/.config/quickshell/modules/common/widgets/StyledLabel.qml b/.config/quickshell/modules/common/widgets/StyledLabel.qml new file mode 100644 index 000000000..f5201baea --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledLabel.qml @@ -0,0 +1,16 @@ +import "root:/modules/common" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Label { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary +} diff --git a/.config/quickshell/modules/common/widgets/StyledListView.qml b/.config/quickshell/modules/common/widgets/StyledListView.qml new file mode 100644 index 000000000..76d9782b4 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledListView.qml @@ -0,0 +1,110 @@ +import "root:/" +import "root:/modules/common/" +import "root:/modules/common/widgets" +import "root:/services" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +/** + * A ListView with animations. + */ +ListView { + id: root + spacing: 5 + property real removeOvershoot: 20 // Account for gaps and bouncy animations + property int dragIndex: -1 + property real dragDistance: 0 + property bool popin: true + + function resetDrag() { + root.dragIndex = -1 + root.dragDistance = 0 + } + + add: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + from: 0, + to: 1, + }), + ] + } + + addDisplaced: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + to: 1, + }), + ] + } + + // displaced: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + + // move: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + // moveDisplaced: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + + remove: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "x", + to: root.width + root.removeOvershoot, + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "opacity", + to: 0, + }) + ] + } + + // This is movement when something is removed, not removing animation! + removeDisplaced: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] + } +} diff --git a/.config/quickshell/modules/common/widgets/StyledProgressBar.qml b/.config/quickshell/modules/common/widgets/StyledProgressBar.qml new file mode 100644 index 000000000..31bce5915 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledProgressBar.qml @@ -0,0 +1,105 @@ +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +/** + * Material 3 progress bar. See https://m3.material.io/components/progress-indicators/overview + */ +ProgressBar { + id: root + property real valueBarWidth: 120 + property real valueBarHeight: 4 + property real valueBarGap: 4 + property color highlightColor: Appearance?.colors.colPrimary ?? "#685496" + property color trackColor: Appearance?.m3colors.m3secondaryContainer ?? "#F1D3F9" + property bool sperm: false // If true, the progress bar will have a wavy fill effect + property real spermAmplitudeMultiplier: sperm ? 0.5 : 0 + property real spermFrequency: 6 + property real spermFps: 60 + + Behavior on spermAmplitudeMultiplier { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Behavior on value { + animation: Appearance?.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + background: Rectangle { + anchors.fill: parent + color: "transparent" + radius: Appearance?.rounding.full ?? 9999 + implicitHeight: valueBarHeight + implicitWidth: valueBarWidth + } + + contentItem: Item { + implicitWidth: parent.width + implicitHeight: parent.height + + Canvas { + id: wavyFill + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + height: parent.height * 6 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var progress = root.visualPosition; + var fillWidth = progress * width; + var amplitude = parent.height * root.spermAmplitudeMultiplier; + var frequency = root.spermFrequency; + var phase = Date.now() / 400.0; + var centerY = height / 2; + + ctx.strokeStyle = root.highlightColor; + ctx.lineWidth = parent.height; + ctx.lineCap = "round"; + ctx.beginPath(); + for (var x = ctx.lineWidth / 2; x <= fillWidth; x += 1) { + var waveY = centerY + amplitude * Math.sin(frequency * 2 * Math.PI * x / width + phase); + if (x === 0) + ctx.moveTo(x, waveY); + else + ctx.lineTo(x, waveY); + } + ctx.stroke(); + } + Connections { + target: root + function onValueChanged() { wavyFill.requestPaint(); } + function onHighlightColorChanged() { wavyFill.requestPaint(); } + } + Timer { + interval: 1000 / root.spermFps + running: root.sperm + repeat: root.sperm + onTriggered: wavyFill.requestPaint() + } + } + Rectangle { // Right remaining part fill + anchors.right: parent.right + width: (1 - root.visualPosition) * parent.width - valueBarGap + height: parent.height + radius: Appearance?.rounding.full ?? 9999 + color: root.trackColor + } + Rectangle { // Stop point + anchors.right: parent.right + width: valueBarGap + height: valueBarGap + radius: Appearance?.rounding.full ?? 9999 + color: root.highlightColor + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/StyledRadioButton.qml b/.config/quickshell/modules/common/widgets/StyledRadioButton.qml new file mode 100644 index 000000000..3ef1ee8ad --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledRadioButton.qml @@ -0,0 +1,86 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets +import Quickshell.Services.Pipewire + +RadioButton { + id: root + implicitHeight: 40 + property string description + property color activeColor: Appearance?.colors.colPrimary ?? "#685496" + property color inactiveColor: Appearance?.m3colors.m3onSurfaceVariant ?? "#45464F" + + PointingHandInteraction {} + + indicator: Item{} + + contentItem: RowLayout { + Layout.fillWidth: true + spacing: 12 + Rectangle { + id: radio + Layout.fillWidth: false + Layout.alignment: Qt.AlignVCenter + width: 20 + height: 20 + radius: Appearance?.rounding.full + border.color: checked ? root.activeColor : root.inactiveColor + border.width: 2 + color: "transparent" + + // Checked indicator + Rectangle { + anchors.centerIn: parent + width: checked ? 10 : 4 + height: checked ? 10 : 4 + radius: Appearance?.rounding.full + color: Appearance?.colors.colPrimary + opacity: checked ? 1 : 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + + } + + // Hover + Rectangle { + anchors.centerIn: parent + width: root.hovered ? 40 : 20 + height: root.hovered ? 40 : 20 + radius: Appearance?.rounding.full + color: Appearance?.m3colors.m3onSurface + opacity: root.hovered ? 0.1 : 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + StyledText { + text: root.description + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Appearance?.m3colors.m3onSurface + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/StyledRectangularShadow.qml b/.config/quickshell/modules/common/widgets/StyledRectangularShadow.qml new file mode 100644 index 000000000..6e1f2e16e --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledRectangularShadow.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Effects +import "root:/modules/common" + +RectangularShadow { + required property var target + anchors.fill: target + radius: target.radius + blur: 1.2 * Appearance.sizes.elevationMargin + spread: 1 + color: Appearance.colors.colShadow + cached: true +} diff --git a/.config/quickshell/modules/common/widgets/StyledSlider.qml b/.config/quickshell/modules/common/widgets/StyledSlider.qml new file mode 100644 index 000000000..ca0980030 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledSlider.qml @@ -0,0 +1,113 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets + +// Material 3 slider. See https://m3.material.io/components/sliders/overview +Slider { + id: root + property real scale: 0.85 + property real backgroundDotSize: 4 * scale + property real backgroundDotMargins: 4 * scale + // property real handleMargins: 0 * scale + property real handleMargins: (root.pressed ? 0 : 2) * scale + property real handleWidth: (root.pressed ? 3 : 5) * scale + property real handleHeight: 44 * scale + property real handleLimit: root.backgroundDotMargins + property real trackHeight: 30 * scale + property color highlightColor: Appearance.colors.colPrimary + property color trackColor: Appearance.colors.colSecondaryContainer + property color handleColor: Appearance.m3colors.m3onSecondaryContainer + property real trackRadius: Appearance.rounding.verysmall * scale + property real unsharpenRadius: Appearance.rounding.unsharpen + + property real limitedHandleRangeWidth: (root.availableWidth - handleWidth - root.handleLimit * 2) + property string tooltipContent: `${Math.round(value * 100)}%` + Layout.fillWidth: true + from: 0 + to: 1 + + Behavior on value { // This makes the adjusted value (like volume) shift smoothly + SmoothedAnimation { + velocity: Appearance.animation.elementMoveFast.velocity + } + } + + Behavior on handleMargins { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + MouseArea { + anchors.fill: parent + onPressed: (mouse) => mouse.accepted = false + cursorShape: root.pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor + } + + background: Item { + anchors.verticalCenter: parent.verticalCenter + implicitHeight: trackHeight + + // Fill left + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + width: root.handleLimit * 2 + root.visualPosition * root.limitedHandleRangeWidth - (root.handleMargins + root.handleWidth / 2) + height: trackHeight + color: root.highlightColor + topLeftRadius: root.trackRadius + bottomLeftRadius: root.trackRadius + topRightRadius: root.unsharpenRadius + bottomRightRadius: root.unsharpenRadius + } + + // Fill right + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + width: root.handleLimit * 2 + (1 - root.visualPosition) * root.limitedHandleRangeWidth - (root.handleMargins + root.handleWidth / 2) + height: trackHeight + color: root.trackColor + topLeftRadius: root.unsharpenRadius + bottomLeftRadius: root.unsharpenRadius + topRightRadius: root.trackRadius + bottomRightRadius: root.trackRadius + } + + // Dot at the end + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: root.backgroundDotMargins + width: root.backgroundDotSize + height: root.backgroundDotSize + radius: Appearance.rounding.full + color: root.handleColor + } + } + + handle: Rectangle { + id: handle + x: root.leftPadding + root.handleLimit + root.visualPosition * root.limitedHandleRangeWidth + y: root.topPadding + root.availableHeight / 2 - height / 2 + implicitWidth: root.handleWidth + implicitHeight: root.handleHeight + radius: Appearance.rounding.full + color: root.handleColor + + Behavior on implicitWidth { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + StyledToolTip { + extraVisibleCondition: root.pressed + content: root.tooltipContent + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/StyledSwitch.qml b/.config/quickshell/modules/common/widgets/StyledSwitch.qml new file mode 100644 index 000000000..217a2f7e4 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledSwitch.qml @@ -0,0 +1,60 @@ +import "root:/modules/common/" +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Qt5Compat.GraphicalEffects + +/** + * Material 3 switch. See https://m3.material.io/components/switch/overview + */ +Switch { + id: root + property real scale: 1 + implicitHeight: 32 * root.scale + implicitWidth: 52 * root.scale + property color activeColor: Appearance?.colors.colPrimary ?? "#685496" + property color inactiveColor: Appearance?.colors.colSurfaceContainerHighest ?? "#45464F" + + PointingHandInteraction {} + + // Custom track styling + background: Rectangle { + width: parent.width + height: parent.height + radius: Appearance?.rounding.full ?? 9999 + color: root.checked ? root.activeColor : root.inactiveColor + border.width: 2 * root.scale + border.color: root.checked ? root.activeColor : Appearance.m3colors.m3outline + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + Behavior on border.color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + // Custom thumb styling + indicator: Rectangle { + width: root.pressed ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale) + height: root.pressed ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale) + radius: Appearance.rounding.full + color: root.checked ? Appearance.m3colors.m3onPrimary : Appearance.m3colors.m3outline + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: root.checked ? (root.pressed ? (22 * root.scale) : 24 * root.scale) : (root.pressed ? (2 * root.scale) : 8 * root.scale) + + Behavior on anchors.leftMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} diff --git a/.config/quickshell/modules/common/widgets/StyledText.qml b/.config/quickshell/modules/common/widgets/StyledText.qml new file mode 100644 index 000000000..7750456e0 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledText.qml @@ -0,0 +1,15 @@ +import "root:/modules/common" +import QtQuick +import QtQuick.Layouts + +Text { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary +} diff --git a/.config/quickshell/modules/common/widgets/StyledTextArea.qml b/.config/quickshell/modules/common/widgets/StyledTextArea.qml new file mode 100644 index 000000000..67d417576 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledTextArea.qml @@ -0,0 +1,15 @@ +import "root:/modules/common" +import QtQuick +import QtQuick.Controls + +TextArea { + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderTextColor: Appearance.m3colors.m3outline + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } +} diff --git a/.config/quickshell/modules/common/widgets/StyledToolTip.qml b/.config/quickshell/modules/common/widgets/StyledToolTip.qml new file mode 100644 index 000000000..1b4bd033a --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledToolTip.qml @@ -0,0 +1,60 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ToolTip { + id: root + property string content + property bool extraVisibleCondition: true + property bool alternativeVisibleCondition: false + property bool internalVisibleCondition: { + const ans = (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition + return ans + } + verticalPadding: 5 + horizontalPadding: 10 + opacity: internalVisibleCondition ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + background: null + + contentItem: Item { + id: contentItemBackground + implicitWidth: tooltipTextObject.width + 2 * root.horizontalPadding + implicitHeight: tooltipTextObject.height + 2 * root.verticalPadding + + Rectangle { + id: backgroundRectangle + anchors.bottom: contentItemBackground.bottom + anchors.horizontalCenter: contentItemBackground.horizontalCenter + color: Appearance?.colors.colTooltip ?? "#3C4043" + radius: Appearance?.rounding.verysmall ?? 7 + width: internalVisibleCondition ? (tooltipTextObject.width + 2 * padding) : 0 + height: internalVisibleCondition ? (tooltipTextObject.height + 2 * padding) : 0 + clip: true + + Behavior on width { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + StyledText { + id: tooltipTextObject + anchors.centerIn: parent + text: content + font.pixelSize: Appearance?.font.pixelSize.smaller ?? 14 + font.hintingPreference: Font.PreferNoHinting // Prevent shaky text + color: Appearance?.colors.colOnTooltip ?? "#FFFFFF" + wrapMode: Text.Wrap + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/VerticalButtonGroup.qml b/.config/quickshell/modules/common/widgets/VerticalButtonGroup.qml new file mode 100644 index 000000000..7d8fc29fe --- /dev/null +++ b/.config/quickshell/modules/common/widgets/VerticalButtonGroup.qml @@ -0,0 +1,47 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +/** + * A container that supports GroupButton children for bounciness. + * See https://m3.material.io/components/button-groups/overview + */ +Rectangle { + id: root + default property alias content: columnLayout.data + property real spacing: 5 + property real padding: 0 + property int clickIndex: columnLayout.clickIndex + + property real contentHeight: { + let total = 0; + for (let i = 0; i < columnLayout.children.length; ++i) { + const child = columnLayout.children[i]; + total += child.baseHeight ?? child.implicitHeight ?? child.height; + } + return total + columnLayout.spacing * (columnLayout.children.length - 1); + } + + topLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[0].radius + padding) : + Appearance?.rounding?.small + topRightRadius: topLeftRadius + bottomLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[columnLayout.children.length - 1].radius + padding) : + Appearance?.rounding?.small + bottomRightRadius: bottomLeftRadius + + color: "transparent" + height: root.contentHeight + padding * 2 + implicitWidth: columnLayout.implicitWidth + padding * 2 + implicitHeight: root.contentHeight + padding * 2 + + children: [ColumnLayout { + id: columnLayout + anchors.fill: parent + anchors.margins: root.padding + spacing: root.spacing + property int clickIndex: -1 + }] +} diff --git a/.config/quickshell/modules/common/widgets/WaveVisualizer.qml b/.config/quickshell/modules/common/widgets/WaveVisualizer.qml new file mode 100644 index 000000000..571e71838 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/WaveVisualizer.qml @@ -0,0 +1,78 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Io + +Canvas { // Visualizer + id: root + property list points + property list smoothPoints + property real maxVisualizerValue: 1000 + property int smoothing: 2 + property bool live: true + property color color: Appearance.m3colors.m3primary + + onPointsChanged: () => { + root.requestPaint() + } + + anchors.fill: parent + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var points = root.points; + var maxVal = root.maxVisualizerValue || 1; + var h = height; + var w = width; + var n = points.length; + if (n < 2) return; + + // Smoothing: simple moving average (optional) + var smoothWindow = root.smoothing; // adjust for more/less smoothing + root.smoothPoints = []; + for (var i = 0; i < n; ++i) { + var sum = 0, count = 0; + for (var j = -smoothWindow; j <= smoothWindow; ++j) { + var idx = Math.max(0, Math.min(n - 1, i + j)); + sum += points[idx]; + count++; + } + root.smoothPoints.push(sum / count); + } + if (!root.live) root.smoothPoints.fill(0); // If not playing, show no points + + ctx.beginPath(); + ctx.moveTo(0, h); + for (var i = 0; i < n; ++i) { + var x = i * w / (n - 1); + var y = h - (root.smoothPoints[i] / maxVal) * h; + ctx.lineTo(x, y); + } + ctx.lineTo(w, h); + ctx.closePath(); + + ctx.fillStyle = Qt.rgba( + root.color.r, + root.color.g, + root.color.b, + 0.15 + ); + ctx.fill(); + } + + layer.enabled: true + layer.effect: MultiEffect { // Blur a bit to obscure away the points + source: root + saturation: 0.2 + blurEnabled: true + blurMax: 7 + blur: 1 + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/notification_utils.js b/.config/quickshell/modules/common/widgets/notification_utils.js new file mode 100644 index 000000000..9b151055c --- /dev/null +++ b/.config/quickshell/modules/common/widgets/notification_utils.js @@ -0,0 +1,77 @@ + +/** + * @param { string } summary + * @returns { string } + */ +function findSuitableMaterialSymbol(summary = "") { + const defaultType = 'chat'; + if(summary.length === 0) return defaultType; + + const keywordsToTypes = { + 'reboot': 'restart_alt', + 'recording': 'screen_record', + 'battery': 'power', + 'power': 'power', + 'screenshot': 'screenshot_monitor', + 'welcome': 'waving_hand', + 'time': 'scheduleb', + 'installed': 'download', + 'configuration reloaded': 'reset_wrench', + 'config': 'reset_wrench', + 'update': 'update', + 'ai response': 'neurology', + 'control': 'settings', + 'upscale': 'compare', + 'install': 'deployed_code_update', + 'startswith:file': 'folder_copy', // Declarative startsWith check + }; + + const lowerSummary = summary.toLowerCase(); + + for (const [keyword, type] of Object.entries(keywordsToTypes)) { + if (keyword.startsWith('startswith:')) { + const startsWithKeyword = keyword.replace('startswith:', ''); + if (lowerSummary.startsWith(startsWithKeyword)) { + return type; + } + } else if (lowerSummary.includes(keyword)) { + return type; + } + } + + return defaultType; +} + +/** + * @param { number | string | Date } timestamp + * @returns { string } + */ +const getFriendlyNotifTimeString = (timestamp) => { + if (!timestamp) return ''; + const messageTime = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - messageTime.getTime(); + + // Less than 1 minute + if (diffMs < 60000) + return 'Now'; + + // Same day - show relative time + if (messageTime.toDateString() === now.toDateString()) { + const diffMinutes = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + + if (diffHours > 0) { + return `${diffHours}h`; + } else { + return `${diffMinutes}m`; + } + } + + // Yesterday + if (messageTime.toDateString() === new Date(now.getTime() - 86400000).toDateString()) + return 'Yesterday'; + + // Older dates + return Qt.formatDateTime(messageTime, "MMMM dd"); +}; \ No newline at end of file diff --git a/.config/quickshell/modules/dock/Dock.qml b/.config/quickshell/modules/dock/Dock.qml new file mode 100644 index 000000000..524fbc11f --- /dev/null +++ b/.config/quickshell/modules/dock/Dock.qml @@ -0,0 +1,148 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property bool pinned: ConfigOptions?.dock.pinnedOnStartup ?? false + + Variants { // For each monitor + model: Quickshell.screens + + Loader { + id: dockLoader + required property var modelData + active: ConfigOptions?.dock.hoverToReveal || (!ToplevelManager.activeToplevel?.activated) + + sourceComponent: PanelWindow { // Window + id: dockRoot + screen: dockLoader.modelData + + property bool reveal: root.pinned + || (ConfigOptions?.dock.hoverToReveal && dockMouseArea.containsMouse) + || dockApps.requestDockShow + || (!ToplevelManager.activeToplevel?.activated) + + anchors { + bottom: true + left: true + right: true + } + + exclusiveZone: root.pinned ? implicitHeight + - (Appearance.sizes.hyprlandGapsOut) + - (Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut) : 0 + + implicitWidth: dockBackground.implicitWidth + WlrLayershell.namespace: "quickshell:dock" + color: "transparent" + + implicitHeight: (ConfigOptions?.dock.height ?? 70) + Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut + + mask: Region { + item: dockMouseArea + } + + MouseArea { + id: dockMouseArea + anchors.top: parent.top + height: parent.height + anchors.topMargin: dockRoot.reveal ? 0 : + ConfigOptions?.dock.hoverToReveal ? (dockRoot.implicitHeight - ConfigOptions.dock.hoverRegionHeight) : + (dockRoot.implicitHeight + 1) + + anchors.left: parent.left + anchors.right: parent.right + hoverEnabled: true + + Behavior on anchors.topMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Item { + id: dockHoverRegion + anchors.fill: parent + + Item { // Wrapper for the dock background + id: dockBackground + anchors { + top: parent.top + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + + implicitWidth: dockRow.implicitWidth + 5 * 2 + height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + + StyledRectangularShadow { + target: dockVisualBackground + } + Rectangle { // The real rectangle that is visible + id: dockVisualBackground + property real margin: Appearance.sizes.elevationMargin + anchors.fill: parent + anchors.topMargin: Appearance.sizes.elevationMargin + anchors.bottomMargin: Appearance.sizes.hyprlandGapsOut + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.large + } + + RowLayout { + id: dockRow + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + spacing: 3 + property real padding: 5 + + VerticalButtonGroup { + Layout.topMargin: Appearance.sizes.hyprlandGapsOut // why does this work + GroupButton { // Pin button + baseWidth: 35 + baseHeight: 35 + clickedWidth: baseWidth + clickedHeight: baseHeight + 20 + buttonRadius: Appearance.rounding.normal + toggled: root.pinned + onClicked: root.pinned = !root.pinned + contentItem: MaterialSymbol { + text: "keep" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: root.pinned ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0 + } + } + } + DockSeparator {} + DockApps { id: dockApps; } + DockSeparator {} + DockButton { + Layout.fillHeight: true + onClicked: Hyprland.dispatch("global quickshell:overviewToggle") + contentItem: MaterialSymbol { + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + font.pixelSize: parent.width / 2 + text: "apps" + color: Appearance.colors.colOnLayer0 + } + } + } + } + } + + } + } + } + } +} diff --git a/.config/quickshell/modules/dock/DockAppButton.qml b/.config/quickshell/modules/dock/DockAppButton.qml new file mode 100644 index 000000000..f4623e625 --- /dev/null +++ b/.config/quickshell/modules/dock/DockAppButton.qml @@ -0,0 +1,115 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +DockButton { + id: root + property var appToplevel + property var appListRoot + property int lastFocused: -1 + property real iconSize: 35 + property real countDotWidth: 10 + property real countDotHeight: 4 + property bool appIsActive: appToplevel.toplevels.find(t => (t.activated == true)) !== undefined + + property bool isSeparator: appToplevel.appId === "SEPARATOR" + property var desktopEntry: DesktopEntries.byId(appToplevel.appId) + enabled: !isSeparator + implicitWidth: isSeparator ? 1 : implicitHeight - topInset - bottomInset + + Loader { + active: isSeparator + anchors { + fill: parent + topMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal + bottomMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal + } + sourceComponent: DockSeparator {} + } + + Loader { + anchors.fill: parent + active: appToplevel.toplevels.length > 0 + sourceComponent: MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: { + appListRoot.lastHoveredButton = root + appListRoot.buttonHovered = true + lastFocused = appToplevel.toplevels.length - 1 + } + onExited: { + if (appListRoot.lastHoveredButton === root) { + appListRoot.buttonHovered = false + } + } + } + } + + onClicked: { + if (appToplevel.toplevels.length === 0) { + root.desktopEntry?.execute(); + return; + } + lastFocused = (lastFocused + 1) % appToplevel.toplevels.length + appToplevel.toplevels[lastFocused].activate() + } + + middleClickAction: () => { + root.desktopEntry?.execute(); + } + + contentItem: Loader { + active: !isSeparator + sourceComponent: Item { + anchors.centerIn: parent + + Loader { + id: iconImageLoader + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + active: !root.isSeparator + sourceComponent: IconImage { + source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing") + implicitSize: root.iconSize + } + } + + RowLayout { + spacing: 3 + anchors { + top: iconImageLoader.bottom + topMargin: 2 + horizontalCenter: parent.horizontalCenter + } + Repeater { + model: Math.min(appToplevel.toplevels.length, 3) + delegate: Rectangle { + required property int index + radius: Appearance.rounding.full + implicitWidth: (appToplevel.toplevels.length <= 3) ? + root.countDotWidth : root.countDotHeight // Circles when too many + implicitHeight: root.countDotHeight + color: appIsActive ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4) + } + } + } + } + } +} diff --git a/.config/quickshell/modules/dock/DockApps.qml b/.config/quickshell/modules/dock/DockApps.qml new file mode 100644 index 000000000..ffda024c7 --- /dev/null +++ b/.config/quickshell/modules/dock/DockApps.qml @@ -0,0 +1,262 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { + id: root + property real maxWindowPreviewHeight: 200 + property real maxWindowPreviewWidth: 300 + property real windowControlsHeight: 30 + + property Item lastHoveredButton + property bool buttonHovered: false + property bool requestDockShow: previewPopup.show + + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.hyprlandGapsOut // why does this work + implicitWidth: listView.implicitWidth + + StyledListView { + id: listView + spacing: 2 + orientation: ListView.Horizontal + anchors { + top: parent.top + bottom: parent.bottom + } + implicitWidth: contentWidth + + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + model: ScriptModel { + objectProp: "appId" + values: { + var map = new Map(); + + // Pinned apps + const pinnedApps = ConfigOptions?.dock.pinnedApps ?? []; + for (const appId of pinnedApps) { + if (!map.has(appId.toLowerCase())) map.set(appId.toLowerCase(), ({ + pinned: true, + toplevels: [] + })); + } + + // Separator + if (pinnedApps.length > 0) { + map.set("SEPARATOR", { pinned: false, toplevels: [] }); + } + + // Open windows + for (const toplevel of ToplevelManager.toplevels.values) { + if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({ + pinned: false, + toplevels: [] + })); + map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel); + } + + var values = []; + + for (const [key, value] of map) { + values.push({ appId: key, toplevels: value.toplevels, pinned: value.pinned }); + } + + return values; + } + } + delegate: DockAppButton { + required property var modelData + appToplevel: modelData + appListRoot: root + } + } + + PopupWindow { + id: previewPopup + property var appTopLevel: root.lastHoveredButton?.appToplevel + property bool allPreviewsReady: false + Connections { + target: root + function onLastHoveredButtonChanged() { + previewPopup.allPreviewsReady = false; // Reset readiness when the hovered button changes + } + } + function updatePreviewReadiness() { + for(var i = 0; i < previewRowLayout.children.length; i++) { + const view = previewRowLayout.children[i]; + if (view.hasContent === false) { + allPreviewsReady = false; + return; + } + } + allPreviewsReady = true; + } + property bool shouldShow: { + const hoverConditions = (popupMouseArea.containsMouse || root.buttonHovered) + return hoverConditions && allPreviewsReady; + } + property bool show: false + + onShouldShowChanged: { + if (shouldShow) { + // show = true; + updateTimer.restart(); + } else { + updateTimer.restart(); + } + } + Timer { + id: updateTimer + interval: 100 + onTriggered: { + previewPopup.show = previewPopup.shouldShow + } + } + anchor { + window: root.QsWindow.window + adjustment: PopupAdjustment.None + gravity: Edges.Top | Edges.Right + edges: Edges.Top | Edges.Left + + } + visible: popupBackground.visible + color: "transparent" + implicitWidth: root.QsWindow.window?.width ?? 1 + implicitHeight: popupMouseArea.implicitHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2 + + MouseArea { + id: popupMouseArea + anchors.bottom: parent.bottom + implicitWidth: popupBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: root.maxWindowPreviewHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2 + hoverEnabled: true + x: { + const itemCenter = root.QsWindow?.mapFromItem(root.lastHoveredButton, root.lastHoveredButton?.width / 2, 0); + return itemCenter.x - width / 2 + } + StyledRectangularShadow { + target: popupBackground + opacity: previewPopup.show ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + Rectangle { + id: popupBackground + property real padding: 5 + opacity: previewPopup.show ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + clip: true + color: Appearance.colors.colSurfaceContainer + radius: Appearance.rounding.normal + anchors.bottom: parent.bottom + anchors.bottomMargin: Appearance.sizes.elevationMargin + anchors.horizontalCenter: parent.horizontalCenter + implicitHeight: previewRowLayout.implicitHeight + padding * 2 + implicitWidth: previewRowLayout.implicitWidth + padding * 2 + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: previewRowLayout + anchors.centerIn: parent + Repeater { + model: ScriptModel { + values: previewPopup.appTopLevel?.toplevels ?? [] + } + RippleButton { + id: windowButton + required property var modelData + padding: 0 + middleClickAction: () => { + windowButton.modelData?.close(); + } + onClicked: { + windowButton.modelData?.activate(); + } + contentItem: ColumnLayout { + implicitWidth: screencopyView.implicitWidth + implicitHeight: screencopyView.implicitHeight + + ButtonGroup { + contentWidth: parent.width - anchors.margins * 2 + WrapperRectangle { + Layout.fillWidth: true + color: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer) + radius: Appearance.rounding.small + margin: 5 + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + text: windowButton.modelData?.title + elide: Text.ElideRight + color: Appearance.m3colors.m3onSurface + } + } + GroupButton { + id: closeButton + colBackground: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer) + baseWidth: windowControlsHeight + baseHeight: windowControlsHeight + buttonRadius: Appearance.rounding.full + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "close" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSurface + } + onClicked: { + windowButton.modelData?.close(); + } + } + } + ScreencopyView { + id: screencopyView + captureSource: previewPopup ? windowButton.modelData : null + live: true + paintCursor: true + constraintSize: Qt.size(root.maxWindowPreviewWidth, root.maxWindowPreviewHeight) + onHasContentChanged: { + previewPopup.updatePreviewReadiness(); + } + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: screencopyView.width + height: screencopyView.height + radius: Appearance.rounding.small + } + } + } + } + } + } + } + } + } + } +} diff --git a/.config/quickshell/modules/dock/DockButton.qml b/.config/quickshell/modules/dock/DockButton.qml new file mode 100644 index 000000000..577cbcdc7 --- /dev/null +++ b/.config/quickshell/modules/dock/DockButton.qml @@ -0,0 +1,16 @@ +import "root:/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RippleButton { + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + implicitWidth: implicitHeight - topInset - bottomInset + buttonRadius: Appearance.rounding.normal + + topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding + bottomInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding +} diff --git a/.config/quickshell/modules/dock/DockSeparator.qml b/.config/quickshell/modules/dock/DockSeparator.qml new file mode 100644 index 000000000..abb45d1da --- /dev/null +++ b/.config/quickshell/modules/dock/DockSeparator.qml @@ -0,0 +1,13 @@ +import "root:/" +import "root:/modules/common" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + Layout.topMargin: Appearance.sizes.elevationMargin + dockRow.padding + Appearance.rounding.normal + Layout.bottomMargin: Appearance.sizes.hyprlandGapsOut + dockRow.padding + Appearance.rounding.normal + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant +} diff --git a/.config/quickshell/modules/mediaControls/MediaControls.qml b/.config/quickshell/modules/mediaControls/MediaControls.qml new file mode 100644 index 000000000..4350658f6 --- /dev/null +++ b/.config/quickshell/modules/mediaControls/MediaControls.qml @@ -0,0 +1,189 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/file_utils.js" as FileUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property bool visible: false + readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property var realPlayers: Mpris.players.values.filter(player => isRealPlayer(player)) + readonly property var meaningfulPlayers: filterDuplicatePlayers(realPlayers) + readonly property real osdWidth: Appearance.sizes.osdWidth + readonly property real widgetWidth: Appearance.sizes.mediaControlsWidth + readonly property real widgetHeight: Appearance.sizes.mediaControlsHeight + property real contentPadding: 13 + property real popupRounding: Appearance.rounding.screenRounding - Appearance.sizes.elevationMargin + 1 + property real artRounding: Appearance.rounding.verysmall + property list visualizerPoints: [] + + property bool hasPlasmaIntegration: false + function isRealPlayer(player) { + // return true + return ( + // Remove unecessary native buses from browsers if there's plasma integration + !(hasPlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.firefox')) && + !(hasPlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.chromium')) && + // playerctld just copies other buses and we don't need duplicates + !player.dbusName?.startsWith('org.mpris.MediaPlayer2.playerctld') && + // Non-instance mpd bus + !(player.dbusName?.endsWith('.mpd') && !player.dbusName.endsWith('MediaPlayer2.mpd')) + ); + } + function filterDuplicatePlayers(players) { + let filtered = []; + let used = new Set(); + + for (let i = 0; i < players.length; ++i) { + if (used.has(i)) continue; + let p1 = players[i]; + let group = [i]; + + // Find duplicates by trackTitle prefix + for (let j = i + 1; j < players.length; ++j) { + let p2 = players[j]; + if (p1.trackTitle && p2.trackTitle && + (p1.trackTitle.includes(p2.trackTitle) || p2.trackTitle.includes(p1.trackTitle))) { + group.push(j); + } + } + + // Pick the one with non-empty trackArtUrl, or fallback to the first + let chosenIdx = group.find(idx => players[idx].trackArtUrl && players[idx].trackArtUrl.length > 0); + if (chosenIdx === undefined) chosenIdx = group[0]; + + filtered.push(players[chosenIdx]); + group.forEach(idx => used.add(idx)); + } + return filtered; + } + + Process { + id: cavaProc + running: mediaControlsLoader.active + onRunningChanged: { + if (!cavaProc.running) { + root.visualizerPoints = []; + } + } + command: ["cava", "-p", `${FileUtils.trimFileProtocol(Directories.config)}/quickshell/scripts/cava/raw_output_config.txt`] + stdout: SplitParser { + onRead: data => { + // Parse `;`-separated values into the visualizerPoints array + let points = data.split(";").map(p => parseFloat(p.trim())).filter(p => !isNaN(p)); + root.visualizerPoints = points; + } + } + } + + Loader { + id: mediaControlsLoader + active: false + + sourceComponent: PanelWindow { + id: mediaControlsRoot + visible: true + + exclusiveZone: 0 + implicitWidth: ( + (mediaControlsRoot.screen.width / 2) // Middle of screen + - (osdWidth / 2) // Dodge OSD + - (widgetWidth / 2) // Account for widget width + ) * 2 + implicitHeight: playerColumnLayout.implicitHeight + color: "transparent" + WlrLayershell.namespace: "quickshell:mediaControls" + + anchors { + top: !ConfigOptions.bar.bottom + bottom: ConfigOptions.bar.bottom + left: true + } + mask: Region { + item: playerColumnLayout + } + + ColumnLayout { + id: playerColumnLayout + anchors.top: parent.top + anchors.bottom: parent.bottom + x: (mediaControlsRoot.screen.width / 2) // Middle of screen + - (osdWidth / 2) // Dodge OSD + - (widgetWidth) // Account for widget width + + (Appearance.sizes.elevationMargin) // It's fine for shadows to overlap + spacing: -Appearance.sizes.elevationMargin // Shadow overlap okay + + Repeater { + model: ScriptModel { + values: root.meaningfulPlayers + } + delegate: PlayerControl { + required property MprisPlayer modelData + player: modelData + visualizerPoints: root.visualizerPoints + } + } + } + } + } + + IpcHandler { + target: "mediaControls" + + function toggle(): void { + mediaControlsLoader.active = !mediaControlsLoader.active; + if(mediaControlsLoader.active) Notifications.timeoutAll(); + } + + function close(): void { + mediaControlsLoader.active = false; + } + + function open(): void { + mediaControlsLoader.active = true; + Notifications.timeoutAll(); + } + } + + GlobalShortcut { + name: "mediaControlsToggle" + description: qsTr("Toggles media controls on press") + + onPressed: { + if (!mediaControlsLoader.active && Mpris.players.values.filter(player => isRealPlayer(player)).length === 0) { + return; + } + mediaControlsLoader.active = !mediaControlsLoader.active; + if(mediaControlsLoader.active) Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "mediaControlsOpen" + description: qsTr("Opens media controls on press") + + onPressed: { + mediaControlsLoader.active = true; + Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "mediaControlsClose" + description: qsTr("Closes media controls on press") + + onPressed: { + mediaControlsLoader.active = false; + } + } + +} \ No newline at end of file diff --git a/.config/quickshell/modules/mediaControls/PlayerControl.qml b/.config/quickshell/modules/mediaControls/PlayerControl.qml new file mode 100644 index 000000000..cce0788db --- /dev/null +++ b/.config/quickshell/modules/mediaControls/PlayerControl.qml @@ -0,0 +1,303 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import "root:/modules/common/functions/file_utils.js" as FileUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { // Player instance + id: playerController + required property MprisPlayer player + property var artUrl: player?.trackArtUrl + property string artDownloadLocation: Directories.coverArt + property string artFileName: Qt.md5(artUrl) + ".jpg" + property string artFilePath: `${artDownloadLocation}/${artFileName}` + property color artDominantColor: colorQuantizer?.colors[0] || Appearance.m3colors.m3secondaryContainer + property bool downloaded: false + property list visualizerPoints: [] + property real maxVisualizerValue: 1000 // Max value in the data points + property int visualizerSmoothing: 2 // Number of points to average for smoothing + + implicitWidth: widgetWidth + implicitHeight: widgetHeight + + component TrackChangeButton: RippleButton { + implicitWidth: 24 + implicitHeight: 24 + + property var iconName + colBackground: ColorUtils.transparentize(blendedColors.colSecondaryContainer, 1) + colBackgroundHover: blendedColors.colSecondaryContainerHover + colRipple: blendedColors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.huge + fill: 1 + horizontalAlignment: Text.AlignHCenter + color: blendedColors.colOnSecondaryContainer + text: iconName + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + + Timer { // Force update for prevision + running: playerController.player?.playbackState == MprisPlaybackState.Playing + interval: 1000 + repeat: true + onTriggered: { + playerController.player.positionChanged() + } + } + + onArtUrlChanged: { + if (playerController.artUrl.length == 0) { + playerController.artDominantColor = Appearance.m3colors.m3secondaryContainer + return; + } + // console.log("PlayerControl: Art URL changed to", playerController.artUrl) + // console.log("Download cmd:", coverArtDownloader.command.join(" ")) + playerController.downloaded = false + coverArtDownloader.running = true + } + + Process { // Cover art downloader + id: coverArtDownloader + property string targetFile: playerController.artUrl + command: [ "bash", "-c", `[ -f ${artFilePath} ] || curl -sSL '${targetFile}' -o '${artFilePath}'` ] + onExited: (exitCode, exitStatus) => { + playerController.downloaded = true + } + } + + ColorQuantizer { + id: colorQuantizer + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + depth: 0 // 2^0 = 1 color + rescaleSize: 1 // Rescale to 1x1 pixel for faster processing + } + + property bool backgroundIsDark: artDominantColor.hslLightness < 0.5 + property QtObject blendedColors: QtObject { + property color colLayer0: ColorUtils.mix(Appearance.colors.colLayer0, artDominantColor, (backgroundIsDark && Appearance.m3colors.darkmode) ? 0.6 : 0.5) + property color colLayer1: ColorUtils.mix(Appearance.colors.colLayer1, artDominantColor, 0.5) + property color colOnLayer0: ColorUtils.mix(Appearance.colors.colOnLayer0, artDominantColor, 0.5) + property color colOnLayer1: ColorUtils.mix(Appearance.colors.colOnLayer1, artDominantColor, 0.5) + property color colSubtext: ColorUtils.mix(Appearance.colors.colOnLayer1, artDominantColor, 0.5) + property color colPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimary, artDominantColor), artDominantColor, 0.5) + property color colPrimaryHover: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryHover, artDominantColor), artDominantColor, 0.3) + property color colPrimaryActive: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryActive, artDominantColor), artDominantColor, 0.3) + property color colSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, artDominantColor, 0.15) + property color colSecondaryContainerHover: ColorUtils.mix(Appearance.colors.colSecondaryContainerHover, artDominantColor, 0.3) + property color colSecondaryContainerActive: ColorUtils.mix(Appearance.colors.colSecondaryContainerActive, artDominantColor, 0.5) + property color colOnPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.m3colors.m3onPrimary, artDominantColor), artDominantColor, 0.5) + property color colOnSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3onSecondaryContainer, artDominantColor, 0.5) + + } + + StyledRectangularShadow { + target: background + } + Rectangle { // Background + id: background + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + color: blendedColors.colLayer0 + radius: root.popupRounding + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: background.width + height: background.height + radius: background.radius + } + } + + Image { + id: blurredArt + anchors.fill: parent + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + sourceSize.width: background.width + sourceSize.height: background.height + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + layer.enabled: true + layer.effect: MultiEffect { + source: blurredArt + saturation: 0.2 + blurEnabled: true + blurMax: 100 + blur: 1 + } + + Rectangle { + anchors.fill: parent + color: ColorUtils.transparentize(blendedColors.colLayer0, 0.25) + radius: root.popupRounding + } + } + + WaveVisualizer { + id: visualizerCanvas + anchors.fill: parent + live: playerController.player?.isPlaying + points: playerController.visualizerPoints + maxVisualizerValue: playerController.maxVisualizerValue + smoothing: playerController.visualizerSmoothing + color: blendedColors.colPrimary + } + + RowLayout { + anchors.fill: parent + anchors.margins: root.contentPadding + spacing: 15 + + Rectangle { // Art background + id: artBackground + Layout.fillHeight: true + implicitWidth: height + radius: root.artRounding + color: ColorUtils.transparentize(blendedColors.colLayer1, 0.5) + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: artBackground.width + height: artBackground.height + radius: artBackground.radius + } + } + + Image { // Art image + id: mediaArt + property int size: parent.height + anchors.fill: parent + + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + width: size + height: size + sourceSize.width: size + sourceSize.height: size + } + } + + ColumnLayout { // Info & controls + Layout.fillHeight: true + spacing: 2 + + StyledText { + id: trackTitle + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.large + color: blendedColors.colOnLayer0 + elide: Text.ElideRight + text: StringUtils.cleanMusicTitle(playerController.player?.trackTitle) || "Untitled" + } + StyledText { + id: trackArtist + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: blendedColors.colSubtext + elide: Text.ElideRight + text: playerController.player?.trackArtist + } + Item { Layout.fillHeight: true } + Item { + Layout.fillWidth: true + implicitHeight: trackTime.implicitHeight + sliderRow.implicitHeight + + StyledText { + id: trackTime + anchors.bottom: sliderRow.top + anchors.bottomMargin: 5 + anchors.left: parent.left + font.pixelSize: Appearance.font.pixelSize.small + color: blendedColors.colSubtext + elide: Text.ElideRight + text: `${StringUtils.friendlyTimeForSeconds(playerController.player?.position)} / ${StringUtils.friendlyTimeForSeconds(playerController.player?.length)}` + } + RowLayout { + id: sliderRow + anchors { + bottom: parent.bottom + left: parent.left + right: parent.right + } + TrackChangeButton { + iconName: "skip_previous" + onClicked: playerController.player?.previous() + } + Item { + id: progressBarContainer + Layout.fillWidth: true + implicitHeight: progressBar.implicitHeight + + StyledProgressBar { + id: progressBar + anchors.fill: parent + highlightColor: blendedColors.colPrimary + trackColor: blendedColors.colSecondaryContainer + value: playerController.player?.position / playerController.player?.length + sperm: playerController.player?.isPlaying + } + } + TrackChangeButton { + iconName: "skip_next" + onClicked: playerController.player?.next() + } + } + + RippleButton { + id: playPauseButton + anchors.right: parent.right + anchors.bottom: sliderRow.top + anchors.bottomMargin: 5 + property real size: 44 + implicitWidth: size + implicitHeight: size + onClicked: playerController.player.togglePlaying(); + + buttonRadius: playerController.player?.isPlaying ? Appearance?.rounding.normal : size / 2 + colBackground: playerController.player?.isPlaying ? blendedColors.colPrimary : blendedColors.colSecondaryContainer + colBackgroundHover: playerController.player?.isPlaying ? blendedColors.colPrimaryHover : blendedColors.colSecondaryContainerHover + colRipple: playerController.player?.isPlaying ? blendedColors.colPrimaryActive : blendedColors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.huge + fill: 1 + horizontalAlignment: Text.AlignHCenter + color: playerController.player?.isPlaying ? blendedColors.colOnPrimary : blendedColors.colOnSecondaryContainer + text: playerController.player?.isPlaying ? "pause" : "play_arrow" + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/notificationPopup/NotificationPopup.qml b/.config/quickshell/modules/notificationPopup/NotificationPopup.qml new file mode 100644 index 000000000..fb046343d --- /dev/null +++ b/.config/quickshell/modules/notificationPopup/NotificationPopup.qml @@ -0,0 +1,47 @@ +import "root:/" +import "root:/modules/common/" +import "root:/modules/common/widgets" +import "root:/services" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: notificationPopup + + PanelWindow { + id: root + visible: (Notifications.popupList.length > 0) + screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? null + + WlrLayershell.namespace: "quickshell:notificationPopup" + WlrLayershell.layer: WlrLayer.Overlay + exclusiveZone: 0 + + anchors { + top: true + right: true + bottom: true + } + + mask: Region { + item: listview.contentItem + } + + color: "transparent" + implicitWidth: Appearance.sizes.notificationPopupWidth + + NotificationListView { + id: listview + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: 5 + implicitWidth: parent.width - Appearance.sizes.elevationMargin * 2 + popup: true + } + } +} diff --git a/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml b/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml new file mode 100644 index 000000000..765386bc8 --- /dev/null +++ b/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml @@ -0,0 +1,152 @@ +import "root:/services/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Quickshell.Wayland + +Scope { + id: root + property bool showOsdValues: false + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + property var brightnessMonitor: Brightness.getMonitorForScreen(focusedScreen) + + function triggerOsd() { + showOsdValues = true + osdTimeout.restart() + } + + Timer { + id: osdTimeout + interval: ConfigOptions.osd.timeout + repeat: false + running: false + onTriggered: { + showOsdValues = false + } + } + + Connections { + target: Audio.sink?.audio ?? null + function onVolumeChanged() { + if (!Audio.ready) return + root.showOsdValues = false + } + } + + Connections { + target: Brightness + function onBrightnessChanged() { + if (!root.brightnessMonitor.ready) return + root.triggerOsd() + } + } + + Loader { + id: osdLoader + active: showOsdValues + + sourceComponent: PanelWindow { + id: osdRoot + + Connections { + target: root + function onFocusedScreenChanged() { + osdRoot.screen = root.focusedScreen + } + } + + exclusionMode: ExclusionMode.Normal + WlrLayershell.namespace: "quickshell:onScreenDisplay" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: !ConfigOptions.bar.bottom + bottom: ConfigOptions.bar.bottom + } + mask: Region { + item: osdValuesWrapper + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + visible: osdLoader.active + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + Item { + id: osdValuesWrapper + // Extra space for shadow + implicitHeight: osdValues.implicitHeight + Appearance.sizes.elevationMargin * 2 + implicitWidth: osdValues.implicitWidth + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: root.showOsdValues = false + } + + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + OsdValueIndicator { + id: osdValues + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + value: root.brightnessMonitor?.brightness ?? 50 + icon: "light_mode" + rotateIcon: true + scaleIcon: true + name: qsTr("Brightness") + } + } + } + + } + } + + IpcHandler { + target: "osdBrightness" + + function trigger() { + root.triggerOsd() + } + + function hide() { + showOsdValues = false + } + + function toggle() { + showOsdValues = !showOsdValues + } + } + + GlobalShortcut { + name: "osdBrightnessTrigger" + description: qsTr("Triggers brightness OSD on press") + + onPressed: { + root.triggerOsd() + } + } + GlobalShortcut { + name: "osdBrightnessHide" + description: qsTr("Hides brightness OSD on press") + + onPressed: { + root.showOsdValues = false + } + } + +} diff --git a/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml b/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml new file mode 100644 index 000000000..5d23b4405 --- /dev/null +++ b/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml @@ -0,0 +1,203 @@ +import "root:/services/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property bool showOsdValues: false + property string protectionMessage: "" + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + + function triggerOsd() { + showOsdValues = true + osdTimeout.restart() + } + + Timer { + id: osdTimeout + interval: ConfigOptions.osd.timeout + repeat: false + running: false + onTriggered: { + root.showOsdValues = false + root.protectionMessage = "" + } + } + + Connections { + target: Brightness + function onBrightnessChanged() { + showOsdValues = false + } + } + + Connections { // Listen to volume changes + target: Audio.sink?.audio ?? null + function onVolumeChanged() { + if (!Audio.ready) return + root.triggerOsd() + } + function onMutedChanged() { + if (!Audio.ready) return + root.triggerOsd() + } + } + + Connections { // Listen to protection triggers + target: Audio + function onSinkProtectionTriggered(reason) { + root.protectionMessage = reason; + root.triggerOsd() + } + } + + Loader { + id: osdLoader + active: showOsdValues + + sourceComponent: PanelWindow { + id: osdRoot + + Connections { + target: root + function onFocusedScreenChanged() { + osdRoot.screen = root.focusedScreen + } + } + + exclusionMode: ExclusionMode.Normal + WlrLayershell.namespace: "quickshell:onScreenDisplay" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: !ConfigOptions.bar.bottom + bottom: ConfigOptions.bar.bottom + } + mask: Region { + item: osdValuesWrapper + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + visible: osdLoader.active + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + Item { + id: osdValuesWrapper + // Extra space for shadow + implicitHeight: contentColumnLayout.implicitHeight + Appearance.sizes.elevationMargin * 2 + implicitWidth: contentColumnLayout.implicitWidth + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: root.showOsdValues = false + } + + ColumnLayout { + id: contentColumnLayout + anchors { + top: parent.top + left: parent.left + right: parent.right + leftMargin: Appearance.sizes.elevationMargin + rightMargin: Appearance.sizes.elevationMargin + } + spacing: 0 + + OsdValueIndicator { + id: osdValues + Layout.fillWidth: true + value: Audio.sink?.audio.volume ?? 0 + icon: Audio.sink?.audio.muted ? "volume_off" : "volume_up" + name: qsTr("Volume") + } + + Item { + id: protectionMessageWrapper + implicitHeight: protectionMessageBackground.implicitHeight + implicitWidth: protectionMessageBackground.implicitWidth + Layout.alignment: Qt.AlignHCenter + opacity: root.protectionMessage !== "" ? 1 : 0 + + StyledRectangularShadow { + target: protectionMessageBackground + } + Rectangle { + id: protectionMessageBackground + anchors.centerIn: parent + color: Appearance.m3colors.m3error + property real padding: 10 + implicitHeight: protectionMessageRowLayout.implicitHeight + padding * 2 + implicitWidth: protectionMessageRowLayout.implicitWidth + padding * 2 + radius: Appearance.rounding.normal + + RowLayout { + id: protectionMessageRowLayout + anchors.centerIn: parent + MaterialSymbol { + id: protectionMessageIcon + text: "dangerous" + iconSize: Appearance.font.pixelSize.hugeass + color: Appearance.m3colors.m3onError + } + StyledText { + id: protectionMessageTextWidget + horizontalAlignment: Text.AlignHCenter + color: Appearance.m3colors.m3onError + wrapMode: Text.Wrap + text: root.protectionMessage + } + } + } + } + } + } + } + } + } + + IpcHandler { + target: "osdVolume" + + function trigger() { + root.triggerOsd() + } + + function hide() { + showOsdValues = false + } + + function toggle() { + showOsdValues = !showOsdValues + } + } + GlobalShortcut { + name: "osdVolumeTrigger" + description: qsTr("Triggers volume OSD on press") + + onPressed: { + root.triggerOsd() + } + } + GlobalShortcut { + name: "osdVolumeHide" + description: qsTr("Hides volume OSD on press") + + onPressed: { + root.showOsdValues = false + } + } + +} diff --git a/.config/quickshell/modules/onScreenDisplay/OsdValueIndicator.qml b/.config/quickshell/modules/onScreenDisplay/OsdValueIndicator.qml new file mode 100644 index 000000000..dfbf4a6b8 --- /dev/null +++ b/.config/quickshell/modules/onScreenDisplay/OsdValueIndicator.qml @@ -0,0 +1,101 @@ +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +// import Qt5Compat.GraphicalEffects + +Item { + id: root + required property real value + required property string icon + required property string name + property bool rotateIcon: false + property bool scaleIcon: false + + property real valueIndicatorVerticalPadding: 9 + property real valueIndicatorLeftPadding: 10 + property real valueIndicatorRightPadding: 20 // An icon is circle ish, a column isn't, hence the extra padding + + Layout.margins: Appearance.sizes.elevationMargin + implicitWidth: Appearance.sizes.osdWidth + implicitHeight: valueIndicator.implicitHeight + + StyledRectangularShadow { + target: valueIndicator + } + WrapperRectangle { + id: valueIndicator + anchors.fill: parent + radius: Appearance.rounding.full + color: Appearance.colors.colLayer0 + implicitWidth: valueRow.implicitWidth + + RowLayout { // Icon on the left, stuff on the right + id: valueRow + Layout.margins: 10 + anchors.fill: parent + spacing: 10 + + Item { + implicitWidth: 30 + implicitHeight: 30 + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: valueIndicatorLeftPadding + Layout.topMargin: valueIndicatorVerticalPadding + Layout.bottomMargin: valueIndicatorVerticalPadding + MaterialSymbol { // Icon + anchors.centerIn: parent + color: Appearance.colors.colOnLayer0 + renderType: Text.QtRendering + + text: root.icon + iconSize: 20 + 10 * (root.scaleIcon ? value : 1) + rotation: 180 * (root.rotateIcon ? value : 0) + + Behavior on iconSize { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on rotation { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + } + } + ColumnLayout { // Stuff + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: valueIndicatorRightPadding + spacing: 5 + + RowLayout { // Name fill left, value on the right end + Layout.leftMargin: valueProgressBar.height / 2 // Align text with progressbar radius curve's left end + Layout.rightMargin: valueProgressBar.height / 2 // Align text with progressbar radius curve's left end + + StyledText { + color: Appearance.colors.colOnLayer0 + font.pixelSize: Appearance.font.pixelSize.small + Layout.fillWidth: true + text: root.name + } + + StyledText { + color: Appearance.colors.colOnLayer0 + font.pixelSize: Appearance.font.pixelSize.small + Layout.fillWidth: false + text: Math.round(root.value * 100) + } + } + + StyledProgressBar { + id: valueProgressBar + Layout.fillWidth: true + value: root.value + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/onScreenKeyboard/OnScreenKeyboard.qml b/.config/quickshell/modules/onScreenKeyboard/OnScreenKeyboard.qml new file mode 100644 index 000000000..e78f45b2e --- /dev/null +++ b/.config/quickshell/modules/onScreenKeyboard/OnScreenKeyboard.qml @@ -0,0 +1,169 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property bool pinned: ConfigOptions?.osk.pinnedOnStartup ?? false + + component OskControlButton: GroupButton { // Pin button + baseWidth: 40 + baseHeight: 40 + clickedWidth: baseWidth + clickedHeight: baseHeight + 20 + buttonRadius: Appearance.rounding.normal + } + + Loader { + id: oskLoader + active: false + onActiveChanged: { + if (!oskLoader.active) { + Ydotool.releaseAllKeys(); + } + } + + sourceComponent: PanelWindow { // Window + id: oskRoot + visible: oskLoader.active + + anchors { + bottom: true + left: true + right: true + } + + function hide() { + oskLoader.active = false + } + exclusiveZone: root.pinned ? implicitHeight - Appearance.sizes.hyprlandGapsOut : 0 + implicitWidth: oskBackground.width + Appearance.sizes.elevationMargin * 2 + implicitHeight: oskBackground.height + Appearance.sizes.elevationMargin * 2 + WlrLayershell.namespace: "quickshell:osk" + WlrLayershell.layer: WlrLayer.Overlay + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + mask: Region { + item: oskBackground + } + + + // Background + StyledRectangularShadow { + target: oskBackground + } + Rectangle { + id: oskBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.windowRounding + property real padding: 10 + implicitWidth: oskRowLayout.implicitWidth + padding * 2 + implicitHeight: oskRowLayout.implicitHeight + padding * 2 + + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + oskRoot.hide() + } + } + + RowLayout { + id: oskRowLayout + anchors.centerIn: parent + spacing: 5 + VerticalButtonGroup { + OskControlButton { // Pin button + toggled: root.pinned + onClicked: root.pinned = !root.pinned + contentItem: MaterialSymbol { + text: "keep" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: root.pinned ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0 + } + } + OskControlButton { + onClicked: () => { + oskRoot.hide() + } + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + text: "keyboard_hide" + iconSize: Appearance.font.pixelSize.larger + } + } + } + Rectangle { + Layout.topMargin: 20 + Layout.bottomMargin: 20 + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant + } + OskContent { + id: oskContent + Layout.fillWidth: true + } + } + } + + } + } + + IpcHandler { + target: "osk" + + function toggle(): void { + oskLoader.active = !oskLoader.active + } + + function close(): void { + oskLoader.active = false + } + + function open(): void { + oskLoader.active = true + } + } + + GlobalShortcut { + name: "oskToggle" + description: qsTr("Toggles on screen keyboard on press") + + onPressed: { + oskLoader.active = !oskLoader.active; + } + } + + GlobalShortcut { + name: "oskOpen" + description: qsTr("Opens on screen keyboard on press") + + onPressed: { + oskLoader.active = true; + } + } + + GlobalShortcut { + name: "oskClose" + description: qsTr("Closes on screen keyboard on press") + + onPressed: { + oskLoader.active = false; + } + } + +} diff --git a/.config/quickshell/modules/onScreenKeyboard/OskContent.qml b/.config/quickshell/modules/onScreenKeyboard/OskContent.qml new file mode 100644 index 000000000..06e954adc --- /dev/null +++ b/.config/quickshell/modules/onScreenKeyboard/OskContent.qml @@ -0,0 +1,47 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "layouts.js" as Layouts +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Hyprland + +Item { + id: root + property var activeLayoutName: ConfigOptions?.osk.layout ?? Layouts.defaultLayout + property var layouts: Layouts.byName + property var currentLayout: layouts[activeLayoutName] + + implicitWidth: keyRows.implicitWidth + implicitHeight: keyRows.implicitHeight + + ColumnLayout { + id: keyRows + anchors.fill: parent + spacing: 5 + + Repeater { + model: root.currentLayout.keys + + delegate: RowLayout { + id: keyRow + required property var modelData + spacing: 5 + + Repeater { + model: modelData + // A normal key looks like this: {label: "a", labelShift: "A", shape: "normal", keycode: 30, type: "normal"} + delegate: OskKey { + required property var modelData + keyData: modelData + } + } + } + } + } +} diff --git a/.config/quickshell/modules/onScreenKeyboard/OskKey.qml b/.config/quickshell/modules/onScreenKeyboard/OskKey.qml new file mode 100644 index 000000000..1f28a9e57 --- /dev/null +++ b/.config/quickshell/modules/onScreenKeyboard/OskKey.qml @@ -0,0 +1,125 @@ +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +RippleButton { + id: root + property var keyData + property string key: keyData.label + property string type: keyData.keytype + property var keycode: keyData.keycode + property string shape: keyData.shape + property bool isShift: Ydotool.shiftKeys.includes(keycode) + property bool isBackspace: (key.toLowerCase() == "backspace") + property bool isEnter: (key.toLowerCase() == "enter" || key.toLowerCase() == "return") + property real baseWidth: 45 + property real baseHeight: 45 + property var widthMultiplier: ({ + "normal": 1, + "fn": 1, + "tab": 1.6, + "caps": 1.9, + "shift": 2.5, + "control": 1.3 + }) + property var heightMultiplier: ({ + "normal": 1, + "fn": 0.7, + "tab": 1, + "caps": 1, + "shift": 1, + "control": 1 + }) + toggled: isShift ? Ydotool.shiftMode : false + + enabled: shape != "empty" + colBackground: shape == "empty" ? ColorUtils.transparentize(Appearance.colors.colLayer1) : Appearance.colors.colLayer1 + buttonRadius: Appearance.rounding.small + implicitWidth: baseWidth * widthMultiplier[shape] || baseWidth + implicitHeight: baseHeight * heightMultiplier[shape] || baseHeight + Layout.fillWidth: shape == "space" || shape == "expand" + + Connections { + target: Ydotool + enabled: isShift + function onShiftModeChanged() { + if (Ydotool.shiftMode == 0) { + capsLockTimer.hasStarted = false; + } + } + } + + Timer { + id: capsLockTimer + property bool hasStarted: false + property bool canCaps: false + interval: 300 + function startWaiting() { + hasStarted = true; + canCaps = true; + start(); + } + onTriggered: { + canCaps = false; + } + } + + downAction: () => { + Ydotool.press(root.keycode); + if (isShift && Ydotool.shiftMode == 0) Ydotool.shiftMode = 1; + } + releaseAction: () => { + if (root.type == "normal") { + Ydotool.release(root.keycode); + if (Ydotool.shiftMode == 1) { + Ydotool.releaseShiftKeys() + } + } else if (isShift) { + if (Ydotool.shiftMode == 1) { + if (!capsLockTimer.hasStarted) { + capsLockTimer.startWaiting(); + } else { + if (capsLockTimer.canCaps) { + Ydotool.shiftMode = 2; // Caps lock mode + } else { + Ydotool.releaseShiftKeys() + } + } + } else if (Ydotool.shiftMode == 2) { + Ydotool.releaseShiftKeys(); + } + } else if (root.type == "modkey") { + root.toggled = !root.toggled; + if (!root.toggled) { + if (isShift) { + Ydotool.releaseShiftKeys(); + } else { + Ydotool.release(root.keycode); + } + } + } + + } + + contentItem: StyledText { + id: keyText + anchors.fill: parent + font.family: (isBackspace || isEnter) ? Appearance.font.family.iconMaterial : Appearance.font.family.main + font.pixelSize: root.shape == "fn" ? Appearance.font.pixelSize.small : + (isBackspace || isEnter) ? Appearance.font.pixelSize.huge : + Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: root.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + text: root.isBackspace ? "backspace" : root.isEnter ? "subdirectory_arrow_left" : + Ydotool.shiftMode == 2 ? (root.keyData.labelCaps || root.keyData.labelShift || root.keyData.label) : + Ydotool.shiftMode == 1 ? (root.keyData.labelShift || root.keyData.label) : + root.keyData.label + } +} diff --git a/.config/ags/modules/onscreenkeyboard/data_keyboardlayouts.js b/.config/quickshell/modules/onScreenKeyboard/layouts.js similarity index 98% rename from .config/ags/modules/onscreenkeyboard/data_keyboardlayouts.js rename to .config/quickshell/modules/onScreenKeyboard/layouts.js index 1a67b7a1f..6b8b98f06 100644 --- a/.config/ags/modules/onscreenkeyboard/data_keyboardlayouts.js +++ b/.config/quickshell/modules/onScreenKeyboard/layouts.js @@ -1,9 +1,9 @@ // We're going to use ydotool // See /usr/include/linux/input-event-codes.h for keycodes -export const DEFAULT_OSK_LAYOUT = "qwerty_full" -export const oskLayouts = { - qwerty_full: { +const defaultLayout = "qwerty_full"; +const byName = { + "qwerty_full": { name: "QWERTY - Full", name_short: "US", comment: "Like physical keyboard", @@ -88,7 +88,7 @@ export const oskLayouts = { { keytype: "normal", label: "Enter", shape: "expand", keycode: 28 } ], [ - { keytype: "modkey", label: "Shift", labelShift: "Shift ⇧", labelCaps: "Locked ⇩", shape: "shift", keycode: 42 }, + { keytype: "modkey", label: "Shift", labelShift: "Shift", labelCaps: "Caps", shape: "shift", keycode: 42 }, { keytype: "normal", label: "z", labelShift: "Z", shape: "normal", keycode: 44 }, { keytype: "normal", label: "x", labelShift: "X", shape: "normal", keycode: 45 }, { keytype: "normal", label: "c", labelShift: "C", shape: "normal", keycode: 46 }, @@ -99,7 +99,7 @@ export const oskLayouts = { { keytype: "normal", label: ",", labelShift: "<", shape: "normal", keycode: 51 }, { keytype: "normal", label: ".", labelShift: ">", shape: "normal", keycode: 52 }, { keytype: "normal", label: "/", labelShift: "?", shape: "normal", keycode: 53 }, - { keytype: "modkey", label: "Shift", labelShift: "Shift ⇧", labelCaps: "Locked ⇩", shape: "expand", keycode: 54 } // optional + { keytype: "modkey", label: "Shift", labelShift: "Shift", labelCaps: "Caps", shape: "expand", keycode: 54 } // optional ], [ { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 29 }, @@ -113,7 +113,7 @@ export const oskLayouts = { ] ] }, - qwertz_full: { + "qwertz_full": { name: "QWERTZ - Full", name_short: "DE", comment: "Keyboard layout commonly used in German-speaking countries", diff --git a/.config/quickshell/modules/overview/Overview.qml b/.config/quickshell/modules/overview/Overview.qml new file mode 100644 index 000000000..a7817e6e9 --- /dev/null +++ b/.config/quickshell/modules/overview/Overview.qml @@ -0,0 +1,240 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: overviewScope + property bool dontAutoCancelSearch: false + Variants { + id: overviewVariants + model: Quickshell.screens + PanelWindow { + id: root + required property var modelData + property string searchingText: "" + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) + property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor.id) + screen: modelData + visible: GlobalStates.overviewOpen + + WlrLayershell.namespace: "quickshell:overview" + WlrLayershell.layer: WlrLayer.Overlay + // WlrLayershell.keyboardFocus: GlobalStates.overviewOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + color: "transparent" + + mask: Region { + item: GlobalStates.overviewOpen ? columnLayout : null + } + HyprlandWindow.visibleMask: Region { + item: GlobalStates.overviewOpen ? columnLayout : null + } + + + anchors { + top: true + left: true + right: true + bottom: true + } + + HyprlandFocusGrab { + id: grab + windows: [ root ] + property bool canBeActive: root.monitorIsFocused + active: false + onCleared: () => { + if (!active) GlobalStates.overviewOpen = false + } + } + + Connections { + target: GlobalStates + function onOverviewOpenChanged() { + if (!GlobalStates.overviewOpen) { + searchWidget.disableExpandAnimation() + overviewScope.dontAutoCancelSearch = false; + } else { + if (!overviewScope.dontAutoCancelSearch) { + searchWidget.cancelSearch() + } + delayedGrabTimer.start() + } + } + } + + Timer { + id: delayedGrabTimer + interval: ConfigOptions.hacks.arbitraryRaceConditionDelay + repeat: false + onTriggered: { + if (!grab.canBeActive) return + grab.active = GlobalStates.overviewOpen + } + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + + function setSearchingText(text) { + searchWidget.setSearchingText(text); + } + + ColumnLayout { + id: columnLayout + visible: GlobalStates.overviewOpen + anchors { + horizontalCenter: parent.horizontalCenter + top: !ConfigOptions.bar.bottom ? parent.top : undefined + bottom: ConfigOptions.bar.bottom ? parent.bottom : undefined + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + GlobalStates.overviewOpen = false; + } else if (event.key === Qt.Key_Left) { + if (!root.searchingText) Hyprland.dispatch("workspace r-1"); + } else if (event.key === Qt.Key_Right) { + if (!root.searchingText) Hyprland.dispatch("workspace r+1"); + } + } + + Item { + height: 1 // Prevent Wayland protocol error + width: 1 // Prevent Wayland protocol error + } + + SearchWidget { + id: searchWidget + Layout.alignment: Qt.AlignHCenter + onSearchingTextChanged: (text) => { + root.searchingText = searchingText + } + } + + Loader { + id: overviewLoader + active: GlobalStates.overviewOpen + sourceComponent: OverviewWidget { + panelWindow: root + visible: (root.searchingText == "") + } + } + } + + } + } + + IpcHandler { + target: "overview" + + function toggle() { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen + } + function close() { + GlobalStates.overviewOpen = false + } + function open() { + GlobalStates.overviewOpen = true + } + function toggleReleaseInterrupt() { + GlobalStates.superReleaseMightTrigger = false + } + } + + GlobalShortcut { + name: "overviewToggle" + description: qsTr("Toggles overview on press") + + onPressed: { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen + } + } + GlobalShortcut { + name: "overviewClose" + description: qsTr("Closes overview") + + onPressed: { + GlobalStates.overviewOpen = false + } + } + GlobalShortcut { + name: "overviewToggleRelease" + description: qsTr("Toggles overview on release") + + onPressed: { + GlobalStates.superReleaseMightTrigger = true + } + + onReleased: { + if (!GlobalStates.superReleaseMightTrigger) { + GlobalStates.superReleaseMightTrigger = true + return + } + GlobalStates.overviewOpen = !GlobalStates.overviewOpen + } + } + GlobalShortcut { + name: "overviewToggleReleaseInterrupt" + description: qsTr("Interrupts possibility of overview being toggled on release. ") + + qsTr("This is necessary because GlobalShortcut.onReleased in quickshell triggers whether or not you press something else while holding the key. ") + + qsTr("To make sure this works consistently, use binditn = MODKEYS, catchall in an automatically triggered submap that includes everything.") + + onPressed: { + GlobalStates.superReleaseMightTrigger = false + } + } + GlobalShortcut { + name: "overviewClipboardToggle" + description: qsTr("Toggle clipboard query on overview widget") + + onPressed: { + if (GlobalStates.overviewOpen && overviewScope.dontAutoCancelSearch) { + GlobalStates.overviewOpen = false; + return; + } + for (let i = 0; i < overviewVariants.instances.length; i++) { + let panelWindow = overviewVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + overviewScope.dontAutoCancelSearch = true; + panelWindow.setSearchingText( + ConfigOptions.search.prefix.clipboard + ); + GlobalStates.overviewOpen = true; + return + } + } + } + } + + GlobalShortcut { + name: "overviewEmojiToggle" + description: qsTr("Toggle emoji query on overview widget") + + onPressed: { + if (GlobalStates.overviewOpen && overviewScope.dontAutoCancelSearch) { + GlobalStates.overviewOpen = false; + return; + } + for (let i = 0; i < overviewVariants.instances.length; i++) { + let panelWindow = overviewVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + overviewScope.dontAutoCancelSearch = true; + panelWindow.setSearchingText( + ConfigOptions.search.prefix.emojis + ); + GlobalStates.overviewOpen = true; + return + } + } + } + } + +} diff --git a/.config/quickshell/modules/overview/OverviewWidget.qml b/.config/quickshell/modules/overview/OverviewWidget.qml new file mode 100644 index 000000000..e0999d6e3 --- /dev/null +++ b/.config/quickshell/modules/overview/OverviewWidget.qml @@ -0,0 +1,269 @@ +import "root:/" +import "root:/services/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { + id: root + required property var panelWindow + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(panelWindow.screen) + readonly property var toplevels: ToplevelManager.toplevels + readonly property int workspacesShown: ConfigOptions.overview.numOfRows * ConfigOptions.overview.numOfCols + readonly property int workspaceGroup: Math.floor((monitor.activeWorkspace?.id - 1) / workspacesShown) + property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor.id) + property var windows: HyprlandData.windowList + property var windowByAddress: HyprlandData.windowByAddress + property var windowAddresses: HyprlandData.addresses + property var monitorData: HyprlandData.monitors.find(m => m.id === root.monitor.id) + property real scale: ConfigOptions.overview.scale + property color activeBorderColor: Appearance.colors.colSecondary + + property real workspaceImplicitWidth: (monitorData?.transform % 2 === 1) ? + ((monitor.height - monitorData?.reserved[0] - monitorData?.reserved[2]) * root.scale / monitor.scale) : + ((monitor.width - monitorData?.reserved[0] - monitorData?.reserved[2]) * root.scale / monitor.scale) + property real workspaceImplicitHeight: (monitorData?.transform % 2 === 1) ? + ((monitor.width - monitorData?.reserved[1] - monitorData?.reserved[3]) * root.scale / monitor.scale) : + ((monitor.height - monitorData?.reserved[1] - monitorData?.reserved[3]) * root.scale / monitor.scale) + + property real workspaceNumberMargin: 80 + property real workspaceNumberSize: Math.min(workspaceImplicitHeight, workspaceImplicitWidth) * monitor.scale + property int workspaceZ: 0 + property int windowZ: 1 + property int windowDraggingZ: 99999 + property real workspaceSpacing: 5 + + property int draggingFromWorkspace: -1 + property int draggingTargetWorkspace: -1 + + implicitWidth: overviewBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: overviewBackground.implicitHeight + Appearance.sizes.elevationMargin * 2 + + property Component windowComponent: OverviewWindow {} + property list windowWidgets: [] + + StyledRectangularShadow { + target: overviewBackground + } + Rectangle { // Background + id: overviewBackground + property real padding: 10 + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + + implicitWidth: workspaceColumnLayout.implicitWidth + padding * 2 + implicitHeight: workspaceColumnLayout.implicitHeight + padding * 2 + radius: Appearance.rounding.screenRounding * root.scale + padding + color: Appearance.colors.colLayer0 + + ColumnLayout { // Workspaces + id: workspaceColumnLayout + + z: root.workspaceZ + anchors.centerIn: parent + spacing: workspaceSpacing + Repeater { + model: ConfigOptions.overview.numOfRows + delegate: RowLayout { + id: row + property int rowIndex: index + spacing: workspaceSpacing + + Repeater { // Workspace repeater + model: ConfigOptions.overview.numOfCols + Rectangle { // Workspace + id: workspace + property int colIndex: index + property int workspaceValue: root.workspaceGroup * workspacesShown + rowIndex * ConfigOptions.overview.numOfCols + colIndex + 1 + property color defaultWorkspaceColor: Appearance.colors.colLayer1 // TODO: reconsider this color for a cleaner look + property color hoveredWorkspaceColor: ColorUtils.mix(defaultWorkspaceColor, Appearance.colors.colLayer1Hover, 0.1) + property color hoveredBorderColor: Appearance.colors.colLayer2Hover + property bool hoveredWhileDragging: false + + implicitWidth: root.workspaceImplicitWidth + implicitHeight: root.workspaceImplicitHeight + color: hoveredWhileDragging ? hoveredWorkspaceColor : defaultWorkspaceColor + radius: Appearance.rounding.screenRounding * root.scale + border.width: 2 + border.color: hoveredWhileDragging ? hoveredBorderColor : "transparent" + + StyledText { + anchors.centerIn: parent + text: workspaceValue + font.pixelSize: root.workspaceNumberSize * root.scale + font.weight: Font.DemiBold + color: ColorUtils.transparentize(Appearance.colors.colOnLayer1, 0.8) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + MouseArea { + id: workspaceArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: { + if (root.draggingTargetWorkspace === -1) { + // Hyprland.dispatch(`exec qs ipc call overview close`) + GlobalStates.overviewOpen = false + Hyprland.dispatch(`workspace ${workspaceValue}`) + } + } + } + + DropArea { + anchors.fill: parent + onEntered: { + root.draggingTargetWorkspace = workspaceValue + if (root.draggingFromWorkspace == root.draggingTargetWorkspace) return; + hoveredWhileDragging = true + } + onExited: { + hoveredWhileDragging = false + if (root.draggingTargetWorkspace == workspaceValue) root.draggingTargetWorkspace = -1 + } + } + + } + } + } + } + } + + Item { // Windows & focused workspace indicator + id: windowSpace + anchors.centerIn: parent + implicitWidth: workspaceColumnLayout.implicitWidth + implicitHeight: workspaceColumnLayout.implicitHeight + + Repeater { // Window repeater + model: ScriptModel { + values: { + // console.log(JSON.stringify(ToplevelManager.toplevels.values.map(t => t), null, 2)) + return ToplevelManager.toplevels.values.filter((toplevel) => { + const address = `0x${toplevel.HyprlandToplevel.address}` + // console.log(`Checking window with address: ${address}`) + var win = windowByAddress[address] + return (root.workspaceGroup * root.workspacesShown < win?.workspace?.id && win?.workspace?.id <= (root.workspaceGroup + 1) * root.workspacesShown) + }) + } + } + delegate: OverviewWindow { + id: window + required property var modelData + property var address: `0x${modelData.HyprlandToplevel.address}` + windowData: windowByAddress[address] + toplevel: modelData + monitorData: root.monitorData + scale: root.scale + availableWorkspaceWidth: root.workspaceImplicitWidth + availableWorkspaceHeight: root.workspaceImplicitHeight + + property int monitorId: windowData?.monitor + property var monitor: HyprlandData.monitors[monitorId] + + property bool atInitPosition: (initX == x && initY == y) + restrictToWorkspace: Drag.active || atInitPosition + + property int workspaceColIndex: (windowData?.workspace.id - 1) % ConfigOptions.overview.numOfCols + property int workspaceRowIndex: Math.floor((windowData?.workspace.id - 1) % root.workspacesShown / ConfigOptions.overview.numOfCols) + xOffset: (root.workspaceImplicitWidth + workspaceSpacing) * workspaceColIndex - (monitor?.x * root.scale) + yOffset: (root.workspaceImplicitHeight + workspaceSpacing) * workspaceRowIndex - (monitor?.y * root.scale) + + Timer { + id: updateWindowPosition + interval: ConfigOptions.hacks.arbitraryRaceConditionDelay + repeat: false + running: false + onTriggered: { + window.x = Math.round(Math.max((windowData?.at[0] - monitorData?.reserved[0]) * root.scale, 0) + xOffset) + window.y = Math.round(Math.max((windowData?.at[1] - monitorData?.reserved[1]) * root.scale, 0) + yOffset) + // console.log(`[OverviewWindow] Updated position for window ${windowData?.address} to (${window.x}, ${window.y})`) + } + } + + z: atInitPosition ? root.windowZ : root.windowDraggingZ + Drag.hotSpot.x: targetWindowWidth / 2 + Drag.hotSpot.y: targetWindowHeight / 2 + MouseArea { + id: dragArea + anchors.fill: parent + hoverEnabled: true + onEntered: hovered = true // For hover color change + onExited: hovered = false // For hover color change + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + drag.target: parent + onPressed: { + root.draggingFromWorkspace = windowData?.workspace.id + window.pressed = true + window.Drag.active = true + window.Drag.source = window + // console.log(`[OverviewWindow] Dragging window ${windowData?.address} from position (${window.x}, ${window.y})`) + } + onReleased: { + const targetWorkspace = root.draggingTargetWorkspace + window.pressed = false + window.Drag.active = false + root.draggingFromWorkspace = -1 + if (targetWorkspace !== -1 && targetWorkspace !== windowData?.workspace.id) { + Hyprland.dispatch(`movetoworkspacesilent ${targetWorkspace}, address:${window.windowData?.address}`) + updateWindowPosition.restart() + } + else { + window.x = window.initX + window.y = window.initY + } + } + onClicked: (event) => { + if (!windowData) return; + + if (event.button === Qt.LeftButton) { + GlobalStates.overviewOpen = false + Hyprland.dispatch(`focuswindow address:${windowData.address}`) + event.accepted = true + } else if (event.button === Qt.MiddleButton) { + Hyprland.dispatch(`closewindow address:${windowData.address}`) + event.accepted = true + } + } + + StyledToolTip { + extraVisibleCondition: false + alternativeVisibleCondition: dragArea.containsMouse && !window.Drag.active + content: `${windowData.title}\n[${windowData.class}] ${windowData.xwayland ? "[XWayland] " : ""}\n` + } + } + } + } + + Rectangle { // Focused workspace indicator + id: focusedWorkspaceIndicator + property int activeWorkspaceInGroup: monitor.activeWorkspace?.id - (root.workspaceGroup * root.workspacesShown) + property int activeWorkspaceRowIndex: Math.floor((activeWorkspaceInGroup - 1) / ConfigOptions.overview.numOfCols) + property int activeWorkspaceColIndex: (activeWorkspaceInGroup - 1) % ConfigOptions.overview.numOfCols + x: (root.workspaceImplicitWidth + workspaceSpacing) * activeWorkspaceColIndex + y: (root.workspaceImplicitHeight + workspaceSpacing) * activeWorkspaceRowIndex + z: root.windowZ + width: root.workspaceImplicitWidth + height: root.workspaceImplicitHeight + color: "transparent" + radius: Appearance.rounding.screenRounding * root.scale + border.width: 2 + border.color: root.activeBorderColor + Behavior on x { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on y { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + } + } +} diff --git a/.config/quickshell/modules/overview/OverviewWindow.qml b/.config/quickshell/modules/overview/OverviewWindow.qml new file mode 100644 index 000000000..3b376988b --- /dev/null +++ b/.config/quickshell/modules/overview/OverviewWindow.qml @@ -0,0 +1,111 @@ +import "root:/" +import "root:/services/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { // Window + id: root + property var toplevel + property var windowData + property var monitorData + property var scale + property var availableWorkspaceWidth + property var availableWorkspaceHeight + property bool restrictToWorkspace: true + property real initX: Math.max((windowData?.at[0] - monitorData?.reserved[0]) * root.scale, 0) + xOffset + property real initY: Math.max((windowData?.at[1] - monitorData?.reserved[1]) * root.scale, 0) + yOffset + property real xOffset: 0 + property real yOffset: 0 + + property var targetWindowWidth: windowData?.size[0] * scale + property var targetWindowHeight: windowData?.size[1] * scale + property bool hovered: false + property bool pressed: false + + property var iconToWindowRatio: 0.35 + property var xwaylandIndicatorToIconRatio: 0.35 + property var iconToWindowRatioCompact: 0.6 + property var iconPath: Quickshell.iconPath(AppSearch.guessIcon(windowData?.class), "image-missing") + property bool compactMode: Appearance.font.pixelSize.smaller * 4 > targetWindowHeight || Appearance.font.pixelSize.smaller * 4 > targetWindowWidth + + property bool indicateXWayland: (ConfigOptions.overview.showXwaylandIndicator && windowData?.xwayland) ?? false + + x: initX + y: initY + width: Math.round(Math.min(windowData?.size[0] * root.scale, (restrictToWorkspace ? windowData?.size[0] : availableWorkspaceWidth - x + xOffset))) + height: Math.round(Math.min(windowData?.size[1] * root.scale, (restrictToWorkspace ? windowData?.size[1] : availableWorkspaceHeight - y + yOffset))) + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.width + height: root.height + radius: Appearance.rounding.windowRounding * root.scale + } + } + + Behavior on x { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on y { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ScreencopyView { + id: windowPreview + anchors.fill: parent + captureSource: GlobalStates.overviewOpen ? root.toplevel : null + live: true + + Rectangle { + anchors.fill: parent + radius: Appearance.rounding.windowRounding * root.scale + color: pressed ? ColorUtils.transparentize(Appearance.colors.colLayer2Active, 0.5) : + hovered ? ColorUtils.transparentize(Appearance.colors.colLayer2Hover, 0.7) : + ColorUtils.transparentize(Appearance.colors.colLayer2) + border.color : ColorUtils.transparentize(Appearance.m3colors.m3outline, 0.7) + border.width : 1 + } + + ColumnLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.font.pixelSize.smaller * 0.5 + + Image { + id: windowIcon + property var iconSize: Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) + // mipmap: true + Layout.alignment: Qt.AlignHCenter + source: root.iconPath + width: iconSize + height: iconSize + sourceSize: Qt.size(iconSize, iconSize) + + Behavior on width { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/overview/SearchItem.qml b/.config/quickshell/modules/overview/SearchItem.qml new file mode 100644 index 000000000..d23cb4c09 --- /dev/null +++ b/.config/quickshell/modules/overview/SearchItem.qml @@ -0,0 +1,228 @@ +// pragma NativeMethodBehavior: AcceptThisObject +import "root:/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/fuzzysort.js" as Fuzzy +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Hyprland + +RippleButton { + id: root + property var entry + property string query + property bool entryShown: entry?.shown ?? true + property string itemType: entry?.type + property string itemName: entry?.name + property string itemIcon: entry?.icon ?? "" + property var itemExecute: entry?.execute + property string fontType: entry?.fontType ?? "main" + property string itemClickActionName: entry?.clickActionName + property string bigText: entry?.bigText ?? "" + property string materialSymbol: entry?.materialSymbol ?? "" + property string cliphistRawString: entry?.cliphistRawString ?? "" + + property string highlightPrefix: `` + property string highlightSuffix: `` + function highlightContent(content, query) { + if (!query || query.length === 0 || content == query || fontType === "monospace") + return StringUtils.escapeHtml(content); + + let contentLower = content.toLowerCase(); + let queryLower = query.toLowerCase(); + + let result = ""; + let lastIndex = 0; + let qIndex = 0; + + for (let i = 0; i < content.length && qIndex < query.length; i++) { + if (contentLower[i] === queryLower[qIndex]) { + // Add non-highlighted part (escaped) + if (i > lastIndex) + result += StringUtils.escapeHtml(content.slice(lastIndex, i)); + // Add highlighted character (escaped) + result += root.highlightPrefix + StringUtils.escapeHtml(content[i]) + root.highlightSuffix; + lastIndex = i + 1; + qIndex++; + } + } + // Add the rest of the string (escaped) + if (lastIndex < content.length) + result += StringUtils.escapeHtml(content.slice(lastIndex)); + + return result; + } + property string displayContent: highlightContent(root.itemName, root.query) + + property list urls: { + if (!root.itemName) return []; + // Regular expression to match URLs + const urlRegex = /https?:\/\/[^\s<>"{}|\\^`[\]]+/gi; + const matches = root.itemName?.match(urlRegex) + ?.filter(url => !url.includes("…")) // Elided = invalid + return matches ? matches : []; + } + + visible: root.entryShown + property int horizontalMargin: 10 + property int buttonHorizontalPadding: 10 + property int buttonVerticalPadding: 5 + property bool keyboardDown: false + + implicitHeight: rowLayout.implicitHeight + root.buttonVerticalPadding * 2 + implicitWidth: rowLayout.implicitWidth + root.buttonHorizontalPadding * 2 + buttonRadius: Appearance.rounding.normal + colBackground: (root.down || root.keyboardDown) ? Appearance.colors.colLayer1Active : + ((root.hovered || root.focus) ? Appearance.colors.colLayer1Hover : + ColorUtils.transparentize(Appearance.colors.colSurfaceContainerHigh, 1)) + colBackgroundHover: Appearance.colors.colLayer1Hover + colRipple: Appearance.colors.colLayer1Active + + background { + anchors.fill: root + anchors.leftMargin: root.horizontalMargin + anchors.rightMargin: root.horizontalMargin + } + + PointingHandInteraction {} + onClicked: { + root.itemExecute() + Hyprland.dispatch("global quickshell:overviewClose") + } + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.keyboardDown = true + root.clicked() + event.accepted = true; + } + } + Keys.onReleased: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.keyboardDown = false + event.accepted = true; + } + } + + RowLayout { + id: rowLayout + spacing: iconLoader.sourceComponent === null ? 0 : 10 + anchors.fill: parent + anchors.leftMargin: root.horizontalMargin + root.buttonHorizontalPadding + anchors.rightMargin: root.horizontalMargin + root.buttonHorizontalPadding + + // Icon + Loader { + id: iconLoader + active: true + sourceComponent: root.materialSymbol !== "" ? materialSymbolComponent : + root.bigText ? bigTextComponent : + root.itemIcon !== "" ? iconImageComponent : + null + } + + Component { + id: iconImageComponent + IconImage { + source: Quickshell.iconPath(root.itemIcon, "image-missing") + width: 35 + height: 35 + } + } + + Component { + id: materialSymbolComponent + MaterialSymbol { + text: root.materialSymbol + iconSize: 30 + color: Appearance.m3colors.m3onSurface + } + } + + Component { + id: bigTextComponent + StyledText { + text: root.bigText + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onSurface + } + } + + // Main text + ColumnLayout { + id: contentColumn + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 0 + StyledText { + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + visible: root.itemType && root.itemType != qsTr("App") + text: root.itemType + } + RowLayout { + Loader { // Checkmark for copied clipboard entry + visible: itemName == Quickshell.clipboardText && root.cliphistRawString + active: itemName == Quickshell.clipboardText && root.cliphistRawString + sourceComponent: Rectangle { + implicitWidth: activeText.implicitHeight + implicitHeight: activeText.implicitHeight + radius: Appearance.rounding.full + color: Appearance.colors.colPrimary + MaterialSymbol { + id: activeText + anchors.centerIn: parent + text: "check" + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onPrimary + } + } + } + Repeater { // Favicons for links + model: root.query == root.itemName ? [] : root.urls + Favicon { + required property var modelData + size: parent.height + url: modelData + } + } + StyledText { // Item name/content + Layout.fillWidth: true + id: nameText + textFormat: Text.StyledText // RichText also works, but StyledText ensures elide work + font.pixelSize: Appearance.font.pixelSize.small + font.family: Appearance.font.family[root.fontType] + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + text: `${root.displayContent}` + } + } + Loader { // Clipboard image preview + active: root.cliphistRawString && /^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(root.cliphistRawString) + sourceComponent: CliphistImage { + Layout.fillWidth: true + entry: root.cliphistRawString + maxWidth: contentColumn.width + maxHeight: 140 + } + } + } + + // Action text + StyledText { + Layout.fillWidth: false + visible: (root.hovered || root.focus) + id: clickAction + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + text: root.itemClickActionName + } + } +} diff --git a/.config/quickshell/modules/overview/SearchWidget.qml b/.config/quickshell/modules/overview/SearchWidget.qml new file mode 100644 index 000000000..9e6866240 --- /dev/null +++ b/.config/quickshell/modules/overview/SearchWidget.qml @@ -0,0 +1,425 @@ +import "root:/" +import "root:/services/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import Qt5Compat.GraphicalEffects +import Qt.labs.platform +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +Item { // Wrapper + id: root + readonly property string xdgConfigHome: Directories.config + property string searchingText: "" + property bool showResults: searchingText != "" + property real searchBarHeight: searchBar.height + Appearance.sizes.elevationMargin * 2 + implicitWidth: searchWidgetContent.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: searchWidgetContent.implicitHeight + Appearance.sizes.elevationMargin * 2 + + property string mathResult: "" + + function disableExpandAnimation() { + searchWidthBehavior.enabled = false; + } + + function cancelSearch() { + searchInput.selectAll() + root.searchingText = "" + searchWidthBehavior.enabled = true; + } + + function setSearchingText(text) { + searchInput.text = text; + root.searchingText = text; + } + + property var searchActions: [ + { + action: "img", + execute: () => { + executor.executeCommand(Directories.wallpaperSwitchScriptPath) + } + }, + { + action: "dark", + execute: () => { + executor.executeCommand(`${Directories.wallpaperSwitchScriptPath} --mode dark --noswitch`) + } + }, + { + action: "light", + execute: () => { + executor.executeCommand(`${Directories.wallpaperSwitchScriptPath} --mode light --noswitch`) + } + }, + { + action: "accentcolor", + execute: (args) => { + executor.executeCommand( + `${Directories.wallpaperSwitchScriptPath} --noswitch --color ${args != '' ? ("'"+args+"'") : ""}` + ) + } + }, + { + action: "todo", + execute: (args) => { + Todo.addTask(args) + } + }, + ] + + function focusFirstItemIfNeeded() { + if (searchInput.focus) appResults.currentIndex = 0; // Focus the first item + } + + Timer { + id: nonAppResultsTimer + interval: ConfigOptions.search.nonAppResultDelay + onTriggered: { + mathProcess.calculateExpression(root.searchingText); + } + } + + Process { + id: mathProcess + property list baseCommand: ["qalc", "-t"] + function calculateExpression(expression) { + // mathProcess.running = false + mathProcess.command = baseCommand.concat(expression) + mathProcess.running = true + } + stdout: SplitParser { + onRead: data => { + root.mathResult = data + root.focusFirstItemIfNeeded() + } + } + } + + Process { + id: executor + property list baseCommand: ["bash", "-c"] + function executeCommand(command) { + executor.command = baseCommand.concat( + `${command} || ${ConfigOptions.apps.terminal} fish -C 'echo "${qsTr("Searching for package with that command")}..." && pacman -F ${command}'` + ) + executor.startDetached() + } + } + + Keys.onPressed: (event) => { + // Prevent Esc and Backspace from registering + if (event.key === Qt.Key_Escape) return; + + // Handle Backspace: focus and delete character if not focused + if (event.key === Qt.Key_Backspace) { + if (!searchInput.activeFocus) { + searchInput.forceActiveFocus(); + if (event.modifiers & Qt.ControlModifier) { + // Delete word before cursor + let text = searchInput.text; + let pos = searchInput.cursorPosition; + if (pos > 0) { + // Find the start of the previous word + let left = text.slice(0, pos); + let match = left.match(/(\s*\S+)\s*$/); + let deleteLen = match ? match[0].length : 1; + searchInput.text = text.slice(0, pos - deleteLen) + text.slice(pos); + searchInput.cursorPosition = pos - deleteLen; + } + } else { + // Delete character before cursor if any + if (searchInput.cursorPosition > 0) { + searchInput.text = searchInput.text.slice(0, searchInput.cursorPosition - 1) + + searchInput.text.slice(searchInput.cursorPosition); + searchInput.cursorPosition -= 1; + } + } + // Always move cursor to end after programmatic edit + searchInput.cursorPosition = searchInput.text.length; + event.accepted = true; + } + // If already focused, let TextField handle it + return; + } + + // Only handle visible printable characters (ignore control chars, arrows, etc.) + if ( + event.text && + event.text.length === 1 && + event.key !== Qt.Key_Enter && + event.key !== Qt.Key_Return && + event.text.charCodeAt(0) >= 0x20 // ignore control chars like Backspace, Tab, etc. + ) { + if (!searchInput.activeFocus) { + searchInput.forceActiveFocus(); + // Insert the character at the cursor position + searchInput.text = searchInput.text.slice(0, searchInput.cursorPosition) + + event.text + + searchInput.text.slice(searchInput.cursorPosition); + searchInput.cursorPosition += 1; + event.accepted = true; + } + } + } + + StyledRectangularShadow { + target: searchWidgetContent + } + Rectangle { // Background + id: searchWidgetContent + anchors.centerIn: parent + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + radius: Appearance.rounding.large + color: Appearance.colors.colLayer0 + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + spacing: 0 + + // clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: searchWidgetContent.width + height: searchWidgetContent.width + radius: searchWidgetContent.radius + } + } + + RowLayout { + id: searchBar + spacing: 5 + MaterialSymbol { + id: searchIcon + Layout.leftMargin: 15 + iconSize: Appearance.font.pixelSize.huge + color: Appearance.m3colors.m3onSurface + text: root.searchingText.startsWith(ConfigOptions.search.prefix.clipboard) ? 'content_paste_search' : 'search' + } + TextField { // Search box + id: searchInput + + focus: GlobalStates.overviewOpen + Layout.rightMargin: 15 + padding: 15 + renderType: Text.NativeRendering + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderText: qsTr("Search, calculate or run") + placeholderTextColor: Appearance.m3colors.m3outline + implicitWidth: root.searchingText == "" ? Appearance.sizes.searchWidthCollapsed : Appearance.sizes.searchWidth + + Behavior on implicitWidth { + id: searchWidthBehavior + enabled: false + NumberAnimation { + duration: 300 + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + onTextChanged: root.searchingText = text + + onAccepted: { + if (appResults.count > 0) { + // Get the first visible delegate and trigger its click + let firstItem = appResults.itemAtIndex(0); + if (firstItem && firstItem.clicked) { + firstItem.clicked(); + } + } + } + + background: null + + cursorDelegate: Rectangle { + width: 1 + color: searchInput.activeFocus ? Appearance.colors.colPrimary : "transparent" + radius: 1 + } + } + } + + Rectangle { // Separator + visible: root.showResults + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + ListView { // App results + id: appResults + visible: root.showResults + Layout.fillWidth: true + implicitHeight: Math.min(600, appResults.contentHeight + topMargin + bottomMargin) + clip: true + topMargin: 10 + bottomMargin: 10 + spacing: 2 + KeyNavigation.up: searchBar + highlightMoveDuration : 100 + + onFocusChanged: { + if(focus) appResults.currentIndex = 1; + } + + Connections { + target: root + function onSearchingTextChanged() { + if (appResults.count > 0) + appResults.currentIndex = 0; + } + } + + model: ScriptModel { + id: model + values: { // Search results are handled here + ////////////////// Skip? ////////////////// + if(root.searchingText == "") return []; + + ///////////// Special cases /////////////// + if (root.searchingText.startsWith(ConfigOptions.search.prefix.clipboard)) { // Clipboard + const searchString = root.searchingText.slice(ConfigOptions.search.prefix.clipboard.length); + return Cliphist.fuzzyQuery(searchString).map(entry => { + return { + cliphistRawString: entry, + name: entry.replace(/^\s*\S+\s+/, ""), + clickActionName: "", + type: `#${entry.match(/^\s*(\S+)/)?.[1] || ""}`, + execute: () => { + Hyprland.dispatch(`exec echo '${StringUtils.shellSingleQuoteEscape(entry)}' | cliphist decode | wl-copy`); + } + }; + }).filter(Boolean); + } + if (root.searchingText.startsWith(ConfigOptions.search.prefix.emojis)) { // Clipboard + const searchString = root.searchingText.slice(ConfigOptions.search.prefix.emojis.length); + return Emojis.fuzzyQuery(searchString).map(entry => { + return { + cliphistRawString: entry, + bigText: entry.match(/^\s*(\S+)/)?.[1] || "", + name: entry.replace(/^\s*\S+\s+/, ""), + clickActionName: "", + type: "Emoji", + execute: () => { + Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(entry.match(/^\s*(\S+)/)?.[1])}'`); + } + }; + }).filter(Boolean); + } + + + ////////////////// Init /////////////////// + nonAppResultsTimer.restart(); + const mathResultObject = { + name: root.mathResult, + clickActionName: qsTr("Copy"), + type: qsTr("Math result"), + fontType: "monospace", + materialSymbol: 'calculate', + execute: () => { + Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(root.mathResult)}'`) + } + } + const commandResultObject = { + name: searchingText.replace("file://", ""), + clickActionName: qsTr("Run"), + type: qsTr("Run command"), + fontType: "monospace", + materialSymbol: 'terminal', + execute: () => { + executor.executeCommand(searchingText.startsWith('sudo') ? `${ConfigOptions.apps.terminal} fish -C '${root.searchingText.replace("file://", "")}'` : root.searchingText.replace("file://", "")); + } + } + const launcherActionObjects = root.searchActions + .map(action => { + const actionString = `${ConfigOptions.search.prefix.action}${action.action}`; + if (actionString.startsWith(root.searchingText) || root.searchingText.startsWith(actionString)) { + return { + name: root.searchingText.startsWith(actionString) ? root.searchingText : actionString, + clickActionName: qsTr("Run"), + type: qsTr("Action"), + materialSymbol: 'settings_suggest', + execute: () => { + action.execute(root.searchingText.split(" ").slice(1).join(" ")) + }, + }; + } + return null; + }) + .filter(Boolean); + + let result = []; + + //////////////// Apps ////////////////// + result = result.concat( + AppSearch.fuzzyQuery(root.searchingText) + .map((entry) => { + entry.clickActionName = qsTr("Launch"); + entry.type = qsTr("App"); + return entry; + }) + ); + + ////////// Launcher actions //////////// + result = result.concat(launcherActionObjects); + + /////////// Math result & command ////////// + const startsWithNumber = /^\d/.test(root.searchingText); + if (startsWithNumber) { + result.push(mathResultObject); + result.push(commandResultObject); + } else { + result.push(commandResultObject); + result.push(mathResultObject); + } + + ///////////////// Web search //////////////// + result.push({ + name: root.searchingText, + clickActionName: qsTr("Search"), + type: qsTr("Search the web"), + materialSymbol: 'travel_explore', + execute: () => { + let url = ConfigOptions.search.engineBaseUrl + root.searchingText + for (let site of ConfigOptions.search.excludedSites) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + } + }); + + return result; + } + } + + delegate: SearchItem { // The selectable item for each search result + required property var modelData + anchors.left: parent?.left + anchors.right: parent?.right + entry: modelData + query: root.searchingText.startsWith(ConfigOptions.search.prefix.clipboard) ? + root.searchingText.slice(ConfigOptions.search.prefix.clipboard.length) : + root.searchingText; + } + } + + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/screenCorners/ScreenCorners.qml b/.config/quickshell/modules/screenCorners/ScreenCorners.qml new file mode 100644 index 000000000..3988d73d8 --- /dev/null +++ b/.config/quickshell/modules/screenCorners/ScreenCorners.qml @@ -0,0 +1,87 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: screenCorners + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + + Variants { + model: Quickshell.screens + + PanelWindow { + visible: (ConfigOptions.appearance.fakeScreenRounding === 1 + || (ConfigOptions.appearance.fakeScreenRounding === 2 + && !activeWindow?.fullscreen)) + + property var modelData + + screen: modelData + exclusionMode: ExclusionMode.Ignore + mask: Region { + item: null + } + HyprlandWindow.visibleMask: Region { + Region { + item: topLeftCorner + } + Region { + item: topRightCorner + } + Region { + item: bottomLeftCorner + } + Region { + item: bottomRightCorner + } + } + WlrLayershell.namespace: "quickshell:screenCorners" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: true + left: true + right: true + bottom: true + } + + RoundCorner { + id: topLeftCorner + anchors.top: parent.top + anchors.left: parent.left + size: Appearance.rounding.screenRounding + corner: cornerEnum.topLeft + } + RoundCorner { + id: topRightCorner + anchors.top: parent.top + anchors.right: parent.right + size: Appearance.rounding.screenRounding + corner: cornerEnum.topRight + } + RoundCorner { + id: bottomLeftCorner + anchors.bottom: parent.bottom + anchors.left: parent.left + size: Appearance.rounding.screenRounding + corner: cornerEnum.bottomLeft + } + RoundCorner { + id: bottomRightCorner + anchors.bottom: parent.bottom + anchors.right: parent.right + size: Appearance.rounding.screenRounding + corner: cornerEnum.bottomRight + } + + } + + } + +} diff --git a/.config/quickshell/modules/session/Session.qml b/.config/quickshell/modules/session/Session.qml new file mode 100644 index 000000000..94e6123ff --- /dev/null +++ b/.config/quickshell/modules/session/Session.qml @@ -0,0 +1,228 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + + Loader { + id: sessionLoader + active: false + + sourceComponent: PanelWindow { // Session menu + id: sessionRoot + visible: sessionLoader.active + property string subtitle + + function hide() { + sessionLoader.active = false + } + + + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "quickshell:session" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: ColorUtils.transparentize(Appearance.m3colors.m3background, 0.3) + + anchors { + top: true + left: true + right: true + } + + implicitWidth: root.focusedScreen?.width ?? 0 + implicitHeight: root.focusedScreen?.height ?? 0 + + MouseArea { + id: sessionMouseArea + anchors.fill: parent + onClicked: { + sessionRoot.hide() + } + } + + ColumnLayout { // Content column + anchors.centerIn: parent + spacing: 15 + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sessionRoot.hide(); + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 0 + StyledText { // Title + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.title + font.weight: Font.DemiBold + text: qsTr("Session") + } + + StyledText { // Small instruction + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.normal + text: qsTr("Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel") + } + } + + GridLayout { + columns: 4 + columnSpacing: 15 + rowSpacing: 15 + + SessionActionButton { + id: sessionLock + focus: sessionRoot.visible + buttonIcon: "lock" + buttonText: qsTr("Lock") + onClicked: { Hyprland.dispatch("exec loginctl lock-session"); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.right: sessionSleep + KeyNavigation.down: sessionHibernate + } + SessionActionButton { + id: sessionSleep + buttonIcon: "dark_mode" + buttonText: qsTr("Sleep") + onClicked: { Hyprland.dispatch("exec systemctl suspend || loginctl suspend"); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionLock + KeyNavigation.right: sessionLogout + KeyNavigation.down: sessionShutdown + } + SessionActionButton { + id: sessionLogout + buttonIcon: "logout" + buttonText: qsTr("Logout") + onClicked: { Hyprland.dispatch("exec pkill Hyprland"); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionSleep + KeyNavigation.right: sessionTaskManager + KeyNavigation.down: sessionReboot + } + SessionActionButton { + id: sessionTaskManager + buttonIcon: "browse_activity" + buttonText: qsTr("Task Manager") + onClicked: { Hyprland.dispatch(`exec ${ConfigOptions.apps.taskManager}`); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionLogout + KeyNavigation.down: sessionFirmwareReboot + } + + SessionActionButton { + id: sessionHibernate + buttonIcon: "downloading" + buttonText: qsTr("Hibernate") + onClicked: { Hyprland.dispatch("exec systemctl hibernate || loginctl hibernate"); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.up: sessionLock + KeyNavigation.right: sessionShutdown + } + SessionActionButton { + id: sessionShutdown + buttonIcon: "power_settings_new" + buttonText: qsTr("Shutdown") + onClicked: { Hyprland.dispatch("exec systemctl poweroff || loginctl poweroff"); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionHibernate + KeyNavigation.right: sessionReboot + KeyNavigation.up: sessionSleep + } + SessionActionButton { + id: sessionReboot + buttonIcon: "restart_alt" + buttonText: qsTr("Reboot") + onClicked: { Hyprland.dispatch("exec reboot || loginctl reboot"); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionShutdown + KeyNavigation.right: sessionFirmwareReboot + KeyNavigation.up: sessionLogout + } + SessionActionButton { + id: sessionFirmwareReboot + buttonIcon: "settings_applications" + buttonText: qsTr("Reboot to firmware settings") + onClicked: { Hyprland.dispatch("exec systemctl reboot --firmware-setup || loginctl reboot --firmware-setup"); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.up: sessionTaskManager + KeyNavigation.left: sessionReboot + } + } + + Rectangle { + Layout.alignment: Qt.AlignHCenter + radius: Appearance.rounding.normal + implicitHeight: sessionSubtitle.implicitHeight + 10 * 2 + implicitWidth: sessionSubtitle.implicitWidth + 15 * 2 + color: Appearance.colors.colTooltip + clip: true + + Behavior on implicitWidth { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + StyledText { + id: sessionSubtitle + anchors.centerIn: parent + color: Appearance.colors.colOnTooltip + text: sessionRoot.subtitle + } + } + } + + } + } + + IpcHandler { + target: "session" + + function toggle(): void { + sessionLoader.active = !sessionLoader.active; + } + + function close(): void { + sessionLoader.active = false; + } + + function open(): void { + sessionLoader.active = true; + } + } + + GlobalShortcut { + name: "sessionToggle" + description: qsTr("Toggles session screen on press") + + onPressed: { + sessionLoader.active = !sessionLoader.active; + } + } + + GlobalShortcut { + name: "sessionOpen" + description: qsTr("Opens session screen on press") + + onPressed: { + sessionLoader.active = true; + } + } + +} diff --git a/.config/quickshell/modules/session/SessionActionButton.qml b/.config/quickshell/modules/session/SessionActionButton.qml new file mode 100644 index 000000000..becda60c1 --- /dev/null +++ b/.config/quickshell/modules/session/SessionActionButton.qml @@ -0,0 +1,62 @@ +import "root:/modules/common" +import "root:/modules/common/widgets/" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +RippleButton { + id: button + + property string buttonIcon + property string buttonText + property bool keyboardDown: false + property real size: 120 + + buttonRadius: (button.focus || button.down) ? size / 2 : Appearance.rounding.verylarge + colBackground: button.keyboardDown ? Appearance.colors.colSecondaryContainerActive : + button.focus ? Appearance.colors.colPrimary : + Appearance.colors.colSecondaryContainer + colBackgroundHover: Appearance.colors.colPrimary + colRipple: Appearance.colors.colPrimaryActive + property color colText: (button.down || button.keyboardDown || button.focus || button.hovered) ? + Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0 + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + background.implicitHeight: size + background.implicitWidth: size + + Behavior on buttonRadius { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + keyboardDown = true + button.clicked() + event.accepted = true; + } + } + Keys.onReleased: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + keyboardDown = false + event.accepted = true; + } + } + + contentItem: MaterialSymbol { + id: icon + anchors.fill: parent + color: button.colText + horizontalAlignment: Text.AlignHCenter + iconSize: 45 + text: buttonIcon + } + + StyledToolTip { + content: buttonText + } + +} diff --git a/.config/quickshell/modules/sidebarLeft/AiChat.qml b/.config/quickshell/modules/sidebarLeft/AiChat.qml new file mode 100644 index 000000000..1ce752c7b --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/AiChat.qml @@ -0,0 +1,555 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "./aiChat/" +import "root:/modules/common/functions/fuzzysort.js" as Fuzzy +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/file_utils.js" as FileUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Hyprland + +Item { + id: root + property var inputField: messageInputField + property string commandPrefix: "/" + + property var suggestionQuery: "" + property var suggestionList: [] + + onFocusChanged: (focus) => { + if (focus) { + root.inputField.forceActiveFocus() + } + } + + Keys.onPressed: (event) => { + messageInputField.forceActiveFocus() + if (event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageUp) { + messageListView.contentY = Math.max(0, messageListView.contentY - messageListView.height / 2) + event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + messageListView.contentY = Math.min(messageListView.contentHeight - messageListView.height / 2, messageListView.contentY + messageListView.height / 2) + event.accepted = true + } + } + } + + property var allCommands: [ + { + name: "model", + description: qsTr("Choose model"), + execute: (args) => { + Ai.setModel(args[0]); + } + }, + { + name: "clear", + description: qsTr("Clear chat history"), + execute: () => { + Ai.clearMessages(); + } + }, + { + name: "key", + description: qsTr("Set API key"), + execute: (args) => { + if (args[0] == "get") { + Ai.printApiKey() + } else { + Ai.setApiKey(args[0]); + } + } + }, + { + name: "temp", + description: qsTr("Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5."), + execute: (args) => { + // console.log(args) + if (args.length == 0 || args[0] == "get") { + Ai.printTemperature() + } else { + const temp = parseFloat(args[0]); + Ai.setTemperature(temp); + } + } + }, + { + name: "test", + description: qsTr("Markdown test"), + execute: () => { + Ai.addMessage(` + +A longer think block to test revealing animation +OwO wem ipsum dowo sit amet, consekituwet awipiscing ewit, sed do eiuwsmod tempow inwididunt ut wabowe et dowo mawa. Ut enim ad minim weniam, quis nostwud exeucitation uwuwamcow bowowis nisi ut awiquip ex ea commowo consequat. Duuis aute iwuwe dowo in wepwependewit in wowuptate velit esse ciwwum dowo eu fugiat nuwa pawiatuw. Excepteuw sint occaecat cupidatat non pwowoident, sunt in cuwpa qui officia desewunt mowit anim id est wabowum. Meouw! >w< +Mowe uwu wem ipsum! + +## ✏️ Markdown test +### Formatting + +- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com) +- Arch lincox icon + +### Table + +Quickshell vs AGS/Astal + +| | Quickshell | AGS/Astal | +|--------------------------|------------------|-------------------| +| UI Toolkit | Qt | Gtk3/Gtk4 | +| Language | QML | Js/Ts/Lua | +| Reactivity | Implied | Needs declaration | +| Widget placement | Mildly difficult | More intuitive | +| Bluetooth & Wifi support | ❌ | ✅ | +| No-delay keybinds | ✅ | ❌ | +| Development | New APIs | New syntax | + +### Code block + +Just a hello world... + +\`\`\`cpp +#include +// This is intentionally very long to test scrolling +const std::string GREETING = \"UwU\"; +int main(int argc, char* argv[]) { + std::cout << GREETING; +} +\`\`\` + +### LaTeX + + +Inline w/ dollar signs: $\\frac{1}{2} = \\frac{2}{4}$ + +Inline w/ double dollar signs: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$ + +Inline w/ backslash and square brackets \\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\] + +Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\) +`, + Ai.interfaceRole); + } + }, + ] + + function handleInput(inputText) { + if (inputText.startsWith(root.commandPrefix)) { + // Handle special commands + const command = inputText.split(" ")[0].substring(1); + const args = inputText.split(" ").slice(1); + const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`); + if (commandObj) { + commandObj.execute(args); + } else { + Ai.addMessage(qsTr("Unknown command: ") + command, Ai.interfaceRole); + } + } + else { + Ai.sendUserMessage(inputText); + } + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + + Item { // Messages + Layout.fillWidth: true + Layout.fillHeight: true + StyledListView { // Message list + id: messageListView + anchors.fill: parent + spacing: 10 + popin: false + + property int lastResponseLength: 0 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + add: null // Prevent function calls from being janky + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + model: ScriptModel { + values: Ai.messageIDs.filter(id => { + const message = Ai.messageByID[id]; + return message?.visibleToUser ?? true; + }) + } + delegate: AiMessage { + required property var modelData + required property int index + messageIndex: index + messageData: { + Ai.messageByID[modelData] + } + messageInputField: root.inputField + } + } + + Item { // Placeholder when list is empty + opacity: Ai.messageIDs.length === 0 ? 1 : 0 + visible: opacity > 0 + anchors.fill: parent + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 60 + color: Appearance.m3colors.m3outline + text: "neurology" + } + StyledText { + id: widgetNameText + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + font.family: Appearance.font.family.title + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: qsTr("Large language models") + } + StyledText { + id: widgetDescriptionText + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignLeft + wrapMode: Text.Wrap + text: qsTr("Ctrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window") + } + } + } + } + + Item { // Suggestion description + visible: descriptionText.text.length > 0 + Layout.fillWidth: true + implicitHeight: descriptionBackground.implicitHeight + + Rectangle { + id: descriptionBackground + color: Appearance.colors.colTooltip + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: descriptionText.implicitHeight + 5 * 2 + radius: Appearance.rounding.verysmall + + StyledText { + id: descriptionText + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnTooltip + wrapMode: Text.Wrap + text: root.suggestionList[suggestions.selectedIndex]?.description ?? "" + } + } + } + + FlowButtonGroup { // Suggestions + id: suggestions + visible: root.suggestionList.length > 0 && messageInputField.text.length > 0 + property int selectedIndex: 0 + Layout.fillWidth: true + spacing: 5 + + Repeater { + id: suggestionRepeater + model: { + suggestions.selectedIndex = 0 + return root.suggestionList.slice(0, 10) + } + delegate: ApiCommandButton { + id: commandButton + colBackground: suggestions.selectedIndex === index ? Appearance.colors.colLayer2Hover : Appearance.colors.colLayer2 + bounce: false + contentItem: StyledText { + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignHCenter + text: modelData.displayName ?? modelData.name + } + + onHoveredChanged: { + if (commandButton.hovered) { + suggestions.selectedIndex = index; + } + } + onClicked: { + suggestions.acceptSuggestion(modelData.name) + } + } + } + + function acceptSuggestion(word) { + const words = messageInputField.text.trim().split(/\s+/); + if (words.length > 0) { + words[words.length - 1] = word; + } else { + words.push(word); + } + const updatedText = words.join(" ") + " "; + messageInputField.text = updatedText; + messageInputField.cursorPosition = messageInputField.text.length; + messageInputField.forceActiveFocus(); + } + + function acceptSelectedWord() { + if (suggestions.selectedIndex >= 0 && suggestions.selectedIndex < suggestionRepeater.count) { + const word = root.suggestionList[suggestions.selectedIndex].name; + suggestions.acceptSuggestion(word); + } + } + } + + Rectangle { // Input area + id: inputWrapper + property real columnSpacing: 5 + Layout.fillWidth: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitWidth: messageInputField.implicitWidth + implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin + + commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + columnSpacing, 45) + clip: true + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + RowLayout { // Input field and send button + id: inputFieldRowLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + spacing: 0 + + StyledTextArea { // The actual TextArea + id: messageInputField + wrapMode: TextArea.Wrap + Layout.fillWidth: true + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + placeholderText: StringUtils.format(qsTr('Message the model... "{0}" for commands'), root.commandPrefix) + + background: null + + onTextChanged: { // Handle suggestions + if(messageInputField.text.length === 0) { + root.suggestionQuery = "" + root.suggestionList = [] + return + } else if(messageInputField.text.startsWith(`${root.commandPrefix}model`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const modelResults = Fuzzy.go(root.suggestionQuery, Ai.modelList.map(model => { + return { + name: Fuzzy.prepare(model), + obj: model, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = modelResults.map(model => { + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "model ") : ""}${model.target}`, + displayName: `${Ai.models[model.target].name}`, + description: `${Ai.models[model.target].description}`, + } + }) + } else if(messageInputField.text.startsWith(root.commandPrefix)) { + root.suggestionQuery = messageInputField.text + root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(messageInputField.text.substring(1))).map(cmd => { + return { + name: `${root.commandPrefix}${cmd.name}`, + description: `${cmd.description}`, + } + }) + } + } + + function accept() { + root.handleInput(text) + text = "" + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + suggestions.acceptSelectedWord(); + event.accepted = true; + } else if (event.key === Qt.Key_Up && suggestions.visible) { + suggestions.selectedIndex = Math.max(0, suggestions.selectedIndex - 1); + event.accepted = true; + } else if (event.key === Qt.Key_Down && suggestions.visible) { + suggestions.selectedIndex = Math.min(root.suggestionList.length - 1, suggestions.selectedIndex + 1); + event.accepted = true; + } else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { + if (event.modifiers & Qt.ShiftModifier) { + // Insert newline + messageInputField.insert(messageInputField.cursorPosition, "\n") + event.accepted = true + } else { // Accept text + const inputText = messageInputField.text + messageInputField.clear() + root.handleInput(inputText) + event.accepted = true + } + } + } + } + + RippleButton { // Send button + id: sendButton + Layout.alignment: Qt.AlignTop + Layout.rightMargin: 5 + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + enabled: messageInputField.text.length > 0 + toggled: enabled + + MouseArea { + anchors.fill: parent + cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + const inputText = messageInputField.text + root.handleInput(inputText) + messageInputField.clear() + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + // fill: sendButton.enabled ? 1 : 0 + color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled + text: "send" + } + } + } + + RowLayout { // Controls + id: commandButtonsRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.leftMargin: 5 + anchors.rightMargin: 5 + spacing: 5 + + property var commandsShown: [ + { + name: "model", + sendDirectly: false, + }, + { + name: "clear", + sendDirectly: true, + }, + ] + + Item { + implicitHeight: providerRowLayout.implicitHeight + 5 * 2 + implicitWidth: providerRowLayout.implicitWidth + 10 * 2 + + RowLayout { + id: providerRowLayout + anchors.centerIn: parent + + MaterialSymbol { + text: "api" + iconSize: Appearance.font.pixelSize.large + } + StyledText { + id: providerName + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3onSurface + elide: Text.ElideRight + text: Ai.getModel().name + } + } + StyledToolTip { + id: toolTip + extraVisibleCondition: false + alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered + content: StringUtils.format(qsTr("Current model: {0}\nSet it with {1}model MODEL"), + Ai.getModel().name, root.commandPrefix) + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + } + } + + Item { Layout.fillWidth: true } + + ButtonGroup { + padding: 0 + + Repeater { // Command buttons + model: commandButtonsRow.commandsShown + delegate: ApiCommandButton { + property string commandRepresentation: `${root.commandPrefix}${modelData.name}` + buttonText: commandRepresentation + onClicked: { + if(modelData.sendDirectly) { + root.handleInput(commandRepresentation) + } else { + messageInputField.text = commandRepresentation + " " + messageInputField.cursorPosition = messageInputField.text.length + messageInputField.forceActiveFocus() + } + if (modelData.name === "clear") { + messageInputField.text = "" + } + } + } + } + } + } + + } + + } + +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/Anime.qml b/.config/quickshell/modules/sidebarLeft/Anime.qml new file mode 100644 index 000000000..1300d5482 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/Anime.qml @@ -0,0 +1,637 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/fuzzysort.js" as Fuzzy +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/file_utils.js" as FileUtils +import "./anime/" +import Qt.labs.platform +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Hyprland + +Item { + id: root + property var inputField: tagInputField + readonly property var responses: Booru.responses + property string previewDownloadPath: Directories.booruPreviews + property string downloadPath: Directories.booruDownloads + property string nsfwPath: Directories.booruDownloadsNsfw + property string commandPrefix: "/" + property real scrollOnNewResponse: 100 + property int tagSuggestionDelay: 210 + property var suggestionQuery: "" + property var suggestionList: [] + + Connections { + target: Booru + function onTagSuggestion(query, suggestions) { + root.suggestionQuery = query; + root.suggestionList = suggestions; + } + } + + property var allCommands: [ + { + name: "mode", + description: qsTr("Set the current API provider"), + execute: (args) => { + Booru.setProvider(args[0]); + } + }, + { + name: "clear", + description: qsTr("Clear the current list of images"), + execute: () => { + Booru.clearResponses(); + } + }, + { + name: "next", + description: qsTr("Get the next page of results"), + execute: () => { + if (root.responses.length > 0) { + const lastResponse = root.responses[root.responses.length - 1]; + root.handleInput(`${lastResponse.tags.join(" ")} ${parseInt(lastResponse.page) + 1}`); + } + } + }, + { + name: "safe", + description: qsTr("Disable NSFW content"), + execute: () => { + PersistentStateManager.setState("booru.allowNsfw", false); + } + }, + { + name: "lewd", + description: qsTr("Allow NSFW content"), + execute: () => { + PersistentStateManager.setState("booru.allowNsfw", true); + } + }, + ] + + function handleInput(inputText) { + if (inputText.startsWith(root.commandPrefix)) { + // Handle special commands + const command = inputText.split(" ")[0].substring(1); + const args = inputText.split(" ").slice(1); + const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`); + if (commandObj) { + commandObj.execute(args); + } else { + Booru.addSystemMessage(qsTr("Unknown command: ") + command); + } + } + else if (inputText.trim() == "+") { + if (root.responses.length > 0) { + const lastResponse = root.responses[root.responses.length - 1] + root.handleInput(lastResponse.tags.join(" ") + ` ${parseInt(lastResponse.page) + 1}`); + } + } + else { + // Create tag list + const tagList = inputText.split(/\s+/).filter(tag => tag.length > 0); + let pageIndex = 1; + for (let i = 0; i < tagList.length; ++i) { // Detect page number + if (/^\d+$/.test(tagList[i])) { + pageIndex = parseInt(tagList[i], 10); + tagList.splice(i, 1); + break; + } + } + Booru.makeRequest(tagList, PersistentStates.booru.allowNsfw, ConfigOptions.sidebar.booru.limit, pageIndex); + } + } + + onFocusChanged: (focus) => { + if (focus) { + tagInputField.forceActiveFocus() + } + } + + Keys.onPressed: (event) => { + tagInputField.forceActiveFocus() + if (event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageUp) { + booruResponseListView.contentY = Math.max(0, booruResponseListView.contentY - booruResponseListView.height / 2) + event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + booruResponseListView.contentY = Math.min(booruResponseListView.contentHeight - booruResponseListView.height / 2, booruResponseListView.contentY + booruResponseListView.height / 2) + event.accepted = true + } + } + } + + + ColumnLayout { + id: columnLayout + anchors.fill: parent + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + StyledListView { // Booru responses + id: booruResponseListView + anchors.fill: parent + spacing: 10 + + property int lastResponseLength: 0 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + model: ScriptModel { + values: { + if(root.responses.length > booruResponseListView.lastResponseLength) { + if (booruResponseListView.lastResponseLength > 0 && root.responses[booruResponseListView.lastResponseLength].provider != "system") + booruResponseListView.contentY = booruResponseListView.contentY + root.scrollOnNewResponse + booruResponseListView.lastResponseLength = root.responses.length + } + return root.responses + } + } + delegate: BooruResponse { + responseData: modelData + tagInputField: root.inputField + previewDownloadPath: root.previewDownloadPath + downloadPath: root.downloadPath + nsfwPath: root.nsfwPath + } + } + + Item { // Placeholder when list is empty + opacity: root.responses.length === 0 ? 1 : 0 + visible: opacity > 0 + anchors.fill: parent + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 60 + color: Appearance.m3colors.m3outline + text: "bookmark_heart" + } + StyledText { + id: widgetNameText + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + font.family: Appearance.font.family.title + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: qsTr("Anime boorus") + } + } + } + + Item { // Queries awaiting response + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 10 + implicitHeight: pendingBackground.implicitHeight + opacity: Booru.runningRequests > 0 ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + Rectangle { + id: pendingBackground + color: Appearance.m3colors.m3inverseSurface + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: pendingText.implicitHeight + 12 * 2 + radius: Appearance.rounding.verysmall + + StyledText { + id: pendingText + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.m3colors.m3inverseOnSurface + wrapMode: Text.Wrap + text: StringUtils.format(qsTr("{0} queries pending"), Booru.runningRequests) + } + } + } + } + + Item { // Tag suggestion description + visible: tagDescriptionText.text.length > 0 + Layout.fillWidth: true + implicitHeight: tagDescriptionBackground.implicitHeight + + Rectangle { + id: tagDescriptionBackground + color: Appearance.colors.colTooltip + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: tagDescriptionText.implicitHeight + 5 * 2 + radius: Appearance.rounding.verysmall + + StyledText { + id: tagDescriptionText + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnTooltip + wrapMode: Text.Wrap + text: root.suggestionList[tagSuggestions.selectedIndex]?.description ?? "" + } + } + } + + FlowButtonGroup { // Tag suggestions + id: tagSuggestions + visible: root.suggestionList.length > 0 && tagInputField.text.length > 0 + property int selectedIndex: 0 + Layout.fillWidth: true + spacing: 5 + + Repeater { + id: tagSuggestionRepeater + model: { + tagSuggestions.selectedIndex = 0 + return root.suggestionList.slice(0, 10) + } + delegate: ApiCommandButton { + id: tagButton + colBackground: tagSuggestions.selectedIndex === index ? Appearance.colors.colLayer2Hover : Appearance.colors.colLayer2 + bounce: false + contentItem: RowLayout { + anchors.centerIn: parent + spacing: 5 + StyledText { + Layout.fillWidth: false + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignRight + text: modelData.displayName ?? modelData.name + } + StyledText { + Layout.fillWidth: false + visible: modelData.count !== undefined + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignLeft + text: modelData.count ?? "" + } + } + + onHoveredChanged: { + if (tagButton.hovered) { + tagSuggestions.selectedIndex = index; + } + } + onClicked: { + tagSuggestions.acceptTag(modelData.name) + } + } + } + + function acceptTag(tag) { + const words = tagInputField.text.trim().split(/\s+/); + if (words.length > 0) { + words[words.length - 1] = tag; + } else { + words.push(tag); + } + const updatedText = words.join(" ") + " "; + tagInputField.text = updatedText; + tagInputField.cursorPosition = tagInputField.text.length; + tagInputField.forceActiveFocus(); + } + + function acceptSelectedTag() { + if (tagSuggestions.selectedIndex >= 0 && tagSuggestions.selectedIndex < tagSuggestionRepeater.count) { + const tag = root.suggestionList[tagSuggestions.selectedIndex].name; + tagSuggestions.acceptTag(tag); + } + } + } + + Rectangle { // Tag input area + id: tagInputContainer + property real columnSpacing: 5 + Layout.fillWidth: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitWidth: tagInputField.implicitWidth + implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin + + commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + columnSpacing, 45) + clip: true + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + RowLayout { // Input field and send button + id: inputFieldRowLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + spacing: 0 + + StyledTextArea { // The actual TextArea + id: tagInputField + wrapMode: TextArea.Wrap + Layout.fillWidth: true + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + renderType: Text.NativeRendering + placeholderText: StringUtils.format(qsTr('Enter tags, or "{0}" for commands'), root.commandPrefix) + + background: null + + property Timer searchTimer: Timer { // Timer for tag suggestions + interval: root.tagSuggestionDelay + repeat: false + onTriggered: { + const inputText = tagInputField.text + const words = inputText.trim().split(/\s+/); + if (words.length > 0) { + Booru.triggerTagSearch(words[words.length - 1]); + } + } + } + + onTextChanged: { // Handle tag suggestions + if(tagInputField.text.length === 0) { + root.suggestionQuery = "" + root.suggestionList = [] + searchTimer.stop(); + return + } + if(tagInputField.text.startsWith(`${root.commandPrefix}mode`)) { + root.suggestionQuery = tagInputField.text.split(" ")[1] ?? "" + const providerResults = Fuzzy.go(root.suggestionQuery, Booru.providerList.map(provider => { + return { + name: Fuzzy.prepare(provider), + obj: provider, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = providerResults.map(provider => { + return { + name: `${tagInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "mode ") : ""}${provider.target}`, + displayName: `${Booru.providers[provider.target].name}`, + description: `${Booru.providers[provider.target].description}`, + } + }) + searchTimer.stop(); + return + } + if(tagInputField.text.startsWith(root.commandPrefix)) { + root.suggestionQuery = tagInputField.text + root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(tagInputField.text.substring(1))).map(cmd => { + return { + name: `${root.commandPrefix}${cmd.name}`, + description: `${cmd.description}`, + } + }) + searchTimer.stop(); + return + } + searchTimer.restart(); + } + + function accept() { + root.handleInput(text) + text = "" + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + tagSuggestions.acceptSelectedTag(); + event.accepted = true; + } else if (event.key === Qt.Key_Up) { + tagSuggestions.selectedIndex = Math.max(0, tagSuggestions.selectedIndex - 1); + event.accepted = true; + } else if (event.key === Qt.Key_Down) { + tagSuggestions.selectedIndex = Math.min(root.suggestionList.length - 1, tagSuggestions.selectedIndex + 1); + event.accepted = true; + } else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { + if (event.modifiers & Qt.ShiftModifier) { + // Insert newline + tagInputField.insert(tagInputField.cursorPosition, "\n") + event.accepted = true + } else { // Accept text + const inputText = tagInputField.text + root.handleInput(inputText) + tagInputField.clear() + event.accepted = true + } + } + } + } + + RippleButton { // Send button + id: sendButton + Layout.alignment: Qt.AlignTop + Layout.rightMargin: 5 + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + enabled: tagInputField.text.length > 0 + toggled: enabled + + MouseArea { + anchors.fill: parent + cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + const inputText = tagInputField.text + root.handleInput(inputText) + tagInputField.clear() + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + // fill: sendButton.enabled ? 1 : 0 + color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled + text: "send" + } + } + } + + RowLayout { // Controls + id: commandButtonsRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.leftMargin: 5 + anchors.rightMargin: 5 + spacing: 5 + + property var commandsShown: [ + { + name: "mode", + sendDirectly: false, + }, + { + name: "clear", + sendDirectly: true, + }, + ] + + Item { + implicitHeight: providerRowLayout.implicitHeight + 5 * 2 + implicitWidth: providerRowLayout.implicitWidth + 10 * 2 + + RowLayout { + id: providerRowLayout + anchors.centerIn: parent + + MaterialSymbol { + text: "api" + iconSize: Appearance.font.pixelSize.large + } + StyledText { + id: providerName + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3onSurface + text: Booru.providers[Booru.currentProvider].name + } + } + StyledToolTip { + id: toolTip + extraVisibleCondition: false + alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered + // content: qsTr("The current API used. Endpoint: ") + Booru.providers[Booru.currentProvider].url + qsTr("\nSet with /mode PROVIDER") + content: StringUtils.format(qsTr("Current API endpoint: {0}\nSet it with {1}mode PROVIDER"), + Booru.providers[Booru.currentProvider].url, root.commandPrefix) + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + } + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + text: "•" + } + + Item { // NSFW toggle + visible: width > 0 + implicitWidth: switchesRow.implicitWidth + Layout.fillHeight: true + + RowLayout { + id: switchesRow + spacing: 5 + anchors.centerIn: parent + + MouseArea { + hoverEnabled: true + PointingHandInteraction {} + onClicked: { + nsfwSwitch.checked = !nsfwSwitch.checked + } + } + + StyledText { + Layout.fillHeight: true + Layout.leftMargin: 10 + Layout.alignment: Qt.AlignVCenter + font.pixelSize: Appearance.font.pixelSize.smaller + color: nsfwSwitch.enabled ? Appearance.colors.colOnLayer1 : Appearance.m3colors.m3outline + text: qsTr("Allow NSFW") + } + StyledSwitch { + id: nsfwSwitch + enabled: Booru.currentProvider !== "zerochan" + scale: 0.6 + Layout.alignment: Qt.AlignVCenter + checked: (PersistentStates.booru.allowNsfw && Booru.currentProvider !== "zerochan") + onCheckedChanged: { + if (!nsfwSwitch.enabled) return; + PersistentStateManager.setState("booru.allowNsfw", checked) + } + } + } + } + + Item { Layout.fillWidth: true } + + ButtonGroup { + padding: 0 + Repeater { // Command buttons + id: commandRepeater + model: commandButtonsRow.commandsShown + delegate: ApiCommandButton { + id: tagButton + property string commandRepresentation: `${root.commandPrefix}${modelData.name}` + buttonText: commandRepresentation + colBackground: Appearance.colors.colLayer2 + + onClicked: { + if(modelData.sendDirectly) { + root.handleInput(commandRepresentation) + } else { + tagInputField.text = commandRepresentation + " " + tagInputField.cursorPosition = tagInputField.text.length + tagInputField.forceActiveFocus() + } + if (modelData.name === "clear") { + tagInputField.text = "" + } + } + } + } + } + } + + } + } +} diff --git a/.config/quickshell/modules/sidebarLeft/ApiCommandButton.qml b/.config/quickshell/modules/sidebarLeft/ApiCommandButton.qml new file mode 100644 index 000000000..b9fab2961 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/ApiCommandButton.qml @@ -0,0 +1,29 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +GroupButton { + id: button + property string buttonText + + horizontalPadding: 8 + verticalPadding: 6 + + baseWidth: contentItem.implicitWidth + horizontalPadding * 2 + clickedWidth: baseWidth + 20 + baseHeight: contentItem.implicitHeight + verticalPadding * 2 + + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colBackgroundActive: Appearance.colors.colLayer2Active + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + text: buttonText + color: Appearance.m3colors.m3onSurface + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml b/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml new file mode 100644 index 000000000..ce0b22802 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml @@ -0,0 +1,205 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Effects +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property int sidebarPadding: 15 + property bool detach: false + property Component contentComponent: SidebarLeftContent {} + property Item sidebarContent + + Component.onCompleted: { + root.sidebarContent = contentComponent.createObject(null, { + "scopeRoot": root, + }); + sidebarLoader.item.contentParent.children = [root.sidebarContent]; + } + + onDetachChanged: { + if (root.detach) { + sidebarContent.parent = null; // Detach content from sidebar + sidebarLoader.active = false; // Unload sidebar + detachedSidebarLoader.active = true; // Load detached window + detachedSidebarLoader.item.contentParent.children = [sidebarContent]; + } else { + sidebarContent.parent = null; // Detach content from window + detachedSidebarLoader.active = false; // Unload detached window + sidebarLoader.active = true; // Load sidebar + sidebarLoader.item.contentParent.children = [sidebarContent]; + } + } + + Loader { + id: sidebarLoader + active: true + + sourceComponent: PanelWindow { // Window + id: sidebarRoot + visible: GlobalStates.sidebarLeftOpen + + property bool extend: false + property real sidebarWidth: sidebarRoot.extend ? Appearance.sizes.sidebarWidthExtended : Appearance.sizes.sidebarWidth + property var contentParent: sidebarLeftBackground + + function hide() { + GlobalStates.sidebarLeftOpen = false + } + + exclusiveZone: 0 + implicitWidth: Appearance.sizes.sidebarWidthExtended + Appearance.sizes.elevationMargin + WlrLayershell.namespace: "quickshell:sidebarLeft" + // Hyprland 0.49: OnDemand is Exclusive, Exclusive just breaks click-outside-to-close + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + color: "transparent" + + anchors { + top: true + left: true + bottom: true + } + + mask: Region { + item: sidebarLeftBackground + } + + HyprlandFocusGrab { // Click outside to close + id: grab + windows: [ sidebarRoot ] + active: sidebarRoot.visible + onActiveChanged: { // Focus the selected tab + if (active) sidebarLeftBackground.children[0].focusActiveItem() + } + onCleared: () => { + if (!active) sidebarRoot.hide() + } + } + + // Content + StyledRectangularShadow { + target: sidebarLeftBackground + radius: sidebarLeftBackground.radius + } + Rectangle { + id: sidebarLeftBackground + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: Appearance.sizes.hyprlandGapsOut + anchors.leftMargin: Appearance.sizes.hyprlandGapsOut + width: sidebarRoot.sidebarWidth - Appearance.sizes.hyprlandGapsOut - Appearance.sizes.elevationMargin + height: parent.height - Appearance.sizes.hyprlandGapsOut * 2 + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + Behavior on width { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sidebarRoot.hide(); + } + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_O) { + sidebarRoot.extend = !sidebarRoot.extend; + } + else if (event.key === Qt.Key_P) { + root.detach = !root.detach; + } + event.accepted = true; + } + } + } + } + } + + Loader { + id: detachedSidebarLoader + active: false + + sourceComponent: FloatingWindow { + id: detachedSidebarRoot + visible: GlobalStates.sidebarLeftOpen + property var contentParent: detachedSidebarBackground + + Rectangle { + id: detachedSidebarBackground + anchors.fill: parent + color: Appearance.colors.colLayer0 + + Keys.onPressed: (event) => { + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_P) { + root.detach = !root.detach; + } + event.accepted = true; + } + } + } + } + } + + IpcHandler { + target: "sidebarLeft" + + function toggle(): void { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen + } + + function close(): void { + GlobalStates.sidebarLeftOpen = false + } + + function open(): void { + GlobalStates.sidebarLeftOpen = true + } + } + + GlobalShortcut { + name: "sidebarLeftToggle" + description: qsTr("Toggles left sidebar on press") + + onPressed: { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + } + + GlobalShortcut { + name: "sidebarLeftOpen" + description: qsTr("Opens left sidebar on press") + + onPressed: { + GlobalStates.sidebarLeftOpen = true; + } + } + + GlobalShortcut { + name: "sidebarLeftClose" + description: qsTr("Closes left sidebar on press") + + onPressed: { + GlobalStates.sidebarLeftOpen = false; + } + } + + GlobalShortcut { + name: "sidebarLeftToggleDetach" + description: qsTr("Detach left sidebar into a window/Attach it back") + + onPressed: { + root.detach = !root.detach; + } + } + +} diff --git a/.config/quickshell/modules/sidebarLeft/SidebarLeftContent.qml b/.config/quickshell/modules/sidebarLeft/SidebarLeftContent.qml new file mode 100644 index 000000000..b0358e7b4 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/SidebarLeftContent.qml @@ -0,0 +1,96 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Effects +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { + id: root + required property var scopeRoot + anchors.fill: parent + property var tabButtonList: [ + {"icon": "neurology", "name": qsTr("Intelligence")}, + {"icon": "translate", "name": qsTr("Translator")}, + {"icon": "bookmark_heart", "name": qsTr("Anime")}, + ] + property int selectedTab: 0 + + function focusActiveItem() { + swipeView.currentItem.forceActiveFocus() + } + + Keys.onPressed: (event) => { + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1) + event.accepted = true; + } + else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0) + event.accepted = true; + } + else if (event.key === Qt.Key_Tab) { + root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length; + event.accepted = true; + } + else if (event.key === Qt.Key_Backtab) { + root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length; + event.accepted = true; + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: sidebarPadding + + spacing: sidebarPadding + + PrimaryTabBar { // Tab strip + id: tabBar + tabButtonList: root.tabButtonList + externalTrackedTab: root.selectedTab + function onCurrentIndexChanged(currentIndex) { + root.selectedTab = currentIndex + } + } + + SwipeView { // Content pages + id: swipeView + Layout.topMargin: 5 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + currentIndex: tabBar.externalTrackedTab + onCurrentIndexChanged: { + tabBar.enableIndicatorAnimation = true + root.selectedTab = currentIndex + } + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + AiChat {} + Translator {} + Anime {} + } + + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/Translator.qml b/.config/quickshell/modules/sidebarLeft/Translator.qml new file mode 100644 index 000000000..37e5a37b1 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/Translator.qml @@ -0,0 +1,248 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "./translator/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +/** + * Translator widget with the `trans` commandline tool. + */ +Item { + id: root + // Widgets + property var inputField: inputCanvas.inputTextArea + // Widget variables + property bool translationFor: false // Indicates if the translation is for an autocorrected text + property string translatedText: "" + property list languages: [] + // Options + property string targetLanguage: ConfigOptions.language.translator.targetLanguage + property string sourceLanguage: ConfigOptions.language.translator.sourceLanguage + property string hostLanguage: targetLanguage + + property bool showLanguageSelector: false + property bool languageSelectorTarget: false // true for target language, false for source language + + function showLanguageSelectorDialog(isTargetLang: bool) { + root.languageSelectorTarget = isTargetLang; + root.showLanguageSelector = true + } + + onFocusChanged: (focus) => { + if (focus) { + root.inputField.forceActiveFocus() + } + } + + Timer { + id: translateTimer + interval: ConfigOptions.sidebar.translator.delay + repeat: false + onTriggered: () => { + if (root.inputField.text.trim().length > 0) { + // console.log("Translating with command:", translateProc.command); + translateProc.running = false; + translateProc.buffer = ""; // Clear the buffer + translateProc.running = true; // Restart the process + } else { + root.translatedText = ""; + } + } + } + + Process { + id: translateProc + command: ["bash", "-c", `trans -no-theme -no-bidi` + + ` -source '${StringUtils.shellSingleQuoteEscape(root.sourceLanguage)}'` + + ` -target '${StringUtils.shellSingleQuoteEscape(root.targetLanguage)}'` + + ` -no-ansi '${StringUtils.shellSingleQuoteEscape(root.inputField.text.trim())}'`] + property string buffer: "" + stdout: SplitParser { + onRead: data => { + translateProc.buffer += data + "\n"; + } + } + onExited: (exitCode, exitStatus) => { + // 1. Split into sections by double newlines + const sections = translateProc.buffer.trim().split(/\n\s*\n/); + // console.log("BUFFER:", translateProc.buffer); + // console.log("SECTIONS:", sections); + + // 2. Extract relevant data + root.translatedText = sections.length > 1 ? sections[1].trim() : ""; + } + } + + Process { + id: getLanguagesProc + command: ["trans", "-list-languages", "-no-bidi"] + property list bufferList: ["auto"] + running: true + stdout: SplitParser { + onRead: data => { + getLanguagesProc.bufferList.push(data.trim()); + } + } + onExited: (exitCode, exitStatus) => { + // Ensure "auto" is always the first language + let langs = getLanguagesProc.bufferList + .filter(lang => lang.trim().length > 0 && lang !== "auto") + .sort((a, b) => a.localeCompare(b)); + langs.unshift("auto"); + root.languages = langs; + getLanguagesProc.bufferList = []; // Clear the buffer + } + } + + ColumnLayout { + anchors.fill: parent + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: contentColumn.implicitHeight + + ColumnLayout { + id: contentColumn + anchors.fill: parent + + LanguageSelectorButton { // Target language button + id: targetLanguageButton + displayText: root.targetLanguage + onClicked: { + root.showLanguageSelectorDialog(true); + } + } + + TextCanvas { // Content translation + id: outputCanvas + isInput: false + placeholderText: qsTr("Translation goes here...") + property bool hasTranslation: (root.translatedText.trim().length > 0) + text: hasTranslation ? root.translatedText : "" + GroupButton { + id: copyButton + baseWidth: height + buttonRadius: Appearance.rounding.small + enabled: outputCanvas.displayedText.trim().length > 0 + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "content_copy" + color: copyButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + Quickshell.clipboardText = outputCanvas.displayedText + } + } + GroupButton { + id: searchButton + baseWidth: height + buttonRadius: Appearance.rounding.small + enabled: outputCanvas.displayedText.trim().length > 0 + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "travel_explore" + color: searchButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + let url = ConfigOptions.search.engineBaseUrl + outputCanvas.displayedText; + for (let site of ConfigOptions.search.excludedSites) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + } + } + } + + } + } + + LanguageSelectorButton { // Source language button + id: sourceLanguageButton + displayText: root.sourceLanguage + onClicked: { + root.showLanguageSelectorDialog(false); + } + } + + TextCanvas { // Content input + id: inputCanvas + isInput: true + placeholderText: qsTr("Enter text to translate...") + onInputTextChanged: { + translateTimer.restart(); + } + GroupButton { + id: pasteButton + baseWidth: height + buttonRadius: Appearance.rounding.small + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "content_paste" + color: deleteButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + root.inputField.text = Quickshell.clipboardText + } + } + GroupButton { + id: deleteButton + baseWidth: height + buttonRadius: Appearance.rounding.small + enabled: inputCanvas.inputTextArea.text.length > 0 + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "close" + color: deleteButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + root.inputField.text = "" + } + } + } + } + + Loader { + anchors.fill: parent + active: root.showLanguageSelector + visible: root.showLanguageSelector + z: 9999 + sourceComponent: SelectionDialog { + id: languageSelectorDialog + titleText: qsTr("Select Language") + items: root.languages + defaultChoice: root.languageSelectorTarget ? root.targetLanguage : root.sourceLanguage + onCanceled: () => { + root.showLanguageSelector = false; + } + onSelected: (result) => { + root.showLanguageSelector = false; + if (!result || result.length === 0) return; // No selection made + + if (root.languageSelectorTarget) { + root.targetLanguage = result; + ConfigLoader.setConfigValueAndSave("language.translator.targetLanguage", result); // Save to config + } else { + root.sourceLanguage = result; + ConfigLoader.setConfigValueAndSave("language.translator.sourceLanguage", result); // Save to config + } + + translateTimer.restart(); // Restart translation after language change + } + } + } +} diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml new file mode 100644 index 000000000..8d7952110 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml @@ -0,0 +1,298 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "../" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects +import org.kde.syntaxhighlighting + +Rectangle { + id: root + property int messageIndex + property var messageData + property var messageInputField + + property real messagePadding: 7 + property real contentSpacing: 3 + + property bool enableMouseSelection: false + property bool renderMarkdown: true + property bool editing: false + + property list messageBlocks: StringUtils.splitMarkdownBlocks(root.messageData?.content) + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: columnLayout.implicitHeight + root.messagePadding * 2 + + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + function saveMessage() { + if (!root.editing) return; + // Get all Loader children (each represents a segment) + const segments = messageContentColumnLayout.children + .map(child => child.segment) + .filter(segment => (segment)); + + // Reconstruct markdown + const newContent = segments.map(segment => { + if (segment.type === "code") { + const lang = segment.lang ? segment.lang : ""; + // Remove trailing newlines + const code = segment.content.replace(/\n+$/, ""); + return "```" + lang + "\n" + code + "\n```"; + } else { + return segment.content; + } + }).join(""); + + root.editing = false + root.messageData.content = newContent; + } + + Keys.onPressed: (event) => { + if ( // Prevent de-select + event.key === Qt.Key_Control || + event.key == Qt.Key_Shift || + event.key == Qt.Key_Alt || + event.key == Qt.Key_Meta + ) { + event.accepted = true + } + // Ctrl + S to save + if ((event.key === Qt.Key_S) && event.modifiers == Qt.ControlModifier) { + root.saveMessage(); + event.accepted = true; + } + } + + ColumnLayout { // Main layout of the whole thing + id: columnLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: messagePadding + spacing: root.contentSpacing + + RowLayout { // Header + spacing: 15 + Layout.fillWidth: true + + Rectangle { // Name + id: nameWrapper + color: Appearance.colors.colSecondaryContainer + // color: "transparent" + radius: Appearance.rounding.small + implicitHeight: Math.max(nameRowLayout.implicitHeight + 5 * 2, 30) + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + RowLayout { + id: nameRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 7 + + Item { + Layout.alignment: Qt.AlignVCenter + Layout.fillHeight: true + implicitWidth: messageData?.role == 'assistant' ? modelIcon.width : roleIcon.implicitWidth + implicitHeight: messageData?.role == 'assistant' ? modelIcon.height : roleIcon.implicitHeight + + CustomIcon { + id: modelIcon + anchors.centerIn: parent + visible: messageData?.role == 'assistant' && Ai.models[messageData?.model].icon + width: Appearance.font.pixelSize.large + height: Appearance.font.pixelSize.large + source: messageData?.role == 'assistant' ? Ai.models[messageData?.model].icon : + messageData?.role == 'user' ? 'linux-symbolic' : 'desktop-symbolic' + } + ColorOverlay { + visible: modelIcon.visible + anchors.fill: modelIcon + source: modelIcon + color: Appearance.m3colors.m3onSecondaryContainer + } + + MaterialSymbol { + id: roleIcon + anchors.centerIn: parent + visible: !modelIcon.visible + iconSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onSecondaryContainer + text: messageData?.role == 'user' ? 'person' : + messageData?.role == 'interface' ? 'settings' : + messageData?.role == 'assistant' ? 'neurology' : + 'computer' + } + } + + StyledText { + id: providerName + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + text: messageData?.role == 'assistant' ? Ai.models[messageData?.model].name : + (messageData?.role == 'user' && SystemInfo.username) ? SystemInfo.username : + qsTr("Interface") + } + } + } + + Button { // Not visible to model + id: modelVisibilityIndicator + visible: messageData?.role == 'interface' + implicitWidth: 16 + implicitHeight: 30 + Layout.alignment: Qt.AlignVCenter + + background: Item + + MaterialSymbol { + id: notVisibleToModelText + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + text: "visibility_off" + } + StyledToolTip { + content: qsTr("Not visible to model") + } + } + + ButtonGroup { + spacing: 5 + + AiMessageControlButton { + id: copyButton + buttonIcon: activated ? "inventory" : "content_copy" + + onClicked: { + Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(root.messageData?.content)}'`) + copyButton.activated = true + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyButton.activated = false + } + } + + StyledToolTip { + content: qsTr("Copy") + } + } + AiMessageControlButton { + id: editButton + activated: root.editing + enabled: root.messageData?.done ?? false + buttonIcon: "edit" + onClicked: { + root.editing = !root.editing + if (!root.editing) { // Save changes + root.saveMessage() + } + } + StyledToolTip { + content: root.editing ? qsTr("Save") : qsTr("Edit") + } + } + AiMessageControlButton { + id: toggleMarkdownButton + activated: !root.renderMarkdown + buttonIcon: "code" + onClicked: { + root.renderMarkdown = !root.renderMarkdown + } + StyledToolTip { + content: qsTr("View Markdown source") + } + } + AiMessageControlButton { + id: deleteButton + buttonIcon: "close" + onClicked: { + Ai.removeMessage(root.messageIndex) + } + StyledToolTip { + content: qsTr("Delete") + } + } + } + } + + ColumnLayout { // Message content + id: messageContentColumnLayout + + spacing: 0 + Repeater { + model: ScriptModel { + values: root.messageBlocks.map((block, index) => index) + } + delegate: Loader { + required property int index + property var thisBlock: root.messageBlocks[index] + Layout.fillWidth: true + // property var segment: thisBlock + property var segmentContent: thisBlock.content + property var segmentLang: thisBlock.lang + property var messageData: root.messageData + property var editing: root.editing + property var renderMarkdown: root.renderMarkdown + property var enableMouseSelection: root.enableMouseSelection + property bool thinking: root.messageData?.thinking ?? true + property bool done: root.messageData?.done ?? false + property bool completed: thisBlock.completed ?? false + + source: thisBlock.type === "code" ? "MessageCodeBlock.qml" : + thisBlock.type === "think" ? "MessageThinkBlock.qml" : + "MessageTextBlock.qml" + + } + } + } + + Flow { // Annotations + id: annotationFlowLayout + visible: root.messageData?.annotationSources?.length > 0 + spacing: 5 + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + + Repeater { + model: ScriptModel { + values: root.messageData?.annotationSources || [] + } + delegate: AnnotationSourceButton { + id: annotationButton + displayText: modelData.text + url: modelData.url + } + } + + } + + } +} + diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessageControlButton.qml b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessageControlButton.qml new file mode 100644 index 000000000..2d7cb82df --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessageControlButton.qml @@ -0,0 +1,30 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +GroupButton { + id: button + property string buttonIcon + property bool activated: false + toggled: activated + + baseWidth: height + + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: buttonIcon + color: button.activated ? Appearance.m3colors.m3onPrimary : + button.enabled ? Appearance.m3colors.m3onSurface : + Appearance.colors.colOnLayer1Inactive + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml b/.config/quickshell/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml new file mode 100644 index 000000000..a253a291e --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml @@ -0,0 +1,57 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import Qt5Compat.GraphicalEffects +import Qt.labs.platform +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Hyprland + +RippleButton { + id: root + property string displayText + property string url + + property real faviconSize: 20 + implicitHeight: 30 + leftPadding: (implicitHeight - faviconSize) / 2 + rightPadding: 10 + buttonRadius: Appearance.rounding.full + colBackground: Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover + colRipple: Appearance.colors.colSurfaceContainerHighestActive + + PointingHandInteraction {} + onClicked: { + if (url) { + Qt.openUrlExternally(url) + Hyprland.dispatch("global quickshell:sidebarLeftClose") + } + } + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: rowLayout.implicitWidth + implicitHeight: rowLayout.implicitHeight + RowLayout { + id: rowLayout + anchors.fill: parent + spacing: 5 + Favicon { + url: root.url + size: root.faviconSize + displayText: root.displayText + } + StyledText { + id: text + horizontalAlignment: Text.AlignHCenter + text: displayText + color: Appearance.m3colors.m3onSurface + } + } + } +} diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml b/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml new file mode 100644 index 000000000..ea7bb0ee3 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml @@ -0,0 +1,256 @@ +pragma ComponentBehavior: Bound + +import "root:/" +import "root:/services" +import "root:/modules/common/" +import "root:/modules/common/widgets" +import "../" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/file_utils.js" as FileUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects +import org.kde.syntaxhighlighting + +ColumnLayout { + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property var segmentContent: parent?.segmentContent ?? ({}) + property var segmentLang: parent?.segmentLang ?? "txt" + property var messageData: parent?.messageData ?? {} + + property real codeBlockBackgroundRounding: Appearance.rounding.small + property real codeBlockHeaderPadding: 3 + property real codeBlockComponentSpacing: 2 + + spacing: codeBlockComponentSpacing + anchors.left: parent.left + anchors.right: parent.right + + Rectangle { // Code background + Layout.fillWidth: true + topLeftRadius: codeBlockBackgroundRounding + topRightRadius: codeBlockBackgroundRounding + bottomLeftRadius: Appearance.rounding.unsharpen + bottomRightRadius: Appearance.rounding.unsharpen + color: Appearance.colors.colSurfaceContainerHighest + implicitHeight: codeBlockTitleBarRowLayout.implicitHeight + codeBlockHeaderPadding * 2 + + RowLayout { // Language and buttons + id: codeBlockTitleBarRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: codeBlockHeaderPadding + anchors.rightMargin: codeBlockHeaderPadding + spacing: 5 + + StyledText { + id: codeBlockLanguage + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: false + Layout.topMargin: 7 + Layout.bottomMargin: 7 + Layout.leftMargin: 10 + font.pixelSize: Appearance.font.pixelSize.small + font.weight: Font.DemiBold + color: Appearance.colors.colOnLayer2 + text: segmentLang ? Repository.definitionForName(segmentLang).name : "plain" + } + + Item { Layout.fillWidth: true } + + ButtonGroup { + AiMessageControlButton { + id: copyCodeButton + buttonIcon: activated ? "inventory" : "content_copy" + + onClicked: { + Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(segmentContent)}'`) + copyCodeButton.activated = true + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyCodeButton.activated = false + } + } + StyledToolTip { + content: qsTr("Copy code") + } + } + AiMessageControlButton { + id: saveCodeButton + buttonIcon: activated ? "check" : "save" + + onClicked: { + const downloadPath = FileUtils.trimFileProtocol(Directories.downloads) + Hyprland.dispatch(`exec echo '${StringUtils.shellSingleQuoteEscape(segmentContent)}' > '${downloadPath}/code.${segmentLang || "txt"}'`) + Hyprland.dispatch(`exec notify-send 'Code saved to file' '${downloadPath}/code.${segmentLang || "txt"}' -a Shell`) + saveCodeButton.activated = true + saveIconTimer.restart() + } + + Timer { + id: saveIconTimer + interval: 1500 + repeat: false + onTriggered: { + saveCodeButton.activated = false + } + } + StyledToolTip { + content: qsTr("Save to Downloads") + } + } + } + } + } + + RowLayout { // Line numbers and code + spacing: codeBlockComponentSpacing + + Rectangle { // Line numbers + implicitWidth: 40 + Layout.fillHeight: true + Layout.fillWidth: false + topLeftRadius: Appearance.rounding.unsharpen + bottomLeftRadius: codeBlockBackgroundRounding + topRightRadius: Appearance.rounding.unsharpen + bottomRightRadius: Appearance.rounding.unsharpen + color: Appearance.colors.colLayer2 + + ColumnLayout { + id: lineNumberColumnLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.rightMargin: 5 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Repeater { + model: codeTextArea.text.split("\n").length + Text { + required property int index + Layout.fillWidth: true + Layout.alignment: Qt.AlignRight + font.family: Appearance.font.family.monospace + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + text: index + 1 + } + } + } + } + + Rectangle { // Code background + Layout.fillWidth: true + topLeftRadius: Appearance.rounding.unsharpen + bottomLeftRadius: Appearance.rounding.unsharpen + topRightRadius: Appearance.rounding.unsharpen + bottomRightRadius: codeBlockBackgroundRounding + color: Appearance.colors.colLayer2 + implicitHeight: codeTextArea.implicitHeight + + ScrollView { + id: codeScrollView + Layout.fillWidth: true + Layout.fillHeight: true + implicitWidth: parent.width + implicitHeight: codeTextArea.implicitHeight + 1 + contentWidth: codeTextArea.width - 1 + // contentHeight: codeTextArea.contentHeight + clip: true + ScrollBar.vertical.policy: ScrollBar.AlwaysOff + + ScrollBar.horizontal: ScrollBar { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + padding: 5 + policy: ScrollBar.AsNeeded + opacity: visualSize == 1 ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + contentItem: Rectangle { + implicitHeight: 6 + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2Active + } + } + + TextArea { // Code + id: codeTextArea + Layout.fillWidth: true + readOnly: !editing + selectByMouse: enableMouseSelection || editing + renderType: Text.NativeRendering + font.family: Appearance.font.family.monospace + font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text + font.pixelSize: Appearance.font.pixelSize.small + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + // wrapMode: TextEdit.Wrap + color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 + + text: segmentContent + onTextChanged: { + segmentContent = text + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + // Insert 4 spaces at cursor + const cursor = codeTextArea.cursorPosition; + codeTextArea.insert(cursor, " "); + codeTextArea.cursorPosition = cursor + 4; + event.accepted = true; + } else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) { + codeTextArea.copy(); + event.accepted = true; + } + } + + SyntaxHighlighter { + id: highlighter + textEdit: codeTextArea + repository: Repository + definition: Repository.definitionForName(segmentLang || "plaintext") + theme: Appearance.syntaxHighlightingTheme + } + } + } + + // MouseArea to block scrolling + // MouseArea { + // id: codeBlockMouseArea + // anchors.fill: parent + // acceptedButtons: editing ? Qt.NoButton : Qt.LeftButton + // cursorShape: (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor + // onWheel: (event) => { + // event.accepted = false + // } + // } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml b/.config/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml new file mode 100644 index 000000000..faa6c5590 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml @@ -0,0 +1,147 @@ +pragma ComponentBehavior: Bound + +import "root:/" +import "root:/services" +import "root:/modules/common/" +import "root:/modules/common/widgets" +import "../" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects + +ColumnLayout { + id: root + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property string segmentContent: parent?.segmentContent ?? ({}) + property var messageData: parent?.messageData ?? {} + property bool done: parent?.done ?? true + property list renderedLatexHashes: [] + + property string renderedSegmentContent: "" + + Layout.fillWidth: true + + Timer { + id: renderTimer + interval: 1000 + repeat: false + onTriggered: { + renderLatex() + for (const hash of renderedLatexHashes) { + handleRenderedLatex(hash, true); + } + } + } + + function renderLatex() { + // Regex for $...$, $$...$$, \[...\] + // Note: This is a simple approach and may need refinement for edge cases + let regex = /(\$\$([\s\S]+?)\$\$)|(\$([^\$]+?)\$)|(\\\[((?:.|\n)+?)\\\])|(\\\(([\s\S]+?)\\\))/g; + let match; + while ((match = regex.exec(segmentContent)) !== null) { + let expression = match[1] || match[2] || match[3] || match[4] || match[5] || match[6] || match[7] || match[8]; + if (expression) { + Qt.callLater(() => { + const [renderHash, isNew] = LatexRenderer.requestRender(expression.trim()); + if (!renderedLatexHashes.includes(renderHash)) { + renderedLatexHashes.push(renderHash); + } + }); + } + } + } + + function handleRenderedLatex(hash, force = false) { + if (renderedLatexHashes.includes(hash) || force) { + const imagePath = LatexRenderer.renderedImagePaths[hash]; + const markdownImage = `![latex](${imagePath})`; + + const expression = LatexRenderer.processedExpressions[hash]; + renderedSegmentContent = renderedSegmentContent.replace(expression, markdownImage); + } + } + + onDoneChanged: { + renderTimer.restart(); + } + onEditingChanged: { + if (!editing) { + renderLatex() + } else { + // console.log("Editing mode enabled", segmentContent) + textArea.text = segmentContent + } + } + + onSegmentContentChanged: { + // console.log("Segment content changed: " + segmentContent); + renderedSegmentContent = segmentContent; + if (!root.editing && segmentContent) { + root.renderLatex(); + } + } + + onRenderedSegmentContentChanged: { + // console.log("Rendered segment content changed: " + renderedSegmentContent); + if (renderedSegmentContent) { + textArea.text = renderedSegmentContent; + } + } + + // When something finishes rendering + // 1. Check if the hash is in the list + // 2. If it is, replace the expression with the image path + Connections { + target: LatexRenderer + function onRenderFinished(hash, imagePath) { + const expression = LatexRenderer.processedExpressions[hash]; + // console.log("Render finished: " + hash + " " + expression); + handleRenderedLatex(hash); + } + } + + TextArea { + id: textArea + + Layout.fillWidth: true + readOnly: !editing + selectByMouse: enableMouseSelection || editing + renderType: Text.NativeRendering + font.family: Appearance.font.family.reading + font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text + font.pixelSize: Appearance.font.pixelSize.small + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + wrapMode: TextEdit.Wrap + color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 + textFormat: renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText + text: qsTr("Waiting for response...") + + onTextChanged: { + if (!root.editing) return + segmentContent = text + } + + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + Hyprland.dispatch("global quickshell:sidebarLeftClose") + } + + MouseArea { // Pointing hand for links + anchors.fill: parent + acceptedButtons: Qt.NoButton // Only for hover + hoverEnabled: true + cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : + (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor + } + } +} diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/MessageThinkBlock.qml b/.config/quickshell/modules/sidebarLeft/aiChat/MessageThinkBlock.qml new file mode 100644 index 000000000..1ae941b78 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/aiChat/MessageThinkBlock.qml @@ -0,0 +1,180 @@ +pragma ComponentBehavior: Bound + +import "root:/" +import "root:/services" +import "root:/modules/common/" +import "root:/modules/common/widgets" +import "../" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects + +Item { + id: root + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property string segmentContent: parent?.segmentContent ?? ({}) + property var messageData: parent?.messageData ?? {} + property bool done: parent?.done ?? true + property bool completed: parent?.completed ?? false + + property real thinkBlockBackgroundRounding: Appearance.rounding.small + property real thinkBlockHeaderPaddingVertical: 3 + property real thinkBlockHeaderPaddingHorizontal: 10 + property real thinkBlockComponentSpacing: 2 + + property var collapseAnimation: messageTextBlock.implicitHeight > 40 ? Appearance.animation.elementMoveEnter : Appearance.animation.elementMoveFast + property bool collapsed: true /* should be root.completed but its kinda buggy rn so nope */ + + Layout.fillWidth: true + implicitHeight: collapsed ? header.implicitHeight : columnLayout.implicitHeight + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.width + height: root.height + radius: thinkBlockBackgroundRounding + } + } + + Behavior on implicitHeight { + enabled: root.completed ?? false + NumberAnimation { + duration: collapseAnimation.duration + easing.type: collapseAnimation.type + easing.bezierCurve: collapseAnimation.bezierCurve + } + } + + ColumnLayout { + id: columnLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 0 + + Rectangle { // Header background + id: header + color: Appearance.colors.colSurfaceContainerHighest + Layout.fillWidth: true + implicitHeight: thinkBlockTitleBarRowLayout.implicitHeight + thinkBlockHeaderPaddingVertical * 2 + + MouseArea { // Click to reveal + id: headerMouseArea + enabled: root.completed + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + root.collapsed = !root.collapsed + } + } + + RowLayout { // Header content + id: thinkBlockTitleBarRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: thinkBlockHeaderPaddingHorizontal + anchors.rightMargin: thinkBlockHeaderPaddingHorizontal + spacing: 10 + + MaterialSymbol { + Layout.fillWidth: false + Layout.topMargin: 7 + Layout.bottomMargin: 7 + Layout.leftMargin: 3 + text: "linked_services" + } + StyledText { + id: thinkBlockLanguage + Layout.fillWidth: false + Layout.alignment: Qt.AlignLeft + text: root.completed ? qsTr("Chain of Thought") : (qsTr("Thinking") + ".".repeat(Math.random() * 4)) + } + Item { Layout.fillWidth: true } + RippleButton { // Expand button + id: expandButton + visible: root.completed + implicitWidth: 22 + implicitHeight: 22 + colBackground: headerMouseArea.containsMouse ? Appearance.colors.colLayer2Hover + : ColorUtils.transparentize(Appearance.colors.colLayer2, 1) + colBackgroundHover: Appearance.colors.colLayer2Hover + colRipple: Appearance.colors.colLayer2Active + + onClicked: { root.collapsed = !root.collapsed } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: "keyboard_arrow_down" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer2 + rotation: root.collapsed ? 0 : 180 + Behavior on rotation { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + } + + } + + } + + } + + Item { + id: content + Layout.fillWidth: true + implicitHeight: collapsed ? 0 : contentBackground.implicitHeight + thinkBlockComponentSpacing + clip: true + + Behavior on implicitHeight { + enabled: root.completed ?? false + NumberAnimation { + duration: collapseAnimation.duration + easing.type: collapseAnimation.easing + easing.bezierCurve: collapseAnimation.bezierCurve + } + } + + Rectangle { + id: contentBackground + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: messageTextBlock.implicitHeight + color: Appearance.colors.colLayer2 + + // Load data for the message at the correct scope + property bool editing: root.editing + property bool renderMarkdown: root.renderMarkdown + property bool enableMouseSelection: root.enableMouseSelection + property string segmentContent: root.segmentContent + property var messageData: root.messageData + property bool done: root.done + + MessageTextBlock { + id: messageTextBlock + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/anime/BooruImage.qml b/.config/quickshell/modules/sidebarLeft/anime/BooruImage.qml new file mode 100644 index 000000000..4e115bc76 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/anime/BooruImage.qml @@ -0,0 +1,191 @@ +import "root:/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQml +import Qt.labs.platform +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Effects +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +Button { + id: root + property var imageData + property var rowHeight + property bool manualDownload: false + property string previewDownloadPath + property string downloadPath + property string nsfwPath + property string fileName: decodeURIComponent((imageData.file_url).substring((imageData.file_url).lastIndexOf('/') + 1)) + property string filePath: `${root.previewDownloadPath}/${root.fileName}` + property int maxTagStringLineLength: 50 + property real imageRadius: Appearance.rounding.small + + property bool showActions: false + Process { + id: downloadProcess + running: false + command: ["bash", "-c", `[ -f ${root.filePath} ] || curl -sSL '${root.imageData.preview_url ?? root.imageData.sample_url}' -o '${root.filePath}'`] + onExited: (exitCode, exitStatus) => { + imageObject.source = `${previewDownloadPath}/${root.fileName}` + } + } + + Component.onCompleted: { + if (root.manualDownload) { + downloadProcess.running = true + } + } + + StyledToolTip { + content: `${StringUtils.wordWrap(root.imageData.tags, root.maxTagStringLineLength)}` + } + + padding: 0 + implicitWidth: root.rowHeight * modelData.aspect_ratio + implicitHeight: root.rowHeight + + background: Rectangle { + implicitWidth: root.rowHeight * modelData.aspect_ratio + implicitHeight: root.rowHeight + radius: imageRadius + color: Appearance.colors.colLayer2 + } + + contentItem: Item { + anchors.fill: parent + + Image { + id: imageObject + anchors.fill: parent + width: root.rowHeight * modelData.aspect_ratio + height: root.rowHeight + visible: opacity > 0 + opacity: status === Image.Ready ? 1 : 0 + fillMode: Image.PreserveAspectFit + source: modelData.preview_url + sourceSize.width: root.rowHeight * modelData.aspect_ratio + sourceSize.height: root.rowHeight + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.rowHeight * modelData.aspect_ratio + height: root.rowHeight + radius: imageRadius + } + } + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + } + + RippleButton { + id: menuButton + anchors.top: parent.top + anchors.right: parent.right + property real buttonSize: 30 + anchors.margins: Math.max(root.imageRadius - buttonSize / 2, 8) + implicitHeight: buttonSize + implicitWidth: buttonSize + + buttonRadius: Appearance.rounding.full + colBackground: ColorUtils.transparentize(Appearance.m3colors.m3surface, 0.3) + colBackgroundHover: ColorUtils.transparentize(ColorUtils.mix(Appearance.m3colors.m3surface, Appearance.m3colors.m3onSurface, 0.8), 0.2) + colRipple: ColorUtils.transparentize(ColorUtils.mix(Appearance.m3colors.m3surface, Appearance.m3colors.m3onSurface, 0.6), 0.1) + + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.large + color: Appearance.m3colors.m3onSurface + text: "more_vert" + } + + onClicked: { + root.showActions = !root.showActions + } + } + + Loader { + id: contextMenuLoader + active: root.showActions + anchors.top: menuButton.bottom + anchors.right: parent.right + anchors.margins: 8 + + sourceComponent: Item { + width: contextMenu.width + height: contextMenu.height + + StyledRectangularShadow { + target: contextMenu + } + Rectangle { + id: contextMenu + anchors.centerIn: parent + opacity: root.showActions ? 1 : 0 + visible: opacity > 0 + radius: Appearance.rounding.small + color: Appearance.colors.colSurfaceContainer + implicitHeight: contextMenuColumnLayout.implicitHeight + radius * 2 + implicitWidth: contextMenuColumnLayout.implicitWidth + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + ColumnLayout { + id: contextMenuColumnLayout + anchors.centerIn: parent + spacing: 0 + + MenuButton { + id: openFileLinkButton + Layout.fillWidth: true + buttonText: qsTr("Open file link") + onClicked: { + root.showActions = false + Hyprland.dispatch("keyword cursor:no_warps true") + Qt.openUrlExternally(root.imageData.file_url) + Hyprland.dispatch("keyword cursor:no_warps false") + } + } + MenuButton { + id: sourceButton + visible: root.imageData.source && root.imageData.source.length > 0 + Layout.fillWidth: true + buttonText: StringUtils.format(qsTr("Go to source ({0})"), StringUtils.getDomain(root.imageData.source)) + enabled: root.imageData.source && root.imageData.source.length > 0 + onClicked: { + root.showActions = false + Hyprland.dispatch("keyword cursor:no_warps true") + Qt.openUrlExternally(root.imageData.source) + Hyprland.dispatch("keyword cursor:no_warps false") + } + } + MenuButton { + id: downloadButton + Layout.fillWidth: true + buttonText: qsTr("Download") + onClicked: { + root.showActions = false + Hyprland.dispatch(`exec curl '${root.imageData.file_url}' -o '${root.imageData.is_nsfw ? root.nsfwPath : root.downloadPath}/${root.fileName}' && notify-send '${qsTr("Download complete")}' '${root.downloadPath}/${root.fileName}' -a 'Shell'`) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/anime/BooruResponse.qml b/.config/quickshell/modules/sidebarLeft/anime/BooruResponse.qml new file mode 100644 index 000000000..7a207582f --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/anime/BooruResponse.qml @@ -0,0 +1,301 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "../" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects + +Rectangle { + id: root + property var responseData + property var tagInputField + + property string previewDownloadPath + property string downloadPath + property string nsfwPath + + property real availableWidth: parent.width + property real rowTooShortThreshold: 190 + property real imageSpacing: 5 + property real responsePadding: 5 + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: columnLayout.implicitHeight + root.responsePadding * 2 + + Component.onCompleted: { + // Break property bind to prevent aggressive updates + availableWidth = parent.width + } + + Connections { + target: parent + function onWidthChanged() { + updateWidthTimer.restart() + } + } + + Timer { + id: updateWidthTimer + interval: 100 + onTriggered: { + availableWidth = parent.width + } + } + + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + ColumnLayout { + id: columnLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: responsePadding + spacing: root.imageSpacing + + RowLayout { // Header + Rectangle { // Provider name + id: providerNameWrapper + color: Appearance.colors.colSecondaryContainer + radius: Appearance.rounding.small + implicitWidth: providerName.implicitWidth + 10 * 2 + implicitHeight: Math.max(providerName.implicitHeight + 5 * 2, 30) + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: providerName + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.m3colors.m3onSecondaryContainer + text: Booru.providers[root.responseData.provider].name + } + } + Item { Layout.fillWidth: true } + Item { // Page number + visible: root.responseData.page != "" && root.responseData.page > 0 + implicitWidth: Math.max(pageNumber.implicitWidth + 10 * 2, 30) + implicitHeight: pageNumber.implicitHeight + 5 * 2 + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: pageNumber + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnLayer2 + // text: `Page ${root.responseData.page}` + text: StringUtils.format(qsTr("Page {0}"), root.responseData.page) + } + } + } + + Flickable { // Tag strip + id: tagsFlickable + visible: root.responseData.tags.length > 0 + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: { + return true + } + implicitHeight: tagRowLayout.implicitHeight + // height: tagRowLayout.implicitHeight + contentWidth: tagRowLayout.implicitWidth + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: tagsFlickable.width + height: tagsFlickable.height + radius: Appearance.rounding.small + } + } + + Behavior on height { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + RowLayout { + id: tagRowLayout + Layout.alignment: Qt.AlignBottom + + Repeater { + id: tagRepeater + model: root.responseData.tags + + ApiCommandButton { + Layout.fillWidth: false + buttonText: modelData + onClicked: { + if(root.tagInputField.text.length !== 0) root.tagInputField.text += " " + root.tagInputField.text += modelData + } + } + } + + } + } + + StyledText { // Message + id: messageText + Layout.fillWidth: true + visible: root.responseData.message.length > 0 + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + text: root.responseData.message + wrapMode: Text.WordWrap + Layout.margins: responsePadding + textFormat: Text.MarkdownText + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + Hyprland.dispatch("global quickshell:sidebarLeftClose") + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton // Only for hover + hoverEnabled: true + cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor + } + } + + Repeater { + model: ScriptModel { + values: { + // Greedily add images to a row as long as rowHeight >= rowTooShortThreshold + let i = 0; + let rows = []; + const responseList = root.responseData.images; + const minRowHeight = rowTooShortThreshold; + const availableImageWidth = availableWidth - root.imageSpacing - (responsePadding * 2); + + while (i < responseList.length) { + let row = { + height: 0, + images: [], + }; + let j = i; + let combinedAspect = 0; + let rowHeight = 0; + + // Try to add as many images as possible without going below minRowHeight + while (j < responseList.length) { + combinedAspect += responseList[j].aspect_ratio; + // Subtract imageSpacing for each gap between images in the row + let imagesInRow = j - i + 1; + let totalSpacing = root.imageSpacing * (imagesInRow - 1); + let rowAvailableWidth = availableImageWidth - totalSpacing; + rowHeight = rowAvailableWidth / combinedAspect; + if (rowHeight < minRowHeight) { + combinedAspect -= responseList[j].aspect_ratio; + imagesInRow -= 1; + totalSpacing = root.imageSpacing * (imagesInRow - 1); + rowAvailableWidth = availableImageWidth - totalSpacing; + rowHeight = rowAvailableWidth / combinedAspect; + break; + } + j++; + } + + // If we couldn't add any image (shouldn't happen), add at least one + if (j === i) { + row.images.push(responseList[i]); + row.height = availableImageWidth / responseList[i].aspect_ratio; + rows.push(row); + i++; + } else { + for (let k = i; k < j; k++) { + row.images.push(responseList[k]); + } + // Recalculate spacing for the final row + let imagesInRow = j - i; + let totalSpacing = root.imageSpacing * (imagesInRow - 1); + let rowAvailableWidth = availableImageWidth - totalSpacing; + row.height = rowAvailableWidth / combinedAspect; + rows.push(row); + i = j; + } + } + return rows; + } + } + delegate: RowLayout { + id: imageRow + required property var modelData + property var rowHeight: modelData.height + spacing: root.imageSpacing + + Repeater { + model: modelData.images + delegate: BooruImage { + required property var modelData + imageData: modelData + rowHeight: imageRow.rowHeight + imageRadius: imageRow.modelData.images.length == 1 ? 50 : Appearance.rounding.normal + // Download manually to reduce redundant requests or make sure downloading works + manualDownload: ["danbooru", "waifu.im", "t.alcy.cc"].includes(root.responseData.provider) + previewDownloadPath: root.previewDownloadPath + downloadPath: root.downloadPath + nsfwPath: root.nsfwPath + } + } + } + } + + RippleButton { // Next page button + id: button + property string buttonText + visible: root.responseData.page != "" && root.responseData.page > 0 + + Layout.alignment: Qt.AlignRight + implicitHeight: 30 + leftPadding: 10 + rightPadding: 5 + + onClicked: { + tagInputField.text = `${responseData.tags.join(" ")} ${parseInt(root.responseData.page) + 1}` + tagInputField.accept() + } + + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover + colRipple: Appearance.colors.colSurfaceContainerHighestActive + + contentItem: Item { + anchors.fill: parent + implicitHeight: nextPageRow.implicitHeight + implicitWidth: nextPageRow.implicitWidth + + RowLayout { + id: nextPageRow + anchors.centerIn: parent + spacing: 0 + StyledText { + Layout.alignment: Qt.AlignVCenter + verticalAlignment: Text.AlignVCenter + text: "Next page" + color: Appearance.m3colors.m3onSurface + } + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + iconSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onSurface + text: "chevron_right" + } + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/translator/LanguageSelectorButton.qml b/.config/quickshell/modules/sidebarLeft/translator/LanguageSelectorButton.qml new file mode 100644 index 000000000..37df25f83 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/translator/LanguageSelectorButton.qml @@ -0,0 +1,45 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +RippleButton { + id: root + property string displayText: "" + colBackground: Appearance.colors.colLayer2 + + implicitWidth: contentItem.implicitWidth + horizontalPadding * 2 + implicitHeight: contentItem.implicitHeight + verticalPadding * 2 + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: languageRow.implicitWidth + implicitHeight: languageText.implicitHeight + RowLayout { + id: languageRow + anchors.centerIn: parent + spacing: 0 + StyledText { + id: languageText + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 5 + text: root.displayText + color: Appearance.colors.colOnLayer2 + font.pixelSize: Appearance.font.pixelSize.small + } + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + iconSize: Appearance.font.pixelSize.hugeass + text: "arrow_drop_down" + color: Appearance.colors.colOnLayer2 + } + } + } +} diff --git a/.config/quickshell/modules/sidebarLeft/translator/TextCanvas.qml b/.config/quickshell/modules/sidebarLeft/translator/TextCanvas.qml new file mode 100644 index 000000000..dad25020f --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/translator/TextCanvas.qml @@ -0,0 +1,92 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +Rectangle { + id: root + property bool isInput: true // true for input, false for output + property string placeholderText + property string text: "" + property var inputTextArea: isInput ? inputLoader.item : undefined + readonly property string displayedText: isInput ? inputLoader.item.text : + root.text.length > 0 ? outputLoader.item.text : "" + default property alias actionButtons: actions.data + Layout.fillWidth: true + implicitHeight: Math.max(150, inputColumn.implicitHeight) + color: isInput ? Appearance.colors.colLayer1 : Appearance.colors.colSurfaceContainer + radius: Appearance.rounding.normal + border.color: isInput ? Appearance.colors.colOutlineVariant : "transparent" + border.width: isInput ? 1 : 0 + + signal inputTextChanged(); // Signal emitted when text changes + + ColumnLayout { + id: inputColumn + anchors.fill: parent + spacing: 0 + + Loader { + id: inputLoader + active: root.isInput + visible: root.isInput + Layout.fillWidth: true + sourceComponent: StyledTextArea { // Input area + id: inputTextArea + placeholderText: root.placeholderText + wrapMode: TextEdit.Wrap + textFormat: TextEdit.PlainText + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + padding: 15 + background: null + onTextChanged: root.inputTextChanged() + } + } + + Loader { + id: outputLoader + active: !root.isInput + visible: !root.isInput + Layout.fillWidth: true + sourceComponent: StyledText { // Output area + id: outputTextArea + padding: 15 + wrapMode: Text.Wrap + font.pixelSize: Appearance.font.pixelSize.small + color: root.text.length > 0 ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + text: root.text.length > 0 ? root.text : root.placeholderText + } + } + + Item { Layout.fillHeight: true } + + RowLayout { // Status row + Layout.fillWidth: true + Layout.margins: 10 + spacing: 10 + + Loader { + active: root.isInput + visible: root.isInput + Layout.leftMargin: 10 + sourceComponent: Text { + text: qsTr("%1 characters").arg(inputLoader.item.text.length) + color: Appearance.colors.colOnLayer1 + font.pixelSize: Appearance.font.pixelSize.smaller + } + } + Item { Layout.fillWidth: true } + ButtonGroup { + id: actions + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/BottomWidgetGroup.qml b/.config/quickshell/modules/sidebarRight/BottomWidgetGroup.qml new file mode 100644 index 000000000..a29dddeda --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/BottomWidgetGroup.qml @@ -0,0 +1,236 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import "./calendar" +import "./todo" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + clip: true + implicitHeight: collapsed ? collapsedBottomWidgetGroupRow.implicitHeight : bottomWidgetGroupRow.implicitHeight + property int selectedTab: 0 + property bool collapsed: PersistentStates.sidebar.bottomGroup.collapsed + property var tabs: [ + {"type": "calendar", "name": "Calendar", "icon": "calendar_month", "widget": calendarWidget}, + {"type": "todo", "name": "To Do", "icon": "done_outline", "widget": todoWidget} + ] + + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + function setCollapsed(state) { + PersistentStateManager.setState("sidebar.bottomGroup.collapsed", state) + if (collapsed) { + bottomWidgetGroupRow.opacity = 0 + } + else { + collapsedBottomWidgetGroupRow.opacity = 0 + } + collapseCleanFadeTimer.start() + } + + Timer { + id: collapseCleanFadeTimer + interval: Appearance.animation.elementMove.duration / 2 + repeat: false + onTriggered: { + if(collapsed) collapsedBottomWidgetGroupRow.opacity = 1 + else bottomWidgetGroupRow.opacity = 1 + } + } + + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) + && event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabs.length - 1) + } else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0) + } + event.accepted = true; + } + } + + // The thing when collapsed + RowLayout { + id: collapsedBottomWidgetGroupRow + opacity: collapsed ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + NumberAnimation { + id: collapsedBottomWidgetGroupRowFade + duration: Appearance.animation.elementMove.duration / 2 + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + spacing: 15 + + CalendarHeaderButton { + Layout.margins: 10 + Layout.rightMargin: 0 + forceCircle: true + onClicked: { + root.setCollapsed(false) + } + contentItem: MaterialSymbol { + text: "keyboard_arrow_up" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + + StyledText { + property int remainingTasks: Todo.list.filter(task => !task.done).length; + Layout.margins: 10 + Layout.leftMargin: 0 + text: `${DateTime.collapsedCalendarFormat} • ${remainingTasks} task${remainingTasks > 1 ? "s" : ""}` + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + } + } + + // The thing when expanded + RowLayout { + id: bottomWidgetGroupRow + + opacity: collapsed ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + NumberAnimation { + id: bottomWidgetGroupRowFade + duration: Appearance.animation.elementMove.duration / 2 + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + anchors.fill: parent + height: tabStack.height + spacing: 10 + + // Navigation rail + Item { + Layout.fillHeight: true + Layout.fillWidth: false + Layout.leftMargin: 10 + Layout.topMargin: 10 + width: tabBar.width + // Navigation rail buttons + ColumnLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 5 + id: tabBar + spacing: 15 + Repeater { + model: root.tabs + NavRailButton { + toggled: root.selectedTab == index + buttonText: modelData.name + buttonIcon: modelData.icon + onClicked: { + root.selectedTab = index + } + } + } + } + // Collapse button + CalendarHeaderButton { + anchors.left: parent.left + anchors.top: parent.top + forceCircle: true + onClicked: { + root.setCollapsed(true) + } + contentItem: MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + } + + // Content area + StackLayout { + id: tabStack + Layout.fillWidth: true + height: tabStack.children[0]?.tabLoader?.implicitHeight // TODO: make this less stupid + Layout.alignment: Qt.AlignVCenter + property int realIndex: 0 + property int animationDuration: Appearance.animation.elementMoveFast.duration * 1.5 + + // Switch the tab on halfway of the anim duration + Connections { + target: root + function onSelectedTabChanged() { + delayedStackSwitch.start() + tabStack.realIndex = root.selectedTab + } + } + Timer { + id: delayedStackSwitch + interval: tabStack.animationDuration / 2 + repeat: false + onTriggered: { + tabStack.currentIndex = root.selectedTab + } + } + + Repeater { + model: tabs + Item { // TODO: make behavior on y also act for the item that's switched to + id: tabItem + property int tabIndex: index + property string tabType: modelData.type + property int animDistance: 5 + property var tabLoader: tabLoader + // Opacity: show up only when being animated to + opacity: (tabStack.currentIndex === tabItem.tabIndex && tabStack.realIndex === tabItem.tabIndex) ? 1 : 0 + // Y: starts animating when user selects a different tab + y: (tabStack.realIndex === tabItem.tabIndex) ? 0 : (tabStack.realIndex < tabItem.tabIndex) ? animDistance : -animDistance + Behavior on opacity { NumberAnimation { duration: tabStack.animationDuration / 2; easing.type: Easing.OutCubic } } + Behavior on y { NumberAnimation { duration: tabStack.animationDuration; easing.type: Easing.OutExpo } } + Loader { + id: tabLoader + anchors.fill: parent + sourceComponent: modelData.widget + focus: root.selectedTab === tabItem.tabIndex + } + } + } + } + } + + // Calendar component + Component { + id: calendarWidget + + CalendarWidget { + anchors.centerIn: parent + } + } + + // To Do component + Component { + id: todoWidget + TodoWidget { + anchors.fill: parent + anchors.margins: 5 + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/CenterWidgetGroup.qml b/.config/quickshell/modules/sidebarRight/CenterWidgetGroup.qml new file mode 100644 index 000000000..1b426da43 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/CenterWidgetGroup.qml @@ -0,0 +1,82 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import "./calendar" +import "./notifications" +import "./todo" +import "./volumeMixer" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + property int selectedTab: 0 + property var tabButtonList: [{"icon": "notifications", "name": qsTr("Notifications")}, {"icon": "volume_up", "name": qsTr("Volume mixer")}] + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1) + } else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0) + } + event.accepted = true; + } + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_Tab) { + root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length + } else if (event.key === Qt.Key_Backtab) { + root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length + } + event.accepted = true; + } + } + + ColumnLayout { + anchors.margins: 5 + anchors.fill: parent + spacing: 0 + + PrimaryTabBar { + id: tabBar + tabButtonList: root.tabButtonList + externalTrackedTab: root.selectedTab + + function onCurrentIndexChanged(currentIndex) { + root.selectedTab = currentIndex + } + } + + SwipeView { + id: swipeView + Layout.topMargin: 5 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + currentIndex: root.selectedTab + onCurrentIndexChanged: { + tabBar.enableIndicatorAnimation = true + root.selectedTab = currentIndex + } + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + NotificationList {} + VolumeMixer {} + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/SidebarRight.qml b/.config/quickshell/modules/sidebarRight/SidebarRight.qml new file mode 100644 index 000000000..ed9788f0e --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/SidebarRight.qml @@ -0,0 +1,246 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "./quickToggles/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Effects +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + property int sidebarWidth: Appearance.sizes.sidebarWidth + property int sidebarPadding: 15 + + PanelWindow { + id: sidebarRoot + visible: GlobalStates.sidebarRightOpen + + function hide() { + GlobalStates.sidebarRightOpen = false + } + + exclusiveZone: 0 + implicitWidth: sidebarWidth + WlrLayershell.namespace: "quickshell:sidebarRight" + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + anchors { + top: true + right: true + bottom: true + } + + HyprlandFocusGrab { + id: grab + windows: [ sidebarRoot ] + active: GlobalStates.sidebarRightOpen + onCleared: () => { + if (!active) sidebarRoot.hide() + } + } + + Loader { + id: sidebarContentLoader + active: GlobalStates.sidebarRightOpen + anchors { + top: parent.top + bottom: parent.bottom + right: parent.right + left: parent.left + topMargin: Appearance.sizes.hyprlandGapsOut + rightMargin: Appearance.sizes.hyprlandGapsOut + bottomMargin: Appearance.sizes.hyprlandGapsOut + leftMargin: Appearance.sizes.elevationMargin + } + width: sidebarWidth - Appearance.sizes.hyprlandGapsOut - Appearance.sizes.elevationMargin + height: parent.height - Appearance.sizes.hyprlandGapsOut * 2 + + focus: GlobalStates.sidebarRightOpen + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sidebarRoot.hide(); + } + } + + sourceComponent: Item { + implicitHeight: sidebarRightBackground.implicitHeight + implicitWidth: sidebarRightBackground.implicitWidth + + StyledRectangularShadow { + target: sidebarRightBackground + } + Rectangle { + id: sidebarRightBackground + + anchors.fill: parent + implicitHeight: parent.height - Appearance.sizes.hyprlandGapsOut * 2 + implicitWidth: sidebarWidth - Appearance.sizes.hyprlandGapsOut * 2 + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + ColumnLayout { + spacing: sidebarPadding + anchors.fill: parent + anchors.margins: sidebarPadding + + RowLayout { + Layout.fillHeight: false + spacing: 10 + Layout.margins: 10 + Layout.topMargin: 5 + Layout.bottomMargin: 0 + + Item { + implicitWidth: distroIcon.width + implicitHeight: distroIcon.height + CustomIcon { + id: distroIcon + width: 25 + height: 25 + source: SystemInfo.distroIcon + } + ColorOverlay { + anchors.fill: distroIcon + source: distroIcon + color: Appearance.colors.colOnLayer0 + } + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer0 + text: StringUtils.format(qsTr("Uptime: {0}"), DateTime.uptime) + textFormat: Text.MarkdownText + } + + Item { + Layout.fillWidth: true + } + + ButtonGroup { + QuickToggleButton { + toggled: false + buttonIcon: "restart_alt" + onClicked: { + Hyprland.dispatch("reload") + Quickshell.reload(true) + } + StyledToolTip { + content: qsTr("Reload Hyprland & Quickshell") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "settings" + onClicked: { + Hyprland.dispatch(`exec ${ConfigOptions.apps.settings}`) + Hyprland.dispatch(`global quickshell:sidebarRightClose`) + } + StyledToolTip { + content: qsTr("Plasma Settings") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "power_settings_new" + onClicked: { + Hyprland.dispatch("global quickshell:sessionOpen") + } + StyledToolTip { + content: qsTr("Session") + } + } + } + } + + ButtonGroup { + Layout.alignment: Qt.AlignHCenter + spacing: 5 + padding: 5 + color: Appearance.colors.colLayer1 + + NetworkToggle {} + BluetoothToggle {} + NightLight {} + GameMode {} + IdleInhibitor {} + } + + // Center widget group + CenterWidgetGroup { + focus: sidebarRoot.visible + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + } + + BottomWidgetGroup { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + } + } + } + } + } + + + } + + IpcHandler { + target: "sidebarRight" + + function toggle(): void { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + if(GlobalStates.sidebarRightOpen) Notifications.timeoutAll(); + } + + function close(): void { + GlobalStates.sidebarRightOpen = false; + } + + function open(): void { + GlobalStates.sidebarRightOpen = true; + Notifications.timeoutAll(); + } + } + + GlobalShortcut { + name: "sidebarRightToggle" + description: qsTr("Toggles right sidebar on press") + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + if(GlobalStates.sidebarRightOpen) Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "sidebarRightOpen" + description: qsTr("Opens right sidebar on press") + + onPressed: { + GlobalStates.sidebarRightOpen = true; + Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "sidebarRightClose" + description: qsTr("Closes right sidebar on press") + + onPressed: { + GlobalStates.sidebarRightOpen = false; + } + } + +} diff --git a/.config/quickshell/modules/sidebarRight/calendar/CalendarDayButton.qml b/.config/quickshell/modules/sidebarRight/calendar/CalendarDayButton.qml new file mode 100644 index 000000000..7d1af447d --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/calendar/CalendarDayButton.qml @@ -0,0 +1,36 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RippleButton { + id: button + property string day + property int isToday + property bool bold + + Layout.fillWidth: false + Layout.fillHeight: false + implicitWidth: 38; + implicitHeight: 38; + + toggled: (isToday == 1) + buttonRadius: Appearance.rounding.small + + contentItem: StyledText { + anchors.fill: parent + text: day + horizontalAlignment: Text.AlignHCenter + font.weight: bold ? Font.DemiBold : Font.Normal + color: (isToday == 1) ? Appearance.m3colors.m3onPrimary : + (isToday == 0) ? Appearance.colors.colOnLayer1 : + Appearance.colors.colOutlineVariant + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} + diff --git a/.config/quickshell/modules/sidebarRight/calendar/CalendarHeaderButton.qml b/.config/quickshell/modules/sidebarRight/calendar/CalendarHeaderButton.qml new file mode 100644 index 000000000..92ef3c854 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/calendar/CalendarHeaderButton.qml @@ -0,0 +1,38 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RippleButton { + id: button + property string buttonText: "" + property string tooltipText: "" + property bool forceCircle: false + + implicitHeight: 30 + implicitWidth: forceCircle ? implicitHeight : (contentItem.implicitWidth + 10 * 2) + Behavior on implicitWidth { + SmoothedAnimation { + velocity: Appearance.animation.elementMove.velocity + } + } + + background.anchors.fill: button + buttonRadius: Appearance.rounding.full + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colRipple: Appearance.colors.colLayer2Active + + contentItem: StyledText { + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + + StyledToolTip { + content: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/calendar/CalendarWidget.qml b/.config/quickshell/modules/sidebarRight/calendar/CalendarWidget.qml new file mode 100644 index 000000000..1f11d87a2 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/calendar/CalendarWidget.qml @@ -0,0 +1,122 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "./calendar_layout.js" as CalendarLayout +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + // Layout.topMargin: 10 + anchors.topMargin: 10 + property int monthShift: 0 + property var viewingDate: CalendarLayout.getDateInXMonthsTime(monthShift) + property var calendarLayout: CalendarLayout.getCalendarLayout(viewingDate, monthShift === 0) + width: calendarColumn.width + implicitHeight: calendarColumn.height + 10 * 2 + + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) + && event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageDown) { + monthShift++; + } else if (event.key === Qt.Key_PageUp) { + monthShift--; + } + event.accepted = true; + } + } + MouseArea { + anchors.fill: parent + onWheel: (event) => { + if (event.angleDelta.y > 0) { + monthShift--; + } else if (event.angleDelta.y < 0) { + monthShift++; + } + } + } + + ColumnLayout { + id: calendarColumn + anchors.centerIn: parent + spacing: 5 + + // Calendar header + RowLayout { + Layout.fillWidth: true + spacing: 5 + CalendarHeaderButton { + clip: true + buttonText: `${monthShift != 0 ? "• " : ""}${viewingDate.toLocaleDateString(Qt.locale(), "MMMM yyyy")}` + tooltipText: (monthShift === 0) ? "" : qsTr("Jump to current month") + onClicked: { + monthShift = 0; + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: false + } + CalendarHeaderButton { + forceCircle: true + onClicked: { + monthShift--; + } + contentItem: MaterialSymbol { + text: "chevron_left" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + CalendarHeaderButton { + forceCircle: true + onClicked: { + monthShift++; + } + contentItem: MaterialSymbol { + text: "chevron_right" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + } + + // Week days row + RowLayout { + id: weekDaysRow + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + spacing: 5 + Repeater { + model: CalendarLayout.weekDays + delegate: CalendarDayButton { + day: modelData.day + isToday: modelData.today + bold: true + enabled: false + } + } + } + + // Real week rows + Repeater { + id: calendarRows + // model: calendarLayout + model: 6 + delegate: RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + spacing: 5 + Repeater { + model: Array(7).fill(modelData) + delegate: CalendarDayButton { + day: calendarLayout[modelData][index].day + isToday: calendarLayout[modelData][index].today + } + } + } + } + } +} \ No newline at end of file diff --git a/.config/ags/modules/sideright/calendar_layout.js b/.config/quickshell/modules/sidebarRight/calendar/calendar_layout.js similarity index 70% rename from .config/ags/modules/sideright/calendar_layout.js rename to .config/quickshell/modules/sidebarRight/calendar/calendar_layout.js index 35b481d6d..7f750b411 100644 --- a/.config/ags/modules/sideright/calendar_layout.js +++ b/.config/quickshell/modules/sidebarRight/calendar/calendar_layout.js @@ -1,3 +1,13 @@ +const weekDays = [ // MONDAY IS THE FIRST DAY OF THE WEEK :HESRIGHTYOUKNOW: + { day: 'Mo', today: 0 }, + { day: 'Tu', today: 0 }, + { day: 'We', today: 0 }, + { day: 'Th', today: 0 }, + { day: 'Fr', today: 0 }, + { day: 'Sa', today: 0 }, + { day: 'Su', today: 0 }, +] + function checkLeapYear(year) { return ( year % 400 == 0 || @@ -30,7 +40,27 @@ function getPrevMonthDays(month, year) { return 31; } -export function getCalendarLayout(dateObject, highlight) { +function getDateInXMonthsTime(x) { + var currentDate = new Date(); // Get the current date + if (x == 0) return currentDate; // If x is 0, return the current date + + var targetMonth = currentDate.getMonth() + x; // Calculate the target month + var targetYear = currentDate.getFullYear(); // Get the current year + + // Adjust the year and month if necessary + targetYear += Math.floor(targetMonth / 12); + targetMonth = (targetMonth % 12 + 12) % 12; + + // Create a new date object with the target year and month + var targetDate = new Date(targetYear, targetMonth, 1); + + // Set the day to the last day of the month to get the desired date + // targetDate.setDate(0); + + return targetDate; +} + +function getCalendarLayout(dateObject, highlight) { if (!dateObject) dateObject = new Date(); const weekday = (dateObject.getDay() + 6) % 7; // MONDAY IS THE FIRST DAY OF THE WEEK const day = dateObject.getDate(); diff --git a/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml b/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml new file mode 100644 index 000000000..491b7683f --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml @@ -0,0 +1,119 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets + +Item { + id: root + + NotificationListView { // Scrollable window + id: listview + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: statusRow.top + anchors.bottomMargin: 5 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: listview.width + height: listview.height + radius: Appearance.rounding.normal + } + } + + popup: false + } + + // Placeholder when list is empty + Item { + anchors.fill: listview + + visible: opacity > 0 + opacity: (Notifications.list.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: "notifications_active" + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: qsTr("No notifications") + } + } + } + + Item { + id: statusRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + Layout.fillWidth: true + implicitHeight: Math.max( + controls.implicitHeight, + statusText.implicitHeight + ) + + StyledText { + id: statusText + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + horizontalAlignment: Text.AlignHCenter + text: `${Notifications.list.length} notifications` + + opacity: Notifications.list.length > 0 ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + ButtonGroup { + id: controls + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: 5 + + NotificationStatusButton { + buttonIcon: "notifications_paused" + buttonText: qsTr("Silent") + toggled: Notifications.silent + onClicked: () => { + Notifications.silent = !Notifications.silent; + } + } + NotificationStatusButton { + buttonIcon: "clear_all" + buttonText: qsTr("Clear") + onClicked: () => { + Notifications.discardAllNotifications() + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/notifications/NotificationStatusButton.qml b/.config/quickshell/modules/sidebarRight/notifications/NotificationStatusButton.qml new file mode 100644 index 000000000..bcfeaecfd --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/notifications/NotificationStatusButton.qml @@ -0,0 +1,44 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +GroupButton { + id: button + property string buttonText: "" + property string buttonIcon: "" + + baseWidth: content.implicitWidth + 10 * 2 + baseHeight: 30 + + buttonRadius: baseHeight / 2 + buttonRadiusPressed: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colBackgroundActive: Appearance.colors.colLayer2Active + property color colText: toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + + contentItem: Item { + id: content + anchors.fill: parent + implicitWidth: contentRowLayout.implicitWidth + implicitHeight: contentRowLayout.implicitHeight + RowLayout { + id: contentRowLayout + anchors.centerIn: parent + spacing: 5 + MaterialSymbol { + text: buttonIcon + iconSize: Appearance.font.pixelSize.large + color: button.colText + } + StyledText { + text: buttonText + font.pixelSize: Appearance.font.pixelSize.small + color: button.colText + } + } + } + +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/quickToggles/BluetoothToggle.qml b/.config/quickshell/modules/sidebarRight/quickToggles/BluetoothToggle.qml new file mode 100644 index 000000000..083ecc036 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/quickToggles/BluetoothToggle.qml @@ -0,0 +1,36 @@ +import "../" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +QuickToggleButton { + toggled: Bluetooth.bluetoothEnabled + buttonIcon: Bluetooth.bluetoothConnected ? "bluetooth_connected" : Bluetooth.bluetoothEnabled ? "bluetooth" : "bluetooth_disabled" + onClicked: { + toggleBluetooth.running = true + } + altAction: () => { + Hyprland.dispatch(`exec ${ConfigOptions.apps.bluetooth}`) + Hyprland.dispatch("global quickshell:sidebarRightClose") + } + Process { + id: toggleBluetooth + command: ["bash", "-c", `bluetoothctl power ${Bluetooth.bluetoothEnabled ? "off" : "on"}`] + onRunningChanged: { + if(!running) { + Bluetooth.update() + } + } + } + StyledToolTip { + content: StringUtils.format(qsTr("{0} | Right-click to configure"), + (Bluetooth.bluetoothEnabled && Bluetooth.bluetoothDeviceName.length > 0) ? + Bluetooth.bluetoothDeviceName : qsTr("Bluetooth")) + + } +} diff --git a/.config/quickshell/modules/sidebarRight/quickToggles/GameMode.qml b/.config/quickshell/modules/sidebarRight/quickToggles/GameMode.qml new file mode 100644 index 000000000..1cd56acc7 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/quickToggles/GameMode.qml @@ -0,0 +1,26 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "../" +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +QuickToggleButton { + property bool enabled: false + buttonIcon: "gamepad" + toggled: enabled + + onClicked: { + enabled = !enabled + if (enabled) { + // gameModeOn.running = true + Hyprland.dispatch(`exec 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; keyword general:allow_tearing 1"`) + } else { + Hyprland.dispatch("exec hyprctl reload") + } + } + + StyledToolTip { + content: qsTr("Game mode") + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/quickToggles/IdleInhibitor.qml b/.config/quickshell/modules/sidebarRight/quickToggles/IdleInhibitor.qml new file mode 100644 index 000000000..b48d3467f --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/quickToggles/IdleInhibitor.qml @@ -0,0 +1,32 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "../" +import Quickshell.Io +import Quickshell +import Quickshell.Hyprland + +QuickToggleButton { + id: root + toggled: false + buttonIcon: "coffee" + onClicked: { + if (toggled) { + root.toggled = false + Hyprland.dispatch("exec pkill wayland-idle") // pkill doesn't accept too long names + } else { + root.toggled = true + Hyprland.dispatch('exec ${XDG_CONFIG_HOME:-$HOME/.config}/quickshell/scripts/wayland-idle-inhibitor.py') + } + } + Process { + id: fetchActiveState + running: true + command: ["bash", "-c", "pidof wayland-idle-inhibitor.py"] + onExited: (exitCode, exitStatus) => { + root.toggled = exitCode === 0 + } + } + StyledToolTip { + content: qsTr("Keep system awake") + } +} diff --git a/.config/quickshell/modules/sidebarRight/quickToggles/NetworkToggle.qml b/.config/quickshell/modules/sidebarRight/quickToggles/NetworkToggle.qml new file mode 100644 index 000000000..5271e3769 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/quickToggles/NetworkToggle.qml @@ -0,0 +1,33 @@ +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "../" +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +QuickToggleButton { + toggled: Network.networkName.length > 0 && Network.networkName != "lo" + buttonIcon: Network.materialSymbol + onClicked: { + toggleNetwork.running = true + } + altAction: () => { + Hyprland.dispatch(`exec ${Network.ethernet ? ConfigOptions.apps.networkEthernet : ConfigOptions.apps.network}`) + Hyprland.dispatch("global quickshell:sidebarRightClose") + } + Process { + id: toggleNetwork + command: ["bash", "-c", "nmcli radio wifi | grep -q enabled && nmcli radio wifi off || nmcli radio wifi on"] + onRunningChanged: { + if(!running) { + Network.update() + } + } + } + StyledToolTip { + content: StringUtils.format(qsTr("{0} | Right-click to configure"), Network.networkName) + } +} diff --git a/.config/quickshell/modules/sidebarRight/quickToggles/NightLight.qml b/.config/quickshell/modules/sidebarRight/quickToggles/NightLight.qml new file mode 100644 index 000000000..72df3e1ef --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/quickToggles/NightLight.qml @@ -0,0 +1,42 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "../" +import Quickshell.Io +import Quickshell + +QuickToggleButton { + id: nightLightButton + property bool enabled: false + toggled: enabled + buttonIcon: "nightlight" + onClicked: { + nightLightButton.enabled = !nightLightButton.enabled + if (enabled) { + nightLightOn.startDetached() + } + else { + nightLightOff.startDetached() + } + } + Process { + id: nightLightOn + command: ["gammastep"] + } + Process { + id: nightLightOff + command: ["pkill", "gammastep"] + } + Process { + id: updateNightLightState + running: true + command: ["pidof", "gammastep"] + stdout: SplitParser { + onRead: (data) => { // if not empty then set toggled to true + nightLightButton.enabled = data.length > 0 + } + } + } + StyledToolTip { + content: qsTr("Night Light") + } +} diff --git a/.config/quickshell/modules/sidebarRight/quickToggles/QuickToggleButton.qml b/.config/quickshell/modules/sidebarRight/quickToggles/QuickToggleButton.qml new file mode 100644 index 000000000..c80f91ba6 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/quickToggles/QuickToggleButton.qml @@ -0,0 +1,33 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io + +GroupButton { + id: button + property string buttonIcon + baseWidth: altAction ? 60 : 40 + baseHeight: 40 + clickedWidth: baseWidth + 20 + toggled: false + buttonRadius: (altAction && toggled) ? Appearance?.rounding.normal : Math.min(baseHeight, baseWidth) / 2 + buttonRadiusPressed: Appearance?.rounding?.small + + contentItem: MaterialSymbol { + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.larger + fill: toggled ? 1 : 0 + color: toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: buttonIcon + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/.config/quickshell/modules/sidebarRight/todo/TaskList.qml b/.config/quickshell/modules/sidebarRight/todo/TaskList.qml new file mode 100644 index 000000000..b727ab1cd --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/todo/TaskList.qml @@ -0,0 +1,180 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Item { + id: root + required property var taskList; + property string emptyPlaceholderIcon + property string emptyPlaceholderText + property int todoListItemSpacing: 5 + property int todoListItemPadding: 8 + property int listBottomPadding: 80 + + Flickable { + id: flickable + anchors.fill: parent + contentHeight: columnLayout.height + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: flickable.width + height: flickable.height + radius: Appearance.rounding.small + } + } + + ColumnLayout { + id: columnLayout + width: parent.width + spacing: 0 + Repeater { + model: ScriptModel { + values: taskList + } + delegate: Item { + id: todoItem + property bool pendingDoneToggle: false + property bool pendingDelete: false + property bool enableHeightAnimation: false + + Layout.fillWidth: true + implicitHeight: todoItemRectangle.implicitHeight + todoListItemSpacing + height: implicitHeight + clip: true + + Behavior on implicitHeight { + enabled: enableHeightAnimation + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + function startAction() { + enableHeightAnimation = true + todoItem.implicitHeight = 0 + actionTimer.start() + } + + Timer { + id: actionTimer + interval: Appearance.animation.elementMoveFast.duration + repeat: false + onTriggered: { + if (todoItem.pendingDelete) { + Todo.deleteItem(modelData.originalIndex) + } else if (todoItem.pendingDoneToggle) { + if (!modelData.done) Todo.markDone(modelData.originalIndex) + else Todo.markUnfinished(modelData.originalIndex) + } + } + } + + Rectangle { + id: todoItemRectangle + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: todoContentRowLayout.implicitHeight + color: Appearance.colors.colLayer2 + radius: Appearance.rounding.small + ColumnLayout { + id: todoContentRowLayout + anchors.left: parent.left + anchors.right: parent.right + + StyledText { + Layout.fillWidth: true // Needed for wrapping + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.topMargin: todoListItemPadding + id: todoContentText + text: modelData.content + wrapMode: Text.Wrap + } + RowLayout { + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.bottomMargin: todoListItemPadding + Item { + Layout.fillWidth: true + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + todoItem.pendingDoneToggle = true + todoItem.startAction() + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: modelData.done ? "remove_done" : "check" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + todoItem.pendingDelete = true + todoItem.startAction() + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "delete_forever" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + } + } + } + } + + } + // Bottom padding + Item { + implicitHeight: listBottomPadding + } + } + } + + Item { // Placeholder when list is empty + visible: opacity > 0 + opacity: taskList.length === 0 ? 1 : 0 + anchors.fill: parent + + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: emptyPlaceholderIcon + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: emptyPlaceholderText + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/todo/TodoItemActionButton.qml b/.config/quickshell/modules/sidebarRight/todo/TodoItemActionButton.qml new file mode 100644 index 000000000..e013a18bc --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/todo/TodoItemActionButton.qml @@ -0,0 +1,35 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RippleButton { + id: button + property string buttonText: "" + property string tooltipText: "" + + implicitHeight: 30 + implicitWidth: implicitHeight + + Behavior on implicitWidth { + SmoothedAnimation { + velocity: Appearance.animation.elementMove.velocity + } + } + + buttonRadius: Appearance.rounding.small + + contentItem: StyledText { + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + + StyledToolTip { + content: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/todo/TodoWidget.qml b/.config/quickshell/modules/sidebarRight/todo/TodoWidget.qml new file mode 100644 index 000000000..9f5f4fd08 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/todo/TodoWidget.qml @@ -0,0 +1,308 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts + +Item { + id: root + property int currentTab: 0 + property var tabButtonList: [{"icon": "checklist", "name": qsTr("Unfinished")}, {"name": qsTr("Done"), "icon": "check_circle"}] + property bool showAddDialog: false + property int dialogMargins: 20 + property int fabSize: 48 + property int fabMargins: 14 + + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) && event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageDown) { + currentTab = Math.min(currentTab + 1, root.tabButtonList.length - 1) + } else if (event.key === Qt.Key_PageUp) { + currentTab = Math.max(currentTab - 1, 0) + } + event.accepted = true; + } + // Open add dialog on "N" (any modifiers) + else if (event.key === Qt.Key_N) { + root.showAddDialog = true + event.accepted = true; + } + // Close dialog on Esc if open + else if (event.key === Qt.Key_Escape && root.showAddDialog) { + root.showAddDialog = false + event.accepted = true; + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: currentTab + onCurrentIndexChanged: currentTab = currentIndex + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: SecondaryTabButton { + selected: (index == currentTab) + buttonText: modelData.name + buttonIcon: modelData.icon + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + property bool enableIndicatorAnimation: false + Connections { + target: root + function onCurrentTabChanged() { + tabIndicator.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem.children[0].children[tabBar.currentIndex].tabContentWidth + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: Appearance.colors.colPrimary + radius: Appearance.rounding.full + + Behavior on x { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + SwipeView { + id: swipeView + Layout.topMargin: 10 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + clip: true + currentIndex: currentTab + onCurrentIndexChanged: { + tabIndicator.enableIndicatorAnimation = true + currentTab = currentIndex + } + + // To Do tab + TaskList { + listBottomPadding: root.fabSize + root.fabMargins * 2 + emptyPlaceholderIcon: "check_circle" + emptyPlaceholderText: qsTr("Nothing here!") + taskList: Todo.list + .map(function(item, i) { return Object.assign({}, item, {originalIndex: i}); }) + .filter(function(item) { return !item.done; }) + } + TaskList { + listBottomPadding: root.fabSize + root.fabMargins * 2 + emptyPlaceholderIcon: "checklist" + emptyPlaceholderText: qsTr("Finished tasks will go here") + taskList: Todo.list + .map(function(item, i) { return Object.assign({}, item, {originalIndex: i}); }) + .filter(function(item) { return item.done; }) + } + + } + } + + // + FAB + StyledRectangularShadow { + target: fabButton + radius: Appearance.rounding.normal + } + Button { + id: fabButton + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: root.fabMargins + anchors.bottomMargin: root.fabMargins + width: root.fabSize + height: root.fabSize + PointingHandInteraction {} + + onClicked: root.showAddDialog = true + + background: Rectangle { + id: fabBackground + anchors.fill: parent + radius: Appearance.rounding.normal + color: (fabButton.down) ? Appearance.colors.colPrimaryContainerActive : (fabButton.hovered ? Appearance.colors.colPrimaryContainerHover : Appearance.colors.colPrimaryContainer) + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + contentItem: MaterialSymbol { + text: "add" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.huge + color: Appearance.m3colors.m3onPrimaryContainer + } + } + + Item { + anchors.fill: parent + z: 9999 + + visible: opacity > 0 + opacity: root.showAddDialog ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + onVisibleChanged: { + if (!visible) { + todoInput.text = "" + fabButton.focus = true + } + } + + Rectangle { // Scrim + anchors.fill: parent + radius: Appearance.rounding.small + color: Appearance.colors.colScrim + MouseArea { + hoverEnabled: true + anchors.fill: parent + preventStealing: true + propagateComposedEvents: false + } + } + + Rectangle { // The dialog + id: dialog + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: root.dialogMargins + implicitHeight: dialogColumnLayout.implicitHeight + + color: Appearance.colors.colSurfaceContainerHigh + radius: Appearance.rounding.normal + + function addTask() { + if (todoInput.text.length > 0) { + Todo.addTask(todoInput.text) + todoInput.text = "" + root.showAddDialog = false + root.currentTab = 0 // Show unfinished tasks + } + } + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: qsTr("Add task") + } + + TextField { + id: todoInput + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderText: qsTr("Task description") + placeholderTextColor: Appearance.m3colors.m3outline + focus: root.showAddDialog + onAccepted: dialog.addTask() + + background: Rectangle { + anchors.fill: parent + radius: Appearance.rounding.verysmall + border.width: 2 + border.color: todoInput.activeFocus ? Appearance.colors.colPrimary : Appearance.m3colors.m3outline + color: "transparent" + } + + cursorDelegate: Rectangle { + width: 1 + color: todoInput.activeFocus ? Appearance.colors.colPrimary : "transparent" + radius: 1 + } + } + + RowLayout { + Layout.bottomMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.alignment: Qt.AlignRight + spacing: 5 + + DialogButton { + buttonText: qsTr("Cancel") + onClicked: root.showAddDialog = false + } + DialogButton { + buttonText: qsTr("Add") + enabled: todoInput.text.length > 0 + onClicked: dialog.addTask() + } + } + } + } + } +} diff --git a/.config/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml b/.config/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml new file mode 100644 index 000000000..cc956cc03 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml @@ -0,0 +1,55 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets +import Quickshell.Services.Pipewire + +GroupButton { + id: button + required property bool input + + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colBackgroundActive: Appearance.colors.colLayer2Active + clickedWidth: baseWidth + 30 + + contentItem: RowLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: false + Layout.leftMargin: 5 + color: Appearance.colors.colOnLayer2 + iconSize: Appearance.font.pixelSize.hugeass + text: input ? "mic_external_on" : "media_output" + } + + ColumnLayout { + Layout.fillWidth: true + Layout.rightMargin: 5 + spacing: 0 + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.normal + text: input ? qsTr("Input") : qsTr("Output") + color: Appearance.colors.colOnLayer2 + } + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.smaller + text: (input ? Pipewire.defaultAudioSource?.description : Pipewire.defaultAudioSink?.description) ?? qsTr("Unknown") + color: Appearance.m3colors.m3outline + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml b/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml new file mode 100644 index 000000000..2e1570f30 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml @@ -0,0 +1,288 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Pipewire + + +Item { + id: root + property bool showDeviceSelector: false + property bool deviceSelectorInput + property int dialogMargins: 16 + property PwNode selectedDevice + + function showDeviceSelectorDialog(input: bool) { + root.selectedDevice = null + root.showDeviceSelector = true + root.deviceSelectorInput = input + } + + Keys.onPressed: (event) => { + // Close dialog on pressing Esc if open + if (event.key === Qt.Key_Escape && root.showDeviceSelector) { + root.showDeviceSelector = false + event.accepted = true; + } + } + + ColumnLayout { + anchors.fill: parent + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Flickable { + id: flickable + anchors.fill: parent + contentHeight: volumeMixerColumnLayout.height + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: flickable.width + height: flickable.height + radius: Appearance.rounding.normal + } + } + + ColumnLayout { + id: volumeMixerColumnLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 10 + spacing: 10 + + // Get a list of nodes that output to the default sink + PwNodeLinkTracker { + id: linkTracker + node: Pipewire.defaultAudioSink + } + + Repeater { + model: linkTracker.linkGroups + + VolumeMixerEntry { + Layout.fillWidth: true + // Get links to the default sinnk + required property PwLinkGroup modelData + // Consider sources that output to the default sink + node: modelData.source + } + } + } + } + + // Placeholder when list is empty + Item { + anchors.fill: flickable + + visible: opacity > 0 + opacity: (linkTracker.linkGroups.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: "brand_awareness" + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: qsTr("No audio source") + } + } + } + } + // Device selector + ButtonGroup { + id: deviceSelectorRowLayout + Layout.fillWidth: true + Layout.fillHeight: false + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: false + onClicked: root.showDeviceSelectorDialog(input) + } + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: true + onClicked: root.showDeviceSelectorDialog(input) + } + } + } + + // Device selector dialog + Item { + anchors.fill: parent + z: 9999 + + visible: opacity > 0 + opacity: root.showDeviceSelector ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + Rectangle { // Scrim + id: scrimOverlay + anchors.fill: parent + radius: Appearance.rounding.small + color: Appearance.colors.colScrim + MouseArea { + hoverEnabled: true + anchors.fill: parent + preventStealing: true + propagateComposedEvents: false + } + } + + Rectangle { // The dialog + id: dialog + color: Appearance.colors.colSurfaceContainerHigh + radius: Appearance.rounding.normal + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 30 + implicitHeight: dialogColumnLayout.implicitHeight + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + id: dialogTitle + Layout.topMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: `Select ${root.deviceSelectorInput ? "input" : "output"} device` + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } + + Flickable { + id: dialogFlickable + Layout.fillWidth: true + clip: true + implicitHeight: Math.min(scrimOverlay.height - dialogMargins * 8 - dialogTitle.height - dialogButtonsRowLayout.height, devicesColumnLayout.implicitHeight) + + contentHeight: devicesColumnLayout.implicitHeight + + ColumnLayout { + id: devicesColumnLayout + anchors.fill: parent + Layout.fillWidth: true + spacing: 0 + + Repeater { + model: ScriptModel { + values: Pipewire.nodes.values.filter(node => { + return !node.isStream && node.isSink !== root.deviceSelectorInput && node.audio + }) + } + + // This could and should be refractored, but all data becomes null when passed wtf + delegate: StyledRadioButton { + id: radioButton + required property var modelData + Layout.leftMargin: root.dialogMargins + Layout.rightMargin: root.dialogMargins + Layout.fillWidth: true + + description: modelData.description + checked: modelData.id === Pipewire.defaultAudioSink?.id + + Connections { + target: root + function onShowDeviceSelectorChanged() { + if(!root.showDeviceSelector) return; + radioButton.checked = (modelData.id === Pipewire.defaultAudioSink?.id) + } + } + + onCheckedChanged: { + if (checked) { + root.selectedDevice = modelData + } + } + } + } + Item { + implicitHeight: dialogMargins + } + } + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } + + RowLayout { + id: dialogButtonsRowLayout + Layout.bottomMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignRight + + DialogButton { + buttonText: qsTr("Cancel") + onClicked: { + root.showDeviceSelector = false + } + } + DialogButton { + buttonText: qsTr("OK") + onClicked: { + root.showDeviceSelector = false + if (root.selectedDevice) { + if (root.deviceSelectorInput) { + Pipewire.preferredDefaultAudioSource = root.selectedDevice + } else { + Pipewire.preferredDefaultAudioSink = root.selectedDevice + } + } + } + } + } + } + } + } + +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml b/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml new file mode 100644 index 000000000..c4600c7b8 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml @@ -0,0 +1,65 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Pipewire + +Item { + id: root + required property PwNode node; + PwObjectTracker { objects: [ node ] } + + implicitHeight: rowLayout.implicitHeight + + RowLayout { + id: rowLayout + anchors.fill: parent + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + RowLayout { + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.normal + elide: Text.ElideRight + text: { + // application.name -> description -> name + const app = root.node.properties["application.name"] ?? (root.node.description != "" ? root.node.description : root.node.name); + const media = root.node.properties["media.name"]; + return media != undefined ? `${app} • ${media}` : app; + } + } + } + + RowLayout { + Image { + property real size: slider.trackHeight * 1.3 + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + visible: source != "" + sourceSize.width: size + sourceSize.height: size + source: { + let icon; + icon = AppSearch.guessIcon(root.node.properties["application.icon-name"]); + if (AppSearch.iconExists(icon)) return Quickshell.iconPath(icon, "image-missing"); + icon = AppSearch.guessIcon(root.node.properties["node.name"]); + return Quickshell.iconPath(icon, "image-missing"); + } + } + StyledSlider { + id: slider + value: root.node.audio.volume + onValueChanged: root.node.audio.volume = value + } + } + } + } +} \ No newline at end of file diff --git a/.config/ags/scripts/ai/show-installed-ollama-models.sh b/.config/quickshell/scripts/ai/show-installed-ollama-models.sh similarity index 100% rename from .config/ags/scripts/ai/show-installed-ollama-models.sh rename to .config/quickshell/scripts/ai/show-installed-ollama-models.sh diff --git a/.config/quickshell/scripts/applycolor.sh b/.config/quickshell/scripts/applycolor.sh new file mode 100755 index 000000000..58fad4ce3 --- /dev/null +++ b/.config/quickshell/scripts/applycolor.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +term_alpha=100 #Set this to < 100 make all your terminals transparent +# sleep 0 # idk i wanted some delay or colors dont get applied properly +if [ ! -d "$STATE_DIR"/user/generated ]; then + mkdir -p "$STATE_DIR"/user/generated +fi +cd "$CONFIG_DIR" || exit + +colornames='' +colorstrings='' +colorlist=() +colorvalues=() + +colornames=$(cat $STATE_DIR/user/generated/material_colors.scss | cut -d: -f1) +colorstrings=$(cat $STATE_DIR/user/generated/material_colors.scss | cut -d: -f2 | cut -d ' ' -f2 | cut -d ";" -f1) +IFS=$'\n' +colorlist=($colornames) # Array of color names +colorvalues=($colorstrings) # Array of color values + +apply_term() { + # Check if terminal escape sequence template exists + if [ ! -f "$SCRIPT_DIR"/terminal/sequences.txt ]; then + echo "Template file not found for Terminal. Skipping that." + return + fi + # Copy template + mkdir -p "$STATE_DIR"/user/generated/terminal + cp "$SCRIPT_DIR"/terminal/sequences.txt "$STATE_DIR"/user/generated/terminal/sequences.txt + # Apply colors + for i in "${!colorlist[@]}"; do + sed -i "s/${colorlist[$i]} #/${colorvalues[$i]#\#}/g" "$STATE_DIR"/user/generated/terminal/sequences.txt + done + + sed -i "s/\$alpha/$term_alpha/g" "$STATE_DIR/user/generated/terminal/sequences.txt" + + for file in /dev/pts/*; do + if [[ $file =~ ^/dev/pts/[0-9]+$ ]]; then + { + cat "$STATE_DIR"/user/generated/terminal/sequences.txt >"$file" + } & disown || true + fi + done +} + +apply_qt() { + sh "$CONFIG_DIR/scripts/kvantum/materialQT.sh" # generate kvantum theme + python "$CONFIG_DIR/scripts/kvantum/changeAdwColors.py" # apply config colors +} + +apply_ags() { + pidof agsv1 && agsv1 run-js "handleStyles(false);" + pidof agsv1 && agsv1 run-js 'openColorScheme.value = true; Utils.timeout(2000, () => openColorScheme.value = false);' +} + +apply_ags & +apply_qt & +apply_term & diff --git a/.config/quickshell/scripts/cava/raw_output_config.txt b/.config/quickshell/scripts/cava/raw_output_config.txt new file mode 100644 index 000000000..7760e4ea2 --- /dev/null +++ b/.config/quickshell/scripts/cava/raw_output_config.txt @@ -0,0 +1,17 @@ +[general] +mode = waves +framerate = 60 +autosens = 1 +bars = 50 + +[output] +method = raw +raw_target = /dev/stdout +data_format = ascii +channels = mono +mono_option = average + +[smoothing] +noise_reduction = 20 + + diff --git a/.config/ags/scripts/color_generation/generate_colors_material.py b/.config/quickshell/scripts/generate_colors_material.py similarity index 95% rename from .config/ags/scripts/color_generation/generate_colors_material.py rename to .config/quickshell/scripts/generate_colors_material.py index 3755ae571..db6b1664b 100755 --- a/.config/ags/scripts/color_generation/generate_colors_material.py +++ b/.config/quickshell/scripts/generate_colors_material.py @@ -87,27 +87,26 @@ elif args.color is not None: argb = hex_to_argb(args.color) hct = Hct.from_int(argb) -if args.scheme == 'fruitsalad': +if args.scheme == 'scheme-fruit-salad': from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad as Scheme -elif args.scheme == 'expressive': +elif args.scheme == 'scheme-expressive': from materialyoucolor.scheme.scheme_expressive import SchemeExpressive as Scheme -elif args.scheme == 'monochrome': +elif args.scheme == 'scheme-monochrome': from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome as Scheme -elif args.scheme == 'rainbow': +elif args.scheme == 'scheme-rainbow': from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow as Scheme -elif args.scheme == 'tonalspot': +elif args.scheme == 'scheme-tonal-spot': from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot as Scheme -elif args.scheme == 'neutral': +elif args.scheme == 'scheme-neutral': from materialyoucolor.scheme.scheme_neutral import SchemeNeutral as Scheme -elif args.scheme == 'fidelity': +elif args.scheme == 'scheme-fidelity': from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity as Scheme -elif args.scheme == 'content': +elif args.scheme == 'scheme-content': from materialyoucolor.scheme.scheme_content import SchemeContent as Scheme -elif args.scheme == 'vibrant': +elif args.scheme == 'scheme-vibrant': from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant as Scheme else: - from schemes.scheme_morevibrant import SchemeMoreVibrant as Scheme - + from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot as Scheme # Generate scheme = Scheme(hct, darkmode, 0.0) diff --git a/.config/ags/scripts/hyprland/get_keybinds.py b/.config/quickshell/scripts/hyprland/get_keybinds.py similarity index 100% rename from .config/ags/scripts/hyprland/get_keybinds.py rename to .config/quickshell/scripts/hyprland/get_keybinds.py diff --git a/.config/ags/scripts/kvantum/adwsvg.py b/.config/quickshell/scripts/kvantum/adwsvg.py similarity index 96% rename from .config/ags/scripts/kvantum/adwsvg.py rename to .config/quickshell/scripts/kvantum/adwsvg.py index 7f2ae8e2c..10ce1d150 100644 --- a/.config/ags/scripts/kvantum/adwsvg.py +++ b/.config/quickshell/scripts/kvantum/adwsvg.py @@ -38,7 +38,7 @@ def main(): xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) - scss_file = os.path.join(xdg_state_home, "ags", "scss", "_material.scss") + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") svg_path = os.path.join(xdg_config_home, "Kvantum", "Colloid", "Colloid.svg") output_path = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.svg") diff --git a/.config/ags/scripts/kvantum/adwsvgDark.py b/.config/quickshell/scripts/kvantum/adwsvgDark.py similarity index 97% rename from .config/ags/scripts/kvantum/adwsvgDark.py rename to .config/quickshell/scripts/kvantum/adwsvgDark.py index 5e09d8360..9fb097740 100644 --- a/.config/ags/scripts/kvantum/adwsvgDark.py +++ b/.config/quickshell/scripts/kvantum/adwsvgDark.py @@ -38,7 +38,7 @@ def main(): xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) - scss_file = os.path.join(xdg_state_home, "ags", "scss", "_material.scss") + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") svg_path = os.path.join(xdg_config_home, "Kvantum", "Colloid", "ColloidDark.svg") output_path = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.svg") diff --git a/.config/ags/scripts/kvantum/changeAdwColors.py b/.config/quickshell/scripts/kvantum/changeAdwColors.py similarity index 96% rename from .config/ags/scripts/kvantum/changeAdwColors.py rename to .config/quickshell/scripts/kvantum/changeAdwColors.py index a7d1e6b9d..26d067ad5 100644 --- a/.config/ags/scripts/kvantum/changeAdwColors.py +++ b/.config/quickshell/scripts/kvantum/changeAdwColors.py @@ -32,7 +32,7 @@ if __name__ == "__main__": xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) config_file = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.kvconfig") - scss_file = os.path.join(xdg_state_home, "ags", "scss", "_material.scss") + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") # Define your mappings here mappings = { diff --git a/.config/ags/scripts/kvantum/materialQT.sh b/.config/quickshell/scripts/kvantum/materialQT.sh similarity index 77% rename from .config/ags/scripts/kvantum/materialQT.sh rename to .config/quickshell/scripts/kvantum/materialQT.sh index 7495fad37..3d1f8a7bf 100755 --- a/.config/ags/scripts/kvantum/materialQT.sh +++ b/.config/quickshell/scripts/kvantum/materialQT.sh @@ -3,18 +3,17 @@ XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" -CONFIG_DIR="$XDG_CONFIG_HOME/ags" -CACHE_DIR="$XDG_CACHE_HOME/ags" -STATE_DIR="$XDG_STATE_HOME/ags" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" get_light_dark() { - lightdark="" - if [ ! -f "$STATE_DIR/user/colormode.txt" ]; then - echo "" >"$STATE_DIR/user/colormode.txt" + current_mode=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null | tr -d "'") + if [[ "$current_mode" == "prefer-dark" ]]; then + echo "dark" else - lightdark=$(sed -n '1p' "$STATE_DIR/user/colormode.txt") + echo "light" fi - echo "$lightdark" } apply_qt() { diff --git a/.config/quickshell/scripts/switchwall.sh b/.config/quickshell/scripts/switchwall.sh new file mode 100755 index 000000000..dffd9bd61 --- /dev/null +++ b/.config/quickshell/scripts/switchwall.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash + +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MATUGEN_DIR="$XDG_CONFIG_HOME/matugen" +terminalscheme="$XDG_CONFIG_HOME/quickshell/scripts/terminal/scheme-base.json" + +pre_process() { + local mode_flag="$1" + # Set GNOME color-scheme if mode_flag is dark or light + if [[ "$mode_flag" == "dark" ]]; then + gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' + gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3-dark' + elif [[ "$mode_flag" == "light" ]]; then + gsettings set org.gnome.desktop.interface color-scheme 'prefer-light' + gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3' + fi + + if [ ! -d "$CACHE_DIR"/user/generated ]; then + mkdir -p "$CACHE_DIR"/user/generated + fi +} + +post_process() { + local screen_width="$1" + local screen_height="$2" + local wallpaper_path="$3" + + # Determine the largest region on the wallpaper that's sufficiently un-busy to put widgets in + if [ ! -f "$MATUGEN_DIR/scripts/least_busy_region.py" ]; then + echo "Error: least_busy_region.py script not found in $MATUGEN_DIR/scripts/" + else + "$MATUGEN_DIR/scripts/least_busy_region.py" \ + --screen-width "$screen_width" --screen-height "$screen_height" \ + --width 300 --height 200 \ + "$wallpaper_path" > "$STATE_DIR"/user/generated/wallpaper/least_busy_region.json + fi +} + +check_and_prompt_upscale() { + local img="$1" + min_width_desired="$(hyprctl monitors -j | jq '([.[].width] | max)' | xargs)" # max monitor width + min_height_desired="$(hyprctl monitors -j | jq '([.[].height] | max)' | xargs)" # max monitor height + + if command -v identify &>/dev/null && [ -f "$img" ]; then + local img_width img_height + if is_video "$img"; then # Not check resolution for videos, just let em pass + img_width=$min_width_desired + img_height=$min_height_desired + else + img_width=$(identify -format "%w" "$img" 2>/dev/null) + img_height=$(identify -format "%h" "$img" 2>/dev/null) + fi + if [[ "$img_width" -lt "$min_width_desired" || "$img_height" -lt "$min_height_desired" ]]; then + action=$(notify-send "Upscale?" \ + "Image resolution (${img_width}x${img_height}) is lower than screen resolution (${min_width_desired}x${min_height_desired})" \ + -A "open_upscayl=Open Upscayl"\ + -a "Wallpaper switcher") + if [[ "$action" == "open_upscayl" ]]; then + if command -v upscayl &>/dev/null; then + nohup upscayl > /dev/null 2>&1 & + else + action2=$(notify-send \ + -a "Wallpaper switcher" \ + -c "im.error" \ + -A "install_upscayl=Install Upscayl (Arch)" \ + "Install Upscayl?" \ + "yay -S upscayl-bin") + if [[ "$action2" == "install_upscayl" ]]; then + kitty -1 yay -S upscayl-bin + if command -v upscayl &>/dev/null; then + nohup upscayl > /dev/null 2>&1 & + fi + fi + fi + fi + fi + fi +} + +THUMBNAIL_DIR="/tmp/mpvpaper_thumbnails" +CUSTOM_DIR="$XDG_CONFIG_HOME/hypr/custom" +RESTORE_SCRIPT_DIR="$CUSTOM_DIR/scripts" +RESTORE_SCRIPT="$RESTORE_SCRIPT_DIR/__restore_video_wallpaper.sh" +VIDEO_OPTS="no-audio loop hwdec=auto scale=bilinear interpolation=no video-sync=display-resample panscan=1.0 video-scale-x=1.0 video-scale-y=1.0 video-align-x=0.5 video-align-y=0.5" + +is_video() { + local extension="${1##*.}" + [[ "$extension" == "mp4" || "$extension" == "mkv" || "$extension" == "webm" ]] && return 0 || return 1 +} + +kill_existing_mpvpaper() { + pkill -f -9 mpvpaper || true +} + +create_restore_script() { + local video_path=$1 + cat > "$RESTORE_SCRIPT.tmp" << EOF +#!/bin/bash +# Generated by switchwall.sh - Don't modify it by yourself. +# Time: $(date) + +pkill -f -9 mpvpaper + +for monitor in \$(hyprctl monitors -j | jq -r '.[] | .name'); do + mpvpaper -o "$VIDEO_OPTS" "\$monitor" "$video_path" & + sleep 0.1 +done +EOF + mv "$RESTORE_SCRIPT.tmp" "$RESTORE_SCRIPT" + chmod +x "$RESTORE_SCRIPT" +} + +remove_restore() { + cat > "$RESTORE_SCRIPT.tmp" << EOF +#!/bin/bash +# The content of this script will be generated by switchwall.sh - Don't modify it by yourself. +EOF + mv "$RESTORE_SCRIPT.tmp" "$RESTORE_SCRIPT" +} + +switch() { + imgpath="$1" + mode_flag="$2" + type_flag="$3" + color_flag="$4" + color="$5" + read scale screenx screeny screensizey < <(hyprctl monitors -j | jq '.[] | select(.focused) | .scale, .x, .y, .height' | xargs) + cursorposx=$(hyprctl cursorpos -j | jq '.x' 2>/dev/null) || cursorposx=960 + cursorposx=$(bc <<< "scale=0; ($cursorposx - $screenx) * $scale / 1") + cursorposy=$(hyprctl cursorpos -j | jq '.y' 2>/dev/null) || cursorposy=540 + cursorposy=$(bc <<< "scale=0; ($cursorposy - $screeny) * $scale / 1") + cursorposy_inverted=$((screensizey - cursorposy)) + + if [[ "$color_flag" == "1" ]]; then + matugen_args=(color hex "$color") + generate_colors_material_args=(--color "$color") + else + if [[ -z "$imgpath" ]]; then + echo 'Aborted' + exit 0 + fi + + check_and_prompt_upscale "$imgpath" & + kill_existing_mpvpaper + + if is_video "$imgpath"; then + mkdir -p "$THUMBNAIL_DIR" + + missing_deps=() + if ! command -v mpvpaper &> /dev/null; then + missing_deps+=("mpvpaper") + fi + if ! command -v ffmpeg &> /dev/null; then + missing_deps+=("ffmpeg") + fi + if [ ${#missing_deps[@]} -gt 0 ]; then + echo "Missing deps: ${missing_deps[*]}" + echo "Arch: sudo pacman -S ${missing_deps[*]}" + action=$(notify-send \ + -a "Wallpaper switcher" \ + -c "im.error" \ + -A "install_arch=Install (Arch)" \ + "Can't switch to video wallpaper" \ + "Missing dependencies: ${missing_deps[*]}") + if [[ "$action" == "install_arch" ]]; then + kitty -1 sudo pacman -S "${missing_deps[*]}" + if command -v mpvpaper &>/dev/null && command -v ffmpeg &>/dev/null; then + notify-send 'Wallpaper switcher' 'Alright, try again!' -a "Wallpaper switcher" + fi + fi + exit 0 + fi + + local video_path="$imgpath" + monitors=$(hyprctl monitors -j | jq -r '.[] | .name') + for monitor in $monitors; do + mpvpaper -o "$VIDEO_OPTS" "$monitor" "$video_path" & + sleep 0.1 + done + + # Extract first frame for color generation + thumbnail="$THUMBNAIL_DIR/$(basename "$imgpath").jpg" + ffmpeg -y -i "$imgpath" -vframes 1 "$thumbnail" 2>/dev/null + + if [ -f "$thumbnail" ]; then + matugen_args=(image "$thumbnail") + generate_colors_material_args=(--path "$thumbnail") + create_restore_script "$video_path" + else + echo "Cannot create image to colorgen" + remove_restore + exit 1 + fi + else + matugen_args=(image "$imgpath") + generate_colors_material_args=(--path "$imgpath") + # Set wallpaper with swww + swww img "$imgpath" --transition-step 100 --transition-fps 120 \ + --transition-type grow --transition-angle 30 --transition-duration 1 \ + --transition-pos "$cursorposx, $cursorposy_inverted" + remove_restore + fi + fi + + # Determine mode if not set + if [[ -z "$mode_flag" ]]; then + current_mode=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null | tr -d "'") + if [[ "$current_mode" == "prefer-dark" ]]; then + mode_flag="dark" + else + mode_flag="light" + fi + fi + + [[ -n "$mode_flag" ]] && matugen_args+=(--mode "$mode_flag") && generate_colors_material_args+=(--mode "$mode_flag") + [[ -n "$type_flag" ]] && matugen_args+=(--type "$type_flag") && generate_colors_material_args+=(--scheme "$type_flag") + generate_colors_material_args+=(--termscheme "$terminalscheme" --blend_bg_fg) + generate_colors_material_args+=(--cache "$STATE_DIR/user/color.txt") + + pre_process "$mode_flag" + + matugen "${matugen_args[@]}" + source "$(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate" + python "$SCRIPT_DIR/generate_colors_material.py" "${generate_colors_material_args[@]}" \ + > "$STATE_DIR"/user/generated/material_colors.scss + "$SCRIPT_DIR"/applycolor.sh + deactivate + + # Pass screen width, height, and wallpaper path to post_process + max_width_desired="$(hyprctl monitors -j | jq '([.[].width] | min)' | xargs)" + max_height_desired="$(hyprctl monitors -j | jq '([.[].height] | min)' | xargs)" + post_process "$max_width_desired" "$max_height_desired" "$imgpath" +} + +main() { + imgpath="" + mode_flag="" + type_flag="" + color_flag="" + color="" + noswitch_flag="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + mode_flag="$2" + shift 2 + ;; + --type) + type_flag="$2" + shift 2 + ;; + --color) + color_flag="1" + if [[ "$2" =~ ^#?[A-Fa-f0-9]{6}$ ]]; then + color="$2" + shift 2 + else + color=$(hyprpicker --no-fancy) + shift + fi + ;; + --noswitch) + noswitch_flag="1" + imgpath=$(swww query | awk -F 'image: ' '{print $2}') + shift + ;; + *) + if [[ -z "$imgpath" ]]; then + imgpath="$1" + fi + shift + ;; + esac + done + + # Only prompt for wallpaper if not using --color and not using --noswitch and no imgpath set + if [[ -z "$imgpath" && -z "$color_flag" && -z "$noswitch_flag" ]]; then + cd "$(xdg-user-dir PICTURES)/Wallpapers" 2>/dev/null || cd "$(xdg-user-dir PICTURES)" || return 1 + imgpath="$(kdialog --getopenfilename . --title 'Choose wallpaper')" + fi + + switch "$imgpath" "$mode_flag" "$type_flag" "$color_flag" "$color" +} + +main "$@" diff --git a/.config/ags/scripts/templates/terminal/scheme-base.json b/.config/quickshell/scripts/terminal/scheme-base.json similarity index 100% rename from .config/ags/scripts/templates/terminal/scheme-base.json rename to .config/quickshell/scripts/terminal/scheme-base.json diff --git a/.config/ags/scripts/templates/terminal/sequences.txt b/.config/quickshell/scripts/terminal/sequences.txt similarity index 65% rename from .config/ags/scripts/templates/terminal/sequences.txt rename to .config/quickshell/scripts/terminal/sequences.txt index 40575f546..97459582e 100644 --- a/.config/ags/scripts/templates/terminal/sequences.txt +++ b/.config/quickshell/scripts/terminal/sequences.txt @@ -1 +1 @@ -]4;0;#$term0 #\]1;0;#$term0 #\]4;1;#$term1 #\]4;2;#$term2 #\]4;3;#$term3 #\]4;4;#$term4 #\]4;5;#$term5 #\]4;6;#$term6 #\]4;7;#$term7 #\]4;8;#$term8 #\]4;9;#$term9 #\]4;10;#$term10 #\]4;11;#$term11 #\]4;12;#$term12 #\]4;13;#$term13 #\]4;14;#$term14 #\]4;15;#$term15 #\]10;#$term7 #\]11;[$alpha]#$term0 #\]12;#$term7 #\]13;#$term7 #\]17;#$term7 #\]19;#$term0 #\]4;232;#$term7 #\]4;256;#$term7 #\]708;[$alpha]#$term0 #\]11;#$term0 #\ +]4;0;#$term0 #\]1;0;#$term0 #\]4;1;#$term1 #\]4;2;#$term2 #\]4;3;#$term3 #\]4;4;#$term4 #\]4;5;#$term5 #\]4;6;#$term6 #\]4;7;#$term7 #\]4;8;#$term8 #\]4;9;#$term9 #\]4;10;#$term10 #\]4;11;#$term11 #\]4;12;#$term12 #\]4;13;#$term13 #\]4;14;#$term14 #\]4;15;#$term15 #\]10;#$term7 #\]11;[100]#$term0 #\]12;#$term7 #\]13;#$term7 #\]17;#$term7 #\]19;#$term0 #\]4;232;#$term7 #\]4;256;#$term7 #\]708;[100]#$term0 #\]11;#$term0 #\ \ No newline at end of file diff --git a/.config/ags/scripts/wayland-idle-inhibitor.py b/.config/quickshell/scripts/wayland-idle-inhibitor.py similarity index 96% rename from .config/ags/scripts/wayland-idle-inhibitor.py rename to .config/quickshell/scripts/wayland-idle-inhibitor.py index ec74d09b1..9bdaabb04 100755 --- a/.config/ags/scripts/wayland-idle-inhibitor.py +++ b/.config/quickshell/scripts/wayland-idle-inhibitor.py @@ -1,4 +1,8 @@ #!/usr/bin/env -S\_/bin/sh\_-xc\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" + +# From https://github.com/stwa/wayland-idle-inhibitor +# License: WTFPL Version 2 + import sys from dataclasses import dataclass from signal import SIGINT, SIGTERM, signal diff --git a/.config/quickshell/services/Ai.qml b/.config/quickshell/services/Ai.qml new file mode 100644 index 000000000..dbb8869d7 --- /dev/null +++ b/.config/quickshell/services/Ai.qml @@ -0,0 +1,718 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/object_utils.js" as ObjectUtils +import "root:/modules/common" +import Quickshell; +import Quickshell.Io; +import Qt.labs.platform +import QtQuick; + +/** + * Basic service to handle LLM chats. Supports Google's and OpenAI's API formats. + */ +Singleton { + id: root + + readonly property string interfaceRole: "interface" + readonly property string apiKeyEnvVarName: "API_KEY" + property Component aiMessageComponent: AiMessageData {} + property string systemPrompt: ConfigOptions?.ai?.systemPrompt ?? "" + property var messages: [] + property var messageIDs: [] + property var messageByID: ({}) + readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {} + readonly property var apiKeysLoaded: KeyringStorage.loaded + property var postResponseHook + property real temperature: PersistentStates?.ai?.temperature ?? 0.5 + + function idForMessage(message) { + // Generate a unique ID using timestamp and random value + return Date.now().toString(36) + Math.random().toString(36).substr(2, 8); + } + + function safeModelName(modelName) { + return modelName.replace(/:/g, "_").replace(/\./g, "_") + } + + // Model properties: + // - name: Name of the model + // - icon: Icon name of the model + // - description: Description of the model + // - endpoint: Endpoint of the model + // - model: Model name of the model + // - requires_key: Whether the model requires an API key + // - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key. + // - key_get_link: Link to get an API key + // - key_get_description: Description of pricing and how to get an API key + // - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai". + // - tools: List of tools that the model can use. Each tool is an object with the tool name as the key and an empty object as the value. + // - extraParams: Extra parameters to be passed to the model. This is a JSON object. + property var models: { + "gemini-2.0-flash-search": { + "name": "Gemini 2.0 Flash (Search)", + "icon": "google-gemini-symbolic", + "description": qsTr("Online | Google's model\nGives up-to-date information with search."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "model": "gemini-2.0-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": qsTr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + "tools": [ + { + "google_search": {} + }, + ] + }, + "gemini-2.0-flash-tools": { + "name": "Gemini 2.0 Flash (Tools)", + "icon": "google-gemini-symbolic", + "description": qsTr("Experimental | Online | Google's model\nCan do a little more but doesn't search quickly"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "model": "gemini-2.0-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": qsTr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + "tools": [ + { + "functionDeclarations": [ + { + "name": "switch_to_search_mode", + "description": "Search the web", + }, + { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + }, + { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + }, + ] + } + ] + }, + "gemini-2.5-flash-search": { + "name": "Gemini 2.5 Flash (Search)", + "icon": "google-gemini-symbolic", + "description": qsTr("Online | Google's model\nGives up-to-date information with search."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:streamGenerateContent", + "model": "gemini-2.5-flash-preview-05-20", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": qsTr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + "tools": [ + { + "google_search": ({}) + }, + ] + }, + "gemini-2.5-flash-tools": { + "name": "Gemini 2.5 Flash (Tools)", + "icon": "google-gemini-symbolic", + "description": qsTr("Experimental | Online | Google's model\nCan do a little more but doesn't search quickly"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:streamGenerateContent", + "model": "gemini-2.5-flash-preview-05-20", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": qsTr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + "tools": [ + { + "functionDeclarations": [ + { + "name": "switch_to_search_mode", + "description": "Search the web", + }, + { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + }, + { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + }, + ] + } + ] + }, + "openrouter-llama4-maverick": { + "name": "Llama 4 Maverick", + "icon": "ollama-symbolic", + "description": StringUtils.format(qsTr("Online via {0} | {1}'s model"), "OpenRouter", "Meta"), + "homepage": "https://openrouter.ai/meta-llama/llama-4-maverick:free", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "model": "meta-llama/llama-4-maverick:free", + "requires_key": true, + "key_id": "openrouter", + "key_get_link": "https://openrouter.ai/settings/keys", + "key_get_description": qsTr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"), + }, + "openrouter-deepseek-r1": { + "name": "DeepSeek R1", + "icon": "deepseek-symbolic", + "description": StringUtils.format(qsTr("Online via {0} | {1}'s model"), "OpenRouter", "DeepSeek"), + "homepage": "https://openrouter.ai/deepseek/deepseek-r1:free", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "model": "deepseek/deepseek-r1:free", + "requires_key": true, + "key_id": "openrouter", + "key_get_link": "https://openrouter.ai/settings/keys", + "key_get_description": qsTr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"), + }, + } + property var modelList: Object.keys(root.models) + property var currentModelId: PersistentStates?.ai?.model || modelList[0] + + Component.onCompleted: { + setModel(currentModelId, false); // Do necessary setup for model + getOllamaModels.running = true + } + + function guessModelLogo(model) { + if (model.includes("llama")) return "ollama-symbolic"; + if (model.includes("gemma")) return "google-gemini-symbolic"; + if (model.includes("deepseek")) return "deepseek-symbolic"; + if (/^phi\d*:/i.test(model)) return "microsoft-symbolic"; + return "ollama-symbolic"; + } + + function guessModelName(model) { + const replaced = model.replace(/-/g, ' ').replace(/:/g, ' '); + let words = replaced.split(' '); + words[words.length - 1] = words[words.length - 1].replace(/(\d+)b$/, (_, num) => `${num}B`) + words = words.map((word) => { + return (word.charAt(0).toUpperCase() + word.slice(1)) + }); + if (words[words.length - 1] === "Latest") words.pop(); + else words[words.length - 1] = `(${words[words.length - 1]})`; // Surround the last word with square brackets + const result = words.join(' '); + return result; + } + + Process { + id: getOllamaModels + command: ["bash", "-c", `${Directories.config}/quickshell/scripts/ai/show-installed-ollama-models.sh`.replace(/file:\/\//, "")] + stdout: SplitParser { + onRead: data => { + try { + if (data.length === 0) return; + const dataJson = JSON.parse(data); + root.modelList = [...root.modelList, ...dataJson]; + dataJson.forEach(model => { + const safeModelName = root.safeModelName(model); + root.models[safeModelName] = { + "name": guessModelName(model), + "icon": guessModelLogo(model), + "description": StringUtils.format(qsTr("Local Ollama model | {0}"), model), + "homepage": `https://ollama.com/library/${model}`, + "endpoint": "http://localhost:11434/v1/chat/completions", + "model": model, + } + }); + + root.modelList = Object.keys(root.models); + + } catch (e) { + console.log("Could not fetch Ollama models:", e); + } + } + } + } + + function addMessage(message, role) { + if (message.length === 0) return; + const aiMessage = aiMessageComponent.createObject(root, { + "role": role, + "content": message, + "thinking": false, + "done": true, + }); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function removeMessage(index) { + if (index < 0 || index >= messageIDs.length) return; + const id = root.messageIDs[index]; + root.messageIDs.splice(index, 1); + root.messageIDs = [...root.messageIDs]; + delete root.messageByID[id]; + } + + function addApiKeyAdvice(model) { + root.addMessage( + StringUtils.format(qsTr('To set an API key, pass it with the command\n\nTo view the key, pass "get" with the command
\n\n### For {0}:\n\n**Link**: {1}\n\n{2}'), + model.name, model.key_get_link, model.key_get_description ?? qsTr("No further instruction provided")), + Ai.interfaceRole + ); + } + + function getModel() { + return models[currentModelId]; + } + + function setModel(modelId, feedback = true) { + if (!modelId) modelId = "" + modelId = modelId.toLowerCase() + if (modelList.indexOf(modelId) !== -1) { + PersistentStateManager.setState("ai.model", modelId); + if (feedback) root.addMessage(StringUtils.format(StringUtils.format("Model set to {0}"), models[modelId].name), root.interfaceRole) + if (models[modelId].requires_key) { + // If key not there show advice + if (root.apiKeysLoaded && (!root.apiKeys[models[modelId].key_id] || root.apiKeys[models[modelId].key_id].length === 0)) { + root.addApiKeyAdvice(models[modelId]) + } + } + } else { + if (feedback) root.addMessage(qsTr("Invalid model. Supported: \n```\n") + modelList.join("\n```\n```\n"), Ai.interfaceRole) + "\n```" + } + if (models[modelId]?.requires_key) { + KeyringStorage.fetchKeyringData(); + } + } + + function getTemperature() { + return root.temperature; + } + + function setTemperature(value) { + if (value == NaN || value < 0 || value > 2) { + root.addMessage(qsTr("Temperature must be between 0 and 2"), Ai.interfaceRole); + return; + } + PersistentStateManager.setState("ai.temperature", value); + root.temperature = value; + root.addMessage(StringUtils.format(qsTr("Temperature set to {0}"), value), Ai.interfaceRole); + } + + function setApiKey(key) { + const model = models[currentModelId]; + if (!model.requires_key) { + root.addMessage(StringUtils.format(qsTr("{0} does not require an API key"), model.name), Ai.interfaceRole); + return; + } + if (!key || key.length === 0) { + const model = models[currentModelId]; + root.addApiKeyAdvice(model) + return; + } + KeyringStorage.setNestedField(["apiKeys", model.key_id], key.trim()); + root.addMessage(StringUtils.format(qsTr("API key set for {0}"), model.name, Ai.interfaceRole)); + } + + function printApiKey() { + const model = models[currentModelId]; + if (model.requires_key) { + const key = root.apiKeys[model.key_id]; + if (key) { + root.addMessage(StringUtils.format(qsTr("API key:\n\n```txt\n{0}\n```"), key), Ai.interfaceRole); + } else { + root.addMessage(StringUtils.format(qsTr("No API key set for {0}"), model.name), Ai.interfaceRole); + } + } else { + root.addMessage(StringUtils.format(qsTr("{0} does not require an API key"), model.name), Ai.interfaceRole); + } + } + + function printTemperature() { + root.addMessage(StringUtils.format(qsTr("Temperature: {0}"), root.temperature), Ai.interfaceRole); + } + + function clearMessages() { + root.messageIDs = []; + root.messageByID = ({}); + } + + Process { + id: requester + property var baseCommand: ["bash", "-c"] + property var message + property bool isReasoning + property string apiFormat: "openai" + property string geminiBuffer: "" + + function buildGeminiEndpoint(model) { + // console.log("ENDPOINT: " + model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`) + return model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`; + } + + function buildOpenAIEndpoint(model) { + return model.endpoint; + } + + function markDone() { + requester.message.done = true; + if (root.postResponseHook) { + root.postResponseHook(); + root.postResponseHook = null; // Reset hook after use + } + } + + function buildGeminiRequestData(model, messages) { + let baseData = { + "contents": messages.filter(message => (message.role != Ai.interfaceRole)).map(message => { + const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role; + const usingSearch = model.tools[0].google_search != undefined + if (!usingSearch && message.functionCall != undefined && message.functionCall.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionCall: { + "name": message.functionName, + } + }] + } + } + if (!usingSearch && message.functionResponse != undefined && message.functionResponse.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionResponse: { + "name": message.functionName, + "response": { "content": message.functionResponse } + } + }] + } + } + return { + "role": geminiApiRoleName, + "parts": [{ + text: message.content, + }] + } + }), + "tools": [ + ...model.tools, + ], + "system_instruction": { + "parts": [{ text: root.systemPrompt }] + }, + "generationConfig": { + // "temperature": root.temperature, + }, + }; + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildOpenAIRequestData(model, messages) { + let baseData = { + "model": model.model, + "messages": [ + {role: "system", content: root.systemPrompt}, + ...messages.filter(message => (message.role != Ai.interfaceRole)).map(message => { + return { + "role": message.role, + "content": message.content, + } + }), + ], + "stream": true, + // "temperature": root.temperature, + }; + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function makeRequest() { + const model = models[currentModelId]; + requester.apiFormat = model.api_format ?? "openai"; + + /* Put API key in environment variable */ + if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : "" + + /* Build endpoint, request data */ + const endpoint = (apiFormat === "gemini") ? buildGeminiEndpoint(model) : buildOpenAIEndpoint(model); + const messageArray = root.messageIDs.map(id => root.messageByID[id]); + const data = (apiFormat === "gemini") ? buildGeminiRequestData(model, messageArray) : buildOpenAIRequestData(model, messageArray); + // console.log("REQUEST DATA: ", JSON.stringify(data, null, 2)); + + let requestHeaders = { + "Content-Type": "application/json", + } + + /* Create local message object */ + requester.message = root.aiMessageComponent.createObject(root, { + "role": "assistant", + "model": currentModelId, + "content": "", + "thinking": true, + "done": false, + }); + const id = idForMessage(requester.message); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = requester.message; + + /* Build header string for curl */ + let headerString = Object.entries(requestHeaders) + .filter(([k, v]) => v && v.length > 0) + .map(([k, v]) => `-H '${k}: ${v}'`) + .join(' '); + + // console.log("Request headers: ", JSON.stringify(requestHeaders)); + // console.log("Header string: ", headerString); + + /* Create command string */ + const requestCommandString = `curl --no-buffer "${endpoint}"` + + ` ${headerString}` + + ((apiFormat == "gemini") ? "" : ` -H "Authorization: Bearer \$\{${root.apiKeyEnvVarName}\}"`) + + ` -d '${StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'` + // console.log("Request command: ", requestCommandString); + requester.command = baseCommand.concat([requestCommandString]); + + /* Reset vars and make the request */ + requester.isReasoning = false + requester.running = true + } + + function parseGeminiBuffer() { + // console.log("BUFFER DATA: ", requester.geminiBuffer); + try { + if (requester.geminiBuffer.length === 0) return; + const dataJson = JSON.parse(requester.geminiBuffer); + if (!dataJson.candidates) return; + + if (dataJson.candidates[0]?.finishReason) { + requester.markDone(); + } + // Function call handling + if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) { + const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall; + requester.message.functionName = functionCall.name; + requester.message.functionCall = functionCall.name; + requester.message.content += `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n`; + root.handleGeminiFunctionCall(functionCall.name, functionCall.args); + return + } + // Normal text response + const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text + requester.message.content += responseContent; + const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => { + return { + "type": "url_citation", + "text": chunk?.web?.title, + "url": chunk?.web?.uri, + } + }); + const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => { + return { + "type": "url_citation", + "start_index": citation.segment?.startIndex, + "end_index": citation.segment?.endIndex, + "text": citation?.segment.text, + "url": annotationSources[citation.groundingChunkIndices[0]]?.url, + "sources": citation.groundingChunkIndices + } + }); + requester.message.annotationSources = annotationSources; + requester.message.annotations = annotations; + // console.log(JSON.stringify(requester.message, null, 2)); + } catch (e) { + console.log("[AI] Could not parse response from stream: ", e); + requester.message.content += requester.geminiBuffer + } finally { + requester.geminiBuffer = ""; + } + } + + function handleGeminiResponseLine(line) { + if (line.startsWith("[")) { + requester.geminiBuffer += line.slice(1).trim(); + } else if (line == "]") { + requester.geminiBuffer += line.slice(0, -1).trim(); + parseGeminiBuffer(); + } else if (line.startsWith(",")) { // end of one entry + parseGeminiBuffer(); + } else { + requester.geminiBuffer += line.trim(); + } + } + + function handleOpenAIResponseLine(line) { + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = line.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + // console.log("Clean data: ", cleanData); + if (!cleanData || cleanData.startsWith(":")) return; + + if (cleanData === "[DONE]") { + requester.markDone(); + return; + } + const dataJson = JSON.parse(cleanData); + + let newContent = ""; + const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content; + const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content; + + if (responseContent && responseContent.length > 0) { + if (requester.isReasoning) { + requester.isReasoning = false; + requester.message.content += "\n\n\n\n"; + } + newContent = dataJson.choices[0]?.delta?.content || dataJson.message.content; + } else if (responseReasoning && responseReasoning.length > 0) { + // console.log("Reasoning content: ", dataJson.choices[0].delta.reasoning); + if (!requester.isReasoning) { + requester.isReasoning = true; + requester.message.content += "\n\n\n\n"; + } + newContent = dataJson.choices[0].delta.reasoning || dataJson.choices[0].delta.reasoning_content; + } + + requester.message.content += newContent; + + if (dataJson.done) { + requester.markDone(); + } + } + + stdout: SplitParser { + onRead: data => { + // console.log("RAW DATA: ", data); + if (data.length === 0) return; + + // Handle response line + if (requester.message.thinking) requester.message.thinking = false; + try { + if (requester.apiFormat === "gemini") { + requester.handleGeminiResponseLine(data); + } + else if (requester.apiFormat === "openai") { + requester.handleOpenAIResponseLine(data); + } + else { + console.log("Unknown API format: ", requester.apiFormat); + requester.message.content += data; + } + } catch (e) { + console.log("[AI] Could not parse response from stream: ", e); + requester.message.content += data; + } + } + } + + onExited: (exitCode, exitStatus) => { + if (requester.apiFormat == "gemini") requester.parseGeminiBuffer(); + else requester.markDone(); + + try { // to parse full response into json for error handling + // console.log("Full response: ", requester.message.content + "]"); + const parsedResponse = JSON.parse(requester.message.content + "]"); + requester.message.content = `\`\`\`json\n${JSON.stringify(parsedResponse, null, 2)}\n\`\`\``; + } catch (e) { + // console.log("[AI] Could not parse response on exit: ", e); + } + + if (requester.message.content.includes("API key not valid")) { + root.addApiKeyAdvice(models[requester.message.model]); + } + } + } + + function sendUserMessage(message) { + if (message.length === 0) return; + root.addMessage(message, "user"); + requester.makeRequest(); + } + + function addFunctionOutputMessage(name, output) { + const aiMessage = aiMessageComponent.createObject(root, { + "role": "user", + "content": `[[ Output of ${name} ]]`, + "functionName": name, + "functionResponse": output, + "thinking": false, + "done": true, + "visibleToUser": false, + }); + // console.log("Adding function output message: ", JSON.stringify(aiMessage)); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function buildGeminiFunctionOutput(name, output) { + const functionResponsePart = { + "name": name, + "response": { "content": output } + } + return { + "role": "user", + "parts": [{ + functionResponse: functionResponsePart, + }] + } + } + + function handleGeminiFunctionCall(name, args) { + if (name === "switch_to_search_mode") { + if (root.currentModelId === "gemini-2.5-flash-tools") { + root.setModel("gemini-2.5-flash-search", false); + root.postResponseHook = () => root.setModel("gemini-2.5-flash-tools", false); + } else if (root.currentModelId === "gemini-2.0-flash-tools") { + root.setModel("gemini-2.0-flash-search", false); + root.postResponseHook = () => root.setModel("gemini-2.0-flash-tools", false); + } + addFunctionOutputMessage(name, qsTr("Switched to search mode. Continue with the user's request.")) + requester.makeRequest(); + } else if (name === "get_shell_config") { + const configJson = ObjectUtils.toPlainObject(ConfigOptions) + addFunctionOutputMessage(name, JSON.stringify(configJson)); + requester.makeRequest(); + } else if (name === "set_shell_config") { + if (!args.key || !args.value) { + addFunctionOutputMessage(name, qsTr("Invalid arguments. Must provide `key` and `value`.")); + return; + } + const key = args.key; + const value = args.value; + ConfigLoader.setLiveConfigValue(key, value); + ConfigLoader.saveConfig(); + } + else root.addMessage(qsTr("Unknown function call: {0}"), "assistant"); + } + +} diff --git a/.config/quickshell/services/AiMessageData.qml b/.config/quickshell/services/AiMessageData.qml new file mode 100644 index 000000000..b5f208548 --- /dev/null +++ b/.config/quickshell/services/AiMessageData.qml @@ -0,0 +1,19 @@ +import "root:/modules/common" +import QtQuick; + +/** + * Represents a message in an AI conversation. (Kind of) follows the OpenAI API message structure. + */ +QtObject { + property string role + property string content + property string model + property bool thinking: true + property bool done: false + property var annotations: [] + property var annotationSources: [] + property string functionName + property string functionCall + property string functionResponse + property bool visibleToUser: true +} diff --git a/.config/quickshell/services/AppSearch.qml b/.config/quickshell/services/AppSearch.qml new file mode 100644 index 000000000..876df1838 --- /dev/null +++ b/.config/quickshell/services/AppSearch.qml @@ -0,0 +1,116 @@ +pragma Singleton + +import "root:/modules/common" +import "root:/modules/common/functions/fuzzysort.js" as Fuzzy +import "root:/modules/common/functions/levendist.js" as Levendist +import Quickshell +import Quickshell.Io + +/** + * - Eases fuzzy searching for applications by name + * - Guesses icon name for window class name + */ +Singleton { + id: root + property bool sloppySearch: ConfigOptions?.search.sloppy ?? false + property real scoreThreshold: 0.2 + property var substitutions: ({ + "code-url-handler": "visual-studio-code", + "Code": "visual-studio-code", + "gnome-tweaks": "org.gnome.tweaks", + "pavucontrol-qt": "pavucontrol", + "wps": "wps-office2019-kprometheus", + "wpsoffice": "wps-office2019-kprometheus", + "footclient": "foot", + "zen": "zen-browser", + }) + property var regexSubstitutions: [ + { + "regex": /^steam_app_(\\d+)$/, + "replace": "steam_icon_$1" + }, + { + "regex": /Minecraft.*/, + "replace": "minecraft" + }, + { + "regex": /.*polkit.*/, + "replace": "system-lock-screen" + }, + { + "regex": /gcr.prompter/, + "replace": "system-lock-screen" + } + ] + + readonly property list list: Array.from(DesktopEntries.applications.values) + .sort((a, b) => a.name.localeCompare(b.name)) + + readonly property var preppedNames: list.map(a => ({ + name: Fuzzy.prepare(`${a.name} `), + entry: a + })) + + function fuzzyQuery(search: string): var { // Idk why list doesn't work + if (root.sloppySearch) { + const results = list.map(obj => ({ + entry: obj, + score: Levendist.computeScore(obj.name.toLowerCase(), search.toLowerCase()) + })).filter(item => item.score > root.scoreThreshold) + .sort((a, b) => b.score - a.score) + return results + .map(item => item.entry) + } + + return Fuzzy.go(search, preppedNames, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + } + + function iconExists(iconName) { + return (Quickshell.iconPath(iconName, true).length > 0) + && !iconName.includes("image-missing"); + } + + function guessIcon(str) { + if (!str || str.length == 0) return "image-missing"; + + // Normal substitutions + if (substitutions[str]) + return substitutions[str]; + + // Regex substitutions + for (let i = 0; i < regexSubstitutions.length; i++) { + const substitution = regexSubstitutions[i]; + const replacedName = str.replace( + substitution.regex, + substitution.replace, + ); + if (replacedName != str) return replacedName; + } + + // If it gets detected normally, no need to guess + if (iconExists(str)) return str; + + let guessStr = str; + // Guess: Take only app name of reverse domain name notation + guessStr = str.split('.').slice(-1)[0].toLowerCase(); + if (iconExists(guessStr)) return guessStr; + // Guess: normalize to kebab case + guessStr = str.toLowerCase().replace(/\s+/g, "-"); + if (iconExists(guessStr)) return guessStr; + // Guess: First fuzze desktop entry match + const searchResults = root.fuzzyQuery(str); + if (searchResults.length > 0) { + const firstEntry = searchResults[0]; + guessStr = firstEntry.icon + if (iconExists(guessStr)) return guessStr; + } + + // Give up + return str; + } +} diff --git a/.config/quickshell/services/Audio.qml b/.config/quickshell/services/Audio.qml new file mode 100644 index 000000000..2fd5e0cac --- /dev/null +++ b/.config/quickshell/services/Audio.qml @@ -0,0 +1,51 @@ +import "root:/modules/common" +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for default Pipewire audio sink and source. + */ +Singleton { + id: root + + property bool ready: Pipewire.defaultAudioSink?.ready ?? false + property PwNode sink: Pipewire.defaultAudioSink + property PwNode source: Pipewire.defaultAudioSource + + signal sinkProtectionTriggered(string reason); + + PwObjectTracker { + objects: [sink, source] + } + + Connections { // Protection against sudden volume changes + target: sink?.audio ?? null + property bool lastReady: false + property real lastVolume: 0 + function onVolumeChanged() { + if (!ConfigOptions.audio.protection.enable) return; + if (!lastReady) { + lastVolume = sink.audio.volume; + lastReady = true; + return; + } + const newVolume = sink.audio.volume; + const maxAllowedIncrease = ConfigOptions.audio.protection.maxAllowedIncrease / 100; + const maxAllowed = ConfigOptions.audio.protection.maxAllowed / 100; + + if (newVolume - lastVolume > maxAllowedIncrease) { + sink.audio.volume = lastVolume; + root.sinkProtectionTriggered("Illegal increment"); + } else if (newVolume > maxAllowed) { + sink.audio.volume = lastVolume; + root.sinkProtectionTriggered("Exceeded max allowed"); + } + lastVolume = sink.audio.volume; + } + + } + +} diff --git a/.config/quickshell/services/Battery.qml b/.config/quickshell/services/Battery.qml new file mode 100644 index 000000000..08bdee7cd --- /dev/null +++ b/.config/quickshell/services/Battery.qml @@ -0,0 +1,30 @@ +pragma Singleton + +import "root:/modules/common" +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Quickshell.Services.UPower + +Singleton { + property bool available: UPower.displayDevice.isLaptopBattery + property var chargeState: UPower.displayDevice.state + property bool isCharging: chargeState == UPowerDeviceState.Charging + property bool isPluggedIn: isCharging || chargeState == UPowerDeviceState.PendingCharge + property real percentage: UPower.displayDevice.percentage + + property bool isLow: percentage <= ConfigOptions.battery.low / 100 + property bool isCritical: percentage <= ConfigOptions.battery.critical / 100 + property bool isSuspending: percentage <= ConfigOptions.battery.suspend / 100 + + property bool isLowAndNotCharging: isLow && !isCharging + property bool isCriticalAndNotCharging: isCritical && !isCharging + + onIsLowAndNotChargingChanged: { + if (available && isLowAndNotCharging) Hyprland.dispatch(`exec notify-send "Low battery" "Consider plugging in your device" -u critical -a "Shell"`) + } + + onIsCriticalAndNotChargingChanged: { + if (available && isCriticalAndNotCharging) Hyprland.dispatch(`exec notify-send "Critically low battery" "🙏 I beg for pleas charg\nAutomatic suspend triggers at ${ConfigOptions.battery.suspend}%" -u critical -a "Shell"`) + } +} diff --git a/.config/quickshell/services/Bluetooth.qml b/.config/quickshell/services/Bluetooth.qml new file mode 100644 index 000000000..817bbc921 --- /dev/null +++ b/.config/quickshell/services/Bluetooth.qml @@ -0,0 +1,73 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell; +import Quickshell.Io; +import QtQuick; + +/** + * Basic polled Bluetooth state. + */ +Singleton { + id: root + + property int updateInterval: 1000 + property string bluetoothDeviceName: "" + property string bluetoothDeviceAddress: "" + property bool bluetoothEnabled: false + property bool bluetoothConnected: false + + function update() { + updateBluetoothDevice.running = true + updateBluetoothStatus.running = true + updateBluetoothEnabled.running = true + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + update() + interval = root.updateInterval + } + } + + // Check if Bluetooth is enabled (controller powered on) + Process { + id: updateBluetoothEnabled + command: ["sh", "-c", "bluetoothctl show | grep -q 'Powered: yes' && echo 1 || echo 0"] + running: true + stdout: SplitParser { + onRead: data => { + root.bluetoothEnabled = (parseInt(data) === 1) + } + } + } + + // Get the name and address of the first connected Bluetooth device + Process { + id: updateBluetoothDevice + command: ["sh", "-c", "bluetoothctl info | awk -F': ' '/Name: /{name=$2} /Device /{addr=$2} END{print name \":\" addr}'"] + running: true + stdout: SplitParser { + onRead: data => { + let parts = data.split(":") + root.bluetoothDeviceName = parts[0] || "" + root.bluetoothDeviceAddress = parts[1] || "" + } + } + } + + // Check if any device is connected + Process { + id: updateBluetoothStatus + command: ["sh", "-c", "bluetoothctl info | grep -q 'Connected: yes' && echo 1 || echo 0"] + running: true + stdout: SplitParser { + onRead: data => { + root.bluetoothConnected = (parseInt(data) === 1) + } + } + } +} diff --git a/.config/quickshell/services/Booru.qml b/.config/quickshell/services/Booru.qml new file mode 100644 index 000000000..49256bfa7 --- /dev/null +++ b/.config/quickshell/services/Booru.qml @@ -0,0 +1,468 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import Quickshell; +import Quickshell.Io; +import Qt.labs.platform +import QtQuick; + +/** + * A service for interacting with various booru APIs. + */ +Singleton { + id: root + property Component booruResponseDataComponent: BooruResponseData {} + + signal tagSuggestion(string query, var suggestions) + + property string failMessage: qsTr("That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number") + property var responses: [] + property int runningRequests: 0 + property var defaultUserAgent: ConfigOptions?.networking?.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" + property var providerList: Object.keys(providers).filter(provider => provider !== "system" && providers[provider].api) + property var providers: { + "system": { "name": qsTr("System") }, + "yandere": { + "name": "yande.re", + "url": "https://yande.re", + "api": "https://yande.re/post.json", + "description": qsTr("All-rounder | Good quality, decent quantity"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://yande.re/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "konachan": { + "name": "Konachan", + "url": "https://konachan.com", + "api": "https://konachan.com/post.json", + "description": qsTr("For desktop wallpapers | Good quality"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://konachan.com/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "zerochan": { + "name": "Zerochan", + "url": "https://www.zerochan.net", + "api": "https://www.zerochan.net/?json", + "description": qsTr("Clean stuff | Excellent quality, no NSFW"), + "mapFunc": (response) => { + response = response.items + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.join(" "), + "rating": "safe", // Zerochan doesn't have nsfw + "is_nsfw": false, + "md5": item.md5, + "preview_url": item.thumbnail, + "sample_url": item.thumbnail, + "file_url": item.thumbnail, + "file_ext": "avif", + "source": getWorkingImageSource(item.source) ?? item.thumbnail, + "character": item.tag + } + }) + } + }, + "danbooru": { + "name": "Danbooru", + "url": "https://danbooru.donmai.us", + "api": "https://danbooru.donmai.us/posts.json", + "description": qsTr("The popular one | Best quantity, but quality can vary wildly"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.image_width, + "height": item.image_height, + "aspect_ratio": item.image_width / item.image_height, + "tags": item.tag_string, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_file_url, + "sample_url": item.file_url ?? item.large_file_url, + "file_url": item.large_file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://danbooru.donmai.us/tags.json?search[name_matches]={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.post_count + } + }) + } + + }, + "gelbooru": { + "name": "Gelbooru", + "url": "https://gelbooru.com", + "api": "https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1", + "description": qsTr("The hentai one | Great quantity, a lot of NSFW, quality varies wildly"), + "mapFunc": (response) => { + response = response.post + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating.replace('general', 's').charAt(0), + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_url.split('.').pop(), + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://gelbooru.com/index.php?page=dapi&s=tag&q=index&json=1&orderby=count&name_pattern={{query}}%", + "tagMapFunc": (response) => { + return response.tag.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "waifu.im": { + "name": "waifu.im", + "url": "https://waifu.im", + "api": "https://api.waifu.im/search", + "description": qsTr("Waifus only | Excellent quality, limited quantity"), + "mapFunc": (response) => { + response = response.images + return response.map(item => { + return { + "id": item.image_id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.map(tag => {return tag.name}).join(" "), + "rating": item.is_nsfw ? "e" : "s", + "is_nsfw": item.is_nsfw, + "md5": item.md5, + "preview_url": item.sample_url ?? item.url, // preview_url just says access denied (maybe i fucked up and sent too many requests idk) + "sample_url": item.url, + "file_url": item.url, + "file_ext": item.extension, + "source": getWorkingImageSource(item.source) ?? item.url, + } + }) + }, + "tagSearchTemplate": "https://api.waifu.im/tags", + "tagMapFunc": (response) => { + return [...response.versatile.map(item => {return {"name": item}}), + ...response.nsfw.map(item => {return {"name": item}})] + } + }, + "t.alcy.cc": { + "name": "Alcy", + "url": "https://t.alcy.cc", + "api": "https://t.alcy.cc/", + "description": qsTr("Large images | God tier quality, no NSFW."), + "fixedTags": [ + { + "name": "ycy", + "count": "General" + }, + { + "name": "moez", + "count": "Moe" + }, + { + "name": "ysz", + "count": "Genshin Impact" + }, + { + "name": "fj", + "count": "Landscape" + }, + { + "name": "bd", + "count": "Girl on white background" + }, + { + "name": "xhl", + "count": "Shiggy" + }, + ], + "manualParseFunc": (responseText) => { + // Alcy just returns image links, each on a new line + const lines = responseText.trim().split('\n'); + return lines.map(line => { + return { + "id": Qt.md5(line), + // Alcy doesn't provide dimensions and images are often of god resolution + "width": 1000, + "height": 1000, + "aspect_ratio": 1, // Default aspect ratio + "tags": "[no tags]", + "rating": "s", + "is_nsfw": false, + "md5": Qt.md5(line), + "preview_url": line, + "sample_url": line, + "file_url": line, + "file_ext": line.split('.').pop(), + "source": "", + } + }); + }, + } + } + property var currentProvider: PersistentStates.booru.provider + + function getWorkingImageSource(url) { + if (url.includes('pximg.net')) { + return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/') + 1).replace(/_p\d+\.(png|jpg|jpeg|gif)$/, '')}`; + } + return url; + } + + function setProvider(provider) { + provider = provider.toLowerCase() + if (providerList.indexOf(provider) !== -1) { + PersistentStateManager.setState("booru.provider", provider) + root.addSystemMessage(qsTr("Provider set to ") + providers[provider].name + + (provider == "zerochan" ? qsTr(". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!") : "")) + } else { + root.addSystemMessage(qsTr("Invalid API provider. Supported: \n- ") + providerList.join("\n- ")) + } + } + + function clearResponses() { + responses = [] + } + + function addSystemMessage(message) { + responses = [...responses, root.booruResponseDataComponent.createObject(null, { + "provider": "system", + "tags": [], + "page": -1, + "images": [], + "message": `${message}` + })] + } + + function constructRequestUrl(tags, nsfw=true, limit=20, page=1) { + var provider = providers[currentProvider] + var baseUrl = provider.api + var url = baseUrl + var tagString = tags.join(" ") + if (!nsfw && !(["zerochan", "waifu.im", "t.alcy.cc"].includes(currentProvider))) { + if (currentProvider == "gelbooru") + tagString += " rating:general"; + else + tagString += " rating:safe"; + } + var params = [] + // Tags & limit + if (currentProvider === "zerochan") { + params.push("c=" + tagString) // zerochan doesn't have search in api, so we use color + params.push("l=" + limit) + params.push("s=" + "fav") + params.push("t=" + 1) + params.push("p=" + page) + } + else if (currentProvider === "waifu.im") { + var tagsArray = tagString.split(" "); + tagsArray.forEach(tag => { + params.push("included_tags=" + encodeURIComponent(tag)); + }); + params.push("limit=" + Math.min(limit, 30)) // Only admin can do > 30 + params.push("is_nsfw=" + (nsfw ? "null" : "false")) // null is random + } + else if (currentProvider === "t.alcy.cc") { + url += tagString + params.push("json") + params.push("quantity=" + limit) + } + else { + params.push("tags=" + encodeURIComponent(tagString)) + params.push("limit=" + limit) + if (currentProvider == "gelbooru") { + params.push("pid=" + page) + } + else { + params.push("page=" + page) + } + } + if (baseUrl.indexOf("?") === -1) { + url += "?" + params.join("&") + } else { + url += "&" + params.join("&") + } + return url + } + + function makeRequest(tags, nsfw=false, limit=20, page=1) { + var url = constructRequestUrl(tags, nsfw, limit, page) + // console.log("[Booru] Making request to " + url) + + const newResponse = root.booruResponseDataComponent.createObject(null, { + "provider": currentProvider, + "tags": tags, + "page": page, + "images": [], + "message": "" + }) + + var xhr = new XMLHttpRequest() + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + const provider = providers[currentProvider] + let response; + if (provider.manualParseFunc) { + response = provider.manualParseFunc(xhr.responseText) + } else { + response = JSON.parse(xhr.responseText) + response = provider.mapFunc(response) + } + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + newResponse.images = response + newResponse.message = response.length > 0 ? "" : root.failMessage + + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + newResponse.message = root.failMessage + } finally { + root.runningRequests--; + root.responses = [...root.responses, newResponse] + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + else if (currentProvider == "zerochan") { + const userAgent = ConfigOptions?.sidebar?.booru?.zerochan?.username ? `Desktop sidebar booru viewer - username: ${ConfigOptions.sidebar.booru.zerochan.username}` : defaultUserAgent + xhr.setRequestHeader("User-Agent", userAgent) + } + root.runningRequests++; + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } + + property var currentTagRequest: null + function triggerTagSearch(query) { + if (currentTagRequest) { + currentTagRequest.abort(); + } + + var provider = providers[currentProvider] + if (provider.fixedTags) { + root.tagSuggestion(query, provider.fixedTags) + return provider.fixedTags; + } else if (!provider.tagSearchTemplate) { + return + } + var url = provider.tagSearchTemplate.replace("{{query}}", encodeURIComponent(query)) + + var xhr = new XMLHttpRequest() + currentTagRequest = xhr + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + currentTagRequest = null + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + var response = JSON.parse(xhr.responseText) + response = provider.tagMapFunc(response) + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + root.tagSuggestion(query, response) + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } +} + diff --git a/.config/quickshell/services/BooruResponseData.qml b/.config/quickshell/services/BooruResponseData.qml new file mode 100644 index 000000000..38e1b8c76 --- /dev/null +++ b/.config/quickshell/services/BooruResponseData.qml @@ -0,0 +1,13 @@ +import "root:/modules/common" +import QtQuick; + +/** + * A booru response. + */ +QtObject { + property string provider + property var tags + property var page + property var images + property string message +} diff --git a/.config/quickshell/services/Brightness.qml b/.config/quickshell/services/Brightness.qml new file mode 100644 index 000000000..aba664aa9 --- /dev/null +++ b/.config/quickshell/services/Brightness.qml @@ -0,0 +1,152 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://github.com/caelestia-dots/shell/ (`quickshell` branch) with modifications. +// License: GPLv3 + +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick + +/** + * For managing brightness of monitors. Supports both brightnessctl and ddcutil. + */ +Singleton { + id: root + + signal brightnessChanged() + + property var ddcMonitors: [] + readonly property list monitors: Quickshell.screens.map(screen => monitorComp.createObject(root, { + screen + })) + + function getMonitorForScreen(screen: ShellScreen): var { + return monitors.find(m => m.screen === screen); + } + + function increaseBrightness(): void { + const focusedName = Hyprland.focusedMonitor.name; + const monitor = monitors.find(m => focusedName === m.screen.name); + if (monitor) + monitor.setBrightness(monitor.brightness + 0.05); + } + + function decreaseBrightness(): void { + const focusedName = Hyprland.focusedMonitor.name; + const monitor = monitors.find(m => focusedName === m.screen.name); + if (monitor) + monitor.setBrightness(monitor.brightness - 0.05); + } + + reloadableId: "brightness" + + onMonitorsChanged: { + ddcMonitors = []; + ddcProc.running = true; + } + + Process { + id: ddcProc + + command: ["ddcutil", "detect", "--brief"] + stdout: SplitParser { + splitMarker: "\n\n" + onRead: data => { + if (data.startsWith("Display ")) { + const lines = data.split("\n").map(l => l.trim()); + root.ddcMonitors.push({ + model: lines.find(l => l.startsWith("Monitor:")).split(":")[2], + busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1] + }); + } + } + } + onExited: root.ddcMonitorsChanged() + } + + Process { + id: setProc + } + + component BrightnessMonitor: QtObject { + id: monitor + + required property ShellScreen screen + readonly property bool isDdc: root.ddcMonitors.some(m => m.model === screen.model) + readonly property string busNum: root.ddcMonitors.find(m => m.model === screen.model)?.busNum ?? "" + property real brightness + property bool ready: false + + onBrightnessChanged: { + if (monitor.ready) { + root.brightnessChanged(); + } + } + + function initialize() { + monitor.ready = false; + initProc.command = isDdc ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`]; + initProc.running = true; + } + + readonly property Process initProc: Process { + stdout: SplitParser { + onRead: data => { + const [, , , current, max] = data.split(" "); + monitor.brightness = parseInt(current) / parseInt(max); + monitor.ready = true; + } + } + } + + function setBrightness(value: real): void { + value = Math.max(0.01, Math.min(1, value)); + const rounded = Math.round(value * 100); + if (Math.round(brightness * 100) === rounded) + return; + brightness = value; + setProc.command = isDdc ? ["ddcutil", "-b", busNum, "setvcp", "10", rounded] : ["brightnessctl", "s", `${rounded}%`, "--quiet"]; + setProc.startDetached(); + } + + Component.onCompleted: { + initialize(); + } + + onBusNumChanged: { + initialize(); + } + } + + Component { + id: monitorComp + + BrightnessMonitor {} + } + + IpcHandler { + target: "brightness" + + function increment() { + onPressed: root.increaseBrightness() + } + + function decrement() { + onPressed: root.decreaseBrightness() + } + } + + GlobalShortcut { + name: "brightnessIncrease" + description: qsTr("Increase brightness") + onPressed: root.increaseBrightness() + } + + GlobalShortcut { + name: "brightnessDecrease" + description: qsTr("Decrease brightness") + onPressed: root.decreaseBrightness() + } +} diff --git a/.config/quickshell/services/Cliphist.qml b/.config/quickshell/services/Cliphist.qml new file mode 100644 index 000000000..bebafb102 --- /dev/null +++ b/.config/quickshell/services/Cliphist.qml @@ -0,0 +1,81 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common/functions/fuzzysort.js" as Fuzzy +import "root:/modules/common/functions/levendist.js" as Levendist +import "root:/modules/common" +import "root:/" +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property bool sloppySearch: ConfigOptions?.search.sloppy ?? false + property real scoreThreshold: 0.2 + property list entries: [] + readonly property var preparedEntries: entries.map(a => ({ + name: Fuzzy.prepare(`${a.replace(/^\s*\S+\s+/, "")}`), + entry: a + })) + function fuzzyQuery(search: string): var { + if (root.sloppySearch) { + const results = entries.slice(0, 100).map(str => ({ + entry: str, + score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase()) + })).filter(item => item.score > root.scoreThreshold) + .sort((a, b) => b.score - a.score) + return results + .map(item => item.entry) + } + + return Fuzzy.go(search, preparedEntries, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + } + + function refresh() { + readProc.buffer = [] + readProc.running = true + } + + Connections { + target: Quickshell + function onClipboardTextChanged() { + delayedUpdateTimer.restart() + } + } + + Timer { + id: delayedUpdateTimer + interval: ConfigOptions.hacks.arbitraryRaceConditionDelay + repeat: false + onTriggered: { + root.refresh() + } + } + + Process { + id: readProc + property list buffer: [] + + command: ["cliphist", "list"] + + stdout: SplitParser { + onRead: (line) => { + readProc.buffer.push(line) + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + root.entries = readProc.buffer + } else { + console.error("[Cliphist] Failed to refresh with code", exitCode, "and status", exitStatus) + } + } + } +} diff --git a/.config/quickshell/services/ConfigLoader.qml b/.config/quickshell/services/ConfigLoader.qml new file mode 100644 index 000000000..347e8f400 --- /dev/null +++ b/.config/quickshell/services/ConfigLoader.qml @@ -0,0 +1,138 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import "root:/modules/common/functions/file_utils.js" as FileUtils +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/object_utils.js" as ObjectUtils +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Qt.labs.platform + +/** + * Loads and manages the shell configuration file. + * The config file is by default at XDG_CONFIG_HOME/illogical-impulse/config.json. + * Automatically reloaded when the file changes. + */ +Singleton { + id: root + property string filePath: Directories.shellConfigPath + property bool firstLoad: true + property bool preventNextLoad: false + property var preventNextNotification: false + + function loadConfig() { + configFileView.reload() + } + + function applyConfig(fileContent) { + try { + if (fileContent.trim() === "") { + console.warn("[ConfigLoader] Config file is empty, skipping load."); + return; + } + const json = JSON.parse(fileContent); + + ObjectUtils.applyToQtObject(ConfigOptions, json); + if (root.firstLoad) { + root.firstLoad = false; + root.preventNextLoad = true; + root.saveConfig(); // Make sure new properties are added to the user's config file + } + } catch (e) { + console.error("[ConfigLoader] Error reading file:", e); + console.log("[ConfigLoader] File content was:", fileContent); + Hyprland.dispatch(`exec notify-send "${qsTr("Shell configuration failed to load")}" "${root.filePath}"`) + return; + + } + } + + function setLiveConfigValue(nestedKey, value) { + let keys = nestedKey.split("."); + let obj = ConfigOptions; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Convert value to correct type using JSON.parse when safe + let convertedValue = value; + if (typeof value === "string") { + let trimmed = value.trim(); + if (trimmed === "true" || trimmed === "false" || !isNaN(Number(trimmed))) { + try { + convertedValue = JSON.parse(trimmed); + } catch (e) { + convertedValue = value; + } + } + } + + obj[keys[keys.length - 1]] = convertedValue; + } + + function saveConfig() { + const plainConfig = ObjectUtils.toPlainObject(ConfigOptions) + Hyprland.dispatch(`exec echo '${StringUtils.shellSingleQuoteEscape(JSON.stringify(plainConfig, null, 2))}' > '${root.filePath}'`) + } + + function setConfigValueAndSave(nestedKey, value, preventNextNotification = true) { + setLiveConfigValue(nestedKey, value); + root.preventNextNotification = preventNextNotification; + saveConfig(); + } + + Timer { + id: delayedFileRead + interval: ConfigOptions.hacks.arbitraryRaceConditionDelay + running: false + onTriggered: { + if (root.preventNextLoad) { + root.preventNextLoad = false; + return; + } + if (root.firstLoad) { + root.applyConfig(configFileView.text()) + } else { + root.applyConfig(configFileView.text()) + if (!root.preventNextNotification) { + // Hyprland.dispatch(`exec notify-send "${qsTr("Shell configuration reloaded")}" "${root.filePath}"`) + } else { + root.preventNextNotification = false; + } + } + } + } + + FileView { + id: configFileView + path: Qt.resolvedUrl(root.filePath) + watchChanges: true + onFileChanged: { + this.reload() + delayedFileRead.start() + } + onLoadedChanged: { + const fileContent = configFileView.text() + delayedFileRead.start() + } + onLoadFailed: (error) => { + if(error == FileViewError.FileNotFound) { + console.log("[ConfigLoader] File not found, creating new file.") + root.saveConfig() + Hyprland.dispatch(`exec notify-send "${qsTr("Shell configuration created")}" "${root.filePath}"`) + } else { + Hyprland.dispatch(`exec notify-send "${qsTr("Shell configuration failed to load")}" "${root.filePath}"`) + } + } + } +} diff --git a/.config/quickshell/services/DateTime.qml b/.config/quickshell/services/DateTime.qml new file mode 100644 index 000000000..4f24e9447 --- /dev/null +++ b/.config/quickshell/services/DateTime.qml @@ -0,0 +1,52 @@ +import "root:/modules/common" +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for date and time strings. + */ +Singleton { + property string time: Qt.formatDateTime(clock.date, ConfigOptions?.time.format ?? "hh:mm") + property string date: Qt.formatDateTime(clock.date, ConfigOptions?.time.dateFormat ?? "dddd, dd/MM") + property string collapsedCalendarFormat: Qt.formatDateTime(clock.date, "dd MMMM yyyy") + property string uptime: "0h, 0m" + + SystemClock { + id: clock + precision: SystemClock.Minutes + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + fileUptime.reload() + const textUptime = fileUptime.text() + const uptimeSeconds = Number(textUptime.split(" ")[0] ?? 0) + + // Convert seconds to days, hours, and minutes + const days = Math.floor(uptimeSeconds / 86400) + const hours = Math.floor((uptimeSeconds % 86400) / 3600) + const minutes = Math.floor((uptimeSeconds % 3600) / 60) + + // Build the formatted uptime string + let formatted = "" + if (days > 0) formatted += `${days}d` + if (hours > 0) formatted += `${formatted ? ", " : ""}${hours}h` + if (minutes > 0 || !formatted) formatted += `${formatted ? ", " : ""}${minutes}m` + uptime = formatted + interval = ConfigOptions?.resources?.updateInterval ?? 3000 + } + } + + FileView { + id: fileUptime + + path: "/proc/uptime" + } + +} diff --git a/.config/quickshell/services/Emojis.qml b/.config/quickshell/services/Emojis.qml new file mode 100644 index 000000000..852c831b5 --- /dev/null +++ b/.config/quickshell/services/Emojis.qml @@ -0,0 +1,65 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common/functions/fuzzysort.js" as Fuzzy +import "root:/modules/common/functions/levendist.js" as Levendist +import "root:/modules/common" +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Emojis. + */ +Singleton { + id: root + property string emojiScriptPath: `${Directories.config}/hypr/hyprland/scripts/fuzzel-emoji.sh` + property string lineBeforeData: "### DATA ###" + property list list + readonly property var preparedEntries: list.map(a => ({ + name: Fuzzy.prepare(`${a}`), + entry: a + })) + function fuzzyQuery(search: string): var { + if (root.sloppySearch) { + const results = entries.slice(0, 100).map(str => ({ + entry: str, + score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase()) + })).filter(item => item.score > root.scoreThreshold) + .sort((a, b) => b.score - a.score) + return results + .map(item => item.entry) + } + + return Fuzzy.go(search, preparedEntries, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + } + + function load() { + emojiFileView.reload() + } + + function updateEmojis(fileContent) { + const lines = fileContent.split("\n") + const dataIndex = lines.indexOf(root.lineBeforeData) + if (dataIndex === -1) { + console.warn("No data section found in emoji script file.") + return + } + const emojis = lines.slice(dataIndex + 1).filter(line => line.trim() !== "") + root.list = emojis.map(line => line.trim()) + } + + FileView { + id: emojiFileView + path: Qt.resolvedUrl(root.emojiScriptPath) + onLoadedChanged: { + const fileContent = emojiFileView.text() + root.updateEmojis(fileContent) + } + } +} diff --git a/.config/quickshell/services/FirstRunExperience.qml b/.config/quickshell/services/FirstRunExperience.qml new file mode 100644 index 000000000..eee995a55 --- /dev/null +++ b/.config/quickshell/services/FirstRunExperience.qml @@ -0,0 +1,36 @@ +pragma Singleton + +import "root:/modules/common/functions/file_utils.js" as FileUtils +import "root:/modules/common" +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +Singleton { + id: root + property string firstRunFilePath: `${Directories.state}/user/first_run.txt` + property string firstRunFileContent: "This file is just here to confirm you've been greeted :>" + property string firstRunNotifSummary: "Welcome!" + property string firstRunNotifBody: "Hit Super+/ for a list of keybinds" + property string defaultWallpaperPath: FileUtils.trimFileProtocol(`${Directories.config}/quickshell/assets/images/default_wallpaper.png`) + + function load() { + firstRunFileView.reload() + } + + function handleFirstRun() { + Hyprland.dispatch(`exec notify-send '${root.firstRunNotifSummary}' '${root.firstRunNotifBody}' -a 'Shell'`) + Hyprland.dispatch(`exec '${Directories.wallpaperSwitchScriptPath}' '${root.defaultWallpaperPath}'`) + } + + FileView { + id: firstRunFileView + path: Qt.resolvedUrl(firstRunFilePath) + onLoadFailed: (error) => { + if (error == FileViewError.FileNotFound) { + firstRunFileView.setText(root.firstRunFileContent) + root.handleFirstRun() + } + } + } +} diff --git a/.config/quickshell/services/HyprlandData.qml b/.config/quickshell/services/HyprlandData.qml new file mode 100644 index 000000000..2b88ad9cc --- /dev/null +++ b/.config/quickshell/services/HyprlandData.qml @@ -0,0 +1,69 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +/** + * Provides access to some Hyprland data not available in Quickshell.Hyprland. + */ +Singleton { + id: root + property var windowList: [] + property var addresses: [] + property var windowByAddress: ({}) + property var monitors: [] + + function updateWindowList() { + getClients.running = true + getMonitors.running = true + } + + Component.onCompleted: { + updateWindowList() + } + + Connections { + target: Hyprland + + function onRawEvent(event) { + // Filter out redundant old v1 events for the same thing + if(event.name in [ + "activewindow", "focusedmon", "monitoradded", + "createworkspace", "destroyworkspace", "moveworkspace", + "activespecial", "movewindow", "windowtitle" + ]) return ; + updateWindowList() + } + } + + Process { + id: getClients + command: ["bash", "-c", "hyprctl clients -j | jq -c"] + stdout: SplitParser { + onRead: (data) => { + root.windowList = JSON.parse(data) + let tempWinByAddress = {} + for (var i = 0; i < root.windowList.length; ++i) { + var win = root.windowList[i] + tempWinByAddress[win.address] = win + } + root.windowByAddress = tempWinByAddress + root.addresses = root.windowList.map((win) => win.address) + } + } + } + Process { + id: getMonitors + command: ["bash", "-c", "hyprctl monitors -j | jq -c"] + stdout: SplitParser { + onRead: (data) => { + root.monitors = JSON.parse(data) + } + } + } +} + diff --git a/.config/quickshell/services/HyprlandKeybinds.qml b/.config/quickshell/services/HyprlandKeybinds.qml new file mode 100644 index 000000000..189ba76d5 --- /dev/null +++ b/.config/quickshell/services/HyprlandKeybinds.qml @@ -0,0 +1,73 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import "root:/modules/common/functions/file_utils.js" as FileUtils +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +/** + * A service that provides access to Hyprland keybinds. + * Uses the `get_keybinds.py` script to parse comments in config files in a certain format and convert to JSON. + */ +Singleton { + id: root + property string keybindParserPath: FileUtils.trimFileProtocol(`${Directories.config}/quickshell/scripts/hyprland/get_keybinds.py`) + property string defaultKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/keybinds.conf`) + property string userKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/custom/keybinds.conf`) + property var defaultKeybinds: {"children": []} + property var userKeybinds: {"children": []} + property var keybinds: ({ + children: [ + ...(defaultKeybinds.children ?? []), + ...(userKeybinds.children ?? []), + ] + }) + + Connections { + target: Hyprland + + function onRawEvent(event) { + if (event.name == "configreloaded") { + getDefaultKeybinds.running = true + getUserKeybinds.running = true + } + } + } + + Process { + id: getDefaultKeybinds + running: true + command: [root.keybindParserPath, "--path", root.defaultKeybindConfigPath,] + + stdout: SplitParser { + onRead: data => { + try { + root.defaultKeybinds = JSON.parse(data) + } catch (e) { + console.error("[CheatsheetKeybinds] Error parsing keybinds:", e) + } + } + } + } + + Process { + id: getUserKeybinds + running: true + command: [root.keybindParserPath, "--path", root.userKeybindConfigPath] + + stdout: SplitParser { + onRead: data => { + try { + root.userKeybinds = JSON.parse(data) + } catch (e) { + console.error("[CheatsheetKeybinds] Error parsing keybinds:", e) + } + } + } + } +} + diff --git a/.config/quickshell/services/KeyringStorage.qml b/.config/quickshell/services/KeyringStorage.qml new file mode 100644 index 000000000..3f3956f98 --- /dev/null +++ b/.config/quickshell/services/KeyringStorage.qml @@ -0,0 +1,119 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import Quickshell; +import Quickshell.Io; +import Qt.labs.platform +import QtQuick; + +/** + * For storing sensitive data in the keyring. + * Use this for small data only, since it stores a JSON of the contents directly and doesn't use a database. + */ +Singleton { + id: root + + property bool loaded: false + property var keyringData: ({}) + + property var properties: { + "application": "illogical-impulse", + "explanation": qsTr("For storing API keys and other sensitive information"), + } + property var propertiesAsArgs: Object.keys(root.properties).reduce( + function(arr, key) { + return arr.concat([key, root.properties[key]]); + }, [] + ) + property string keyringLabel: StringUtils.format(qsTr("{0} Safe Storage"), "illogical-impulse") + + function setNestedField(path, value) { + if (!root.keyringData) root.keyringData = {}; + let keys = path; + let obj = root.keyringData; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Set the value at the innermost key + obj[keys[keys.length - 1]] = value; + + // Reassign each parent object from the bottom up to trigger change notifications + for (let i = keys.length - 2; i >= 0; --i) { + let parent = parents[i]; + let key = keys[i]; + // Shallow clone to change object identity (spread replaced with Object.assign) + parent[key] = Object.assign({}, parent[key]); + } + + // Finally, reassign root.keyringData to trigger top-level change + root.keyringData = Object.assign({}, root.keyringData); + + saveKeyringData(); + } + + function fetchKeyringData() { + // console.log("[KeyringStorage] Fetching keyring data..."); + // console.log("[KeyringStorage] getData command:'" + getData.command.join("' '") + "'"); + getData.running = true; + } + + function saveKeyringData() { + saveData.stdinEnabled = true; + saveData.running = true; + } + + Process { + id: saveData + command: [ + "secret-tool", "store", "--label=" + keyringLabel, + ...propertiesAsArgs, + ] + onRunningChanged: { + if (saveData.running) { + // console.log("[KeyringStorage] Saving with command: '" + saveData.command.join("' '") + "'"); + saveData.write(JSON.stringify(root.keyringData)); + stdinEnabled = false // End input stream + } + } + } + + Process { + id: getData + command: [ // We need to use echo for a newline so splitparser does parse + "bash", "-c", `echo $(secret-tool lookup 'application' 'illogical-impulse')`, + ] + stdout: SplitParser { + onRead: data => { + if(data.length === 0) return; + try { + root.keyringData = JSON.parse(data); + // console.log("[KeyringStorage] Keyring data fetched:", JSON.stringify(root.keyringData)); + } catch (e) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + } + } + onExited: (exitCode, exitStatus) => { + // console.log("[KeyringStorage] Keyring data fetch process exited with code:", exitCode); + if (exitCode !== 0) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + root.loaded = true; + } + } + +} diff --git a/.config/quickshell/services/LatexRenderer.qml b/.config/quickshell/services/LatexRenderer.qml new file mode 100644 index 000000000..e7066fa4c --- /dev/null +++ b/.config/quickshell/services/LatexRenderer.qml @@ -0,0 +1,87 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/file_utils.js" as FileUtils +import "root:/modules/common" +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Qt.labs.platform + +/** + * Renders LaTeX snippets with MicroTeX. + * For every request: + * 1. Hash it + * 2. Check if the hash is already processed + * 3. If not, render it with MicroTeX and mark as processed + */ +Singleton { + id: root + + readonly property var renderPadding: 4 // This is to prevent cutoff in the rendered images + + property list processedHashes: [] + property var processedExpressions: ({}) + property var renderedImagePaths: ({}) + property string microtexBinaryDir: "/opt/MicroTeX" + property string microtexBinaryName: "LaTeX" + property string latexOutputPath: Directories.latexOutput + + signal renderFinished(string hash, string imagePath) + + /** + * Requests rendering of a LaTeX expression. + * Returns the [hash, isNew] + */ + function requestRender(expression) { + // 1. Hash it and initialize necessary variables + const hash = Qt.md5(expression) + const imagePath = `${latexOutputPath}/${hash}.svg` + + // 2. Check if the hash is already processed + if (processedHashes.includes(hash)) { + // console.log("Already processed: " + hash) + renderFinished(hash, imagePath) + return [hash, false] + } else { + root.processedHashes.push(hash) + root.processedExpressions[hash] = expression + // console.log("Rendering expression: " + expression) + } + + // 3. If not, render it with MicroTeX and mark as processed + // console.log(`[LatexRenderer] Rendering expression: ${expression} with hash: ${hash}`) + // console.log(` to file: ${imagePath}`) + // console.log(` with command: cd ${microtexBinaryDir} && ./${microtexBinaryName} -headless -input=${StringUtils.shellSingleQuoteEscape(expression)} -output=${imagePath} -textsize=${Appearance.font.pixelSize.normal} -padding=${renderPadding} -background=${Appearance.m3colors.m3tertiary} -foreground=${Appearance.m3colors.m3onTertiary} -maxwidth=0.85`) + const processQml = ` + import Quickshell.Io + Process { + id: microtexProcess${hash} + running: true + command: [ "bash", "-c", + "cd ${root.microtexBinaryDir} && ./${root.microtexBinaryName} -headless '-input=${StringUtils.shellSingleQuoteEscape(StringUtils.escapeBackslashes(expression))}' " + + "'-output=${imagePath}' " + + "'-textsize=${Appearance.font.pixelSize.normal}' " + + "'-padding=${renderPadding}' " + // + "'-background=${Appearance.m3colors.m3tertiary}' " + + "'-foreground=${Appearance.colors.colOnLayer1}' " + + "-maxwidth=0.85 " + ] + // stdout: SplitParser { + // onRead: data => { console.log("MicroTeX: " + data) } + // } + onExited: (exitCode, exitStatus) => { + // console.log("[LatexRenderer] MicroTeX process exited with code: " + exitCode + ", status: " + exitStatus) + renderedImagePaths["${hash}"] = "${imagePath}" + root.renderFinished("${hash}", "${imagePath}") + microtexProcess${hash}.destroy() + } + } + ` + // console.log("MicroTeX: " + processQml) + Qt.createQmlObject(processQml, root, `MicroTeXProcess_${hash}`) + return [hash, true] + } +} \ No newline at end of file diff --git a/.config/quickshell/services/MaterialThemeLoader.qml b/.config/quickshell/services/MaterialThemeLoader.qml new file mode 100644 index 000000000..cd4eb686b --- /dev/null +++ b/.config/quickshell/services/MaterialThemeLoader.qml @@ -0,0 +1,58 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Automatically reloads generated material colors. + * It is necessary to run reapplyTheme() on startup because Singletons are lazily loaded. + */ +Singleton { + id: root + property string filePath: Directories.generatedMaterialThemePath + + function reapplyTheme() { + themeFileView.reload() + } + + function applyColors(fileContent) { + const json = JSON.parse(fileContent) + for (const key in json) { + if (json.hasOwnProperty(key)) { + // Convert snake_case to CamelCase + const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase()) + const m3Key = `m3${camelCaseKey}` + Appearance.m3colors[m3Key] = json[key] + } + } + + Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5) + } + + Timer { + id: delayedFileRead + interval: ConfigOptions?.hacks?.arbitraryRaceConditionDelay ?? 100 + repeat: false + running: false + onTriggered: { + root.applyColors(themeFileView.text()) + } + } + + FileView { + id: themeFileView + path: Qt.resolvedUrl(root.filePath) + watchChanges: true + onFileChanged: { + this.reload() + delayedFileRead.start() + } + onLoadedChanged: { + const fileContent = themeFileView.text() + root.applyColors(fileContent) + } + } +} diff --git a/.config/quickshell/services/MprisController.qml b/.config/quickshell/services/MprisController.qml new file mode 100644 index 000000000..96aa5e80b --- /dev/null +++ b/.config/quickshell/services/MprisController.qml @@ -0,0 +1,164 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://git.outfoxxed.me/outfoxxed/nixnew +// It does not have a license, but the author is okay with redistribution. + +import QtQml.Models +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris + +/** + * A service that provides easy access to the active Mpris player. + */ +Singleton { + id: root; + property MprisPlayer trackedPlayer: null; + property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null; + signal trackChanged(reverse: bool); + + property bool __reverse: false; + + property var activeTrack; + + Instantiator { + model: Mpris.players; + + Connections { + required property MprisPlayer modelData; + target: modelData; + + Component.onCompleted: { + if (root.trackedPlayer == null || modelData.isPlaying) { + root.trackedPlayer = modelData; + } + } + + Component.onDestruction: { + if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) { + for (const player of Mpris.players.values) { + if (player.playbackState.isPlaying) { + root.trackedPlayer = player; + break; + } + } + + if (trackedPlayer == null && Mpris.players.values.length != 0) { + trackedPlayer = Mpris.players.values[0]; + } + } + } + + function onPlaybackStateChanged() { + if (root.trackedPlayer !== modelData) root.trackedPlayer = modelData; + } + } + } + + Connections { + target: activePlayer + + function onPostTrackChanged() { + root.updateTrack(); + } + + function onTrackArtUrlChanged() { + // console.log("arturl:", activePlayer.trackArtUrl) + // root.updateTrack(); + if (root.activePlayer.uniqueId == root.activeTrack.uniqueId && root.activePlayer.trackArtUrl != root.activeTrack.artUrl) { + // cantata likes to send cover updates *BEFORE* updating the track info. + // as such, art url changes shouldn't be able to break the reverse animation + const r = root.__reverse; + root.updateTrack(); + root.__reverse = r; + + } + } + } + + onActivePlayerChanged: this.updateTrack(); + + function updateTrack() { + //console.log(`update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtists}`) + this.activeTrack = { + uniqueId: this.activePlayer?.uniqueId ?? 0, + artUrl: this.activePlayer?.trackArtUrl ?? "", + title: this.activePlayer?.trackTitle || qsTr("Unknown Title"), + artist: this.activePlayer?.trackArtist || qsTr("Unknown Artist"), + album: this.activePlayer?.trackAlbum || qsTr("Unknown Album"), + }; + + this.trackChanged(__reverse); + this.__reverse = false; + } + + property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying; + property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false; + function togglePlaying() { + if (this.canTogglePlaying) this.activePlayer.togglePlaying(); + } + + property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false; + function previous() { + if (this.canGoPrevious) { + this.__reverse = true; + this.activePlayer.previous(); + } + } + + property bool canGoNext: this.activePlayer?.canGoNext ?? false; + function next() { + if (this.canGoNext) { + this.__reverse = false; + this.activePlayer.next(); + } + } + + property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl; + + property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl; + property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None; + function setLoopState(loopState: var) { + if (this.loopSupported) { + this.activePlayer.loopState = loopState; + } + } + + property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl; + property bool hasShuffle: this.activePlayer?.shuffle ?? false; + function setShuffle(shuffle: bool) { + if (this.shuffleSupported) { + this.activePlayer.shuffle = shuffle; + } + } + + function setActivePlayer(player: MprisPlayer) { + const targetPlayer = player ?? Mpris.players[0]; + console.log(`[Mpris] Active player ${targetPlayer} << ${activePlayer}`) + + if (targetPlayer && this.activePlayer) { + this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer); + } else { + // always animate forward if going to null + this.__reverse = false; + } + + this.trackedPlayer = targetPlayer; + } + + IpcHandler { + target: "mpris" + + function pauseAll(): void { + for (const player of Mpris.players.values) { + if (player.canPause) player.pause(); + } + } + + function playPause(): void { root.togglePlaying(); } + function previous(): void { root.previous(); } + function next(): void { root.next(); } + } +} diff --git a/.config/quickshell/services/Network.qml b/.config/quickshell/services/Network.qml new file mode 100644 index 000000000..50bfb671f --- /dev/null +++ b/.config/quickshell/services/Network.qml @@ -0,0 +1,93 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick + +/** + * Simple polled network state service. + */ +Singleton { + id: root + + property bool wifi: true + property bool ethernet: false + property int updateInterval: 1000 + property string networkName: "" + property int networkStrength + property string materialSymbol: ethernet ? "lan" : + (Network.networkName.length > 0 && Network.networkName != "lo") ? ( + Network.networkStrength > 80 ? "signal_wifi_4_bar" : + Network.networkStrength > 60 ? "network_wifi_3_bar" : + Network.networkStrength > 40 ? "network_wifi_2_bar" : + Network.networkStrength > 20 ? "network_wifi_1_bar" : + "signal_wifi_0_bar" + ) : "signal_wifi_off" + function update() { + updateConnectionType.startCheck(); + updateNetworkName.running = true; + updateNetworkStrength.running = true; + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + root.update(); + interval = root.updateInterval; + } + } + + Process { + id: updateConnectionType + property string buffer + command: ["sh", "-c", "nmcli -t -f NAME,TYPE,DEVICE c show --active"] + running: true + function startCheck() { + buffer = ""; + updateConnectionType.running = true; + } + stdout: SplitParser { + onRead: data => { + updateConnectionType.buffer += data + "\n"; + } + } + onExited: (exitCode, exitStatus) => { + const lines = updateConnectionType.buffer.trim().split('\n'); + let hasEthernet = false; + let hasWifi = false; + lines.forEach(line => { + if (line.includes("ethernet")) + hasEthernet = true; + else if (line.includes("wireless")) + hasWifi = true; + }); + root.ethernet = hasEthernet; + root.wifi = hasWifi; + } + } + + Process { + id: updateNetworkName + command: ["sh", "-c", "nmcli -t -f NAME c show --active | head -1"] + running: true + stdout: SplitParser { + onRead: data => { + root.networkName = data; + } + } + } + + Process { + id: updateNetworkStrength + running: true + command: ["sh", "-c", "nmcli -f IN-USE,SIGNAL,SSID device wifi | awk '/^\*/{if (NR!=1) {print $2}}'"] + stdout: SplitParser { + onRead: data => { + root.networkStrength = parseInt(data); + } + } + } +} diff --git a/.config/quickshell/services/Notifications.qml b/.config/quickshell/services/Notifications.qml new file mode 100644 index 000000000..75033292c --- /dev/null +++ b/.config/quickshell/services/Notifications.qml @@ -0,0 +1,274 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import "root:/" +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications + +/** + * Provides extra features not in Quickshell.Services.Notifications: + * - Persistent storage + * - Popup notifications, with timeout + * - Notification groups by app + */ +Singleton { + id: root + component Notif: QtObject { + required property int id + property Notification notification + property list actions: notification?.actions.map((action) => ({ + "identifier": action.identifier, + "text": action.text, + })) ?? [] + property bool popup: false + property string appIcon: notification?.appIcon ?? "" + property string appName: notification?.appName ?? "" + property string body: notification?.body ?? "" + property string image: notification?.image ?? "" + property string summary: notification?.summary ?? "" + property double time + property string urgency: notification?.urgency.toString() ?? "normal" + property Timer timer + } + + function notifToJSON(notif) { + return { + "id": notif.id, + "actions": notif.actions, + "appIcon": notif.appIcon, + "appName": notif.appName, + "body": notif.body, + "image": notif.image, + "summary": notif.summary, + "time": notif.time, + "urgency": notif.urgency, + } + } + function notifToString(notif) { + return JSON.stringify(notifToJSON(notif), null, 2); + } + + component NotifTimer: Timer { + required property int id + interval: 5000 + running: true + onTriggered: () => { + root.timeoutNotification(id); + destroy() + } + } + + property bool silent: false + property var filePath: Directories.notificationsPath + property list list: [] + property var popupList: list.filter((notif) => notif.popup); + property bool popupInhibited: (GlobalStates?.sidebarRightOpen ?? false) || silent + property var latestTimeForApp: ({}) + Component { + id: notifComponent + Notif {} + } + Component { + id: notifTimerComponent + NotifTimer {} + } + + function stringifyList(list) { + return JSON.stringify(list.map((notif) => notifToJSON(notif)), null, 2); + } + + onListChanged: { + // Update latest time for each app + root.list.forEach((notif) => { + if (!root.latestTimeForApp[notif.appName] || notif.time > root.latestTimeForApp[notif.appName]) { + root.latestTimeForApp[notif.appName] = Math.max(root.latestTimeForApp[notif.appName] || 0, notif.time); + } + }); + // Remove apps that no longer have notifications + Object.keys(root.latestTimeForApp).forEach((appName) => { + if (!root.list.some((notif) => notif.appName === appName)) { + delete root.latestTimeForApp[appName]; + } + }); + } + + function appNameListForGroups(groups) { + return Object.keys(groups).sort((a, b) => { + // Sort by time, descending + return groups[b].time - groups[a].time; + }); + } + + function groupsForList(list) { + const groups = {}; + list.forEach((notif) => { + if (!groups[notif.appName]) { + groups[notif.appName] = { + appName: notif.appName, + appIcon: notif.appIcon, + notifications: [], + time: 0 + }; + } + groups[notif.appName].notifications.push(notif); + // Always set to the latest time in the group + groups[notif.appName].time = latestTimeForApp[notif.appName] || notif.time; + }); + return groups; + } + + property var groupsByAppName: groupsForList(root.list) + property var popupGroupsByAppName: groupsForList(root.popupList) + property var appNameList: appNameListForGroups(root.groupsByAppName) + property var popupAppNameList: appNameListForGroups(root.popupGroupsByAppName) + + // Quickshell's notification IDs starts at 1 on each run, while saved notifications + // can already contain higher IDs. This is for avoiding id collisions + property int idOffset + signal initDone(); + signal notify(notification: var); + signal discard(id: var); + signal discardAll(); + signal timeout(id: var); + + NotificationServer { + id: notifServer + // actionIconsSupported: true + actionsSupported: true + bodyHyperlinksSupported: true + bodyImagesSupported: true + bodyMarkupSupported: true + bodySupported: true + imageSupported: true + keepOnReload: false + persistenceSupported: true + + onNotification: (notification) => { + notification.tracked = true + const newNotifObject = notifComponent.createObject(root, { + "id": notification.id + root.idOffset, + "notification": notification, + "time": Date.now(), + }); + root.list = [...root.list, newNotifObject]; + + // Popup + if (!root.popupInhibited) { + newNotifObject.popup = true; + newNotifObject.timer = notifTimerComponent.createObject(root, { + "id": newNotifObject.id, + "interval": notification.expireTimeout < 0 ? 5000 : notification.expireTimeout, + }); + } + + root.notify(newNotifObject); + // console.log(notifToString(newNotifObject)); + notifFileView.setText(stringifyList(root.list)); + } + } + + function discardNotification(id) { + const index = root.list.findIndex((notif) => notif.id === id); + const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id); + if (index !== -1) { + root.list.splice(index, 1); + notifFileView.setText(stringifyList(root.list)); + triggerListChange() + } + if (notifServerIndex !== -1) { + notifServer.trackedNotifications.values[notifServerIndex].dismiss() + } + root.discard(id); + } + + function discardAllNotifications() { + root.list = [] + triggerListChange() + notifFileView.setText(stringifyList(root.list)); + notifServer.trackedNotifications.values.forEach((notif) => { + notif.dismiss() + }) + root.discardAll(); + } + + function timeoutNotification(id) { + const index = root.list.findIndex((notif) => notif.id === id); + if (root.list[index] != null) + root.list[index].popup = false; + root.timeout(id); + } + + function timeoutAll() { + root.popupList.forEach((notif) => { + root.timeout(notif.id); + }) + root.popupList.forEach((notif) => { + notif.popup = false; + }); + } + + function attemptInvokeAction(id, notifIdentifier) { + const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id); + if (notifServerIndex !== -1) { + const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex]; + const action = notifServerNotif.actions.find((action) => action.identifier === notifIdentifier); + action.invoke() + } + // else console.log("Notification not found in server: " + id) + // root.discard(id); + } + + function triggerListChange() { + root.list = root.list.slice(0) + } + + function refresh() { + notifFileView.reload() + } + + Component.onCompleted: { + refresh() + } + + FileView { + id: notifFileView + path: Qt.resolvedUrl(filePath) + onLoaded: { + const fileContents = notifFileView.text() + root.list = JSON.parse(fileContents).map((notif) => { + return notifComponent.createObject(root, { + "id": notif.id, + "actions": notif.actions, + "appIcon": notif.appIcon, + "appName": notif.appName, + "body": notif.body, + "image": notif.image, + "summary": notif.summary, + "time": notif.time, + "urgency": notif.urgency, + }); + }); + // Find largest id + let maxId = 0 + root.list.forEach((notif) => { + maxId = Math.max(maxId, notif.id) + }) + + console.log("[Notifications] File loaded") + root.idOffset = maxId + root.initDone() + } + onLoadFailed: (error) => { + if(error == FileViewError.FileNotFound) { + console.log("[Notifications] File not found, creating new file.") + root.list = [] + notifFileView.setText(stringifyList(root.list)); + } else { + console.log("[Notifications] Error loading file: " + error) + } + } + } +} diff --git a/.config/quickshell/services/PersistentStateManager.qml b/.config/quickshell/services/PersistentStateManager.qml new file mode 100644 index 000000000..c3d1536ed --- /dev/null +++ b/.config/quickshell/services/PersistentStateManager.qml @@ -0,0 +1,105 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import "root:/modules/common/functions/object_utils.js" as ObjectUtils +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Qt.labs.platform + +/** + * Manages persistent states across sessions. + * Run loadStates() once at startup to load the states, then use setState() and getState() to modify and access them. + */ +Singleton { + id: root + property string fileDir: Directories.state + property string fileName: "states.json" + property string filePath: `${root.fileDir}/${root.fileName}` + property bool allowWriteback: false + + function getState(nestedKey) { + let keys = nestedKey.split("."); + let obj = PersistentStates; + for (let i = 0; i < keys.length; ++i) { + if (obj[keys[i]] === undefined) { + console.error(`[PersistentStateManager] Key "${keys[i]}" not found in PersistentStates`); + return null; + } + obj = obj[keys[i]]; + } + return obj; + } + + function setState(nestedKey, value) { + if (!root.allowWriteback) return; + let keys = nestedKey.split("."); + let obj = PersistentStates; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Set the value at the innermost key + obj[keys[keys.length - 1]] = value; + + saveStates() + } + + function loadStates() { + stateFileView.reload() + } + + function saveStates() { + const plainStates = ObjectUtils.toPlainObject(PersistentStates) + stateFileView.setText(JSON.stringify(plainStates, null, 2)) + } + + function applyStates(fileContent) { + try { + const json = JSON.parse(fileContent); + ObjectUtils.applyToQtObject(PersistentStates, json); + root.allowWriteback = true + } catch (e) { + console.error("[PersistentStateManager] Error reading file:", e); + return; + } + } + + Timer { + id: delayedFileRead + interval: ConfigOptions?.hacks?.arbitraryRaceConditionDelay ?? 100 + repeat: false + running: false + onTriggered: { + root.applyStates(stateFileView.text()) + } + } + + FileView { + id: stateFileView + path: root.filePath + watchChanges: true + // onFileChanged: { + // console.log("[PersistentStateManager] File changed, reloading...") + // this.reload() + // delayedFileRead.start() + // } + onLoadedChanged: { + const fileContent = stateFileView.text() + root.applyStates(fileContent) + } + onLoadFailed: (error) => { + console.log("[PersistentStateManager] File not found, creating new file") + root.saveStates() + } + } +} diff --git a/.config/quickshell/services/ResourceUsage.qml b/.config/quickshell/services/ResourceUsage.qml new file mode 100644 index 000000000..c9a501bc2 --- /dev/null +++ b/.config/quickshell/services/ResourceUsage.qml @@ -0,0 +1,62 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Simple polled resource usage service with RAM, Swap, and CPU usage. + */ +Singleton { + property double memoryTotal: 1 + property double memoryFree: 1 + property double memoryUsed: memoryTotal - memoryFree + property double memoryUsedPercentage: memoryUsed / memoryTotal + property double swapTotal: 1 + property double swapFree: 1 + property double swapUsed: swapTotal - swapFree + property double swapUsedPercentage: swapUsed / swapTotal + property double cpuUsage: 0 + property var previousCpuStats + + Timer { + interval: 1 + running: true + repeat: true + onTriggered: { + // Reload files + fileMeminfo.reload() + fileStat.reload() + + // Parse memory and swap usage + const textMeminfo = fileMeminfo.text() + memoryTotal = Number(textMeminfo.match(/MemTotal: *(\d+)/)?.[1] ?? 1) + memoryFree = Number(textMeminfo.match(/MemAvailable: *(\d+)/)?.[1] ?? 0) + swapTotal = Number(textMeminfo.match(/SwapTotal: *(\d+)/)?.[1] ?? 1) + swapFree = Number(textMeminfo.match(/SwapFree: *(\d+)/)?.[1] ?? 0) + + // Parse CPU usage + const textStat = fileStat.text() + const cpuLine = textStat.match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/) + if (cpuLine) { + const stats = cpuLine.slice(1).map(Number) + const total = stats.reduce((a, b) => a + b, 0) + const idle = stats[3] + + if (previousCpuStats) { + const totalDiff = total - previousCpuStats.total + const idleDiff = idle - previousCpuStats.idle + cpuUsage = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0 + } + + previousCpuStats = { total, idle } + } + interval = ConfigOptions?.resources?.updateInterval ?? 3000 + } + } + + FileView { id: fileMeminfo; path: "/proc/meminfo" } + FileView { id: fileStat; path: "/proc/stat" } +} \ No newline at end of file diff --git a/.config/quickshell/services/SystemInfo.qml b/.config/quickshell/services/SystemInfo.qml new file mode 100644 index 000000000..ffd478b65 --- /dev/null +++ b/.config/quickshell/services/SystemInfo.qml @@ -0,0 +1,69 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Provides some system info: distro, username. + */ +Singleton { + property string distroName: "Unknown" + property string distroId: "unknown" + property string distroIcon: "linux-symbolic" + property string username: "user" + + Timer { + triggeredOnStart: true + interval: 1 + running: true + repeat: false + onTriggered: { + getUsername.running = true + fileOsRelease.reload() + const textOsRelease = fileOsRelease.text() + + // Extract the friendly name (PRETTY_NAME field, fallback to NAME) + const prettyNameMatch = textOsRelease.match(/^PRETTY_NAME="(.+?)"/m) + const nameMatch = textOsRelease.match(/^NAME="(.+?)"/m) + distroName = prettyNameMatch ? prettyNameMatch[1] : (nameMatch ? nameMatch[1].replace(/Linux/i, "").trim() : "Unknown") + + // Extract the ID (LOGO field, fallback to "unknown") + const logoMatch = textOsRelease.match(/^LOGO=(.+)$/m) + distroId = logoMatch ? logoMatch[1].replace(/"/g, "") : "unknown" + + // Update the distroIcon property based on distroId + switch (distroId) { + case "arch": distroIcon = "arch-symbolic"; break; + case "endeavouros": distroIcon = "endeavouros-symbolic"; break; + case "cachyos": distroIcon = "cachyos-symbolic"; break; + case "nixos": distroIcon = "nixos-symbolic"; break; + case "fedora": distroIcon = "fedora-symbolic"; break; + case "linuxmint": + case "ubuntu": + case "zorin": + case "popos": distroIcon = "ubuntu-symbolic"; break; + case "debian": + case "raspbian": + case "kali": distroIcon = "debian-symbolic"; break; + default: distroIcon = "linux-symbolic"; break; + } + } + } + + Process { + id: getUsername + command: ["whoami"] + stdout: SplitParser { + onRead: data => { + username = data.trim() + } + } + } + + FileView { + id: fileOsRelease + path: "/etc/os-release" + } +} \ No newline at end of file diff --git a/.config/quickshell/services/Todo.qml b/.config/quickshell/services/Todo.qml new file mode 100644 index 000000000..2cbf0d7dc --- /dev/null +++ b/.config/quickshell/services/Todo.qml @@ -0,0 +1,88 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import Quickshell; +import Quickshell.Io; +import Qt.labs.platform +import QtQuick; + +/** + * Simple to-do list manager. + * Each item is an object with "content" and "done" properties. + */ +Singleton { + id: root + property var filePath: Directories.todoPath + property var list: [] + + function addItem(item) { + list.push(item) + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + + function addTask(desc) { + const item = { + "content": desc, + "done": false, + } + addItem(item) + } + + function markDone(index) { + if (index >= 0 && index < list.length) { + list[index].done = true + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + } + + function markUnfinished(index) { + if (index >= 0 && index < list.length) { + list[index].done = false + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + } + + function deleteItem(index) { + if (index >= 0 && index < list.length) { + list.splice(index, 1) + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + } + + function refresh() { + todoFileView.reload() + } + + Component.onCompleted: { + refresh() + } + + FileView { + id: todoFileView + path: Qt.resolvedUrl(root.filePath) + onLoaded: { + const fileContents = todoFileView.text() + root.list = JSON.parse(fileContents) + console.log("[To Do] File loaded") + } + onLoadFailed: (error) => { + if(error == FileViewError.FileNotFound) { + console.log("[To Do] File not found, creating new file.") + root.list = [] + todoFileView.setText(JSON.stringify(root.list)) + } else { + console.log("[To Do] Error loading file: " + error) + } + } + } +} + diff --git a/.config/quickshell/services/Ydotool.qml b/.config/quickshell/services/Ydotool.qml new file mode 100644 index 000000000..7cafcbbe2 --- /dev/null +++ b/.config/quickshell/services/Ydotool.qml @@ -0,0 +1,42 @@ +pragma Singleton + +import "root:/modules/common" +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +Singleton { + id: root + property int shiftMode: 0 // 0: off, 1: on, 2: lock + property list shiftKeys: [42, 54] // Keycodes for Shift keys (left and right) + property list altKeys: [56, 100] // Keycodes for Alt keys (left and right) + property list ctrlKeys: [29, 97] // Keycodes for Ctrl keys (left and right) + + onShiftModeChanged: { + if (shiftMode === 0) { + + } + } + + function releaseAllKeys() { + const keycodes = Array.from(Array(249).keys()); + const releaseCommand = `ydotool key --key-delay 0 ${keycodes.map(keycode => `${keycode}:0`).join(" ")}` + Hyprland.dispatch(`exec ${releaseCommand}`) + root.shiftMode = 0; // Reset shift mode + } + + function releaseShiftKeys() { + const releaseCommand = `ydotool key --key-delay 0 ${root.shiftKeys.map(keycode => `${keycode}:0`).join(" ")}` + Hyprland.dispatch(`exec ${releaseCommand}`) + root.shiftMode = 0; // Reset shift mode + } + + function press(keycode) { + Hyprland.dispatch(`exec ydotool key --key-delay 0 ${keycode}:1`); + } + + function release(keycode) { + Hyprland.dispatch(`exec ydotool key --key-delay 0 ${keycode}:0`); + } +} + diff --git a/.config/quickshell/shell.qml b/.config/quickshell/shell.qml new file mode 100644 index 000000000..4edfde51f --- /dev/null +++ b/.config/quickshell/shell.qml @@ -0,0 +1,73 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic + +// Adjust this to make the shell smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +import "./modules/common/" +import "./modules/backgroundWidgets/" +import "./modules/bar/" +import "./modules/cheatsheet/" +import "./modules/dock/" +import "./modules/mediaControls/" +import "./modules/notificationPopup/" +import "./modules/onScreenDisplay/" +import "./modules/onScreenKeyboard/" +import "./modules/overview/" +import "./modules/screenCorners/" +import "./modules/session/" +import "./modules/sidebarLeft/" +import "./modules/sidebarRight/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Quickshell +import "./services/" + +ShellRoot { + // Enable/disable modules here. False = not loaded at all, so rest assured + // no unnecessary stuff will take up memory if you decide to only use, say, the overview. + property bool enableBar: true + property bool enableBackgroundWidgets: true + property bool enableCheatsheet: true + property bool enableDock: false + property bool enableMediaControls: true + property bool enableNotificationPopup: true + property bool enableOnScreenDisplayBrightness: true + property bool enableOnScreenDisplayVolume: true + property bool enableOnScreenKeyboard: true + property bool enableOverview: true + property bool enableReloadPopup: true + property bool enableScreenCorners: true + property bool enableSession: true + property bool enableSidebarLeft: true + property bool enableSidebarRight: true + + // Force initialization of some singletons + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme() + ConfigLoader.loadConfig() + PersistentStateManager.loadStates() + Cliphist.refresh() + FirstRunExperience.load() + } + + LazyLoader { active: enableBar; component: Bar {} } + LazyLoader { active: enableBackgroundWidgets; component: BackgroundWidgets {} } + LazyLoader { active: enableCheatsheet; component: Cheatsheet {} } + LazyLoader { active: enableDock; component: Dock {} } + LazyLoader { active: enableMediaControls; component: MediaControls {} } + LazyLoader { active: enableNotificationPopup; component: NotificationPopup {} } + LazyLoader { active: enableOnScreenDisplayBrightness; component: OnScreenDisplayBrightness {} } + LazyLoader { active: enableOnScreenDisplayVolume; component: OnScreenDisplayVolume {} } + LazyLoader { active: enableOnScreenKeyboard; component: OnScreenKeyboard {} } + LazyLoader { active: enableOverview; component: Overview {} } + LazyLoader { active: enableReloadPopup; component: ReloadPopup {} } + LazyLoader { active: enableScreenCorners; component: ScreenCorners {} } + LazyLoader { active: enableSession; component: Session {} } + LazyLoader { active: enableSidebarLeft; component: SidebarLeft {} } + LazyLoader { active: enableSidebarRight; component: SidebarRight {} } +} + diff --git a/.config/starship.toml b/.config/starship.toml index 751f2fd2c..5ed04489e 100644 --- a/.config/starship.toml +++ b/.config/starship.toml @@ -10,25 +10,25 @@ add_newline = false # $character # """ format = """ -$cmd_duration$directory $git_branch +$cmd_duration 󰜥 $directory $git_branch $character """ # Replace the "❯" symbol in the prompt with "➜" [character] # The name of the module we are configuring is "character" -success_symbol = "[• ](bold fg:green) " -error_symbol = "[• 󰅙](bold fg:red) " +success_symbol = "[ 󰜥 ](bold fg:blue)" +error_symbol = "[ 󰜥 ](bold fg:red)" # Disable the package module, hiding it from the prompt completely [package] disabled = true [git_branch] -style = "bg: green" +style = "bg: cyan" symbol = "󰘬" -truncation_length = 4 +truncation_length = 12 truncation_symbol = "" -format = "• [](bold fg:green)[$symbol $branch(:$remote_branch)](fg:black bg:green)[ ](bold fg:green)" +format = "󰜥 [](bold fg:cyan)[$symbol $branch(:$remote_branch)](fg:black bg:cyan)[ ](bold fg:cyan)" [git_commit] commit_hash_length = 4 @@ -52,7 +52,7 @@ deleted = " 🗑 " [hostname] ssh_only = false -format = "[•$hostname](bg:cyan bold fg:black)[](bold fg:cyan )" +format = "[•$hostname](bg:cyan bold fg:black)[](bold fg:cyan)" trim_at = ".companyname.com" disabled = false @@ -82,8 +82,8 @@ home_symbol = "  " read_only = "  " style = "bg:green fg:black" truncation_length = 6 -truncation_symbol = "••/" -format = '[](bold fg:green)[$path ]($style)[](bold fg:green)' +truncation_symbol = " ••/" +format = '[](bold fg:green)[󰉋 $path]($style)[](bold fg:green)' [directory.substitutions] @@ -93,7 +93,8 @@ format = '[](bold fg:green)[$path ]($style)[](bold fg:green)' "Music" = " 󰎈 " "Pictures" = "  " "Videos" = "  " +"GitHub" = " 󰊤 " [cmd_duration] min_time = 0 -format = '[](bold fg:yellow)[ $duration](bold bg:yellow fg:black)[](bold fg:yellow) •• ' +format = '[](bold fg:yellow)[󰪢 $duration](bold bg:yellow fg:black)[](bold fg:yellow)' diff --git a/.local/bin/rubyshot b/.local/bin/rubyshot deleted file mode 100755 index 8431bd693..000000000 --- a/.local/bin/rubyshot +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/bash - -WORKSPACES="$(hyprctl monitors -j | jq -r 'map(.activeWorkspace.id)')" -WINDOWS="$(hyprctl clients -j | jq -r --argjson workspaces "$WORKSPACES" 'map(select([.workspace.id] | inside($workspaces)))' )" -GEOM=$(echo "$WINDOWS" | jq -r '.[] | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' | slurp -f '%x %y %w %h') -wayshot -s "$GEOM" --stdout ${#:+"$@"} \ No newline at end of file diff --git a/README.md b/README.md index 0c3a78267..33ceb2cd4 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,18 @@ ```bash bash <(curl -s "https://end-4.github.io/dots-hyprland-wiki/setup.sh") ``` - - **Manual** installation, other distros and more: - - See the [Wiki](https://end-4.github.io/dots-hyprland-wiki/en/i-i/01setup/) - - (_Available in: English, Vietnamese, and Simplified Chinese. Translations are welcome._) - - - **Default keybinds**: Parts similar to Windows and GNOME. Hit Super+/ for a list. -
- Here's an image, just in case... - - ![image](https://github.com/user-attachments/assets/dff2f842-5458-4f5a-89ec-3979095574de) -
+ If you are using fish shell (non-posix-compliant shell) then: + ```bash + bash -c "$(curl -s https://end-4.github.io/dots-hyprland-wiki/setup.sh)" + ``` + + - **Manual** installation, other distros and more: + - See the [Wiki](https://end-4.github.io/dots-hyprland-wiki/en/ii-qs/01setup/) + + - **Default keybinds**: Should be somewhat familiar if you've used Windows or GNOME. + - For a list, hit `Super`+`/` + - For a terminal, hit `Super`+`Enter` @@ -56,42 +57,37 @@ | Software | Purpose | | ------------- | ------------- | | [Hyprland](https://github.com/hyprwm/hyprland) | The compositor (for noobs, you can just call it a window manager) | - | [AGS](https://github.com/Aylur/ags) | A GTK widget system, responsible for the status bar, sidebars, etc. | - | [Fuzzel](https://mark.stosberg.com/fuzzel-a-great-dmenu-and-rofi-altenrative-for-wayland/) | For clipboard and emoji picker | + | [Quickshell](https://quickshell.outfoxxed.me/) | A QtQuick-based widget system, responsible for the status bar, sidebars, etc. | + - For a more comprehensive list of dependencies, see [scriptdata/dependencies.conf](https://github.com/end-4/dots-hyprland/blob/main/scriptdata/dependencies.conf) -
- Help improve these dotfiles - - - Try the Quickshell-powered version at [`ii-qs` branch](https://github.com/end-4/dots-hyprland/tree/ii-qs) - It comes with major improvements, and you're free to make suggestions 👉 [#1276](https://github.com/end-4/dots-hyprland/pull/1276) - -
-

• screenshots •

-## Main branch (*illogical-impulse*) +## illogical-impulseQuickshell -### AI -![image](https://github.com/user-attachments/assets/9d7af13f-89ef-470d-ba78-d2288b79cf60) -_Sidebar offers online and offline chat. Text selection summary is offline only for privacy._ +| AI | Common widgets | +|:---|:---------------| +| ![image](https://github.com/user-attachments/assets/08d26785-b54d-4ad1-875b-bb08cc6757f5) | ![image](https://github.com/user-attachments/assets/4fcd63d9-0943-4b21-8737-4bed97b71961) | +| Window management | Weeb power | +| ![image](https://github.com/user-attachments/assets/86cc511b-0d33-4c78-bcc0-3037d02a17da) | ![image](https://github.com/user-attachments/assets/292259fc-57d3-4663-a583-2ce2faad13fb) | -### Notifications, music controls, system, calendar -![image](https://github.com/end-4/dots-hyprland/assets/97237370/406b72b6-fa38-4f0d-a6c4-4d7d5d5ddcb7) -_On the sidebar: flicking the notification_ +By the way... +- The funny notification positions are mimicking Android 16's dragging behavior +- The clock on the wallpaper is automatically placed at the "least busy" region of the image -### Intuitive window management -![image](https://github.com/user-attachments/assets/02983b9b-79ba-4c25-8717-90bef2357ae5) -_You can also drag and drop windows across workspaces_ +## illogical-impulseAGS (Deprecated) -### Power to weebs -![image](https://github.com/user-attachments/assets/bbb332ec-962a-4e88-a95b-486d0bd8ce76) -_Get yande.re and konachan images from sidebar_ +| AI | Common widgets | +|:---|:---------------| +| ![image](https://github.com/user-attachments/assets/9d7af13f-89ef-470d-ba78-d2288b79cf60) | ![image](https://github.com/end-4/dots-hyprland/assets/97237370/406b72b6-fa38-4f0d-a6c4-4d7d5d5ddcb7) | +| Window management | Weeb power | +| ![image](https://github.com/user-attachments/assets/02983b9b-79ba-4c25-8717-90bef2357ae5) | ![image](https://github.com/user-attachments/assets/bbb332ec-962a-4e88-a95b-486d0bd8ce76) | ## Unsupported stuff @@ -124,8 +120,10 @@ _Get yande.re and konachan images from sidebar_

- - [@clsty](https://github.com/clsty) for making an actually good install script + many other stuff that I neglect + - [@clsty](https://github.com/clsty) for making my work accessible by taking care of the install script and many other things - [@midn8hustlr](https://github.com/midn8hustlr) for greatly improving the color generation system + - [@outfoxxed](https://github.com/outfoxxed/) for being extremely supportive in my Quickshell journey + - Quickshell: [Soramane](https://github.com/caelestia-dots/shell/), [FridayFaerie](https://github.com/FridayFaerie/quickshell), [nydragon](https://github.com/nydragon/nysh) - AGS: [Aylur's config](https://github.com/Aylur/dotfiles/tree/ags-pre-ts), [kotontrion's config](https://github.com/kotontrion/dotfiles) - EWW: [fufexan's config](https://github.com/fufexan/dotfiles) (he thanks more people there btw) - AI bots for providing useful examples diff --git a/arch-packages/illogical-impulse-agsv1-git/.gitignore b/arch-packages/illogical-impulse-agsv1-git/.gitignore deleted file mode 100644 index 1faa43724..000000000 --- a/arch-packages/illogical-impulse-agsv1-git/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/ii-agsv1/ -/libgnome-volume-control/ diff --git a/arch-packages/illogical-impulse-agsv1-git/PKGBUILD b/arch-packages/illogical-impulse-agsv1-git/PKGBUILD deleted file mode 100644 index 54a28984c..000000000 --- a/arch-packages/illogical-impulse-agsv1-git/PKGBUILD +++ /dev/null @@ -1,45 +0,0 @@ -# Modified from AUR package "aylurs-gtk-shell-git" maintained by kotontrion -pkgname=illogical-impulse-agsv1-git -_pkgname=ii-agsv1 -pkgver=r4.3e8d365 -pkgrel=4 -pkgdesc="Aylurs's Gtk Shell (AGS), patched for illogical-impulse dotfiles." -arch=('x86_64') -url="https://github.com/end-4/ii-agsv1" -license=('GPL-3.0-only') -makedepends=('git' 'gobject-introspection' 'meson' 'npm' 'typescript') -depends=('gvfs' 'gjs' 'glib2' 'glib2-devel' 'glibc' 'gtk3' 'gtk-layer-shell' 'libpulse' 'pam' 'gnome-bluetooth-3.0' 'gammastep') -optdepends=('greetd: required for greetd service' - 'libdbusmenu-gtk3: required for systemtray service' - 'libsoup3: required for the Utils.fetch feature' - 'libnotify: required for sending notifications' - 'networkmanager: required for network service' - 'power-profiles-daemon: required for powerprofiles service' - 'upower: required for battery service') -conflicts=('illogical-impulse-agsv1') -backup=('etc/pam.d/ags') -source=("git+${url}.git") -sha256sums=('SKIP') - -pkgver(){ - cd $srcdir/$_pkgname - printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" -} - -prepare() { - cd $srcdir/$_pkgname -} - -build() { - cd $srcdir/$_pkgname - npm install - arch-meson build --libdir "lib/$_pkgname" -Dbuild_types=true - meson compile -C build -} - -package() { - cd $srcdir/$_pkgname - meson install -C build --destdir "$pkgdir" - rm ${pkgdir}/usr/bin/ags - ln -sf /usr/share/com.github.Aylur.ags/com.github.Aylur.ags ${pkgdir}/usr/bin/agsv1 -} diff --git a/arch-packages/illogical-impulse-audio/PKGBUILD b/arch-packages/illogical-impulse-audio/PKGBUILD index d2d1e7889..6cab7de53 100644 --- a/arch-packages/illogical-impulse-audio/PKGBUILD +++ b/arch-packages/illogical-impulse-audio/PKGBUILD @@ -5,10 +5,10 @@ pkgdesc='Illogical Impulse Audio Dependencies' arch=(any) license=(None) depends=( - pavucontrol + cava + pavucontrol-qt wireplumber libdbusmenu-gtk3 playerctl - swww ) diff --git a/arch-packages/illogical-impulse-backlight/PKGBUILD b/arch-packages/illogical-impulse-backlight/PKGBUILD index fe216262c..2525be9d4 100644 --- a/arch-packages/illogical-impulse-backlight/PKGBUILD +++ b/arch-packages/illogical-impulse-backlight/PKGBUILD @@ -5,6 +5,8 @@ pkgdesc='Illogical Impulse Backlight Dependencies' arch=(any) license=(None) depends=( + gammastep + geoclue brightnessctl ddcutil ) diff --git a/arch-packages/illogical-impulse-basic/PKGBUILD b/arch-packages/illogical-impulse-basic/PKGBUILD index ac74a3c36..c338727f9 100644 --- a/arch-packages/illogical-impulse-basic/PKGBUILD +++ b/arch-packages/illogical-impulse-basic/PKGBUILD @@ -11,14 +11,10 @@ depends=( cliphist cmake curl - fuzzel rsync wget ripgrep jq - npm meson - typescript - gjs xdg-user-dirs ) diff --git a/arch-packages/illogical-impulse-fonts-themes/PKGBUILD b/arch-packages/illogical-impulse-fonts-themes/PKGBUILD index 038d53c4a..ed1a95070 100644 --- a/arch-packages/illogical-impulse-fonts-themes/PKGBUILD +++ b/arch-packages/illogical-impulse-fonts-themes/PKGBUILD @@ -6,19 +6,17 @@ arch=(any) license=(None) depends=( adw-gtk-theme-git - qt5ct - qt6ct - qt5-wayland + breeze-plus + eza + fish fontconfig + kde-material-you-colors + kitty + matugen-bin + starship ttf-readex-pro ttf-jetbrains-mono-nerd ttf-material-symbols-variable-git - ttf-space-mono-nerd ttf-rubik-vf ttf-gabarito-git - fish - foot - starship - kvantum - kvantum-qt5 ) diff --git a/arch-packages/illogical-impulse-gnome/PKGBUILD b/arch-packages/illogical-impulse-gnome/PKGBUILD deleted file mode 100644 index 33af4b8ba..000000000 --- a/arch-packages/illogical-impulse-gnome/PKGBUILD +++ /dev/null @@ -1,12 +0,0 @@ -pkgname=illogical-impulse-gnome -pkgver=1.0 -pkgrel=2 -pkgdesc='Illogical Impulse GNOME Dependencies' -arch=(any) -license=(None) -depends=( - polkit-gnome - gnome-keyring - gnome-control-center - blueberry networkmanager -) diff --git a/arch-packages/illogical-impulse-gtk/PKGBUILD b/arch-packages/illogical-impulse-gtk/PKGBUILD deleted file mode 100644 index 50a57d1c8..000000000 --- a/arch-packages/illogical-impulse-gtk/PKGBUILD +++ /dev/null @@ -1,18 +0,0 @@ -pkgname=illogical-impulse-gtk -pkgver=1.0 -pkgrel=1 -pkgdesc='Illogical Impulse GTK Dependencies' -arch=(any) -license=(None) -depends=( - webp-pixbuf-loader - gtk-layer-shell - gtk3 - gtksourceview3 - gobject-introspection - upower - yad - ydotool - xdg-user-dirs-gtk -) - diff --git a/arch-packages/illogical-impulse-hyprland/PKGBUILD b/arch-packages/illogical-impulse-hyprland/PKGBUILD index c743af7ac..93c7b5f8e 100644 --- a/arch-packages/illogical-impulse-hyprland/PKGBUILD +++ b/arch-packages/illogical-impulse-hyprland/PKGBUILD @@ -12,8 +12,9 @@ depends=( hyprland-qt-support hyprland-qtutils hyprlock - xdg-desktop-portal-hyprland hyprcursor hyprwayland-scanner hyprland + xdg-desktop-portal-hyprland + wl-clipboard ) diff --git a/arch-packages/illogical-impulse-kde/PKGBUILD b/arch-packages/illogical-impulse-kde/PKGBUILD new file mode 100644 index 000000000..b8b91c3f9 --- /dev/null +++ b/arch-packages/illogical-impulse-kde/PKGBUILD @@ -0,0 +1,14 @@ +pkgname=illogical-impulse-kde +pkgver=1.0 +pkgrel=2 +pkgdesc='Illogical Impulse KDE Dependencies' +arch=(any) +license=(None) +depends=( + bluedevil + gnome-keyring + networkmanager + plasma-nm + polkit-kde-agent + systemsettings +) diff --git a/arch-packages/illogical-impulse-python/PKGBUILD b/arch-packages/illogical-impulse-python/PKGBUILD index fc1ac1e05..d9c6caa55 100644 --- a/arch-packages/illogical-impulse-python/PKGBUILD +++ b/arch-packages/illogical-impulse-python/PKGBUILD @@ -13,4 +13,5 @@ depends=( libportal-gtk4 gobject-introspection sassc + python-opencv ) diff --git a/arch-packages/illogical-impulse-screencapture/PKGBUILD b/arch-packages/illogical-impulse-screencapture/PKGBUILD index ab5911fb9..8feb1c0d0 100644 --- a/arch-packages/illogical-impulse-screencapture/PKGBUILD +++ b/arch-packages/illogical-impulse-screencapture/PKGBUILD @@ -7,7 +7,7 @@ license=(None) depends=( swappy wf-recorder - grim + hyprshot tesseract tesseract-data-eng slurp diff --git a/arch-packages/illogical-impulse-toolkit/PKGBUILD b/arch-packages/illogical-impulse-toolkit/PKGBUILD new file mode 100644 index 000000000..6f12584a2 --- /dev/null +++ b/arch-packages/illogical-impulse-toolkit/PKGBUILD @@ -0,0 +1,26 @@ +pkgname=illogical-impulse-toolkit +pkgver=1.0 +pkgrel=1 +pkgdesc='Illogical Impulse GTK/Qt Dependencies' +arch=(any) +license=(None) +depends=( + kdialog + qt6-5compat + qt6-base + qt6-declarative + qt6-imageformats + qt6-multimedia + qt6-positioning + qt6-quicktimeline + qt6-sensors + qt6-svg + qt6-tools + qt6-translations + qt6-virtualkeyboard + qt6-wayland + syntax-highlighting + upower + wtype + ydotool +) diff --git a/arch-packages/illogical-impulse-widgets/PKGBUILD b/arch-packages/illogical-impulse-widgets/PKGBUILD index cdff28667..868bea9e6 100644 --- a/arch-packages/illogical-impulse-widgets/PKGBUILD +++ b/arch-packages/illogical-impulse-widgets/PKGBUILD @@ -5,12 +5,15 @@ pkgdesc='Illogical Impulse Widget Dependencies' arch=(any) license=(None) depends=( - dart-sass + fuzzel + glib2 # for `gsettings` it seems? hypridle - hyprutils + hyprutils hyprlock - wlogout - wl-clipboard hyprpicker nm-connection-editor + quickshell + swww + translate-shell + wlogout ) diff --git a/diagnose b/diagnose index 50c2cc000..df1d0dee4 100755 --- a/diagnose +++ b/diagnose @@ -38,16 +38,6 @@ ii_check_venv() { which python deactivate } -ii_check_ags() { - pkill ags - pkill agsv1 - agsv1 > ii_ags.log 2>&1 & - GUI_PID=$! - sleep 10 - kill $GUI_PID - echo "AGS log saved to \"ii_ags.log\"." -} -#x ii_check_ags e "Checking git repo info" x git remote get-url origin @@ -57,15 +47,14 @@ e "Checking distro" x ii_check_distro e "Checking variables" -x declare -p XDG_BIN_HOME # ~/.local/bin x declare -p XDG_CACHE_HOME # ~/.cache x declare -p XDG_CONFIG_HOME # ~/.config x declare -p XDG_DATA_HOME # ~/.local/share x declare -p XDG_STATE_HOME # ~/.local/state -x declare -p ILLOGICAL_IMPULSE_VIRTUAL_ENV # $XDG_STATE_HOME/ags/.venv +x declare -p ILLOGICAL_IMPULSE_VIRTUAL_ENV # $XDG_STATE_HOME/quickshell/.venv e "Checking directories/files" -x ls -l ~/.local/state/ags/.venv +x ls -l ~/.local/state/quickshell/.venv #x cat ~/.config/ags/ #e "Checking command existence" @@ -77,8 +66,6 @@ commands+=(ags agsv1) e "Checking versions" x Hyprland --version -x ags --version -x agsv1 --version e "Finished. Output saved as \"$output_file\"." if ! command -v curl 2>&1 >>/dev/null ;then echo "\"curl\" not found, pastebin upload unavailable.";exit;fi diff --git a/install.sh b/install.sh index 9836bb846..e22f912b4 100755 --- a/install.sh +++ b/install.sh @@ -15,6 +15,11 @@ prevent_sudo_or_root startask () { printf "\e[34m[$0]: Hi there! Before we start:\n" + printf '\n' + printf '[NEW] illogical-impulse is now powered by Quickshell. If you were using the old AGS version and would like to keep it, do not run this script.\n' + printf ' The AGS version, although uses less memory, has much worse performance. If you do not need (inconsistent) translations, the Quickshell version is recommended.\n' + printf ' If you would like it anyway, run the script in its branch instead: git checkout ii-ags && ./install.sh\n' + printf '\n' printf 'This script 1. only works for ArchLinux and Arch-based distros.\n' printf ' 2. does not handle system-level/hardware stuff like Nvidia drivers\n' printf "\e[31m" @@ -100,11 +105,10 @@ install-local-pkgbuild() { } # Install core dependencies from the meta-packages -metapkgs=(./arch-packages/illogical-impulse-{audio,python,backlight,basic,fonts-themes,gnome,gtk,portal,screencapture,widgets}) -metapkgs+=(./arch-packages/illogical-impulse-agsv1-git) +metapkgs=(./arch-packages/illogical-impulse-{audio,backlight,basic,fonts-themes,kde,portal,python,screencapture,toolkit,widgets}) metapkgs+=(./arch-packages/illogical-impulse-hyprland) metapkgs+=(./arch-packages/illogical-impulse-microtex-git) -metapkgs+=(./arch-packages/illogical-impulse-oneui4-icons-git) +# metapkgs+=(./arch-packages/illogical-impulse-oneui4-icons-git) [[ -f /usr/share/icons/Bibata-Modern-Classic/index.theme ]] || \ metapkgs+=(./arch-packages/illogical-impulse-bibata-modern-classic-bin) @@ -119,31 +123,33 @@ showfun install-python-packages v install-python-packages ## Optional dependencies -# if pacman -Qs ^plasma-browser-integration$ ;then SKIP_PLASMAINTG=true;fi -# case $SKIP_PLASMAINTG in -# true) sleep 0;; -# *) -# if $ask;then -# echo -e "\e[33m[$0]: NOTE: The size of \"plasma-browser-integration\" is about 250 MiB.\e[0m" -# echo -e "\e[33mIt is needed if you want playtime of media in Firefox to be shown on the music controls widget.\e[0m" -# echo -e "\e[33mInstall it? [y/N]\e[0m" -# read -p "====> " p -# else -# p=y -# fi -# case $p in -# y) x sudo pacman -S --needed --noconfirm plasma-browser-integration ;; -# *) echo "Ok, won't install" -# esac -# ;; -# esac +if pacman -Qs ^plasma-browser-integration$ ;then SKIP_PLASMAINTG=true;fi +case $SKIP_PLASMAINTG in + true) sleep 0;; + *) + if $ask;then + echo -e "\e[33m[$0]: NOTE: The size of \"plasma-browser-integration\" is about 600 MiB.\e[0m" + echo -e "\e[33mIt is needed if you want playtime of media in Firefox to be shown on the music controls widget.\e[0m" + echo -e "\e[33mInstall it? [y/N]\e[0m" + read -p "====> " p + else + p=y + fi + case $p in + y) x sudo pacman -S --needed --noconfirm plasma-browser-integration ;; + *) echo "Ok, won't install" + esac + ;; +esac v sudo usermod -aG video,i2c,input "$(whoami)" v bash -c "echo i2c-dev | sudo tee /etc/modules-load.d/i2c-dev.conf" +v sudo pacman -S archlinux-xdg-menu && XDG_MENU_PREFIX=arch- kbuildsycoca6; sudo ln -s /etc/xdg/menus/plasma-applications.menu /etc/xdg/menus/applications.menu v systemctl --user enable ydotool --now v sudo systemctl enable bluetooth --now v gsettings set org.gnome.desktop.interface font-name 'Rubik 11' v gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' +v kwriteconfig6 --file kdeglobals --group KDE --key widgetStyle Darkly ##################################################################################### @@ -156,11 +162,11 @@ v mkdir -p $XDG_BIN_HOME $XDG_CACHE_HOME $XDG_CONFIG_HOME $XDG_DATA_HOME # original dotfiles and new ones in the SAME DIRECTORY # (eg. in ~/.config/hypr) won't be mixed together -# MISC (For .config/* but not AGS, not Fish, not Hyprland) +# MISC (For .config/* but not fish, not Hyprland) case $SKIP_MISCCONF in true) sleep 0;; *) - for i in $(find .config/ -mindepth 1 -maxdepth 1 ! -name 'ags' ! -name 'fish' ! -name 'hypr' -exec basename {} \;); do + for i in $(find .config/ -mindepth 1 -maxdepth 1 ! -name 'fish' ! -name 'hypr' -exec basename {} \;); do # i=".config/$i" echo "[$0]: Found target: .config/$i" if [ -d ".config/$i" ];then v rsync -av --delete ".config/$i/" "$XDG_CONFIG_HOME/$i/" @@ -177,24 +183,6 @@ case $SKIP_FISH in ;; esac -# For AGS -case $SKIP_AGS in - true) sleep 0;; - *) - v rsync -av --delete --exclude '/user_options.jsonc' .config/ags/ "$XDG_CONFIG_HOME"/ags/ - t="$XDG_CONFIG_HOME/ags/user_options.jsonc" - if [ -f $t ];then - echo -e "\e[34m[$0]: \"$t\" already exists.\e[0m" - # v cp -f .config/ags/user_options.jsonc $t.new - existed_ags_opt=y - else - echo -e "\e[33m[$0]: \"$t\" does not exist yet.\e[0m" - v cp .config/ags/user_options.jsonc $t - existed_ags_opt=n - fi - ;; -esac - # For Hyprland case $SKIP_HYPRLAND in true) sleep 0;; @@ -203,15 +191,9 @@ case $SKIP_HYPRLAND in t="$XDG_CONFIG_HOME/hypr/hyprland.conf" if [ -f $t ];then echo -e "\e[34m[$0]: \"$t\" already exists.\e[0m" - if [ -f "$XDG_STATE_HOME/ags/user/firstrun.txt" ] - then - v cp -f .config/hypr/hyprland.conf $t.new - existed_hypr_conf=y - else - v mv $t $t.old - v cp -f .config/hypr/hyprland.conf $t - existed_hypr_conf_firstrun=y - fi + v mv $t $t.old + v cp -f .config/hypr/hyprland.conf $t + existed_hypr_conf_firstrun=y else echo -e "\e[33m[$0]: \"$t\" does not exist yet.\e[0m" v cp .config/hypr/hyprland.conf $t @@ -250,7 +232,7 @@ esac # some foldes (eg. .local/bin) should be processed separately to avoid `--delete' for rsync, # since the files here come from different places, not only about one program. -v rsync -av ".local/bin/" "$XDG_BIN_HOME" +# v rsync -av ".local/bin/" "$XDG_BIN_HOME" # No longer needed since scripts are no longer in ~/.local/bin # Prevent hyprland from not fully loaded sleep 1 @@ -261,10 +243,7 @@ grep -q 'source ${XDG_CONFIG_HOME:-~/.config}/zshrc.d/dots-hyprland.zsh' ~/.zshr warn_files=() warn_files_tests=() -warn_files_tests+=(/usr/local/bin/ags) -warn_files_tests+=(/usr/local/etc/pam.d/ags) warn_files_tests+=(/usr/local/lib/{GUtils-1.0.typelib,Gvc-1.0.typelib,libgutils.so,libgvc.so}) -warn_files_tests+=(/usr/local/share/com.github.Aylur.ags) warn_files_tests+=(/usr/local/share/fonts/TTF/Rubik{,-Italic}'[wght]'.ttf) warn_files_tests+=(/usr/local/share/licenses/ttf-rubik) warn_files_tests+=(/usr/local/share/fonts/TTF/Gabarito-{Black,Bold,ExtraBold,Medium,Regular,SemiBold}.ttf) @@ -290,10 +269,6 @@ printf "\e[36mPress \e[30m\e[46m Ctrl+Super+T \e[0m\e[36m to select a wallpaper\ printf "\e[36mPress \e[30m\e[46m Super+/ \e[0m\e[36m for a list of keybinds\e[0m\n" printf "\n" -case $existed_ags_opt in - y) printf "\n\e[33m[$0]: Warning: \"$XDG_CONFIG_HOME/ags/user_options.jsonc\" already existed before and we didn't overwrite it. \e[0m\n" -# printf "\e[33mPlease use \"$XDG_CONFIG_HOME/ags/user_options.jsonc.new\" as a reference for a proper format.\e[0m\n" -;;esac case $existed_hypr_conf_firstrun in y) printf "\n\e[33m[$0]: Warning: \"$XDG_CONFIG_HOME/hypr/hyprland.conf\" already existed before. As it seems it is your first run, we replaced it with a new one. \e[0m\n" printf "\e[33mAs it seems it is your first run, we replaced it with a new one. The old one has been renamed to \"$XDG_CONFIG_HOME/hypr/hyprland.conf.old\".\e[0m\n" @@ -312,7 +287,7 @@ case $existed_hyprlock_conf in ;;esac if [[ -z "${ILLOGICAL_IMPULSE_VIRTUAL_ENV}" ]]; then - printf "\n\e[31m[$0]: \!! Important \!! : Please ensure environment variable \e[0m \$ILLOGICAL_IMPULSE_VIRTUAL_ENV \e[31m is set to proper value (by default \"~/.local/state/ags/.venv\"), or AGS config will not work. We have already provided this configuration in ~/.config/hypr/hyprland/env.conf, but you need to ensure it is included in hyprland.conf, and also a restart is needed for applying it.\e[0m\n" + printf "\n\e[31m[$0]: \!! Important \!! : Please ensure environment variable \e[0m \$ILLOGICAL_IMPULSE_VIRTUAL_ENV \e[31m is set to proper value (by default \"~/.local/state/quickshell/.venv\"), or Quickshell config will not work. We have already provided this configuration in ~/.config/hypr/hyprland/env.conf, but you need to ensure it is included in hyprland.conf, and also a restart is needed for applying it.\e[0m\n" fi if [[ ! -z "${warn_files[@]}" ]]; then diff --git a/licenses/LGPL-3.0.txt b/licenses/LGPL-3.0.txt new file mode 100644 index 000000000..0a041280b --- /dev/null +++ b/licenses/LGPL-3.0.txt @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser 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 +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/licenses/MIT.txt b/licenses/MIT.txt new file mode 100644 index 000000000..9a7392926 --- /dev/null +++ b/licenses/MIT.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/licenses/README.md b/licenses/README.md new file mode 100644 index 000000000..02bf9af72 --- /dev/null +++ b/licenses/README.md @@ -0,0 +1,3 @@ +# Licenses + +This repository contains code from other repositories. Files containing such code should include a license notice, and a copy should be stored in this folder. diff --git a/manual-install-helper.sh b/manual-install-helper.sh index 663f3009a..d594f2c16 100755 --- a/manual-install-helper.sh +++ b/manual-install-helper.sh @@ -11,7 +11,6 @@ source ./scriptdata/installers prevent_sudo_or_root if command -v pacman >/dev/null 2>&1;then printf "\e[31m[$0]: pacman found, it seems that the system is ArchLinux or Arch-based distro. Aborting...\e[0m\n";exit 1;fi -v install-agsv1 v install-Rubik v install-Gabarito v install-OneUI diff --git a/scriptdata/installers b/scriptdata/installers index 842ca9375..7de00454c 100644 --- a/scriptdata/installers +++ b/scriptdata/installers @@ -123,7 +123,7 @@ install-uv (){ # Both for Arch(based) and other distros. install-python-packages (){ UV_NO_MODIFY_PATH=1 - ILLOGICAL_IMPULSE_VIRTUAL_ENV=$XDG_STATE_HOME/ags/.venv + ILLOGICAL_IMPULSE_VIRTUAL_ENV=$XDG_STATE_HOME/quickshell/.venv x mkdir -p $(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV) # we need python 3.12 https://github.com/python-pillow/Pillow/issues/8089 x uv venv --prompt .venv $(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV) -p 3.12 diff --git a/scriptdata/requirements.txt b/scriptdata/requirements.txt index 8c923e9c3..c2f380c4a 100644 --- a/scriptdata/requirements.txt +++ b/scriptdata/requirements.txt @@ -26,13 +26,11 @@ pycparser==2.22 # via cffi pyproject-hooks==1.2.0 # via build -# pywal==3.3.0 - # via -r scriptdata/requirements.in pywayland==0.4.18 # via -r scriptdata/requirements.in setproctitle==1.3.4 # via -r scriptdata/requirements.in -setuptools==75.8.0 +setuptools==80.9.0 # via setuptools-scm setuptools-scm==8.1.0 # via -r scriptdata/requirements.in diff --git a/update.sh b/update.sh new file mode 100755 index 000000000..098a6a0fa --- /dev/null +++ b/update.sh @@ -0,0 +1,849 @@ +#!/usr/bin/env bash +# +# update.sh - Enhanced dotfiles update script +# +# Features: +# - Pull latest commits from remote +# - Rebuild packages if PKGBUILD files changed (user choice) +# - Handle config file conflicts with user choices +# - Respect .updateignore file for exclusions +# +set -uo pipefail + +# === Configuration === +FORCE_CHECK=false +CHECK_PACKAGES=false +REPO_DIR="$(cd "$(dirname $0)" &>/dev/null && pwd)" +ARCH_PACKAGES_DIR="${REPO_DIR}/arch-packages" +UPDATE_IGNORE_FILE="${REPO_DIR}/.updateignore" +HOME_UPDATE_IGNORE_FILE="${HOME}/.updateignore" + +# Directories to monitor for changes +MONITOR_DIRS=(".config" ".local/bin") + +# === Color Codes === +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +PURPLE='\033[0;35m' +NC='\033[0m' # No Color + +# === Helper Functions === +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +log_header() { + echo -e "\n${PURPLE}=== $1 ===${NC}" +} + +die() { + log_error "$1" + exit 1 +} + +# Function to safely read input with terminal compatibility +safe_read() { + local prompt="$1" + local varname="$2" + local default="${3:-}" + + # Simple approach: just use read with /dev/tty and handle errors + local input_value="" + + # Display prompt and read from terminal + echo -n "$prompt" + if read input_value /dev/null || read input_value 2>/dev/null; then + eval "$varname='$input_value'" + return 0 + else + # If read failed and we have a default, use it + if [[ -n "$default" ]]; then + echo + log_warning "Using default: $default" + eval "$varname='$default'" + return 0 + else + echo + log_error "Failed to read input" + return 1 + fi + fi +} + +# Function to check if a file should be ignored +should_ignore() { + local file_path="$1" + local relative_path="${file_path#$HOME/}" + + # Also get path relative to repo for repo-level ignores + local repo_relative="" + if [[ "$file_path" == "$REPO_DIR"* ]]; then + repo_relative="${file_path#$REPO_DIR/}" + fi + + # Check both repo and home ignore files + for ignore_file in "$UPDATE_IGNORE_FILE" "$HOME_UPDATE_IGNORE_FILE"; do + if [[ -f "$ignore_file" ]]; then + while IFS= read -r pattern || [[ -n "$pattern" ]]; do + # Skip empty lines and comments + [[ -z "$pattern" || "$pattern" =~ ^[[:space:]]*# ]] && continue + # Remove leading/trailing whitespace + pattern=$(echo "$pattern" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [[ -z "$pattern" ]] && continue + + # Handle different gitignore-style patterns + local should_skip=false + + # Exact match + if [[ "$relative_path" == "$pattern" ]] || [[ "$repo_relative" == "$pattern" ]]; then + should_skip=true + fi + + # Wildcard patterns (basic glob matching) + if [[ "$relative_path" == $pattern ]] || [[ "$repo_relative" == $pattern ]]; then + should_skip=true + fi + + # Directory patterns (ending with /) + if [[ "$pattern" == */ ]]; then + local dir_pattern="${pattern%/}" + if [[ "$relative_path" == "$dir_pattern"/* ]] || [[ "$repo_relative" == "$dir_pattern"/* ]]; then + should_skip=true + fi + fi + + # Patterns starting with / (from root) + if [[ "$pattern" == /* ]]; then + local root_pattern="${pattern#/}" + if [[ "$relative_path" == "$root_pattern" ]] || [[ "$relative_path" == "$root_pattern"/* ]] || + [[ "$repo_relative" == "$root_pattern" ]] || [[ "$repo_relative" == "$root_pattern"/* ]]; then + should_skip=true + fi + fi + + # Patterns with wildcards + if [[ "$pattern" == *"*"* ]]; then + if [[ "$relative_path" == $pattern ]] || [[ "$repo_relative" == $pattern ]]; then + should_skip=true + fi + # Also check if any parent directory matches + local temp_path="$relative_path" + while [[ "$temp_path" == */* ]]; do + temp_path="${temp_path%/*}" + if [[ "$temp_path" == $pattern ]]; then + should_skip=true + break + fi + done + fi + + # Simple substring matching (for backward compatibility) + if [[ ! "$should_skip" == true ]]; then + if [[ "$file_path" == *"$pattern"* ]] || [[ "$relative_path" == *"$pattern"* ]]; then + should_skip=true + fi + fi + + if [[ "$should_skip" == true ]]; then + return 0 + fi + done <"$ignore_file" + fi + done + return 1 +} + +# Function to show file diff with syntax highlighting if possible +show_diff() { + local file1="$1" + local file2="$2" + + echo -e "\n${CYAN}Showing differences:${NC}" + echo -e "${CYAN}Old file: $file1${NC}" + echo -e "${CYAN}New file: $file2${NC}" + echo "----------------------------------------" + + if command -v diff &>/dev/null; then + diff -u "$file1" "$file2" || true + else + echo "diff command not available" + fi + echo "----------------------------------------" +} + +# Function to handle file conflicts +handle_file_conflict() { + local repo_file="$1" + local home_file="$2" + local filename=$(basename "$home_file") + local dirname=$(dirname "$home_file") + + echo -e "\n${YELLOW}Conflict detected:${NC} $home_file" + echo "Repository version differs from your local version." + echo + echo "Choose an action:" + echo "1) Replace local file with repository version" + echo "2) Keep local file unchanged" + echo "3) Backup local file as ${filename}.old, use repository version" + echo "4) Save repository version as ${filename}.new, keep local file" + echo "5) Show diff and decide" + echo "6) Skip this file" + echo + + while true; do + if ! safe_read "Enter your choice (1-6): " choice "6"; then + echo + log_warning "Failed to read input. Skipping file." + return + fi + + case $choice in + 1) + cp -p "$repo_file" "$home_file" + log_success "Replaced $home_file with repository version" + break + ;; + 2) + log_info "Keeping local version of $home_file" + break + ;; + 3) + mv "$home_file" "${dirname}/${filename}.old" + cp -p "$repo_file" "$home_file" + log_success "Backed up local file to ${filename}.old and updated with repository version" + break + ;; + 4) + cp -p "$repo_file" "${dirname}/${filename}.new" + log_success "Saved repository version as ${filename}.new, kept local file" + break + ;; + 5) + show_diff "$home_file" "$repo_file" + echo + echo "After reviewing the diff, choose:" + echo "r) Replace with repository version" + echo "k) Keep local version" + echo "b) Backup local and use repository version" + echo "n) Save repository version as .new" + echo "s) Skip this file" + + if ! safe_read "Enter your choice (r/k/b/n/s): " subchoice "s"; then + echo + log_warning "Failed to read input. Skipping file." + return + fi + + case $subchoice in + r) + cp -p "$repo_file" "$home_file" + log_success "Replaced $home_file with repository version" + break + ;; + k) + log_info "Keeping local version of $home_file" + break + ;; + b) + mv "$home_file" "${dirname}/${filename}.old" + cp -p "$repo_file" "$home_file" + log_success "Backed up local file to ${filename}.old and updated" + break + ;; + n) + cp -p "$repo_file" "${dirname}/${filename}.new" + log_success "Saved repository version as ${filename}.new" + break + ;; + s) + log_info "Skipping $home_file" + break + ;; + *) + echo "Invalid choice. Please try again." + ;; + esac + ;; + 6) + log_info "Skipping $home_file" + break + ;; + *) + echo "Invalid choice. Please enter 1-6." + ;; + esac + done +} + +# Function to check if PKGBUILD has changed +check_pkgbuild_changed() { + local pkg_dir="$1" + local pkgbuild_path="${pkg_dir}/PKGBUILD" + + [[ ! -f "$pkgbuild_path" ]] && return 1 + + # Get the path relative to repo + local relative_path="${pkgbuild_path#$REPO_DIR/}" + + # If force check is enabled, always return true + if [[ "$FORCE_CHECK" == true ]]; then + return 0 + fi + + # Check if file changed in the last pull + if git diff --name-only HEAD@{1} HEAD 2>/dev/null | grep -q "^${relative_path}$"; then + return 0 + fi + + return 1 +} + +# Function to list available packages +list_packages() { + local available_packages=() + local changed_packages=() + + if [[ ! -d "$ARCH_PACKAGES_DIR" ]]; then + log_warning "No arch-packages directory found" + return 1 + fi + + for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do + if [[ -f "${pkg_dir}/PKGBUILD" ]]; then + local pkg_name=$(basename "$pkg_dir") + available_packages+=("$pkg_name") + + if check_pkgbuild_changed "$pkg_dir"; then + changed_packages+=("$pkg_name") + fi + fi + done + + if [[ ${#available_packages[@]} -eq 0 ]]; then + log_info "No packages found in arch-packages directory" + return 1 + fi + + echo -e "\n${CYAN}Available packages:${NC}" + for pkg in "${available_packages[@]}"; do + if [[ " ${changed_packages[*]} " =~ " ${pkg} " ]]; then + echo -e " ${GREEN}● ${pkg}${NC} (PKGBUILD changed)" + else + echo -e " ○ ${pkg}" + fi + done + + if [[ ${#changed_packages[@]} -gt 0 ]]; then + echo -e "\n${YELLOW}Packages with changed PKGBUILDs: ${changed_packages[*]}${NC}" + fi + + return 0 +} + +# Function to build selected packages +build_packages() { + local build_mode="$1" # "changed", "all", or "select" + local packages_to_build=() + local rebuilt_packages=0 + + case "$build_mode" in + "changed") + for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do + if [[ -f "${pkg_dir}/PKGBUILD" ]]; then + local pkg_name=$(basename "$pkg_dir") + if check_pkgbuild_changed "$pkg_dir"; then + packages_to_build+=("$pkg_name") + fi + fi + done + ;; + "all") + for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do + if [[ -f "${pkg_dir}/PKGBUILD" ]]; then + local pkg_name=$(basename "$pkg_dir") + packages_to_build+=("$pkg_name") + fi + done + ;; + "select") + echo -e "\nEnter package names separated by spaces (or 'all' for all packages):" + if ! safe_read "Packages to build: " user_selection ""; then + log_warning "Failed to read input. Skipping package builds." + return + fi + + if [[ "$user_selection" == "all" ]]; then + for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do + if [[ -f "${pkg_dir}/PKGBUILD" ]]; then + local pkg_name=$(basename "$pkg_dir") + packages_to_build+=("$pkg_name") + fi + done + else + read -ra packages_to_build <<<"$user_selection" + fi + ;; + esac + + if [[ ${#packages_to_build[@]} -eq 0 ]]; then + log_info "No packages selected for building" + return + fi + + echo -e "\n${CYAN}Packages to build: ${packages_to_build[*]}${NC}" + + if ! safe_read "Proceed with building these packages? (Y/n): " confirm "Y"; then + log_warning "Failed to read input. Skipping package builds." + return + fi + + if [[ "$confirm" =~ ^[Nn]$ ]]; then + log_info "Package building cancelled by user" + return + fi + + for pkg_name in "${packages_to_build[@]}"; do + local pkg_dir="${ARCH_PACKAGES_DIR}/${pkg_name}" + + if [[ ! -d "$pkg_dir" || ! -f "${pkg_dir}/PKGBUILD" ]]; then + log_error "Package not found or missing PKGBUILD: $pkg_name" + continue + fi + + log_info "Building package: $pkg_name" + cd "$pkg_dir" || continue + + if makepkg -si --noconfirm; then + log_success "Successfully built and installed $pkg_name" + ((rebuilt_packages++)) + else + log_error "Failed to build package $pkg_name" + fi + + cd "$REPO_DIR" || die "Failed to return to repository directory" + done + + if [[ $rebuilt_packages -eq 0 ]]; then + log_warning "No packages were successfully built" + else + log_success "Successfully rebuilt $rebuilt_packages package(s)" + fi +} + +# Function to get list of changed files since last pull or all files if force check +get_changed_files() { + local dir_path="$1" + + if [[ "$FORCE_CHECK" == true ]]; then + # Return all files in the directory + find "$dir_path" -type f -print0 2>/dev/null + else + # Get files that changed in the last pull + local changed_files=() + while IFS= read -r file; do + local full_path="${REPO_DIR}/${file}" + # Check if file is in the directory we're processing + if [[ "$full_path" == "$dir_path"/* ]] && [[ -f "$full_path" ]]; then + printf '%s\0' "$full_path" + fi + done < <(git diff --name-only HEAD@{1} HEAD 2>/dev/null || true) + + # If no files changed via git, but force_check is false, still check all files + # This handles the case where there were no new commits but files might differ + if ! git diff --quiet HEAD@{1} HEAD 2>/dev/null; then + : # Files were found via git diff + else + # No git changes detected, check all files anyway for local differences + find "$dir_path" -type f -print0 2>/dev/null + fi + fi +} + +# Function to check if we have new commits +has_new_commits() { + # Check if HEAD@{1} exists (meaning there was a previous commit) + if git rev-parse --verify HEAD@{1} &>/dev/null; then + # Check if HEAD and HEAD@{1} are different + [[ "$(git rev-parse HEAD)" != "$(git rev-parse HEAD@{1})" ]] + else + # No previous commit reference, assume we have commits + return 0 + fi +} + +# Main script starts here +log_header "Dotfiles Update Script" + +check=true + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -f | --force) + FORCE_CHECK=true + log_info "Force check mode enabled - will check all files regardless of git changes" + shift + ;; + -p | --packages) + CHECK_PACKAGES=true + log_info "Package checking enabled" + shift + ;; + -h | --help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " -f, --force Force check all files even if no new commits" + echo " -p, --packages Enable package checking and building" + echo " -h, --help Show this help message" + echo "" + echo "This script updates your dotfiles by:" + echo " 1. Pulling latest changes from git remote" + echo " 2. Optionally rebuilding packages (if -p flag is used)" + echo " 3. Syncing configuration files" + echo " 4. Updating script permissions" + echo "" + echo "Package modes (when -p is used):" + echo " - If no PKGBUILDs changed: asks if you want to check packages anyway" + echo " - If PKGBUILDs changed: offers to build changed packages" + echo " - Interactive selection of packages to build" + exit 0 + ;; + --skip-notice) + log_warning "Skipping notice about script being untested" + check=false + shift + ;; + *) + log_error "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +if [[ "$check" == true ]]; then + log_warning "THIS SCRIPT IS NOT FULLY TESTED AND MAY CAUSE ISSUES!" + safe_read "BY CONTINUE YOU WILL USE IT AT YOUR OWN RISK (y/N): " response "N" + + if [[ ! "$response" =~ ^[Yy]$ ]]; then + log_error "Update aborted by user" + exit 1 + fi +fi + +# Check if we're in a git repository +cd "$REPO_DIR" || die "Failed to change to repository directory" + +if git rev-parse --is-inside-work-tree &>/dev/null; then + log_info "Running in git repository: $(git rev-parse --show-toplevel)" +else + log_error "Not in a git repository. Please run this script from your dotfiles repository." + exit 1 +fi + +# Step 1: Pull latest commits +log_header "Pulling Latest Changes" + +# Check current branch +current_branch=$(git branch --show-current) +if [[ -z "$current_branch" ]]; then + log_warning "In detached HEAD state. Checking out main/master branch..." + if git show-ref --verify --quiet refs/heads/main; then + git checkout main + current_branch="main" + elif git show-ref --verify --quiet refs/heads/master; then + git checkout master + current_branch="master" + else + die "Could not find main or master branch" + fi +fi + +log_info "Current branch: $current_branch" + +# Check for uncommitted changes +if ! git diff --quiet || ! git diff --cached --quiet; then + log_warning "You have uncommitted changes:" + git status --short + echo + + if ! safe_read "Do you want to continue? This will stash your changes. (y/N): " response "N"; then + echo + log_error "Failed to read input. Aborting." + exit 1 + fi + + if [[ ! "$response" =~ ^[Yy]$ ]]; then + die "Aborted by user" + fi + git stash push -m "Auto-stash before update $(date)" + log_info "Changes stashed" +fi + +# Check if remote exists +if git remote get-url origin &>/dev/null; then + # Pull changes + log_info "Pulling changes from origin/$current_branch..." + if git pull; then + log_success "Successfully pulled latest changes" + else + log_warning "Failed to pull changes from remote. Continuing with local repository..." + log_info "You may need to resolve conflicts manually later." + fi +else + log_warning "No remote 'origin' configured. Skipping pull operation." + log_info "This appears to be a local-only repository." +fi + +# Step 2: Handle package building (only if requested) +rebuilt_packages=0 + +if [[ "$CHECK_PACKAGES" == true ]]; then + log_header "Package Management" + + if [[ ! -d "$ARCH_PACKAGES_DIR" ]]; then + log_warning "No arch-packages directory found. Skipping package management." + else + # Check if any PKGBUILDs have changed + changed_pkgbuilds=() + for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do + if [[ -f "${pkg_dir}/PKGBUILD" ]]; then + local pkg_name=$(basename "$pkg_dir") + if check_pkgbuild_changed "$pkg_dir"; then + changed_pkgbuilds+=("$pkg_name") + fi + fi + done + + if [[ ${#changed_pkgbuilds[@]} -gt 0 ]]; then + log_info "Found ${#changed_pkgbuilds[@]} package(s) with changed PKGBUILDs: ${changed_pkgbuilds[*]}" + echo + echo "Package build options:" + echo "1) Build only packages with changed PKGBUILDs" + echo "2) List all packages and select which to build" + echo "3) Build all packages" + echo "4) Skip package building" + echo + + if safe_read "Choose an option (1-4): " pkg_choice "1"; then + case $pkg_choice in + 1) + build_packages "changed" + ;; + 2) + if list_packages; then + build_packages "select" + fi + ;; + 3) + build_packages "all" + ;; + 4 | *) + log_info "Skipping package building" + ;; + esac + else + log_warning "Failed to read input. Skipping package building." + fi + else + log_info "No PKGBUILDs have changed since last update." + echo + if safe_read "Do you want to check and build packages anyway? (y/N): " check_anyway "N"; then + if [[ "$check_anyway" =~ ^[Yy]$ ]]; then + if list_packages; then + echo + echo "Package build options:" + echo "1) Select specific packages to build" + echo "2) Build all packages" + echo "3) Skip package building" + + if safe_read "Choose an option (1-3): " build_choice "3"; then + case $build_choice in + 1) + build_packages "select" + ;; + 2) + build_packages "all" + ;; + 3 | *) + log_info "Skipping package building" + ;; + esac + else + log_info "Skipping package building" + fi + fi + else + log_info "Skipping package management" + fi + else + log_info "Skipping package management" + fi + fi + fi +else + log_header "Package Management" + log_info "Package checking disabled. Use -p or --packages flag to enable package management." + + # Still show a hint if there are changed PKGBUILDs + if [[ -d "$ARCH_PACKAGES_DIR" ]]; then + changed_count=0 + for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do + if [[ -f "${pkg_dir}/PKGBUILD" ]] && check_pkgbuild_changed "$pkg_dir"; then + ((changed_count++)) + fi + done + + if [[ $changed_count -gt 0 ]]; then + log_warning "Note: $changed_count package(s) have changed PKGBUILDs. Use -p flag to manage packages." + fi + fi +fi + +# Step 3: Update configuration files +log_header "Updating Configuration Files" + +# Check if we should process files +process_files=false +if [[ "$FORCE_CHECK" == true ]]; then + process_files=true + log_info "Force mode: checking all configuration files" +elif has_new_commits; then + process_files=true + log_info "New commits detected: checking changed configuration files" +else + log_info "No new commits found: checking for local file differences" + process_files=true # Always check for differences even without commits +fi + +if [[ "$process_files" == true ]]; then + files_processed=0 + files_updated=0 + files_created=0 + + for dir_name in "${MONITOR_DIRS[@]}"; do + repo_dir_path="${REPO_DIR}/${dir_name}" + home_dir_path="${HOME}/${dir_name}" + + if [[ ! -d "$repo_dir_path" ]]; then + log_warning "Repository directory not found: $repo_dir_path" + continue + fi + + log_info "Processing directory: $dir_name" + + # Create home directory if it doesn't exist + mkdir -p "$home_dir_path" + + # Get files to process (changed files or all files based on mode) + while IFS= read -r -d '' repo_file; do + # Calculate relative path and corresponding home file path + rel_path="${repo_file#$repo_dir_path/}" + home_file="${home_dir_path}/${rel_path}" + + # Check if file should be ignored + if should_ignore "$home_file"; then + continue + fi + + ((files_processed++)) + + # Create directory structure if needed + mkdir -p "$(dirname "$home_file")" + + if [[ -f "$home_file" ]]; then + # File exists, check if different + if ! cmp -s "$repo_file" "$home_file"; then + log_info "Found difference in: $rel_path" + handle_file_conflict "$repo_file" "$home_file" + ((files_updated++)) + fi + else + # New file, copy it + cp -p "$repo_file" "$home_file" + log_success "Created new file: $home_file" + ((files_created++)) + fi + done < <(get_changed_files "$repo_dir_path") + done + + # Show processing summary + echo + log_info "File processing summary:" + log_info "- Files processed: $files_processed" + log_info "- Files with conflicts: $files_updated" + log_info "- New files created: $files_created" +else + log_info "Skipping file updates (no changes detected and not in force mode)" +fi + +# Step 4: Update script permissions +log_header "Updating Script Permissions" + +if [[ -d "${REPO_DIR}/scriptdata" ]]; then + find "${REPO_DIR}/scriptdata" -type f -name "*.sh" -exec chmod +x {} \; + find "${REPO_DIR}/scriptdata" -type f -executable -exec chmod +x {} \; + log_success "Updated script permissions" +fi + +# Make sure local bin scripts are executable +if [[ -d "${HOME}/.local/bin" ]]; then + find "${HOME}/.local/bin" -type f -exec chmod +x {} \; 2>/dev/null || true + log_success "Updated ~/.local/bin script permissions" +fi + +log_header "Update Complete" +log_success "Dotfiles update completed successfully!" + +# Show summary +echo +echo -e "${CYAN}Summary:${NC}" +echo "- Repository: $(git log -1 --pretty=format:'%h - %s (%cr)')" +echo "- Branch: $current_branch" +echo "- Mode: $([ "$FORCE_CHECK" == true ] && echo "Force check" || echo "Normal")" +echo "- Package checking: $([ "$CHECK_PACKAGES" == true ] && echo "Enabled" || echo "Disabled")" + +if [[ $rebuilt_packages -gt 0 ]]; then + echo "- Packages rebuilt: $rebuilt_packages" +fi + +if [[ "$process_files" == true ]]; then + echo "- Files processed: $files_processed" + echo "- Files updated/conflicted: $files_updated" + echo "- New files created: $files_created" +fi + +echo "- Configuration directories: ${MONITOR_DIRS[*]}" + +# Remind about ignore files and show examples +if [[ ! -f "$HOME_UPDATE_IGNORE_FILE" && ! -f "$UPDATE_IGNORE_FILE" ]]; then + echo + log_info "Tip: Create ignore files to exclude files from updates:" + echo " - Repository ignore: ${REPO_DIR}/.updateignore" + echo " - User ignore: ~/.updateignore" + echo + echo "Example patterns:" + echo " *.log # Ignore all .log files" + echo " .config/personal/ # Ignore entire directory" + echo " secret-config.conf # Ignore specific file" + echo " /temp-file # Ignore from root only" + echo " *secret* # Ignore files containing 'secret'" +fi + +echo