1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 21:42:51 -05:00

switch hto monorepo structure

This commit is contained in:
bbedward
2025-11-12 17:18:45 -05:00
parent 6013c994a6
commit 24e800501a
768 changed files with 76284 additions and 221 deletions

View File

@@ -0,0 +1,82 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Widgets
import qs.Common
import qs.Services
Item {
id: root
required property string iconValue
required property int iconSize
property string fallbackText: "A"
property color iconColor: Theme.surfaceText
property color fallbackBackgroundColor: Theme.surfaceLight
property color fallbackTextColor: Theme.primary
property real materialIconSizeAdjustment: Theme.spacingM
property real unicodeIconScale: 0.7
property real fallbackTextScale: 0.4
property alias iconMargins: iconImg.anchors.margins
property real fallbackLeftMargin: 0
property real fallbackRightMargin: 0
property real fallbackTopMargin: 0
property real fallbackBottomMargin: 0
readonly property bool isMaterial: iconValue.startsWith("material:")
readonly property bool isUnicode: iconValue.startsWith("unicode:")
readonly property string materialName: isMaterial ? iconValue.substring(9) : ""
readonly property string unicodeChar: isUnicode ? iconValue.substring(8) : ""
readonly property string iconPath: isMaterial || isUnicode ? "" : Quickshell.iconPath(iconValue, true) || DesktopService.resolveIconPath(iconValue)
visible: iconValue !== undefined && iconValue !== ""
DankIcon {
anchors.centerIn: parent
name: root.materialName
size: root.iconSize - root.materialIconSizeAdjustment
color: root.iconColor
visible: root.isMaterial
}
StyledText {
anchors.centerIn: parent
text: root.unicodeChar
font.pixelSize: root.iconSize * root.unicodeIconScale
color: root.iconColor
visible: root.isUnicode
}
IconImage {
id: iconImg
anchors.fill: parent
source: root.iconPath
smooth: true
asynchronous: true
visible: !root.isMaterial && !root.isUnicode && status === Image.Ready
}
Rectangle {
id: fallbackRect
anchors.fill: parent
anchors.leftMargin: root.fallbackLeftMargin
anchors.rightMargin: root.fallbackRightMargin
anchors.topMargin: root.fallbackTopMargin
anchors.bottomMargin: root.fallbackBottomMargin
visible: !root.isMaterial && !root.isUnicode && iconImg.status !== Image.Ready
color: root.fallbackBackgroundColor
radius: Theme.cornerRadius
border.width: 0
border.color: Theme.primarySelected
StyledText {
anchors.centerIn: parent
text: root.fallbackText
font.pixelSize: root.iconSize * root.fallbackTextScale
color: root.fallbackTextColor
font.weight: Font.Bold
}
}
}

View File

@@ -0,0 +1,104 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Widgets
Rectangle {
id: root
required property var model
required property int index
required property var gridView
property int cellWidth: 120
property int cellHeight: 120
property int cellPadding: 8
property int minIconSize: 32
property int maxIconSize: 64
property real iconSizeRatio: 0.5
property bool hoverUpdatesSelection: true
property bool keyboardNavigationActive: false
property int currentIndex: -1
property bool isPlugin: model?.isPlugin || false
property real mouseAreaLeftMargin: 0
property real mouseAreaRightMargin: 0
property real mouseAreaBottomMargin: 0
property real iconFallbackLeftMargin: 0
property real iconFallbackRightMargin: 0
property real iconFallbackBottomMargin: 0
property real iconMaterialSizeAdjustment: 0
property real iconUnicodeScale: 0.8
signal itemClicked(int index, var modelData)
signal itemRightClicked(int index, var modelData, real mouseX, real mouseY)
signal keyboardNavigationReset()
width: cellWidth - cellPadding
height: cellHeight - cellPadding
radius: Theme.cornerRadius
color: currentIndex === index ? Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) : mouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
AppIconRenderer {
property int computedIconSize: Math.min(root.maxIconSize, Math.max(root.minIconSize, root.cellWidth * root.iconSizeRatio))
width: computedIconSize
height: computedIconSize
anchors.horizontalCenter: parent.horizontalCenter
iconValue: model.icon && model.icon !== "" ? model.icon : model.startupClass
iconSize: computedIconSize
fallbackText: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A"
materialIconSizeAdjustment: root.iconMaterialSizeAdjustment
unicodeIconScale: root.iconUnicodeScale
fallbackTextScale: Math.min(28, computedIconSize * 0.5) / computedIconSize
iconMargins: 0
fallbackLeftMargin: root.iconFallbackLeftMargin
fallbackRightMargin: root.iconFallbackRightMargin
fallbackBottomMargin: root.iconFallbackBottomMargin
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
width: root.cellWidth - 12
text: model.name || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
maximumLineCount: 1
wrapMode: Text.NoWrap
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
anchors.leftMargin: root.mouseAreaLeftMargin
anchors.rightMargin: root.mouseAreaRightMargin
anchors.bottomMargin: root.mouseAreaBottomMargin
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
z: 10
onEntered: {
if (root.hoverUpdatesSelection && !root.keyboardNavigationActive)
root.gridView.currentIndex = root.index
}
onPositionChanged: {
root.keyboardNavigationReset()
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
root.itemClicked(root.index, root.model)
} else if (mouse.button === Qt.RightButton && !root.isPlugin) {
const globalPos = mapToItem(null, mouse.x, mouse.y)
root.itemRightClicked(root.index, root.model, globalPos.x, globalPos.y)
}
}
}
}

View File

@@ -0,0 +1,114 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Widgets
Rectangle {
id: root
required property var model
required property int index
required property var listView
property int itemHeight: 60
property int iconSize: 40
property bool showDescription: true
property bool hoverUpdatesSelection: true
property bool keyboardNavigationActive: false
property bool isCurrentItem: false
property bool isPlugin: model?.isPlugin || false
property real mouseAreaLeftMargin: 0
property real mouseAreaRightMargin: 0
property real mouseAreaBottomMargin: 0
property real iconMargins: 0
property real iconFallbackLeftMargin: 0
property real iconFallbackRightMargin: 0
property real iconFallbackBottomMargin: 0
property real iconMaterialSizeAdjustment: Theme.spacingM
property real iconUnicodeScale: 0.7
signal itemClicked(int index, var modelData)
signal itemRightClicked(int index, var modelData, real mouseX, real mouseY)
signal keyboardNavigationReset()
width: listView.width
height: itemHeight
radius: Theme.cornerRadius
color: isCurrentItem ? Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) : mouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingL
AppIconRenderer {
width: root.iconSize
height: root.iconSize
anchors.verticalCenter: parent.verticalCenter
iconValue: model.icon && model.icon !== "" ? model.icon : model.startupClass
iconSize: root.iconSize
fallbackText: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A"
iconMargins: root.iconMargins
fallbackLeftMargin: root.iconFallbackLeftMargin
fallbackRightMargin: root.iconFallbackRightMargin
fallbackBottomMargin: root.iconFallbackBottomMargin
materialIconSizeAdjustment: root.iconMaterialSizeAdjustment
unicodeIconScale: root.iconUnicodeScale
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: (model.icon !== undefined && model.icon !== "") ? (parent.width - root.iconSize - Theme.spacingL) : parent.width
spacing: Theme.spacingXS
StyledText {
width: parent.width
text: model.name || ""
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
StyledText {
width: parent.width
text: model.comment || "Application"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
elide: Text.ElideRight
maximumLineCount: 1
visible: root.showDescription && model.comment && model.comment.length > 0
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
anchors.leftMargin: root.mouseAreaLeftMargin
anchors.rightMargin: root.mouseAreaRightMargin
anchors.bottomMargin: root.mouseAreaBottomMargin
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
z: 10
onEntered: {
if (root.hoverUpdatesSelection && !root.keyboardNavigationActive)
root.listView.currentIndex = root.index
}
onPositionChanged: {
root.keyboardNavigationReset()
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
root.itemClicked(root.index, root.model)
} else if (mouse.button === Qt.RightButton && !root.isPlugin) {
const globalPos = mapToItem(null, mouse.x, mouse.y)
root.itemRightClicked(root.index, root.model, globalPos.x, globalPos.y)
}
}
}
}

View File

@@ -0,0 +1,57 @@
import QtQuick
import Quickshell.Io
import qs.Common
Image {
id: root
property string imagePath: ""
property string imageHash: ""
property int maxCacheSize: 512
readonly property string cachePath: imageHash ? `${Paths.stringify(Paths.imagecache)}/${imageHash}@${maxCacheSize}x${maxCacheSize}.png` : ""
asynchronous: true
fillMode: Image.PreserveAspectCrop
sourceSize.width: maxCacheSize
sourceSize.height: maxCacheSize
smooth: true
onImagePathChanged: {
if (!imagePath) {
source = ""
imageHash = ""
return
}
hashProcess.command = ["sha256sum", Paths.strip(imagePath)]
hashProcess.running = true
}
onCachePathChanged: {
if (!imageHash || !cachePath)
return
Paths.mkdir(Paths.imagecache)
source = cachePath
}
onStatusChanged: {
if (source == cachePath && status === Image.Error) {
source = imagePath
return
}
if (source != imagePath || status !== Image.Ready || !imageHash || !cachePath)
return
Paths.mkdir(Paths.imagecache)
const grabPath = cachePath
if (visible && width > 0 && height > 0 && Window.window && Window.window.visible)
grabToImage(res => {
return res.saveToFile(grabPath)
})
}
Process {
id: hashProcess
stdout: StdioCollector {
onStreamFinished: root.imageHash = text.split(" ")[0]
}
}
}

View File

@@ -0,0 +1,38 @@
import QtQuick
import qs.Common
import qs.Widgets
StyledRect {
id: root
property string iconName: ""
property int iconSize: Theme.iconSize - 4
property color iconColor: Theme.surfaceText
property color backgroundColor: "transparent"
property bool circular: true
property int buttonSize: 32
signal clicked
signal entered
signal exited
width: buttonSize
height: buttonSize
radius: Theme.cornerRadius
color: backgroundColor
DankIcon {
anchors.centerIn: parent
name: root.iconName
size: root.iconSize
color: root.iconColor
}
StateLayer {
stateColor: Theme.primary
cornerRadius: root.radius
onClicked: root.clicked()
onEntered: root.entered()
onExited: root.exited()
}
}

View File

@@ -0,0 +1,168 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import Quickshell.Services.Mpris
import qs.Common
import qs.Services
Item {
id: root
property MprisPlayer activePlayer
property string artUrl: (activePlayer?.trackArtUrl) || ""
property string lastValidArtUrl: ""
property alias albumArtStatus: albumArt.imageStatus
property real albumSize: Math.min(width, height) * 0.88
property bool showAnimation: true
property real animationScale: 1.0
onArtUrlChanged: {
if (artUrl && albumArt.status !== Image.Error) {
lastValidArtUrl = artUrl
}
}
Loader {
active: activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation
sourceComponent: Component {
Ref {
service: CavaService
}
}
}
Shape {
id: morphingBlob
width: parent.width * 1.1
height: parent.height * 1.1
anchors.centerIn: parent
visible: activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation
asynchronous: false
antialiasing: true
preferredRendererType: Shape.CurveRenderer
z: 0
layer.enabled: false
readonly property real centerX: width / 2
readonly property real centerY: height / 2
readonly property real baseRadius: Math.min(width, height) * 0.41 * root.animationScale
readonly property int segments: 28
property var audioLevels: {
if (!CavaService.cavaAvailable || CavaService.values.length === 0) {
return [0.5, 0.3, 0.7, 0.4, 0.6, 0.5, 0.8, 0.2, 0.9, 0.6]
}
return CavaService.values
}
property var smoothedLevels: [0.5, 0.3, 0.7, 0.4, 0.6, 0.5, 0.8, 0.2, 0.9, 0.6]
property var cubics: []
onAudioLevelsChanged: updatePath()
FrameAnimation {
running: morphingBlob.visible
onTriggered: morphingBlob.updatePath()
}
Component {
id: cubicSegment
PathCubic {}
}
Component.onCompleted: {
shapePath.pathElements.push(Qt.createQmlObject(
'import QtQuick; import QtQuick.Shapes; PathMove {}', shapePath
))
for (let i = 0; i < segments; i++) {
const seg = cubicSegment.createObject(shapePath)
shapePath.pathElements.push(seg)
cubics.push(seg)
}
updatePath()
}
function expSmooth(prev, next, alpha) {
return prev + alpha * (next - prev)
}
function updatePath() {
if (cubics.length === 0) return
for (let i = 0; i < Math.min(smoothedLevels.length, audioLevels.length); i++) {
smoothedLevels[i] = expSmooth(smoothedLevels[i], audioLevels[i], 0.35)
}
const points = []
for (let i = 0; i < segments; i++) {
const angle = (i / segments) * 2 * Math.PI
const audioIndex = i % Math.min(smoothedLevels.length, 10)
const rawLevel = smoothedLevels[audioIndex] || 0
const scaledLevel = Math.sqrt(Math.min(Math.max(rawLevel, 0), 100) / 100) * 100
const normalizedLevel = scaledLevel / 100
const audioLevel = Math.max(0.15, normalizedLevel) * 0.5
const radius = baseRadius * (1.0 + audioLevel)
const x = centerX + Math.cos(angle) * radius
const y = centerY + Math.sin(angle) * radius
points.push({x: x, y: y})
}
const startMove = shapePath.pathElements[0]
startMove.x = points[0].x
startMove.y = points[0].y
const tension = 0.5
for (let i = 0; i < segments; i++) {
const p0 = points[(i - 1 + segments) % segments]
const p1 = points[i]
const p2 = points[(i + 1) % segments]
const p3 = points[(i + 2) % segments]
const c1x = p1.x + (p2.x - p0.x) * tension / 3
const c1y = p1.y + (p2.y - p0.y) * tension / 3
const c2x = p2.x - (p3.x - p1.x) * tension / 3
const c2y = p2.y - (p3.y - p1.y) * tension / 3
const seg = cubics[i]
seg.control1X = c1x
seg.control1Y = c1y
seg.control2X = c2x
seg.control2Y = c2y
seg.x = p2.x
seg.y = p2.y
}
}
ShapePath {
id: shapePath
fillColor: Theme.primary
strokeColor: "transparent"
strokeWidth: 0
joinStyle: ShapePath.RoundJoin
fillRule: ShapePath.WindingFill
}
}
DankCircularImage {
id: albumArt
width: albumSize
height: albumSize
anchors.centerIn: parent
z: 1
imageSource: artUrl || lastValidArtUrl || ""
fallbackIcon: "album"
border.color: Theme.primary
border.width: 2
onImageSourceChanged: {
if (imageSource && imageStatus !== Image.Error) {
lastValidArtUrl = imageSource
}
}
}
}

View File

@@ -0,0 +1,64 @@
import QtQuick
import QtQuick.Effects
import qs.Common
Item {
id: root
anchors.fill: parent
property string screenName: ""
property bool isColorWallpaper: {
var currentWallpaper = SessionData.getMonitorWallpaper(screenName)
return currentWallpaper && currentWallpaper.startsWith("#")
}
Rectangle {
anchors.fill: parent
color: isColorWallpaper ? SessionData.getMonitorWallpaper(screenName) : Theme.background
}
Rectangle {
x: parent.width * 0.7
y: -parent.height * 0.3
width: parent.width * 0.8
height: parent.height * 1.5
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15)
rotation: 35
visible: !isColorWallpaper
}
Rectangle {
x: parent.width * 0.85
y: -parent.height * 0.2
width: parent.width * 0.4
height: parent.height * 1.2
color: Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.12)
rotation: 35
visible: !isColorWallpaper
}
Image {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.leftMargin: Theme.spacingXL * 2
anchors.bottomMargin: Theme.spacingXL * 2
width: 200
height: width * (569.94629 / 506.50931)
fillMode: Image.PreserveAspectFit
smooth: true
mipmap: true
asynchronous: true
source: "file://" + Theme.shellDir + "/assets/danklogonormal.svg"
opacity: 0.25
visible: !isColorWallpaper
layer.enabled: true
layer.smooth: true
layer.mipmap: true
layer.effect: MultiEffect {
saturation: 0
colorization: 1
colorizationColor: Theme.primary
}
}
}

View File

@@ -0,0 +1,75 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property string text: ""
property string iconName: ""
property int iconSize: Theme.iconSizeSmall
property bool enabled: true
property bool hovered: mouseArea.containsMouse
property bool pressed: mouseArea.pressed
property color backgroundColor: Theme.primary
property color textColor: Theme.primaryText
property int buttonHeight: 40
property int horizontalPadding: Theme.spacingL
signal clicked()
width: Math.max(contentRow.implicitWidth + horizontalPadding * 2, 64)
height: buttonHeight
radius: Theme.cornerRadius
color: backgroundColor
opacity: enabled ? 1 : 0.4
Rectangle {
id: stateLayer
anchors.fill: parent
radius: parent.radius
color: {
if (pressed) return Theme.primaryPressed
if (hovered) return Theme.primaryHover
return "transparent"
}
Behavior on color {
ColorAnimation {
duration: Theme.shorterDuration
easing.type: Theme.standardEasing
}
}
}
Row {
id: contentRow
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: root.iconName
size: root.iconSize
color: root.textColor
visible: root.iconName !== ""
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: root.text
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: root.textColor
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: root.enabled
onClicked: root.clicked()
}
}

View File

@@ -0,0 +1,213 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
Flow {
id: root
property var model: []
property int currentIndex: -1
property string selectionMode: "single"
property bool multiSelect: selectionMode === "multi"
property var initialSelection: []
property var currentSelection: initialSelection
property bool checkEnabled: true
property int buttonHeight: 40
property int minButtonWidth: 64
property int buttonPadding: Theme.spacingL
property int checkIconSize: Theme.iconSizeSmall
property int textSize: Theme.fontSizeMedium
signal selectionChanged(int index, bool selected)
signal animationCompleted()
spacing: Theme.spacingXS
Timer {
id: animationTimer
interval: Theme.shortDuration
onTriggered: root.animationCompleted()
}
function isSelected(index) {
if (multiSelect) {
return repeater.itemAt(index)?.selected || false
}
return index === currentIndex
}
function selectItem(index) {
if (multiSelect) {
const modelValue = model[index]
let newSelection = [...currentSelection]
const isCurrentlySelected = newSelection.includes(modelValue)
if (isCurrentlySelected) {
newSelection = newSelection.filter(item => item !== modelValue)
} else {
newSelection.push(modelValue)
}
currentSelection = newSelection
selectionChanged(index, !isCurrentlySelected)
animationTimer.restart()
} else {
const oldIndex = currentIndex
currentIndex = index
selectionChanged(index, true)
if (oldIndex !== index && oldIndex >= 0) {
selectionChanged(oldIndex, false)
}
animationTimer.restart()
}
}
Repeater {
id: repeater
model: ScriptModel {
values: root.model
}
delegate: Rectangle {
id: segment
property bool selected: multiSelect ? root.currentSelection.includes(modelData) : (index === root.currentIndex)
property bool hovered: mouseArea.containsMouse
property bool pressed: mouseArea.pressed
property bool isFirst: index === 0
property bool isLast: index === repeater.count - 1
property bool prevSelected: index > 0 ? root.isSelected(index - 1) : false
property bool nextSelected: index < repeater.count - 1 ? root.isSelected(index + 1) : false
width: Math.max(contentItem.implicitWidth + root.buttonPadding * 2, root.minButtonWidth) + (selected ? 4 : 0)
height: root.buttonHeight
color: selected ? Theme.primary : Theme.surfaceVariant
border.color: "transparent"
border.width: 0
topLeftRadius: (isFirst || selected) ? Theme.cornerRadius : 4
bottomLeftRadius: (isFirst || selected) ? Theme.cornerRadius : 4
topRightRadius: (isLast || selected) ? Theme.cornerRadius : 4
bottomRightRadius: (isLast || selected) ? Theme.cornerRadius : 4
Behavior on width {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on topLeftRadius {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on topRightRadius {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on bottomLeftRadius {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on bottomRightRadius {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Rectangle {
id: stateLayer
anchors.fill: parent
topLeftRadius: parent.topLeftRadius
bottomLeftRadius: parent.bottomLeftRadius
topRightRadius: parent.topRightRadius
bottomRightRadius: parent.bottomRightRadius
color: {
if (pressed) return selected ? Theme.primaryPressed : Theme.surfaceTextHover
if (hovered) return selected ? Theme.primaryHover : Theme.surfaceTextHover
return "transparent"
}
Behavior on color {
ColorAnimation {
duration: Theme.shorterDuration
easing.type: Theme.standardEasing
}
}
}
Item {
id: contentItem
anchors.centerIn: parent
implicitWidth: contentRow.implicitWidth
implicitHeight: contentRow.implicitHeight
Row {
id: contentRow
spacing: Theme.spacingS
DankIcon {
id: checkIcon
name: "check"
size: root.checkIconSize
color: segment.selected ? Theme.primaryText : Theme.surfaceVariantText
visible: root.checkEnabled && segment.selected
opacity: segment.selected ? 1 : 0
scale: segment.selected ? 1 : 0.6
anchors.verticalCenter: parent.verticalCenter
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on scale {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
}
StyledText {
id: buttonText
text: typeof modelData === "string" ? modelData : modelData.text || ""
font.pixelSize: root.textSize
font.weight: segment.selected ? Font.Medium : Font.Normal
color: segment.selected ? Theme.primaryText : Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.selectItem(index)
}
}
}
}

View File

@@ -0,0 +1,79 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import qs.Common
import qs.Widgets
Rectangle {
id: root
property string imageSource: ""
property string fallbackIcon: "notifications"
property string fallbackText: ""
property bool hasImage: imageSource !== ""
property alias imageStatus: internalImage.status
radius: width / 2
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
border.color: "transparent"
border.width: 0
Image {
id: internalImage
anchors.fill: parent
anchors.margins: 2
asynchronous: true
fillMode: Image.PreserveAspectCrop
smooth: true
mipmap: true
cache: true
visible: false
source: root.imageSource
}
MultiEffect {
anchors.fill: parent
anchors.margins: 2
source: internalImage
maskEnabled: true
maskSource: circularMask
visible: internalImage.status === Image.Ready && root.imageSource !== ""
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
Item {
id: circularMask
anchors.centerIn: parent
width: parent.width - 4
height: parent.height - 4
layer.enabled: true
layer.smooth: true
visible: false
Rectangle {
anchors.fill: parent
radius: width / 2
color: "black"
antialiasing: true
}
}
DankIcon {
anchors.centerIn: parent
name: root.fallbackIcon
size: parent.width * 0.5
color: Theme.surfaceVariantText
visible: (internalImage.status !== Image.Ready || root.imageSource === "") && root.fallbackIcon !== ""
}
StyledText {
anchors.centerIn: parent
visible: root.imageSource === "" && root.fallbackIcon === "" && root.fallbackText !== ""
text: root.fallbackText
font.pixelSize: Math.max(12, parent.width * 0.5)
font.weight: Font.Bold
color: Theme.surfaceVariantText
}
}

View File

@@ -0,0 +1,375 @@
import "../Common/fzf.js" as Fzf
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import qs.Common
import qs.Widgets
Item {
id: root
property string text: ""
property string description: ""
property string currentValue: ""
property var options: []
property var optionIcons: []
property bool enableFuzzySearch: false
property int popupWidthOffset: 0
property int maxPopupHeight: 400
property bool openUpwards: false
property int popupWidth: 0
property bool alignPopupRight: false
property int dropdownWidth: 200
property bool compactMode: text === "" && description === ""
property bool addHorizontalPadding: false
signal valueChanged(string value)
width: compactMode ? dropdownWidth : parent.width
implicitHeight: compactMode ? 40 : Math.max(60, labelColumn.implicitHeight + Theme.spacingM)
Component.onDestruction: {
const popup = dropdownMenu
if (popup && popup.visible) {
popup.close()
}
}
Column {
id: labelColumn
anchors.left: parent.left
anchors.right: dropdown.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: root.addHorizontalPadding ? Theme.spacingM : 0
anchors.rightMargin: Theme.spacingL
spacing: Theme.spacingXS
visible: !root.compactMode
StyledText {
text: root.text
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: description.length > 0
wrapMode: Text.WordWrap
width: parent.width
}
}
Rectangle {
id: dropdown
width: root.compactMode ? parent.width : (root.popupWidth === -1 ? undefined : (root.popupWidth > 0 ? root.popupWidth : root.dropdownWidth))
height: 40
anchors.right: parent.right
anchors.rightMargin: root.addHorizontalPadding && !root.compactMode ? Theme.spacingM : 0
anchors.verticalCenter: parent.verticalCenter
radius: Theme.cornerRadius
color: dropdownArea.containsMouse || dropdownMenu.visible ? Theme.surfaceContainerHigh : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
border.color: dropdownMenu.visible ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
border.width: dropdownMenu.visible ? 2 : 1
MouseArea {
id: dropdownArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (dropdownMenu.visible) {
dropdownMenu.close()
return
}
dropdownMenu.searchQuery = ""
dropdownMenu.updateFilteredOptions()
dropdownMenu.open()
const pos = dropdown.mapToItem(Overlay.overlay, 0, 0)
const popupWidth = dropdownMenu.width
const popupHeight = dropdownMenu.height
const overlayHeight = Overlay.overlay.height
if (root.openUpwards || pos.y + dropdown.height + popupHeight + 4 > overlayHeight) {
if (root.alignPopupRight) {
dropdownMenu.x = pos.x + dropdown.width - popupWidth
} else {
dropdownMenu.x = pos.x - (root.popupWidthOffset / 2)
}
dropdownMenu.y = pos.y - popupHeight - 4
} else {
if (root.alignPopupRight) {
dropdownMenu.x = pos.x + dropdown.width - popupWidth
} else {
dropdownMenu.x = pos.x - (root.popupWidthOffset / 2)
}
dropdownMenu.y = pos.y + dropdown.height + 4
}
if (root.enableFuzzySearch && searchField.visible) {
searchField.forceActiveFocus()
}
}
}
Row {
id: contentRow
anchors.left: parent.left
anchors.right: expandIcon.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: {
const currentIndex = root.options.indexOf(root.currentValue)
return currentIndex >= 0 && root.optionIcons.length > currentIndex ? root.optionIcons[currentIndex] : ""
}
size: 18
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
visible: name !== ""
}
StyledText {
text: root.currentValue
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
width: contentRow.width - (contentRow.children[0].visible ? contentRow.children[0].width + contentRow.spacing : 0)
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
}
DankIcon {
id: expandIcon
name: dropdownMenu.visible ? "expand_less" : "expand_more"
size: 20
color: Theme.surfaceText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: Theme.spacingS
Behavior on rotation {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
Popup {
id: dropdownMenu
property string searchQuery: ""
property var filteredOptions: []
property int selectedIndex: -1
property var fzfFinder: new Fzf.Finder(root.options, {
"selector": option => option,
"limit": 50,
"casing": "case-insensitive"
})
function updateFilteredOptions() {
if (!root.enableFuzzySearch || searchQuery.length === 0) {
filteredOptions = root.options
selectedIndex = -1
return
}
const results = fzfFinder.find(searchQuery)
filteredOptions = results.map(result => result.item)
selectedIndex = -1
}
function selectNext() {
if (filteredOptions.length === 0) {
return
}
selectedIndex = (selectedIndex + 1) % filteredOptions.length
listView.positionViewAtIndex(selectedIndex, ListView.Contain)
}
function selectPrevious() {
if (filteredOptions.length === 0) {
return
}
selectedIndex = selectedIndex <= 0 ? filteredOptions.length - 1 : selectedIndex - 1
listView.positionViewAtIndex(selectedIndex, ListView.Contain)
}
function selectCurrent() {
if (selectedIndex < 0 || selectedIndex >= filteredOptions.length) {
return
}
root.currentValue = filteredOptions[selectedIndex]
root.valueChanged(filteredOptions[selectedIndex])
close()
}
parent: Overlay.overlay
width: root.popupWidth === -1 ? undefined : (root.popupWidth > 0 ? root.popupWidth : (dropdown.width + root.popupWidthOffset))
height: Math.min(root.maxPopupHeight, (root.enableFuzzySearch ? 54 : 0) + Math.min(filteredOptions.length, 10) * 36 + 16)
padding: 0
modal: true
dim: false
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: "transparent"
}
contentItem: Rectangle {
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 1)
border.color: Theme.primary
border.width: 2
radius: Theme.cornerRadius
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowBlur: 0.4
shadowColor: Theme.shadowStrong
shadowVerticalOffset: 4
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingS
Rectangle {
id: searchContainer
width: parent.width
height: 42
visible: root.enableFuzzySearch
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
DankTextField {
id: searchField
anchors.fill: parent
anchors.margins: 1
placeholderText: I18n.tr("Search...")
text: dropdownMenu.searchQuery
topPadding: Theme.spacingS
bottomPadding: Theme.spacingS
onTextChanged: {
dropdownMenu.searchQuery = text
dropdownMenu.updateFilteredOptions()
}
Keys.onDownPressed: dropdownMenu.selectNext()
Keys.onUpPressed: dropdownMenu.selectPrevious()
Keys.onReturnPressed: dropdownMenu.selectCurrent()
Keys.onEnterPressed: dropdownMenu.selectCurrent()
Keys.onPressed: event => {
if (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) {
dropdownMenu.selectNext()
event.accepted = true
} else if (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier) {
dropdownMenu.selectPrevious()
event.accepted = true
} else if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) {
dropdownMenu.selectNext()
event.accepted = true
} else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) {
dropdownMenu.selectPrevious()
event.accepted = true
}
}
}
}
Item {
width: 1
height: Theme.spacingXS
visible: root.enableFuzzySearch
}
DankListView {
id: listView
width: parent.width
height: parent.height - (root.enableFuzzySearch ? searchContainer.height + Theme.spacingXS : 0)
clip: true
model: ScriptModel {
values: dropdownMenu.filteredOptions
}
spacing: 2
interactive: true
flickDeceleration: 1500
maximumFlickVelocity: 2000
boundsBehavior: Flickable.DragAndOvershootBounds
boundsMovement: Flickable.FollowBoundsBehavior
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
delegate: Rectangle {
property bool isSelected: dropdownMenu.selectedIndex === index
property bool isCurrentValue: root.currentValue === modelData
property int optionIndex: root.options.indexOf(modelData)
width: ListView.view.width
height: 32
radius: Theme.cornerRadius
color: isSelected ? Theme.primaryHover : optionArea.containsMouse ? Theme.primaryHoverLight : "transparent"
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: optionIndex >= 0 && root.optionIcons.length > optionIndex ? root.optionIcons[optionIndex] : ""
size: 18
color: isCurrentValue ? Theme.primary : Theme.surfaceText
visible: name !== ""
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: modelData
font.pixelSize: Theme.fontSizeMedium
color: isCurrentValue ? Theme.primary : Theme.surfaceText
font.weight: isCurrentValue ? Font.Medium : Font.Normal
width: root.popupWidth > 0 ? undefined : (parent.parent.width - parent.x - Theme.spacingS)
elide: root.popupWidth > 0 ? Text.ElideNone : Text.ElideRight
wrapMode: Text.NoWrap
}
}
MouseArea {
id: optionArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.currentValue = modelData
root.valueChanged(modelData)
dropdownMenu.close()
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,173 @@
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Widgets
Flickable {
id: flickable
property real mouseWheelSpeed: 60
property real momentumVelocity: 0
property bool isMomentumActive: false
property real friction: 0.95
property real minMomentumVelocity: 50
property real maxMomentumVelocity: 2500
property bool _scrollBarActive: false
flickDeceleration: 1500
maximumFlickVelocity: 2000
boundsBehavior: Flickable.StopAtBounds
boundsMovement: Flickable.FollowBoundsBehavior
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
WheelHandler {
id: wheelHandler
property real touchpadSpeed: 1.8
property real momentumRetention: 0.92
property real lastWheelTime: 0
property real momentum: 0
property var velocitySamples: []
property bool sessionUsedMouseWheel: false
function startMomentum() {
flickable.isMomentumActive = true
momentumTimer.start()
}
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: event => {
vbar._scrollBarActive = true
vbar.hideTimer.restart()
const currentTime = Date.now()
const timeDelta = currentTime - lastWheelTime
lastWheelTime = currentTime
const deltaY = event.angleDelta.y
const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0
if (isMouseWheel) {
sessionUsedMouseWheel = true
momentumTimer.stop()
flickable.isMomentumActive = false
velocitySamples = []
momentum = 0
flickable.momentumVelocity = 0
const lines = Math.floor(Math.abs(deltaY) / 120)
const scrollAmount = (deltaY > 0 ? -lines : lines) * flickable.mouseWheelSpeed
let newY = flickable.contentY + scrollAmount
newY = Math.max(0, Math.min(flickable.contentHeight - flickable.height, newY))
if (flickable.flicking) {
flickable.cancelFlick()
}
flickable.contentY = newY
} else {
sessionUsedMouseWheel = false
momentumTimer.stop()
flickable.isMomentumActive = false
let delta = 0
if (event.pixelDelta.y !== 0) {
delta = event.pixelDelta.y * touchpadSpeed
} else {
delta = event.angleDelta.y / 8 * touchpadSpeed
}
velocitySamples.push({
"delta": delta,
"time": currentTime
})
velocitySamples = velocitySamples.filter(s => currentTime - s.time < 100)
if (velocitySamples.length > 1) {
const totalDelta = velocitySamples.reduce((sum, s) => sum + s.delta, 0)
const timeSpan = currentTime - velocitySamples[0].time
if (timeSpan > 0) {
flickable.momentumVelocity = Math.max(-flickable.maxMomentumVelocity, Math.min(flickable.maxMomentumVelocity, totalDelta / timeSpan * 1000))
}
}
if (event.pixelDelta.y !== 0 && timeDelta < 50) {
momentum = momentum * momentumRetention + delta * 0.15
delta += momentum
} else {
momentum = 0
}
let newY = flickable.contentY - delta
newY = Math.max(0, Math.min(flickable.contentHeight - flickable.height, newY))
if (flickable.flicking) {
flickable.cancelFlick()
}
flickable.contentY = newY
}
event.accepted = true
}
onActiveChanged: {
if (!active) {
if (!sessionUsedMouseWheel && Math.abs(flickable.momentumVelocity) >= flickable.minMomentumVelocity) {
startMomentum()
} else {
velocitySamples = []
flickable.momentumVelocity = 0
}
}
}
}
onMovementStarted: {
vbar._scrollBarActive = true
vbar.hideTimer.stop()
}
onMovementEnded: vbar.hideTimer.restart()
Timer {
id: momentumTimer
interval: 16
repeat: true
onTriggered: {
const newY = flickable.contentY - flickable.momentumVelocity * 0.016
const maxY = Math.max(0, flickable.contentHeight - flickable.height)
if (newY < 0 || newY > maxY) {
flickable.contentY = newY < 0 ? 0 : maxY
stop()
flickable.isMomentumActive = false
flickable.momentumVelocity = 0
return
}
flickable.contentY = newY
flickable.momentumVelocity *= flickable.friction
if (Math.abs(flickable.momentumVelocity) < 5) {
stop()
flickable.isMomentumActive = false
flickable.momentumVelocity = 0
}
}
}
NumberAnimation {
id: returnToBoundsAnimation
target: flickable
property: "contentY"
duration: 300
easing.type: Easing.OutQuad
}
ScrollBar.vertical: DankScrollbar {
id: vbar
}
}

View File

@@ -0,0 +1,163 @@
import QtQuick
import QtQuick.Controls
import qs.Widgets
GridView {
id: gridView
property real momentumVelocity: 0
property bool isMomentumActive: false
property real friction: 0.95
property real minMomentumVelocity: 50
property real maxMomentumVelocity: 2500
flickDeceleration: 1500
maximumFlickVelocity: 2000
boundsBehavior: Flickable.StopAtBounds
boundsMovement: Flickable.FollowBoundsBehavior
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
onMovementStarted: {
vbar._scrollBarActive = true
vbar.hideTimer.stop()
}
onMovementEnded: vbar.hideTimer.restart()
WheelHandler {
id: wheelHandler
property real mouseWheelSpeed: 60
property real touchpadSpeed: 1.8
property real momentumRetention: 0.92
property real lastWheelTime: 0
property real momentum: 0
property var velocitySamples: []
property bool sessionUsedMouseWheel: false
function startMomentum() {
isMomentumActive = true
momentumTimer.start()
}
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: event => {
vbar._scrollBarActive = true
vbar.hideTimer.restart()
const currentTime = Date.now()
const timeDelta = currentTime - lastWheelTime
lastWheelTime = currentTime
const deltaY = event.angleDelta.y
const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0
if (isMouseWheel) {
sessionUsedMouseWheel = true
momentumTimer.stop()
isMomentumActive = false
velocitySamples = []
momentum = 0
momentumVelocity = 0
const lines = Math.floor(Math.abs(deltaY) / 120)
const scrollAmount = (deltaY > 0 ? -lines : lines) * cellHeight * 0.35
let newY = contentY + scrollAmount
newY = Math.max(0, Math.min(contentHeight - height, newY))
if (flicking) {
cancelFlick()
}
contentY = newY
} else {
sessionUsedMouseWheel = false
momentumTimer.stop()
isMomentumActive = false
let delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y * touchpadSpeed : event.angleDelta.y / 120 * cellHeight * 1.2
velocitySamples.push({
"delta": delta,
"time": currentTime
})
velocitySamples = velocitySamples.filter(s => currentTime - s.time < 100)
if (velocitySamples.length > 1) {
const totalDelta = velocitySamples.reduce((sum, s) => sum + s.delta, 0)
const timeSpan = currentTime - velocitySamples[0].time
if (timeSpan > 0) {
momentumVelocity = Math.max(-maxMomentumVelocity, Math.min(maxMomentumVelocity, totalDelta / timeSpan * 1000))
}
}
if (event.pixelDelta.y !== 0 && timeDelta < 50) {
momentum = momentum * momentumRetention + delta * 0.15
delta += momentum
} else {
momentum = 0
}
let newY = contentY - delta
newY = Math.max(0, Math.min(contentHeight - height, newY))
if (flicking) {
cancelFlick()
}
contentY = newY
}
event.accepted = true
}
onActiveChanged: {
if (!active) {
if (!sessionUsedMouseWheel && Math.abs(momentumVelocity) >= minMomentumVelocity) {
startMomentum()
} else {
velocitySamples = []
momentumVelocity = 0
}
}
}
}
Timer {
id: momentumTimer
interval: 16
repeat: true
onTriggered: {
const newY = contentY - momentumVelocity * 0.016
const maxY = Math.max(0, contentHeight - height)
if (newY < 0 || newY > maxY) {
contentY = newY < 0 ? 0 : maxY
stop()
isMomentumActive = false
momentumVelocity = 0
return
}
contentY = newY
momentumVelocity *= friction
if (Math.abs(momentumVelocity) < 5) {
stop()
isMomentumActive = false
momentumVelocity = 0
}
}
}
NumberAnimation {
id: returnToBoundsAnimation
target: gridView
property: "contentY"
duration: 300
easing.type: Easing.OutQuad
}
ScrollBar.vertical: DankScrollbar {
id: vbar
}
}

View File

@@ -0,0 +1,69 @@
import QtQuick
import qs.Common
Item {
id: root
property alias name: icon.text
property alias size: icon.font.pixelSize
property alias color: icon.color
property bool filled: false
property real fill: filled ? 1.0 : 0.0
property int grade: Theme.isLightMode ? 0 : -25
property int weight: filled ? 500 : 400
implicitWidth: icon.implicitWidth
implicitHeight: icon.implicitHeight
signal rotationCompleted()
FontLoader {
id: materialSymbolsFont
source: Qt.resolvedUrl("../assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].ttf")
}
StyledText {
id: icon
anchors.fill: parent
font.family: materialSymbolsFont.name
font.pixelSize: Theme.fontSizeMedium
font.weight: root.weight
color: Theme.surfaceText
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
antialiasing: true
font.variableAxes: {
"FILL": root.fill.toFixed(1),
"GRAD": root.grade,
"opsz": 24,
"wght": root.weight
}
Behavior on font.weight {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
Behavior on fill {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Timer {
id: rotationTimer
interval: 16
repeat: false
onTriggered: root.rotationCompleted()
}
onRotationChanged: {
rotationTimer.restart()
}
}

View File

@@ -0,0 +1,291 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Widgets
Rectangle {
id: root
property string currentIcon: ""
property string iconType: "icon" // "icon" or "text"
signal iconSelected(string iconName, string iconType)
width: 240
height: 32
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: dropdownLoader.active ? Theme.primary : Theme.outline
border.width: 1
property var iconCategories: [{
"name": I18n.tr("Numbers"),
"icons": ["looks_one", "looks_two", "looks_3", "looks_4", "looks_5", "looks_6", "filter_1", "filter_2", "filter_3", "filter_4", "filter_5", "filter_6", "filter_7", "filter_8", "filter_9", "filter_9_plus", "plus_one", "exposure_plus_1", "exposure_plus_2"]
}, {
"name": I18n.tr("Workspace"),
"icons": ["work", "laptop", "desktop_windows", "folder", "view_module", "dashboard", "apps", "grid_view"]
}, {
"name": I18n.tr("Development"),
"icons": ["code", "terminal", "bug_report", "build", "engineering", "integration_instructions", "data_object", "schema", "api", "webhook"]
}, {
"name": I18n.tr("Communication"),
"icons": ["chat", "mail", "forum", "message", "video_call", "call", "contacts", "group", "notifications", "campaign"]
}, {
"name": I18n.tr("Media"),
"icons": ["music_note", "headphones", "mic", "videocam", "photo", "movie", "library_music", "album", "radio", "volume_up"]
}, {
"name": I18n.tr("System"),
"icons": ["memory", "storage", "developer_board", "monitor", "keyboard", "mouse", "battery_std", "wifi", "bluetooth", "security", "settings"]
}, {
"name": I18n.tr("Navigation"),
"icons": ["home", "arrow_forward", "arrow_back", "expand_more", "expand_less", "menu", "close", "search", "filter_list", "sort"]
}, {
"name": I18n.tr("Actions"),
"icons": ["add", "remove", "edit", "delete", "save", "download", "upload", "share", "content_copy", "content_paste", "content_cut", "undo", "redo"]
}, {
"name": I18n.tr("Status"),
"icons": ["check", "error", "warning", "info", "done", "pending", "schedule", "update", "sync", "offline_bolt"]
}, {
"name": I18n.tr("Fun"),
"icons": ["celebration", "cake", "star", "favorite", "pets", "sports_esports", "local_fire_department", "bolt", "auto_awesome", "diamond"]
}]
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: (root.iconType === "icon" && root.currentIcon) ? root.currentIcon : (root.iconType === "text" ? "text_fields" : "add")
size: 16
color: root.currentIcon ? Theme.surfaceText : Theme.outline
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: root.currentIcon ? root.currentIcon : I18n.tr("Choose icon")
font.pixelSize: Theme.fontSizeSmall
color: root.currentIcon ? Theme.surfaceText : Theme.outline
anchors.verticalCenter: parent.verticalCenter
width: 160
elide: Text.ElideRight
MouseArea {
anchors.fill: parent
onClicked: {
dropdownLoader.active = !dropdownLoader.active
}
}
}
}
DankIcon {
name: dropdownLoader.active ? "expand_less" : "expand_more"
size: 16
color: Theme.outline
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
}
Loader {
id: dropdownLoader
active: false
asynchronous: true
sourceComponent: PanelWindow {
id: dropdownPopup
visible: true
implicitWidth: 320
implicitHeight: Math.min(500, dropdownContent.implicitHeight + 32)
color: "transparent"
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
anchors {
top: true
left: true
right: true
bottom: true
}
// Top area - above popup
MouseArea {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: popupContainer.y
onClicked: {
dropdownLoader.active = false
}
}
// Bottom area - below popup
MouseArea {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: popupContainer.bottom
anchors.bottom: parent.bottom
onClicked: {
dropdownLoader.active = false
}
}
// Left area - left of popup
MouseArea {
anchors.left: parent.left
anchors.top: popupContainer.top
anchors.bottom: popupContainer.bottom
width: popupContainer.x
onClicked: {
dropdownLoader.active = false
}
}
// Right area - right of popup
MouseArea {
anchors.right: parent.right
anchors.top: popupContainer.top
anchors.bottom: popupContainer.bottom
anchors.left: popupContainer.right
onClicked: {
dropdownLoader.active = false
}
}
Rectangle {
id: popupContainer
width: 320
height: Math.min(500, dropdownContent.implicitHeight + 32)
x: Math.max(16, Math.min(root.mapToItem(null, 0, 0).x, parent.width - width - 16))
y: Math.max(16, Math.min(root.mapToItem(null, 0, root.height + 4).y, parent.height - height - 16))
radius: Theme.cornerRadius
color: Theme.surface
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: Theme.shadowStrong
shadowBlur: 0.8
shadowHorizontalOffset: 0
shadowVerticalOffset: 4
}
// Close button
Rectangle {
width: 24
height: 24
radius: 12
color: closeMouseArea.containsMouse ? Theme.errorHover : "transparent"
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
z: 1
DankIcon {
name: "close"
size: 16
color: closeMouseArea.containsMouse ? Theme.error : Theme.outline
anchors.centerIn: parent
}
MouseArea {
id: closeMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
dropdownLoader.active = false
}
}
}
DankFlickable {
anchors.fill: parent
anchors.margins: Theme.spacingS
contentHeight: dropdownContent.height
clip: true
pressDelay: 0
Column {
id: dropdownContent
width: parent.width
spacing: Theme.spacingM
// Icon categories
Repeater {
model: root.iconCategories
Column {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: modelData.name
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
}
Flow {
width: parent.width
spacing: 4
Repeater {
model: modelData.icons
Rectangle {
width: 36
height: 36
radius: Theme.cornerRadius
color: iconMouseArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
border.color: root.currentIcon === modelData ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
border.width: 2
DankIcon {
name: modelData
size: 20
color: root.currentIcon === modelData ? Theme.primary : Theme.surfaceText
anchors.centerIn: parent
}
MouseArea {
id: iconMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.iconSelected(modelData, "icon")
dropdownLoader.active = false
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
}
}
}
}
}
}
}
function setIcon(iconName, type) {
root.iconType = type
root.iconType = "icon"
root.currentIcon = iconName
}
}

View File

@@ -0,0 +1,189 @@
import QtQuick
import QtQuick.Controls
import qs.Widgets
ListView {
id: listView
property real mouseWheelSpeed: 60
property real savedY: 0
property bool justChanged: false
property bool isUserScrolling: false
property real momentumVelocity: 0
property bool isMomentumActive: false
property real friction: 0.95
property real minMomentumVelocity: 50
property real maxMomentumVelocity: 2500
flickDeceleration: 1500
maximumFlickVelocity: 2000
boundsBehavior: Flickable.StopAtBounds
boundsMovement: Flickable.FollowBoundsBehavior
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
onMovementStarted: {
isUserScrolling = true
vbar._scrollBarActive = true
vbar.hideTimer.stop()
}
onMovementEnded: {
isUserScrolling = false
vbar.hideTimer.restart()
}
onContentYChanged: {
if (!justChanged && isUserScrolling) {
savedY = contentY
}
justChanged = false
}
onModelChanged: {
justChanged = true
contentY = savedY
}
WheelHandler {
id: wheelHandler
property real touchpadSpeed: 1.8
property real lastWheelTime: 0
property real momentum: 0
property var velocitySamples: []
property bool sessionUsedMouseWheel: false
function startMomentum() {
isMomentumActive = true
momentumTimer.start()
}
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: event => {
isUserScrolling = true
vbar._scrollBarActive = true
vbar.hideTimer.restart()
const currentTime = Date.now()
const timeDelta = currentTime - lastWheelTime
lastWheelTime = currentTime
const deltaY = event.angleDelta.y
const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0
if (isMouseWheel) {
sessionUsedMouseWheel = true
momentumTimer.stop()
isMomentumActive = false
velocitySamples = []
momentum = 0
momentumVelocity = 0
const lines = Math.floor(Math.abs(deltaY) / 120)
const scrollAmount = (deltaY > 0 ? -lines : lines) * mouseWheelSpeed
let newY = listView.contentY + scrollAmount
const maxY = Math.max(0, listView.contentHeight - listView.height + listView.originY)
newY = Math.max(listView.originY, Math.min(maxY, newY))
if (listView.flicking) {
listView.cancelFlick()
}
listView.contentY = newY
savedY = newY
} else {
sessionUsedMouseWheel = false
momentumTimer.stop()
isMomentumActive = false
let delta = 0
if (event.pixelDelta.y !== 0) {
delta = event.pixelDelta.y * touchpadSpeed
} else {
delta = event.angleDelta.y / 8 * touchpadSpeed
}
velocitySamples.push({
"delta": delta,
"time": currentTime
})
velocitySamples = velocitySamples.filter(s => currentTime - s.time < 100)
if (velocitySamples.length > 1) {
const totalDelta = velocitySamples.reduce((sum, s) => sum + s.delta, 0)
const timeSpan = currentTime - velocitySamples[0].time
if (timeSpan > 0) {
momentumVelocity = Math.max(-maxMomentumVelocity, Math.min(maxMomentumVelocity, totalDelta / timeSpan * 1000))
}
}
if (event.pixelDelta.y !== 0 && timeDelta < 50) {
momentum = momentum * 0.92 + delta * 0.15
delta += momentum
} else {
momentum = 0
}
let newY = listView.contentY - delta
const maxY = Math.max(0, listView.contentHeight - listView.height + listView.originY)
newY = Math.max(listView.originY, Math.min(maxY, newY))
if (listView.flicking) {
listView.cancelFlick()
}
listView.contentY = newY
savedY = newY
}
event.accepted = true
}
onActiveChanged: {
if (!active) {
isUserScrolling = false
if (!sessionUsedMouseWheel && Math.abs(momentumVelocity) >= minMomentumVelocity) {
startMomentum()
} else {
velocitySamples = []
momentumVelocity = 0
}
}
}
}
Timer {
id: momentumTimer
interval: 16
repeat: true
onTriggered: {
const newY = contentY - momentumVelocity * 0.016
const maxY = Math.max(0, contentHeight - height + originY)
const minY = originY
if (newY < minY || newY > maxY) {
contentY = newY < minY ? minY : maxY
savedY = contentY
stop()
isMomentumActive = false
momentumVelocity = 0
return
}
contentY = newY
savedY = newY
momentumVelocity *= friction
if (Math.abs(momentumVelocity) < 5) {
stop()
isMomentumActive = false
momentumVelocity = 0
}
}
}
ScrollBar.vertical: DankScrollbar {
id: vbar
}
}

View File

@@ -0,0 +1,270 @@
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Widgets
Item {
id: root
activeFocusOnTab: true
KeyNavigation.tab: keyNavigationTab
KeyNavigation.backtab: keyNavigationBacktab
onActiveFocusChanged: {
if (activeFocus) {
locationInput.forceActiveFocus()
}
}
property string currentLocation: ""
property string placeholderText: I18n.tr("Search for a location...")
property bool _internalChange: false
property bool isLoading: false
property string currentSearchText: ""
property Item keyNavigationTab: null
property Item keyNavigationBacktab: null
signal locationSelected(string displayName, string coordinates)
function resetSearchState() {
locationSearchTimer.stop()
dropdownHideTimer.stop()
isLoading = false
searchResultsModel.clear()
}
width: parent.width
height: searchInputField.height + (searchDropdown.visible ? searchDropdown.height : 0)
ListModel {
id: searchResultsModel
}
Timer {
id: locationSearchTimer
interval: 500
running: false
repeat: false
onTriggered: {
if (locationInput.text.length > 2) {
searchResultsModel.clear()
root.isLoading = true
const searchLocation = locationInput.text
root.currentSearchText = searchLocation
const encodedLocation = encodeURIComponent(searchLocation)
const curlCommand = `curl -4 -s --connect-timeout 5 --max-time 10 'https://nominatim.openstreetmap.org/search?q=${encodedLocation}&format=json&limit=5&addressdetails=1'`
Proc.runCommand("locationSearch", ["bash", "-c", curlCommand], (output, exitCode) => {
root.isLoading = false
if (exitCode !== 0) {
searchResultsModel.clear()
return
}
if (root.currentSearchText !== locationInput.text)
return
const raw = output.trim()
searchResultsModel.clear()
if (!raw || raw[0] !== "[") {
return
}
try {
const data = JSON.parse(raw)
if (data.length === 0) {
return
}
for (var i = 0; i < Math.min(data.length, 5); i++) {
const location = data[i]
if (location.display_name && location.lat && location.lon) {
const parts = location.display_name.split(', ')
let cleanName = parts[0]
if (parts.length > 1) {
const state = parts[parts.length - 2]
if (state && state !== cleanName)
cleanName += `, ${state}`
}
const query = `${location.lat},${location.lon}`
searchResultsModel.append({
"name": cleanName,
"query": query
})
}
}
} catch (e) {
}
})
}
}
}
Timer {
id: dropdownHideTimer
interval: 200
running: false
repeat: false
onTriggered: {
if (!locationInput.getActiveFocus() && !searchDropdown.hovered)
root.resetSearchState()
}
}
Item {
id: searchInputField
width: parent.width
height: 48
DankTextField {
id: locationInput
width: parent.width
height: parent.height
leftIconName: "search"
placeholderText: root.placeholderText
text: ""
backgroundColor: Theme.surfaceVariant
normalBorderColor: Theme.primarySelected
focusedBorderColor: Theme.primary
keyNavigationTab: root.keyNavigationTab
keyNavigationBacktab: root.keyNavigationBacktab
onTextEdited: {
if (root._internalChange)
return
if (getActiveFocus()) {
if (text.length > 2) {
root.isLoading = true
locationSearchTimer.restart()
} else {
root.resetSearchState()
}
}
}
onFocusStateChanged: hasFocus => {
if (hasFocus) {
dropdownHideTimer.stop()
} else {
dropdownHideTimer.start()
}
}
}
DankIcon {
name: root.isLoading ? "hourglass_empty" : (searchResultsModel.count > 0 ? "check_circle" : "error")
size: Theme.iconSize - 4
color: root.isLoading ? Theme.surfaceVariantText : (searchResultsModel.count > 0 ? Theme.primary : Theme.error)
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
opacity: (locationInput.getActiveFocus() && locationInput.text.length > 2) ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
StyledRect {
id: searchDropdown
property bool hovered: false
width: parent.width
height: Math.min(Math.max(searchResultsModel.count * 38 + Theme.spacingS * 2, 50), 200)
y: searchInputField.height
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
border.color: Theme.primarySelected
border.width: 1
visible: locationInput.getActiveFocus() && locationInput.text.length > 2 && (searchResultsModel.count > 0 || root.isLoading)
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: {
parent.hovered = true
dropdownHideTimer.stop()
}
onExited: {
parent.hovered = false
if (!locationInput.getActiveFocus())
dropdownHideTimer.start()
}
acceptedButtons: Qt.NoButton
}
Item {
anchors.fill: parent
anchors.margins: Theme.spacingS
DankListView {
id: searchResultsList
anchors.fill: parent
clip: true
model: searchResultsModel
spacing: 2
delegate: StyledRect {
width: searchResultsList.width
height: 36
radius: Theme.cornerRadius
color: resultMouseArea.containsMouse ? Theme.surfaceLight : "transparent"
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
DankIcon {
name: "place"
size: Theme.iconSize - 6
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: model.name || "Unknown"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
width: parent.width - 30
}
}
MouseArea {
id: resultMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root._internalChange = true
const selectedName = model.name
const selectedQuery = model.query
locationInput.text = selectedName
root.locationSelected(selectedName, selectedQuery)
root.resetSearchState()
locationInput.setFocus(false)
root._internalChange = false
}
}
}
}
StyledText {
anchors.centerIn: parent
text: root.isLoading ? "Searching..." : "No locations found"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
visible: searchResultsList.count === 0 && locationInput.text.length > 2
}
}
}
}

View File

@@ -0,0 +1,153 @@
import QtQuick
import qs.Common
Item {
id: root
property string name: ""
property int size: Theme.fontSizeMedium
property alias color: icon.color
width: size
height: size
visible: text.length > 0
// This is for file browser, particularly - might want another map later for app IDs
readonly property var iconMap: ({
// --- Distribution logos ---
"debian": "\u{f08da}",
"arch": "\u{f08c7}",
"archcraft": "\u{f345}",
"guix": "\u{f325}",
"fedora": "\u{f08db}",
"nixos": "\u{f1105}",
"ubuntu": "\u{f0548}",
"gentoo": "\u{f08e8}",
"endeavouros": "\u{f322}",
"manjaro": "\u{f160a}",
"opensuse": "\u{f314}",
// --- special types ---
"folder": "\u{F024B}",
"file": "\u{F0214}",
// --- special filenames (no extension) ---
"docker": "\u{F0868}",
"makefile": "\u{F09EE}",
"license": "\u{F09EE}",
"readme": "\u{F0354}",
// --- programming languages ---
"rs": "\u{F1617}",
"dart": "\u{e798}",
"go": "\u{F07D3}",
"py": "\u{F0320}",
"js": "\u{F031E}",
"jsx": "\u{F031E}",
"ts": "\u{F06E6}",
"tsx": "\u{F06E6}",
"java": "\u{F0B37}",
"c": "\u{F0671}",
"cpp": "\u{F0672}",
"cxx": "\u{F0672}",
"h": "\u{F0672}",
"hpp": "\u{F0672}",
"cs": "\u{F031B}",
"html": "\u{e60e}",
"htm": "\u{e60e}",
"css": "\u{E6b8}",
"scss": "\u{F031C}",
"less": "\u{F031C}",
"md": "\u{F0354}",
"markdown": "\u{F0354}",
"json": "\u{eb0f}",
"jsonc": "\u{eb0f}",
"yaml": "\u{e8eb}",
"yml": "\u{e8eb}",
"xml": "\u{F09EE}",
"sql": "\u{f1c0}",
// --- scripts / shells ---
"sh": "\u{f0bc1}",
"bash": "\u{f0bc1}",
"zsh": "\u{f0bc1}",
"fish": "\u{f0bc1}",
"ps1": "\u{f0bc1}",
"bat": "\u{f0bc1}",
// --- data / config ---
"toml": "\u{e6b2}",
"ini": "\u{F09EE}",
"conf": "\u{F09EE}",
"cfg": "\u{F09EE}",
"csv": "\u{eefc}",
"tsv": "\u{F021C}",
// --- docs / office ---
"pdf": "\u{F0226}",
"doc": "\u{F09EE}",
"docx": "\u{F09EE}",
"rtf": "\u{F09EE}",
"ppt": "\u{F09EE}",
"pptx": "\u{F09EE}",
"log": "\u{F09EE}",
"xls": "\u{F021C}",
"xlsx": "\u{F021C}",
// --- images ---
"ico": "\u{F021F}",
// --- audio / video ---
"mp3": "\u{e638}",
"wav": "\u{e638}",
"flac": "\u{e638}",
"ogg": "\u{e638}",
"mp4": "\u{f0567}",
"mkv": "\u{f0567}",
"webm": "\u{f0567}",
"mov": "\u{f0567}",
// --- archives / packages ---
"zip": "\u{e6aa}",
"tar": "\u{f003c}",
"gz": "\u{f003c}",
"bz2": "\u{f003c}",
"7z": "\u{f003c}",
// --- containers / infra / cloud ---
"dockerfile": "\u{F0868}",
"yml.k8s": "\u{F09EE}",
"yaml.k8s": "\u{F09EE}",
"tf": "\u{F09EE}",
"tfvars": "\u{F09EE}"
})
readonly property string text: iconMap[name] || iconMap["file"] || ""
function getIconForFile(fileName) {
const lowerName = fileName.toLowerCase()
if (lowerName.startsWith("dockerfile")) {
return "docker"
}
const ext = fileName.split('.').pop()
return ext || ""
}
FontLoader {
id: firaCodeFont
source: Qt.resolvedUrl("../assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf")
}
StyledText {
id: icon
anchors.centerIn: parent
font.family: firaCodeFont.name
font.pixelSize: root.size
color: Theme.surfaceText
text: root.text
antialiasing: true
}
}

View File

@@ -0,0 +1,191 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
import qs.Widgets
PanelWindow {
id: root
property string blurNamespace: "dms:osd"
WlrLayershell.namespace: blurNamespace
property alias content: contentLoader.sourceComponent
property alias contentLoader: contentLoader
property var modelData
property bool shouldBeVisible: false
property int autoHideInterval: 2000
property bool enableMouseInteraction: false
property real osdWidth: Theme.iconSize + Theme.spacingS * 2
property real osdHeight: Theme.iconSize + Theme.spacingS * 2
property int animationDuration: Theme.mediumDuration
property var animationEasing: Theme.emphasizedEasing
signal osdShown
signal osdHidden
function show() {
closeTimer.stop()
shouldBeVisible = true
visible = true
hideTimer.restart()
osdShown()
}
function hide() {
shouldBeVisible = false
closeTimer.restart()
}
function resetHideTimer() {
if (shouldBeVisible) {
hideTimer.restart()
}
}
function updateHoverState() {
let isHovered = (enableMouseInteraction && mouseArea.containsMouse) || osdContainer.childHovered
if (enableMouseInteraction) {
if (isHovered) {
hideTimer.stop()
} else if (shouldBeVisible) {
hideTimer.restart()
}
}
}
function setChildHovered(hovered) {
osdContainer.childHovered = hovered
updateHoverState()
}
screen: modelData
visible: false
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
readonly property real dpr: CompositorService.getScreenScale(screen)
readonly property real screenWidth: screen.width
readonly property real screenHeight: screen.height
readonly property real alignedWidth: Theme.px(osdWidth, dpr)
readonly property real alignedHeight: Theme.px(osdHeight, dpr)
readonly property real alignedX: Theme.snap((screenWidth - alignedWidth) / 2, dpr)
readonly property real alignedY: Theme.snap(screenHeight - alignedHeight - Theme.spacingM, dpr)
anchors {
top: true
left: true
right: true
bottom: true
}
Timer {
id: hideTimer
interval: autoHideInterval
repeat: false
onTriggered: {
if (!enableMouseInteraction || !mouseArea.containsMouse) {
hide()
} else {
hideTimer.restart()
}
}
}
Timer {
id: closeTimer
interval: animationDuration + 50
onTriggered: {
if (!shouldBeVisible) {
visible = false
osdHidden()
}
}
}
Item {
id: osdContainer
x: alignedX
y: alignedY
width: alignedWidth
height: alignedHeight
opacity: shouldBeVisible ? 1 : 0
scale: shouldBeVisible ? 1 : 0.9
property bool childHovered: false
property real shadowBlurPx: 10
property real shadowSpreadPx: 0
property real shadowBaseAlpha: 0.60
readonly property real popupSurfaceAlpha: SettingsData.popupTransparency
readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha * osdContainer.opacity))
Item {
id: bgShadowLayer
anchors.fill: parent
visible: osdContainer.popupSurfaceAlpha >= 0.95
layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
layer.smooth: false
layer.textureSize: Qt.size(Math.round(width * root.dpr), Math.round(height * root.dpr))
layer.textureMirroring: ShaderEffectSource.MirrorVertically
layer.effect: MultiEffect {
id: shadowFx
autoPaddingEnabled: true
shadowEnabled: true
blurEnabled: false
maskEnabled: false
property int blurMax: 64
shadowBlur: Math.max(0, Math.min(1, osdContainer.shadowBlurPx / blurMax))
shadowScale: 1 + (2 * osdContainer.shadowSpreadPx) / Math.max(1, Math.min(bgShadowLayer.width, bgShadowLayer.height))
shadowColor: Qt.rgba(0, 0, 0, osdContainer.effectiveShadowAlpha)
}
DankRectangle {
anchors.fill: parent
radius: Theme.cornerRadius
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: enableMouseInteraction
acceptedButtons: Qt.NoButton
propagateComposedEvents: true
z: -1
onContainsMouseChanged: updateHoverState()
}
onChildHoveredChanged: updateHoverState()
Loader {
id: contentLoader
anchors.fill: parent
active: root.visible
asynchronous: false
}
Behavior on opacity {
NumberAnimation {
duration: animationDuration
easing.type: animationEasing
}
}
Behavior on scale {
NumberAnimation {
duration: animationDuration
easing.type: animationEasing
}
}
}
mask: Region {
item: bgShadowLayer
}
}

View File

@@ -0,0 +1,265 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import qs.Common
import qs.Services
import qs.Widgets
PanelWindow {
id: root
property string layerNamespace: "dms:popout"
WlrLayershell.namespace: layerNamespace
property alias content: contentLoader.sourceComponent
property alias contentLoader: contentLoader
property real popupWidth: 400
property real popupHeight: 300
property real triggerX: 0
property real triggerY: 0
property real triggerWidth: 40
property string triggerSection: ""
property string positioning: "center"
property int animationDuration: Theme.expressiveDurations.expressiveDefaultSpatial
property real animationScaleCollapsed: 0.96
property real animationOffset: Theme.spacingL
property list<real> animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial
property list<real> animationExitCurve: Theme.expressiveCurves.emphasized
property bool shouldBeVisible: false
property int keyboardFocusMode: WlrKeyboardFocus.OnDemand
signal opened
signal popoutClosed
signal backgroundClicked
function open() {
closeTimer.stop()
shouldBeVisible = true
visible = true
opened()
}
function close() {
shouldBeVisible = false
closeTimer.restart()
}
function toggle() {
if (shouldBeVisible)
close()
else
open()
}
Timer {
id: closeTimer
interval: animationDuration
onTriggered: {
if (!shouldBeVisible) {
visible = false
popoutClosed()
}
}
}
color: "transparent"
WlrLayershell.layer: {
switch (Quickshell.env("DMS_DANKBAR_LAYER")) {
case "bottom":
return WlrLayershell.Bottom
case "overlay":
return WlrLayershell.Overlay
case "background":
return WlrLayershell.Background
default:
return WlrLayershell.Top
}
}
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: shouldBeVisible ? keyboardFocusMode : WlrKeyboardFocus.None
anchors {
top: true
left: true
right: true
bottom: true
}
readonly property real screenWidth: root.screen.width
readonly property real screenHeight: root.screen.height
readonly property real dpr: CompositorService.getScreenScale(root.screen)
readonly property real alignedWidth: Theme.px(popupWidth, dpr)
readonly property real alignedHeight: Theme.px(popupHeight, dpr)
readonly property real alignedX: Theme.snap((() => {
if (SettingsData.dankBarPosition === SettingsData.Position.Left) {
return triggerY + SettingsData.dankBarBottomGap
} else if (SettingsData.dankBarPosition === SettingsData.Position.Right) {
return screenWidth - triggerY - SettingsData.dankBarBottomGap - popupWidth
} else {
const centerX = triggerX + (triggerWidth / 2) - (popupWidth / 2)
return Math.max(Theme.popupDistance, Math.min(screenWidth - popupWidth - Theme.popupDistance, centerX))
}
})(), dpr)
readonly property real alignedY: Theme.snap((() => {
if (SettingsData.dankBarPosition === SettingsData.Position.Left || SettingsData.dankBarPosition === SettingsData.Position.Right) {
const centerY = triggerX + (triggerWidth / 2) - (popupHeight / 2)
return Math.max(Theme.popupDistance, Math.min(screenHeight - popupHeight - Theme.popupDistance, centerY))
} else if (SettingsData.dankBarPosition === SettingsData.Position.Bottom) {
return Math.max(Theme.popupDistance, screenHeight - triggerY - popupHeight)
} else {
return Math.min(screenHeight - popupHeight - Theme.popupDistance, triggerY)
}
})(), dpr)
MouseArea {
anchors.fill: parent
enabled: shouldBeVisible && contentLoader.opacity > 0.1
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: mouse => {
if (mouse.x < alignedX || mouse.x > alignedX + alignedWidth ||
mouse.y < alignedY || mouse.y > alignedY + alignedHeight) {
backgroundClicked()
close()
}
}
}
Item {
id: contentContainer
x: alignedX
y: alignedY
width: alignedWidth
height: alignedHeight
readonly property bool barTop: SettingsData.dankBarPosition === SettingsData.Position.Top
readonly property bool barBottom: SettingsData.dankBarPosition === SettingsData.Position.Bottom
readonly property bool barLeft: SettingsData.dankBarPosition === SettingsData.Position.Left
readonly property bool barRight: SettingsData.dankBarPosition === SettingsData.Position.Right
readonly property real offsetX: barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0)
readonly property real offsetY: barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0)
property real animX: 0
property real animY: 0
property real scaleValue: root.animationScaleCollapsed
onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr)
onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr)
Connections {
target: root
function onShouldBeVisibleChanged() {
contentContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetX, root.dpr)
contentContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetY, root.dpr)
contentContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed
}
}
Behavior on animX {
NumberAnimation {
duration: root.animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on animY {
NumberAnimation {
duration: root.animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on scaleValue {
NumberAnimation {
duration: root.animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Item {
id: contentWrapper
anchors.centerIn: parent
width: parent.width
height: parent.height
opacity: shouldBeVisible ? 1 : 0
visible: opacity > 0
scale: contentContainer.scaleValue
x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
property real shadowBlurPx: 10
property real shadowSpreadPx: 0
property real shadowBaseAlpha: 0.60
readonly property real popupSurfaceAlpha: SettingsData.popupTransparency
readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha * contentWrapper.opacity))
Behavior on opacity {
NumberAnimation {
duration: animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Item {
id: bgShadowLayer
anchors.fill: parent
visible: contentWrapper.popupSurfaceAlpha >= 0.95
layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
layer.smooth: false
layer.textureSize: Qt.size(Math.round(width * root.dpr), Math.round(height * root.dpr))
layer.textureMirroring: ShaderEffectSource.MirrorVertically
layer.effect: MultiEffect {
id: shadowFx
autoPaddingEnabled: true
shadowEnabled: true
blurEnabled: false
maskEnabled: false
property int blurMax: 64
shadowBlur: Math.max(0, Math.min(1, contentWrapper.shadowBlurPx / blurMax))
shadowScale: 1 + (2 * contentWrapper.shadowSpreadPx) / Math.max(1, Math.min(bgShadowLayer.width, bgShadowLayer.height))
shadowColor: Qt.rgba(0, 0, 0, contentWrapper.effectiveShadowAlpha)
}
DankRectangle {
anchors.fill: parent
radius: Theme.cornerRadius
}
}
Item {
id: contentLoaderWrapper
anchors.fill: parent
x: Theme.snap(x, root.dpr)
y: Theme.snap(y, root.dpr)
Loader {
id: contentLoader
anchors.fill: parent
active: root.visible
asynchronous: false
}
}
}
}
Item {
parent: contentContainer
anchors.fill: parent
focus: true
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
close()
event.accepted = true
}
}
Component.onCompleted: forceActiveFocus()
onVisibleChanged: if (visible) forceActiveFocus()
}
}

View File

@@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Shapes
import qs.Common
Item {
id: root
property color color: Theme.surfaceContainer
property color borderColor: Theme.outlineMedium
property real borderWidth: 1
property real radius: Theme.cornerRadius
property color overlayColor: "transparent"
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: root.color
strokeColor: root.borderColor
strokeWidth: root.borderWidth
startX: root.radius
startY: 0
PathLine { x: root.width - root.radius; y: 0 }
PathQuad { x: root.width; y: root.radius; controlX: root.width; controlY: 0 }
PathLine { x: root.width; y: root.height - root.radius }
PathQuad { x: root.width - root.radius; y: root.height; controlX: root.width; controlY: root.height }
PathLine { x: root.radius; y: root.height }
PathQuad { x: 0; y: root.height - root.radius; controlX: 0; controlY: root.height }
PathLine { x: 0; y: root.radius }
PathQuad { x: root.radius; y: 0; controlX: 0; controlY: 0 }
}
ShapePath {
fillColor: root.overlayColor
strokeColor: "transparent"
strokeWidth: 0
startX: root.radius
startY: 0
PathLine { x: root.width - root.radius; y: 0 }
PathQuad { x: root.width; y: root.radius; controlX: root.width; controlY: 0 }
PathLine { x: root.width; y: root.height - root.radius }
PathQuad { x: root.width - root.radius; y: root.height; controlX: root.width; controlY: root.height }
PathLine { x: root.radius; y: root.height }
PathQuad { x: 0; y: root.height - root.radius; controlX: 0; controlY: root.height }
PathLine { x: 0; y: root.radius }
PathQuad { x: root.radius; y: 0; controlX: 0; controlY: 0 }
}
}
}

View File

@@ -0,0 +1,43 @@
import QtQuick
import QtQuick.Controls
import qs.Common
ScrollBar {
id: scrollbar
property bool _scrollBarActive: false
property alias hideTimer: hideScrollBarTimer
property bool _isParentMoving: parent && (parent.moving || parent.flicking || parent.isMomentumActive)
property bool _shouldShow: pressed || hovered || active || _isParentMoving || _scrollBarActive
policy: (parent && parent.contentHeight > parent.height) ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
minimumSize: 0.08
implicitWidth: 8
interactive: true
hoverEnabled: true
z: 1000
opacity: (policy !== ScrollBar.AlwaysOff && _shouldShow) ? 1.0 : 0.0
visible: policy !== ScrollBar.AlwaysOff
Behavior on opacity {
NumberAnimation {
duration: 160
easing.type: Easing.OutQuad
}
}
contentItem: Rectangle {
implicitWidth: 6
radius: width / 2
color: scrollbar.pressed ? Theme.primary : scrollbar._shouldShow ? Theme.outline : Theme.outlineMedium
opacity: scrollbar.pressed ? 1.0 : scrollbar._shouldShow ? 1.0 : 0.6
}
background: Item {}
Timer {
id: hideScrollBarTimer
interval: 1200
onTriggered: scrollbar._scrollBarActive = false
}
}

View File

@@ -0,0 +1,182 @@
import QtQuick
import Quickshell.Services.Mpris
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
property MprisPlayer activePlayer
property real value: {
if (!activePlayer || activePlayer.length <= 0) return 0
const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length)
const calculatedRatio = pos / activePlayer.length
return Math.max(0, Math.min(1, calculatedRatio))
}
property bool isSeeking: false
implicitHeight: 20
Loader {
anchors.fill: parent
visible: activePlayer && activePlayer.length > 0
sourceComponent: SettingsData.waveProgressEnabled ? waveProgressComponent : flatProgressComponent
z: 1
Component {
id: waveProgressComponent
M3WaveProgress {
value: root.value
isPlaying: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0
property real pendingSeekPosition: -1
Timer {
id: waveSeekDebounceTimer
interval: 150
onTriggered: {
if (parent.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
const clamped = Math.min(parent.pendingSeekPosition, activePlayer.length * 0.99)
activePlayer.position = clamped
parent.pendingSeekPosition = -1
}
}
}
onPressed: (mouse) => {
root.isSeeking = true
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
pendingSeekPosition = r * activePlayer.length
waveSeekDebounceTimer.restart()
}
}
onReleased: {
root.isSeeking = false
waveSeekDebounceTimer.stop()
if (pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99)
activePlayer.position = clamped
pendingSeekPosition = -1
}
}
onPositionChanged: (mouse) => {
if (pressed && root.isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
pendingSeekPosition = r * activePlayer.length
waveSeekDebounceTimer.restart()
}
}
onClicked: (mouse) => {
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
activePlayer.position = r * activePlayer.length
}
}
}
}
}
Component {
id: flatProgressComponent
Item {
property real lineWidth: 3
property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40)
property color fillColor: Theme.primary
property color playheadColor: Theme.primary
readonly property real midY: height / 2
Rectangle {
width: parent.width
height: parent.lineWidth
anchors.verticalCenter: parent.verticalCenter
color: parent.trackColor
radius: height / 2
}
Rectangle {
width: Math.max(0, Math.min(parent.width, parent.width * root.value))
height: parent.lineWidth
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
color: parent.fillColor
radius: height / 2
Behavior on width { NumberAnimation { duration: 80 } }
}
Rectangle {
id: playhead
width: 3
height: Math.max(parent.lineWidth + 8, 14)
radius: width / 2
color: parent.playheadColor
x: Math.max(0, Math.min(parent.width, parent.width * root.value)) - width / 2
y: parent.midY - height / 2
z: 3
Behavior on x { NumberAnimation { duration: 80 } }
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0
property real pendingSeekPosition: -1
Timer {
id: flatSeekDebounceTimer
interval: 150
onTriggered: {
if (parent.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
const clamped = Math.min(parent.pendingSeekPosition, activePlayer.length * 0.99)
activePlayer.position = clamped
parent.pendingSeekPosition = -1
}
}
}
onPressed: (mouse) => {
root.isSeeking = true
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
pendingSeekPosition = r * activePlayer.length
flatSeekDebounceTimer.restart()
}
}
onReleased: {
root.isSeeking = false
flatSeekDebounceTimer.stop()
if (pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99)
activePlayer.position = clamped
pendingSeekPosition = -1
}
}
onPositionChanged: (mouse) => {
if (pressed && root.isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
pendingSeekPosition = r * activePlayer.length
flatSeekDebounceTimer.restart()
}
}
onClicked: (mouse) => {
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
activePlayer.position = r * activePlayer.length
}
}
}
}
}
}
}

View File

@@ -0,0 +1,204 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
import qs.Widgets
pragma ComponentBehavior: Bound
PanelWindow {
id: root
property string layerNamespace: "dms:slideout"
WlrLayershell.namespace: layerNamespace
property bool isVisible: false
property var targetScreen: null
property var modelData: null
property real slideoutWidth: 480
property bool expandable: false
property bool expandedWidth: false
property real expandedWidthValue: 960
property Component content: null
property string title: ""
property alias container: contentContainer
property real customTransparency: -1
function show() {
visible = true
isVisible = true
}
function hide() {
isVisible = false
}
function toggle() {
if (isVisible) {
hide()
} else {
show()
}
}
visible: isVisible
screen: modelData
anchors.top: true
anchors.bottom: true
anchors.right: true
implicitWidth: expandable ? expandedWidthValue : slideoutWidth
implicitHeight: modelData ? modelData.height : 800
color: "transparent"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.exclusiveZone: 0
WlrLayershell.keyboardFocus: isVisible ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
readonly property real dpr: CompositorService.getScreenScale(root.screen)
readonly property real alignedWidth: Theme.px(expandable && expandedWidth ? expandedWidthValue : slideoutWidth, dpr)
readonly property real alignedHeight: Theme.px(modelData ? modelData.height : 800, dpr)
mask: Region {
item: Rectangle {
x: root.width - alignedWidth
y: 0
width: alignedWidth
height: root.height
}
}
Item {
id: slideContainer
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
width: alignedWidth
height: alignedHeight
property real slideOffset: alignedWidth
Connections {
target: root
function onIsVisibleChanged() {
slideContainer.slideOffset = root.isVisible ? 0 : slideContainer.width
}
}
Behavior on slideOffset {
NumberAnimation {
id: slideAnimation
duration: 450
easing.type: Easing.OutCubic
onRunningChanged: {
if (!running && !isVisible) {
root.visible = false
}
}
}
}
Behavior on width {
NumberAnimation {
duration: 250
easing.type: Easing.OutCubic
}
}
Item {
id: contentRect
layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
layer.smooth: false
layer.textureSize: Qt.size(width * root.dpr, height * root.dpr)
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width
x: Theme.snap(slideContainer.slideOffset, root.dpr)
DankRectangle {
anchors.fill: parent
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b,
customTransparency >= 0 ? customTransparency : SettingsData.popupTransparency)
}
Column {
id: headerColumn
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
visible: root.title !== ""
Row {
width: parent.width
height: 32
Column {
width: parent.width - buttonRow.width
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: root.title
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
}
Row {
id: buttonRow
spacing: Theme.spacingXS
DankActionButton {
id: expandButton
iconName: root.expandedWidth ? "unfold_less" : "unfold_more"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
visible: root.expandable
onClicked: root.expandedWidth = !root.expandedWidth
transform: Rotation {
angle: 90
origin.x: expandButton.width / 2
origin.y: expandButton.height / 2
}
}
DankActionButton {
id: closeButton
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.hide()
}
}
}
}
Item {
id: contentContainer
anchors.top: root.title !== "" ? headerColumn.bottom : parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.topMargin: root.title !== "" ? 0 : Theme.spacingL
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
anchors.bottomMargin: Theme.spacingL
Loader {
anchors.fill: parent
sourceComponent: root.content
}
}
}
}
}

View File

@@ -0,0 +1,274 @@
import QtQuick
import QtQuick.Effects
import qs.Common
import qs.Widgets
Item {
id: slider
property int value: 50
property int minimum: 0
property int maximum: 100
property string leftIcon: ""
property string rightIcon: ""
property bool enabled: true
property string unit: "%"
property bool showValue: true
property bool isDragging: false
property bool wheelEnabled: true
property real valueOverride: -1
property bool alwaysShowValue: false
readonly property bool containsMouse: sliderMouseArea.containsMouse
property color thumbOutlineColor: Theme.surfaceContainer
property color trackColor: enabled ? Theme.outline : Theme.outline
signal sliderValueChanged(int newValue)
signal sliderDragFinished(int finalValue)
height: 48
function updateValueFromPosition(x) {
let ratio = Math.max(0, Math.min(1, (x - sliderHandle.width / 2) / (sliderTrack.width - sliderHandle.width)))
let newValue = Math.round(minimum + ratio * (maximum - minimum))
if (newValue !== value) {
value = newValue
sliderValueChanged(newValue)
}
}
Row {
anchors.centerIn: parent
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: slider.leftIcon
size: Theme.iconSize
color: slider.enabled ? Theme.surfaceText : Theme.onSurface_38
anchors.verticalCenter: parent.verticalCenter
visible: slider.leftIcon.length > 0
}
StyledRect {
id: sliderTrack
property int leftIconWidth: slider.leftIcon.length > 0 ? Theme.iconSize : 0
property int rightIconWidth: slider.rightIcon.length > 0 ? Theme.iconSize : 0
width: parent.width - (leftIconWidth + rightIconWidth + (slider.leftIcon.length > 0 ? Theme.spacingM : 0) + (slider.rightIcon.length > 0 ? Theme.spacingM : 0))
height: 12
radius: Theme.cornerRadius
color: slider.trackColor
anchors.verticalCenter: parent.verticalCenter
clip: false
StyledRect {
id: sliderFill
height: parent.height
radius: Theme.cornerRadius
width: {
const ratio = (slider.value - slider.minimum) / (slider.maximum - slider.minimum)
const travel = sliderTrack.width - sliderHandle.width
const center = (travel * ratio) + sliderHandle.width / 2
return Math.max(0, Math.min(sliderTrack.width, center))
}
color: slider.enabled ? Theme.primary : Theme.withAlpha(Theme.onSurface, 0.12)
}
StyledRect {
id: sliderHandle
property bool active: sliderMouseArea.containsMouse || sliderMouseArea.pressed || slider.isDragging
width: 8
height: 24
radius: Theme.cornerRadius
x: {
const ratio = (slider.value - slider.minimum) / (slider.maximum - slider.minimum)
const travel = sliderTrack.width - width
return Math.max(0, Math.min(travel, travel * ratio))
}
anchors.verticalCenter: parent.verticalCenter
color: slider.enabled ? Theme.primary : Theme.withAlpha(Theme.onSurface, 0.12)
border.width: 3
border.color: slider.thumbOutlineColor
StyledRect {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.onPrimary
opacity: slider.enabled ? (sliderMouseArea.pressed ? 0.16 : (sliderMouseArea.containsMouse ? 0.08 : 0)) : 0
visible: opacity > 0
}
StyledRect {
anchors.centerIn: parent
width: parent.width + 20
height: parent.height + 20
radius: width / 2
color: "transparent"
border.width: 2
border.color: Theme.primary
opacity: slider.enabled && slider.focus ? 0.3 : 0
visible: opacity > 0
}
Rectangle {
id: ripple
anchors.centerIn: parent
width: 0
height: 0
radius: width / 2
color: Theme.onPrimary
opacity: 0
function start() {
opacity = 0.16
width = 0
height = 0
rippleAnimation.start()
}
SequentialAnimation {
id: rippleAnimation
NumberAnimation {
target: ripple
properties: "width,height"
to: 28
duration: 180
}
NumberAnimation {
target: ripple
property: "opacity"
to: 0
duration: 150
}
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onPressedChanged: {
if (pressed && slider.enabled) {
ripple.start()
}
}
}
scale: active ? 1.05 : 1.0
Behavior on scale {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
Item {
id: sliderContainer
anchors.fill: parent
MouseArea {
id: sliderMouseArea
property bool isDragging: false
anchors.fill: parent
anchors.topMargin: -10
anchors.bottomMargin: -10
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: slider.enabled
preventStealing: true
acceptedButtons: Qt.LeftButton
onWheel: wheelEvent => {
if (!slider.wheelEnabled) {
wheelEvent.accepted = false
return
}
let step = Math.max(0.5, (maximum - minimum) / 100)
let newValue = wheelEvent.angleDelta.y > 0 ? Math.min(maximum, value + step) : Math.max(minimum, value - step)
newValue = Math.round(newValue)
if (newValue !== value) {
value = newValue
sliderValueChanged(newValue)
}
wheelEvent.accepted = true
}
onPressed: mouse => {
if (slider.enabled) {
slider.isDragging = true
sliderMouseArea.isDragging = true
updateValueFromPosition(mouse.x)
}
}
onReleased: {
if (slider.enabled) {
slider.isDragging = false
sliderMouseArea.isDragging = false
slider.sliderDragFinished(slider.value)
}
}
onPositionChanged: mouse => {
if (pressed && slider.isDragging && slider.enabled) {
updateValueFromPosition(mouse.x)
}
}
onClicked: mouse => {
if (slider.enabled && !slider.isDragging) {
updateValueFromPosition(mouse.x)
}
}
}
}
StyledRect {
id: valueTooltip
width: tooltipText.contentWidth + Theme.spacingS * 2
height: tooltipText.contentHeight + Theme.spacingXS * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: Theme.outline
border.width: 1
anchors.bottom: parent.top
anchors.bottomMargin: Theme.spacingM
x: Math.max(0, Math.min(parent.width - width, sliderHandle.x + sliderHandle.width/2 - width/2))
visible: slider.alwaysShowValue ? slider.showValue : ((sliderMouseArea.containsMouse && slider.showValue) || (slider.isDragging && slider.showValue))
opacity: visible ? 1 : 0
StyledText {
id: tooltipText
text: (slider.valueOverride >= 0 ? Math.round(slider.valueOverride) : slider.value) + slider.unit
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
anchors.centerIn: parent
font.hintingPreference: Font.PreferFullHinting
}
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
DankIcon {
name: slider.rightIcon
size: Theme.iconSize
color: slider.enabled ? Theme.surfaceText : Theme.onSurface_38
anchors.verticalCenter: parent.verticalCenter
visible: slider.rightIcon.length > 0
}
}
}

View File

@@ -0,0 +1,254 @@
import QtQuick
import qs.Common
import qs.Widgets
FocusScope {
id: tabBar
property alias model: tabRepeater.model
property int currentIndex: 0
property int spacing: Theme.spacingL
property int tabHeight: 56
property bool showIcons: true
property bool equalWidthTabs: true
property bool enableArrowNavigation: true
property Item nextFocusTarget: null
property Item previousFocusTarget: null
signal tabClicked(int index)
signal actionTriggered(int index)
focus: false
activeFocusOnTab: true
height: tabHeight
KeyNavigation.tab: nextFocusTarget
KeyNavigation.down: nextFocusTarget
KeyNavigation.backtab: previousFocusTarget
KeyNavigation.up: previousFocusTarget
Keys.onPressed: (event) => {
if (!tabBar.activeFocus || tabRepeater.count === 0)
return
function findSelectableIndex(startIndex, step) {
let idx = startIndex
for (let i = 0; i < tabRepeater.count; i++) {
idx = (idx + step + tabRepeater.count) % tabRepeater.count
const item = tabRepeater.itemAt(idx)
if (item && !item.isAction)
return idx
}
return -1
}
const goToIndex = (nextIndex) => {
if (nextIndex >= 0 && nextIndex !== tabBar.currentIndex) {
tabBar.currentIndex = nextIndex
tabBar.tabClicked(nextIndex)
}
}
const resolveTarget = (item) => {
if (!item)
return null
if (item.focusTarget)
return resolveTarget(item.focusTarget)
return item
}
const focusItem = (item) => {
const target = resolveTarget(item)
if (!target)
return false
if (target.requestFocus) {
Qt.callLater(() => target.requestFocus())
return true
}
if (target.forceActiveFocus) {
Qt.callLater(() => target.forceActiveFocus())
return true
}
return false
}
if (event.key === Qt.Key_Right && tabBar.enableArrowNavigation) {
const baseIndex = (tabBar.currentIndex >= 0 && tabBar.currentIndex < tabRepeater.count) ? tabBar.currentIndex : -1
const nextIndex = findSelectableIndex(baseIndex, 1)
if (nextIndex >= 0) {
goToIndex(nextIndex)
event.accepted = true
}
} else if (event.key === Qt.Key_Left && tabBar.enableArrowNavigation) {
const baseIndex = (tabBar.currentIndex >= 0 && tabBar.currentIndex < tabRepeater.count) ? tabBar.currentIndex : 0
const nextIndex = findSelectableIndex(baseIndex, -1)
if (nextIndex >= 0) {
goToIndex(nextIndex)
event.accepted = true
}
} else if (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier)) {
if (focusItem(tabBar.previousFocusTarget)) {
event.accepted = true
}
} else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Down) {
if (focusItem(tabBar.nextFocusTarget)) {
event.accepted = true
}
} else if (event.key === Qt.Key_Up) {
if (focusItem(tabBar.previousFocusTarget)) {
event.accepted = true
}
}
}
Row {
id: tabRow
anchors.fill: parent
spacing: tabBar.spacing
Repeater {
id: tabRepeater
Item {
id: tabItem
property bool isAction: modelData && modelData.isAction === true
property bool isActive: !isAction && tabBar.currentIndex === index
property bool hasIcon: tabBar.showIcons && modelData && modelData.icon && modelData.icon.length > 0
property bool hasText: modelData && modelData.text && modelData.text.length > 0
width: tabBar.equalWidthTabs ? (tabBar.width - tabBar.spacing * Math.max(0, tabRepeater.count - 1)) / Math.max(1, tabRepeater.count) : Math.max(contentCol.implicitWidth + Theme.spacingXL, 64)
height: tabBar.tabHeight
Column {
id: contentCol
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: modelData.icon || ""
anchors.horizontalCenter: parent.horizontalCenter
size: Theme.iconSize
color: tabItem.isActive ? Theme.primary : Theme.surfaceText
visible: hasIcon
}
StyledText {
text: modelData.text || ""
anchors.horizontalCenter: parent.horizontalCenter
font.pixelSize: Theme.fontSizeMedium
color: tabItem.isActive ? Theme.primary : Theme.surfaceText
font.weight: tabItem.isActive ? Font.Medium : Font.Normal
visible: hasText
}
}
Rectangle {
id: stateLayer
anchors.fill: parent
color: Theme.surfaceTint
opacity: tabArea.pressed ? 0.12 : (tabArea.containsMouse ? 0.08 : 0)
visible: opacity > 0
radius: Theme.cornerRadius
Behavior on opacity { NumberAnimation { duration: Theme.shortDuration; easing.type: Theme.standardEasing } }
}
MouseArea {
id: tabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (tabItem.isAction) {
tabBar.actionTriggered(index)
} else {
tabBar.tabClicked(index)
}
}
}
}
}
}
Rectangle {
id: indicator
y: parent.height + 7
height: 3
width: 60
topLeftRadius: Theme.cornerRadius
topRightRadius: Theme.cornerRadius
bottomLeftRadius: 0
bottomRightRadius: 0
color: Theme.primary
visible: false
property bool animationEnabled: false
property bool initialSetupComplete: false
Behavior on x {
enabled: indicator.animationEnabled
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
Behavior on width {
enabled: indicator.animationEnabled
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
}
Rectangle {
width: parent.width
height: 1
y: parent.height + 10
color: Theme.outlineStrong
}
function updateIndicator() {
if (tabRepeater.count === 0 || currentIndex < 0 || currentIndex >= tabRepeater.count) {
return
}
const item = tabRepeater.itemAt(currentIndex)
if (!item || item.isAction) {
return
}
const tabPos = item.mapToItem(tabBar, 0, 0)
const tabCenterX = tabPos.x + item.width / 2
const indicatorWidth = 60
if (tabPos.x < 10 && currentIndex > 0) {
Qt.callLater(updateIndicator)
return
}
if (!indicator.initialSetupComplete) {
indicator.animationEnabled = false
indicator.width = indicatorWidth
indicator.x = tabCenterX - indicatorWidth / 2
indicator.visible = true
indicator.initialSetupComplete = true
indicator.animationEnabled = true
} else {
indicator.width = indicatorWidth
indicator.x = tabCenterX - indicatorWidth / 2
indicator.visible = true
}
}
onCurrentIndexChanged: {
Qt.callLater(updateIndicator)
}
onWidthChanged: Qt.callLater(updateIndicator)
}

View File

@@ -0,0 +1,204 @@
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Widgets
StyledRect {
id: root
activeFocusOnTab: true
KeyNavigation.tab: keyNavigationTab
KeyNavigation.backtab: keyNavigationBacktab
onActiveFocusChanged: {
if (activeFocus) {
textInput.forceActiveFocus()
}
}
property alias text: textInput.text
property string placeholderText: ""
property alias font: textInput.font
property alias textColor: textInput.color
property alias enabled: textInput.enabled
property alias echoMode: textInput.echoMode
property alias validator: textInput.validator
property alias maximumLength: textInput.maximumLength
property string leftIconName: ""
property int leftIconSize: Theme.iconSize
property color leftIconColor: Theme.surfaceVariantText
property color leftIconFocusedColor: Theme.primary
property bool showClearButton: false
property color backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
property color focusedBorderColor: Theme.primary
property color normalBorderColor: Theme.outlineMedium
property color placeholderColor: Theme.outlineButton
property int borderWidth: 1
property int focusedBorderWidth: 2
property real cornerRadius: Theme.cornerRadius
readonly property real leftPadding: Theme.spacingM + (leftIconName ? leftIconSize + Theme.spacingM : 0)
readonly property real rightPadding: Theme.spacingM + (showClearButton && text.length > 0 ? 24 + Theme.spacingM : 0)
property real topPadding: Theme.spacingM
property real bottomPadding: Theme.spacingM
property bool ignoreLeftRightKeys: false
property bool ignoreTabKeys: false
property var keyForwardTargets: []
property Item keyNavigationTab: null
property Item keyNavigationBacktab: null
signal textEdited
signal editingFinished
signal accepted
signal focusStateChanged(bool hasFocus)
function getActiveFocus() {
return textInput.activeFocus
}
function setFocus(value) {
textInput.focus = value
}
function forceActiveFocus() {
textInput.forceActiveFocus()
}
function selectAll() {
textInput.selectAll()
}
function clear() {
textInput.clear()
}
function insertText(str) {
textInput.insert(textInput.cursorPosition, str)
}
width: 200
height: 48
radius: cornerRadius
color: backgroundColor
border.color: textInput.activeFocus ? focusedBorderColor : normalBorderColor
border.width: textInput.activeFocus ? focusedBorderWidth : borderWidth
DankIcon {
id: leftIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
name: leftIconName
size: leftIconSize
color: textInput.activeFocus ? leftIconFocusedColor : leftIconColor
visible: leftIconName !== ""
}
TextInput {
id: textInput
anchors.fill: parent
anchors.leftMargin: root.leftPadding
anchors.rightMargin: root.rightPadding
anchors.topMargin: root.topPadding
anchors.bottomMargin: root.bottomPadding
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
verticalAlignment: TextInput.AlignVCenter
selectByMouse: !root.ignoreLeftRightKeys
clip: true
activeFocusOnTab: true
KeyNavigation.tab: root.keyNavigationTab
KeyNavigation.backtab: root.keyNavigationBacktab
onTextChanged: root.textEdited()
onEditingFinished: root.editingFinished()
onAccepted: root.accepted()
onActiveFocusChanged: root.focusStateChanged(activeFocus)
Keys.forwardTo: root.keyForwardTargets
Keys.onLeftPressed: event => {
if (root.ignoreLeftRightKeys) {
event.accepted = true
} else {
// Allow normal TextInput cursor movement
event.accepted = false
}
}
Keys.onRightPressed: event => {
if (root.ignoreLeftRightKeys) {
event.accepted = true
} else {
event.accepted = false
}
}
Keys.onPressed: event => {
if (root.ignoreTabKeys && (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab)) {
event.accepted = false
for (var i = 0; i < root.keyForwardTargets.length; i++) {
if (root.keyForwardTargets[i]) {
root.keyForwardTargets[i].Keys.pressed(event)
}
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.IBeamCursor
acceptedButtons: Qt.NoButton
}
}
StyledRect {
id: clearButton
width: 24
height: 24
radius: 12
color: clearArea.containsMouse ? Theme.outlineStrong : "transparent"
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
visible: showClearButton && text.length > 0
DankIcon {
anchors.centerIn: parent
name: "close"
size: 16
color: clearArea.containsMouse ? Theme.outline : Theme.surfaceVariantText
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
textInput.text = ""
}
}
}
StyledText {
id: placeholderLabel
anchors.fill: textInput
text: root.placeholderText
font: textInput.font
color: placeholderColor
verticalAlignment: textInput.verticalAlignment
visible: textInput.text.length === 0 && !textInput.activeFocus
elide: Text.ElideRight
}
Behavior on border.color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on border.width {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}

View File

@@ -0,0 +1,181 @@
import QtQuick
import qs.Common
import qs.Widgets
Item {
id: toggle
// API
property bool checked: false
property bool enabled: true
property bool toggling: false
property string text: ""
property string description: ""
property bool hideText: false
signal clicked
signal toggled(bool checked)
signal toggleCompleted(bool checked)
readonly property bool showText: text && !hideText
readonly property int trackWidth: 52
readonly property int trackHeight: 30
readonly property int insetCircle: 24
width: showText ? parent.width : trackWidth
height: showText ? 60 : trackHeight
function handleClick() {
if (!enabled) return
checked = !checked
clicked()
toggled(checked)
}
StyledRect {
id: background
anchors.fill: parent
radius: showText ? Theme.cornerRadius : 0
color: "transparent"
visible: showText
StateLayer {
visible: showText
disabled: !toggle.enabled
stateColor: Theme.primary
cornerRadius: parent.radius
onClicked: toggle.handleClick()
}
}
Row {
anchors.left: parent.left
anchors.right: toggleTrack.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingXS
visible: showText
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
StyledText {
text: toggle.text
font.pixelSize: Appearance.fontSize.normal
font.weight: Font.Medium
opacity: toggle.enabled ? 1 : 0.4
}
StyledText {
text: toggle.description
font.pixelSize: Appearance.fontSize.small
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: Math.min(implicitWidth, toggle.width - 120)
visible: toggle.description.length > 0
}
}
}
StyledRect {
id: toggleTrack
width: showText ? trackWidth : Math.max(parent.width, trackWidth)
height: showText ? trackHeight : Math.max(parent.height, trackHeight)
anchors.right: parent.right
anchors.rightMargin: showText ? Theme.spacingM : 0
anchors.verticalCenter: parent.verticalCenter
radius: Theme.cornerRadius
color: (checked && enabled) ? Theme.primary : Theme.surfaceVariantAlpha
opacity: toggling ? 0.6 : (enabled ? 1 : 0.4)
border.color: (!checked || !enabled) ? Theme.outline : "transparent"
readonly property int pad: Math.round((height - thumb.width) / 2)
readonly property int edgeLeft: pad
readonly property int edgeRight: width - thumb.width - pad
StyledRect {
id: thumb
width: (checked && enabled) ? insetCircle : insetCircle - 4
height: (checked && enabled) ? insetCircle : insetCircle - 4
radius: Theme.cornerRadius
anchors.verticalCenter: parent.verticalCenter
color: (checked && enabled) ? Theme.surface : Theme.outline
border.color: (checked && enabled) ? Theme.outline : Theme.outline
border.width: (checked && enabled) ? 1 : 2
x: (checked && enabled) ? toggleTrack.edgeRight : toggleTrack.edgeLeft
Behavior on x {
SequentialAnimation {
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
}
ScriptAction {
script: {
toggle.toggleCompleted(toggle.checked)
}
}
}
}
Behavior on color {
ColorAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.emphasized
}
}
Behavior on border.width {
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.emphasized
}
}
DankIcon {
id: checkIcon
anchors.centerIn: parent
name: "check"
size: 20
color: Theme.surfaceText
filled: true
opacity: checked && enabled ? 1 : 0
scale: checked && enabled ? 1 : 0.6
Behavior on opacity {
NumberAnimation {
duration: Anims.durShort
easing.type: Easing.BezierSpline
easing.bezierCurve: Anims.emphasized
}
}
Behavior on scale {
NumberAnimation {
duration: Anims.durShort
easing.type: Easing.BezierSpline
easing.bezierCurve: Anims.emphasized
}
}
}
}
StateLayer {
disabled: !toggle.enabled
stateColor: Theme.primary
cornerRadius: parent.radius
onClicked: toggle.handleClick()
}
}
}

View File

@@ -0,0 +1,83 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
PanelWindow {
id: root
WlrLayershell.namespace: "dms:tooltip"
property string text: ""
property real targetX: 0
property real targetY: 0
property var targetScreen: null
property bool alignLeft: false
property bool alignRight: false
function show(text, x, y, screen, leftAlign, rightAlign) {
root.text = text;
if (screen) {
targetScreen = screen;
const screenX = screen.x || 0;
targetX = x - screenX;
} else {
targetScreen = null;
targetX = x;
}
targetY = y;
alignLeft = leftAlign ?? false;
alignRight = rightAlign ?? false;
visible = true;
}
function hide() {
visible = false;
}
screen: targetScreen
implicitWidth: Math.min(300, Math.max(120, textContent.implicitWidth + Theme.spacingM * 2))
implicitHeight: textContent.implicitHeight + Theme.spacingS * 2
color: "transparent"
visible: false
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
anchors {
top: true
left: true
}
margins {
left: {
if (alignLeft) return Math.round(Math.max(Theme.spacingS, Math.min((targetScreen?.width ?? Screen.width) - implicitWidth - Theme.spacingS, targetX)))
if (alignRight) return Math.round(Math.max(Theme.spacingS, Math.min((targetScreen?.width ?? Screen.width) - implicitWidth - Theme.spacingS, targetX - implicitWidth)))
return Math.round(Math.max(Theme.spacingS, Math.min((targetScreen?.width ?? Screen.width) - implicitWidth - Theme.spacingS, targetX - implicitWidth / 2)))
}
top: {
if (alignLeft || alignRight) return Math.round(Math.max(Theme.spacingS, Math.min((targetScreen?.height ?? Screen.height) - implicitHeight - Theme.spacingS, targetY - implicitHeight / 2)))
return Math.round(Math.max(Theme.spacingS, Math.min((targetScreen?.height ?? Screen.height) - implicitHeight - Theme.spacingS, targetY)))
}
}
Rectangle {
anchors.fill: parent
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
radius: Theme.cornerRadius
border.width: 1
border.color: Theme.outlineMedium
Text {
id: textContent
anchors.centerIn: parent
text: root.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
wrapMode: Text.NoWrap
maximumLineCount: 1
elide: Text.ElideRight
width: Math.min(implicitWidth, 300 - Theme.spacingM * 2)
}
}
}

View File

@@ -0,0 +1,166 @@
import QtQuick
import QtQuick.Shapes
import qs.Common
Item {
id: root
property real value: 0
property real lineWidth: 2
property real wavelength: 20
property real amp: 1.6
property real phase: 0.0
property bool isPlaying: false
property real currentAmp: 1.6
property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40)
property color fillColor: Theme.primary
property color playheadColor: Theme.primary
property real dpr: (root.window ? root.window.devicePixelRatio : 1)
function snap(v) { return Math.round(v * dpr) / dpr }
readonly property real playX: snap(root.width * root.value)
readonly property real midY: snap(height / 2)
Behavior on currentAmp { NumberAnimation { duration: 300; easing.type: Easing.OutCubic } }
onIsPlayingChanged: currentAmp = isPlaying ? amp : 0
Shape {
id: flatTrack
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
layer.enabled: true
ShapePath {
strokeColor: root.trackColor
strokeWidth: snap(root.lineWidth)
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
fillColor: "transparent"
PathMove { id: flatStart; x: 0; y: root.midY }
PathLine { id: flatEnd; x: root.width; y: root.midY }
}
}
Item {
id: waveClip
anchors.fill: parent
clip: true
readonly property real startX: snap(root.lineWidth/2)
readonly property real aaBias: (0.25 / root.dpr)
readonly property real endX: Math.max(startX, Math.min(root.playX - startX - aaBias, width))
Rectangle {
id: mask
anchors.top: parent.top
anchors.bottom: parent.bottom
x: 0
width: waveClip.endX
color: "transparent"
clip: true
Shape {
id: waveShape
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width + 4 * root.wavelength
antialiasing: true
preferredRendererType: Shape.CurveRenderer
x: waveOffsetX
ShapePath {
id: wavePath
strokeColor: root.fillColor
strokeWidth: snap(root.lineWidth)
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
fillColor: "transparent"
PathSvg { id: waveSvg; path: "" }
}
}
}
Rectangle {
id: startCap
width: snap(root.lineWidth)
height: snap(root.lineWidth)
radius: width / 2
color: root.fillColor
x: waveClip.startX - width/2
y: root.midY - height/2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase)
visible: waveClip.endX > waveClip.startX
z: 2
}
Rectangle {
id: endCap
width: snap(root.lineWidth)
height: snap(root.lineWidth)
radius: width / 2
color: root.fillColor
x: waveClip.endX - width/2
y: root.midY - height/2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase)
visible: waveClip.endX > waveClip.startX
z: 2
}
}
Rectangle {
id: playhead
width: 3.5
height: Math.max(root.lineWidth + 12, 16)
radius: width / 2
color: root.playheadColor
x: root.playX - width / 2
y: root.midY - height / 2
z: 3
}
property real k: (2 * Math.PI) / Math.max(1e-6, wavelength)
function wrapMod(a, m) { let r = a % m; return r < 0 ? r + m : r }
readonly property real waveOffsetX: -wrapMod(phase / k, wavelength)
FrameAnimation {
running: root.visible && (root.isPlaying || root.currentAmp > 0)
onTriggered: {
if (root.isPlaying) root.phase += 0.03 * frameTime * 60
startCap.y = root.midY - startCap.height/2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase)
endCap.y = root.midY - endCap.height/2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase)
}
}
function buildStaticWave() {
const start = waveClip.startX - 2 * root.wavelength
const end = width + 2 * root.wavelength
if (end <= start) { waveSvg.path = ""; return }
const kLocal = k
const halfPeriod = root.wavelength / 2
function y0(x) { return root.midY + root.currentAmp * Math.sin(kLocal * x) }
function dy0(x) { return root.currentAmp * Math.cos(kLocal * x) * kLocal }
let x0 = start
let d = `M ${x0} ${y0(x0)}`
while (x0 < end) {
const x1 = Math.min(x0 + halfPeriod, end)
const dx = x1 - x0
const yA = y0(x0), yB = y0(x1)
const dyA = dy0(x0), dyB = dy0(x1)
const c1x = x0 + dx/3
const c1y = yA + (dyA * dx)/3
const c2x = x1 - dx/3
const c2y = yB - (dyB * dx)/3
d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${x1} ${yB}`
x0 = x1
}
waveSvg.path = d
}
Component.onCompleted: { currentAmp = isPlaying ? amp : 0; buildStaticWave() }
onWidthChanged: { flatStart.x = 0; flatEnd.x = width; buildStaticWave() }
onHeightChanged: buildStaticWave()
onCurrentAmpChanged: buildStaticWave()
onWavelengthChanged: { k = (2 * Math.PI) / Math.max(1e-6, wavelength); buildStaticWave() }
}

View File

@@ -0,0 +1,30 @@
import QtQuick
import qs.Services
Item {
id: root
required property string varName
property var defaultValue: undefined
readonly property var value: {
const pid = parent?.pluginId ?? ""
if (!pid || !PluginService.globalVars[pid]) {
return defaultValue
}
return PluginService.globalVars[pid][varName] ?? defaultValue
}
function set(newValue) {
const pid = parent?.pluginId ?? ""
if (pid) {
PluginService.setGlobalVar(pid, varName, newValue)
} else {
console.warn("PluginGlobalVar: Cannot set", varName, "- no pluginId from parent")
}
}
visible: false
width: 0
height: 0
}

View File

@@ -0,0 +1,22 @@
import QtQuick
import qs.Common
MouseArea {
id: root
property bool disabled: false
property color stateColor: Theme.surfaceText
property real cornerRadius: parent && parent.radius !== undefined ? parent.radius : Theme.cornerRadius
readonly property real stateOpacity: disabled ? 0 : pressed ? 0.12 : containsMouse ? 0.08 : 0
anchors.fill: parent
cursorShape: disabled ? undefined : Qt.PointingHandCursor
hoverEnabled: true
Rectangle {
anchors.fill: parent
radius: root.cornerRadius
color: Qt.rgba(stateColor.r, stateColor.g, stateColor.b, stateOpacity)
}
}

View File

@@ -0,0 +1,29 @@
import QtQuick
import qs.Common
Rectangle {
color: "transparent"
radius: Appearance.rounding.normal
readonly property var standardAnimation: {
"duration": Appearance.anim.durations.normal,
"easing.type": Easing.BezierSpline,
"easing.bezierCurve": Appearance.anim.curves.standard
}
Behavior on radius {
NumberAnimation {
duration: standardAnimation.duration
easing.type: standardAnimation["easing.type"]
easing.bezierCurve: standardAnimation["easing.bezierCurve"]
}
}
Behavior on opacity {
NumberAnimation {
duration: standardAnimation.duration
easing.type: standardAnimation["easing.type"]
easing.bezierCurve: standardAnimation["easing.bezierCurve"]
}
}
}

View File

@@ -0,0 +1,50 @@
import QtQuick
import qs.Common
import qs.Services
Text {
property bool isMonospace: false
FontLoader {
id: interFont
source: Qt.resolvedUrl("../assets/fonts/inter/InterVariable.ttf")
}
FontLoader {
id: firaCodeFont
source: Qt.resolvedUrl("../assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf")
}
readonly property string resolvedFontFamily: {
const requestedFont = isMonospace ? SettingsData.monoFontFamily : SettingsData.fontFamily
const defaultFont = isMonospace ? SettingsData.defaultMonoFontFamily : SettingsData.defaultFontFamily
if (requestedFont === defaultFont) {
return isMonospace ? firaCodeFont.name : interFont.name
}
return requestedFont
}
readonly property var standardAnimation: {
"duration": Appearance.anim.durations.normal,
"easing.type": Easing.BezierSpline,
"easing.bezierCurve": Appearance.anim.curves.standard
}
color: Theme.surfaceText
font.pixelSize: Appearance.fontSize.normal
font.family: resolvedFontFamily
font.weight: SettingsData.fontWeight
wrapMode: Text.WordWrap
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
antialiasing: true
Behavior on opacity {
NumberAnimation {
duration: standardAnimation.duration
easing.type: standardAnimation["easing.type"]
easing.bezierCurve: standardAnimation["easing.bezierCurve"]
}
}
}

View File

@@ -0,0 +1,24 @@
import QtQuick
import qs.Common
import qs.Services
TextMetrics {
property bool isMonospace: false
readonly property string resolvedFontFamily: {
const requestedFont = isMonospace ? SettingsData.monoFontFamily : SettingsData.fontFamily
const defaultFont = isMonospace ? SettingsData.defaultMonoFontFamily : SettingsData.defaultFontFamily
if (requestedFont === defaultFont) {
const availableFonts = Qt.fontFamilies()
if (!availableFonts.includes(requestedFont)) {
return isMonospace ? "Monospace" : "DejaVu Sans"
}
}
return requestedFont
}
font.pixelSize: Appearance.fontSize.normal
font.family: resolvedFontFamily
font.weight: SettingsData.fontWeight
}

View File

@@ -0,0 +1,73 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import qs.Common
import qs.Widgets
Item {
id: root
property string colorOverride: ""
property real brightnessOverride: 0.5
property real contrastOverride: 1
readonly property bool hasColorOverride: colorOverride !== ""
property bool useNerdFont: false
property string nerdFontIcon: ""
IconImage {
id: iconImage
anchors.fill: parent
visible: !root.useNerdFont
smooth: true
asynchronous: true
layer.enabled: hasColorOverride
layer.effect: MultiEffect {
colorization: 1
colorizationColor: colorOverride
brightness: brightnessOverride
contrast: contrastOverride
}
}
DankNFIcon {
id: nfIcon
anchors.centerIn: parent
visible: root.useNerdFont
name: root.nerdFontIcon
size: Math.min(root.width, root.height)
color: hasColorOverride ? colorOverride : Theme.surfaceText
}
Component.onCompleted: {
Proc.runCommand(null, ["sh", "-c", ". /etc/os-release && echo $ID"], (output, exitCode) => {
if (exitCode !== 0) return
const distroId = output.trim()
// Nerd fonts are better than images usually
const supportedDistroNFs = ["debian", "arch", "archcraft", "fedora", "nixos", "ubuntu", "guix", "gentoo", "endeavouros", "manjaro", "opensuse"]
if (supportedDistroNFs.includes(distroId)) {
root.useNerdFont = true
root.nerdFontIcon = distroId
return
}
Proc.runCommand(null, ["sh", "-c", ". /etc/os-release && echo $LOGO"], (logoOutput, logoExitCode) => {
if (logoExitCode !== 0) return
const logo = logoOutput.trim()
if (logo === "cachyos") {
iconImage.source = "file:///usr/share/icons/cachyos.svg"
return
} else if (logo === "guix-icon") {
iconImage.source = "file:///run/current-system/profile/share/icons/hicolor/scalable/apps/guix-icon.svg"
return
}
iconImage.source = Quickshell.iconPath(logo, true)
}, 0)
}, 0)
}
}