commit 70bdc50dd6d8624eb70e4ea3bb4ae592d390ab42 Author: NotAlexNoyle Date: Wed Oct 14 17:13:22 2026 -0400 Add Fan Stack, a macOS-style fan popup for a folder on the Plasma panel Pure QML Plasma 6 widget. Reuses Folder View's FolderModel for listing, sorting and thumbnails, trims it to the newest N files and fans them out along an arc from the panel icon with an unroll/fold animation. Co-Authored-By: Claude Fable 5.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d62ebc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Eclipse / IDE state +.settings/ +.metadata/ +*.swp +*~ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + 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/README.md b/README.md new file mode 100644 index 0000000..391efbb --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Fan Stack + +A folder widget for the KDE Plasma 6 panel that fans its newest files out in +an arc above the icon, the way a macOS Dock stack does. + +Click the icon and the folder's newest entries unroll along a curve, each +with a thumbnail (or file type icon) and a label. Click an entry to open it, +click the last entry to open the folder itself, click anywhere else (or press +Escape) to fold the fan back into the icon. + +## Features + +- Animated fan that unrolls from the panel icon and folds back on close. + The whole thing honours the system-wide animation speed setting. +- Newest files first (or any other Folder View sort order), with a + "N more…" entry that opens the folder in your file manager. +- Thumbnails for images, PDFs, videos and whatever else your thumbnailers + cover, using the same preview plugins as Dolphin. +- Curves to the right, or to the left when the arc would not fit on screen. +- Configurable lean angle, number of entries, icon size, animation length, + folder, panel icon, sort order, hidden files. +- Middle-click the panel icon to open the folder. Drop files on it to move + them into the folder. + +## Requirements + +- KDE Plasma 6 (developed and tested on 6.7, Wayland). +- `plasma-desktop` (provides the Folder View model the widget reuses), + `kitemmodels`, and Qt 6.5+ (for `QtQuick.Effects`). All of these are part + of a normal Plasma installation. + +## Install + +```sh +./install.sh +``` + +or, by hand: + +```sh +kpackagetool6 --type Plasma/Applet --install . # first time +kpackagetool6 --type Plasma/Applet --upgrade . # later versions +``` + +Then right-click a panel, choose *Add Widgets…*, and drag **Fan Stack** onto +the panel. By default it shows your Downloads folder; right-click the icon +and choose *Configure…* to change the folder, icon and the rest. + +## Settings + +| Setting | Default | Meaning | +| --- | --- | --- | +| Folder | Downloads | Folder to fan out | +| Icon | `folder-download` | Icon shown on the panel | +| Files shown in the fan | 8 | Entries before the "N more…" item | +| Fan icon size | 48 px | Icon size inside the fan | +| Fan lean at the top | 38° | How far the top entry tilts from vertical; the arc starts vertical at the icon | +| Fan curves | Away from the screen edge | Force right or left if you prefer | +| Animation length | 350 ms | Scaled by the global animation speed | +| Sort by / Descending / Folders first | Date, descending | Same options as Folder View | +| First file at the top | on | Otherwise the newest file sits nearest the panel | +| Show thumbnails | on | Uses the system preview plugins | +| Show hidden files | off | | + +## Hacking + +The widget is plain QML, no build step. After editing an installed copy, +reload Plasma's QML without restarting it: + +```sh +qdbus org.kde.plasmashell /PlasmaShell refreshCurrentShell +``` + +For development you can symlink the checkout into place instead of +installing it: + +```sh +ln -s "$PWD" ~/.local/share/plasma/plasmoids/com.notalexnoyle.fanstack +``` + +Layout: + +- `contents/ui/main.qml` – panel icon, folder model, proxy that trims the list +- `contents/ui/FanPopup.qml` – the frameless popup and the arc geometry +- `contents/ui/FanItem.qml` – one entry: icon, label, open/close animation +- `contents/ui/configGeneral.qml`, `contents/config/main.xml` – settings + +The arc is a circle that leaves the panel icon vertically. Its radius grows +with the number of entries so that the top entry tilts exactly as far as the +configured lean, capped at 60° to keep each label clear of the icon above. + +## License + +GPL-2.0-or-later. See `LICENSE`. diff --git a/contents/config/config.qml b/contents/config/config.qml new file mode 100644 index 0000000..ec30a99 --- /dev/null +++ b/contents/config/config.qml @@ -0,0 +1,11 @@ +import QtQuick + +import org.kde.plasma.configuration + +ConfigModel { + ConfigCategory { + name: i18n("General") + icon: "folder-download" + source: "configGeneral.qml" + } +} diff --git a/contents/config/main.xml b/contents/config/main.xml new file mode 100644 index 0000000..ad34045 --- /dev/null +++ b/contents/config/main.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + folder-download + + + + 8 + + + + 48 + + + + true + + + + 38 + + + + 0 + + + + true + + + + false + + + + 2 + + + + true + + + + false + + + + 350 + + + + diff --git a/contents/ui/FanItem.qml b/contents/ui/FanItem.qml new file mode 100644 index 0000000..7bcf36c --- /dev/null +++ b/contents/ui/FanItem.qml @@ -0,0 +1,154 @@ +/* + One entry of the fan: an icon with a pill-shaped label beside it. + + `t` runs from 0 (tucked into the dock icon, invisible) to 1 (resting on + the arc). The entry slides along the arc as t grows, growing and fading in + on the way; the label fades in during the second half. Opening animates t + up with a per-entry delay so the fan unrolls from the bottom; closing + animates it back down from the top. +*/ +import QtQuick +import QtQuick.Effects + +import org.kde.kirigami as Kirigami + +Item { + id: item + + required property var fan + required property int fanIndex + property var iconSource + property string label: "" + property bool emphasised: false + + signal activated() + + property real t: 0 + + readonly property int size: fan.iconSize + readonly property int dir: fan.direction + readonly property real angle: fan.angleFor(fanIndex) * t + readonly property real cx: fan.originX + dir * fan.arcRadius * (1 - Math.cos(angle)) + readonly property real cy: fan.originY - fan.arcRadius * Math.sin(angle) + readonly property real clampedT: Math.max(0, Math.min(1, t)) + readonly property bool hovered: hover.hovered + readonly property bool settled: t >= 1 + + width: size + fan.labelGap + pill.width + height: size + x: dir > 0 ? cx - size / 2 : cx + size / 2 - width + y: cy - size / 2 + z: hovered ? 1000 : fanIndex + opacity: Math.min(1, clampedT * 2.5) + visible: t > 0.001 + + Item { + id: iconBox + width: item.size + height: item.size + x: item.dir > 0 ? 0 : item.width - item.size + scale: (0.55 + 0.45 * item.clampedT) * (item.hovered && item.settled ? 1.12 : 1) + Behavior on scale { + enabled: item.settled + NumberAnimation { + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } + } + + Kirigami.Icon { + id: icon + anchors.fill: parent + source: item.iconSource + visible: false + } + MultiEffect { + anchors.fill: icon + source: icon + shadowEnabled: true + shadowColor: "black" + shadowOpacity: 0.6 + shadowBlur: 0.9 + shadowVerticalOffset: 2 + } + } + + Rectangle { + id: pill + x: item.dir > 0 ? item.size + item.fan.labelGap : 0 + anchors.verticalCenter: iconBox.verticalCenter + anchors.verticalCenterOffset: item.fan.labelDrop + width: labelText.width + Kirigami.Units.largeSpacing * 2 + height: labelText.implicitHeight + Kirigami.Units.smallSpacing * 2 + radius: height / 2 + color: Qt.rgba(0, 0, 0, item.hovered ? 0.82 : 0.62) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.18) + opacity: Math.max(0, (item.clampedT - 0.45) / 0.55) + + Text { + id: labelText + anchors.centerIn: parent + width: Math.min(implicitWidth, item.fan.labelMaxWidth) + text: item.label + color: "white" + elide: Text.ElideRight + font.family: Kirigami.Theme.defaultFont.family + font.pointSize: Kirigami.Theme.defaultFont.pointSize + font.weight: item.emphasised ? Font.DemiBold : Font.Normal + } + } + + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + } + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: item.activated() + } + + SequentialAnimation { + id: openAnim + PauseAnimation { + duration: item.fanIndex * item.fan.stagger + } + NumberAnimation { + target: item + property: "t" + to: 1 + duration: item.fan.duration + easing.type: Easing.OutBack + easing.overshoot: 0.9 + } + } + SequentialAnimation { + id: closeAnim + PauseAnimation { + duration: Math.max(0, item.fan.itemCount - 1 - item.fanIndex) * item.fan.stagger + } + NumberAnimation { + target: item + property: "t" + to: 0 + duration: item.fan.closeDuration + easing.type: Easing.InCubic + } + } + Connections { + target: item.fan + function onOpenStarted() { + closeAnim.stop(); + openAnim.restart(); + } + function onCloseStarted() { + openAnim.stop(); + closeAnim.restart(); + } + function onItemsReset() { + openAnim.stop(); + closeAnim.stop(); + item.t = 0; + } + } +} diff --git a/contents/ui/FanPopup.qml b/contents/ui/FanPopup.qml new file mode 100644 index 0000000..b621624 --- /dev/null +++ b/contents/ui/FanPopup.qml @@ -0,0 +1,203 @@ +/* + The fan itself: a frameless, background-less dialog that sits on top of + the panel icon. Every entry is a FanItem whose position is a point on a + circular arc; the arc's centre lies to the side of the dock icon, so the + entries climb and curve away from the panel like a Dock stack. + + All coordinates are in popup space. The origin (centre of the dock icon) + sits just below the popup's bottom edge, since the dialog is placed flush + against the top of the icon. +*/ +import QtQuick + +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.kirigami as Kirigami + +PlasmaCore.Dialog { + id: dialog + + property Item dockIcon: null + property var fanModel: null + property int totalCount: 0 + property string folderUrl: "" + property string folderIcon: "folder" + + // true from the start of the open animation until the close starts + property bool open: false + property bool closing: false + property double lastCloseAt: 0 + // +1 curves to the right, -1 to the left; chosen every time the fan opens + property int direction: 1 + + signal openStarted() + signal closeStarted() + signal itemsReset() + + // ---- geometry ---- + readonly property int iconSize: Plasmoid.configuration.fanIconSize + readonly property int gap: Math.round(iconSize * 0.35) + readonly property int step: iconSize + gap + readonly property bool newestAtTop: Plasmoid.configuration.newestAtTop + // The arc leaves the dock icon vertically and tilts over until its top + // entry leans this far from vertical (radians). Above ~60° a label, which + // sits beside its icon towards the inside of the curve, would run into + // the icon above it even with the small downward label offset below. + readonly property real maxTangent: Math.max(10, Math.min(60, Plasmoid.configuration.fanLean)) * Math.PI / 180 + readonly property int labelDrop: Math.round(iconSize / 6) + readonly property int labelGap: Math.round(iconSize * 0.25) + readonly property int labelMaxWidth: iconSize * 5 + readonly property int margin: Kirigami.Units.largeSpacing * 2 + readonly property real dockHalf: dockIcon ? dockIcon.height / 2 : 0 + readonly property real firstDistance: dockHalf + iconSize * 0.75 + readonly property int itemCount: fanRepeater.count + 1 + readonly property real arcLength: firstDistance + (itemCount - 1) * step + // Radius grows with the number of entries so the arc always ends at maxTangent. + readonly property real arcRadius: Math.max(step * 8, arcLength / maxTangent) + readonly property real maxAngle: angleFor(itemCount - 1) + readonly property real arcWidth: arcRadius * (1 - Math.cos(maxAngle)) + readonly property real arcHeight: arcRadius * Math.sin(maxAngle) + readonly property int contentWidth: Math.ceil(margin * 2 + iconSize + arcWidth + labelGap + labelMaxWidth) + readonly property int contentHeight: Math.ceil(margin + iconSize / 2 + arcHeight - dockHalf) + readonly property real originX: direction > 0 ? margin + iconSize / 2 : contentWidth - margin - iconSize / 2 + readonly property real originY: contentHeight + dockHalf + + // ---- timing (honours the global animation speed) ---- + readonly property int duration: Math.round(Plasmoid.configuration.animationDuration * Kirigami.Units.longDuration / 200) + readonly property int stagger: Math.round(duration * 0.08) + readonly property int closeDuration: Math.round(duration * 0.7) + + // Angle (radians) of entry i around the arc's centre; the origin is at 0. + function angleFor(i) { + return (firstDistance + i * step) / arcRadius; + } + + function pickDirection() { + const wanted = Plasmoid.configuration.direction; + if (wanted === 1) { + return 1; + } + if (wanted === 2) { + return -1; + } + if (!dockIcon) { + return 1; + } + const centre = dockIcon.mapToGlobal(dockIcon.width / 2, 0); + const screen = Plasmoid.containment.screenGeometry; + const reach = contentWidth - margin - iconSize / 2; + const fitsRight = centre.x + reach <= screen.x + screen.width; + const fitsLeft = centre.x - reach >= screen.x; + return (!fitsRight && fitsLeft) ? -1 : 1; + } + + function openFan() { + if (open) { + return; + } + closeTimer.stop(); + itemsReset(); + direction = pickDirection(); + closing = false; + open = true; + visible = true; + requestActivate(); + Qt.callLater(openStarted); + } + + function closeFan() { + if (!open || closing) { + return; + } + open = false; + closing = true; + lastCloseAt = Date.now(); + closeStarted(); + closeTimer.restart(); + } + + location: Plasmoid.location + type: PlasmaCore.Dialog.PopupMenu + flags: Qt.WindowStaysOnTopHint + backgroundHints: PlasmaCore.Dialog.NoBackground + hideOnWindowDeactivate: false + visible: false + + // Clicking anywhere else closes the fan, but with the animation. + onActiveChanged: { + if (!active && open) { + closeFan(); + } + } + + onVisibleChanged: { + if (!visible) { + closeTimer.stop(); + if (open || closing) { + lastCloseAt = Date.now(); + } + open = false; + closing = false; + itemsReset(); + } + } + + mainItem: Item { + id: content + width: dialog.contentWidth + height: dialog.contentHeight + focus: true + Keys.onEscapePressed: dialog.closeFan() + + // Lives here because anything declared directly under the Dialog + // would be taken as its mainItem. + Timer { + id: closeTimer + interval: (dialog.itemCount - 1) * dialog.stagger + dialog.closeDuration + 40 + onTriggered: dialog.visible = false + } + + // Clicks on empty space close the fan, like the Dock does. + MouseArea { + anchors.fill: parent + onClicked: dialog.closeFan() + } + + Repeater { + id: fanRepeater + model: dialog.fanModel + delegate: FanItem { + // FanItem has required properties, so the usual context + // injection of model/index is off and we must ask for them. + required property int index + required property var model + + fan: dialog + fanIndex: dialog.newestAtTop ? fanRepeater.count - index : index + iconSource: model.decoration + // display is briefly undefined while a row is being removed + label: model.display ?? "" + onActivated: { + const target = model.url; + dialog.closeFan(); + Qt.openUrlExternally(target); + } + } + } + + FanItem { + fan: dialog + fanIndex: dialog.newestAtTop ? 0 : fanRepeater.count + iconSource: dialog.folderIcon + emphasised: true + label: { + const more = dialog.totalCount - fanRepeater.count; + return more > 0 ? i18n("%1 more…", more) : i18n("Open folder…"); + } + onActivated: { + dialog.closeFan(); + Qt.openUrlExternally(dialog.folderUrl); + } + } + } +} diff --git a/contents/ui/configGeneral.qml b/contents/ui/configGeneral.qml new file mode 100644 index 0000000..760ad42 --- /dev/null +++ b/contents/ui/configGeneral.qml @@ -0,0 +1,161 @@ +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import QtQuick.Dialogs as QtDialogs + +import org.kde.kirigami as Kirigami +import org.kde.kcmutils as KCM +import org.kde.iconthemes as KIconThemes + +KCM.SimpleKCM { + id: page + + property string cfg_url + property string cfg_icon + property alias cfg_fanLimit: fanLimitSpin.value + property alias cfg_fanIconSize: iconSizeSpin.value + property int cfg_direction + property alias cfg_newestAtTop: newestAtTopCheck.checked + property alias cfg_fanLean: leanSpin.value + property alias cfg_previews: previewsCheck.checked + property alias cfg_showHiddenFiles: hiddenCheck.checked + property int cfg_sortMode + property alias cfg_sortDesc: sortDescCheck.checked + property alias cfg_sortDirsFirst: dirsFirstCheck.checked + property alias cfg_animationDuration: durationSpin.value + + Kirigami.FormLayout { + RowLayout { + Kirigami.FormData.label: i18n("Folder:") + QQC2.TextField { + id: urlField + Layout.fillWidth: true + text: page.cfg_url + placeholderText: i18n("Downloads folder") + onTextEdited: page.cfg_url = text + } + QQC2.Button { + icon.name: "folder-open" + text: i18n("Browse…") + onClicked: folderDialog.open() + } + } + + QQC2.Button { + Kirigami.FormData.label: i18n("Icon:") + icon.name: page.cfg_icon + text: page.cfg_icon + onClicked: iconDialog.open() + } + + Item { + Kirigami.FormData.isSection: true + } + + QQC2.SpinBox { + id: fanLimitSpin + Kirigami.FormData.label: i18n("Files shown in the fan:") + from: 1 + to: 30 + } + + QQC2.SpinBox { + id: iconSizeSpin + Kirigami.FormData.label: i18n("Fan icon size:") + from: 22 + to: 128 + stepSize: 2 + textFromValue: (value, locale) => i18n("%1 px", value) + valueFromText: (text, locale) => parseInt(text) + } + + QQC2.SpinBox { + id: leanSpin + Kirigami.FormData.label: i18n("Fan lean at the top:") + from: 10 + to: 60 + textFromValue: (value, locale) => i18n("%1°", value) + valueFromText: (text, locale) => parseInt(text) + } + + QQC2.ComboBox { + Kirigami.FormData.label: i18n("Fan curves:") + model: [i18n("Away from the screen edge"), i18n("To the right"), i18n("To the left")] + currentIndex: page.cfg_direction + onActivated: page.cfg_direction = currentIndex + } + + QQC2.SpinBox { + id: durationSpin + Kirigami.FormData.label: i18n("Animation length:") + from: 0 + to: 2000 + stepSize: 50 + textFromValue: (value, locale) => i18n("%1 ms", value) + valueFromText: (text, locale) => parseInt(text) + } + + Item { + Kirigami.FormData.isSection: true + } + + QQC2.ComboBox { + id: sortCombo + Kirigami.FormData.label: i18n("Sort by:") + textRole: "text" + valueRole: "value" + model: [ + { text: i18n("Date"), value: 2 }, + { text: i18n("Name"), value: 0 }, + { text: i18n("Size"), value: 1 }, + { text: i18n("Type"), value: 6 } + ] + Component.onCompleted: currentIndex = Math.max(0, indexOfValue(page.cfg_sortMode)) + onActivated: page.cfg_sortMode = currentValue + } + + QQC2.CheckBox { + id: newestAtTopCheck + text: i18n("First file at the top of the fan") + } + + QQC2.CheckBox { + id: sortDescCheck + text: i18n("Descending (newest or largest first)") + } + + QQC2.CheckBox { + id: dirsFirstCheck + text: i18n("Folders first") + } + + QQC2.CheckBox { + id: previewsCheck + text: i18n("Show thumbnails") + } + + QQC2.CheckBox { + id: hiddenCheck + text: i18n("Show hidden files") + } + + QQC2.Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: i18n("Animation length is scaled by the system-wide animation speed. Middle-click the panel icon to open the folder directly; files dropped on it are moved into the folder.") + font: Kirigami.Theme.smallFont + opacity: 0.7 + } + } + + QtDialogs.FolderDialog { + id: folderDialog + currentFolder: page.cfg_url + onAccepted: page.cfg_url = selectedFolder.toString() + } + + KIconThemes.IconDialog { + id: iconDialog + onIconSelected: iconName => page.cfg_icon = iconName + } +} diff --git a/contents/ui/main.qml b/contents/ui/main.qml new file mode 100644 index 0000000..2a5f771 --- /dev/null +++ b/contents/ui/main.qml @@ -0,0 +1,181 @@ +/* + Fan Stack + + A folder on the panel. Clicking it fans the folder's newest entries out in + an arc above the icon, the way a macOS Dock stack does. The popup is a + background-less PlasmaCore.Dialog so only the icons and their labels are + drawn; the arc geometry and the open/close animation live in FanPopup.qml + and FanItem.qml. + + The folder listing comes from the same FolderModel the stock Folder View + uses (org.kde.private.desktopcontainment.folder), so sorting, hidden files + and thumbnails behave exactly like Folder View. A proxy model trims the + sorted list to the first N rows; the last fan entry always opens the folder + in the file manager and says how many more files there are. +*/ +import QtCore +import QtQuick +import QtQuick.Layouts + +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.kirigami as Kirigami +import org.kde.kitemmodels as KItemModels +import org.kde.private.desktopcontainment.folder as Folder + +PlasmoidItem { + id: root + + preferredRepresentation: fullRepresentation + + readonly property bool horizontal: Plasmoid.formFactor !== PlasmaCore.Types.Vertical + readonly property string folderUrl: Plasmoid.configuration.url.length > 0 + ? Plasmoid.configuration.url + : StandardPaths.writableLocation(StandardPaths.DownloadLocation).toString() + readonly property string folderName: { + const trimmed = folderUrl.replace(/\/+$/, ""); + return decodeURIComponent(trimmed.substring(trimmed.lastIndexOf("/") + 1)) || folderUrl; + } + readonly property int fanLimit: Math.max(1, Plasmoid.configuration.fanLimit) + readonly property string iconName: Plasmoid.configuration.icon || folderModel.iconName || "folder" + property int totalCount: 0 + + // Set by the panel representation once it exists; the popup anchors to them. + property Item dockIconItem: null + property Item anchorItem: null + + toolTipMainText: folderName + toolTipSubText: totalCount === 1 ? i18n("1 item") : i18n("%1 items", totalCount) + Plasmoid.icon: iconName + // Keeps an auto-hiding panel on screen while the fan is open. + Plasmoid.status: fanPopup.open ? PlasmaCore.Types.RequiresAttentionStatus : PlasmaCore.Types.ActiveStatus + + function refreshCount() { + totalCount = folderModel.rowCount(); + fanModel.invalidateFilter(); + } + + function toggle() { + if (fanPopup.open) { + fanPopup.closeFan(); + } else if (Date.now() - fanPopup.lastCloseAt > 300) { + // A click on the icon while the fan is open first deactivates the + // popup (which closes it) and only then lands here: don't reopen. + fanPopup.openFan(); + } + } + + Folder.FolderModel { + id: folderModel + url: root.folderUrl + sortMode: Plasmoid.configuration.sortMode + sortDesc: Plasmoid.configuration.sortDesc + sortDirsFirst: Plasmoid.configuration.sortDirsFirst + showHiddenFiles: Plasmoid.configuration.showHiddenFiles + previews: Plasmoid.configuration.previews + // Must be set explicitly: FolderModel's effective plugin list starts + // out empty, and an empty list here makes it fall back to the system + // default thumbnailers (the ones Dolphin uses). + previewPlugins: [] + viewAdapter: viewAdapter + onListingCompleted: root.refreshCount() + onRowsInserted: root.refreshCount() + onRowsRemoved: root.refreshCount() + onModelReset: root.refreshCount() + onLayoutChanged: root.refreshCount() + } + + // FolderModel only creates its thumbnail generator once it has a view adapter. + Folder.ItemViewAdapter { + id: viewAdapter + adapterView: fanPopup.mainItem + adapterModel: folderModel + adapterIconSize: Plasmoid.configuration.fanIconSize + adapterVisibleArea: Qt.rect(0, 0, 10000, 10000) + } + + // Only the first fanLimit rows of the sorted folder reach the fan. The + // callback is rebuilt (and the filter re-run) whenever the limit changes; + // refreshCount() re-runs it when rows shift. + KItemModels.KSortFilterProxyModel { + id: fanModel + sourceModel: folderModel + filterRowCallback: { + const limit = root.fanLimit; + return function (sourceRow, sourceParent) { return sourceRow < limit; }; + } + onFilterRowCallbackChanged: invalidateFilter() + } + + FanPopup { + id: fanPopup + dockIcon: root.dockIconItem + visualParent: root.anchorItem + fanModel: fanModel + totalCount: root.totalCount + folderUrl: root.folderUrl + folderIcon: root.iconName + } + + fullRepresentation: Item { + id: rep + + Layout.minimumWidth: root.horizontal ? rep.height : 0 + Layout.preferredWidth: root.horizontal ? rep.height : -1 + Layout.maximumWidth: root.horizontal ? rep.height : Number.POSITIVE_INFINITY + Layout.minimumHeight: root.horizontal ? 0 : rep.width + Layout.preferredHeight: root.horizontal ? -1 : rep.width + Layout.maximumHeight: root.horizontal ? Number.POSITIVE_INFINITY : rep.width + + Kirigami.Icon { + id: dockIcon + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + source: root.iconName + active: hover.hovered || fanPopup.open + scale: leftTap.pressed ? 0.9 : 1 + Behavior on scale { + NumberAnimation { duration: Kirigami.Units.shortDuration } + } + + // Dialog centres its popup on visualParent. This 1px item is slid + // along the icon so that the popup's arc origin lands exactly on + // the icon's centre, whichever way the fan curves. + Item { + id: popupAnchor + width: 1 + height: dockIcon.height + x: dockIcon.width / 2 - fanPopup.originX + fanPopup.contentWidth / 2 + } + } + + HoverHandler { + id: hover + } + TapHandler { + id: leftTap + acceptedButtons: Qt.LeftButton + onTapped: root.toggle() + } + TapHandler { + acceptedButtons: Qt.MiddleButton + onTapped: Qt.openUrlExternally(root.folderUrl) + } + + // Dropping files on the icon moves/copies them into the folder. + DropArea { + anchors.fill: parent + onDropped: drop => { + if (drop.hasUrls) { + folderModel.dropCwd(drop); + } + } + } + + Component.onCompleted: { + root.dockIconItem = dockIcon; + root.anchorItem = popupAnchor; + } + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..5a9ebd7 --- /dev/null +++ b/install.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Installs the widget for the current user, or upgrades it if it is already +# installed. Run from anywhere; the package is this directory. +set -e +cd "$(dirname "$0")" +id=$(sed -n 's/.*"Id": *"\([^"]*\)".*/\1/p' metadata.json) +if kpackagetool6 --type Plasma/Applet --list 2>/dev/null | grep -q "^$id\$"; then + kpackagetool6 --type Plasma/Applet --upgrade . + echo "Upgraded $id. Reload the shell to pick up the new version:" + echo " qdbus org.kde.plasmashell /PlasmaShell refreshCurrentShell" +else + kpackagetool6 --type Plasma/Applet --install . + echo "Installed $id. Add it to a panel with 'Add Widgets…' (search for Fan Stack)." +fi diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..c67c828 --- /dev/null +++ b/metadata.json @@ -0,0 +1,19 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Authors": [ + { + "Name": "NotAlexNoyle" + } + ], + "Category": "File Management", + "Description": "A folder on the panel that fans its newest files out in an arc, like a macOS Dock stack", + "Icon": "folder-download", + "Id": "com.notalexnoyle.fanstack", + "License": "GPL-2.0-or-later", + "Name": "Fan Stack", + "Version": "1.0" + }, + "Keyword": "folder stack fan dock downloads macos", + "X-Plasma-API-Minimum-Version": "6.0" +}