summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-08-04 22:45:15 +1000
committer2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-08-04 22:45:15 +1000
commitc5381c5194adf97c240acb98eb4c4c950633b325 (patch)
tree52b18eb1771ec6708c86f11d786684f03b8a7c48 /components
parentdashboard: display correct temp units (diff)
downloadcaelestia-shell-c5381c5194adf97c240acb98eb4c4c950633b325.tar.gz
caelestia-shell-c5381c5194adf97c240acb98eb4c4c950633b325.tar.bz2
caelestia-shell-c5381c5194adf97c240acb98eb4c4c950633b325.zip
internal: refactor widgets folder
Split into subdirs and rename to components
Diffstat (limited to 'components')
-rw-r--r--components/MaterialIcon.qml16
-rw-r--r--components/StateLayer.qml103
-rw-r--r--components/StyledClippingRect.qml17
-rw-r--r--components/StyledRect.qml16
-rw-r--r--components/StyledText.qml52
-rw-r--r--components/containers/StyledFlickable.qml17
-rw-r--r--components/containers/StyledListView.qml17
-rw-r--r--components/containers/StyledWindow.qml11
-rw-r--r--components/controls/CustomMouseArea.qml21
-rw-r--r--components/controls/CustomSpinBox.qml108
-rw-r--r--components/controls/StyledBusyIndicator.qml90
-rw-r--r--components/controls/StyledScrollBar.qml36
-rw-r--r--components/controls/StyledSwitch.qml160
-rw-r--r--components/controls/StyledTextField.qml86
-rw-r--r--components/controls/VerticalSlider.qml137
-rw-r--r--components/effects/Colouriser.qml16
-rw-r--r--components/effects/Elevation.qml22
-rw-r--r--components/effects/InnerBorder.qml44
-rw-r--r--components/filedialog/CurrentItem.qml107
-rw-r--r--components/filedialog/DialogButtons.qml93
-rw-r--r--components/filedialog/FileDialog.qml106
-rw-r--r--components/filedialog/FolderContents.qml224
-rw-r--r--components/filedialog/HeaderBar.qml142
-rw-r--r--components/filedialog/Sidebar.qml117
-rw-r--r--components/filedialog/Sizes.qml8
-rw-r--r--components/images/CachingIconImage.qml31
-rw-r--r--components/images/CachingImage.qml41
-rw-r--r--components/misc/CustomShortcut.qml5
-rw-r--r--components/misc/Ref.qml9
-rw-r--r--components/widgets/ExtraIndicator.qml54
30 files changed, 1906 insertions, 0 deletions
diff --git a/components/MaterialIcon.qml b/components/MaterialIcon.qml
new file mode 100644
index 0000000..a1d19d3
--- /dev/null
+++ b/components/MaterialIcon.qml
@@ -0,0 +1,16 @@
+import qs.services
+import qs.config
+
+StyledText {
+ property real fill
+ property int grade: Colours.light ? 0 : -25
+
+ font.family: Appearance.font.family.material
+ font.pointSize: Appearance.font.size.larger
+ font.variableAxes: ({
+ FILL: fill.toFixed(1),
+ GRAD: grade,
+ opsz: fontInfo.pixelSize,
+ wght: fontInfo.weight
+ })
+}
diff --git a/components/StateLayer.qml b/components/StateLayer.qml
new file mode 100644
index 0000000..da30217
--- /dev/null
+++ b/components/StateLayer.qml
@@ -0,0 +1,103 @@
+import qs.services
+import qs.config
+import QtQuick
+
+MouseArea {
+ id: root
+
+ property bool disabled
+ property color color: Colours.palette.m3onSurface
+ property real radius: parent?.radius ?? 0
+
+ function onClicked(): void {
+ }
+
+ anchors.fill: parent
+
+ enabled: !disabled
+ cursorShape: disabled ? undefined : Qt.PointingHandCursor
+ hoverEnabled: true
+
+ onPressed: event => {
+ if (disabled)
+ return;
+
+ rippleAnim.x = event.x;
+ rippleAnim.y = event.y;
+
+ const dist = (ox, oy) => ox * ox + oy * oy;
+ rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y), dist(event.x, height - event.y), dist(width - event.x, event.y), dist(width - event.x, height - event.y)));
+
+ rippleAnim.restart();
+ }
+
+ onClicked: event => !disabled && onClicked(event)
+
+ SequentialAnimation {
+ id: rippleAnim
+
+ property real x
+ property real y
+ property real radius
+
+ PropertyAction {
+ target: ripple
+ property: "x"
+ value: rippleAnim.x
+ }
+ PropertyAction {
+ target: ripple
+ property: "y"
+ value: rippleAnim.y
+ }
+ PropertyAction {
+ target: ripple
+ property: "opacity"
+ value: 0.08
+ }
+ Anim {
+ target: ripple
+ properties: "implicitWidth,implicitHeight"
+ from: 0
+ to: rippleAnim.radius * 2
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: ripple
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ StyledClippingRect {
+ id: hoverLayer
+
+ anchors.fill: parent
+
+ color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.1 : root.containsMouse ? 0.08 : 0)
+ radius: root.radius
+
+ StyledRect {
+ id: ripple
+
+ radius: Appearance.rounding.full
+ color: root.color
+ opacity: 0
+
+ transform: Translate {
+ x: -ripple.width / 2
+ y: -ripple.height / 2
+ }
+ }
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/components/StyledClippingRect.qml b/components/StyledClippingRect.qml
new file mode 100644
index 0000000..8e15c4c
--- /dev/null
+++ b/components/StyledClippingRect.qml
@@ -0,0 +1,17 @@
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+
+ClippingRectangle {
+ id: root
+
+ color: "transparent"
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/components/StyledRect.qml b/components/StyledRect.qml
new file mode 100644
index 0000000..c052b2a
--- /dev/null
+++ b/components/StyledRect.qml
@@ -0,0 +1,16 @@
+import qs.config
+import QtQuick
+
+Rectangle {
+ id: root
+
+ color: "transparent"
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/components/StyledText.qml b/components/StyledText.qml
new file mode 100644
index 0000000..554c20c
--- /dev/null
+++ b/components/StyledText.qml
@@ -0,0 +1,52 @@
+pragma ComponentBehavior: Bound
+
+import qs.services
+import qs.config
+import QtQuick
+
+Text {
+ id: root
+
+ property bool animate: false
+ property string animateProp: "scale"
+ property real animateFrom: 0
+ property real animateTo: 1
+ property int animateDuration: Appearance.anim.durations.normal
+
+ renderType: Text.NativeRendering
+ textFormat: Text.PlainText
+ color: Colours.palette.m3onSurface
+ font.family: Appearance.font.family.sans
+ font.pointSize: Appearance.font.size.smaller
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on text {
+ enabled: root.animate
+
+ SequentialAnimation {
+ Anim {
+ to: root.animateFrom
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ PropertyAction {}
+ Anim {
+ to: root.animateTo
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+
+ component Anim: NumberAnimation {
+ target: root
+ property: root.animateProp
+ duration: root.animateDuration / 2
+ easing.type: Easing.BezierSpline
+ }
+}
diff --git a/components/containers/StyledFlickable.qml b/components/containers/StyledFlickable.qml
new file mode 100644
index 0000000..7b2a75e
--- /dev/null
+++ b/components/containers/StyledFlickable.qml
@@ -0,0 +1,17 @@
+import qs.config
+import QtQuick
+
+Flickable {
+ id: root
+
+ maximumFlickVelocity: 3000
+
+ rebound: Transition {
+ NumberAnimation {
+ properties: "x,y"
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/components/containers/StyledListView.qml b/components/containers/StyledListView.qml
new file mode 100644
index 0000000..b59eca8
--- /dev/null
+++ b/components/containers/StyledListView.qml
@@ -0,0 +1,17 @@
+import qs.config
+import QtQuick
+
+ListView {
+ id: root
+
+ maximumFlickVelocity: 3000
+
+ rebound: Transition {
+ NumberAnimation {
+ properties: "x,y"
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/components/containers/StyledWindow.qml b/components/containers/StyledWindow.qml
new file mode 100644
index 0000000..3b402b5
--- /dev/null
+++ b/components/containers/StyledWindow.qml
@@ -0,0 +1,11 @@
+import qs.utils
+import qs.config
+import Quickshell
+import Quickshell.Wayland
+
+PanelWindow {
+ required property string name
+
+ WlrLayershell.namespace: `caelestia-${name}`
+ color: "transparent"
+}
diff --git a/components/controls/CustomMouseArea.qml b/components/controls/CustomMouseArea.qml
new file mode 100644
index 0000000..7c973c2
--- /dev/null
+++ b/components/controls/CustomMouseArea.qml
@@ -0,0 +1,21 @@
+import QtQuick
+
+MouseArea {
+ property int scrollAccumulatedY: 0
+
+ function onWheel(event: WheelEvent): void {
+ }
+
+ onWheel: event => {
+ // Update accumulated scroll
+ if (Math.sign(event.angleDelta.y) !== Math.sign(scrollAccumulatedY))
+ scrollAccumulatedY = 0;
+ scrollAccumulatedY += event.angleDelta.y;
+
+ // Trigger handler and reset if above threshold
+ if (Math.abs(scrollAccumulatedY) >= 120) {
+ onWheel(event);
+ scrollAccumulatedY = 0;
+ }
+ }
+}
diff --git a/components/controls/CustomSpinBox.qml b/components/controls/CustomSpinBox.qml
new file mode 100644
index 0000000..4611bed
--- /dev/null
+++ b/components/controls/CustomSpinBox.qml
@@ -0,0 +1,108 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+RowLayout {
+ id: root
+
+ property int value
+ property real max: Infinity
+ property real min: -Infinity
+ property alias repeatRate: timer.interval
+
+ signal valueModified(value: int)
+
+ spacing: Appearance.spacing.small
+
+ StyledTextField {
+ inputMethodHints: Qt.ImhFormattedNumbersOnly
+ text: root.value
+ onAccepted: root.valueModified(text)
+
+ padding: Appearance.padding.small
+ leftPadding: Appearance.padding.normal
+ rightPadding: Appearance.padding.normal
+
+ background: StyledRect {
+ implicitWidth: 100
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3surfaceContainerHigh
+ }
+ }
+
+ StyledRect {
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3primary
+
+ implicitWidth: implicitHeight
+ implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ id: upState
+
+ color: Colours.palette.m3onPrimary
+
+ onPressAndHold: timer.start()
+ onReleased: timer.stop()
+
+ function onClicked(): void {
+ root.valueModified(Math.min(root.max, root.value + 1));
+ }
+ }
+
+ MaterialIcon {
+ id: upIcon
+
+ anchors.centerIn: parent
+ text: "keyboard_arrow_up"
+ color: Colours.palette.m3onPrimary
+ }
+ }
+
+ StyledRect {
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3primary
+
+ implicitWidth: implicitHeight
+ implicitHeight: downIcon.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ id: downState
+
+ color: Colours.palette.m3onPrimary
+
+ onPressAndHold: timer.start()
+ onReleased: timer.stop()
+
+ function onClicked(): void {
+ root.valueModified(Math.max(root.min, root.value - 1));
+ }
+ }
+
+ MaterialIcon {
+ id: downIcon
+
+ anchors.centerIn: parent
+ text: "keyboard_arrow_down"
+ color: Colours.palette.m3onPrimary
+ }
+ }
+
+ Timer {
+ id: timer
+
+ interval: 100
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: {
+ if (upState.pressed)
+ upState.onClicked();
+ else if (downState.pressed)
+ downState.onClicked();
+ }
+ }
+}
diff --git a/components/controls/StyledBusyIndicator.qml b/components/controls/StyledBusyIndicator.qml
new file mode 100644
index 0000000..060870f
--- /dev/null
+++ b/components/controls/StyledBusyIndicator.qml
@@ -0,0 +1,90 @@
+pragma ComponentBehavior: Bound
+
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Shapes
+
+BusyIndicator {
+ id: root
+
+ property color fgColour: Colours.palette.m3onPrimaryContainer
+ property color bgColour: Colours.palette.m3primaryContainer
+
+ background: null
+
+ contentItem: Shape {
+ id: shape
+
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: true
+
+ RotationAnimator on rotation {
+ from: 0
+ to: 180
+ running: root.visible && root.running
+ loops: Animation.Infinite
+ duration: Appearance.anim.durations.extraLarge
+ easing.type: Easing.Linear
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+
+ ShapePath {
+ strokeWidth: Math.min(root.implicitWidth, root.implicitHeight) * 0.18
+ strokeColor: root.bgColour
+ fillColor: "transparent"
+ capStyle: ShapePath.RoundCap
+
+ PathAngleArc {
+ centerX: shape.width / 2
+ centerY: shape.height / 2
+ radiusX: root.implicitWidth / 2
+ radiusY: root.implicitHeight / 2
+ startAngle: 0
+ sweepAngle: 360
+ }
+
+ Behavior on strokeColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ ShapePath {
+ strokeWidth: Math.min(root.implicitWidth, root.implicitHeight) * 0.18
+ strokeColor: root.fgColour
+ fillColor: "transparent"
+ capStyle: ShapePath.RoundCap
+
+ PathAngleArc {
+ centerX: shape.width / 2
+ centerY: shape.height / 2
+ radiusX: root.implicitWidth / 2
+ radiusY: root.implicitHeight / 2
+ startAngle: -sweepAngle / 2
+ sweepAngle: 60
+ }
+
+ PathAngleArc {
+ centerX: shape.width / 2
+ centerY: shape.height / 2
+ radiusX: root.implicitWidth / 2
+ radiusY: root.implicitHeight / 2
+ startAngle: 180 - sweepAngle / 2
+ sweepAngle: 60
+ }
+
+ Behavior on strokeColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+}
diff --git a/components/controls/StyledScrollBar.qml b/components/controls/StyledScrollBar.qml
new file mode 100644
index 0000000..61ddc6d
--- /dev/null
+++ b/components/controls/StyledScrollBar.qml
@@ -0,0 +1,36 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Controls
+
+ScrollBar {
+ id: root
+
+ contentItem: StyledRect {
+ implicitWidth: 6
+ opacity: root.pressed ? 1 : root.policy === ScrollBar.AlwaysOn || (root.active && root.size < 1) ? 0.8 : 0
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3secondary
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ CustomMouseArea {
+ z: -1
+ anchors.fill: parent
+
+ function onWheel(event: WheelEvent): void {
+ if (event.angleDelta.y > 0)
+ root.decrease();
+ else if (event.angleDelta.y < 0)
+ root.increase();
+ }
+ }
+}
diff --git a/components/controls/StyledSwitch.qml b/components/controls/StyledSwitch.qml
new file mode 100644
index 0000000..c9d7330
--- /dev/null
+++ b/components/controls/StyledSwitch.qml
@@ -0,0 +1,160 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Shapes
+
+Switch {
+ id: root
+
+ implicitWidth: implicitIndicatorWidth
+ implicitHeight: implicitIndicatorHeight
+
+ indicator: StyledRect {
+ radius: Appearance.rounding.full
+ color: root.checked ? Colours.palette.m3primary : Colours.palette.m3surfaceContainerHighest
+
+ implicitWidth: implicitHeight * 1.7
+ implicitHeight: Appearance.font.size.normal + Appearance.padding.smaller * 2
+
+ StyledRect {
+ readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight
+
+ radius: Appearance.rounding.full
+ color: root.checked ? Colours.palette.m3onPrimary : Colours.palette.m3outline
+
+ x: root.checked ? parent.implicitWidth - nonAnimWidth - Appearance.padding.small / 2 : Appearance.padding.small / 2
+ implicitWidth: nonAnimWidth
+ implicitHeight: parent.implicitHeight - Appearance.padding.small
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledRect {
+ anchors.fill: parent
+ radius: parent.radius
+
+ color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ opacity: root.pressed ? 0.1 : root.hovered ? 0.08 : 0
+
+ Behavior on opacity {
+ NumberAnim {}
+ }
+ }
+
+ Shape {
+ id: icon
+
+ property point start1: {
+ if (root.pressed)
+ return Qt.point(width * 0.2, height / 2);
+ if (root.checked)
+ return Qt.point(width * 0.15, height / 2);
+ return Qt.point(width * 0.15, height * 0.15);
+ }
+ property point end1: {
+ if (root.pressed) {
+ if (root.checked)
+ return Qt.point(width * 0.4, height / 2);
+ return Qt.point(width * 0.8, height / 2);
+ }
+ if (root.checked)
+ return Qt.point(width * 0.4, height * 0.7);
+ return Qt.point(width * 0.85, height * 0.85);
+ }
+ property point start2: {
+ if (root.pressed) {
+ if (root.checked)
+ return Qt.point(width * 0.4, height / 2);
+ return Qt.point(width * 0.2, height / 2);
+ }
+ if (root.checked)
+ return Qt.point(width * 0.4, height * 0.7);
+ return Qt.point(width * 0.15, height * 0.85);
+ }
+ property point end2: {
+ if (root.pressed)
+ return Qt.point(width * 0.8, height / 2);
+ if (root.checked)
+ return Qt.point(width * 0.85, height * 0.2);
+ return Qt.point(width * 0.85, height * 0.15);
+ }
+
+ anchors.centerIn: parent
+ width: height
+ height: parent.implicitHeight - Appearance.padding.small * 2
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: true
+
+ ShapePath {
+ strokeWidth: Appearance.font.size.larger * 0.15
+ strokeColor: root.checked ? Colours.palette.m3primary : Colours.palette.m3surfaceContainerHighest
+ fillColor: "transparent"
+ capStyle: ShapePath.RoundCap
+
+ startX: icon.start1.x
+ startY: icon.start1.y
+
+ PathLine {
+ x: icon.end1.x
+ y: icon.end1.y
+ }
+ PathMove {
+ x: icon.start2.x
+ y: icon.start2.y
+ }
+ PathLine {
+ x: icon.end2.x
+ y: icon.end2.y
+ }
+
+ Behavior on strokeColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ Behavior on start1 {
+ PropAnim {}
+ }
+ Behavior on end1 {
+ PropAnim {}
+ }
+ Behavior on start2 {
+ PropAnim {}
+ }
+ Behavior on end2 {
+ PropAnim {}
+ }
+ }
+
+ Behavior on x {
+ NumberAnim {}
+ }
+
+ Behavior on implicitWidth {
+ NumberAnim {}
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ enabled: false
+ }
+
+ component NumberAnim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+
+ component PropAnim: PropertyAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/components/controls/StyledTextField.qml b/components/controls/StyledTextField.qml
new file mode 100644
index 0000000..30db314
--- /dev/null
+++ b/components/controls/StyledTextField.qml
@@ -0,0 +1,86 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Controls
+
+TextField {
+ id: root
+
+ color: Colours.palette.m3onSurface
+ placeholderTextColor: Colours.palette.m3outline
+ font.family: Appearance.font.family.sans
+ font.pointSize: Appearance.font.size.smaller
+ renderType: TextField.NativeRendering
+ cursorVisible: !readOnly
+
+ background: null
+
+ cursorDelegate: StyledRect {
+ id: cursor
+
+ property bool disableBlink
+
+ implicitWidth: 2
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.normal
+
+ Connections {
+ target: root
+
+ function onCursorPositionChanged(): void {
+ if (root.activeFocus && root.cursorVisible) {
+ cursor.opacity = 1;
+ cursor.disableBlink = true;
+ enableBlink.restart();
+ }
+ }
+ }
+
+ Timer {
+ id: enableBlink
+
+ interval: 100
+ onTriggered: cursor.disableBlink = false
+ }
+
+ Timer {
+ running: root.activeFocus && root.cursorVisible && !cursor.disableBlink
+ repeat: true
+ triggeredOnStart: true
+ interval: 500
+ onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
+ }
+
+ Binding {
+ when: !root.activeFocus || !root.cursorVisible
+ cursor.opacity: 0
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.small
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on placeholderTextColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/components/controls/VerticalSlider.qml b/components/controls/VerticalSlider.qml
new file mode 100644
index 0000000..306cc24
--- /dev/null
+++ b/components/controls/VerticalSlider.qml
@@ -0,0 +1,137 @@
+import ".."
+import "../effects"
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Controls
+
+Slider {
+ id: root
+
+ required property string icon
+ property real oldValue
+
+ orientation: Qt.Vertical
+
+ background: StyledRect {
+ color: Colours.alpha(Colours.palette.m3surfaceContainer, true)
+ radius: Appearance.rounding.full
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ y: root.handle.y
+ implicitHeight: parent.height - y
+
+ color: Colours.alpha(Colours.palette.m3secondary, true)
+ radius: Appearance.rounding.full
+ }
+ }
+
+ handle: Item {
+ id: handle
+
+ property bool moving
+
+ y: root.visualPosition * (root.availableHeight - height)
+ implicitWidth: root.width
+ implicitHeight: root.width
+
+ Elevation {
+ anchors.fill: parent
+ radius: rect.radius
+ level: handleInteraction.containsMouse ? 2 : 1
+ }
+
+ StyledRect {
+ id: rect
+
+ anchors.fill: parent
+
+ color: Colours.alpha(Colours.palette.m3inverseSurface, true)
+ radius: Appearance.rounding.full
+
+ MouseArea {
+ id: handleInteraction
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ MaterialIcon {
+ id: icon
+
+ property bool moving: handle.moving
+
+ function update(): void {
+ animate = !moving;
+ text = moving ? Qt.binding(() => Math.round(root.value * 100)) : Qt.binding(() => root.icon);
+ font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.larger;
+ font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material;
+ }
+
+ animate: true
+ text: root.icon
+ color: Colours.palette.m3inverseOnSurface
+ anchors.centerIn: parent
+
+ Behavior on moving {
+ SequentialAnimation {
+ NumberAnimation {
+ target: icon
+ property: "scale"
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ ScriptAction {
+ script: icon.update()
+ }
+ NumberAnimation {
+ target: icon
+ property: "scale"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+ }
+ }
+
+ onPressedChanged: handle.moving = pressed
+
+ onValueChanged: {
+ if (Math.abs(value - oldValue) < 0.01)
+ return;
+ oldValue = value;
+ handle.moving = true;
+ stateChangeDelay.restart();
+ }
+
+ Timer {
+ id: stateChangeDelay
+
+ interval: 500
+ onTriggered: {
+ if (!root.pressed)
+ handle.moving = false;
+ }
+ }
+
+ Behavior on value {
+ NumberAnimation {
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/components/effects/Colouriser.qml b/components/effects/Colouriser.qml
new file mode 100644
index 0000000..b621ecd
--- /dev/null
+++ b/components/effects/Colouriser.qml
@@ -0,0 +1,16 @@
+import qs.config
+import QtQuick
+import QtQuick.Effects
+
+MultiEffect {
+ colorization: 1
+ brightness: colorizationColor.hslLightness
+
+ Behavior on colorizationColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/components/effects/Elevation.qml b/components/effects/Elevation.qml
new file mode 100644
index 0000000..999b199
--- /dev/null
+++ b/components/effects/Elevation.qml
@@ -0,0 +1,22 @@
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Effects
+
+RectangularShadow {
+ property int level
+ property real dp: [0, 1, 3, 6, 8, 12][level]
+
+ color: Qt.alpha(Colours.palette.m3shadow, 0.7)
+ blur: (dp * 5) ** 0.7
+ spread: -dp * 0.3 + (dp * 0.1) ** 2
+ offset.y: dp / 2
+
+ Behavior on dp {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/components/effects/InnerBorder.qml b/components/effects/InnerBorder.qml
new file mode 100644
index 0000000..e5092ca
--- /dev/null
+++ b/components/effects/InnerBorder.qml
@@ -0,0 +1,44 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Effects
+
+StyledRect {
+ property alias innerRadius: maskInner.radius
+ property alias thickness: maskInner.anchors.margins
+ property alias leftThickness: maskInner.anchors.leftMargin
+ property alias topThickness: maskInner.anchors.topMargin
+ property alias rightThickness: maskInner.anchors.rightMargin
+ property alias bottomThickness: maskInner.anchors.bottomMargin
+
+ anchors.fill: parent
+ color: Colours.palette.m3surfaceContainer
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskSource: mask
+ maskEnabled: true
+ maskInverted: true
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+
+ Item {
+ id: mask
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ Rectangle {
+ id: maskInner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ radius: Appearance.rounding.small
+ }
+ }
+}
diff --git a/components/filedialog/CurrentItem.qml b/components/filedialog/CurrentItem.qml
new file mode 100644
index 0000000..e042445
--- /dev/null
+++ b/components/filedialog/CurrentItem.qml
@@ -0,0 +1,107 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+Item {
+ id: root
+
+ required property var currentItem
+
+ implicitWidth: content.implicitWidth + Appearance.padding.larger + content.anchors.rightMargin
+ implicitHeight: currentItem ? content.implicitHeight + Appearance.padding.normal + content.anchors.bottomMargin : 0
+
+ Shape {
+ preferredRendererType: Shape.CurveRenderer
+
+ ShapePath {
+ id: path
+
+ readonly property real rounding: Appearance.rounding.small
+ readonly property bool flatten: root.implicitHeight < rounding * 2
+ readonly property real roundingY: flatten ? root.implicitHeight / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: Colours.palette.m3surfaceContainer
+
+ startX: root.implicitWidth
+ startY: root.implicitHeight
+
+ PathLine {
+ relativeX: -(root.implicitWidth + path.rounding)
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: path.rounding
+ relativeY: -path.roundingY
+ radiusX: path.rounding
+ radiusY: Math.min(path.rounding, root.implicitHeight)
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: -(root.implicitHeight - path.roundingY * 2)
+ }
+ PathArc {
+ relativeX: path.rounding
+ relativeY: -path.roundingY
+ radiusX: path.rounding
+ radiusY: Math.min(path.rounding, root.implicitHeight)
+ }
+ PathLine {
+ relativeX: root.implicitHeight > 0 ? root.implicitWidth - path.rounding * 2 : root.implicitWidth
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: path.rounding
+ relativeY: -path.rounding
+ radiusX: path.rounding
+ radiusY: path.rounding
+ direction: PathArc.Counterclockwise
+ }
+
+ Behavior on fillColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ clip: true
+
+ StyledText {
+ id: content
+
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.rightMargin: Appearance.padding.larger - Appearance.padding.small
+ anchors.bottomMargin: Appearance.padding.normal - Appearance.padding.small
+
+ text: qsTr(`"%1" selected`).arg(root.currentItem?.fileName)
+ }
+ }
+
+ Behavior on implicitWidth {
+ enabled: !!root.currentItem
+
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/components/filedialog/DialogButtons.qml b/components/filedialog/DialogButtons.qml
new file mode 100644
index 0000000..a64195a
--- /dev/null
+++ b/components/filedialog/DialogButtons.qml
@@ -0,0 +1,93 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property var dialog
+ required property FolderContents folder
+
+ implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2
+
+ color: Colours.palette.m3surfaceContainer
+
+ RowLayout {
+ id: inner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Filter:")
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.rightMargin: Appearance.spacing.normal
+
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.small
+
+ StyledText {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ text: `${root.dialog.filterLabel} (${root.dialog.filters.map(f => `*.${f}`).join(", ")})`
+ }
+ }
+
+ StyledRect {
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.small
+
+ implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2
+
+ StateLayer {
+ disabled: !root.dialog.selectionValid
+
+ function onClicked(): void {
+ root.dialog.accepted(root.folder.currentItem.filePath);
+ }
+ }
+
+ StyledText {
+ id: selectText
+
+ anchors.centerIn: parent
+ anchors.margins: Appearance.padding.normal
+
+ text: qsTr("Select")
+ color: root.dialog.selectionValid ? Colours.palette.m3onSurface : Colours.palette.m3outline
+ }
+ }
+
+ StyledRect {
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.small
+
+ implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2
+
+ StateLayer {
+ function onClicked(): void {
+ root.dialog.rejected();
+ }
+ }
+
+ StyledText {
+ id: cancelText
+
+ anchors.centerIn: parent
+ anchors.margins: Appearance.padding.normal
+
+ text: qsTr("Cancel")
+ }
+ }
+ }
+}
diff --git a/components/filedialog/FileDialog.qml b/components/filedialog/FileDialog.qml
new file mode 100644
index 0000000..a533243
--- /dev/null
+++ b/components/filedialog/FileDialog.qml
@@ -0,0 +1,106 @@
+pragma ComponentBehavior: Bound
+
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+LazyLoader {
+ id: loader
+
+ property list<string> cwd: ["Home"]
+ property string filterLabel: "All files"
+ property list<string> filters: ["*"]
+ property string title: qsTr("Select a file")
+
+ signal accepted(path: string)
+ signal rejected
+
+ function open(): void {
+ activeAsync = true;
+ }
+
+ function close(): void {
+ rejected();
+ }
+
+ onAccepted: activeAsync = false
+ onRejected: activeAsync = false
+
+ FloatingWindow {
+ id: root
+
+ property list<string> cwd: loader.cwd
+ property string filterLabel: loader.filterLabel
+ property list<string> filters: loader.filters
+
+ readonly property bool selectionValid: {
+ const item = folderContents.currentItem;
+ return item && !item.fileIsDir && (filters.includes("*") || filters.includes(item.fileSuffix));
+ }
+
+ function accepted(path: string): void {
+ loader.accepted(path);
+ }
+
+ function rejected(): void {
+ loader.rejected();
+ }
+
+ implicitWidth: 1000
+ implicitHeight: 600
+ color: Colours.palette.m3surface
+ title: loader.title
+
+ onVisibleChanged: {
+ if (!visible)
+ rejected();
+ }
+
+ RowLayout {
+ anchors.fill: parent
+
+ spacing: 0
+
+ Sidebar {
+ Layout.fillHeight: true
+ dialog: root
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ spacing: 0
+
+ HeaderBar {
+ Layout.fillWidth: true
+ dialog: root
+ }
+
+ FolderContents {
+ id: folderContents
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ dialog: root
+ }
+
+ DialogButtons {
+ Layout.fillWidth: true
+ dialog: root
+ folder: folderContents
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+}
diff --git a/components/filedialog/FolderContents.qml b/components/filedialog/FolderContents.qml
new file mode 100644
index 0000000..45930ba
--- /dev/null
+++ b/components/filedialog/FolderContents.qml
@@ -0,0 +1,224 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../controls"
+import "../images"
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Effects
+import QtQuick.Controls
+import Qt.labs.folderlistmodel
+
+Item {
+ id: root
+
+ required property var dialog
+ property alias currentItem: view.currentItem
+
+ StyledRect {
+ anchors.fill: parent
+ color: Colours.palette.m3surfaceContainer
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskSource: mask
+ maskEnabled: true
+ maskInverted: true
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+ }
+
+ Item {
+ id: mask
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.small
+ radius: Appearance.rounding.small
+ }
+ }
+
+ Loader {
+ anchors.centerIn: parent
+ active: view.count === 0
+ asynchronous: true
+ sourceComponent: ColumnLayout {
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "scan_delete"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("This folder is empty")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+ }
+ }
+
+ GridView {
+ id: view
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.small + Appearance.padding.normal
+
+ cellWidth: Sizes.itemWidth + Appearance.spacing.small
+ cellHeight: Sizes.itemWidth + Appearance.spacing.small * 2 + Appearance.padding.normal * 2 + 1
+
+ clip: true
+ focus: true
+ currentIndex: -1
+ Keys.onEscapePressed: currentIndex = -1
+
+ Keys.onReturnPressed: {
+ if (root.dialog.selectionValid)
+ root.dialog.accepted(currentItem.filePath);
+ }
+ Keys.onEnterPressed: {
+ if (root.dialog.selectionValid)
+ root.dialog.accepted(currentItem.filePath);
+ }
+
+ ScrollBar.vertical: StyledScrollBar {}
+
+ model: FolderListModel {
+ showDirsFirst: true
+ folder: {
+ let url = "file://";
+ if (root.dialog.cwd[0] === "Home")
+ url += `${Paths.strip(Paths.home)}/${root.dialog.cwd.slice(1).join("/")}`;
+ else
+ url += root.dialog.cwd.join("/");
+ return url;
+ }
+ onFolderChanged: view.currentIndex = -1
+ }
+
+ delegate: StyledRect {
+ id: item
+
+ required property int index
+ required property string fileName
+ required property string filePath
+ required property url fileUrl
+ required property string fileSuffix
+ required property bool fileIsDir
+
+ readonly property real nonAnimHeight: icon.implicitHeight + name.anchors.topMargin + name.implicitHeight + Appearance.padding.normal * 2
+
+ implicitWidth: Sizes.itemWidth
+ implicitHeight: nonAnimHeight
+
+ radius: Appearance.rounding.normal
+ color: GridView.isCurrentItem ? Colours.palette.m3surfaceContainerHighest : "transparent"
+ z: GridView.isCurrentItem || implicitHeight !== nonAnimHeight ? 1 : 0
+ clip: true
+
+ StateLayer {
+ onDoubleClicked: {
+ if (item.fileIsDir)
+ root.dialog.cwd.push(item.fileName);
+ else if (root.dialog.selectionValid)
+ root.dialog.accepted(item.filePath);
+ }
+
+ function onClicked(): void {
+ view.currentIndex = item.index;
+ }
+ }
+
+ CachingIconImage {
+ id: icon
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.topMargin: Appearance.padding.normal
+
+ asynchronous: true
+ implicitSize: Sizes.itemWidth - Appearance.padding.normal * 2
+ source: {
+ if (!item.fileIsDir)
+ return Quickshell.iconPath("application-x-zerosize");
+
+ const name = item.fileName;
+ if (root.dialog.cwd.length === 1 && ["Desktop", "Documents", "Downloads", "Music", "Pictures", "Public", "Templates", "Videos"].includes(name))
+ return Quickshell.iconPath(`folder-${name.toLowerCase()}`);
+
+ return Quickshell.iconPath("inode-directory");
+ }
+
+ onStatusChanged: {
+ if (status === Image.Error)
+ source = Quickshell.iconPath("error");
+ }
+
+ Process {
+ running: !item.fileIsDir
+ command: ["file", "--mime", "-b", item.filePath]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const mime = text.split(";")[0].replace("/", "-");
+ icon.source = Images.validImageTypes.some(t => mime === `image-${t}`) ? item.fileUrl : Quickshell.iconPath(mime, "image-missing");
+ }
+ }
+ }
+ }
+
+ StyledText {
+ id: name
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: icon.bottom
+ anchors.topMargin: Appearance.spacing.small
+ anchors.margins: Appearance.padding.normal
+
+ horizontalAlignment: Text.AlignHCenter
+ text: item.fileName
+ elide: item.GridView.isCurrentItem ? Text.ElideNone : Text.ElideRight
+ wrapMode: item.GridView.isCurrentItem ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap
+ }
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+ }
+
+ populate: Transition {
+ Anim {
+ property: "scale"
+ from: 0.7
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+
+ CurrentItem {
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: Appearance.padding.small
+
+ currentItem: view.currentItem
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/components/filedialog/HeaderBar.qml b/components/filedialog/HeaderBar.qml
new file mode 100644
index 0000000..4af9672
--- /dev/null
+++ b/components/filedialog/HeaderBar.qml
@@ -0,0 +1,142 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property var dialog
+
+ implicitWidth: inner.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2
+
+ color: Colours.palette.m3surfaceContainer
+
+ RowLayout {
+ id: inner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ spacing: Appearance.spacing.small
+
+ Item {
+ implicitWidth: implicitHeight
+ implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ radius: Appearance.rounding.small
+ disabled: root.dialog.cwd.length === 1
+
+ function onClicked(): void {
+ root.dialog.cwd.pop();
+ }
+ }
+
+ MaterialIcon {
+ id: upIcon
+
+ anchors.centerIn: parent
+ text: "drive_folder_upload"
+ color: root.dialog.cwd.length === 1 ? Colours.palette.m3outline : Colours.palette.m3onSurface
+ grade: 200
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3surfaceContainerHigh
+
+ implicitHeight: pathComponents.implicitHeight + pathComponents.anchors.margins * 2
+
+ RowLayout {
+ id: pathComponents
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.small / 2
+ anchors.leftMargin: 0
+
+ spacing: Appearance.spacing.small
+
+ Repeater {
+ model: root.dialog.cwd
+
+ RowLayout {
+ id: folder
+
+ required property string modelData
+ required property int index
+
+ spacing: 0
+
+ Loader {
+ Layout.rightMargin: Appearance.spacing.small
+ active: folder.index > 0
+ asynchronous: true
+ sourceComponent: StyledText {
+ text: "/"
+ color: Colours.palette.m3onSurfaceVariant
+ font.bold: true
+ }
+ }
+
+ Item {
+ implicitWidth: homeIcon.implicitWidth + (homeIcon.active ? Appearance.padding.small : 0) + folderName.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: folderName.implicitHeight + Appearance.padding.small * 2
+
+ Loader {
+ anchors.fill: parent
+ active: folder.index < root.dialog.cwd.length - 1
+ asynchronous: true
+ sourceComponent: StateLayer {
+ radius: Appearance.rounding.small
+
+ function onClicked(): void {
+ root.dialog.cwd = root.dialog.cwd.slice(0, folder.index + 1);
+ }
+ }
+ }
+
+ Loader {
+ id: homeIcon
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.padding.normal
+
+ active: folder.index === 0 && folder.modelData === "Home"
+ asynchronous: true
+ sourceComponent: MaterialIcon {
+ text: "home"
+ color: root.dialog.cwd.length === 1 ? Colours.palette.m3onSurface : Colours.palette.m3onSurfaceVariant
+ fill: 1
+ }
+ }
+
+ StyledText {
+ id: folderName
+
+ anchors.left: homeIcon.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: homeIcon.active ? Appearance.padding.small : 0
+
+ text: folder.modelData
+ color: folder.index < root.dialog.cwd.length - 1 ? Colours.palette.m3onSurfaceVariant : Colours.palette.m3onSurface
+ font.bold: true
+ }
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+ }
+ }
+ }
+}
diff --git a/components/filedialog/Sidebar.qml b/components/filedialog/Sidebar.qml
new file mode 100644
index 0000000..82a1dd5
--- /dev/null
+++ b/components/filedialog/Sidebar.qml
@@ -0,0 +1,117 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property var dialog
+
+ implicitWidth: Sizes.sidebarWidth
+ implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2
+
+ color: Colours.palette.m3surfaceContainer
+
+ ColumnLayout {
+ id: inner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Appearance.padding.normal
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Appearance.padding.small / 2
+ Layout.bottomMargin: Appearance.spacing.normal
+ text: qsTr("Files")
+ color: Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.larger
+ font.bold: true
+ }
+
+ Repeater {
+ model: ["Home", "Downloads", "Desktop", "Documents", "Music", "Pictures", "Videos"]
+
+ StyledRect {
+ id: place
+
+ required property string modelData
+ readonly property bool selected: modelData === root.dialog.cwd[root.dialog.cwd.length - 1]
+
+ Layout.fillWidth: true
+ implicitHeight: placeInner.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.full
+ color: selected ? Colours.palette.m3secondaryContainer : "transparent"
+
+ StateLayer {
+ color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ if (place.modelData === "Home")
+ root.dialog.cwd = ["Home"];
+ else
+ root.dialog.cwd = ["Home", place.modelData];
+ }
+ }
+
+ RowLayout {
+ id: placeInner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: Appearance.padding.large
+ anchors.rightMargin: Appearance.padding.large
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: {
+ const p = place.modelData;
+ if (p === "Home")
+ return "home";
+ if (p === "Downloads")
+ return "file_download";
+ if (p === "Desktop")
+ return "desktop_windows";
+ if (p === "Documents")
+ return "description";
+ if (p === "Music")
+ return "music_note";
+ if (p === "Pictures")
+ return "image";
+ if (p === "Videos")
+ return "video_library";
+ return "folder";
+ }
+ color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.large
+ fill: place.selected ? 1 : 0
+
+ Behavior on fill {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: place.modelData
+ color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/components/filedialog/Sizes.qml b/components/filedialog/Sizes.qml
new file mode 100644
index 0000000..2ad31f9
--- /dev/null
+++ b/components/filedialog/Sizes.qml
@@ -0,0 +1,8 @@
+pragma Singleton
+
+import Quickshell
+
+Singleton {
+ property int itemWidth: 103
+ property int sidebarWidth: 200
+}
diff --git a/components/images/CachingIconImage.qml b/components/images/CachingIconImage.qml
new file mode 100644
index 0000000..522a947
--- /dev/null
+++ b/components/images/CachingIconImage.qml
@@ -0,0 +1,31 @@
+import QtQuick
+
+Item {
+ property alias asynchronous: image.asynchronous
+ property alias status: image.status
+ property alias mipmap: image.mipmap
+ property alias backer: image
+
+ property real implicitSize
+ readonly property real actualSize: Math.min(width, height)
+
+ property url source
+
+ onSourceChanged: {
+ if (source?.toString().startsWith("image://icon/"))
+ // Directly skip the path prop and treat like a normal Image component
+ image.source = source;
+ else if (source)
+ image.path = source;
+ }
+
+ implicitWidth: implicitSize
+ implicitHeight: implicitSize
+
+ CachingImage {
+ id: image
+
+ anchors.fill: parent
+ fillMode: Image.PreserveAspectFit
+ }
+}
diff --git a/components/images/CachingImage.qml b/components/images/CachingImage.qml
new file mode 100644
index 0000000..1d42238
--- /dev/null
+++ b/components/images/CachingImage.qml
@@ -0,0 +1,41 @@
+import qs.utils
+import Quickshell.Io
+import QtQuick
+
+Image {
+ id: root
+
+ property string path
+ property string hash
+ readonly property string cachePath: `${Paths.stringify(Paths.imagecache)}/${hash}@${width}x${height}.png`
+
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ sourceSize.width: width
+ sourceSize.height: height
+
+ onPathChanged: shaProc.exec(["sha256sum", Paths.strip(path)])
+
+ onCachePathChanged: {
+ if (hash)
+ source = cachePath;
+ }
+
+ onStatusChanged: {
+ if (source == cachePath && status === Image.Error)
+ source = path;
+ else if (source == path && status === Image.Ready) {
+ Paths.mkdir(Paths.imagecache);
+ const grabPath = cachePath;
+ grabToImage(res => res.saveToFile(grabPath));
+ }
+ }
+
+ Process {
+ id: shaProc
+
+ stdout: StdioCollector {
+ onStreamFinished: root.hash = text.split(" ")[0]
+ }
+ }
+}
diff --git a/components/misc/CustomShortcut.qml b/components/misc/CustomShortcut.qml
new file mode 100644
index 0000000..aa35ed8
--- /dev/null
+++ b/components/misc/CustomShortcut.qml
@@ -0,0 +1,5 @@
+import Quickshell.Hyprland
+
+GlobalShortcut {
+ appid: "caelestia"
+}
diff --git a/components/misc/Ref.qml b/components/misc/Ref.qml
new file mode 100644
index 0000000..679f52f
--- /dev/null
+++ b/components/misc/Ref.qml
@@ -0,0 +1,9 @@
+import Quickshell
+import QtQuick
+
+QtObject {
+ required property Singleton service
+
+ Component.onCompleted: service.refCount++
+ Component.onDestruction: service.refCount--
+}
diff --git a/components/widgets/ExtraIndicator.qml b/components/widgets/ExtraIndicator.qml
new file mode 100644
index 0000000..6034b33
--- /dev/null
+++ b/components/widgets/ExtraIndicator.qml
@@ -0,0 +1,54 @@
+import ".."
+import "../effects"
+import qs.services
+import qs.config
+import QtQuick
+
+StyledRect {
+ required property int extra
+
+ anchors.right: parent.right
+ anchors.margins: Appearance.padding.normal
+
+ color: Colours.palette.m3tertiary
+ radius: Appearance.rounding.small
+
+ implicitWidth: count.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: count.implicitHeight + Appearance.padding.small * 2
+
+ opacity: extra > 0 ? 1 : 0
+ scale: extra > 0 ? 1 : 0.5
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ opacity: parent.opacity
+ z: -1
+ level: 2
+ }
+
+ StyledText {
+ id: count
+
+ anchors.centerIn: parent
+ animate: parent.opacity > 0
+ text: qsTr("+%1").arg(parent.extra)
+ color: Colours.palette.m3onTertiary
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+}