diff --git a/.github/assets/bongocat.gif b/.github/assets/bongocat.gif
new file mode 100644
index 000000000..f960fecd7
Binary files /dev/null and b/.github/assets/bongocat.gif differ
diff --git a/.github/assets/dino.png b/.github/assets/dino.png
new file mode 100644
index 000000000..b5bc7bb3b
Binary files /dev/null and b/.github/assets/dino.png differ
diff --git a/.github/assets/kurukuru.gif b/.github/assets/kurukuru.gif
new file mode 100644
index 000000000..38d203d9a
Binary files /dev/null and b/.github/assets/kurukuru.gif differ
diff --git a/.github/assets/logo.svg b/.github/assets/logo.svg
new file mode 100644
index 000000000..6879c92b9
--- /dev/null
+++ b/.github/assets/logo.svg
@@ -0,0 +1,79 @@
+
+
diff --git a/.github/assets/pam.d/fprint b/.github/assets/pam.d/fprint
new file mode 100644
index 000000000..d4814e946
--- /dev/null
+++ b/.github/assets/pam.d/fprint
@@ -0,0 +1,3 @@
+#%PAM-1.0
+
+auth required pam_fprintd.so max-tries=1
diff --git a/.github/assets/pam.d/passwd b/.github/assets/pam.d/passwd
new file mode 100644
index 000000000..4b1406432
--- /dev/null
+++ b/.github/assets/pam.d/passwd
@@ -0,0 +1,6 @@
+#%PAM-1.0
+
+auth required pam_faillock.so preauth
+auth [success=1 default=bad] pam_unix.so nullok
+auth [default=die] pam_faillock.so authfail
+auth required pam_faillock.so authsucc
diff --git a/.github/assets/shaders/opacitymask.frag b/.github/assets/shaders/opacitymask.frag
new file mode 100644
index 000000000..94a80b8a9
--- /dev/null
+++ b/.github/assets/shaders/opacitymask.frag
@@ -0,0 +1,19 @@
+#version 440
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(std140, binding = 0) uniform buf {
+ // qt_Matrix and qt_Opacity must always be both present
+ // if the built-in vertex shader is used.
+ mat4 qt_Matrix;
+ float qt_Opacity;
+};
+
+layout(binding = 1) uniform sampler2D source;
+layout(binding = 2) uniform sampler2D maskSource;
+
+void main()
+{
+ fragColor = texture(source, qt_TexCoord0.st) * (texture(maskSource, qt_TexCoord0.st).a) * qt_Opacity;
+}
diff --git a/.github/assets/shaders/opacitymask.frag.qsb b/.github/assets/shaders/opacitymask.frag.qsb
new file mode 100644
index 000000000..7bf97c280
Binary files /dev/null and b/.github/assets/shaders/opacitymask.frag.qsb differ
diff --git a/.github/assets/wrap_term_launch.sh b/.github/assets/wrap_term_launch.sh
new file mode 100644
index 000000000..caf60c74f
--- /dev/null
+++ b/.github/assets/wrap_term_launch.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env sh
+
+cat ~/.local/state/caelestia/sequences.txt 2>/dev/null
+
+exec "$@"
diff --git a/.github/components/Anim.qml b/.github/components/Anim.qml
new file mode 100644
index 000000000..6883a7984
--- /dev/null
+++ b/.github/components/Anim.qml
@@ -0,0 +1,8 @@
+import qs.config
+import QtQuick
+
+NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+}
diff --git a/.github/components/CAnim.qml b/.github/components/CAnim.qml
new file mode 100644
index 000000000..49484b789
--- /dev/null
+++ b/.github/components/CAnim.qml
@@ -0,0 +1,8 @@
+import qs.config
+import QtQuick
+
+ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+}
diff --git a/.github/components/CategoryNavbar.qml b/.github/components/CategoryNavbar.qml
new file mode 100644
index 000000000..a3e813b5b
--- /dev/null
+++ b/.github/components/CategoryNavbar.qml
@@ -0,0 +1,230 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property var categories
+ required property string activeCategory
+ property bool showScrollButtons: true
+ property bool showExtraContent: false
+ property Component extraContent: null
+
+ signal categoryChanged(string categoryId)
+
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.normal
+
+ visible: opacity > 0
+ implicitHeight: tabsRow.height + Appearance.padding.small + Appearance.padding.normal
+ clip: true
+
+ function scrollToActiveTab(): void {
+ Qt.callLater(() => {
+ if (!tabsFlickable || !tabsRow)
+ return;
+
+ const currentIndex = root.categories.findIndex(cat => cat.id === root.activeCategory);
+ if (currentIndex === -1)
+ return;
+
+ let tabX = 0;
+ for (let i = 0; i < currentIndex && i < tabsRow.children.length; i++) {
+ const child = tabsRow.children[i];
+ if (child) {
+ tabX += child.width + tabsRow.spacing;
+ }
+ }
+
+ const activeTab = tabsRow.children[currentIndex];
+ if (!activeTab)
+ return;
+
+ const tabWidth = activeTab.width;
+ const viewportStart = tabsFlickable.contentX;
+ const viewportEnd = tabsFlickable.contentX + tabsFlickable.width;
+
+ if (tabX < viewportStart) {
+ tabsFlickable.contentX = tabX;
+ } else if (tabX + tabWidth > viewportEnd) {
+ tabsFlickable.contentX = Math.min(tabsFlickable.contentWidth - tabsFlickable.width, tabX + tabWidth - tabsFlickable.width);
+ }
+ });
+ }
+
+ RowLayout {
+ id: tabsContent
+ anchors.fill: parent
+ anchors.leftMargin: Appearance.padding.normal
+ anchors.rightMargin: Appearance.padding.normal
+ anchors.topMargin: Appearance.padding.small
+ anchors.bottomMargin: Appearance.padding.smaller
+ spacing: Appearance.spacing.smaller
+
+ IconButton {
+ icon: "chevron_left"
+ visible: root.showScrollButtons && tabsFlickable.contentWidth > tabsFlickable.width
+ type: IconButton.Text
+ radius: Appearance.rounding.small
+ padding: Appearance.padding.small
+ onClicked: {
+ tabsFlickable.contentX = Math.max(0, tabsFlickable.contentX - 100);
+ }
+ }
+
+ StyledFlickable {
+ id: tabsFlickable
+ Layout.fillWidth: true
+ Layout.preferredHeight: tabsRow.height
+ flickableDirection: Flickable.HorizontalFlick
+ contentWidth: tabsRow.width
+ clip: true
+
+ Behavior on contentX {
+ Anim {
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ propagateComposedEvents: true
+
+ onWheel: wheel => {
+ const delta = wheel.angleDelta.y || wheel.angleDelta.x;
+ tabsFlickable.contentX = Math.max(0, Math.min(tabsFlickable.contentWidth - tabsFlickable.width, tabsFlickable.contentX - delta));
+ wheel.accepted = true;
+ }
+
+ onPressed: mouse => {
+ mouse.accepted = false;
+ }
+ }
+
+ Item {
+ implicitWidth: tabsRow.width
+ implicitHeight: tabsRow.height
+
+ StyledRect {
+ id: activeIndicator
+
+ property Item activeTab: {
+ for (let i = 0; i < tabsRepeater.count; i++) {
+ const tab = tabsRepeater.itemAt(i);
+ if (tab && tab.isActive) {
+ return tab;
+ }
+ }
+ return null;
+ }
+
+ visible: activeTab !== null
+ color: Colours.palette.m3primary
+ radius: 10
+
+ x: activeTab ? activeTab.x : 0
+ y: activeTab ? activeTab.y : 0
+ width: activeTab ? activeTab.width : 0
+ height: activeTab ? activeTab.height : 0
+
+ Behavior on x {
+ Anim {
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on width {
+ Anim {
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+
+ Row {
+ id: tabsRow
+ spacing: Appearance.spacing.small
+
+ Repeater {
+ id: tabsRepeater
+ model: root.categories
+
+ delegate: Item {
+ required property var modelData
+ required property int index
+
+ property bool isActive: root.activeCategory === modelData.id
+
+ implicitWidth: tabContent.width + Appearance.padding.normal * 2
+ implicitHeight: tabContent.height + Appearance.padding.smaller * 2
+
+ StateLayer {
+ anchors.fill: parent
+ radius: 6
+ function onClicked(): void {
+ root.categoryChanged(modelData.id);
+
+ const tabLeft = parent.x;
+ const tabRight = parent.x + parent.width;
+ const viewLeft = tabsFlickable.contentX;
+ const viewRight = tabsFlickable.contentX + tabsFlickable.width;
+
+ const targetX = tabLeft - (tabsFlickable.width - parent.width) / 2;
+
+ tabsFlickable.contentX = Math.max(0, Math.min(tabsFlickable.contentWidth - tabsFlickable.width, targetX));
+ }
+ }
+
+ Row {
+ id: tabContent
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.smaller
+
+ MaterialIcon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.icon
+ font.pointSize: Appearance.font.size.small
+ color: isActive ? Colours.palette.m3surface : Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledText {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.name
+ font.pointSize: Appearance.font.size.small
+ color: isActive ? Colours.palette.m3surface : Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ IconButton {
+ icon: "chevron_right"
+ visible: root.showScrollButtons && tabsFlickable.contentWidth > tabsFlickable.width
+ type: IconButton.Text
+ radius: Appearance.rounding.small
+ padding: Appearance.padding.small
+ onClicked: {
+ tabsFlickable.contentX = Math.min(tabsFlickable.contentWidth - tabsFlickable.width, tabsFlickable.contentX + 100);
+ }
+ }
+
+ Loader {
+ Layout.fillHeight: true
+ active: root.showExtraContent && root.extraContent !== null
+ sourceComponent: root.extraContent
+ }
+ }
+}
diff --git a/.github/components/ConnectionHeader.qml b/.github/components/ConnectionHeader.qml
new file mode 100644
index 000000000..12b427648
--- /dev/null
+++ b/.github/components/ConnectionHeader.qml
@@ -0,0 +1,31 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string icon
+ required property string title
+
+ spacing: Appearance.spacing.normal
+ Layout.alignment: Qt.AlignHCenter
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ animate: true
+ text: root.icon
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ animate: true
+ text: root.title
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+}
diff --git a/.github/components/ConnectionInfoSection.qml b/.github/components/ConnectionInfoSection.qml
new file mode 100644
index 000000000..927ef287d
--- /dev/null
+++ b/.github/components/ConnectionInfoSection.qml
@@ -0,0 +1,59 @@
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property var deviceDetails
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ text: qsTr("IP Address")
+ }
+
+ StyledText {
+ text: root.deviceDetails?.ipAddress || qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Subnet Mask")
+ }
+
+ StyledText {
+ text: root.deviceDetails?.subnet || qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Gateway")
+ }
+
+ StyledText {
+ text: root.deviceDetails?.gateway || qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("DNS Servers")
+ }
+
+ StyledText {
+ text: (root.deviceDetails && root.deviceDetails.dns && root.deviceDetails.dns.length > 0) ? root.deviceDetails.dns.join(", ") : qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ wrapMode: Text.Wrap
+ Layout.maximumWidth: parent.width
+ }
+}
diff --git a/.github/components/Logo.qml b/.github/components/Logo.qml
new file mode 100644
index 000000000..7cd41e17f
--- /dev/null
+++ b/.github/components/Logo.qml
@@ -0,0 +1,70 @@
+import QtQuick
+import QtQuick.Shapes
+import qs.services
+
+Item {
+ id: root
+
+ readonly property real designWidth: 128
+ readonly property real designHeight: 90.38
+
+ property color topColour: Colours.palette.m3primary
+ property color bottomColour: Colours.palette.m3onSurface
+
+ implicitWidth: designWidth
+ implicitHeight: designHeight
+
+ Shape {
+ anchors.centerIn: parent
+ width: root.designWidth
+ height: root.designHeight
+ scale: Math.min(root.width / width, root.height / height)
+ transformOrigin: Item.Center
+ preferredRendererType: Shape.CurveRenderer
+
+ ShapePath {
+ fillColor: root.topColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m42.56,42.96c-7.76,1.6-16.36,4.22-22.44,6.22-.49.16-.88-.44-.53-.82,5.37-5.85,9.66-13.3,9.66-13.3,8.66-14.67,22.97-23.51,39.85-21.14,6.47.91,12.33,3.38,17.26,6.98.99.72,1.14,2.14.31,3.04-.4.44-.95.67-1.51.67-.34,0-.69-.09-1-.26-3.21-1.84-6.82-2.69-10.71-3.24-13.1-1.84-25.41,4.75-31.06,15.83-.94,1.84-.61,3.81.45,5.21.22.3.07.72-.29.8Z"
+ }
+ }
+
+ ShapePath {
+ fillColor: root.bottomColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m103.02,51.8c-.65.11-1.26-.37-1.28-1.03-.06-1.96.15-3.89-.2-5.78-.28-1.48-1.66-2.5-3.16-2.34h-.05c-6.53.73-24.63,3.1-48,9.32-6.89,1.83-9.83,10-5.67,15.79,4.62,6.44,11.84,10.93,20.41,12.13,11.82,1.66,22.99-3.36,29.21-12.65.54-.81,1.54-1.17,2.47-.86.91.3,1.47,1.15,1.47,2.04,0,.33-.08.66-.24.98-7.23,14.21-22.91,22.95-39.59,20.6-7.84-1.1-14.8-4.5-20.28-9.43,0,0,0,0-.02-.01-7.28-5.14-14.7-9.99-27.24-11.98-18.82-2.98-9.53-8.75.46-13.78,7.36-3.13,25.17-7.9,36.24-10.73.16-.03.31-.06.47-.1,1.52-.4,3.2-.83,5.02-1.29,1.06-.26,1.93-.48,2.58-.64.09-.02.18-.04.26-.06.31-.08.56-.14.73-.18.03,0,.06-.01.08-.02.03,0,.05-.01.07-.02.02,0,.04,0,.06-.01.01,0,.03,0,.04-.01,0,0,.02,0,.03,0,.01,0,.02,0,.02,0,10.62-2.58,24.63-5.62,37.74-7.34,1.02-.13,2.03-.26,3.03-.37,7.49-.87,14.58-1.26,20.42-.81,25.43,1.95-4.71,16.77-15.12,18.61Z"
+ }
+ }
+
+ ShapePath {
+ fillColor: root.topColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m98.12.06c-.29,2.08-1.72,8.42-8.36,9.19-.09,0-.09.13,0,.14,6.64.78,8.07,7.11,8.36,9.19.01.08.13.08.14,0,.29-2.08,1.72-8.42,8.36-9.19.09,0,.09-.13,0-.14-6.64-.78-8.07-7.11-8.36-9.19-.01-.08-.13-.08-.14,0Z"
+ }
+ }
+
+ ShapePath {
+ fillColor: root.topColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m113.36,15.5c-.22,1.29-1.08,4.35-4.38,4.87-.08.01-.08.13,0,.14,3.3.52,4.17,3.58,4.38,4.87.01.08.13.08.14,0,.22-1.29,1.08-4.35,4.38-4.87.08-.01.08-.13,0-.14-3.3-.52-4.17-3.58-4.38-4.87-.01-.08-.13-.08-.14,0Z"
+ }
+ }
+
+ ShapePath {
+ fillColor: root.topColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m112.69,65.22c-.19,1.01-.86,3.15-3.2,3.57-.08.01-.08.13,0,.14,2.34.42,3.01,2.56,3.2,3.57.01.08.13.08.14,0,.19-1.01.86-3.15,3.2-3.57.08-.01.08-.13,0-.14-2.34-.42-3.01-2.56-3.2-3.57-.01-.08-.13-.08-.14,0Z"
+ }
+ }
+ }
+}
diff --git a/.github/components/MaterialIcon.qml b/.github/components/MaterialIcon.qml
new file mode 100644
index 000000000..a1d19d3c0
--- /dev/null
+++ b/.github/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/.github/components/PropertyRow.qml b/.github/components/PropertyRow.qml
new file mode 100644
index 000000000..640d5f743
--- /dev/null
+++ b/.github/components/PropertyRow.qml
@@ -0,0 +1,26 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string label
+ required property string value
+ property bool showTopMargin: false
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ Layout.topMargin: root.showTopMargin ? Appearance.spacing.normal : 0
+ text: root.label
+ }
+
+ StyledText {
+ text: root.value
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+}
diff --git a/.github/components/SectionContainer.qml b/.github/components/SectionContainer.qml
new file mode 100644
index 000000000..2b653a5d9
--- /dev/null
+++ b/.github/components/SectionContainer.qml
@@ -0,0 +1,32 @@
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ default property alias content: contentColumn.data
+ property real contentSpacing: Appearance.spacing.larger
+ property bool alignTop: false
+
+ Layout.fillWidth: true
+ implicitHeight: contentColumn.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.transparency.enabled ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : Colours.palette.m3surfaceContainerHigh
+
+ ColumnLayout {
+ id: contentColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: root.alignTop ? parent.top : undefined
+ anchors.verticalCenter: root.alignTop ? undefined : parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: root.contentSpacing
+ }
+}
diff --git a/.github/components/SectionHeader.qml b/.github/components/SectionHeader.qml
new file mode 100644
index 000000000..502e91895
--- /dev/null
+++ b/.github/components/SectionHeader.qml
@@ -0,0 +1,27 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string title
+ property string description: ""
+
+ spacing: 0
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: root.title
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ visible: root.description !== ""
+ text: root.description
+ color: Colours.palette.m3outline
+ }
+}
diff --git a/.github/components/StateLayer.qml b/.github/components/StateLayer.qml
new file mode 100644
index 000000000..a20e26616
--- /dev/null
+++ b/.github/components/StateLayer.qml
@@ -0,0 +1,95 @@
+import qs.services
+import qs.config
+import QtQuick
+
+MouseArea {
+ id: root
+
+ property bool disabled
+ property bool showHoverBackground: true
+ property color color: Colours.palette.m3onSurface
+ property real radius: parent?.radius ?? 0
+ property alias rect: hoverLayer
+
+ 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
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: ripple
+ property: "opacity"
+ to: 0
+ }
+ }
+
+ StyledClippingRect {
+ id: hoverLayer
+
+ anchors.fill: parent
+
+ color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.12 : (root.showHoverBackground && 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
+ }
+ }
+ }
+}
diff --git a/.github/components/StyledClippingRect.qml b/.github/components/StyledClippingRect.qml
new file mode 100644
index 000000000..8f2630c13
--- /dev/null
+++ b/.github/components/StyledClippingRect.qml
@@ -0,0 +1,12 @@
+import Quickshell.Widgets
+import QtQuick
+
+ClippingRectangle {
+ id: root
+
+ color: "transparent"
+
+ Behavior on color {
+ CAnim {}
+ }
+}
diff --git a/.github/components/StyledRect.qml b/.github/components/StyledRect.qml
new file mode 100644
index 000000000..f5d514395
--- /dev/null
+++ b/.github/components/StyledRect.qml
@@ -0,0 +1,11 @@
+import QtQuick
+
+Rectangle {
+ id: root
+
+ color: "transparent"
+
+ Behavior on color {
+ CAnim {}
+ }
+}
diff --git a/.github/components/StyledText.qml b/.github/components/StyledText.qml
new file mode 100644
index 000000000..ed961d26a
--- /dev/null
+++ b/.github/components/StyledText.qml
@@ -0,0 +1,48 @@
+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 {
+ CAnim {}
+ }
+
+ 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/.github/components/containers/StyledFlickable.qml b/.github/components/containers/StyledFlickable.qml
new file mode 100644
index 000000000..bc6ae0f62
--- /dev/null
+++ b/.github/components/containers/StyledFlickable.qml
@@ -0,0 +1,14 @@
+import ".."
+import QtQuick
+
+Flickable {
+ id: root
+
+ maximumFlickVelocity: 3000
+
+ rebound: Transition {
+ Anim {
+ properties: "x,y"
+ }
+ }
+}
diff --git a/.github/components/containers/StyledListView.qml b/.github/components/containers/StyledListView.qml
new file mode 100644
index 000000000..626d20635
--- /dev/null
+++ b/.github/components/containers/StyledListView.qml
@@ -0,0 +1,14 @@
+import ".."
+import QtQuick
+
+ListView {
+ id: root
+
+ maximumFlickVelocity: 3000
+
+ rebound: Transition {
+ Anim {
+ properties: "x,y"
+ }
+ }
+}
diff --git a/.github/components/containers/StyledWindow.qml b/.github/components/containers/StyledWindow.qml
new file mode 100644
index 000000000..8c6e39fc8
--- /dev/null
+++ b/.github/components/containers/StyledWindow.qml
@@ -0,0 +1,9 @@
+import Quickshell
+import Quickshell.Wayland
+
+PanelWindow {
+ required property string name
+
+ WlrLayershell.namespace: `caelestia-${name}`
+ color: "transparent"
+}
diff --git a/.github/components/controls/CircularIndicator.qml b/.github/components/controls/CircularIndicator.qml
new file mode 100644
index 000000000..957899e5c
--- /dev/null
+++ b/.github/components/controls/CircularIndicator.qml
@@ -0,0 +1,108 @@
+import ".."
+import qs.services
+import qs.config
+import Caelestia.Internal
+import QtQuick
+import QtQuick.Templates
+
+BusyIndicator {
+ id: root
+
+ enum AnimType {
+ Advance = 0,
+ Retreat
+ }
+
+ enum AnimState {
+ Stopped,
+ Running,
+ Completing
+ }
+
+ property real implicitSize: Appearance.font.size.normal * 3
+ property real strokeWidth: Appearance.padding.small * 0.8
+ property color fgColour: Colours.palette.m3primary
+ property color bgColour: Colours.palette.m3secondaryContainer
+
+ property alias type: manager.indeterminateAnimationType
+ readonly property alias progress: manager.progress
+
+ property real internalStrokeWidth: strokeWidth
+ property int animState
+
+ padding: 0
+ implicitWidth: implicitSize
+ implicitHeight: implicitSize
+
+ Component.onCompleted: {
+ if (running) {
+ running = false;
+ running = true;
+ }
+ }
+
+ onRunningChanged: {
+ if (running) {
+ manager.completeEndProgress = 0;
+ animState = CircularIndicator.Running;
+ } else {
+ if (animState == CircularIndicator.Running)
+ animState = CircularIndicator.Completing;
+ }
+ }
+
+ states: State {
+ name: "stopped"
+ when: !root.running
+
+ PropertyChanges {
+ root.opacity: 0
+ root.internalStrokeWidth: root.strokeWidth / 3
+ }
+ }
+
+ transitions: Transition {
+ Anim {
+ properties: "opacity,internalStrokeWidth"
+ duration: manager.completeEndDuration * Appearance.anim.durations.scale
+ }
+ }
+
+ contentItem: CircularProgress {
+ anchors.fill: parent
+ strokeWidth: root.internalStrokeWidth
+ fgColour: root.fgColour
+ bgColour: root.bgColour
+ padding: root.padding
+ rotation: manager.rotation
+ startAngle: manager.startFraction * 360
+ value: manager.endFraction - manager.startFraction
+ }
+
+ CircularIndicatorManager {
+ id: manager
+ }
+
+ NumberAnimation {
+ running: root.animState !== CircularIndicator.Stopped
+ loops: Animation.Infinite
+ target: manager
+ property: "progress"
+ from: 0
+ to: 1
+ duration: manager.duration * Appearance.anim.durations.scale
+ }
+
+ NumberAnimation {
+ running: root.animState === CircularIndicator.Completing
+ target: manager
+ property: "completeEndProgress"
+ from: 0
+ to: 1
+ duration: manager.completeEndDuration * Appearance.anim.durations.scale
+ onFinished: {
+ if (root.animState === CircularIndicator.Completing)
+ root.animState = CircularIndicator.Stopped;
+ }
+ }
+}
diff --git a/.github/components/controls/CircularProgress.qml b/.github/components/controls/CircularProgress.qml
new file mode 100644
index 000000000..a15cd900b
--- /dev/null
+++ b/.github/components/controls/CircularProgress.qml
@@ -0,0 +1,69 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+Shape {
+ id: root
+
+ property real value
+ property int startAngle: -90
+ property int strokeWidth: Appearance.padding.smaller
+ property int padding: 0
+ property int spacing: Appearance.spacing.small
+ property color fgColour: Colours.palette.m3primary
+ property color bgColour: Colours.palette.m3secondaryContainer
+
+ readonly property real size: Math.min(width, height)
+ readonly property real arcRadius: (size - padding - strokeWidth) / 2
+ readonly property real vValue: value || 1 / 360
+ readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI)
+
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: true
+
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: root.bgColour
+ strokeWidth: root.strokeWidth
+ capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
+
+ PathAngleArc {
+ startAngle: root.startAngle + 360 * root.vValue + root.gapAngle
+ sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2)
+ radiusX: root.arcRadius
+ radiusY: root.arcRadius
+ centerX: root.size / 2
+ centerY: root.size / 2
+ }
+
+ Behavior on strokeColor {
+ CAnim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: root.fgColour
+ strokeWidth: root.strokeWidth
+ capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
+
+ PathAngleArc {
+ startAngle: root.startAngle
+ sweepAngle: 360 * root.vValue
+ radiusX: root.arcRadius
+ radiusY: root.arcRadius
+ centerX: root.size / 2
+ centerY: root.size / 2
+ }
+
+ Behavior on strokeColor {
+ CAnim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+}
diff --git a/.github/components/controls/CollapsibleSection.qml b/.github/components/controls/CollapsibleSection.qml
new file mode 100644
index 000000000..e3d8eefd1
--- /dev/null
+++ b/.github/components/controls/CollapsibleSection.qml
@@ -0,0 +1,132 @@
+import ".."
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string title
+ property string description: ""
+ property bool expanded: false
+ property bool showBackground: false
+ property bool nested: false
+
+ signal toggleRequested
+
+ spacing: Appearance.spacing.small
+ Layout.fillWidth: true
+
+ Item {
+ id: sectionHeaderItem
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.max(titleRow.implicitHeight + Appearance.padding.normal * 2, 48)
+
+ RowLayout {
+ id: titleRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.padding.normal
+ anchors.rightMargin: Appearance.padding.normal
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: root.title
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ MaterialIcon {
+ text: "expand_more"
+ rotation: root.expanded ? 180 : 0
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.normal
+ Behavior on rotation {
+ Anim {
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+
+ StateLayer {
+ anchors.fill: parent
+ color: Colours.palette.m3onSurface
+ radius: Appearance.rounding.normal
+ showHoverBackground: false
+ function onClicked(): void {
+ root.toggleRequested();
+ root.expanded = !root.expanded;
+ }
+ }
+ }
+
+ default property alias content: contentColumn.data
+
+ Item {
+ id: contentWrapper
+ Layout.fillWidth: true
+ Layout.preferredHeight: root.expanded ? (contentColumn.implicitHeight + Appearance.spacing.small * 2) : 0
+ clip: true
+
+ Behavior on Layout.preferredHeight {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ StyledRect {
+ id: backgroundRect
+ anchors.fill: parent
+ radius: Appearance.rounding.normal
+ color: Colours.transparency.enabled ? Colours.layer(Colours.palette.m3surfaceContainer, root.nested ? 3 : 2) : (root.nested ? Colours.palette.m3surfaceContainerHigh : Colours.palette.m3surfaceContainer)
+ opacity: root.showBackground && root.expanded ? 1.0 : 0.0
+ visible: root.showBackground
+
+ Behavior on opacity {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: contentColumn
+ anchors.left: parent.left
+ anchors.right: parent.right
+ y: Appearance.spacing.small
+ anchors.leftMargin: Appearance.padding.normal
+ anchors.rightMargin: Appearance.padding.normal
+ anchors.bottomMargin: Appearance.spacing.small
+ spacing: Appearance.spacing.small
+ opacity: root.expanded ? 1.0 : 0.0
+
+ Behavior on opacity {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ StyledText {
+ id: descriptionText
+ Layout.fillWidth: true
+ Layout.topMargin: root.description !== "" ? Appearance.spacing.smaller : 0
+ Layout.bottomMargin: root.description !== "" ? Appearance.spacing.small : 0
+ visible: root.description !== ""
+ text: root.description
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ wrapMode: Text.Wrap
+ }
+ }
+ }
+}
diff --git a/.github/components/controls/CustomMouseArea.qml b/.github/components/controls/CustomMouseArea.qml
new file mode 100644
index 000000000..7c973c244
--- /dev/null
+++ b/.github/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/.github/components/controls/CustomSpinBox.qml b/.github/components/controls/CustomSpinBox.qml
new file mode 100644
index 000000000..438dc0806
--- /dev/null
+++ b/.github/components/controls/CustomSpinBox.qml
@@ -0,0 +1,170 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+RowLayout {
+ id: root
+
+ property real value
+ property real max: Infinity
+ property real min: -Infinity
+ property real step: 1
+ property alias repeatRate: timer.interval
+
+ signal valueModified(value: real)
+
+ spacing: Appearance.spacing.small
+
+ property bool isEditing: false
+ property string displayText: root.value.toString()
+
+ onValueChanged: {
+ if (!root.isEditing) {
+ root.displayText = root.value.toString();
+ }
+ }
+
+ StyledTextField {
+ id: textField
+
+ inputMethodHints: Qt.ImhFormattedNumbersOnly
+ text: root.isEditing ? text : root.displayText
+ validator: DoubleValidator {
+ bottom: root.min
+ top: root.max
+ decimals: root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0
+ }
+ onActiveFocusChanged: {
+ if (activeFocus) {
+ root.isEditing = true;
+ } else {
+ root.isEditing = false;
+ root.displayText = root.value.toString();
+ }
+ }
+ onAccepted: {
+ const numValue = parseFloat(text);
+ if (!isNaN(numValue)) {
+ const clampedValue = Math.max(root.min, Math.min(root.max, numValue));
+ root.value = clampedValue;
+ root.displayText = clampedValue.toString();
+ root.valueModified(clampedValue);
+ } else {
+ text = root.displayText;
+ }
+ root.isEditing = false;
+ }
+ onEditingFinished: {
+ if (text !== root.displayText) {
+ const numValue = parseFloat(text);
+ if (!isNaN(numValue)) {
+ const clampedValue = Math.max(root.min, Math.min(root.max, numValue));
+ root.value = clampedValue;
+ root.displayText = clampedValue.toString();
+ root.valueModified(clampedValue);
+ } else {
+ text = root.displayText;
+ }
+ }
+ root.isEditing = false;
+ }
+
+ padding: Appearance.padding.small
+ leftPadding: Appearance.padding.normal
+ rightPadding: Appearance.padding.normal
+
+ background: StyledRect {
+ implicitWidth: 100
+ radius: Appearance.rounding.small
+ color: Colours.tPalette.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 {
+ let newValue = Math.min(root.max, root.value + root.step);
+ // Round to avoid floating point precision errors
+ const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0;
+ newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals);
+ root.value = newValue;
+ root.displayText = newValue.toString();
+ root.valueModified(newValue);
+ }
+ }
+
+ 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 {
+ let newValue = Math.max(root.min, root.value - root.step);
+ // Round to avoid floating point precision errors
+ const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0;
+ newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals);
+ root.value = newValue;
+ root.displayText = newValue.toString();
+ root.valueModified(newValue);
+ }
+ }
+
+ 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/.github/components/controls/FilledSlider.qml b/.github/components/controls/FilledSlider.qml
new file mode 100644
index 000000000..80dd44c5f
--- /dev/null
+++ b/.github/components/controls/FilledSlider.qml
@@ -0,0 +1,146 @@
+import ".."
+import "../effects"
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Templates
+
+Slider {
+ id: root
+
+ required property string icon
+ property real oldValue
+ property bool initialized
+
+ orientation: Qt.Vertical
+
+ background: StyledRect {
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.full
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ y: root.handle.y
+ implicitHeight: parent.height - y
+
+ color: Colours.palette.m3secondary
+ radius: parent.radius
+ }
+ }
+
+ handle: Item {
+ id: handle
+
+ property alias moving: icon.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.palette.m3inverseSurface
+ radius: Appearance.rounding.full
+
+ MouseArea {
+ id: handleInteraction
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ MaterialIcon {
+ id: icon
+
+ property bool moving
+
+ function update(): void {
+ animate = !moving;
+ binding.when = moving;
+ font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.larger;
+ font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material;
+ }
+
+ text: root.icon
+ color: Colours.palette.m3inverseOnSurface
+ anchors.centerIn: parent
+
+ onMovingChanged: anim.restart()
+
+ Binding {
+ id: binding
+
+ target: icon
+ property: "text"
+ value: Math.round(root.value * 100)
+ when: false
+ }
+
+ SequentialAnimation {
+ id: anim
+
+ Anim {
+ target: icon
+ property: "scale"
+ to: 0
+ duration: Appearance.anim.durations.normal / 2
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ ScriptAction {
+ script: icon.update()
+ }
+ Anim {
+ target: icon
+ property: "scale"
+ to: 1
+ duration: Appearance.anim.durations.normal / 2
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+ }
+
+ onPressedChanged: handle.moving = pressed
+
+ onValueChanged: {
+ if (!initialized) {
+ initialized = true;
+ return;
+ }
+ 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 {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+}
diff --git a/.github/components/controls/IconButton.qml b/.github/components/controls/IconButton.qml
new file mode 100644
index 000000000..ffb1d0663
--- /dev/null
+++ b/.github/components/controls/IconButton.qml
@@ -0,0 +1,83 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+
+StyledRect {
+ id: root
+
+ enum Type {
+ Filled,
+ Tonal,
+ Text
+ }
+
+ property alias icon: label.text
+ property bool checked
+ property bool toggle
+ property real padding: type === IconButton.Text ? Appearance.padding.small / 2 : Appearance.padding.smaller
+ property alias font: label.font
+ property int type: IconButton.Filled
+ property bool disabled
+
+ property alias stateLayer: stateLayer
+ property alias label: label
+ property alias radiusAnim: radiusAnim
+
+ property bool internalChecked
+ property color activeColour: type === IconButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary
+ property color inactiveColour: {
+ if (!toggle && type === IconButton.Filled)
+ return Colours.palette.m3primary;
+ return type === IconButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer;
+ }
+ property color activeOnColour: type === IconButton.Filled ? Colours.palette.m3onPrimary : type === IconButton.Tonal ? Colours.palette.m3onSecondary : Colours.palette.m3primary
+ property color inactiveOnColour: {
+ if (!toggle && type === IconButton.Filled)
+ return Colours.palette.m3onPrimary;
+ return type === IconButton.Tonal ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant;
+ }
+ property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1)
+ property color disabledOnColour: Qt.alpha(Colours.palette.m3onSurface, 0.38)
+
+ signal clicked
+
+ onCheckedChanged: internalChecked = checked
+
+ radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour
+
+ implicitWidth: implicitHeight
+ implicitHeight: label.implicitHeight + padding * 2
+
+ StateLayer {
+ id: stateLayer
+
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+ disabled: root.disabled
+
+ function onClicked(): void {
+ if (root.toggle)
+ root.internalChecked = !root.internalChecked;
+ root.clicked();
+ }
+ }
+
+ MaterialIcon {
+ id: label
+
+ anchors.centerIn: parent
+ color: root.disabled ? root.disabledOnColour : root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+ fill: !root.toggle || root.internalChecked ? 1 : 0
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+
+ Behavior on radius {
+ Anim {
+ id: radiusAnim
+ }
+ }
+}
diff --git a/.github/components/controls/IconTextButton.qml b/.github/components/controls/IconTextButton.qml
new file mode 100644
index 000000000..b2bb96cc0
--- /dev/null
+++ b/.github/components/controls/IconTextButton.qml
@@ -0,0 +1,88 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ enum Type {
+ Filled,
+ Tonal,
+ Text
+ }
+
+ property alias icon: iconLabel.text
+ property alias text: label.text
+ property bool checked
+ property bool toggle
+ property real horizontalPadding: Appearance.padding.normal
+ property real verticalPadding: Appearance.padding.smaller
+ property alias font: label.font
+ property int type: IconTextButton.Filled
+
+ property alias stateLayer: stateLayer
+ property alias iconLabel: iconLabel
+ property alias label: label
+
+ property bool internalChecked
+ property color activeColour: type === IconTextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary
+ property color inactiveColour: type === IconTextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer
+ property color activeOnColour: type === IconTextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary
+ property color inactiveOnColour: type === IconTextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+
+ signal clicked
+
+ onCheckedChanged: internalChecked = checked
+
+ radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ color: type === IconTextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour
+
+ implicitWidth: row.implicitWidth + horizontalPadding * 2
+ implicitHeight: row.implicitHeight + verticalPadding * 2
+
+ StateLayer {
+ id: stateLayer
+
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+
+ function onClicked(): void {
+ if (root.toggle)
+ root.internalChecked = !root.internalChecked;
+ root.clicked();
+ }
+ }
+
+ RowLayout {
+ id: row
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ id: iconLabel
+
+ Layout.alignment: Qt.AlignVCenter
+ Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575)
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+ fill: root.internalChecked ? 1 : 0
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+
+ StyledText {
+ id: label
+
+ Layout.alignment: Qt.AlignVCenter
+ Layout.topMargin: -Math.round(iconLabel.fontInfo.pointSize * 0.0575)
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+ }
+ }
+
+ Behavior on radius {
+ Anim {}
+ }
+}
diff --git a/.github/components/controls/Menu.qml b/.github/components/controls/Menu.qml
new file mode 100644
index 000000000..c763b54a8
--- /dev/null
+++ b/.github/components/controls/Menu.qml
@@ -0,0 +1,113 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../effects"
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Elevation {
+ id: root
+
+ property list