Merge remote-tracking branch 'origin/main' into addon-i18n

This commit is contained in:
月月
2025-07-12 21:03:24 +08:00
254 changed files with 2744 additions and 955 deletions
@@ -0,0 +1,48 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/**
* A container that supports GroupButton children for bounciness.
* See https://m3.material.io/components/button-groups/overview
*/
Rectangle {
id: root
default property alias data: rowLayout.data
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,95 @@
// From https://github.com/rafzby/circular-progressbar with modifications
// License: LGPL-3.0 - A copy can be found in `licenses` folder of repo
import QtQuick
import "root:/modules/common"
/**
* Material 3 circular progress. See https://m3.material.io/components/progress-indicators/specs
*/
Item {
id: root
property int size: 30
property int lineWidth: 2
property real value: 0
property color primaryColor: Appearance.m3colors.m3onSecondaryContainer
property color secondaryColor: Appearance.colors.colSecondaryContainer
property real gapAngle: Math.PI / 9
property bool fill: false
property int fillOverflow: 2
property int animationDuration: 1000
property var easingType: Easing.OutCubic
width: size
height: size
signal animationFinished();
onValueChanged: {
canvas.degree = value * 360;
}
onPrimaryColorChanged: {
canvas.requestPaint();
}
onSecondaryColorChanged: {
canvas.requestPaint();
}
Canvas {
id: canvas
property real degree: 0
anchors.fill: parent
antialiasing: true
onDegreeChanged: {
requestPaint();
}
onPaint: {
var ctx = getContext("2d");
var x = root.width / 2;
var y = root.height / 2;
var radius = root.size / 2 - root.lineWidth;
var startAngle = (Math.PI / 180) * 270;
var fullAngle = (Math.PI / 180) * (270 + 360);
var progressAngle = (Math.PI / 180) * (270 + degree);
var epsilon = 0.01; // Small angle in radians
ctx.reset();
if (root.fill) {
ctx.fillStyle = root.secondaryColor;
ctx.beginPath();
ctx.arc(x, y, radius + fillOverflow, startAngle, fullAngle);
ctx.fill();
}
ctx.lineCap = 'round';
ctx.lineWidth = root.lineWidth;
// Secondary
ctx.beginPath();
ctx.arc(x, y, radius, progressAngle + gapAngle, fullAngle - gapAngle);
ctx.strokeStyle = root.secondaryColor;
ctx.stroke();
// Primary (value indication)
var endAngle = progressAngle + (value > 0 ? 0 : epsilon);
ctx.beginPath();
ctx.arc(x, y, radius, startAngle, endAngle);
ctx.strokeStyle = root.primaryColor;
ctx.stroke();
}
Behavior on degree {
NumberAnimation {
duration: root.animationDuration
easing.type: root.easingType
}
}
}
}
@@ -0,0 +1,102 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/services"
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common/functions/file_utils.js" as FileUtils
import Qt5Compat.GraphicalEffects
import Qt.labs.platform
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import Quickshell.Hyprland
Rectangle {
id: root
property string entry
property real maxWidth
property real maxHeight
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 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}'`])
}
Image {
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
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: image.width
height: image.height
radius: root.radius
}
}
}
}
@@ -0,0 +1,8 @@
import QtQuick
import QtQuick.Layouts
RowLayout {
property bool uniform: false
spacing: 10
uniformCellSizes: uniform
}
@@ -0,0 +1,48 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import "root:/services/"
import "root:/modules/common/"
import "root:/modules/common/widgets/"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import "root:/modules/common/functions/file_utils.js" as FileUtils
Flow {
id: root
Layout.fillWidth: true
spacing: 2
property list<var> options: []
property string configOptionName: ""
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
buttonText: modelData.displayName;
toggled: root.currentValue === modelData.value
onClicked: {
root.selected(modelData.value);
}
}
}
}
@@ -0,0 +1,31 @@
import "root:/modules/common/widgets/"
import "root:/modules/common/"
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
RowLayout {
id: root
property string text: ""
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
StyledText {
id: labelWidget
Layout.fillWidth: true
text: root.text
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnSecondaryContainer
}
StyledSpinBox {
id: spinBoxWidget
Layout.fillWidth: false
value: root.value
}
}
@@ -0,0 +1,32 @@
import "root:/modules/common/widgets/"
import "root:/modules/common/"
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
RippleButton {
id: root
Layout.fillWidth: true
implicitHeight: contentItem.implicitHeight + 8 * 2
onClicked: checked = !checked
contentItem: RowLayout {
spacing: 10
StyledText {
id: labelWidget
Layout.fillWidth: true
text: root.text
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnSecondaryContainer
}
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.Controls
import QtQuick.Layouts
import "root:/modules/common/"
import "root:/modules/common/widgets/"
Flickable {
id: root
property real baseWidth: 550
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: 10
}
spacing: 20
}
}
@@ -0,0 +1,23 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "root:/modules/common/"
import "root:/modules/common/widgets/"
ColumnLayout {
id: root
property string title
default property alias data: sectionContent.data
Layout.fillWidth: true
spacing: 8
StyledText {
text: root.title
font.pixelSize: Appearance.font.pixelSize.larger
font.weight: Font.Medium
}
ColumnLayout {
id: sectionContent
spacing: 8
}
}
@@ -0,0 +1,47 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "root:/modules/common/"
import "root:/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
content: root.tooltip
}
}
}
Item { Layout.fillWidth: true }
}
ColumnLayout {
id: sectionContent
Layout.fillWidth: true
spacing: 2
}
}
@@ -0,0 +1,10 @@
import QtQuick
import QtQuick.Layouts
import "root:/modules/common/"
import "root:/modules/common/widgets/"
StyledText {
text: "Subsection"
color: Appearance.colors.colSubtext
Layout.leftMargin: 4
}
@@ -0,0 +1,36 @@
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: "root:/assets/icons" // The folder to check first
width: 30
height: 30
IconImage {
id: iconImage
anchors.fill: parent
source: {
if (iconFolder && iconFolder + "/" + root.source) {
return iconFolder + "/" + root.source
}
return root.source
}
implicitSize: root.height
}
Loader {
active: root.colorize
anchors.fill: iconImage
sourceComponent: ColorOverlay {
source: iconImage
color: root.color
}
}
}
@@ -0,0 +1,38 @@
import "root:/modules/common"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
/**
* Material 3 dialog button. See https://m3.material.io/components/dialogs/overview
*/
RippleButton {
id: button
property string buttonText
implicitHeight: 30
implicitWidth: buttonTextWidget.implicitWidth + 15 * 2
buttonRadius: Appearance?.rounding.full ?? 9999
property color colEnabled: Appearance?.colors.colPrimary ?? "#65558F"
property color colDisabled: Appearance?.m3colors.m3outline ?? "#8D8C96"
contentItem: StyledText {
id: buttonTextWidget
anchors.fill: parent
anchors.leftMargin: 15
anchors.rightMargin: 15
text: buttonText
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance?.font.pixelSize.small ?? 12
color: button.enabled ? button.colEnabled : button.colDisabled
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
@@ -0,0 +1,74 @@
import "root:/modules/common"
import "root:/services"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/**
* 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,53 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/services"
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common/functions/file_utils.js" as FileUtils
import Qt5Compat.GraphicalEffects
import Qt.labs.platform
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Widgets
import Quickshell.Hyprland
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,60 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "root:/modules/common/"
import "root:/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,123 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Widgets
/**
* 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(Appearance?.colors.colLayer1Hover, 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();
root.click() // Because the MouseArea already consumed the event
}
onCanceled: (event) => {
root.down = 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,46 @@
import "root:/modules/common"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
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,124 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
GroupButton {
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
sperm: true
animateSperm: 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 ? "Dark" : "Light"
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2
horizontalAlignment: Text.AlignHCenter
}
}
}
}
@@ -0,0 +1,33 @@
import "root:/modules/common/"
import QtQuick
import QtQuick.Layouts
Text {
id: root
property real iconSize: Appearance?.font.pixelSize.small ?? 16
property real fill: 0
property real truncatedFill: Math.round(fill * 100) / 100 // Reduce memory consumption spikes from constant font remapping
renderType: Text.NativeRendering
font {
hintingPreference: Font.PreferFullHinting
family: Appearance?.font.family.iconMaterial ?? "Material Symbols Rounded"
pixelSize: iconSize
weight: Font.Normal + (Font.DemiBold - Font.Normal) * fill
variableAxes: {
"FILL": truncatedFill,
// "wght": font.weight,
// "GRAD": 0,
"opsz": iconSize,
}
}
verticalAlignment: Text.AlignVCenter
color: Appearance.m3colors.m3onBackground
// Behavior on fill {
// 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,53 @@
import "root:/modules/common"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
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,31 @@
import "root:/modules/common"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
RippleButton {
id: 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,12 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "root:/modules/common/"
import "root:/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,149 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
TabButton {
id: root
property bool toggled: TabBar.tabBar.currentIndex === TabBar.index
property string buttonIcon
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
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,31 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "root:/modules/common/"
import "root:/modules/common/widgets/"
RippleButton {
id: root
Layout.alignment: Qt.AlignLeft
implicitWidth: 40
implicitHeight: 40
Layout.leftMargin: 8
onClicked: {
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,44 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
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
property real baseHighlightHeight: tabBarColumn.children[0].baseHighlightHeight
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
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,27 @@
import "root:/modules/common"
import "root:/services"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
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.colSurfaceContainerHighest
colBackgroundHover: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSurfaceContainerHighestHover
colRipple: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colSurfaceContainerHighestActive
contentItem: StyledText {
horizontalAlignment: Text.AlignHCenter
text: buttonText
color: (urgency == NotificationUrgency.Critical) ? Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
}
}
@@ -0,0 +1,105 @@
import "root:/modules/common"
import "./notification_utils.js" as NotificationUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Notifications
Rectangle { // App icon
id: root
property var appIcon: ""
property var summary: ""
property var urgency: NotificationUrgency.Normal
property var image: ""
property real scale: 1
property real size: 45 * scale
property real materialIconScale: 0.57
property real appIconScale: 0.7
property real smallAppIconScale: 0.49
property real materialIconSize: size * materialIconScale
property real appIconSize: size * appIconScale
property real smallAppIconSize: size * smallAppIconScale
implicitWidth: size
implicitHeight: size
radius: Appearance.rounding.full
color: 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) ?
"release_alert" : guessedIcon
}
anchors.fill: parent
color: (root.urgency == NotificationUrgency.Critical) ?
ColorUtils.mix(Appearance.m3colors.m3onSecondary, Appearance.m3colors.m3onSecondaryContainer, 0.1) :
Appearance.m3colors.m3onSecondaryContainer
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,244 @@
import "root:/modules/common"
import "root:/services"
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import "./notification_utils.js" as NotificationUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import Quickshell.Hyprland
import Quickshell.Services.Notifications
/**
* A group of notifications from the same app.
* Similar to Android's notifications
*/
Item { // 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;
}
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.id);
});
});
}
}
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: Appearance.colors.colSurfaceContainer
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
}
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() }
}
}
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,52 @@
import "root:/modules/common"
import "root:/services"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
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,318 @@
import "root:/modules/common"
import "root:/services"
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import "./notification_utils.js" as NotificationUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
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 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;
}
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.id);
}
}
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.colSurfaceContainerHigh) :
ColorUtils.transparentize(Appearance.colors.colSurfaceContainerHighest)
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
// Layout.fillWidth: true
StyledText {
id: summaryText
visible: !root.onlyNotification
font.pixelSize: root.fontSize
color: Appearance.colors.colOnLayer2
elide: Text.ElideRight
text: root.notificationObject.summary || ""
}
StyledText {
opacity: !root.expanded ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Layout.fillWidth: true
font.pixelSize: root.fontSize
color: Appearance.colors.colSubtext
elide: Text.ElideRight
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:${notificationBodyText.width}px;}</style>` +
`${processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "<br/>")}`
}
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
Hyprland.dispatch("global quickshell:sidebarRightClose")
}
PointingHandLinkHover {}
}
Flickable { // Notification actions
id: actionsFlickable
Layout.fillWidth: true
implicitHeight: actionRowLayout.implicitHeight
contentWidth: actionRowLayout.implicitWidth
clip: !onlyNotification
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout {
id: actionRowLayout
Layout.alignment: Qt.AlignBottom
NotificationActionButton {
Layout.fillWidth: true
buttonText: 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.id, modelData.identifier);
}
}
}
NotificationActionButton {
Layout.fillWidth: true
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
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,31 @@
import "root:/"
import "root:/modules/common/"
import "root:/modules/common/widgets"
import "root:/services"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
StyledListView { // Scrollable window
id: root
property bool popup: false
spacing: 3
model: ScriptModel {
values: root.popup ? Notifications.popupAppNameList : Notifications.appNameList
}
delegate: NotificationGroup {
required property int index
required property var modelData
popup: root.popup
anchors.left: parent?.left
anchors.right: parent?.right
notificationGroup: popup ?
Notifications.popupGroupsByAppName[modelData] :
Notifications.groupsByAppName[modelData]
}
}
@@ -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,98 @@
import "root:/modules/common"
import "root:/services/"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
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
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,173 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Widgets
TabButton {
id: button
property string buttonText
property string buttonIcon
property real minimumWidth: 110
property bool selected: false
property int tabContentWidth: contentItem.children[0].implicitWidth
property int rippleDuration: 1200
height: buttonBackground.height
implicitWidth: Math.max(tabContentWidth, buttonBackground.implicitWidth, minimumWidth)
property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent"
property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED"
property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2"
property color colActive: Appearance?.colors.colPrimary ?? "#65558F"
property color colInactive: Appearance?.colors.colOnLayer1 ?? "#45464F"
component RippleAnim: NumberAnimation {
duration: rippleDuration
easing.type: Appearance?.animation.elementMoveEnter.type
easing.bezierCurve: Appearance?.animationCurves.standardDecel
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: (event) => {
const {x,y} = event
const stateY = buttonBackground.y;
rippleAnim.x = x;
rippleAnim.y = y - stateY;
const dist = (ox,oy) => ox*ox + oy*oy
const stateEndY = stateY + buttonBackground.height
rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)))
rippleFadeAnim.complete();
rippleAnim.restart();
}
onReleased: (event) => {
button.click() // Because the MouseArea already consumed the event
rippleFadeAnim.restart();
}
}
RippleAnim {
id: rippleFadeAnim
target: ripple
property: "opacity"
to: 0
}
SequentialAnimation {
id: rippleAnim
property real x
property real y
property real radius
PropertyAction {
target: ripple
property: "x"
value: rippleAnim.x
}
PropertyAction {
target: ripple
property: "y"
value: rippleAnim.y
}
PropertyAction {
target: ripple
property: "opacity"
value: 1
}
ParallelAnimation {
RippleAnim {
target: ripple
properties: "implicitWidth,implicitHeight"
from: 0
to: rippleAnim.radius * 2
}
}
}
background: Rectangle {
id: buttonBackground
radius: Appearance?.rounding.small
implicitHeight: 50
color: (button.hovered ? button.colBackgroundHover : button.colBackground)
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: buttonBackground.width
height: buttonBackground.height
radius: buttonBackground.radius
}
}
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
Item {
id: ripple
width: ripple.implicitWidth
height: ripple.implicitHeight
opacity: 0
property real implicitWidth: 0
property real implicitHeight: 0
visible: width > 0 && height > 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
RadialGradient {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: button.colRipple }
GradientStop { position: 0.3; color: button.colRipple }
GradientStop { position: 0.5 ; color: Qt.rgba(button.colRipple.r, button.colRipple.g, button.colRipple.b, 0) }
}
}
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
}
contentItem: Item {
anchors.centerIn: buttonBackground
ColumnLayout {
anchors.centerIn: parent
spacing: 0
MaterialSymbol {
visible: buttonIcon?.length > 0
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
text: buttonIcon
iconSize: Appearance?.font.pixelSize.hugeass ?? 25
fill: selected ? 1 : 0
color: selected ? button.colActive : button.colInactive
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
StyledText {
id: buttonTextWidget
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance?.font.pixelSize.small
color: selected ? button.colActive : button.colInactive
text: buttonText
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
}
}
@@ -0,0 +1,26 @@
import "root:/modules/common"
import QtQuick
import Quickshell
/**
* Recreation of GTK revealer. Expects one single child.
*/
Item {
id: root
property bool reveal
property bool vertical: false
clip: true
implicitWidth: (reveal || vertical) ? childrenRect.width : 0
implicitHeight: (reveal || !vertical) ? childrenRect.height : 0
visible: reveal || (width > 0 && height > 0)
Behavior on implicitWidth {
enabled: !vertical
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
enabled: vertical
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
}
@@ -0,0 +1,186 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Widgets
/**
* A button with ripple effect similar to in Material Design.
*/
Button {
id: root
property bool toggled
property string buttonText
property real buttonRadius: Appearance?.rounding?.small ?? 4
property real buttonRadiusPressed: buttonRadius
property real buttonEffectiveRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius
property int rippleDuration: 1200
property bool rippleEnabled: true
property var downAction // When left clicking (down)
property var releaseAction // When left clicking (release)
property var altAction // When right clicking
property var middleClickAction // When middle clicking
property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent"
property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED"
property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F"
property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C"
property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2"
property color colRippleToggled: Appearance?.colors.colPrimaryActive ?? "#D6CEE2"
property color buttonColor: root.enabled ? (root.toggled ?
(root.hovered ? colBackgroundToggledHover :
colBackgroundToggled) :
(root.hovered ? colBackgroundHover :
colBackground)) : colBackground
property color rippleColor: root.toggled ? colRippleToggled : colRipple
function startRipple(x, y) {
const stateY = buttonBackground.y;
rippleAnim.x = x;
rippleAnim.y = y - stateY;
const dist = (ox,oy) => ox*ox + oy*oy
const stateEndY = stateY + buttonBackground.height
rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)))
rippleFadeAnim.complete();
rippleAnim.restart();
}
component RippleAnim: NumberAnimation {
duration: rippleDuration
easing.type: Appearance?.animation.elementMoveEnter.type
easing.bezierCurve: Appearance?.animationCurves.standardDecel
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onPressed: (event) => {
if(event.button === Qt.RightButton) {
if (root.altAction) root.altAction();
return;
}
if(event.button === Qt.MiddleButton) {
if (root.middleClickAction) root.middleClickAction();
return;
}
root.down = true
if (root.downAction) root.downAction();
if (!root.rippleEnabled) return;
const {x,y} = event
startRipple(x, y)
}
onReleased: (event) => {
root.down = false
if (event.button != Qt.LeftButton) return;
if (root.releaseAction) root.releaseAction();
root.click() // Because the MouseArea already consumed the event
if (!root.rippleEnabled) return;
rippleFadeAnim.restart();
}
onCanceled: (event) => {
root.down = false
if (!root.rippleEnabled) return;
rippleFadeAnim.restart();
}
}
RippleAnim {
id: rippleFadeAnim
target: ripple
property: "opacity"
to: 0
}
SequentialAnimation {
id: rippleAnim
property real x
property real y
property real radius
PropertyAction {
target: ripple
property: "x"
value: rippleAnim.x
}
PropertyAction {
target: ripple
property: "y"
value: rippleAnim.y
}
PropertyAction {
target: ripple
property: "opacity"
value: 1
}
ParallelAnimation {
RippleAnim {
target: ripple
properties: "implicitWidth,implicitHeight"
from: 0
to: rippleAnim.radius * 2
}
}
}
background: Rectangle {
id: buttonBackground
radius: root.buttonEffectiveRadius
implicitHeight: 50
color: root.buttonColor
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: buttonBackground.width
height: buttonBackground.height
radius: root.buttonEffectiveRadius
}
}
Item {
id: ripple
width: ripple.implicitWidth
height: ripple.implicitHeight
opacity: 0
visible: width > 0 && height > 0
property real implicitWidth: 0
property real implicitHeight: 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
RadialGradient {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: root.rippleColor }
GradientStop { position: 0.3; color: root.rippleColor }
GradientStop { position: 0.5; color: Qt.rgba(root.rippleColor.r, root.rippleColor.g, root.rippleColor.b, 0) }
}
}
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
}
contentItem: StyledText {
text: root.buttonText
}
}
@@ -0,0 +1,56 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "root:/modules/common/"
import "root:/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: 15
buttonRadius: Appearance.rounding.small
colBackground: Appearance.colors.colLayer2
contentItem: RowLayout {
Item {
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 {
sourceComponent: buttonWithIconRoot.mainContentComponent
Layout.alignment: Qt.AlignVCenter
}
}
}
@@ -0,0 +1,61 @@
import QtQuick 2.9
Item {
id: root
enum CornerEnum { TopLeft, TopRight, BottomLeft, BottomRight }
property var corner: RoundCorner.CornerEnum.TopLeft // Default to TopLeft
property int size: 25
property color color: "#000000"
onColorChanged: {
canvas.requestPaint();
}
onCornerChanged: {
canvas.requestPaint();
}
implicitWidth: size
implicitHeight: size
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
onPaint: {
var ctx = getContext("2d");
var r = root.size;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
switch (root.corner) {
case RoundCorner.CornerEnum.TopLeft:
ctx.arc(r, r, r, Math.PI, 3 * Math.PI / 2);
ctx.lineTo(0, 0);
break;
case RoundCorner.CornerEnum.TopRight:
ctx.arc(0, r, r, 3 * Math.PI / 2, 2 * Math.PI);
ctx.lineTo(r, 0);
break;
case RoundCorner.CornerEnum.BottomLeft:
ctx.arc(r, 0, r, Math.PI / 2, Math.PI);
ctx.lineTo(0, r);
break;
case RoundCorner.CornerEnum.BottomRight:
ctx.arc(0, 0, r, 0, Math.PI / 2);
ctx.lineTo(r, r);
break;
}
ctx.closePath();
ctx.fillStyle = root.color;
ctx.fill();
}
}
Behavior on size {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
@@ -0,0 +1,163 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Widgets
TabButton {
id: root
property string buttonText
property string buttonIcon
property bool selected: false
property int rippleDuration: 1200
height: buttonBackground.height
property int tabContentWidth: buttonBackground.width - buttonBackground.radius*2
property color colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1)
property color colBackgroundHover: Appearance.colors.colLayer1Hover
property color colRipple: Appearance.colors.colLayer1Active
PointingHandInteraction {}
component RippleAnim: NumberAnimation {
duration: rippleDuration
easing.type: Appearance.animation.elementMoveEnter.type
easing.bezierCurve: Appearance.animationCurves.standardDecel
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: (event) => {
const {x,y} = event
const stateY = buttonBackground.y;
rippleAnim.x = x;
rippleAnim.y = y - stateY;
const dist = (ox,oy) => ox*ox + oy*oy
const stateEndY = stateY + buttonBackground.height
rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)))
rippleFadeAnim.complete();
rippleAnim.restart();
}
onReleased: (event) => {
root.click() // Because the MouseArea already consumed the event
rippleFadeAnim.restart();
}
}
RippleAnim {
id: rippleFadeAnim
target: ripple
property: "opacity"
to: 0
}
SequentialAnimation {
id: rippleAnim
property real x
property real y
property real radius
PropertyAction {
target: ripple
property: "x"
value: rippleAnim.x
}
PropertyAction {
target: ripple
property: "y"
value: rippleAnim.y
}
PropertyAction {
target: ripple
property: "opacity"
value: 1
}
ParallelAnimation {
RippleAnim {
target: ripple
properties: "implicitWidth,implicitHeight"
from: 0
to: rippleAnim.radius * 2
}
}
}
background: Rectangle {
id: buttonBackground
radius: Appearance?.rounding.small ?? 7
implicitHeight: 37
color: (root.hovered ? root.colBackgroundHover : root.colBackground)
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: buttonBackground.width
height: buttonBackground.height
radius: buttonBackground.radius
}
}
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
Rectangle {
id: ripple
radius: Appearance.rounding.full
color: root.colRipple
opacity: 0
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
}
contentItem: Item {
anchors.centerIn: buttonBackground
RowLayout {
anchors.centerIn: parent
spacing: 0
Loader {
id: iconLoader
active: buttonIcon?.length > 0
sourceComponent: buttonIcon?.length > 0 ? materialSymbolComponent : null
Layout.rightMargin: 5
}
Component {
id: materialSymbolComponent
MaterialSymbol {
verticalAlignment: Text.AlignVCenter
text: buttonIcon
iconSize: Appearance.font.pixelSize.huge
fill: selected ? 1 : 0
color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
StyledText {
id: buttonTextWidget
verticalAlignment: Text.AlignVCenter
font.pixelSize: Appearance.font.pixelSize.small
color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1
text: buttonText
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
}
}
@@ -0,0 +1,130 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/services"
import "root:/services/"
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
Item {
id: root
property real dialogPadding: 15
property real dialogMargin: 30
property string titleText: "Selection Dialog"
property alias items: choiceModel.values
property int selectedId: choiceListView.currentIndex
property var defaultChoice
signal canceled();
signal selected(var result);
Rectangle { // Scrim
id: scrimOverlay
anchors.fill: parent
radius: Appearance.rounding.small
color: Appearance.colors.colScrim
MouseArea {
hoverEnabled: true
anchors.fill: parent
preventStealing: true
propagateComposedEvents: false
}
}
Rectangle { // The dialog
id: dialog
color: Appearance.colors.colSurfaceContainerHigh
radius: Appearance.rounding.normal
anchors.fill: parent
anchors.margins: dialogMargin
implicitHeight: dialogColumnLayout.implicitHeight
ColumnLayout {
id: dialogColumnLayout
anchors.fill: parent
spacing: 16
StyledText {
id: dialogTitle
Layout.topMargin: dialogPadding
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
Layout.alignment: Qt.AlignLeft
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.larger
text: root.titleText
}
Rectangle {
color: Appearance.m3colors.m3outline
implicitHeight: 1
Layout.fillWidth: true
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
}
ListView {
id: choiceListView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1
model: ScriptModel {
id: choiceModel
}
delegate: StyledRadioButton {
id: radioButton
required property var modelData
required property int index
anchors {
left: parent?.left
right: parent?.right
leftMargin: root.dialogPadding
rightMargin: root.dialogPadding
}
description: modelData.toString()
checked: index === choiceListView.currentIndex
onCheckedChanged: {
if (checked) {
choiceListView.currentIndex = index;
}
}
}
}
Rectangle {
color: Appearance.m3colors.m3outline
implicitHeight: 1
Layout.fillWidth: true
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
}
RowLayout {
id: dialogButtonsRowLayout
Layout.bottomMargin: dialogPadding
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
Layout.alignment: Qt.AlignRight
DialogButton {
buttonText: 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,24 @@
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import "root:/services/"
import "root:/modules/common/"
import "root:/modules/common/widgets/"
GroupButton {
id: root
horizontalPadding: 12
verticalPadding: 8
bounce: false
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
contentItem: StyledText {
color: parent.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
text: root.buttonText
}
}
@@ -0,0 +1,16 @@
import "root:/modules/common"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Label {
renderType: Text.NativeRendering
verticalAlignment: Text.AlignVCenter
font {
hintingPreference: Font.PreferFullHinting
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
}
color: Appearance?.m3colors.m3onBackground ?? "black"
linkColor: Appearance?.m3colors.m3primary
}
@@ -0,0 +1,110 @@
import "root:/"
import "root:/modules/common/"
import "root:/modules/common/widgets"
import "root:/services"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
/**
* A ListView with animations.
*/
ListView {
id: root
spacing: 5
property real removeOvershoot: 20 // Account for gaps and bouncy animations
property int dragIndex: -1
property real dragDistance: 0
property bool popin: true
function resetDrag() {
root.dragIndex = -1
root.dragDistance = 0
}
add: Transition {
animations: [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: popin ? "opacity,scale" : "opacity",
from: 0,
to: 1,
}),
]
}
addDisplaced: Transition {
animations: [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: popin ? "opacity,scale" : "opacity",
to: 1,
}),
]
}
// displaced: Transition {
// animations: [
// Appearance?.animation.elementMove.numberAnimation.createObject(this, {
// property: "y",
// }),
// Appearance?.animation.elementMove.numberAnimation.createObject(this, {
// properties: "opacity,scale",
// to: 1,
// }),
// ]
// }
// move: Transition {
// animations: [
// Appearance?.animation.elementMove.numberAnimation.createObject(this, {
// property: "y",
// }),
// Appearance?.animation.elementMove.numberAnimation.createObject(this, {
// properties: "opacity,scale",
// to: 1,
// }),
// ]
// }
// moveDisplaced: Transition {
// animations: [
// Appearance?.animation.elementMove.numberAnimation.createObject(this, {
// property: "y",
// }),
// Appearance?.animation.elementMove.numberAnimation.createObject(this, {
// properties: "opacity,scale",
// to: 1,
// }),
// ]
// }
remove: Transition {
animations: [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "x",
to: root.width + root.removeOvershoot,
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "opacity",
to: 0,
})
]
}
// This is movement when something is removed, not removing animation!
removeDisplaced: Transition {
animations: [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
]
}
}
@@ -0,0 +1,103 @@
import "root:/services"
import "root:/modules/common"
import "root:/modules/common/widgets"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Qt5Compat.GraphicalEffects
/**
* Material 3 progress bar. See https://m3.material.io/components/progress-indicators/overview
*/
ProgressBar {
id: root
property real valueBarWidth: 120
property real valueBarHeight: 4
property real valueBarGap: 4
property color highlightColor: Appearance?.colors.colPrimary ?? "#685496"
property color trackColor: Appearance?.m3colors.m3secondaryContainer ?? "#F1D3F9"
property bool sperm: false // If true, the progress bar will have a wavy fill effect
property bool animateSperm: true
property real spermAmplitudeMultiplier: sperm ? 0.5 : 0
property real spermFrequency: 6
property real spermFps: 60
Behavior on spermAmplitudeMultiplier {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on value {
animation: Appearance?.animation.elementMoveEnter.numberAnimation.createObject(this)
}
background: Item {
anchors.fill: parent
implicitHeight: valueBarHeight
implicitWidth: valueBarWidth
}
contentItem: Item {
anchors.fill: parent
Canvas {
id: wavyFill
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
}
height: parent.height * 6
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
var progress = root.visualPosition;
var fillWidth = progress * width;
var amplitude = parent.height * root.spermAmplitudeMultiplier;
var frequency = root.spermFrequency;
var phase = Date.now() / 400.0;
var centerY = height / 2;
ctx.strokeStyle = root.highlightColor;
ctx.lineWidth = parent.height;
ctx.lineCap = "round";
ctx.beginPath();
for (var x = ctx.lineWidth / 2; x <= fillWidth; x += 1) {
var waveY = centerY + amplitude * Math.sin(frequency * 2 * Math.PI * x / width + phase);
if (x === 0)
ctx.moveTo(x, waveY);
else
ctx.lineTo(x, waveY);
}
ctx.stroke();
}
Connections {
target: root
function onValueChanged() { wavyFill.requestPaint(); }
function onHighlightColorChanged() { wavyFill.requestPaint(); }
}
Timer {
interval: 1000 / root.spermFps
running: root.animateSperm
repeat: root.sperm
onTriggered: wavyFill.requestPaint()
}
}
Rectangle { // Right remaining part fill
anchors.right: parent.right
width: (1 - root.visualPosition) * parent.width - valueBarGap
height: parent.height
radius: Appearance?.rounding.full ?? 9999
color: root.trackColor
}
Rectangle { // Stop point
anchors.right: parent.right
width: valueBarGap
height: valueBarGap
radius: Appearance?.rounding.full ?? 9999
color: root.highlightColor
}
}
}
@@ -0,0 +1,86 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/services"
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
import Quickshell.Services.Pipewire
RadioButton {
id: root
implicitHeight: 40
property string description
property color activeColor: Appearance?.colors.colPrimary ?? "#685496"
property color inactiveColor: Appearance?.m3colors.m3onSurfaceVariant ?? "#45464F"
PointingHandInteraction {}
indicator: Item{}
contentItem: RowLayout {
Layout.fillWidth: true
spacing: 12
Rectangle {
id: radio
Layout.fillWidth: false
Layout.alignment: Qt.AlignVCenter
width: 20
height: 20
radius: Appearance?.rounding.full
border.color: checked ? root.activeColor : root.inactiveColor
border.width: 2
color: "transparent"
// Checked indicator
Rectangle {
anchors.centerIn: parent
width: checked ? 10 : 4
height: checked ? 10 : 4
radius: Appearance?.rounding.full
color: Appearance?.colors.colPrimary
opacity: checked ? 1 : 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on width {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
}
// Hover
Rectangle {
anchors.centerIn: parent
width: root.hovered ? 40 : 20
height: root.hovered ? 40 : 20
radius: Appearance?.rounding.full
color: Appearance?.m3colors.m3onSurface
opacity: root.hovered ? 0.1 : 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on width {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
}
}
StyledText {
text: root.description
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
wrapMode: Text.Wrap
color: Appearance?.m3colors.m3onSurface
}
}
}
@@ -0,0 +1,14 @@
import QtQuick
import QtQuick.Effects
import "root:/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,113 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/services"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
// Material 3 slider. See https://m3.material.io/components/sliders/overview
Slider {
id: root
property real scale: 0.85
property real backgroundDotSize: 4 * scale
property real backgroundDotMargins: 4 * scale
// property real handleMargins: 0 * scale
property real handleMargins: (root.pressed ? 0 : 2) * scale
property real handleWidth: (root.pressed ? 3 : 5) * scale
property real handleHeight: 44 * scale
property real handleLimit: root.backgroundDotMargins
property real trackHeight: 30 * scale
property color highlightColor: Appearance.colors.colPrimary
property color trackColor: Appearance.colors.colSecondaryContainer
property color handleColor: Appearance.m3colors.m3onSecondaryContainer
property real trackRadius: Appearance.rounding.verysmall * scale
property real unsharpenRadius: Appearance.rounding.unsharpen
property real limitedHandleRangeWidth: (root.availableWidth - handleWidth - root.handleLimit * 2)
property string tooltipContent: `${Math.round(value * 100)}%`
Layout.fillWidth: true
from: 0
to: 1
Behavior on value { // This makes the adjusted value (like volume) shift smoothly
SmoothedAnimation {
velocity: Appearance.animation.elementMoveFast.velocity
}
}
Behavior on handleMargins {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
MouseArea {
anchors.fill: parent
onPressed: (mouse) => mouse.accepted = false
cursorShape: root.pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor
}
background: Item {
anchors.verticalCenter: parent.verticalCenter
implicitHeight: trackHeight
// Fill left
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: root.handleLimit * 2 + root.visualPosition * root.limitedHandleRangeWidth - (root.handleMargins + root.handleWidth / 2)
height: trackHeight
color: root.highlightColor
topLeftRadius: root.trackRadius
bottomLeftRadius: root.trackRadius
topRightRadius: root.unsharpenRadius
bottomRightRadius: root.unsharpenRadius
}
// Fill right
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
width: root.handleLimit * 2 + (1 - root.visualPosition) * root.limitedHandleRangeWidth - (root.handleMargins + root.handleWidth / 2)
height: trackHeight
color: root.trackColor
topLeftRadius: root.unsharpenRadius
bottomLeftRadius: root.unsharpenRadius
topRightRadius: root.trackRadius
bottomRightRadius: root.trackRadius
}
// Dot at the end
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: root.backgroundDotMargins
width: root.backgroundDotSize
height: root.backgroundDotSize
radius: Appearance.rounding.full
color: root.handleColor
}
}
handle: Rectangle {
id: handle
x: root.leftPadding + root.handleLimit + root.visualPosition * root.limitedHandleRangeWidth
y: root.topPadding + root.availableHeight / 2 - height / 2
implicitWidth: root.handleWidth
implicitHeight: root.handleHeight
radius: Appearance.rounding.full
color: root.handleColor
Behavior on implicitWidth {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
StyledToolTip {
extraVisibleCondition: root.pressed
content: root.tooltipContent
}
}
}
@@ -0,0 +1,92 @@
import "root:/modules/common"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
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
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 "root:/modules/common/"
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
/**
* Material 3 switch. See https://m3.material.io/components/switch/overview
*/
Switch {
id: root
property real scale: 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,15 @@
import "root:/modules/common"
import QtQuick
import QtQuick.Layouts
Text {
renderType: Text.NativeRendering
verticalAlignment: Text.AlignVCenter
font {
hintingPreference: Font.PreferFullHinting
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
}
color: Appearance?.m3colors.m3onBackground ?? "black"
linkColor: Appearance?.m3colors.m3primary
}
@@ -0,0 +1,18 @@
import "root:/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,17 @@
import "root:/modules/common"
import QtQuick
import QtQuick.Controls
/**
* Does not include visual layout, but includes the easily neglected colors.
*/
TextInput {
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,60 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ToolTip {
id: root
property string content
property bool extraVisibleCondition: true
property bool alternativeVisibleCondition: false
property bool internalVisibleCondition: {
const ans = (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition
return ans
}
verticalPadding: 5
horizontalPadding: 10
opacity: internalVisibleCondition ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
background: null
contentItem: Item {
id: contentItemBackground
implicitWidth: tooltipTextObject.width + 2 * root.horizontalPadding
implicitHeight: tooltipTextObject.height + 2 * root.verticalPadding
Rectangle {
id: backgroundRectangle
anchors.bottom: contentItemBackground.bottom
anchors.horizontalCenter: contentItemBackground.horizontalCenter
color: Appearance?.colors.colTooltip ?? "#3C4043"
radius: Appearance?.rounding.verysmall ?? 7
width: internalVisibleCondition ? (tooltipTextObject.width + 2 * padding) : 0
height: internalVisibleCondition ? (tooltipTextObject.height + 2 * padding) : 0
clip: true
Behavior on width {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
StyledText {
id: tooltipTextObject
anchors.centerIn: parent
text: content
font.pixelSize: Appearance?.font.pixelSize.smaller ?? 14
font.hintingPreference: Font.PreferNoHinting // Prevent shaky text
color: Appearance?.colors.colOnTooltip ?? "#FFFFFF"
wrapMode: Text.Wrap
}
}
}
}
@@ -0,0 +1,47 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/**
* A container that supports GroupButton children for bounciness.
* See https://m3.material.io/components/button-groups/overview
*/
Rectangle {
id: root
default property alias content: columnLayout.data
property real spacing: 5
property real padding: 0
property int clickIndex: columnLayout.clickIndex
property real contentHeight: {
let total = 0;
for (let i = 0; i < columnLayout.children.length; ++i) {
const child = columnLayout.children[i];
total += child.baseHeight ?? child.implicitHeight ?? child.height;
}
return total + columnLayout.spacing * (columnLayout.children.length - 1);
}
topLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[0].radius + padding) :
Appearance?.rounding?.small
topRightRadius: topLeftRadius
bottomLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[columnLayout.children.length - 1].radius + padding) :
Appearance?.rounding?.small
bottomRightRadius: bottomLeftRadius
color: "transparent"
height: root.contentHeight + padding * 2
implicitWidth: columnLayout.implicitWidth + padding * 2
implicitHeight: root.contentHeight + padding * 2
children: [ColumnLayout {
id: columnLayout
anchors.fill: parent
anchors.margins: root.padding
spacing: root.spacing
property int clickIndex: -1
}]
}
@@ -0,0 +1,78 @@
import "root:/modules/common"
import "root:/modules/common/widgets"
import "root:/services"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Io
Canvas { // Visualizer
id: root
property list<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,77 @@
/**
* @param { string } summary
* @returns { string }
*/
function findSuitableMaterialSymbol(summary = "") {
const defaultType = 'chat';
if(summary.length === 0) return defaultType;
const keywordsToTypes = {
'reboot': 'restart_alt',
'recording': 'screen_record',
'battery': 'power',
'power': 'power',
'screenshot': 'screenshot_monitor',
'welcome': 'waving_hand',
'time': 'scheduleb',
'installed': 'download',
'configuration reloaded': 'reset_wrench',
'config': 'reset_wrench',
'update': 'update',
'ai response': 'neurology',
'control': 'settings',
'upscale': 'compare',
'install': 'deployed_code_update',
'startswith:file': 'folder_copy', // Declarative startsWith check
};
const lowerSummary = summary.toLowerCase();
for (const [keyword, type] of Object.entries(keywordsToTypes)) {
if (keyword.startsWith('startswith:')) {
const startsWithKeyword = keyword.replace('startswith:', '');
if (lowerSummary.startsWith(startsWithKeyword)) {
return type;
}
} else if (lowerSummary.includes(keyword)) {
return type;
}
}
return defaultType;
}
/**
* @param { number | string | Date } timestamp
* @returns { string }
*/
const getFriendlyNotifTimeString = (timestamp) => {
if (!timestamp) return '';
const messageTime = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - messageTime.getTime();
// Less than 1 minute
if (diffMs < 60000)
return 'Now';
// Same day - show relative time
if (messageTime.toDateString() === now.toDateString()) {
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
if (diffHours > 0) {
return `${diffHours}h`;
} else {
return `${diffMinutes}m`;
}
}
// Yesterday
if (messageTime.toDateString() === new Date(now.getTime() - 86400000).toDateString())
return 'Yesterday';
// Older dates
return Qt.formatDateTime(messageTime, "MMMM dd");
};