Rearrange for tidier structure (#2212)

This commit is contained in:
clsty
2025-10-16 07:19:55 +08:00
parent 13065d7e5a
commit 8b493e091d
529 changed files with 165 additions and 138 deletions
@@ -0,0 +1,120 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
Rectangle {
id: root
required property var directory
property bool showBreadcrumb: true
onShowBreadcrumbChanged: {
addressInput.text = root.directory;
}
signal navigateToDirectory(string path)
property real padding: 6
implicitWidth: mainLayout.implicitWidth + padding * 2
implicitHeight: mainLayout.implicitHeight + padding * 2
color: Appearance.colors.colLayer2
function focusBreadcrumb() {
root.showBreadcrumb = false;
addressInput.forceActiveFocus();
}
RowLayout {
id: mainLayout
anchors {
fill: parent
margins: root.padding
}
spacing: 8
RippleButton {
id: parentDirButton
downAction: () => root.navigateToDirectory(FileUtils.parentDirectory(root.directory))
contentItem: MaterialSymbol {
text: "drive_folder_upload"
iconSize: Appearance.font.pixelSize.larger
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
id: directoryEntry
visible: !root.showBreadcrumb
anchors.fill: parent
color: Appearance.colors.colLayer1
radius: Appearance.rounding.full
implicitWidth: addressInput.implicitWidth
implicitHeight: addressInput.implicitHeight
Keys.onPressed: event => {
if (directoryEntry.visible && event.key === Qt.Key_Escape) {
root.showBreadcrumb = true;
event.accepted = true;
return;
}
event.accepted = false;
}
StyledTextInput {
id: addressInput
anchors.fill: parent
padding: 10
text: root.directory
Keys.onPressed: event => {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.navigateToDirectory(text);
root.showBreadcrumb = true;
event.accepted = true;
}
}
MouseArea {
// I-beam cursor
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
cursorShape: Qt.IBeamCursor
}
}
}
Loader {
id: breadcrumbLoader
active: root.showBreadcrumb
visible: root.showBreadcrumb
anchors.fill: parent
sourceComponent: AddressBreadcrumb {
directory: root.directory
onNavigateToDirectory: dir => {
root.navigateToDirectory(dir);
}
}
}
}
RippleButton {
id: dirEditButton
toggled: !root.showBreadcrumb
downAction: () => root.showBreadcrumb = !root.showBreadcrumb
contentItem: MaterialSymbol {
text: "edit"
iconSize: Appearance.font.pixelSize.larger
color: dirEditButton.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer2
}
StyledToolTip {
text: Translation.tr("Edit directory")
}
}
}
}
@@ -0,0 +1,41 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
ListView {
id: root
required property var directory
property var breadcrumbDirectory: ""
Component.onCompleted: breadcrumbDirectory = directory;
onDirectoryChanged: {
if (breadcrumbDirectory.startsWith(directory)) return;
breadcrumbDirectory = directory
}
signal navigateToDirectory(string path)
orientation: ListView.Horizontal
clip: true
spacing: 2
model: breadcrumbDirectory.split("/")
delegate: SelectionGroupButton {
id: folderButton
required property var modelData
required property int index
buttonText: index === 0 ? "/" : modelData
toggled: {
if (directory.trim() === "/") return index === 0;
return index === directory.split("/").length - 1
}
leftmost: index === 0
rightmost: index === breadcrumbDirectory.split("/").length - 1
onClicked: {
root.navigateToDirectory(breadcrumbDirectory.split("/").slice(0, index + 1).join("/"))
}
}
}
@@ -0,0 +1,47 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
/**
* A container that supports GroupButton children for bounciness.
* See https://m3.material.io/components/button-groups/overview
*/
Rectangle {
id: root
default property alias data: rowLayout.data
property alias uniformCellSizes: rowLayout.uniformCellSizes
property real spacing: 5
property real padding: 0
property int clickIndex: rowLayout.clickIndex
property real contentWidth: {
let total = 0;
for (let i = 0; i < rowLayout.children.length; ++i) {
const child = rowLayout.children[i];
if (!child.visible) continue;
total += child.baseWidth ?? child.implicitWidth ?? child.width;
}
return total + rowLayout.spacing * (rowLayout.children.length - 1);
}
topLeftRadius: rowLayout.children.length > 0 ? (rowLayout.children[0].radius + padding) :
Appearance?.rounding?.small
bottomLeftRadius: topLeftRadius
topRightRadius: rowLayout.children.length > 0 ? (rowLayout.children[rowLayout.children.length - 1].radius + padding) :
Appearance?.rounding?.small
bottomRightRadius: topRightRadius
color: "transparent"
width: root.contentWidth + padding * 2
implicitHeight: rowLayout.implicitHeight + padding * 2
implicitWidth: root.contentWidth + padding * 2
children: [RowLayout {
id: rowLayout
anchors.fill: parent
anchors.margins: root.padding
spacing: root.spacing
property int clickIndex: -1
}]
}
@@ -0,0 +1,88 @@
import QtQuick
import QtQuick.Shapes
import qs.modules.common
/**
* Material 3 circular progress. See https://m3.material.io/components/progress-indicators/specs
*/
Item {
id: root
property int implicitSize: 30
property int lineWidth: 2
property real value: 0
property color colPrimary: Appearance.m3colors.m3onSecondaryContainer
property color colSecondary: Appearance.colors.colSecondaryContainer
property real gapAngle: 360 / 18
property bool fill: false
property int fillOverflow: 2
property bool enableAnimation: true
property int animationDuration: 800
property var easingType: Easing.OutCubic
implicitWidth: implicitSize
implicitHeight: implicitSize
property real degree: value * 360
property real centerX: root.width / 2
property real centerY: root.height / 2
property real arcRadius: root.implicitSize / 2 - root.lineWidth
property real startAngle: -90
Behavior on degree {
enabled: root.enableAnimation
NumberAnimation {
duration: root.animationDuration
easing.type: root.easingType
}
}
Loader {
active: root.fill
anchors.fill: parent
sourceComponent: Rectangle {
radius: 9999
color: root.colSecondary
}
}
Shape {
anchors.fill: parent
layer.enabled: true
layer.smooth: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: secondaryPath
strokeColor: root.colSecondary
strokeWidth: root.lineWidth
capStyle: ShapePath.RoundCap
fillColor: "transparent"
PathAngleArc {
centerX: root.centerX
centerY: root.centerY
radiusX: root.arcRadius
radiusY: root.arcRadius
startAngle: root.startAngle - root.gapAngle
sweepAngle: -(360 - root.degree - 2 * root.gapAngle)
}
}
ShapePath {
id: primaryPath
strokeColor: root.colPrimary
strokeWidth: root.lineWidth
capStyle: ShapePath.RoundCap
fillColor: "transparent"
PathAngleArc {
centerX: root.centerX
centerY: root.centerY
radiusX: root.arcRadius
radiusY: root.arcRadius
startAngle: root.startAngle
sweepAngle: root.degree
}
}
}
}
@@ -0,0 +1,131 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import Quickshell
import Quickshell.Io
Rectangle {
id: root
property string entry
property real maxWidth
property real maxHeight
property bool blur: false
property string blurText: "Image hidden"
property string imageDecodePath: Directories.cliphistDecode
property string imageDecodeFileName: `${entryNumber}`
property string imageDecodeFilePath: `${imageDecodePath}/${imageDecodeFileName}`
property string source
property int entryNumber: {
if (!root.entry)
return 0;
const match = root.entry.match(/^(\d+)\t/);
return match ? parseInt(match[1]) : 0;
}
property int imageWidth: {
if (!root.entry)
return 0;
const match = root.entry.match(/(\d+)x(\d+)/);
return match ? parseInt(match[1]) : 0;
}
property int imageHeight: {
if (!root.entry)
return 0;
const match = root.entry.match(/(\d+)x(\d+)/);
return match ? parseInt(match[2]) : 0;
}
property real scale: {
return Math.min(root.maxWidth / imageWidth, root.maxHeight / imageHeight, 1);
}
color: Appearance.colors.colLayer1
radius: Appearance.rounding.small
implicitHeight: imageHeight * scale
implicitWidth: imageWidth * scale
Component.onCompleted: {
decodeImageProcess.running = true;
}
Process {
id: decodeImageProcess
command: ["bash", "-c", `[ -f ${imageDecodeFilePath} ] || echo '${StringUtils.shellSingleQuoteEscape(root.entry)}' | ${Cliphist.cliphistBinary} decode > '${imageDecodeFilePath}'`]
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
root.source = imageDecodeFilePath;
} else {
console.error("[CliphistImage] Failed to decode image for entry:", root.entry);
root.source = "";
}
}
}
Component.onDestruction: {
Quickshell.execDetached(["bash", "-c", `[ -f '${imageDecodeFilePath}' ] && rm -f '${imageDecodeFilePath}'`]);
}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: image.width
height: image.height
radius: root.radius
}
}
StyledImage {
id: image
anchors.fill: parent
source: Qt.resolvedUrl(root.source)
fillMode: Image.PreserveAspectFit
antialiasing: true
asynchronous: true
width: root.imageWidth * root.scale
height: root.imageHeight * root.scale
sourceSize.width: width
sourceSize.height: height
}
Loader {
id: blurLoader
active: root.blur
anchors.fill: image
sourceComponent: GaussianBlur {
source: image
radius: 35
samples: radius * 2 + 1
Rectangle {
anchors.fill: parent
color: ColorUtils.transparentize(Appearance.colors.colLayer0, 0.5)
Column {
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
}
MaterialSymbol {
visible: width <= image.width
anchors.horizontalCenter: parent.horizontalCenter
text: "visibility_off"
font.pixelSize: 28
}
StyledText {
visible: width <= image.width
anchors.horizontalCenter: parent.horizontalCenter
text: root.blurText
color: Appearance.colors.colOnSurface
font.pixelSize: Appearance.font.pixelSize.smallie
}
}
}
}
}
}
@@ -0,0 +1,98 @@
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import QtQuick.Shapes
import Qt5Compat.GraphicalEffects
Item {
id: root
property int implicitSize: 18
property int lineWidth: 2
property real value: 0
property color colPrimary: Appearance?.colors.colOnSecondaryContainer ?? "#685496"
property color colSecondary: ColorUtils.transparentize(colPrimary, 0.5) ?? "#F1D3F9"
property real gapAngle: 360 / 18
property bool fill: true
property int fillOverflow: 2
property bool enableAnimation: true
property int animationDuration: 800
property var easingType: Easing.OutCubic
property bool accountForLightBleeding: true
default property Item textMask: Item {
width: implicitSize
height: implicitSize
StyledText {
anchors.centerIn: parent
text: Math.round(root.value * 100)
font.pixelSize: 12
font.weight: Font.Medium
}
}
implicitWidth: implicitSize
implicitHeight: implicitSize
property real degree: value * 360
property real centerX: root.width / 2
property real centerY: root.height / 2
property real arcRadius: root.implicitSize / 2 - root.lineWidth / 2 - (0.5 * root.accountForLightBleeding)
property real startAngle: -90
Behavior on degree {
enabled: root.enableAnimation
NumberAnimation {
duration: root.animationDuration
easing.type: root.easingType
}
}
Rectangle {
id: contentItem
anchors.fill: parent
radius: implicitSize / 2
color: root.colSecondary
visible: false
layer.enabled: true
layer.smooth: true
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: primaryPath
pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting
strokeColor: root.colPrimary
strokeWidth: root.lineWidth
capStyle: ShapePath.RoundCap
fillColor: root.colPrimary
startX: root.centerX
startY: root.centerY
PathAngleArc {
moveToStart: false
centerX: root.centerX
centerY: root.centerY
radiusX: root.arcRadius
radiusY: root.arcRadius
startAngle: root.startAngle
sweepAngle: root.degree
}
PathLine {
x: primaryPath.startX
y: primaryPath.startY
}
}
}
}
OpacityMask {
anchors.fill: parent
source: contentItem
invert: true
maskSource: root.textMask
}
}
@@ -0,0 +1,101 @@
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
/**
* A progress bar with both ends rounded and text acts as clipping like OneUI 7's battery indicator.
*/
ProgressBar {
id: root
property bool vertical: false
property real valueBarWidth: 30
property real valueBarHeight: 18
property color highlightColor: Appearance?.colors.colOnSecondaryContainer ?? "#685496"
property color trackColor: ColorUtils.transparentize(highlightColor, 0.5) ?? "#F1D3F9"
property alias radius: contentItem.radius
property string text
default property Item textMask: Item {
width: valueBarWidth
height: valueBarHeight
StyledText {
anchors.centerIn: parent
font: root.font
text: root.text
}
}
text: Math.round(value * 100)
font {
pixelSize: 13
weight: text.length > 2 ? Font.Medium : Font.DemiBold
}
background: Item {
implicitHeight: valueBarHeight
implicitWidth: valueBarWidth
}
contentItem: Rectangle {
id: contentItem
anchors.fill: parent
radius: 9999
color: root.trackColor
visible: false
Rectangle {
id: progressFill
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
right: undefined
}
width: parent.width * root.visualPosition
height: parent.height
states: State {
name: "vertical"
when: root.vertical
AnchorChanges {
target: progressFill
anchors {
top: undefined
bottom: parent.bottom
left: parent.left
right: parent.right
}
}
PropertyChanges {
target: progressFill
width: parent.width
height: parent.height * root.visualPosition
}
}
radius: Appearance.rounding.unsharpen
color: root.highlightColor
}
}
OpacityMask {
id: roundingMask
visible: false
anchors.fill: parent
source: contentItem
maskSource: Rectangle {
width: contentItem.width
height: contentItem.height
radius: contentItem.radius
}
}
OpacityMask {
anchors.fill: parent
source: roundingMask
invert: true
maskSource: root.textMask
}
}
@@ -0,0 +1,8 @@
import QtQuick
import QtQuick.Layouts
RowLayout {
property bool uniform: false
spacing: 4
uniformCellSizes: uniform
}
@@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
Flow {
id: root
Layout.fillWidth: true
spacing: 2
property list<var> options: [
{
"displayName": "Option 1",
"icon": "check",
"value": 1
},
{
"displayName": "Option 2",
"icon": "close",
"value": 2
},
]
property var currentValue: null
signal selected(var newValue)
Repeater {
model: root.options
delegate: SelectionGroupButton {
id: paletteButton
required property var modelData
required property int index
onYChanged: {
if (index === 0) {
paletteButton.leftmost = true
} else {
var prev = root.children[index - 1]
var thisIsOnNewLine = prev && prev.y !== paletteButton.y
paletteButton.leftmost = thisIsOnNewLine
prev.rightmost = thisIsOnNewLine
}
}
leftmost: index === 0
rightmost: index === root.options.length - 1
buttonIcon: modelData.icon || ""
buttonText: modelData.displayName
toggled: root.currentValue === modelData.value
onClicked: {
root.selected(modelData.value);
}
}
}
}
@@ -0,0 +1,38 @@
import qs.modules.common.widgets
import qs.modules.common
import QtQuick
import QtQuick.Layouts
RowLayout {
id: root
property string text: ""
property string icon
property alias value: spinBoxWidget.value
property alias stepSize: spinBoxWidget.stepSize
property alias from: spinBoxWidget.from
property alias to: spinBoxWidget.to
spacing: 10
Layout.leftMargin: 8
Layout.rightMargin: 8
RowLayout {
spacing: 10
OptionalMaterialSymbol {
icon: root.icon
opacity: root.enabled ? 1 : 0.4
}
StyledText {
id: labelWidget
Layout.fillWidth: true
text: root.text
color: Appearance.colors.colOnSecondaryContainer
opacity: root.enabled ? 1 : 0.4
}
}
StyledSpinBox {
id: spinBoxWidget
Layout.fillWidth: false
value: root.value
}
}
@@ -0,0 +1,42 @@
import qs.modules.common.widgets
import qs.modules.common
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
RippleButton {
id: root
property string buttonIcon
Layout.fillWidth: true
implicitHeight: contentItem.implicitHeight + 8 * 2
font.pixelSize: Appearance.font.pixelSize.small
onClicked: checked = !checked
contentItem: RowLayout {
spacing: 10
OptionalMaterialSymbol {
icon: root.buttonIcon
opacity: root.enabled ? 1 : 0.4
iconSize: Appearance.font.pixelSize.larger
}
StyledText {
id: labelWidget
Layout.fillWidth: true
text: root.text
font: root.font
color: Appearance.colors.colOnSecondaryContainer
opacity: root.enabled ? 1 : 0.4
}
StyledSwitch {
id: switchWidget
down: root.down
scale: 0.6
Layout.fillWidth: false
checked: root.checked
onClicked: root.clicked()
}
}
}
@@ -0,0 +1,29 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
StyledFlickable {
id: root
property real baseWidth: 600
property bool forceWidth: false
property real bottomContentPadding: 100
default property alias data: contentColumn.data
clip: true
contentHeight: contentColumn.implicitHeight + root.bottomContentPadding // Add some padding at the bottom
implicitWidth: contentColumn.implicitWidth
ColumnLayout {
id: contentColumn
width: root.forceWidth ? root.baseWidth : Math.max(root.baseWidth, implicitWidth)
anchors {
top: parent.top
horizontalCenter: parent.horizontalCenter
margins: 20
}
spacing: 30
}
}
@@ -0,0 +1,36 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
ColumnLayout {
id: root
property string title
property string icon: ""
default property alias data: sectionContent.data
Layout.fillWidth: true
spacing: 6
RowLayout {
spacing: 6
OptionalMaterialSymbol {
icon: root.icon
iconSize: Appearance.font.pixelSize.hugeass
}
StyledText {
text: root.title
font.pixelSize: Appearance.font.pixelSize.larger
font.weight: Font.Medium
color: Appearance.colors.colOnSecondaryContainer
}
}
ColumnLayout {
id: sectionContent
Layout.fillWidth: true
spacing: 4
}
}
@@ -0,0 +1,46 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
ColumnLayout {
id: root
property string title: ""
property string tooltip: ""
default property alias data: sectionContent.data
Layout.fillWidth: true
Layout.topMargin: 4
spacing: 2
RowLayout {
ContentSubsectionLabel {
visible: root.title && root.title.length > 0
text: root.title
}
MaterialSymbol {
visible: root.tooltip && root.tooltip.length > 0
text: "info"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colSubtext
MouseArea {
id: infoMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.WhatsThisCursor
StyledToolTip {
extraVisibleCondition: false
alternativeVisibleCondition: infoMouseArea.containsMouse
text: root.tooltip
}
}
}
Item { Layout.fillWidth: true }
}
ColumnLayout {
id: sectionContent
Layout.fillWidth: true
spacing: 2
}
}
@@ -0,0 +1,10 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
StyledText {
text: "Subsection"
color: Appearance.colors.colSubtext
Layout.leftMargin: 2
}
@@ -0,0 +1,25 @@
import QtQuick
import qs.modules.common
import qs.modules.common.widgets
MaterialCookie {
id: root
property alias text: symbol.text
property alias iconSize: symbol.iconSize
property alias font: symbol.font
property alias colSymbol: symbol.color
property real padding: 6
color: Appearance.colors.colSecondaryContainer
colSymbol: Appearance.colors.colOnSecondaryContainer
sides: 5
implicitSize: Math.max(symbol.implicitWidth, symbol.implicitHeight) + padding * 2
MaterialSymbol {
id: symbol
anchors.centerIn: parent
}
}
@@ -0,0 +1,37 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import Qt5Compat.GraphicalEffects
Item {
id: root
property bool colorize: false
property color color
property string source: ""
property string iconFolder: Qt.resolvedUrl(Quickshell.shellPath("assets/icons")) // The folder to check first
width: 30
height: 30
IconImage {
id: iconImage
anchors.fill: parent
source: {
const fullPathWhenSourceIsIconName = iconFolder + "/" + root.source;
if (iconFolder && fullPathWhenSourceIsIconName) {
return fullPathWhenSourceIsIconName
}
return root.source
}
implicitSize: root.height
}
Loader {
active: root.colorize
anchors.fill: iconImage
sourceComponent: ColorOverlay {
source: iconImage
color: root.color
}
}
}
@@ -0,0 +1,40 @@
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
import QtQuick
/**
* Material 3 dialog button. See https://m3.material.io/components/dialogs/overview
*/
RippleButton {
id: root
property string buttonText
padding: 14
implicitHeight: 36
implicitWidth: buttonTextWidget.implicitWidth + padding * 2
buttonRadius: Appearance?.rounding.full ?? 9999
property color colEnabled: Appearance?.colors.colPrimary ?? "#65558F"
property color colDisabled: Appearance?.m3colors.m3outline ?? "#8D8C96"
colBackground: ColorUtils.transparentize(Appearance.colors.colLayer3)
colBackgroundHover: Appearance.colors.colLayer3Hover
colRipple: Appearance.colors.colLayer3Active
property alias colText: buttonTextWidget.color
contentItem: StyledText {
id: buttonTextWidget
anchors.fill: parent
anchors.leftMargin: root.padding
anchors.rightMargin: root.padding
text: buttonText
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance?.font.pixelSize.small ?? 12
color: root.enabled ? root.colEnabled : root.colDisabled
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
@@ -0,0 +1,25 @@
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
import QtQuick
RippleButton {
id: root
property bool active: false
horizontalPadding: Appearance.rounding.large
verticalPadding: 12
clip: true
pointingHandCursor: !active
implicitWidth: contentItem.implicitWidth + horizontalPadding * 2
implicitHeight: contentItem.implicitHeight + verticalPadding * 2
Behavior on implicitHeight {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
colBackground: ColorUtils.transparentize(Appearance.colors.colLayer3)
colBackgroundHover: active ? colBackground : Appearance.colors.colLayer3Hover
colRipple: Appearance.colors.colLayer3Active
buttonRadius: 0
}
@@ -0,0 +1,41 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
import qs.modules.common.functions
// From https://github.com/caelestia-dots/shell with modifications.
// License: GPLv3
Image {
id: root
required property var fileModelData
asynchronous: true
fillMode: Image.PreserveAspectFit
source: {
if (!fileModelData.fileIsDir)
return Quickshell.iconPath("application-x-zerosize");
if ([Directories.documents, Directories.downloads, Directories.music, Directories.pictures, Directories.videos].some(dir => FileUtils.trimFileProtocol(dir) === fileModelData.filePath))
return Quickshell.iconPath(`folder-${fileModelData.fileName.toLowerCase()}`);
return Quickshell.iconPath("inode-directory");
}
onStatusChanged: {
if (status === Image.Error)
source = Quickshell.iconPath("error");
}
Process {
running: !fileModelData.fileIsDir
command: ["file", "--mime", "-b", fileModelData.filePath]
stdout: StdioCollector {
onStreamFinished: {
const mime = text.split(";")[0].replace("/", "-");
root.source = Images.validImageTypes.some(t => mime === `image-${t}`) ? fileModelData.fileUrl : Quickshell.iconPath(mime, "image-missing");
}
}
}
}
@@ -0,0 +1,72 @@
import qs.modules.common
import qs.services
import QtQuick
/**
* A convenience MouseArea for handling drag events.
*/
MouseArea {
id: root
hoverEnabled: true
acceptedButtons: Qt.LeftButton
property bool interactive: true
property bool automaticallyReset: true
readonly property real dragDiffX: _dragDiffX
readonly property real dragDiffY: _dragDiffY
signal dragPressed(diffX: real, diffY: real)
signal dragReleased(diffX: real, diffY: real)
property real startX: 0
property real startY: 0
property bool dragging: false
property real _dragDiffX: 0
property real _dragDiffY: 0
function resetDrag() {
_dragDiffX = 0
_dragDiffY = 0
}
onPressed: (mouse) => {
if (!root.interactive) {
if (mouse.button === Qt.LeftButton) {
mouse.accepted = false;
}
return;
}
if (mouse.button === Qt.LeftButton) {
startX = mouse.x
startY = mouse.y
}
}
onReleased: (mouse) => {
if (!root.interactive) {
return;
}
dragging = false
root.dragReleased(_dragDiffX, _dragDiffY);
if (root.automaticallyReset) {
root.resetDrag();
}
}
onPositionChanged: (mouse) => {
if (!root.interactive) {
return;
}
if (mouse.buttons & Qt.LeftButton) {
root._dragDiffX = mouse.x - startX
root._dragDiffY = mouse.y - startY
const dist = Math.sqrt(root._dragDiffX * root._dragDiffX + root._dragDiffY * root._dragDiffY);
root.dragPressed(_dragDiffX, _dragDiffY);
root.dragging = true;
}
}
onCanceled: (mouse) => {
if (!root.interactive) {
return;
}
released(mouse);
}
}
@@ -0,0 +1,16 @@
import QtQuick
import qs.modules.common
import qs.modules.common.widgets
Loader {
id: root
property bool shown: true
opacity: shown ? 1 : 0
visible: opacity > 0
active: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
@@ -0,0 +1,48 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import Quickshell.Io
import Quickshell.Widgets
IconImage {
id: root
property string url
property string displayText
property real size: 32
property string downloadUserAgent: Config.options?.networking.userAgent ?? ""
property string faviconDownloadPath: Directories.favicons
property string domainName: url.includes("vertexaisearch") ? displayText : StringUtils.getDomain(url)
property string faviconUrl: `https://www.google.com/s2/favicons?domain=${domainName}&sz=32`
property string fileName: `${domainName}.ico`
property string faviconFilePath: `${faviconDownloadPath}/${fileName}`
property string urlToLoad
Process {
id: faviconDownloadProcess
running: false
command: ["bash", "-c", `[ -f ${faviconFilePath} ] || curl -s '${root.faviconUrl}' -o '${faviconFilePath}' -L -H 'User-Agent: ${downloadUserAgent}'`]
onExited: (exitCode, exitStatus) => {
root.urlToLoad = root.faviconFilePath
}
}
Component.onCompleted: {
faviconDownloadProcess.running = true
}
source: Qt.resolvedUrl(root.urlToLoad)
implicitSize: root.size
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: root.implicitSize
height: root.implicitSize
radius: Appearance.rounding.full
}
}
}
@@ -0,0 +1,59 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
/**
* Material 3 FAB.
*/
RippleButton {
id: root
property string iconText: "add"
property bool expanded: false
property real baseSize: 56
property real elementSpacing: 5
implicitWidth: Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize)
implicitHeight: baseSize
buttonRadius: Appearance.rounding.small
colBackground: Appearance.colors.colPrimaryContainer
colBackgroundHover: Appearance.colors.colPrimaryContainerHover
colRipple: Appearance.colors.colPrimaryContainerActive
contentItem: RowLayout {
id: contentRowLayout
property real horizontalMargins: (root.baseSize - icon.width) / 2
anchors {
verticalCenter: parent?.verticalCenter
left: parent?.left
leftMargin: contentRowLayout.horizontalMargins
}
spacing: 0
MaterialSymbol {
id: icon
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
iconSize: 24
color: Appearance.colors.colOnPrimaryContainer
text: root.iconText
}
Loader {
active: true
sourceComponent: Revealer {
visible: root.expanded || implicitWidth > 0
reveal: root.expanded
implicitWidth: reveal ? (buttonText.implicitWidth + root.elementSpacing + contentRowLayout.horizontalMargins) : 0
StyledText {
id: buttonText
anchors {
left: parent.left
leftMargin: root.elementSpacing
}
text: root.buttonText
color: Appearance.colors.colOnPrimaryContainer
font.pixelSize: 14
font.weight: 450
}
}
}
}
}
@@ -0,0 +1,8 @@
import QtQuick
/**
* This is just to make sure `RippleButton`s can be used in a Flow layout.
*/
Flow {
property int clickIndex: -1
}
@@ -0,0 +1,56 @@
import QtQuick
MouseArea { // Right side | scroll to change volume
id: root
signal scrollUp(delta: int)
signal scrollDown(delta: int)
signal movedAway()
property bool hovered: false
property real lastScrollX: 0
property real lastScrollY: 0
property bool trackingScroll: false
property real moveThreshold: 20
acceptedButtons: Qt.LeftButton
hoverEnabled: true
onEntered: {
root.hovered = true;
}
onExited: {
root.hovered = false;
root.trackingScroll = false;
}
onWheel: event => {
if (event.angleDelta.y < 0)
root.scrollDown(event.angleDelta.y);
else if (event.angleDelta.y > 0)
root.scrollUp(event.angleDelta.y);
// Store the mouse position and start tracking
root.lastScrollX = event.x;
root.lastScrollY = event.y;
root.trackingScroll = true;
}
onPositionChanged: mouse => {
if (root.trackingScroll) {
const dx = mouse.x - root.lastScrollX;
const dy = mouse.y - root.lastScrollY;
if (Math.sqrt(dx * dx + dy * dy) > root.moveThreshold) {
root.movedAway();
root.trackingScroll = false;
}
}
}
onContainsMouseChanged: {
if (!root.containsMouse && root.trackingScroll) {
root.movedAway();
root.trackingScroll = false;
}
}
}
@@ -0,0 +1,130 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/**
* Material 3 button with expressive bounciness.
* See https://m3.material.io/components/button-groups/overview
*/
Button {
id: root
property bool toggled
property string buttonText
property real buttonRadius: Appearance?.rounding?.small ?? 8
property real buttonRadiusPressed: Appearance?.rounding?.small ?? 6
property var downAction // When left clicking (down)
property var releaseAction // When left clicking (release)
property var altAction // When right clicking
property var middleClickAction // When middle clicking
property bool bounce: true
property real baseWidth: contentItem.implicitWidth + horizontalPadding * 2
property real baseHeight: contentItem.implicitHeight + verticalPadding * 2
property real clickedWidth: baseWidth + 20
property real clickedHeight: baseHeight
property var parentGroup: root.parent
property int clickIndex: parentGroup?.clickIndex ?? -1
Layout.fillWidth: (clickIndex - 1 <= parentGroup?.children.indexOf(root) && parentGroup?.children.indexOf(root) <= clickIndex + 1)
Layout.fillHeight: (clickIndex - 1 <= parentGroup?.children.indexOf(root) && parentGroup?.children.indexOf(root) <= clickIndex + 1)
implicitWidth: (root.down && bounce) ? clickedWidth : baseWidth
implicitHeight: (root.down && bounce) ? clickedHeight : baseHeight
property color colBackground: ColorUtils.transparentize(colBackgroundHover, 1) || "transparent"
property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED"
property color colBackgroundActive: Appearance?.colors.colLayer1Active ?? "#D6CEE2"
property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F"
property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C"
property color colBackgroundToggledActive: Appearance?.colors.colPrimaryActive ?? "#D6CEE2"
property real radius: root.down ? root.buttonRadiusPressed : root.buttonRadius
property real leftRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius
property real rightRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius
property color color: root.enabled ? (root.toggled ?
(root.down ? colBackgroundToggledActive :
root.hovered ? colBackgroundToggledHover :
colBackgroundToggled) :
(root.down ? colBackgroundActive :
root.hovered ? colBackgroundHover :
colBackground)) : colBackground
onDownChanged: {
if (root.down) {
if (root.parent.clickIndex !== undefined) {
root.parent.clickIndex = parent.children.indexOf(root)
}
}
}
Behavior on implicitWidth {
animation: Appearance.animation.clickBounce.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance.animation.clickBounce.numberAnimation.createObject(this)
}
Behavior on leftRadius {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on rightRadius {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onPressed: (event) => {
if(event.button === Qt.RightButton) {
if (root.altAction) root.altAction();
return;
}
if(event.button === Qt.MiddleButton) {
if (root.middleClickAction) root.middleClickAction();
return;
}
root.down = true
if (root.downAction) root.downAction();
}
onReleased: (event) => {
root.down = false
if (event.button != Qt.LeftButton) return;
if (root.releaseAction) root.releaseAction();
}
onClicked: (event) => {
if (event.button != Qt.LeftButton) return;
root.click()
}
onCanceled: (event) => {
root.down = false
}
onPressAndHold: () => {
altAction();
root.down = false;
root.clicked = false;
};
}
background: Rectangle {
id: buttonBackground
topLeftRadius: root.leftRadius
topRightRadius: root.rightRadius
bottomLeftRadius: root.leftRadius
bottomRightRadius: root.rightRadius
implicitHeight: 50
color: root.color
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
contentItem: StyledText {
text: root.buttonText
}
}
@@ -0,0 +1,42 @@
import qs.modules.common
import QtQuick
Rectangle {
id: root
property string key
property real horizontalPadding: 6
property real verticalPadding: 1
property real borderWidth: 1
property real extraBottomBorderWidth: 2
property color borderColor: Appearance.colors.colOnLayer0
property real borderRadius: 5
property color keyColor: Appearance.m3colors.m3surfaceContainerLow
implicitWidth: keyFace.implicitWidth + borderWidth * 2
implicitHeight: keyFace.implicitHeight + borderWidth * 2 + extraBottomBorderWidth
radius: borderRadius
color: borderColor
Rectangle {
id: keyFace
anchors {
fill: parent
topMargin: borderWidth
leftMargin: borderWidth
rightMargin: borderWidth
bottomMargin: extraBottomBorderWidth + borderWidth
}
implicitWidth: keyText.implicitWidth + horizontalPadding * 2
implicitHeight: keyText.implicitHeight + verticalPadding * 2
color: keyColor
radius: borderRadius - borderWidth
StyledText {
id: keyText
anchors.centerIn: parent
font.family: Appearance.font.family.monospace
font.pixelSize: Appearance.font.pixelSize.smaller
text: key
}
}
}
@@ -0,0 +1,122 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Quickshell
RippleButton {
id: lightDarkButtonRoot
required property bool dark
property color previewBg: dark ? ColorUtils.colorWithHueOf("#3f3838", Appearance.m3colors.m3primary) :
ColorUtils.colorWithHueOf("#F7F9FF", Appearance.m3colors.m3primary)
property color previewFg: dark ? Qt.lighter(previewBg, 2.2) : ColorUtils.mix(previewBg, "#292929", 0.85)
padding: 5
Layout.fillWidth: true
colBackground: Appearance.colors.colLayer2
toggled: Appearance.m3colors.darkmode === dark
onClicked: {
Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode ${dark ? "dark" : "light"} --noswitch`])
}
contentItem: Item {
anchors.centerIn: parent
implicitWidth: buttonContentLayout.implicitWidth
implicitHeight: buttonContentLayout.implicitHeight
ColumnLayout {
id: buttonContentLayout
anchors.centerIn: parent
Rectangle {
Layout.alignment: Qt.AlignHCenter
implicitWidth: 250
implicitHeight: skeletonColumnLayout.implicitHeight + 10 * 2
radius: lightDarkButtonRoot.buttonRadius - lightDarkButtonRoot.padding
color: lightDarkButtonRoot.previewBg
border {
width: 1
color: Appearance.m3colors.m3outlineVariant
}
// Some skeleton items
ColumnLayout {
id: skeletonColumnLayout
anchors.fill: parent
anchors.margins: 10
spacing: 10
RowLayout {
Rectangle {
radius: Appearance.rounding.full
color: lightDarkButtonRoot.previewFg
implicitWidth: 50
implicitHeight: 50
}
ColumnLayout {
spacing: 4
Rectangle {
radius: Appearance.rounding.unsharpenmore
color: lightDarkButtonRoot.previewFg
Layout.fillWidth: true
implicitHeight: 22
}
Rectangle {
radius: Appearance.rounding.unsharpenmore
color: lightDarkButtonRoot.previewFg
Layout.fillWidth: true
Layout.rightMargin: 45
implicitHeight: 18
}
}
}
StyledProgressBar {
Layout.topMargin: 5
Layout.bottomMargin: 5
Layout.fillWidth: true
value: 0.7
wavy: true
animateWave: lightDarkButtonRoot.toggled
highlightColor: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3primary : lightDarkButtonRoot.previewFg
trackColor: ColorUtils.mix(lightDarkButtonRoot.previewBg, lightDarkButtonRoot.previewFg, 0.5)
}
RowLayout {
spacing: 2
Rectangle {
radius: Appearance.rounding.full
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3primary : lightDarkButtonRoot.previewFg
Layout.fillWidth: true
implicitHeight: 30
MaterialSymbol {
visible: lightDarkButtonRoot.toggled
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "check"
iconSize: 20
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3onPrimary : lightDarkButtonRoot.previewBg
}
}
Rectangle {
radius: Appearance.rounding.unsharpenmore
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3secondaryContainer : lightDarkButtonRoot.previewFg
Layout.fillWidth: true
implicitHeight: 30
}
Rectangle {
topLeftRadius: Appearance.rounding.unsharpenmore
bottomLeftRadius: Appearance.rounding.unsharpenmore
topRightRadius: Appearance.rounding.full
bottomRightRadius: Appearance.rounding.full
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3secondaryContainer : lightDarkButtonRoot.previewFg
Layout.fillWidth: true
implicitHeight: 30
}
}
}
}
StyledText {
Layout.fillWidth: true
text: dark ? Translation.tr("Dark") : Translation.tr("Light")
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2
horizontalAlignment: Text.AlignHCenter
}
}
}
}
@@ -0,0 +1,71 @@
import QtQuick
import QtQuick.Shapes
import Quickshell
import qs.modules.common
Item {
id: root
property real sides: 12
property int implicitSize: 100
property real amplitude: implicitSize / 50
property int renderPoints: 360
property color color: "#605790"
property alias strokeWidth: shapePath.strokeWidth
property bool constantlyRotate: false
implicitWidth: implicitSize
implicitHeight: implicitSize
property real shapeRotation: 0
Loader {
active: constantlyRotate
sourceComponent: FrameAnimation {
running: true
onTriggered: {
shapeRotation += 0.05
}
}
}
Behavior on sides {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Shape {
id: shape
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: shapePath
strokeWidth: 0
fillColor: root.color
pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting
PathPolyline {
property var pointsList: {
var points = []
var cx = shape.width / 2 // center x
var cy = shape.height / 2 // center y
var steps = root.renderPoints
var radius = root.implicitSize / 2 - root.amplitude
for (var i = 0; i <= steps; i++) {
var angle = (i / steps) * 2 * Math.PI
var rotatedAngle = angle * root.sides + Math.PI/2 + (root.shapeRotation * root.constantlyRotate)
var wave = Math.sin(rotatedAngle) * root.amplitude
var x = Math.cos(angle) * (radius + wave) + cx
var y = Math.sin(angle) * (radius + wave) + cy
points.push(Qt.point(x, y))
}
return points
}
path: pointsList
}
}
}
}
@@ -0,0 +1,30 @@
import qs.modules.common
import QtQuick
StyledText {
id: root
property real iconSize: Appearance?.font.pixelSize.small ?? 16
property real fill: 0
property real truncatedFill: fill.toFixed(1) // Reduce memory consumption spikes from constant font remapping
renderType: fill !== 0 ? Text.CurveRendering : Text.NativeRendering
font {
hintingPreference: Font.PreferFullHinting
family: Appearance?.font.family.iconMaterial ?? "Material Symbols Rounded"
pixelSize: iconSize
weight: Font.Normal + (Font.DemiBold - Font.Normal) * truncatedFill
variableAxes: {
"FILL": truncatedFill,
// "wght": font.weight,
// "GRAD": 0,
"opsz": iconSize,
}
}
Behavior on fill { // Leaky leaky, no good
NumberAnimation {
duration: Appearance?.animation.elementMoveFast.duration ?? 200
easing.type: Appearance?.animation.elementMoveFast.type ?? Easing.BezierSpline
easing.bezierCurve: Appearance?.animation.elementMoveFast.bezierCurve ?? [0.34, 0.80, 0.34, 1.00, 1, 1]
}
}
}
@@ -0,0 +1,52 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
/**
* Material 3 styled TextArea (filled style)
* https://m3.material.io/components/text-fields/overview
* Note: We don't use NativeRendering because it makes the small placeholder text look weird
*/
TextArea {
id: root
Material.theme: Material.System
Material.accent: Appearance.m3colors.m3primary
Material.primary: Appearance.m3colors.m3primary
Material.background: Appearance.m3colors.m3surface
Material.foreground: Appearance.m3colors.m3onSurface
Material.containerStyle: Material.Filled
renderType: Text.QtRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
placeholderTextColor: Appearance.m3colors.m3outline
background: Rectangle {
implicitHeight: 56
color: Appearance.m3colors.m3surface
topLeftRadius: 4
topRightRadius: 4
Rectangle {
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
}
height: 1
color: root.focus ? Appearance.m3colors.m3primary :
root.hovered ? Appearance.m3colors.m3outline : Appearance.m3colors.m3outlineVariant
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
font {
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
hintingPreference: Font.PreferFullHinting
}
wrapMode: TextEdit.Wrap
}
@@ -0,0 +1,39 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
/**
* Material 3 styled TextField (filled style)
* https://m3.material.io/components/text-fields/overview
* Note: We don't use NativeRendering because it makes the small placeholder text look weird
*/
TextField {
id: root
Material.theme: Material.System
Material.accent: Appearance.m3colors.m3primary
Material.primary: Appearance.m3colors.m3primary
Material.background: Appearance.m3colors.m3surface
Material.foreground: Appearance.m3colors.m3onSurface
Material.containerStyle: Material.Outlined
renderType: Text.QtRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
placeholderTextColor: Appearance.m3colors.m3outline
clip: true
font {
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
hintingPreference: Font.PreferFullHinting
}
wrapMode: TextEdit.Wrap
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
cursorShape: Qt.IBeamCursor
}
}
@@ -0,0 +1,26 @@
import qs.modules.common
import QtQuick
RippleButton {
id: root
buttonRadius: 0
implicitHeight: 36
implicitWidth: buttonTextWidget.implicitWidth + 14 * 2
contentItem: StyledText {
id: buttonTextWidget
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
text: root.buttonText
horizontalAlignment: Text.AlignLeft
font.pixelSize: Appearance.font.pixelSize.small
color: root.enabled ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3outline
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
@@ -0,0 +1,11 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
ColumnLayout { // Window content with navigation rail and content pane
id: root
property bool expanded: true
property int currentIndex: 0
spacing: 5
}
@@ -0,0 +1,150 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
TabButton {
id: root
property bool toggled: TabBar.tabBar.currentIndex === TabBar.index
property string buttonIcon
property real buttonIconRotation: 0
property string buttonText
property bool expanded: false
property bool showToggledHighlight: true
readonly property real visualWidth: root.expanded ? root.baseSize + 20 + itemText.implicitWidth : root.baseSize
property real baseSize: 56
property real baseHighlightHeight: 32
property real highlightCollapsedTopMargin: 8
padding: 0
// The navigation items target area always spans the full width of the
// nav rail, even if the item container hugs its contents.
Layout.fillWidth: true
// implicitWidth: contentItem.implicitWidth
implicitHeight: baseSize
background: null
PointingHandInteraction {}
// Real stuff
contentItem: Item {
id: buttonContent
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
right: undefined
}
implicitWidth: root.visualWidth
implicitHeight: root.expanded ? itemIconBackground.implicitHeight : itemIconBackground.implicitHeight + itemText.implicitHeight
Rectangle {
id: itemBackground
anchors.top: itemIconBackground.top
anchors.left: itemIconBackground.left
anchors.bottom: itemIconBackground.bottom
implicitWidth: root.visualWidth
radius: Appearance.rounding.full
color: toggled ?
root.showToggledHighlight ?
(root.down ? Appearance.colors.colSecondaryContainerActive : root.hovered ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer)
: ColorUtils.transparentize(Appearance.colors.colSecondaryContainer) :
(root.down ? Appearance.colors.colLayer1Active : root.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1))
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: itemBackground
anchors.top: buttonContent.top
anchors.left: buttonContent.left
anchors.bottom: buttonContent.bottom
}
PropertyChanges {
target: itemBackground
implicitWidth: root.visualWidth
}
}
transitions: Transition {
AnchorAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
PropertyAnimation {
target: itemBackground
property: "implicitWidth"
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
Item {
id: itemIconBackground
implicitWidth: root.baseSize
implicitHeight: root.baseHighlightHeight
anchors {
left: parent.left
verticalCenter: parent.verticalCenter
}
MaterialSymbol {
id: navRailButtonIcon
rotation: root.buttonIconRotation
anchors.centerIn: parent
iconSize: 24
fill: toggled ? 1 : 0
font.weight: (toggled || root.hovered) ? Font.DemiBold : Font.Normal
text: buttonIcon
color: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer1
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
StyledText {
id: itemText
anchors {
top: itemIconBackground.bottom
topMargin: 2
horizontalCenter: itemIconBackground.horizontalCenter
}
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: itemText
anchors {
top: undefined
horizontalCenter: undefined
left: itemIconBackground.right
verticalCenter: itemIconBackground.verticalCenter
}
}
}
transitions: Transition {
AnchorAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
text: buttonText
font.pixelSize: 14
color: Appearance.colors.colOnLayer1
}
}
}
@@ -0,0 +1,30 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
RippleButton {
id: root
Layout.alignment: Qt.AlignLeft
implicitWidth: 40
implicitHeight: 40
Layout.leftMargin: 8
downAction: () => {
parent.expanded = !parent.expanded;
}
buttonRadius: Appearance.rounding.full
rotation: root.parent.expanded ? 0 : -180
Behavior on rotation {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
contentItem: MaterialSymbol {
id: icon
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
iconSize: 24
color: Appearance.colors.colOnLayer1
text: root.parent.expanded ? "menu_open" : "menu"
}
}
@@ -0,0 +1,42 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
Item {
id: root
property int currentIndex: 0
property bool expanded: false
default property alias data: tabBarColumn.data
implicitHeight: tabBarColumn.implicitHeight
implicitWidth: tabBarColumn.implicitWidth
Layout.topMargin: 25
Rectangle {
property real itemHeight: tabBarColumn.children[0]?.baseSize ?? 56
property real baseHighlightHeight: tabBarColumn.children[0]?.baseHighlightHeight ?? 56
anchors {
top: tabBarColumn.top
left: tabBarColumn.left
topMargin: itemHeight * root.currentIndex + (root.expanded ? 0 : ((itemHeight - baseHighlightHeight) / 2))
}
radius: Appearance.rounding.full
color: Appearance.colors.colSecondaryContainer
implicitHeight: root.expanded ? itemHeight : baseHighlightHeight
implicitWidth: tabBarColumn?.children[root.currentIndex]?.visualWidth ?? 100
Behavior on anchors.topMargin {
NumberAnimation {
duration: Appearance.animationCurves.expressiveFastSpatialDuration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial
}
}
}
ColumnLayout {
id: tabBarColumn
anchors.fill: parent
spacing: 0
}
}
@@ -0,0 +1,40 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
Rectangle {
id: root
property alias materialIcon: icon.text
property alias text: noticeText.text
radius: Appearance.rounding.normal
color: Appearance.colors.colPrimaryContainer
implicitWidth: mainRowLayout.implicitWidth + mainRowLayout.anchors.margins * 2
implicitHeight: mainRowLayout.implicitHeight + mainRowLayout.anchors.margins * 2
RowLayout {
id: mainRowLayout
anchors.fill: parent
anchors.margins: 8
spacing: 8
MaterialSymbol {
id: icon
Layout.fillWidth: false
Layout.alignment: Qt.AlignTop
text: "info"
iconSize: Appearance.font.pixelSize.huge
color: Appearance.colors.colOnPrimaryContainer
}
StyledText {
id: noticeText
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: "Notice message"
color: Appearance.colors.colOnPrimaryContainer
wrapMode: Text.WordWrap
}
}
}
@@ -0,0 +1,24 @@
import qs.modules.common
import qs.services
import QtQuick
import Quickshell.Services.Notifications
RippleButton {
id: button
property string buttonText
property string urgency
implicitHeight: 30
leftPadding: 15
rightPadding: 15
buttonRadius: Appearance.rounding.small
colBackground: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainer : Appearance.colors.colLayer4
colBackgroundHover: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colLayer4Hover
colRipple: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colLayer4Active
contentItem: StyledText {
horizontalAlignment: Text.AlignHCenter
text: buttonText
color: (urgency == NotificationUrgency.Critical) ? Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
}
}
@@ -0,0 +1,101 @@
import qs.modules.common
import "./notification_utils.js" as NotificationUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Notifications
MaterialCookie { // App icon
id: root
property var appIcon: ""
property var summary: ""
property var urgency: NotificationUrgency.Normal
property bool isUrgent: urgency === NotificationUrgency.Critical
property var image: ""
property real materialIconScale: 0.57
property real appIconScale: 0.8
property real smallAppIconScale: 0.49
property real materialIconSize: implicitSize * materialIconScale
property real appIconSize: implicitSize * appIconScale
property real smallAppIconSize: implicitSize * smallAppIconScale
implicitSize: 38 * scale
sides: isUrgent ? 10 : 0
amplitude: implicitSize / 24
color: isUrgent ? Appearance.colors.colPrimary : Appearance.colors.colSecondaryContainer
Loader {
id: materialSymbolLoader
active: root.appIcon == ""
anchors.fill: parent
sourceComponent: MaterialSymbol {
text: {
const defaultIcon = NotificationUtils.findSuitableMaterialSymbol("")
const guessedIcon = NotificationUtils.findSuitableMaterialSymbol(root.summary)
return (root.urgency == NotificationUrgency.Critical && guessedIcon === defaultIcon) ?
"priority_high" : guessedIcon
}
anchors.fill: parent
color: isUrgent ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
iconSize: root.materialIconSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Loader {
id: appIconLoader
active: root.image == "" && root.appIcon != ""
anchors.centerIn: parent
sourceComponent: IconImage {
id: appIconImage
implicitSize: root.appIconSize
asynchronous: true
source: Quickshell.iconPath(root.appIcon, "image-missing")
}
}
Loader {
id: notifImageLoader
active: root.image != ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
Image {
id: notifImage
anchors.fill: parent
readonly property int size: parent.width
source: root.image
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
width: size
height: size
sourceSize.width: size
sourceSize.height: size
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: notifImage.size
height: notifImage.size
radius: Appearance.rounding.full
}
}
}
Loader {
id: notifImageAppIconLoader
active: root.appIcon != ""
anchors.bottom: parent.bottom
anchors.right: parent.right
sourceComponent: IconImage {
implicitSize: root.smallAppIconSize
asynchronous: true
source: Quickshell.iconPath(root.appIcon, "image-missing")
}
}
}
}
}
@@ -0,0 +1,255 @@
import qs.services
import qs.modules.common
import qs.modules.common.functions
import "./notification_utils.js" as NotificationUtils
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
/**
* A group of notifications from the same app.
* Similar to Android's notifications
*/
MouseArea { // Notification group area
id: root
property var notificationGroup
property var notifications: notificationGroup?.notifications ?? []
property int notificationCount: notifications.length
property bool multipleNotifications: notificationCount > 1
property bool expanded: false
property bool popup: false
property real padding: 10
implicitHeight: background.implicitHeight
property real dragConfirmThreshold: 70 // Drag further to discard notification
property real dismissOvershoot: 20 // Account for gaps and bouncy animations
property var qmlParent: root.parent.parent // There's something between this and the parent ListView
property var parentDragIndex: qmlParent.dragIndex
property var parentDragDistance: qmlParent.dragDistance
property var dragIndexDiff: Math.abs(parentDragIndex - index)
property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) :
parentDragDistance > dragConfirmThreshold ? 0 :
dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) :
dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0
function destroyWithAnimation() {
root.qmlParent.resetDrag()
background.anchors.leftMargin = background.anchors.leftMargin; // Break binding
destroyAnimation.running = true;
}
hoverEnabled: true
onContainsMouseChanged: {
if (!root.popup) return;
if (root.containsMouse) root.notifications.forEach(notif => {
Notifications.cancelTimeout(notif.notificationId);
});
else root.notifications.forEach(notif => {
Notifications.timeoutNotification(notif.notificationId);
});
}
SequentialAnimation { // Drag finish animation
id: destroyAnimation
running: false
NumberAnimation {
target: background.anchors
property: "leftMargin"
to: root.width + root.dismissOvershoot
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
onFinished: () => {
root.notifications.forEach((notif) => {
Qt.callLater(() => {
Notifications.discardNotification(notif.notificationId);
});
});
}
}
function toggleExpanded() {
if (expanded) implicitHeightAnim.enabled = true;
else implicitHeightAnim.enabled = false;
root.expanded = !root.expanded;
}
DragManager { // Drag manager
id: dragManager
anchors.fill: parent
interactive: !expanded
automaticallyReset: false
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton)
root.toggleExpanded();
else if (mouse.button === Qt.MiddleButton)
root.destroyWithAnimation();
}
onDraggingChanged: () => {
if (dragging) {
root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root);
}
}
onDragDiffXChanged: () => {
root.qmlParent.dragDistance = dragDiffX;
}
onDragReleased: (diffX, diffY) => {
if (diffX > root.dragConfirmThreshold)
root.destroyWithAnimation();
else
dragManager.resetDrag();
}
}
StyledRectangularShadow {
target: background
visible: popup
}
Rectangle { // Background of the notification
id: background
anchors.left: parent.left
width: parent.width
color: popup ? ColorUtils.applyAlpha(Appearance.colors.colLayer2, 1 - Appearance.backgroundTransparency) : Appearance.colors.colLayer2
radius: Appearance.rounding.normal
anchors.leftMargin: root.xOffset
Behavior on anchors.leftMargin {
enabled: !dragManager.dragging
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial
}
}
clip: true
implicitHeight: expanded ?
row.implicitHeight + padding * 2 :
Math.min(80, row.implicitHeight + padding * 2)
Behavior on implicitHeight {
id: implicitHeightAnim
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout { // Left column for icon, right column for content
id: row
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: root.padding
spacing: 10
NotificationAppIcon { // Icons
Layout.alignment: Qt.AlignTop
Layout.fillWidth: false
image: root?.multipleNotifications ? "" : notificationGroup?.notifications[0]?.image ?? ""
appIcon: notificationGroup?.appIcon
summary: notificationGroup?.notifications[root.notificationCount - 1]?.summary
urgency: root.notifications.some(n => n.urgency === NotificationUrgency.Critical.toString()) ?
NotificationUrgency.Critical : NotificationUrgency.Normal
}
ColumnLayout { // Content
Layout.fillWidth: true
spacing: expanded ? (root.multipleNotifications ?
(notificationGroup?.notifications[root.notificationCount - 1].image != "") ? 35 :
5 : 0) : 0
// spacing: 00
Behavior on spacing {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Item { // App name (or summary when there's only 1 notif) and time
id: topRow
// spacing: 0
Layout.fillWidth: true
property real fontSize: Appearance.font.pixelSize.smaller
property bool showAppName: root.multipleNotifications
implicitHeight: Math.max(topTextRow.implicitHeight, expandButton.implicitHeight)
RowLayout {
id: topTextRow
anchors.left: parent.left
anchors.right: expandButton.left
anchors.verticalCenter: parent.verticalCenter
spacing: 5
StyledText {
id: appName
elide: Text.ElideRight
Layout.fillWidth: true
text: (topRow.showAppName ?
notificationGroup?.appName :
notificationGroup?.notifications[0]?.summary) || ""
font.pixelSize: topRow.showAppName ?
topRow.fontSize :
Appearance.font.pixelSize.small
color: topRow.showAppName ?
Appearance.colors.colSubtext :
Appearance.colors.colOnLayer2
}
StyledText {
id: timeText
// Layout.fillWidth: true
Layout.rightMargin: 10
horizontalAlignment: Text.AlignLeft
text: NotificationUtils.getFriendlyNotifTimeString(notificationGroup?.time)
font.pixelSize: topRow.fontSize
color: Appearance.colors.colSubtext
}
}
NotificationGroupExpandButton {
id: expandButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
count: root.notificationCount
expanded: root.expanded
fontSize: topRow.fontSize
onClicked: { root.toggleExpanded() }
altAction: () => { root.toggleExpanded() }
StyledToolTip {
text: Translation.tr("Tip: right-clicking a group\nalso expands it")
}
}
}
StyledListView { // Notification body (expanded)
id: notificationsColumn
implicitHeight: contentHeight
Layout.fillWidth: true
spacing: expanded ? 5 : 3
// clip: true
interactive: false
Behavior on spacing {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
model: ScriptModel {
values: root.expanded ? root.notifications.slice().reverse() :
root.notifications.slice().reverse().slice(0, 2)
}
delegate: NotificationItem {
required property int index
required property var modelData
notificationObject: modelData
expanded: root.expanded
onlyNotification: (root.notificationCount === 1)
opacity: (!root.expanded && index == 1 && root.notificationCount > 2) ? 0.5 : 1
visible: root.expanded || (index < 2)
anchors.left: parent?.left
anchors.right: parent?.right
}
}
}
}
}
}
@@ -0,0 +1,47 @@
import qs.services
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
RippleButton { // Expand button
id: root
required property int count
required property bool expanded
property real fontSize: Appearance?.font.pixelSize.small ?? 12
property real iconSize: Appearance?.font.pixelSize.normal ?? 16
implicitHeight: fontSize + 4 * 2
implicitWidth: Math.max(contentItem.implicitWidth + 5 * 2, 30)
Layout.alignment: Qt.AlignVCenter
Layout.fillHeight: false
buttonRadius: Appearance.rounding.full
colBackground: ColorUtils.mix(Appearance?.colors.colLayer2, Appearance?.colors.colLayer2Hover, 0.5)
colBackgroundHover: Appearance?.colors.colLayer2Hover ?? "#E5DFED"
colRipple: Appearance?.colors.colLayer2Active ?? "#D6CEE2"
contentItem: Item {
anchors.centerIn: parent
implicitWidth: contentRow.implicitWidth
RowLayout {
id: contentRow
anchors.centerIn: parent
spacing: 3
StyledText {
Layout.leftMargin: 4
visible: root.count > 1
text: root.count
font.pixelSize: root.fontSize
}
MaterialSymbol {
text: "keyboard_arrow_down"
iconSize: root.iconSize
color: Appearance.colors.colOnLayer2
rotation: expanded ? 180 : 0
Behavior on rotation {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
}
}
}
@@ -0,0 +1,340 @@
import qs
import qs.modules.common
import qs.services
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.Notifications
Item { // Notification item area
id: root
property var notificationObject
property bool expanded: false
property bool onlyNotification: false
property real fontSize: Appearance.font.pixelSize.small
property real padding: onlyNotification ? 0 : 8
property real summaryElideRatio: 0.85
property real dragConfirmThreshold: 70 // Drag further to discard notification
property real dismissOvershoot: notificationIcon.implicitWidth + 20 // Account for gaps and bouncy animations
property var qmlParent: root?.parent?.parent // There's something between this and the parent ListView
property var parentDragIndex: qmlParent?.dragIndex ?? -1
property var parentDragDistance: qmlParent?.dragDistance ?? 0
property var dragIndexDiff: Math.abs(parentDragIndex - index)
property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) :
parentDragDistance > dragConfirmThreshold ? 0 :
dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) :
dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0
implicitHeight: background.implicitHeight
function processNotificationBody(body, appName) {
let processedBody = body
// Clean Chromium-based browsers notifications - remove first line
if (appName) {
const lowerApp = appName.toLowerCase()
const chromiumBrowsers = [
"brave", "chrome", "chromium", "vivaldi", "opera", "microsoft edge"
]
if (chromiumBrowsers.some(name => lowerApp.includes(name))) {
const lines = body.split('\n\n')
if (lines.length > 1 && lines[0].startsWith('<a')) {
processedBody = lines.slice(1).join('\n\n')
}
}
}
return processedBody
}
function destroyWithAnimation() {
root.qmlParent.resetDrag()
background.anchors.leftMargin = background.anchors.leftMargin; // Break binding
destroyAnimation.running = true;
}
TextMetrics {
id: summaryTextMetrics
font.pixelSize: root.fontSize
text: root.notificationObject.summary || ""
}
SequentialAnimation { // Drag finish animation
id: destroyAnimation
running: false
NumberAnimation {
target: background.anchors
property: "leftMargin"
to: root.width + root.dismissOvershoot
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
onFinished: () => {
Notifications.discardNotification(notificationObject.notificationId);
}
}
DragManager { // Drag manager
id: dragManager
anchors.fill: root
anchors.leftMargin: root.expanded ? -notificationIcon.implicitWidth : 0
interactive: expanded
automaticallyReset: false
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: (mouse) => {
if (mouse.button === Qt.MiddleButton) {
root.destroyWithAnimation();
}
}
onDraggingChanged: () => {
if (dragging) {
root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root);
}
}
onDragDiffXChanged: () => {
root.qmlParent.dragDistance = dragDiffX;
}
onDragReleased: (diffX, diffY) => {
if (diffX > root.dragConfirmThreshold)
root.destroyWithAnimation();
else
dragManager.resetDrag();
}
}
NotificationAppIcon { // App icon
id: notificationIcon
opacity: (!onlyNotification && notificationObject.image != "" && expanded) ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
image: notificationObject.image
anchors.right: background.left
anchors.top: background.top
anchors.rightMargin: 10
}
Rectangle { // Background of notification item
id: background
width: parent.width
anchors.left: parent.left
radius: Appearance.rounding.small
anchors.leftMargin: root.xOffset
Behavior on anchors.leftMargin {
enabled: !dragManager.dragging
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial
}
}
color: (expanded && !onlyNotification) ?
(notificationObject.urgency == NotificationUrgency.Critical) ?
ColorUtils.mix(Appearance.colors.colSecondaryContainer, Appearance.colors.colLayer2, 0.35) :
(Appearance.colors.colLayer3) :
ColorUtils.transparentize(Appearance.colors.colLayer3)
implicitHeight: expanded ? (contentColumn.implicitHeight + padding * 2) : summaryRow.implicitHeight
Behavior on implicitHeight {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
ColumnLayout { // Content column
id: contentColumn
anchors.fill: parent
anchors.margins: expanded ? root.padding : 0
spacing: 3
Behavior on anchors.margins {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout { // Summary row
id: summaryRow
visible: !root.onlyNotification || !root.expanded
Layout.fillWidth: true
implicitHeight: summaryText.implicitHeight
StyledText {
id: summaryText
Layout.fillWidth: summaryTextMetrics.width >= summaryRow.implicitWidth * root.summaryElideRatio
visible: !root.onlyNotification
font.pixelSize: root.fontSize
color: Appearance.colors.colOnLayer3
elide: Text.ElideRight
text: root.notificationObject.summary || ""
}
StyledText {
opacity: !root.expanded ? 1 : 0
visible: opacity > 0
Layout.fillWidth: true
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
font.pixelSize: root.fontSize
color: Appearance.colors.colSubtext
elide: Text.ElideRight
wrapMode: Text.Wrap // Needed for proper eliding????
maximumLineCount: 1
textFormat: Text.StyledText
text: {
return processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "<br/>")
}
}
}
ColumnLayout { // Expanded content
Layout.fillWidth: true
opacity: root.expanded ? 1 : 0
visible: opacity > 0
StyledText { // Notification body (expanded)
id: notificationBodyText
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Layout.fillWidth: true
font.pixelSize: root.fontSize
color: Appearance.colors.colSubtext
wrapMode: Text.Wrap
elide: Text.ElideRight
textFormat: Text.RichText
text: {
return `<style>img{max-width:${300 /* binding to notificationBodyText.width would cause a binding loop */}px;}</style>` +
`${processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "<br/>")}`
}
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
GlobalStates.sidebarRightOpen = false
}
PointingHandLinkHover {}
}
Item {
Layout.fillWidth: true
implicitWidth: actionsFlickable.implicitWidth
implicitHeight: actionsFlickable.implicitHeight
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: actionsFlickable.width
height: actionsFlickable.height
radius: Appearance.rounding.small
}
}
ScrollEdgeFade {
target: actionsFlickable
vertical: false
}
StyledFlickable { // Notification actions
id: actionsFlickable
anchors.fill: parent
implicitHeight: actionRowLayout.implicitHeight
contentWidth: actionRowLayout.implicitWidth
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout {
id: actionRowLayout
Layout.alignment: Qt.AlignBottom
NotificationActionButton {
Layout.fillWidth: true
buttonText: Translation.tr("Close")
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
root.destroyWithAnimation()
}
contentItem: MaterialSymbol {
iconSize: Appearance.font.pixelSize.large
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "close"
}
}
Repeater {
id: actionRepeater
model: notificationObject.actions
NotificationActionButton {
Layout.fillWidth: true
buttonText: modelData.text
urgency: notificationObject.urgency
onClicked: {
Notifications.attemptInvokeAction(notificationObject.notificationId, modelData.identifier);
}
}
}
NotificationActionButton {
Layout.fillWidth: true
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
Quickshell.clipboardText = notificationObject.body
copyIcon.text = "inventory"
copyIconTimer.restart()
}
Timer {
id: copyIconTimer
interval: 1500
repeat: false
onTriggered: {
copyIcon.text = "content_copy"
}
}
contentItem: MaterialSymbol {
id: copyIcon
iconSize: Appearance.font.pixelSize.large
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "content_copy"
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,27 @@
pragma ComponentBehavior: Bound
import qs.modules.common.widgets
import qs.services
import QtQuick
import Quickshell
StyledListView { // Scrollable window
id: root
property bool popup: false
spacing: 3
model: ScriptModel {
values: root.popup ? Notifications.popupAppNameList : Notifications.appNameList
}
delegate: NotificationGroup {
required property int index
required property var modelData
popup: root.popup
anchors.left: parent?.left
anchors.right: parent?.right
notificationGroup: popup ?
Notifications.popupGroupsByAppName[modelData] :
Notifications.groupsByAppName[modelData]
}
}
@@ -0,0 +1,27 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
Loader {
id: root
required property string icon
property real iconSize: Appearance.font.pixelSize.larger
Layout.alignment: Qt.AlignVCenter
active: root.icon && root.icon.length > 0
visible: active
sourceComponent: Item {
implicitWidth: materialSymbol.implicitWidth
MaterialSymbol {
id: materialSymbol
anchors.centerIn: parent
iconSize: root.iconSize
color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
text: root.icon
}
}
}
@@ -0,0 +1,7 @@
import QtQuick
MouseArea {
anchors.fill: parent
onPressed: (mouse) => mouse.accepted = false
cursorShape: Qt.PointingHandCursor
}
@@ -0,0 +1,8 @@
import QtQuick
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton // Only for hover
hoverEnabled: true
cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
}
@@ -0,0 +1,52 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
Item {
id: root
property string text: ""
property bool extraVisibleCondition: true
property bool alternativeVisibleCondition: false
property real horizontalPadding: 10
property real verticalPadding: 5
property var anchorEdges: Edges.Top
property var anchorGravity: anchorEdges
readonly property bool internalVisibleCondition: (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition
Loader {
id: tooltipLoader
anchors.fill: parent
active: internalVisibleCondition
sourceComponent: PopupWindow {
visible: true
anchor {
window: root.QsWindow.window
item: root.parent
edges: root.anchorEdges
gravity: root.anchorGravity
}
mask: Region {
item: null
}
color: "transparent"
implicitWidth: contentItem.implicitWidth + root.horizontalPadding * 2
implicitHeight: contentItem.implicitHeight + root.verticalPadding * 2
StyledToolTipContent {
id: contentItem
anchors.centerIn: parent
text: root.text
shown: false
Component.onCompleted: shown = true
horizontalPadding: root.horizontalPadding
verticalPadding: root.verticalPadding
}
}
}
}
@@ -0,0 +1,97 @@
import qs.modules.common
import qs.services
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ColumnLayout {
id: root
spacing: 0
required property var tabButtonList // Something like [{"icon": "notifications", "name": Translation.tr("Notifications")}, {"icon": "volume_up", "name": Translation.tr("Volume mixer")}]
required property var externalTrackedTab
property bool enableIndicatorAnimation: false
property color colIndicator: Appearance?.colors.colPrimary ?? "#65558F"
property color colBorder: Appearance?.m3colors.m3outlineVariant ?? "#C6C6D0"
signal currentIndexChanged(int index)
property bool centerTabBar: parent.width > 500
Layout.fillWidth: !centerTabBar
Layout.alignment: Qt.AlignHCenter
implicitWidth: Math.max(tabBar.implicitWidth, 600)
TabBar {
id: tabBar
Layout.fillWidth: true
currentIndex: root.externalTrackedTab
onCurrentIndexChanged: {
root.onCurrentIndexChanged(currentIndex)
}
background: Item {
WheelHandler {
onWheel: (event) => {
if (event.angleDelta.y < 0)
tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1)
else if (event.angleDelta.y > 0)
tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0)
}
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
}
}
Repeater {
model: root.tabButtonList
delegate: PrimaryTabButton {
selected: (index == root.externalTrackedTab)
buttonText: modelData.name
buttonIcon: modelData.icon
minimumWidth: 160
}
}
}
Item { // Tab indicator
id: tabIndicator
Layout.fillWidth: true
height: 3
Connections {
target: root
function onExternalTrackedTabChanged() {
root.enableIndicatorAnimation = true
}
}
Rectangle {
id: indicator
property int tabCount: root.tabButtonList.length
property real fullTabSize: root.width / tabCount;
property real targetWidth: tabBar.contentItem?.children[0]?.children[tabBar.currentIndex]?.tabContentWidth ?? 0
implicitWidth: targetWidth
anchors {
top: parent.top
bottom: parent.bottom
}
x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2
color: root.colIndicator
radius: Appearance?.rounding.full ?? 9999
Behavior on x {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on implicitWidth {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
}
}
Rectangle { // Tabbar bottom border
id: tabBarBottomBorder
Layout.fillWidth: true
implicitHeight: 1
color: root.colBorder
}
}
@@ -0,0 +1,172 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
TabButton {
id: button
property string buttonText
property string buttonIcon
property real minimumWidth: 110
property bool selected: false
property int tabContentWidth: contentItem.children[0].implicitWidth
property int rippleDuration: 1200
height: buttonBackground.height
implicitWidth: Math.max(tabContentWidth, buttonBackground.implicitWidth, minimumWidth)
property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent"
property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED"
property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2"
property color colActive: Appearance?.colors.colPrimary ?? "#65558F"
property color colInactive: Appearance?.colors.colOnLayer1 ?? "#45464F"
component RippleAnim: NumberAnimation {
duration: rippleDuration
easing.type: Appearance?.animation.elementMoveEnter.type
easing.bezierCurve: Appearance?.animationCurves.standardDecel
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: (event) => {
button.click() // Because the MouseArea already consumed the event
const {x,y} = event
const stateY = buttonBackground.y;
rippleAnim.x = x;
rippleAnim.y = y - stateY;
const dist = (ox,oy) => ox*ox + oy*oy
const stateEndY = stateY + buttonBackground.height
rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)))
rippleFadeAnim.complete();
rippleAnim.restart();
}
onReleased: (event) => {
rippleFadeAnim.restart();
}
}
RippleAnim {
id: rippleFadeAnim
duration: rippleDuration * 2
target: ripple
property: "opacity"
to: 0
}
SequentialAnimation {
id: rippleAnim
property real x
property real y
property real radius
PropertyAction {
target: ripple
property: "x"
value: rippleAnim.x
}
PropertyAction {
target: ripple
property: "y"
value: rippleAnim.y
}
PropertyAction {
target: ripple
property: "opacity"
value: 1
}
ParallelAnimation {
RippleAnim {
target: ripple
properties: "implicitWidth,implicitHeight"
from: 0
to: rippleAnim.radius * 2
}
}
}
background: Rectangle {
id: buttonBackground
radius: Appearance?.rounding.small
implicitHeight: 50
color: (button.hovered ? button.colBackgroundHover : button.colBackground)
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: buttonBackground.width
height: buttonBackground.height
radius: buttonBackground.radius
}
}
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
Item {
id: ripple
width: ripple.implicitWidth
height: ripple.implicitHeight
opacity: 0
property real implicitWidth: 0
property real implicitHeight: 0
visible: width > 0 && height > 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
RadialGradient {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: button.colRipple }
GradientStop { position: 0.3; color: button.colRipple }
GradientStop { position: 0.5 ; color: Qt.rgba(button.colRipple.r, button.colRipple.g, button.colRipple.b, 0) }
}
}
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
}
contentItem: Item {
anchors.centerIn: buttonBackground
ColumnLayout {
anchors.centerIn: parent
spacing: 0
MaterialSymbol {
visible: buttonIcon?.length > 0
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
text: buttonIcon
iconSize: Appearance?.font.pixelSize.hugeass ?? 25
fill: selected ? 1 : 0
color: selected ? button.colActive : button.colInactive
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
StyledText {
id: buttonTextWidget
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance?.font.pixelSize.small
color: selected ? button.colActive : button.colInactive
text: buttonText
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
}
}
@@ -0,0 +1,25 @@
import qs.modules.common
import QtQuick
/**
* Recreation of GTK revealer. Expects one single child.
*/
Item {
id: root
property bool reveal
property bool vertical: false
clip: true
implicitWidth: (reveal || vertical) ? childrenRect.width : 0
implicitHeight: (reveal || !vertical) ? childrenRect.height : 0
visible: reveal || (width > 0 && height > 0)
Behavior on implicitWidth {
enabled: !vertical
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
enabled: vertical
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
}
@@ -0,0 +1,186 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
/**
* A button with ripple effect similar to in Material Design.
*/
Button {
id: root
property bool toggled
property string buttonText
property bool pointingHandCursor: true
property real buttonRadius: Appearance?.rounding?.small ?? 4
property real buttonRadiusPressed: buttonRadius
property real buttonEffectiveRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius
property int rippleDuration: 1200
property bool rippleEnabled: true
property var downAction // When left clicking (down)
property var releaseAction // When left clicking (release)
property var altAction // When right clicking
property var middleClickAction // When middle clicking
property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent"
property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED"
property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F"
property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C"
property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2"
property color colRippleToggled: Appearance?.colors.colPrimaryActive ?? "#D6CEE2"
opacity: root.enabled ? 1 : 0.4
property color buttonColor: ColorUtils.transparentize(root.toggled ?
(root.hovered ? colBackgroundToggledHover :
colBackgroundToggled) :
(root.hovered ? colBackgroundHover :
colBackground), root.enabled ? 0 : 1)
property color rippleColor: root.toggled ? colRippleToggled : colRipple
function startRipple(x, y) {
const stateY = buttonBackground.y;
rippleAnim.x = x;
rippleAnim.y = y - stateY;
const dist = (ox,oy) => ox*ox + oy*oy
const stateEndY = stateY + buttonBackground.height
rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)))
rippleFadeAnim.complete();
rippleAnim.restart();
}
component RippleAnim: NumberAnimation {
duration: rippleDuration
easing.type: Appearance?.animation.elementMoveEnter.type
easing.bezierCurve: Appearance?.animationCurves.standardDecel
}
MouseArea {
anchors.fill: parent
cursorShape: root.pointingHandCursor ? Qt.PointingHandCursor : Qt.ArrowCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onPressed: (event) => {
if(event.button === Qt.RightButton) {
if (root.altAction) root.altAction(event);
return;
}
if(event.button === Qt.MiddleButton) {
if (root.middleClickAction) root.middleClickAction();
return;
}
root.down = true
if (root.downAction) root.downAction();
if (!root.rippleEnabled) return;
const {x,y} = event
startRipple(x, y)
}
onReleased: (event) => {
root.down = false
if (event.button != Qt.LeftButton) return;
if (root.releaseAction) root.releaseAction();
root.click() // Because the MouseArea already consumed the event
if (!root.rippleEnabled) return;
rippleFadeAnim.restart();
}
onCanceled: (event) => {
root.down = false
if (!root.rippleEnabled) return;
rippleFadeAnim.restart();
}
}
RippleAnim {
id: rippleFadeAnim
duration: rippleDuration * 2
target: ripple
property: "opacity"
to: 0
}
SequentialAnimation {
id: rippleAnim
property real x
property real y
property real radius
PropertyAction {
target: ripple
property: "x"
value: rippleAnim.x
}
PropertyAction {
target: ripple
property: "y"
value: rippleAnim.y
}
PropertyAction {
target: ripple
property: "opacity"
value: 1
}
ParallelAnimation {
RippleAnim {
target: ripple
properties: "implicitWidth,implicitHeight"
from: 0
to: rippleAnim.radius * 2
}
}
}
background: Rectangle {
id: buttonBackground
radius: root.buttonEffectiveRadius
implicitHeight: 30
color: root.buttonColor
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: buttonBackground.width
height: buttonBackground.height
radius: root.buttonEffectiveRadius
}
}
Item {
id: ripple
width: ripple.implicitWidth
height: ripple.implicitHeight
opacity: 0
visible: width > 0 && height > 0
property real implicitWidth: 0
property real implicitHeight: 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
RadialGradient {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: root.rippleColor }
GradientStop { position: 0.3; color: root.rippleColor }
GradientStop { position: 0.5; color: Qt.rgba(root.rippleColor.r, root.rippleColor.g, root.rippleColor.b, 0) }
}
}
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
}
contentItem: StyledText {
text: root.buttonText
}
}
@@ -0,0 +1,57 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
RippleButton {
id: buttonWithIconRoot
property string nerdIcon
property string materialIcon
property bool materialIconFill: true
property string mainText: "Button text"
property Component mainContentComponent: Component {
StyledText {
text: buttonWithIconRoot.mainText
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnSecondaryContainer
}
}
implicitHeight: 35
horizontalPadding: 10
buttonRadius: Appearance.rounding.small
colBackground: Appearance.colors.colLayer2
contentItem: RowLayout {
Item {
Layout.fillWidth: false
implicitWidth: Math.max(materialIconLoader.implicitWidth, nerdIconLoader.implicitWidth)
Loader {
id: materialIconLoader
anchors.centerIn: parent
active: !nerdIcon
sourceComponent: MaterialSymbol {
text: buttonWithIconRoot.materialIcon
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnSecondaryContainer
fill: buttonWithIconRoot.materialIconFill ? 1 : 0
}
}
Loader {
id: nerdIconLoader
anchors.centerIn: parent
active: nerdIcon
sourceComponent: StyledText {
text: buttonWithIconRoot.nerdIcon
font.pixelSize: Appearance.font.pixelSize.larger
font.family: Appearance.font.family.iconNerd
color: Appearance.colors.colOnSecondaryContainer
}
}
}
Loader {
Layout.fillWidth: true
sourceComponent: buttonWithIconRoot.mainContentComponent
Layout.alignment: Qt.AlignVCenter
}
}
}
@@ -0,0 +1,80 @@
import QtQuick
import QtQuick.Shapes
Item {
id: root
enum CornerEnum { TopLeft, TopRight, BottomLeft, BottomRight }
property var corner: RoundCorner.CornerEnum.TopLeft
property alias leftVisualMargin: shape.anchors.leftMargin
property alias topVisualMargin: shape.anchors.topMargin
property alias rightVisualMargin: shape.anchors.rightMargin
property alias bottomVisualMargin: shape.anchors.bottomMargin
property int implicitSize: 25
property color color: "#000000"
implicitWidth: implicitSize
implicitHeight: implicitSize
property bool isTopLeft: corner === RoundCorner.CornerEnum.TopLeft
property bool isBottomLeft: corner === RoundCorner.CornerEnum.BottomLeft
property bool isTopRight: corner === RoundCorner.CornerEnum.TopRight
property bool isBottomRight: corner === RoundCorner.CornerEnum.BottomRight
property bool isTop: isTopLeft || isTopRight
property bool isBottom: isBottomLeft || isBottomRight
property bool isLeft: isTopLeft || isBottomLeft
property bool isRight: isTopRight || isBottomRight
Shape {
id: shape
anchors {
top: root.isTop ? parent.top : undefined
bottom: root.isBottom ? parent.bottom : undefined
left: root.isLeft ? parent.left : undefined
right: root.isRight ? parent.right : undefined
}
layer.enabled: true
layer.smooth: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: shapePath
strokeWidth: 0
fillColor: root.color
pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting
startX: switch (root.corner) {
case RoundCorner.CornerEnum.TopLeft: return 0;
case RoundCorner.CornerEnum.TopRight: return root.implicitSize;
case RoundCorner.CornerEnum.BottomLeft: return 0;
case RoundCorner.CornerEnum.BottomRight: return root.implicitSize;
}
startY: switch (root.corner) {
case RoundCorner.CornerEnum.TopLeft: return 0;
case RoundCorner.CornerEnum.TopRight: return 0;
case RoundCorner.CornerEnum.BottomLeft: return root.implicitSize;
case RoundCorner.CornerEnum.BottomRight: return root.implicitSize;
}
PathAngleArc {
moveToStart: false
centerX: root.implicitSize - shapePath.startX
centerY: root.implicitSize - shapePath.startY
radiusX: root.implicitSize
radiusY: root.implicitSize
startAngle: switch (root.corner) {
case RoundCorner.CornerEnum.TopLeft: return 180;
case RoundCorner.CornerEnum.TopRight: return -90;
case RoundCorner.CornerEnum.BottomLeft: return 90;
case RoundCorner.CornerEnum.BottomRight: return 0;
}
sweepAngle: 90
}
PathLine {
x: shapePath.startX
y: shapePath.startY
}
}
}
}
@@ -0,0 +1,59 @@
import QtQuick
import qs.modules.common
import qs.modules.common.functions
Item {
id: root
z: 99
required property Item target
property real fadeSize: Appearance.m3colors.darkmode ? 40 : 20
property color color: ColorUtils.transparentize(Appearance.colors.colShadow, Appearance.m3colors.darkmode ? 0 : 0.7)
property bool vertical: true
anchors.fill: target
EndGradient {
anchors {
top: parent.top
left: parent.left
right: vertical ? parent.right : undefined
bottom: vertical ? undefined : parent.bottom
}
shown: !(root.vertical ? root.target.atYBeginning : root.target.atXBeginning)
}
EndGradient {
anchors {
bottom: parent.bottom
right: parent.right
left: vertical ? parent.left : undefined
top: vertical ? undefined : parent.top
}
shown: !(root.vertical ? root.target.atYEnd : root.target.atXEnd)
rotation: 180
}
component EndGradient: Rectangle {
required property bool shown
height: vertical ? root.fadeSize : parent.height
width: vertical ? parent.width : root.fadeSize
opacity: shown ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
gradient: Gradient {
orientation: root.vertical ? Gradient.Vertical : Gradient.Horizontal
GradientStop {
position: 0.0
color: root.color
}
GradientStop {
position: 1.0
color: ColorUtils.transparentize(root.color)
}
}
}
}
@@ -0,0 +1,178 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
TabButton {
id: root
property string buttonText
property string buttonIcon
property bool selected: false
property int rippleDuration: 1200
height: buttonBackground.height
property int tabContentWidth: buttonBackground.width - buttonBackground.radius*2
property color colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1)
property color colBackgroundHover: Appearance.colors.colLayer1Hover
property color colRipple: Appearance.colors.colLayer1Active
PointingHandInteraction {}
component RippleAnim: NumberAnimation {
duration: rippleDuration
easing.type: Appearance.animation.elementMoveEnter.type
easing.bezierCurve: Appearance.animationCurves.standardDecel
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: (event) => {
root.click() // Because the MouseArea already consumed the event
const {x,y} = event
const stateY = buttonBackground.y;
rippleAnim.x = x;
rippleAnim.y = y - stateY;
const dist = (ox,oy) => ox*ox + oy*oy
const stateEndY = stateY + buttonBackground.height
rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)))
rippleFadeAnim.complete();
rippleAnim.restart();
}
onReleased: (event) => {
rippleFadeAnim.restart();
}
}
RippleAnim {
id: rippleFadeAnim
duration: rippleDuration * 2
target: ripple
property: "opacity"
to: 0
}
SequentialAnimation {
id: rippleAnim
property real x
property real y
property real radius
PropertyAction {
target: ripple
property: "x"
value: rippleAnim.x
}
PropertyAction {
target: ripple
property: "y"
value: rippleAnim.y
}
PropertyAction {
target: ripple
property: "opacity"
value: 1
}
ParallelAnimation {
RippleAnim {
target: ripple
properties: "implicitWidth,implicitHeight"
from: 0
to: rippleAnim.radius * 2
}
}
}
background: Rectangle {
id: buttonBackground
radius: Appearance?.rounding.small ?? 7
implicitHeight: 37
color: (root.hovered ? root.colBackgroundHover : root.colBackground)
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: buttonBackground.width
height: buttonBackground.height
radius: buttonBackground.radius
}
}
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
Item {
id: ripple
width: ripple.implicitWidth
height: ripple.implicitHeight
opacity: 0
property real implicitWidth: 0
property real implicitHeight: 0
visible: width > 0 && height > 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
RadialGradient {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: root.colRipple }
GradientStop { position: 0.3; color: root.colRipple }
GradientStop { position: 0.5 ; color: Qt.rgba(root.colRipple.r, root.colRipple.g, root.colRipple.b, 0) }
}
}
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
}
contentItem: Item {
anchors.centerIn: buttonBackground
RowLayout {
anchors.centerIn: parent
spacing: 0
Loader {
id: iconLoader
active: buttonIcon?.length > 0
sourceComponent: buttonIcon?.length > 0 ? materialSymbolComponent : null
Layout.rightMargin: 5
}
Component {
id: materialSymbolComponent
MaterialSymbol {
verticalAlignment: Text.AlignVCenter
text: buttonIcon
iconSize: Appearance.font.pixelSize.huge
fill: selected ? 1 : 0
color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
StyledText {
id: buttonTextWidget
verticalAlignment: Text.AlignVCenter
font.pixelSize: Appearance.font.pixelSize.small
color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1
text: buttonText
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
}
}
@@ -0,0 +1,128 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
import QtQuick.Layouts
import Quickshell
Item {
id: root
property real dialogPadding: 15
property real dialogMargin: 30
property string titleText: "Selection Dialog"
property alias items: choiceModel.values
property int selectedId: choiceListView.currentIndex
property var defaultChoice
signal canceled();
signal selected(var result);
Rectangle { // Scrim
id: scrimOverlay
anchors.fill: parent
radius: Appearance.rounding.small
color: Appearance.colors.colScrim
MouseArea {
hoverEnabled: true
anchors.fill: parent
preventStealing: true
propagateComposedEvents: false
}
}
Rectangle { // The dialog
id: dialog
color: Appearance.colors.colSurfaceContainerHigh
radius: Appearance.rounding.normal
anchors.fill: parent
anchors.margins: dialogMargin
implicitHeight: dialogColumnLayout.implicitHeight
ColumnLayout {
id: dialogColumnLayout
anchors.fill: parent
spacing: 16
StyledText {
id: dialogTitle
Layout.topMargin: dialogPadding
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
Layout.alignment: Qt.AlignLeft
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.larger
text: root.titleText
}
Rectangle {
color: Appearance.m3colors.m3outline
implicitHeight: 1
Layout.fillWidth: true
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
}
StyledListView {
id: choiceListView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1
spacing: 6
model: ScriptModel {
id: choiceModel
}
delegate: StyledRadioButton {
id: radioButton
required property var modelData
required property int index
anchors {
left: parent?.left
right: parent?.right
leftMargin: root.dialogPadding
rightMargin: root.dialogPadding
}
description: modelData.toString()
checked: index === choiceListView.currentIndex
onCheckedChanged: {
if (checked) {
choiceListView.currentIndex = index;
}
}
}
}
Rectangle {
color: Appearance.m3colors.m3outline
implicitHeight: 1
Layout.fillWidth: true
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
}
RowLayout {
id: dialogButtonsRowLayout
Layout.bottomMargin: dialogPadding
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
Layout.alignment: Qt.AlignRight
DialogButton {
buttonText: Translation.tr("Cancel")
onClicked: root.canceled()
}
DialogButton {
buttonText: Translation.tr("OK")
onClicked: root.selected(
root.selectedId === -1 ? null :
root.items[root.selectedId]
)
}
}
}
}
}
@@ -0,0 +1,62 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import qs.services
import qs.modules.common
import qs.modules.common.widgets
GroupButton {
id: root
horizontalPadding: 12
verticalPadding: 8
bounce: false
property string buttonIcon
property bool leftmost: false
property bool rightmost: false
leftRadius: (toggled || leftmost) ? (height / 2) : Appearance.rounding.unsharpenmore
rightRadius: (toggled || rightmost) ? (height / 2) : Appearance.rounding.unsharpenmore
colBackground: Appearance.colors.colSecondaryContainer
colBackgroundHover: Appearance.colors.colSecondaryContainerHover
colBackgroundActive: Appearance.colors.colSecondaryContainerActive
contentItem: RowLayout {
spacing: 4 * (root.buttonText?.length > 0)
Loader {
Layout.alignment: Qt.AlignVCenter
active: root.buttonIcon && root.buttonIcon.length > 0
visible: active
sourceComponent: Item {
implicitWidth: materialSymbol.implicitWidth
MaterialSymbol {
id: materialSymbol
anchors.centerIn: parent
text: root.buttonIcon
iconSize: Appearance.font.pixelSize.larger
color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
}
}
}
Item {
implicitWidth: root.buttonText?.length > 0 ? textItem.implicitWidth : 0
implicitHeight: textMetrics.height // Force height to that of regular text
TextMetrics {
id: textMetrics
font.family: Appearance.font.family.main
text: "Abc"
}
StyledText {
id: textItem
anchors.centerIn: parent
color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
text: root.buttonText
}
}
}
}
@@ -0,0 +1,12 @@
import QtQuick
import QtQuick.Effects
MultiEffect {
id: root
source: wallpaper
anchors.fill: source
saturation: 0.2
blurEnabled: true
blurMax: 100
blur: 1
}
@@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Controls
import qs.modules.common
Flickable {
id: root
maximumFlickVelocity: 3500
boundsBehavior: Flickable.DragOverBounds
property real touchpadScrollFactor: Config?.options.interactions.scrolling.touchpadScrollFactor ?? 100
property real mouseScrollFactor: Config?.options.interactions.scrolling.mouseScrollFactor ?? 50
property real mouseScrollDeltaThreshold: Config?.options.interactions.scrolling.mouseScrollDeltaThreshold ?? 120
// Accumulated scroll destination so wheel deltas stack while animating
property real scrollTargetY: 0
ScrollBar.vertical: StyledScrollBar {}
MouseArea {
visible: Config?.options.interactions.scrolling.fasterTouchpadScroll
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: function(wheelEvent) {
const delta = wheelEvent.angleDelta.y / root.mouseScrollDeltaThreshold;
// The angleDelta.y of a touchpad is usually small and continuous,
// while that of a mouse wheel is typically in multiples of ±120.
var scrollFactor = Math.abs(wheelEvent.angleDelta.y) >= root.mouseScrollDeltaThreshold ? root.mouseScrollFactor : root.touchpadScrollFactor;
const maxY = Math.max(0, root.contentHeight - root.height);
const base = scrollAnim.running ? root.scrollTargetY : root.contentY;
var targetY = Math.max(0, Math.min(base - delta * scrollFactor, maxY));
root.scrollTargetY = targetY;
root.contentY = targetY;
wheelEvent.accepted = true;
}
}
Behavior on contentY {
NumberAnimation {
id: scrollAnim
duration: Appearance.animation.scroll.duration
easing.type: Appearance.animation.scroll.type
easing.bezierCurve: Appearance.animation.scroll.bezierCurve
}
}
// Keep target synced when not animating (e.g., drag/flick or programmatic changes)
onContentYChanged: {
if (!scrollAnim.running) {
root.scrollTargetY = root.contentY;
}
}
}
@@ -0,0 +1,15 @@
import QtQuick
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
Image {
asynchronous: true
retainWhileLoading: true
visible: opacity > 0
opacity: (status === Image.Ready) ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
}
@@ -0,0 +1,9 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
ProgressBar {
indeterminate: true
Material.accent: Appearance.colors.colPrimary
}
@@ -0,0 +1,15 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls
Label {
renderType: Text.NativeRendering
verticalAlignment: Text.AlignVCenter
font {
hintingPreference: Font.PreferFullHinting
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
}
color: Appearance?.m3colors.m3onBackground ?? "black"
linkColor: Appearance?.m3colors.m3primary
}
@@ -0,0 +1,152 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
/**
* A ListView with animations.
*/
ListView {
id: root
spacing: 5
property real removeOvershoot: 20 // Account for gaps and bouncy animations
property int dragIndex: -1
property real dragDistance: 0
property bool popin: true
property bool animateAppearance: true
property bool animateMovement: false
// Accumulated scroll destination so wheel deltas stack while animating
property real scrollTargetY: 0
property real touchpadScrollFactor: Config?.options.interactions.scrolling.touchpadScrollFactor ?? 100
property real mouseScrollFactor: Config?.options.interactions.scrolling.mouseScrollFactor ?? 50
property real mouseScrollDeltaThreshold: Config?.options.interactions.scrolling.mouseScrollDeltaThreshold ?? 120
function resetDrag() {
root.dragIndex = -1
root.dragDistance = 0
}
maximumFlickVelocity: 3500
boundsBehavior: Flickable.DragOverBounds
ScrollBar.vertical: StyledScrollBar {}
MouseArea {
visible: Config?.options.interactions.scrolling.fasterTouchpadScroll
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: function(wheelEvent) {
const delta = wheelEvent.angleDelta.y / root.mouseScrollDeltaThreshold;
// The angleDelta.y of a touchpad is usually small and continuous,
// while that of a mouse wheel is typically in multiples of ±120.
var scrollFactor = Math.abs(wheelEvent.angleDelta.y) >= root.mouseScrollDeltaThreshold ? root.mouseScrollFactor : root.touchpadScrollFactor;
const maxY = Math.max(0, root.contentHeight - root.height);
const base = scrollAnim.running ? root.scrollTargetY : root.contentY;
var targetY = Math.max(0, Math.min(base - delta * scrollFactor, maxY));
root.scrollTargetY = targetY;
root.contentY = targetY;
wheelEvent.accepted = true;
}
}
Behavior on contentY {
NumberAnimation {
id: scrollAnim
duration: Appearance.animation.scroll.duration
easing.type: Appearance.animation.scroll.type
easing.bezierCurve: Appearance.animation.scroll.bezierCurve
}
}
// Keep target synced when not animating (e.g., drag/flick or programmatic changes)
onContentYChanged: {
if (!scrollAnim.running) {
root.scrollTargetY = root.contentY;
}
}
add: Transition {
animations: animateAppearance ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: popin ? "opacity,scale" : "opacity",
from: 0,
to: 1,
}),
] : []
}
addDisplaced: Transition {
animations: animateAppearance ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: popin ? "opacity,scale" : "opacity",
to: 1,
}),
] : []
}
displaced: Transition {
animations: root.animateMovement ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
] : []
}
move: Transition {
animations: root.animateMovement ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
] : []
}
moveDisplaced: Transition {
animations: root.animateMovement ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
] : []
}
remove: Transition {
animations: animateAppearance ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "x",
to: root.width + root.removeOvershoot,
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "opacity",
to: 0,
})
] : []
}
// This is movement when something is removed, not removing animation!
removeDisplaced: Transition {
animations: animateAppearance ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
] : []
}
}
@@ -0,0 +1,97 @@
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
/**
* Material 3 progress bar. See https://m3.material.io/components/progress-indicators/overview
*/
ProgressBar {
id: root
property real valueBarWidth: 120
property real valueBarHeight: 4
property real valueBarGap: 4
property color highlightColor: Appearance?.colors.colPrimary ?? "#685496"
property color trackColor: Appearance?.m3colors.m3secondaryContainer ?? "#F1D3F9"
property bool wavy: false // If true, the progress bar will have a wavy fill effect
property bool animateWave: true
property real waveAmplitudeMultiplier: wavy ? 0.5 : 0
property real waveFrequency: 6
property real waveFps: 60
Behavior on waveAmplitudeMultiplier {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on value {
animation: Appearance?.animation.elementMoveEnter.numberAnimation.createObject(this)
}
background: Item {
implicitHeight: valueBarHeight
implicitWidth: valueBarWidth
}
contentItem: Item {
id: contentItem
anchors.fill: parent
Loader {
anchors {
left: parent.left
verticalCenter: parent.verticalCenter
}
active: root.wavy
sourceComponent: WavyLine {
id: wavyFill
frequency: root.waveFrequency
color: root.highlightColor
amplitudeMultiplier: root.wavy ? 0.5 : 0
height: contentItem.height * 6
width: contentItem.width * root.visualPosition
lineWidth: contentItem.height
fullLength: root.width
Connections {
target: root
function onValueChanged() { wavyFill.requestPaint(); }
function onHighlightColorChanged() { wavyFill.requestPaint(); }
}
FrameAnimation {
running: root.animateWave
onTriggered: {
wavyFill.requestPaint()
}
}
}
}
Loader {
active: !root.wavy
sourceComponent: Rectangle {
anchors.left: parent.left
width: contentItem.width * root.visualPosition
height: contentItem.height
radius: height / 2
color: root.highlightColor
}
}
Rectangle { // Right remaining part fill
anchors.right: parent.right
width: (1 - root.visualPosition) * parent.width - valueBarGap
height: parent.height
radius: height / 2
color: root.trackColor
}
Rectangle { // Stop point
anchors.right: parent.right
width: valueBarGap
height: valueBarGap
radius: height / 2
color: root.highlightColor
}
}
}
@@ -0,0 +1,88 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
import Quickshell.Services.Pipewire
RadioButton {
id: root
padding: 4
implicitHeight: contentItem.implicitHeight + padding * 2
property string description
property color activeColor: Appearance?.colors.colPrimary ?? "#685496"
property color inactiveColor: Appearance?.m3colors.m3onSurfaceVariant ?? "#45464F"
PointingHandInteraction {}
indicator: Item{}
contentItem: RowLayout {
id: contentItem
Layout.fillWidth: true
spacing: 12
Rectangle {
id: radio
Layout.fillWidth: false
Layout.alignment: Qt.AlignVCenter
width: 20
height: 20
radius: Appearance?.rounding.full
border.color: checked ? root.activeColor : root.inactiveColor
border.width: 2
color: "transparent"
// Checked indicator
Rectangle {
anchors.centerIn: parent
width: checked ? 10 : 4
height: checked ? 10 : 4
radius: Appearance?.rounding.full
color: Appearance?.colors.colPrimary
opacity: checked ? 1 : 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on width {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
}
// Hover
Rectangle {
anchors.centerIn: parent
width: root.hovered ? 40 : 20
height: root.hovered ? 40 : 20
radius: Appearance?.rounding.full
color: Appearance?.m3colors.m3onSurface
opacity: root.hovered ? 0.1 : 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on width {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
}
}
StyledText {
text: root.description
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
wrapMode: Text.Wrap
color: Appearance?.m3colors.m3onSurface
}
}
}
@@ -0,0 +1,14 @@
import QtQuick
import QtQuick.Effects
import qs.modules.common
RectangularShadow {
required property var target
anchors.fill: target
radius: target.radius
blur: 0.9 * Appearance.sizes.elevationMargin
offset: Qt.vector2d(0.0, 1.0)
spread: 1
color: Appearance.colors.colShadow
cached: true
}
@@ -0,0 +1,28 @@
import QtQuick
import QtQuick.Controls
import qs.modules.common
import qs.modules.common.functions
ScrollBar {
id: root
policy: ScrollBar.AsNeeded
topPadding: Appearance.rounding.normal
bottomPadding: Appearance.rounding.normal
contentItem: Rectangle {
implicitWidth: 4
implicitHeight: root.visualSize
radius: width / 2
color: Appearance.colors.colOnSurfaceVariant
opacity: root.policy === ScrollBar.AlwaysOn || (root.active && root.size < 1.0) ? 0.5 : 0
Behavior on opacity {
NumberAnimation {
duration: 350
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
}
}
@@ -0,0 +1,192 @@
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
/**
* Material 3 slider. See https://m3.material.io/components/sliders/overview
* It doesn't exactly match the spec because it does not make sense to have stuff on a computer that fucking huge.
* Should be at 3/4 scale...
*/
Slider {
id: root
property list<real> stopIndicatorValues: [1]
enum Configuration {
Wavy = 4,
XS = 12,
S = 18,
M = 30,
L = 42,
XL = 72
}
property var configuration: StyledSlider.Configuration.S
property real handleDefaultWidth: 3
property real handlePressedWidth: 1.5
property color highlightColor: Appearance.colors.colPrimary
property color trackColor: Appearance.colors.colSecondaryContainer
property color handleColor: Appearance.colors.colPrimary
property color dotColor: Appearance.m3colors.m3onSecondaryContainer
property color dotColorHighlighted: Appearance.m3colors.m3onPrimary
property real unsharpenRadius: Appearance.rounding.unsharpen
property real trackWidth: configuration
property real trackRadius: trackWidth >= StyledSlider.Configuration.XL ? 21
: trackWidth >= StyledSlider.Configuration.L ? 12
: trackWidth >= StyledSlider.Configuration.M ? 9
: trackWidth >= StyledSlider.Configuration.S ? 6
: height / 2
property real handleHeight: (configuration === StyledSlider.Configuration.Wavy) ? 24 : Math.max(33, trackWidth + 9)
property real handleWidth: root.pressed ? handlePressedWidth : handleDefaultWidth
property real handleMargins: 4
property real trackDotSize: 3
property string tooltipContent: `${Math.round(value * 100)}%`
property bool wavy: configuration === StyledSlider.Configuration.Wavy // If true, the progress bar will have a wavy fill effect
property bool animateWave: true
property real waveAmplitudeMultiplier: wavy ? 0.5 : 0
property real waveFrequency: 6
property real waveFps: 60
leftPadding: handleMargins
rightPadding: handleMargins
property real effectiveDraggingWidth: width - leftPadding - rightPadding
Layout.fillWidth: true
from: 0
to: 1
Behavior on value { // This makes the adjusted value (like volume) shift smoothly
SmoothedAnimation {
velocity: Appearance.animation.elementMoveFast.velocity
}
}
Behavior on handleMargins {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
component TrackDot: Rectangle {
required property real value
anchors.verticalCenter: parent.verticalCenter
x: root.handleMargins + (value * root.effectiveDraggingWidth) - (root.trackDotSize / 2)
width: root.trackDotSize
height: root.trackDotSize
radius: Appearance.rounding.full
color: value > root.visualPosition ? root.dotColor : root.dotColorHighlighted
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
MouseArea {
anchors.fill: parent
onPressed: (mouse) => mouse.accepted = false
cursorShape: root.pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor
}
background: Item {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
implicitHeight: trackWidth
// Fill left
Loader {
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
}
width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins)
height: root.trackWidth
active: !root.wavy
sourceComponent: Rectangle {
color: root.highlightColor
topLeftRadius: root.trackRadius
bottomLeftRadius: root.trackRadius
topRightRadius: root.unsharpenRadius
bottomRightRadius: root.unsharpenRadius
}
}
Loader {
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
}
width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins)
height: root.height
active: root.wavy
sourceComponent: WavyLine {
id: wavyFill
frequency: root.waveFrequency
fullLength: root.width
color: root.highlightColor
amplitudeMultiplier: root.wavy ? 0.5 : 0
width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins)
height: root.trackWidth
Connections {
target: root
function onValueChanged() { wavyFill.requestPaint(); }
function onHighlightColorChanged() { wavyFill.requestPaint(); }
}
FrameAnimation {
running: root.animateWave
onTriggered: {
wavyFill.requestPaint()
}
}
}
}
// Fill right
Rectangle {
anchors {
verticalCenter: parent.verticalCenter
right: parent.right
}
width: root.handleMargins + ((1 - root.visualPosition) * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins)
height: trackWidth
color: root.trackColor
topRightRadius: root.trackRadius
bottomRightRadius: root.trackRadius
topLeftRadius: root.unsharpenRadius
bottomLeftRadius: root.unsharpenRadius
}
// Stop indicators
Repeater {
model: root.stopIndicatorValues
TrackDot {
required property real modelData
value: modelData
anchors.verticalCenter: parent.verticalCenter
}
}
}
handle: Rectangle {
id: handle
implicitWidth: root.handleWidth
implicitHeight: root.handleHeight
x: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2)
anchors.verticalCenter: parent.verticalCenter
radius: Appearance.rounding.full
color: root.handleColor
Behavior on implicitWidth {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
StyledToolTip {
extraVisibleCondition: root.pressed
text: root.tooltipContent
}
}
}
@@ -0,0 +1,94 @@
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
/**
* Material 3 styled SpinBox component.
*/
SpinBox {
id: root
property real baseHeight: 35
property real radius: Appearance.rounding.small
property real innerButtonRadius: Appearance.rounding.unsharpen
editable: true
opacity: root.enabled ? 1 : 0.4
background: Rectangle {
color: Appearance.colors.colLayer2
radius: root.radius
}
contentItem: Item {
implicitHeight: root.baseHeight
implicitWidth: Math.max(labelText.implicitWidth, 40)
StyledTextInput {
id: labelText
anchors.centerIn: parent
text: root.value // displayText would make the numbers weird like 1,000 instead of 1000
color: Appearance.colors.colOnLayer2
font.pixelSize: Appearance.font.pixelSize.small
validator: root.validator
onTextChanged: {
root.value = parseFloat(text);
}
}
}
down.indicator: Rectangle {
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
}
implicitHeight: root.baseHeight
implicitWidth: root.baseHeight
topLeftRadius: root.radius
bottomLeftRadius: root.radius
topRightRadius: root.innerButtonRadius
bottomRightRadius: root.innerButtonRadius
color: root.down.pressed ? Appearance.colors.colLayer2Active :
root.down.hovered ? Appearance.colors.colLayer2Hover :
ColorUtils.transparentize(Appearance.colors.colLayer2)
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
MaterialSymbol {
anchors.centerIn: parent
text: "remove"
iconSize: 20
color: Appearance.colors.colOnLayer2
}
}
up.indicator: Rectangle {
anchors {
verticalCenter: parent.verticalCenter
right: parent.right
}
implicitHeight: root.baseHeight
implicitWidth: root.baseHeight
topRightRadius: root.radius
bottomRightRadius: root.radius
topLeftRadius: root.innerButtonRadius
bottomLeftRadius: root.innerButtonRadius
color: root.up.pressed ? Appearance.colors.colLayer2Active :
root.up.hovered ? Appearance.colors.colLayer2Hover :
ColorUtils.transparentize(Appearance.colors.colLayer2)
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
MaterialSymbol {
anchors.centerIn: parent
text: "add"
iconSize: 20
color: Appearance.colors.colOnLayer2
}
}
}
@@ -0,0 +1,60 @@
import qs.modules.common
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
/**
* Material 3 switch. See https://m3.material.io/components/switch/overview
*/
Switch {
id: root
property real scale: 0.6 // Default in m3 spec is huge af
implicitHeight: 32 * root.scale
implicitWidth: 52 * root.scale
property color activeColor: Appearance?.colors.colPrimary ?? "#685496"
property color inactiveColor: Appearance?.colors.colSurfaceContainerHighest ?? "#45464F"
PointingHandInteraction {}
// Custom track styling
background: Rectangle {
width: parent.width
height: parent.height
radius: Appearance?.rounding.full ?? 9999
color: root.checked ? root.activeColor : root.inactiveColor
border.width: 2 * root.scale
border.color: root.checked ? root.activeColor : Appearance.m3colors.m3outline
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
Behavior on border.color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
// Custom thumb styling
indicator: Rectangle {
width: (root.pressed || root.down) ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale)
height: (root.pressed || root.down) ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale)
radius: Appearance.rounding.full
color: root.checked ? Appearance.m3colors.m3onPrimary : Appearance.m3colors.m3outline
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: root.checked ? ((root.pressed || root.down) ? (22 * root.scale) : 24 * root.scale) : ((root.pressed || root.down) ? (2 * root.scale) : 8 * root.scale)
Behavior on anchors.leftMargin {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on width {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
@@ -0,0 +1,87 @@
import qs.modules.common
import QtQuick
Text {
id: root
property bool animateChange: false
property real animationDistanceX: 0
property real animationDistanceY: 6
renderType: Text.NativeRendering
verticalAlignment: Text.AlignVCenter
font {
hintingPreference: Font.PreferDefaultHinting
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
}
color: Appearance?.m3colors.m3onBackground ?? "black"
linkColor: Appearance?.m3colors.m3primary
component Anim: NumberAnimation {
target: root
duration: 300 / 2
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
Component.onCompleted: {
textAnimationBehavior.originalX = root.x;
textAnimationBehavior.originalY = root.y;
}
Behavior on text {
id: textAnimationBehavior
property real originalX: root.x
property real originalY: root.y
enabled: root.animateChange
SequentialAnimation {
alwaysRunToEnd: true
ParallelAnimation {
Anim {
property: "x"
to: textAnimationBehavior.originalX - root.animationDistanceX
easing.type: Easing.InSine
}
Anim {
property: "y"
to: textAnimationBehavior.originalY - root.animationDistanceY
easing.type: Easing.InSine
}
Anim {
property: "opacity"
to: 0
easing.type: Easing.InSine
}
}
PropertyAction {} // Tie the text update to this point (we don't want it to happen during the first slide+fade)
PropertyAction {
target: root
property: "x"
value: textAnimationBehavior.originalX + root.animationDistanceX
}
PropertyAction {
target: root
property: "y"
value: textAnimationBehavior.originalY + root.animationDistanceY
}
ParallelAnimation {
Anim {
property: "x"
to: textAnimationBehavior.originalX
easing.type: Easing.OutSine
}
Anim {
property: "y"
to: textAnimationBehavior.originalY
easing.type: Easing.OutSine
}
Anim {
property: "opacity"
to: 1
easing.type: Easing.OutSine
}
}
}
}
}
@@ -0,0 +1,18 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls
/**
* Does not include visual layout, but includes the easily neglected colors.
*/
TextArea {
renderType: Text.NativeRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
placeholderTextColor: Appearance.m3colors.m3outline
font {
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
hintingPreference: Font.PreferFullHinting
}
}
@@ -0,0 +1,18 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls
/**
* Does not include visual layout, but includes the easily neglected colors.
*/
TextInput {
color: Appearance.colors.colOnLayer1
renderType: Text.NativeRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
font {
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
hintingPreference: Font.PreferFullHinting
}
}
@@ -0,0 +1,25 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ToolTip {
id: root
property bool extraVisibleCondition: true
property bool alternativeVisibleCondition: false
readonly property bool internalVisibleCondition: (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition
verticalPadding: 5
horizontalPadding: 10
background: null
visible: internalVisibleCondition
contentItem: StyledToolTipContent {
id: contentItem
text: root.text
shown: root.internalVisibleCondition
horizontalPadding: root.horizontalPadding
verticalPadding: root.verticalPadding
}
}
@@ -0,0 +1,52 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
required property string text
property bool shown: false
property real horizontalPadding: 10
property real verticalPadding: 5
implicitWidth: tooltipTextObject.implicitWidth + 2 * root.horizontalPadding
implicitHeight: tooltipTextObject.implicitHeight + 2 * root.verticalPadding
property bool isVisible: backgroundRectangle.implicitHeight > 0
Rectangle {
id: backgroundRectangle
anchors {
bottom: root.bottom
horizontalCenter: root.horizontalCenter
}
color: Appearance?.colors.colTooltip ?? "#3C4043"
radius: Appearance?.rounding.verysmall ?? 7
opacity: shown ? 1 : 0
implicitWidth: shown ? (tooltipTextObject.implicitWidth + 2 * root.horizontalPadding) : 0
implicitHeight: shown ? (tooltipTextObject.implicitHeight + 2 * root.verticalPadding) : 0
clip: true
Behavior on implicitWidth {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
StyledText {
id: tooltipTextObject
anchors.centerIn: parent
text: root.text
font.pixelSize: Appearance?.font.pixelSize.smaller ?? 14
font.hintingPreference: Font.PreferNoHinting // Prevent shaky text
color: Appearance?.colors.colOnTooltip ?? "#FFFFFF"
wrapMode: Text.Wrap
}
}
}
@@ -0,0 +1,56 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
/**
* Thumbnail image. It currently generates to the right place at the right size, but does not handle metadata/maintenance on modification.
* See Freedesktop's spec: https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html
*/
StyledImage {
id: root
property bool generateThumbnail: true
required property string sourcePath
property string thumbnailSizeName: Images.thumbnailSizeNameForDimensions(sourceSize.width, sourceSize.height)
property string thumbnailPath: {
if (sourcePath.length == 0) return;
const resolvedUrlWithoutFileProtocol = FileUtils.trimFileProtocol(`${Qt.resolvedUrl(sourcePath)}`);
const encodedUrlWithoutFileProtocol = resolvedUrlWithoutFileProtocol.split("/").map(part => encodeURIComponent(part)).join("/");
const md5Hash = Qt.md5(`file://${encodedUrlWithoutFileProtocol}`);
return `${Directories.genericCache}/thumbnails/${thumbnailSizeName}/${md5Hash}.png`;
}
source: thumbnailPath
asynchronous: true
smooth: true
mipmap: false
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
onSourceSizeChanged: {
if (!root.generateThumbnail) return;
thumbnailGeneration.running = false;
thumbnailGeneration.running = true;
}
Process {
id: thumbnailGeneration
command: {
const maxSize = Images.thumbnailSizes[root.thumbnailSizeName];
return ["bash", "-c",
`[ -f '${FileUtils.trimFileProtocol(root.thumbnailPath)}' ] && exit 0 || { magick '${root.sourcePath}' -resize ${maxSize}x${maxSize} '${FileUtils.trimFileProtocol(root.thumbnailPath)}' && exit 1; }`
]
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 1) { // Force reload if thumbnail had to be generated
root.source = "";
root.source = root.thumbnailPath; // Force reload
}
}
}
}
@@ -0,0 +1,42 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
/**
* Material 3 expressive style toolbar.
* https://m3.material.io/components/toolbars
*/
Item {
id: root
property real padding: 8
property alias colBackground: background.color
property alias spacing: toolbarLayout.spacing
default property alias data: toolbarLayout.data
implicitWidth: background.implicitWidth
implicitHeight: background.implicitHeight
property alias radius: background.radius
StyledRectangularShadow {
target: background
}
Rectangle {
id: background
anchors.fill: parent
color: Appearance.m3colors.m3surfaceContainer // Needs to be opaque
implicitHeight: Math.max(toolbarLayout.implicitHeight + root.padding * 2, 56)
implicitWidth: toolbarLayout.implicitWidth + root.padding * 2
radius: height / 2
RowLayout {
id: toolbarLayout
spacing: 4
anchors {
fill: parent
margins: root.padding
}
}
}
}
@@ -0,0 +1,8 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
RippleButton {
Layout.fillHeight: true
buttonRadius: Appearance.rounding.full
}
@@ -0,0 +1,28 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import qs.modules.common
import qs.modules.common.widgets
TextField {
id: filterField
property alias colBackground: background.color
Layout.fillHeight: true
implicitWidth: 200
padding: 10
placeholderTextColor: Appearance.colors.colSubtext
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.small
renderType: Text.NativeRendering
selectedTextColor: Appearance.colors.colOnSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
background: Rectangle {
id: background
color: Appearance.colors.colLayer1
radius: Appearance.rounding.full
}
}
@@ -0,0 +1,45 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
/**
* A container that supports GroupButton children for bounciness.
* See https://m3.material.io/components/button-groups/overview
*/
Rectangle {
id: root
default property alias content: columnLayout.data
property real spacing: 5
property real padding: 0
property int clickIndex: columnLayout.clickIndex
property real contentHeight: {
let total = 0;
for (let i = 0; i < columnLayout.children.length; ++i) {
const child = columnLayout.children[i];
total += child.baseHeight ?? child.implicitHeight ?? child.height;
}
return total + columnLayout.spacing * (columnLayout.children.length - 1);
}
topLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[0].radius + padding) :
Appearance?.rounding?.small
topRightRadius: topLeftRadius
bottomLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[columnLayout.children.length - 1].radius + padding) :
Appearance?.rounding?.small
bottomRightRadius: bottomLeftRadius
color: "transparent"
height: root.contentHeight + padding * 2
implicitWidth: columnLayout.implicitWidth + padding * 2
implicitHeight: root.contentHeight + padding * 2
children: [ColumnLayout {
id: columnLayout
anchors.fill: parent
anchors.margins: root.padding
spacing: root.spacing
property int clickIndex: -1
}]
}
@@ -0,0 +1,10 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.functions
ToolbarButton {
colBackground: ColorUtils.transparentize(Appearance.colors.colPrimaryContainer)
colBackgroundHover: Appearance.colors.colPrimaryContainerHover
colRipple: Appearance.colors.colPrimaryContainerActive
}
@@ -0,0 +1,73 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Effects
Canvas { // Visualizer
id: root
property list<var> points
property list<var> smoothPoints
property real maxVisualizerValue: 1000
property int smoothing: 2
property bool live: true
property color color: Appearance.m3colors.m3primary
onPointsChanged: () => {
root.requestPaint()
}
anchors.fill: parent
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
var points = root.points;
var maxVal = root.maxVisualizerValue || 1;
var h = height;
var w = width;
var n = points.length;
if (n < 2) return;
// Smoothing: simple moving average (optional)
var smoothWindow = root.smoothing; // adjust for more/less smoothing
root.smoothPoints = [];
for (var i = 0; i < n; ++i) {
var sum = 0, count = 0;
for (var j = -smoothWindow; j <= smoothWindow; ++j) {
var idx = Math.max(0, Math.min(n - 1, i + j));
sum += points[idx];
count++;
}
root.smoothPoints.push(sum / count);
}
if (!root.live) root.smoothPoints.fill(0); // If not playing, show no points
ctx.beginPath();
ctx.moveTo(0, h);
for (var i = 0; i < n; ++i) {
var x = i * w / (n - 1);
var y = h - (root.smoothPoints[i] / maxVal) * h;
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fillStyle = Qt.rgba(
root.color.r,
root.color.g,
root.color.b,
0.15
);
ctx.fill();
}
layer.enabled: true
layer.effect: MultiEffect { // Blur a bit to obscure away the points
source: root
saturation: 0.2
blurEnabled: true
blurMax: 7
blur: 1
}
}
@@ -0,0 +1,34 @@
import qs.modules.common
import QtQuick
Canvas {
id: root
property real amplitudeMultiplier: 0.5
property real frequency: 6
property color color: Appearance?.colors.colPrimary ?? "#685496"
property real lineWidth: 4
property real fullLength: width
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
var amplitude = root.lineWidth * root.amplitudeMultiplier;
var frequency = root.frequency;
var phase = Date.now() / 400.0;
var centerY = height / 2;
ctx.strokeStyle = root.color;
ctx.lineWidth = root.lineWidth;
ctx.lineCap = "round";
ctx.beginPath();
for (var x = ctx.lineWidth / 2; x <= root.width - ctx.lineWidth / 2; x += 1) {
var waveY = centerY + amplitude * Math.sin(frequency * 2 * Math.PI * x / root.fullLength + phase);
if (x === 0)
ctx.moveTo(x, waveY);
else
ctx.lineTo(x, waveY);
}
ctx.stroke();
}
}
@@ -0,0 +1,90 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
Rectangle {
id: root
property bool show: false
default property alias data: contentColumn.data
property real backgroundHeight: 600
property real backgroundAnimationMovementDistance: 60
signal dismiss()
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Escape) {
root.dismiss();
event.accepted = true;
}
}
color: root.show ? Appearance.colors.colScrim : ColorUtils.transparentize(Appearance.colors.colScrim)
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
visible: dialogBackground.implicitHeight > 0
onShowChanged: {
dialogBackgroundHeightAnimation.easing.bezierCurve = (show ? Appearance.animationCurves.emphasizedDecel : Appearance.animationCurves.emphasizedAccel)
dialogBackground.implicitHeight = show ? backgroundHeight : 0
}
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
MouseArea { // Clicking outside the dialog should dismiss
anchors.fill: parent
acceptedButtons: Qt.AllButtons
hoverEnabled: true
onPressed: root.dismiss()
}
Rectangle {
id: dialogBackground
anchors.horizontalCenter: parent.horizontalCenter
radius: Appearance.rounding.large
color: Appearance.m3colors.m3surfaceContainerHigh // Use opaque version of layer3
property real targetY: root.height / 2 - root.backgroundHeight / 2
y: root.show ? targetY : (targetY - root.backgroundAnimationMovementDistance)
implicitWidth: 350
implicitHeight: 0
Behavior on implicitHeight {
NumberAnimation {
id: dialogBackgroundHeightAnimation
duration: Appearance.animation.elementMoveFast.duration
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animationCurves.emphasizedDecel
}
}
Behavior on y {
NumberAnimation {
duration: dialogBackgroundHeightAnimation.duration
easing.type: dialogBackgroundHeightAnimation.easing.type
easing.bezierCurve: dialogBackgroundHeightAnimation.easing.bezierCurve
}
}
MouseArea { // So clicking inside the dialog won't dismiss
anchors.fill: parent
acceptedButtons: Qt.AllButtons
hoverEnabled: true
}
ColumnLayout {
id: contentColumn
anchors {
fill: parent
margins: dialogBackground.radius
}
spacing: 16
opacity: root.show ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
}
}
@@ -0,0 +1,15 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
RowLayout {
id: root
spacing: 4
// These shouldn't be needed but it would be a terrible waste of space to follow the spec
Layout.margins: -8
Layout.topMargin: 0
}
@@ -0,0 +1,16 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
Rectangle {
implicitHeight: 1
color: Appearance.colors.colOutline
Layout.fillWidth: true
Layout.leftMargin: -Appearance.rounding.large
Layout.rightMargin: -Appearance.rounding.large
Layout.topMargin: -8
Layout.bottomMargin: -8
}
@@ -0,0 +1,13 @@
import QtQuick
import Quickshell
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
StyledText {
text: "Dialog Title"
font {
pixelSize: Appearance.font.pixelSize.title
family: Appearance.font.family.title
}
}
@@ -0,0 +1,77 @@
/**
* @param { string } summary
* @returns { string }
*/
function findSuitableMaterialSymbol(summary = "") {
const defaultType = 'chat';
if(summary.length === 0) return defaultType;
const keywordsToTypes = {
'reboot': 'restart_alt',
'record': 'screen_record',
'battery': 'power',
'power': 'power',
'screenshot': 'screenshot_monitor',
'welcome': 'waving_hand',
'time': 'scheduleb',
'installed': 'download',
'configuration reloaded': 'reset_wrench',
'config': 'reset_wrench',
'update': 'update',
'ai response': 'neurology',
'control': 'settings',
'upsca': 'compare',
'install': 'deployed_code_update',
'startswith:file': 'folder_copy', // Declarative startsWith check
};
const lowerSummary = summary.toLowerCase();
for (const [keyword, type] of Object.entries(keywordsToTypes)) {
if (keyword.startsWith('startswith:')) {
const startsWithKeyword = keyword.replace('startswith:', '');
if (lowerSummary.startsWith(startsWithKeyword)) {
return type;
}
} else if (lowerSummary.includes(keyword)) {
return type;
}
}
return defaultType;
}
/**
* @param { number | string | Date } timestamp
* @returns { string }
*/
const getFriendlyNotifTimeString = (timestamp) => {
if (!timestamp) return '';
const messageTime = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - messageTime.getTime();
// Less than 1 minute
if (diffMs < 60000)
return 'Now';
// Same day - show relative time
if (messageTime.toDateString() === now.toDateString()) {
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
if (diffHours > 0) {
return `${diffHours}h`;
} else {
return `${diffMinutes}m`;
}
}
// Yesterday
if (messageTime.toDateString() === new Date(now.getTime() - 86400000).toDateString())
return 'Yesterday';
// Older dates
return Qt.formatDateTime(messageTime, "MMMM dd");
};