Rearrange for tidier structure (#2212)

This commit is contained in:
clsty
2025-10-16 07:19:55 +08:00
parent 13065d7e5a
commit 8b493e091d
529 changed files with 165 additions and 138 deletions
@@ -0,0 +1,376 @@
import QtQuick
import Quickshell
import qs.modules.common.functions
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property QtObject m3colors
property QtObject animation
property QtObject animationCurves
property QtObject colors
property QtObject rounding
property QtObject font
property QtObject sizes
property string syntaxHighlightingTheme
// Transparency. The quadratic functions were derived from analysis of hand-picked transparency values.
ColorQuantizer {
id: wallColorQuant
property string wallpaperPath: Config.options.background.wallpaperPath
property bool wallpaperIsVideo: wallpaperPath.endsWith(".mp4") || wallpaperPath.endsWith(".webm") || wallpaperPath.endsWith(".mkv") || wallpaperPath.endsWith(".avi") || wallpaperPath.endsWith(".mov")
source: Qt.resolvedUrl(wallpaperIsVideo ? Config.options.background.thumbnailPath : Config.options.background.wallpaperPath)
depth: 0 // 2^0 = 1 color
rescaleSize: 10
}
property real wallpaperVibrancy: (wallColorQuant.colors[0]?.hslSaturation + wallColorQuant.colors[0]?.hslLightness) / 2
property real autoBackgroundTransparency: { // y = 0.5768x^2 - 0.759x + 0.2896
let x = wallpaperVibrancy
let y = 0.5768 * (x * x) - 0.759 * (x) + 0.2896
return Math.max(0, Math.min(0.22, y))
}
property real autoContentTransparency: { // y = -10.1734x^2 + 3.4457x + 0.1872
let x = autoBackgroundTransparency
let y = -10.1734 * (x * x) + 3.4457 * (x) + 0.1872
return Math.max(0, Math.min(0.6, y))
}
property real backgroundTransparency: Config?.options.appearance.transparency.enable ? Config?.options.appearance.transparency.automatic ? autoBackgroundTransparency : Config?.options.appearance.transparency.backgroundTransparency : 0
property real contentTransparency: Config?.options.appearance.transparency.enable ? Config?.options.appearance.transparency.automatic ? autoContentTransparency : Config?.options.appearance.transparency.contentTransparency : 0
m3colors: QtObject {
property bool darkmode: false
property bool transparent: false
property color m3primary_paletteKeyColor: "#91689E"
property color m3secondary_paletteKeyColor: "#837186"
property color m3tertiary_paletteKeyColor: "#9D6A67"
property color m3neutral_paletteKeyColor: "#7C757B"
property color m3neutral_variant_paletteKeyColor: "#7D747D"
property color m3background: "#161217"
property color m3onBackground: "#EAE0E7"
property color m3surface: "#161217"
property color m3surfaceDim: "#161217"
property color m3surfaceBright: "#3D373D"
property color m3surfaceContainerLowest: "#110D12"
property color m3surfaceContainerLow: "#1F1A1F"
property color m3surfaceContainer: "#231E23"
property color m3surfaceContainerHigh: "#2D282E"
property color m3surfaceContainerHighest: "#383339"
property color m3onSurface: "#EAE0E7"
property color m3surfaceVariant: "#4C444D"
property color m3onSurfaceVariant: "#CFC3CD"
property color m3inverseSurface: "#EAE0E7"
property color m3inverseOnSurface: "#342F34"
property color m3outline: "#988E97"
property color m3outlineVariant: "#4C444D"
property color m3shadow: "#000000"
property color m3scrim: "#000000"
property color m3surfaceTint: "#E5B6F2"
property color m3primary: "#E5B6F2"
property color m3onPrimary: "#452152"
property color m3primaryContainer: "#5D386A"
property color m3onPrimaryContainer: "#F9D8FF"
property color m3inversePrimary: "#775084"
property color m3secondary: "#D5C0D7"
property color m3onSecondary: "#392C3D"
property color m3secondaryContainer: "#534457"
property color m3onSecondaryContainer: "#F2DCF3"
property color m3tertiary: "#F5B7B3"
property color m3onTertiary: "#4C2523"
property color m3tertiaryContainer: "#BA837F"
property color m3onTertiaryContainer: "#000000"
property color m3error: "#FFB4AB"
property color m3onError: "#690005"
property color m3errorContainer: "#93000A"
property color m3onErrorContainer: "#FFDAD6"
property color m3primaryFixed: "#F9D8FF"
property color m3primaryFixedDim: "#E5B6F2"
property color m3onPrimaryFixed: "#2E0A3C"
property color m3onPrimaryFixedVariant: "#5D386A"
property color m3secondaryFixed: "#F2DCF3"
property color m3secondaryFixedDim: "#D5C0D7"
property color m3onSecondaryFixed: "#241727"
property color m3onSecondaryFixedVariant: "#514254"
property color m3tertiaryFixed: "#FFDAD7"
property color m3tertiaryFixedDim: "#F5B7B3"
property color m3onTertiaryFixed: "#331110"
property color m3onTertiaryFixedVariant: "#663B39"
property color m3success: "#B5CCBA"
property color m3onSuccess: "#213528"
property color m3successContainer: "#374B3E"
property color m3onSuccessContainer: "#D1E9D6"
property color term0: "#EDE4E4"
property color term1: "#B52755"
property color term2: "#A97363"
property color term3: "#AF535D"
property color term4: "#A67F7C"
property color term5: "#B2416B"
property color term6: "#8D76AD"
property color term7: "#272022"
property color term8: "#0E0D0D"
property color term9: "#B52755"
property color term10: "#A97363"
property color term11: "#AF535D"
property color term12: "#A67F7C"
property color term13: "#B2416B"
property color term14: "#8D76AD"
property color term15: "#221A1A"
}
colors: QtObject {
property color colSubtext: m3colors.m3outline
property color colLayer0: ColorUtils.mix(ColorUtils.transparentize(m3colors.m3background, root.backgroundTransparency), m3colors.m3primary, Config.options.appearance.extraBackgroundTint ? 0.99 : 1)
property color colOnLayer0: m3colors.m3onBackground
property color colLayer0Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.9, root.contentTransparency))
property color colLayer0Active: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.8, root.contentTransparency))
property color colLayer0Border: ColorUtils.mix(root.m3colors.m3outlineVariant, colLayer0, 0.4)
property color colLayer1: ColorUtils.transparentize(m3colors.m3surfaceContainerLow, root.contentTransparency);
property color colOnLayer1: m3colors.m3onSurfaceVariant;
property color colOnLayer1Inactive: ColorUtils.mix(colOnLayer1, colLayer1, 0.45);
property color colLayer2: ColorUtils.transparentize(m3colors.m3surfaceContainer, root.contentTransparency)
property color colOnLayer2: m3colors.m3onSurface;
property color colOnLayer2Disabled: ColorUtils.mix(colOnLayer2, m3colors.m3background, 0.4);
property color colLayer1Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer1, colOnLayer1, 0.92), root.contentTransparency)
property color colLayer1Active: ColorUtils.transparentize(ColorUtils.mix(colLayer1, colOnLayer1, 0.85), root.contentTransparency);
property color colLayer2Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer2, colOnLayer2, 0.90), root.contentTransparency)
property color colLayer2Active: ColorUtils.transparentize(ColorUtils.mix(colLayer2, colOnLayer2, 0.80), root.contentTransparency);
property color colLayer2Disabled: ColorUtils.transparentize(ColorUtils.mix(colLayer2, m3colors.m3background, 0.8), root.contentTransparency);
property color colLayer3: ColorUtils.transparentize(m3colors.m3surfaceContainerHigh, root.contentTransparency)
property color colOnLayer3: m3colors.m3onSurface;
property color colLayer3Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer3, colOnLayer3, 0.90), root.contentTransparency)
property color colLayer3Active: ColorUtils.transparentize(ColorUtils.mix(colLayer3, colOnLayer3, 0.80), root.contentTransparency);
property color colLayer4: ColorUtils.transparentize(m3colors.m3surfaceContainerHighest, root.contentTransparency)
property color colOnLayer4: m3colors.m3onSurface;
property color colLayer4Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer4, colOnLayer4, 0.90), root.contentTransparency)
property color colLayer4Active: ColorUtils.transparentize(ColorUtils.mix(colLayer4, colOnLayer4, 0.80), root.contentTransparency);
property color colPrimary: m3colors.m3primary
property color colOnPrimary: m3colors.m3onPrimary
property color colPrimaryHover: ColorUtils.mix(colors.colPrimary, colLayer1Hover, 0.87)
property color colPrimaryActive: ColorUtils.mix(colors.colPrimary, colLayer1Active, 0.7)
property color colPrimaryContainer: m3colors.m3primaryContainer
property color colPrimaryContainerHover: ColorUtils.mix(colors.colPrimaryContainer, colors.colOnPrimaryContainer, 0.9)
property color colPrimaryContainerActive: ColorUtils.mix(colors.colPrimaryContainer, colors.colOnPrimaryContainer, 0.8)
property color colOnPrimaryContainer: m3colors.m3onPrimaryContainer
property color colSecondary: m3colors.m3secondary
property color colSecondaryHover: ColorUtils.mix(m3colors.m3secondary, colLayer1Hover, 0.85)
property color colSecondaryActive: ColorUtils.mix(m3colors.m3secondary, colLayer1Active, 0.4)
property color colSecondaryContainer: m3colors.m3secondaryContainer
property color colSecondaryContainerHover: ColorUtils.mix(m3colors.m3secondaryContainer, m3colors.m3onSecondaryContainer, 0.90)
property color colSecondaryContainerActive: ColorUtils.mix(m3colors.m3secondaryContainer, m3colors.m3onSecondaryContainer, 0.54)
property color colTertiary: m3colors.m3tertiary
property color colTertiaryHover: ColorUtils.mix(m3colors.m3tertiary, colLayer1Hover, 0.85)
property color colTertiaryActive: ColorUtils.mix(m3colors.m3tertiary, colLayer1Active, 0.4)
property color colTertiaryContainer: m3colors.m3tertiaryContainer
property color colTertiaryContainerHover: ColorUtils.mix(m3colors.m3tertiaryContainer, m3colors.m3onTertiaryContainer, 0.90)
property color colTertiaryContainerActive: ColorUtils.mix(m3colors.m3tertiaryContainer, colLayer1Active, 0.54)
property color colOnSecondaryContainer: m3colors.m3onSecondaryContainer
property color colSurfaceContainerLow: ColorUtils.transparentize(m3colors.m3surfaceContainerLow, root.contentTransparency)
property color colSurfaceContainer: ColorUtils.transparentize(m3colors.m3surfaceContainer, root.contentTransparency)
property color colSurfaceContainerHigh: ColorUtils.transparentize(m3colors.m3surfaceContainerHigh, root.contentTransparency)
property color colSurfaceContainerHighest: ColorUtils.transparentize(m3colors.m3surfaceContainerHighest, root.contentTransparency)
property color colSurfaceContainerHighestHover: ColorUtils.mix(m3colors.m3surfaceContainerHighest, m3colors.m3onSurface, 0.95)
property color colSurfaceContainerHighestActive: ColorUtils.mix(m3colors.m3surfaceContainerHighest, m3colors.m3onSurface, 0.85)
property color colOnSurface: m3colors.m3onSurface
property color colOnSurfaceVariant: m3colors.m3onSurfaceVariant
property color colTooltip: m3colors.m3inverseSurface
property color colOnTooltip: m3colors.m3inverseOnSurface
property color colScrim: ColorUtils.transparentize(m3colors.m3scrim, 0.5)
property color colShadow: ColorUtils.transparentize(m3colors.m3shadow, 0.7)
property color colOutline: m3colors.m3outline
property color colOutlineVariant: m3colors.m3outlineVariant
property color colError: m3colors.m3error
property color colErrorHover: ColorUtils.mix(m3colors.m3error, colLayer1Hover, 0.85)
property color colErrorActive: ColorUtils.mix(m3colors.m3error, colLayer1Active, 0.7)
property color colOnError: m3colors.m3onError
property color colErrorContainer: m3colors.m3errorContainer
property color colErrorContainerHover: ColorUtils.mix(m3colors.m3errorContainer, m3colors.m3onErrorContainer, 0.90)
property color colErrorContainerActive: ColorUtils.mix(m3colors.m3errorContainer, m3colors.m3onErrorContainer, 0.70)
property color colOnErrorContainer: m3colors.m3onErrorContainer
}
rounding: QtObject {
property int unsharpen: 2
property int unsharpenmore: 6
property int verysmall: 8
property int small: 12
property int normal: 17
property int large: 23
property int verylarge: 30
property int full: 9999
property int screenRounding: large
property int windowRounding: 18
}
font: QtObject {
property QtObject family: QtObject {
property string main: "Rubik"
property string title: "Gabarito"
property string iconMaterial: "Material Symbols Rounded"
property string iconNerd: "JetBrains Mono NF"
property string monospace: "JetBrains Mono NF"
property string reading: "Readex Pro"
property string expressive: "Space Grotesk"
}
property QtObject pixelSize: QtObject {
property int smallest: 10
property int smaller: 12
property int smallie: 13
property int small: 15
property int normal: 16
property int large: 17
property int larger: 19
property int huge: 22
property int hugeass: 23
property int title: huge
}
}
animationCurves: QtObject {
readonly property list<real> expressiveFastSpatial: [0.42, 1.67, 0.21, 0.90, 1, 1] // Default, 350ms
readonly property list<real> expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1.00, 1, 1] // Default, 500ms
readonly property list<real> expressiveSlowSpatial: [0.39, 1.29, 0.35, 0.98, 1, 1] // Default, 650ms
readonly property list<real> expressiveEffects: [0.34, 0.80, 0.34, 1.00, 1, 1] // Default, 200ms
readonly property list<real> emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1]
readonly property list<real> emphasizedFirstHalf: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82]
readonly property list<real> emphasizedLastHalf: [5 / 24, 0.82, 0.25, 1, 1, 1]
readonly property list<real> emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1]
readonly property list<real> emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1]
readonly property list<real> standard: [0.2, 0, 0, 1, 1, 1]
readonly property list<real> standardAccel: [0.3, 0, 1, 1, 1, 1]
readonly property list<real> standardDecel: [0, 0, 0, 1, 1, 1]
readonly property real expressiveFastSpatialDuration: 350
readonly property real expressiveDefaultSpatialDuration: 500
readonly property real expressiveSlowSpatialDuration: 650
readonly property real expressiveEffectsDuration: 200
}
animation: QtObject {
property QtObject elementMove: QtObject {
property int duration: animationCurves.expressiveDefaultSpatialDuration
property int type: Easing.BezierSpline
property list<real> bezierCurve: animationCurves.expressiveDefaultSpatial
property int velocity: 650
property Component numberAnimation: Component {
NumberAnimation {
duration: root.animation.elementMove.duration
easing.type: root.animation.elementMove.type
easing.bezierCurve: root.animation.elementMove.bezierCurve
}
}
}
property QtObject elementMoveEnter: QtObject {
property int duration: 400
property int type: Easing.BezierSpline
property list<real> bezierCurve: animationCurves.emphasizedDecel
property int velocity: 650
property Component numberAnimation: Component {
NumberAnimation {
duration: root.animation.elementMoveEnter.duration
easing.type: root.animation.elementMoveEnter.type
easing.bezierCurve: root.animation.elementMoveEnter.bezierCurve
}
}
}
property QtObject elementMoveExit: QtObject {
property int duration: 200
property int type: Easing.BezierSpline
property list<real> bezierCurve: animationCurves.emphasizedAccel
property int velocity: 650
property Component numberAnimation: Component {
NumberAnimation {
duration: root.animation.elementMoveExit.duration
easing.type: root.animation.elementMoveExit.type
easing.bezierCurve: root.animation.elementMoveExit.bezierCurve
}
}
}
property QtObject elementMoveFast: QtObject {
property int duration: animationCurves.expressiveEffectsDuration
property int type: Easing.BezierSpline
property list<real> bezierCurve: animationCurves.expressiveEffects
property int velocity: 850
property Component colorAnimation: Component { ColorAnimation {
duration: root.animation.elementMoveFast.duration
easing.type: root.animation.elementMoveFast.type
easing.bezierCurve: root.animation.elementMoveFast.bezierCurve
}}
property Component numberAnimation: Component { NumberAnimation {
duration: root.animation.elementMoveFast.duration
easing.type: root.animation.elementMoveFast.type
easing.bezierCurve: root.animation.elementMoveFast.bezierCurve
}}
}
property QtObject elementResize: QtObject {
property int duration: 300
property int type: Easing.BezierSpline
property list<real> bezierCurve: animationCurves.emphasized
property int velocity: 650
property Component numberAnimation: Component {
NumberAnimation {
duration: root.animation.elementResize.duration
easing.type: root.animation.elementResize.type
easing.bezierCurve: root.animation.elementResize.bezierCurve
}
}
}
property QtObject clickBounce: QtObject {
property int duration: 200
property int type: Easing.BezierSpline
property list<real> bezierCurve: animationCurves.expressiveFastSpatial
property int velocity: 850
property Component numberAnimation: Component { NumberAnimation {
duration: root.animation.clickBounce.duration
easing.type: root.animation.clickBounce.type
easing.bezierCurve: root.animation.clickBounce.bezierCurve
}}
}
property QtObject scroll: QtObject {
property int duration: 200
property int type: Easing.BezierSpline
property list<real> bezierCurve: animationCurves.standardDecel
}
property QtObject menuDecel: QtObject {
property int duration: 350
property int type: Easing.OutExpo
}
}
sizes: QtObject {
property real baseBarHeight: 40
property real barHeight: Config.options.bar.cornerStyle === 1 ?
(baseBarHeight + root.sizes.hyprlandGapsOut * 2) : baseBarHeight
property real barCenterSideModuleWidth: Config.options?.bar.verbose ? 360 : 140
property real barCenterSideModuleWidthShortened: 280
property real barCenterSideModuleWidthHellaShortened: 190
property real barShortenScreenWidthThreshold: 1200 // Shorten if screen width is at most this value
property real barHellaShortenScreenWidthThreshold: 1000 // Shorten even more...
property real elevationMargin: 10
property real fabShadowRadius: 5
property real fabHoveredShadowRadius: 7
property real hyprlandGapsOut: 5
property real mediaControlsWidth: 440
property real mediaControlsHeight: 160
property real notificationPopupWidth: 410
property real osdWidth: 180
property real searchWidthCollapsed: 260
property real searchWidth: 450
property real sidebarWidth: 460
property real sidebarWidthExtended: 750
property real baseVerticalBarWidth: 46
property real verticalBarWidth: Config.options.bar.cornerStyle === 1 ?
(baseVerticalBarWidth + root.sizes.hyprlandGapsOut * 2) : baseVerticalBarWidth
property real wallpaperSelectorWidth: 1200
property real wallpaperSelectorHeight: 690
property real wallpaperSelectorItemMargins: 8
property real wallpaperSelectorItemPadding: 6
}
syntaxHighlightingTheme: root.m3colors.darkmode ? "Monokai" : "ayu Light"
}
@@ -0,0 +1,442 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string filePath: Directories.shellConfigPath
property alias options: configOptionsJsonAdapter
property bool ready: false
property int readWriteDelay: 50 // milliseconds
function setNestedValue(nestedKey, value) {
let keys = nestedKey.split(".");
let obj = root.options;
let parents = [obj];
// Traverse and collect parent objects
for (let i = 0; i < keys.length - 1; ++i) {
if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") {
obj[keys[i]] = {};
}
obj = obj[keys[i]];
parents.push(obj);
}
// Convert value to correct type using JSON.parse when safe
let convertedValue = value;
if (typeof value === "string") {
let trimmed = value.trim();
if (trimmed === "true" || trimmed === "false" || !isNaN(Number(trimmed))) {
try {
convertedValue = JSON.parse(trimmed);
} catch (e) {
convertedValue = value;
}
}
}
obj[keys[keys.length - 1]] = convertedValue;
}
Timer {
id: fileReloadTimer
interval: root.readWriteDelay
repeat: false
onTriggered: {
configFileView.reload()
}
}
Timer {
id: fileWriteTimer
interval: root.readWriteDelay
repeat: false
onTriggered: {
configFileView.writeAdapter()
}
}
FileView {
id: configFileView
path: root.filePath
watchChanges: true
onFileChanged: fileReloadTimer.restart()
onAdapterUpdated: fileWriteTimer.restart()
onLoaded: root.ready = true
onLoadFailed: error => {
if (error == FileViewError.FileNotFound) {
writeAdapter();
}
}
JsonAdapter {
id: configOptionsJsonAdapter
property JsonObject policies: JsonObject {
property int ai: 1 // 0: No | 1: Yes | 2: Local
property int weeb: 1 // 0: No | 1: Open | 2: Closet
}
property JsonObject ai: JsonObject {
property string systemPrompt: "## Style\n- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system\n- Desktop environment: {DE}\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## 🐧 Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n"
property string tool: "functions" // search, functions, or none
property list<var> extraModels: [
{
"api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models
"description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B",
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
"homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory
"icon": "spark-symbolic", // Not mandatory
"key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory
"key_id": "openrouter",
"model": "deepseek/deepseek-r1-distill-llama-70b:free",
"name": "Custom: DS R1 Dstl. LLaMA 70B",
"requires_key": true
}
]
}
property JsonObject appearance: JsonObject {
property bool extraBackgroundTint: true
property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen
property JsonObject transparency: JsonObject {
property bool enable: false
property bool automatic: true
property real backgroundTransparency: 0.11
property real contentTransparency: 0.57
}
property JsonObject wallpaperTheming: JsonObject {
property bool enableAppsAndShell: true
property bool enableQtApps: true
property bool enableTerminal: true
property JsonObject terminalGenerationProps: JsonObject {
property real harmony: 0.6
property real harmonizeThreshold: 100
property real termFgBoost: 0.35
property bool forceDarkMode: false
}
}
property JsonObject palette: JsonObject {
property string type: "auto" // Allowed: auto, scheme-content, scheme-expressive, scheme-fidelity, scheme-fruit-salad, scheme-monochrome, scheme-neutral, scheme-rainbow, scheme-tonal-spot
}
}
property JsonObject audio: JsonObject {
// Values in %
property JsonObject protection: JsonObject {
// Prevent sudden bangs
property bool enable: false
property real maxAllowedIncrease: 10
property real maxAllowed: 99
}
}
property JsonObject apps: JsonObject {
property string bluetooth: "kcmshell6 kcm_bluetooth"
property string network: "kitty -1 fish -c nmtui"
property string networkEthernet: "kcmshell6 kcm_networkmanagement"
property string taskManager: "plasma-systemmonitor --page-name Processes"
property string terminal: "kitty -1" // This is only for shell actions
}
property JsonObject background: JsonObject {
property JsonObject clock: JsonObject {
property bool fixedPosition: false
property real x: -500
property real y: -500
property bool show: true
property string style: "cookie" // Options: "cookie", "digital"
property real scale: 1
property JsonObject cookie: JsonObject {
property bool aiStyling: false
property int sides: 14
property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none"
property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide"
property string minuteHandStyle: "medium" // Options "classic", "thin", "medium", "bold", "hide"
property string secondHandStyle: "dot" // Options: "dot", "line", "classic", "hide"
property string dateStyle: "bubble" // Options: "border", "rect", "bubble" , "hide"
property bool timeIndicators: true
property bool hourMarks: false
property bool dateInClock: true
property bool constantlyRotate: false
}
}
property string wallpaperPath: ""
property string thumbnailPath: ""
property string quote: ""
property bool showQuote: false
property bool hideWhenFullscreen: true
property JsonObject parallax: JsonObject {
property bool vertical: false
property bool autoVertical: false
property bool enableWorkspace: true
property real workspaceZoom: 1.07 // Relative to your screen, not wallpaper size
property bool enableSidebar: true
property real clockFactor: 0.8
}
}
property JsonObject bar: JsonObject {
property JsonObject autoHide: JsonObject {
property bool enable: false
property int hoverRegionWidth: 2
property bool pushWindows: false
property JsonObject showWhenPressingSuper: JsonObject {
property bool enable: true
property int delay: 140
}
}
property bool bottom: false // Instead of top
property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle
property bool borderless: false // true for no grouping of items
property string topLeftIcon: "spark" // Options: "distro" or any icon name in ~/.config/quickshell/ii/assets/icons
property bool showBackground: true
property bool verbose: true
property bool vertical: false
property JsonObject resources: JsonObject {
property bool alwaysShowSwap: true
property bool alwaysShowCpu: true
property int memoryWarningThreshold: 95
property int swapWarningThreshold: 85
property int cpuWarningThreshold: 90
}
property list<string> screenList: [] // List of names, like "eDP-1", find out with 'hyprctl monitors' command
property JsonObject utilButtons: JsonObject {
property bool showScreenSnip: true
property bool showColorPicker: false
property bool showMicToggle: false
property bool showKeyboardToggle: true
property bool showDarkModeToggle: true
property bool showPerformanceProfileToggle: false
}
property JsonObject tray: JsonObject {
property bool monochromeIcons: true
property bool showItemId: false
property bool invertPinnedItems: true // Makes the below a whitelist for the tray and blacklist for the pinned area
property list<string> pinnedItems: [ ]
}
property JsonObject workspaces: JsonObject {
property bool monochromeIcons: true
property int shown: 10
property bool showAppIcons: true
property bool alwaysShowNumbers: false
property int showNumberDelay: 300 // milliseconds
property list<string> numberMap: ["1", "2"] // Characters to show instead of numbers on workspace indicator
property bool useNerdFont: false
}
property JsonObject weather: JsonObject {
property bool enable: false
property bool enableGPS: true // gps based location
property string city: "" // When 'enableGPS' is false
property bool useUSCS: false // Instead of metric (SI) units
property int fetchInterval: 10 // minutes
}
property JsonObject indicators: JsonObject {
property JsonObject notifications: JsonObject {
property bool showUnreadCount: false
}
}
}
property JsonObject battery: JsonObject {
property int low: 20
property int critical: 5
property bool automaticSuspend: true
property int suspend: 3
}
property JsonObject conflictKiller: JsonObject {
property bool autoKillNotificationDaemons: false
property bool autoKillTrays: false
}
property JsonObject crosshair: JsonObject {
// Valorant crosshair format. Use https://www.vcrdb.net/builder
property string code: "0;P;d;1;0l;10;0o;2;1b;0"
}
property JsonObject dock: JsonObject {
property bool enable: false
property bool monochromeIcons: true
property real height: 60
property real hoverRegionHeight: 2
property bool pinnedOnStartup: false
property bool hoverToReveal: true // When false, only reveals on empty workspace
property list<string> pinnedApps: [ // IDs of pinned entries
"org.kde.dolphin", "kitty",]
property list<string> ignoredAppRegexes: []
}
property JsonObject interactions: JsonObject {
property JsonObject scrolling: JsonObject {
property bool fasterTouchpadScroll: false // Enable faster scrolling with touchpad
property int mouseScrollDeltaThreshold: 120 // delta >= this then it gets detected as mouse scroll rather than touchpad
property int mouseScrollFactor: 120
property int touchpadScrollFactor: 450
}
property JsonObject deadPixelWorkaround: JsonObject { // Hyprland leaves out 1 pixel on the right for interactions
property bool enable: false
}
}
property JsonObject language: JsonObject {
property string ui: "auto" // UI language. "auto" for system locale, or specific language code like "zh_CN", "en_US"
property JsonObject translator: JsonObject {
property string engine: "auto" // Run `trans -list-engines` for available engines. auto should use google
property string targetLanguage: "auto" // Run `trans -list-all` for available languages
property string sourceLanguage: "auto"
}
}
property JsonObject light: JsonObject {
property JsonObject night: JsonObject {
property bool automatic: true
property string from: "19:00" // Format: "HH:mm", 24-hour time
property string to: "06:30" // Format: "HH:mm", 24-hour time
property int colorTemperature: 5000
}
}
property JsonObject lock: JsonObject {
property bool useHyprlock: false
property bool launchOnStartup: false
property JsonObject blur: JsonObject {
property bool enable: false
property real radius: 100
property real extraZoom: 1.1
}
property bool centerClock: true
property bool showLockedText: true
property JsonObject security: JsonObject {
property bool unlockKeyring: true
property bool requirePasswordToPower: false
}
}
property JsonObject media: JsonObject {
// Attempt to remove dupes (the aggregator playerctl one and browsers' native ones when there's plasma browser integration)
property bool filterDuplicatePlayers: true
}
property JsonObject networking: JsonObject {
property string userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
}
property JsonObject notifications: JsonObject {
property int timeout: 7000
}
property JsonObject osd: JsonObject {
property int timeout: 1000
}
property JsonObject osk: JsonObject {
property string layout: "qwerty_full"
property bool pinnedOnStartup: false
}
property JsonObject overview: JsonObject {
property bool enable: true
property real scale: 0.18 // Relative to screen size
property real rows: 2
property real columns: 5
}
property JsonObject resources: JsonObject {
property int updateInterval: 3000
}
property JsonObject search: JsonObject {
property int nonAppResultDelay: 30 // This prevents lagging when typing
property string engineBaseUrl: "https://www.google.com/search?q="
property list<string> excludedSites: ["quora.com"]
property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird.
property JsonObject prefix: JsonObject {
property bool showDefaultActionsWithoutPrefix: true
property string action: "/"
property string app: ">"
property string clipboard: ";"
property string emojis: ":"
property string math: "="
property string shellCommand: "$"
property string webSearch: "?"
}
}
property JsonObject sidebar: JsonObject {
property bool keepRightSidebarLoaded: true
property JsonObject translator: JsonObject {
property bool enable: false
property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag.
}
property JsonObject ai: JsonObject {
property bool textFadeIn: true
}
property JsonObject booru: JsonObject {
property bool allowNsfw: false
property string defaultProvider: "yandere"
property int limit: 20
property JsonObject zerochan: JsonObject {
property string username: "[unset]"
}
}
property JsonObject cornerOpen: JsonObject {
property bool enable: true
property bool bottom: false
property bool valueScroll: true
property bool clickless: false
property real cornerRegionWidth: 60
property real cornerRegionHeight: 2
property bool visualize: false
}
}
property JsonObject time: JsonObject {
// https://doc.qt.io/qt-6/qtime.html#toString
property string format: "hh:mm"
property string shortDateFormat: "dd/MM"
property string dateFormat: "ddd, dd/MM"
property JsonObject pomodoro: JsonObject {
property string alertSound: ""
property int breakTime: 300
property int cyclesBeforeLongBreak: 4
property int focus: 1500
property int longBreak: 900
}
property bool secondPrecision: false
}
property JsonObject wallpaperSelector: JsonObject {
property bool useSystemFileDialog: false
}
property JsonObject windows: JsonObject {
property bool showTitlebar: true // Client-side decoration for shell apps
property bool centerTitle: true
}
property JsonObject hacks: JsonObject {
property int arbitraryRaceConditionDelay: 20 // milliseconds
}
property JsonObject screenshotTool: JsonObject {
property bool showContentRegions: true
}
property JsonObject workSafety: JsonObject {
property JsonObject enable: JsonObject {
property bool wallpaper: true
property bool clipboard: true
}
property JsonObject triggerCondition: JsonObject {
property list<string> networkNameKeywords: ["airport", "cafe", "college", "company", "eduroam", "free", "guest", "public", "school", "university"]
property list<string> fileKeywords: ["anime", "booru", "ecchi", "hentai", "yande.re", "konachan", "breast", "nipples", "pussy", "nsfw", "spoiler", "girl"]
property list<string> linkKeywords: ["hentai", "porn", "sukebei", "hitomi.la", "rule34", "gelbooru", "fanbox", "dlsite"]
}
}
}
}
}
@@ -0,0 +1,55 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common.functions
import Qt.labs.platform
import QtQuick
import Quickshell
Singleton {
// XDG Dirs, with "file://"
readonly property string home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
readonly property string config: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0]
readonly property string state: StandardPaths.standardLocations(StandardPaths.StateLocation)[0]
readonly property string cache: StandardPaths.standardLocations(StandardPaths.CacheLocation)[0]
readonly property string genericCache: StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0]
readonly property string documents: StandardPaths.standardLocations(StandardPaths.DocumentsLocation)[0]
readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0]
readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0]
readonly property string music: StandardPaths.standardLocations(StandardPaths.MusicLocation)[0]
readonly property string videos: StandardPaths.standardLocations(StandardPaths.MoviesLocation)[0]
// Other dirs used by the shell, without "file://"
property string assetsPath: Quickshell.shellPath("assets")
property string scriptPath: Quickshell.shellPath("scripts")
property string favicons: FileUtils.trimFileProtocol(`${Directories.cache}/media/favicons`)
property string coverArt: FileUtils.trimFileProtocol(`${Directories.cache}/media/coverart`)
property string booruPreviews: FileUtils.trimFileProtocol(`${Directories.cache}/media/boorus`)
property string booruDownloads: FileUtils.trimFileProtocol(Directories.pictures + "/homework")
property string booruDownloadsNsfw: FileUtils.trimFileProtocol(Directories.pictures + "/homework/🌶️")
property string latexOutput: FileUtils.trimFileProtocol(`${Directories.cache}/media/latex`)
property string shellConfig: FileUtils.trimFileProtocol(`${Directories.config}/illogical-impulse`)
property string shellConfigName: "config.json"
property string shellConfigPath: `${Directories.shellConfig}/${Directories.shellConfigName}`
property string todoPath: FileUtils.trimFileProtocol(`${Directories.state}/user/todo.json`)
property string notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`)
property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`)
property string generatedWallpaperCategoryPath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/wallpaper/category.txt`)
property string cliphistDecode: FileUtils.trimFileProtocol(`/tmp/quickshell/media/cliphist`)
property string screenshotTemp: "/tmp/quickshell/media/screenshot"
property string wallpaperSwitchScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/colors/switchwall.sh`)
property string defaultAiPrompts: Quickshell.shellPath("defaults/ai/prompts")
property string userAiPrompts: FileUtils.trimFileProtocol(`${Directories.shellConfig}/ai/prompts`)
property string aiChats: FileUtils.trimFileProtocol(`${Directories.state}/user/ai/chats`)
property string aiTranslationScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/ai/gemini-translate.sh`)
// Cleanup on init
Component.onCompleted: {
Quickshell.execDetached(["mkdir", "-p", `${shellConfig}`])
Quickshell.execDetached(["mkdir", "-p", `${favicons}`])
Quickshell.execDetached(["bash", "-c", `rm -rf '${coverArt}'; mkdir -p '${coverArt}'`])
Quickshell.execDetached(["bash", "-c", `rm -rf '${booruPreviews}'; mkdir -p '${booruPreviews}'`])
Quickshell.execDetached(["bash", "-c", `rm -rf '${latexOutput}'; mkdir -p '${latexOutput}'`])
Quickshell.execDetached(["bash", "-c", `rm -rf '${cliphistDecode}'; mkdir -p '${cliphistDecode}'`])
Quickshell.execDetached(["mkdir", "-p", `${aiChats}`])
}
}
@@ -0,0 +1,23 @@
pragma Singleton
// From https://github.com/caelestia-dots/shell (GPLv3)
import Quickshell
Singleton {
id: root
function getBluetoothDeviceMaterialSymbol(systemIconName: string): string {
if (systemIconName.includes("headset") || systemIconName.includes("headphones"))
return "headphones";
if (systemIconName.includes("audio"))
return "speaker";
if (systemIconName.includes("phone"))
return "smartphone";
if (systemIconName.includes("mouse"))
return "mouse";
if (systemIconName.includes("keyboard"))
return "keyboard";
return "bluetooth";
}
}
@@ -0,0 +1,31 @@
pragma Singleton
import Quickshell
Singleton {
// Formats
readonly property list<string> validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"]
readonly property list<string> validImageExtensions: ["jpg", "jpeg", "png", "webp", "tif", "tiff", "svg"]
function isValidImageByName(name: string): bool {
return validImageExtensions.some(t => name.endsWith(`.${t}`));
}
// Thumbnails
// https://specifications.freedesktop.org/thumbnail-spec/latest/directory.html
readonly property var thumbnailSizes: ({
"normal": 128,
"large": 256,
"x-large": 512,
"xx-large": 1024
})
function thumbnailSizeNameForDimensions(width: int, height: int): string {
const sizeNames = Object.keys(thumbnailSizes);
for(let i = 0; i < sizeNames.length; i++) {
const sizeName = sizeNames[i];
const maxSize = thumbnailSizes[sizeName];
if (width <= maxSize && height <= maxSize) return sizeName;
}
return "xx-large";
}
}
@@ -0,0 +1,97 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property alias states: persistentStatesJsonAdapter
property string fileDir: Directories.state
property string fileName: "states.json"
property string filePath: `${root.fileDir}/${root.fileName}`
property bool ready: false
property string previousHyprlandInstanceSignature: ""
property bool isNewHyprlandInstance: previousHyprlandInstanceSignature !== states.hyprlandInstanceSignature
onReadyChanged: {
root.previousHyprlandInstanceSignature = root.states.hyprlandInstanceSignature
root.states.hyprlandInstanceSignature = Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") || ""
}
Timer {
id: fileReloadTimer
interval: 100
repeat: false
onTriggered: {
persistentStatesFileView.reload()
}
}
Timer {
id: fileWriteTimer
interval: 100
repeat: false
onTriggered: {
persistentStatesFileView.writeAdapter()
}
}
FileView {
id: persistentStatesFileView
path: root.filePath
watchChanges: true
onFileChanged: fileReloadTimer.restart()
onAdapterUpdated: fileWriteTimer.restart()
onLoaded: root.ready = true
onLoadFailed: error => {
console.log("Failed to load persistent states file:", error);
if (error == FileViewError.FileNotFound) {
fileWriteTimer.restart();
}
}
adapter: JsonAdapter {
id: persistentStatesJsonAdapter
property string hyprlandInstanceSignature: ""
property JsonObject ai: JsonObject {
property string model
property real temperature: 0.5
}
property JsonObject sidebar: JsonObject {
property JsonObject bottomGroup: JsonObject {
property bool collapsed: false
property int tab: 0
}
}
property JsonObject booru: JsonObject {
property bool allowNsfw: false
property string provider: "yandere"
}
property JsonObject idle: JsonObject {
property bool inhibit: false
}
property JsonObject timer: JsonObject {
property JsonObject pomodoro: JsonObject {
property bool running: false
property int start: 0
property bool isBreak: false
property int cycle: 0
}
property JsonObject stopwatch: JsonObject {
property bool running: false
property int start: 0
property list<var> laps: []
}
}
}
}
}
@@ -0,0 +1,127 @@
pragma Singleton
import Quickshell
Singleton {
id: root
/**
* Returns a color with the hue of color2 and the saturation, value, and alpha of color1.
*
* @param {string} color1 - The base color (any Qt.color-compatible string).
* @param {string} color2 - The color to take hue from.
* @returns {Qt.rgba} The resulting color.
*/
function colorWithHueOf(color1, color2) {
var c1 = Qt.color(color1);
var c2 = Qt.color(color2);
// Qt.color hsvHue/hsvSaturation/hsvValue/alpha return 0-1
var hue = c2.hsvHue;
var sat = c1.hsvSaturation;
var val = c1.hsvValue;
var alpha = c1.a;
return Qt.hsva(hue, sat, val, alpha);
}
/**
* Returns a color with the saturation of color2 and the hue/value/alpha of color1.
*
* @param {string} color1 - The base color (any Qt.color-compatible string).
* @param {string} color2 - The color to take saturation from.
* @returns {Qt.rgba} The resulting color.
*/
function colorWithSaturationOf(color1, color2) {
var c1 = Qt.color(color1);
var c2 = Qt.color(color2);
var hue = c1.hsvHue;
var sat = c2.hsvSaturation;
var val = c1.hsvValue;
var alpha = c1.a;
return Qt.hsva(hue, sat, val, alpha);
}
/**
* Returns a color with the given lightness and the hue, saturation, and alpha of the input color (using HSL).
*
* @param {string} color - The base color (any Qt.color-compatible string).
* @param {number} lightness - The lightness value to use (0-1).
* @returns {Qt.rgba} The resulting color.
*/
function colorWithLightness(color, lightness) {
var c = Qt.color(color);
return Qt.hsla(c.hslHue, c.hslSaturation, lightness, c.a);
}
/**
* Returns a color with the lightness of color2 and the hue, saturation, and alpha of color1 (using HSL).
*
* @param {string} color1 - The base color (any Qt.color-compatible string).
* @param {string} color2 - The color to take lightness from.
* @returns {Qt.rgba} The resulting color.
*/
function colorWithLightnessOf(color1, color2) {
var c2 = Qt.color(color2);
return colorWithLightness(color1, c2.hslLightness);
}
/**
* Adapts color1 to the accent (hue and saturation) of color2 using HSL, keeping lightness and alpha from color1.
*
* @param {string} color1 - The base color (any Qt.color-compatible string).
* @param {string} color2 - The accent color.
* @returns {Qt.rgba} The resulting color.
*/
function adaptToAccent(color1, color2) {
var c1 = Qt.color(color1);
var c2 = Qt.color(color2);
var hue = c2.hslHue;
var sat = c2.hslSaturation;
var light = c1.hslLightness;
var alpha = c1.a;
return Qt.hsla(hue, sat, light, alpha);
}
/**
* Mixes two colors by a given percentage.
*
* @param {string} color1 - The first color (any Qt.color-compatible string).
* @param {string} color2 - The second color.
* @param {number} percentage - The mix ratio (0-1). 1 = all color1, 0 = all color2.
* @returns {Qt.rgba} The resulting mixed color.
*/
function mix(color1, color2, percentage = 0.5) {
var c1 = Qt.color(color1);
var c2 = Qt.color(color2);
return Qt.rgba(percentage * c1.r + (1 - percentage) * c2.r, percentage * c1.g + (1 - percentage) * c2.g, percentage * c1.b + (1 - percentage) * c2.b, percentage * c1.a + (1 - percentage) * c2.a);
}
/**
* Transparentizes a color by a given percentage.
*
* @param {string} color - The color (any Qt.color-compatible string).
* @param {number} percentage - The amount to transparentize (0-1).
* @returns {Qt.rgba} The resulting color.
*/
function transparentize(color, percentage = 1) {
var c = Qt.color(color);
return Qt.rgba(c.r, c.g, c.b, c.a * (1 - percentage));
}
/**
* Sets the alpha channel of a color.
*
* @param {string} color - The base color (any Qt.color-compatible string).
* @param {number} alpha - The desired alpha (0-1).
* @returns {Qt.rgba} The resulting color with applied alpha.
*/
function applyAlpha(color, alpha) {
var c = Qt.color(color);
var a = Math.max(0, Math.min(1, alpha));
return Qt.rgba(c.r, c.g, c.b, a);
}
}
@@ -0,0 +1,71 @@
pragma Singleton
import Quickshell
Singleton {
id: root
/**
* Trims the File protocol off the input string
* @param {string} str
* @returns {string}
*/
function trimFileProtocol(str) {
let s = str;
if (typeof s !== "string") s = str.toString(); // Convert to string if it's an url or whatever
return s.startsWith("file://") ? s.slice(7) : s;
}
/**
* Extracts the file name from a file path
* @param {string} str
* @returns {string}
*/
function fileNameForPath(str) {
if (typeof str !== "string") return "";
const trimmed = trimFileProtocol(str);
return trimmed.split(/[\\/]/).pop();
}
/**
* Extracts the folder name from a directory path
* @param {string} str
* @returns {string}
*/
function folderNameForPath(str) {
if (typeof str !== "string") return "";
const trimmed = trimFileProtocol(str);
// Remove trailing slash if present
const noTrailing = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
if (!noTrailing) return "";
return noTrailing.split(/[\\/]/).pop();
}
/**
* Removes the file extension from a file path or name
* @param {string} str
* @returns {string}
*/
function trimFileExt(str) {
if (typeof str !== "string") return "";
const trimmed = trimFileProtocol(str);
const lastDot = trimmed.lastIndexOf(".");
if (lastDot > -1 && lastDot > trimmed.lastIndexOf("/")) {
return trimmed.slice(0, lastDot);
}
return trimmed;
}
/**
* Returns the parent directory of a given file path
* @param {string} str
* @returns {string}
*/
function parentDirectory(str) {
if (typeof str !== "string") return "";
const trimmed = trimFileProtocol(str);
const parts = trimmed.split(/[\\/]/);
if (parts.length <= 1) return "";
parts.pop();
return parts.join("/");
}
}
@@ -0,0 +1,18 @@
pragma Singleton
import Quickshell
import "./fuzzysort.js" as FuzzySort
/**
* Wrapper for FuzzySort to play nicely with Quickshell's imports
*/
Singleton {
function go(...args) {
return FuzzySort.go(...args)
}
function prepare(...args) {
return FuzzySort.prepare(...args)
}
}
@@ -0,0 +1,18 @@
pragma Singleton
import Quickshell
import "./levendist.js" as Levendist
/**
* Wrapper for levendist.js to play nicely with Quickshell's imports
*/
Singleton {
function computeScore(...args) {
return Levendist.computeScore(...args)
}
function computeTextMatchScore(...args) {
return Levendist.computeTextMatchScore(...args)
}
}
@@ -0,0 +1,98 @@
pragma Singleton
import Quickshell
Singleton {
id: root
function toPlainObject(qtObj) {
if (qtObj === null || typeof qtObj !== "object") return qtObj;
// Handle true arrays
if (Array.isArray(qtObj)) {
return qtObj.map(item => toPlainObject(item));
}
// Handle array-like Qt objects (e.g., have length and numeric keys)
if (
typeof qtObj.length === "number" &&
qtObj.length > 0 &&
Object.keys(qtObj).every(
key => !isNaN(key) || key === "length"
)
) {
let arr = [];
for (let i = 0; i < qtObj.length; i++) {
arr.push(toPlainObject(qtObj[i]));
}
return arr;
}
const result = ({});
for (let key in qtObj) {
if (
typeof qtObj[key] !== "function" &&
!key.startsWith("objectName") &&
!key.startsWith("children") &&
!key.startsWith("object") &&
!key.startsWith("parent") &&
!key.startsWith("metaObject") &&
!key.startsWith("destroyed") &&
!key.startsWith("reloadableId")
) {
result[key] = toPlainObject(qtObj[key]);
}
}
// console.log(JSON.stringify(result))
return result;
}
function applyToQtObject(qtObj, jsonObj) {
// console.log("applyToQtObject", JSON.stringify(qtObj, null, 2), "<<", JSON.stringify(jsonObj, null, 2));
if (!qtObj || typeof jsonObj !== "object" || jsonObj === null) return;
// Detect array-like Qt objects
const isQtArrayLike = obj => {
return obj && typeof obj === "object" &&
typeof obj.length === "number" &&
obj.length > 0 &&
Object.keys(obj).every(key => !isNaN(key) || key === "length");
};
// If both are arrays or array-like, update in place or replace
if ((Array.isArray(qtObj) || isQtArrayLike(qtObj)) && Array.isArray(jsonObj)) {
qtObj.length = 0;
for (let i = 0; i < jsonObj.length; i++) {
qtObj.push(jsonObj[i]);
}
return;
}
// If target is array or array-like but source is not, clear
if ((Array.isArray(qtObj) || isQtArrayLike(qtObj)) && !Array.isArray(jsonObj)) {
qtObj.length = 0;
return;
}
// If source is array but target is not, assign directly if possible
if (!(Array.isArray(qtObj) || isQtArrayLike(qtObj)) && Array.isArray(jsonObj)) {
return jsonObj;
}
for (let key in jsonObj) {
if (!qtObj.hasOwnProperty(key)) continue;
const value = qtObj[key];
const jsonValue = jsonObj[key];
// console.log("applying to qt obj key:", value, "jsonValue:", jsonValue);
if ((Array.isArray(value) || isQtArrayLike(value)) && Array.isArray(jsonValue)) {
value.length = 0;
for (let i = 0; i < jsonValue.length; i++) {
value.push(jsonValue[i]);
}
} else if (value && typeof value === "object" && !Array.isArray(value) && !isQtArrayLike(value)) {
applyToQtObject(value, jsonValue);
} else {
qtObj[key] = jsonValue;
}
}
}
}
@@ -0,0 +1,50 @@
pragma Singleton
import Quickshell
import qs.services
import qs.modules.common
Singleton {
id: root
function closeAllWindows() {
HyprlandData.windowList.map(w => w.pid).forEach(pid => {
Quickshell.execDetached(["kill", pid]);
});
}
function lock() {
Quickshell.execDetached(["loginctl", "lock-session"]);
}
function suspend() {
Quickshell.execDetached(["bash", "-c", "systemctl suspend || loginctl suspend"]);
}
function logout() {
closeAllWindows();
Quickshell.execDetached(["pkill", "-i", "Hyprland"]);
}
function launchTaskManager() {
Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]);
}
function hibernate() {
Quickshell.execDetached(["bash", "-c", `systemctl hibernate || loginctl hibernate`]);
}
function poweroff() {
closeAllWindows();
Quickshell.execDetached(["bash", "-c", `systemctl poweroff || loginctl poweroff`]);
}
function reboot() {
closeAllWindows();
Quickshell.execDetached(["bash", "-c", `reboot || loginctl reboot`]);
}
function rebootToFirmware() {
closeAllWindows();
Quickshell.execDetached(["bash", "-c", `systemctl reboot --firmware-setup || loginctl reboot --firmware-setup`]);
}
}
@@ -0,0 +1,288 @@
pragma Singleton
import Quickshell
Singleton {
id: root
/**
* Formats a string according to the args that are passed inc
* @param { string } str
* @param {...any} args
* @returns { string }
*/
function format(str, ...args) {
return str.replace(/{(\d+)}/g, (match, index) => typeof args[index] !== 'undefined' ? args[index] : match);
}
/**
* Returns the domain of the passed in url or null
* @param { string } url
* @returns { string| null }
*/
function getDomain(url) {
const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/);
return match ? match[1] : null;
}
/**
* Returns the base url of the passed in url or null
* @param { string } url
* @returns { string | null }
*/
function getBaseUrl(url) {
const match = url.match(/^(https?:\/\/[^\/]+)(\/.*)?$/);
return match ? match[1] : null;
}
/**
* Escapes single quotes in shell commands
* @param { string } str
* @returns { string }
*/
function shellSingleQuoteEscape(str) {
return String(str)
// .replace(/\\/g, '\\\\')
.replace(/'/g, "'\\''");
}
/**
* Splits markdown blocks into three different types: text, think, and code.
* @param { string } markdown
* @returns {Array<{type: "text" | "think" | "code", content: string, lang?: string, completed?: boolean}>}
*/
function splitMarkdownBlocks(markdown) {
const regex = /```(\w+)?\n([\s\S]*?)```|<think>([\s\S]*?)<\/think>/g;
/**
* @type {{type: "text" | "think" | "code"; content: string; lang: string | undefined; completed: boolean | undefined}[]}
*/
let result = [];
let lastIndex = 0;
let match;
while ((match = regex.exec(markdown)) !== null) {
if (match.index > lastIndex) {
const text = markdown.slice(lastIndex, match.index);
if (text.trim()) {
result.push({
type: "text",
content: text
});
}
}
if (match[0].startsWith('```')) {
if (match[2] && match[2].trim()) {
result.push({
type: "code",
lang: match[1] || "",
content: match[2],
completed: true
});
}
} else if (match[0].startsWith('<think>')) {
if (match[3] && match[3].trim()) {
result.push({
type: "think",
content: match[3],
completed: true
});
}
}
lastIndex = regex.lastIndex;
}
// Handle any remaining text after the last match
if (lastIndex < markdown.length) {
const text = markdown.slice(lastIndex);
// Check for unfinished <think> block
const thinkStart = text.indexOf('<think>');
const codeStart = text.indexOf('```');
if (thinkStart !== -1 && (codeStart === -1 || thinkStart < codeStart)) {
const beforeThink = text.slice(0, thinkStart);
if (beforeThink.trim()) {
result.push({
type: "text",
content: beforeThink
});
}
const thinkContent = text.slice(thinkStart + 7);
if (thinkContent.trim()) {
result.push({
type: "think",
content: thinkContent,
completed: false
});
}
} else if (codeStart !== -1) {
const beforeCode = text.slice(0, codeStart);
if (beforeCode.trim()) {
result.push({
type: "text",
content: beforeCode
});
}
// Try to detect language after ```
const codeLangMatch = text.slice(codeStart + 3).match(/^(\w+)?\n/);
let lang = "";
let codeContentStart = codeStart + 3;
if (codeLangMatch) {
lang = codeLangMatch[1] || "";
codeContentStart += codeLangMatch[0].length;
} else if (text[codeStart + 3] === '\n') {
codeContentStart += 1;
}
const codeContent = text.slice(codeContentStart);
if (codeContent.trim()) {
result.push({
type: "code",
lang,
content: codeContent,
completed: false
});
}
} else if (text.trim()) {
result.push({
type: "text",
content: text
});
}
}
// console.log(JSON.stringify(result, null, 2));
return result;
}
/**
* Returns the original string with backslashes escaped
* @param { string } str
* @returns { string }
*/
function escapeBackslashes(str) {
return str.replace(/\\/g, '\\\\');
}
/**
* Wraps words to supplied maximum length
* @param { string | null } str
* @param { number } maxLen
* @returns { string }
*/
function wordWrap(str, maxLen) {
if (!str)
return "";
let words = str.split(" ");
let lines = [];
let current = "";
for (let i = 0; i < words.length; ++i) {
if ((current + (current.length > 0 ? " " : "") + words[i]).length > maxLen) {
if (current.length > 0)
lines.push(current);
current = words[i];
} else {
current += (current.length > 0 ? " " : "") + words[i];
}
}
if (current.length > 0)
lines.push(current);
return lines.join("\n");
}
/**
* Cleans up a music title by removing bracketed and special characters.
* @param { string } title
* @returns { string }
*/
function cleanMusicTitle(title) {
if (!title)
return "";
// Brackets
title = title.replace(/^ *\([^)]*\) */g, " "); // Round brackets
title = title.replace(/^ *\[[^\]]*\] */g, " "); // Square brackets
title = title.replace(/^ *\{[^\}]*\} */g, " "); // Curly brackets
// Japenis brackets
title = title.replace(/^ *【[^】]*】/, ""); // Touhou
title = title.replace(/^ *《[^》]*》/, ""); // ??
title = title.replace(/^ *「[^」]*」/, ""); // OP/ED thingie
title = title.replace(/^ *『[^』]*』/, ""); // OP/ED thingie
return title.trim();
}
/**
* Converts seconds to a friendly time string (e.g. 1:23 or 1:02:03).
* @param { number } seconds
* @returns { string }
*/
function friendlyTimeForSeconds(seconds) {
if (isNaN(seconds) || seconds < 0)
return "0:00";
seconds = Math.floor(seconds);
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
} else {
return `${m}:${s.toString().padStart(2, '0')}`;
}
}
/**
* Escapes HTML special characters in a string.
* @param { string } str
* @returns { string }
*/
function escapeHtml(str) {
if (typeof str !== 'string')
return str;
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* Cleans a cliphist entry by removing leading digits and tab.
* @param { string } str
* @returns { string }
*/
function cleanCliphistEntry(str: string): string {
return str.replace(/^\d+\t/, "");
}
/**
* Checks if any substring in the list is contained in the string.
* @param { string } str
* @param { string[] } substrings
* @returns { boolean }
*/
function stringListContainsSubstring(str, substrings) {
for (let i = 0; i < substrings.length; ++i) {
if (str.includes(substrings[i])) {
return true;
}
}
return false;
}
/**
* Removes the given prefix from the string if present.
* @param { string } str
* @param { string } prefix
* @returns { string }
*/
function cleanPrefix(str, prefix) {
if (str.startsWith(prefix)) {
return str.slice(prefix.length);
}
return str;
}
/**
* Removes the first matching prefix from the string if present.
* @param { string } str
* @param { string[] } prefixes
* @returns { string }
*/
function cleanOnePrefix(str, prefixes) {
for (let i = 0; i < prefixes.length; ++i) {
if (str.startsWith(prefixes[i])) {
return str.slice(prefixes[i].length);
}
}
return str;
}
}
@@ -0,0 +1,682 @@
.pragma library
// https://github.com/farzher/fuzzysort
// License: MIT | Copyright (c) 2018 Stephen Kamenar
// A copy of the license is available in the `licenses` folder of this repository
var single = (search, target) => {
if(!search || !target) return NULL
var preparedSearch = getPreparedSearch(search)
if(!isPrepared(target)) target = getPrepared(target)
var searchBitflags = preparedSearch.bitflags
if((searchBitflags & target._bitflags) !== searchBitflags) return NULL
return algorithm(preparedSearch, target)
}
var go = (search, targets, options) => {
if(!search) return options?.all ? all(targets, options) : noResults
var preparedSearch = getPreparedSearch(search)
var searchBitflags = preparedSearch.bitflags
var containsSpace = preparedSearch.containsSpace
var threshold = denormalizeScore( options?.threshold || 0 )
var limit = options?.limit || INFINITY
var resultsLen = 0; var limitedCount = 0
var targetsLen = targets.length
function push_result(result) {
if(resultsLen < limit) { q.add(result); ++resultsLen }
else {
++limitedCount
if(result._score > q.peek()._score) q.replaceTop(result)
}
}
// This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys]
// options.key
if(options?.key) {
var key = options.key
for(var i = 0; i < targetsLen; ++i) { var obj = targets[i]
var target = getValue(obj, key)
if(!target) continue
if(!isPrepared(target)) target = getPrepared(target)
if((searchBitflags & target._bitflags) !== searchBitflags) continue
var result = algorithm(preparedSearch, target)
if(result === NULL) continue
if(result._score < threshold) continue
result.obj = obj
push_result(result)
}
// options.keys
} else if(options?.keys) {
var keys = options.keys
var keysLen = keys.length
outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i]
{ // early out based on bitflags
var keysBitflags = 0
for (var keyI = 0; keyI < keysLen; ++keyI) {
var key = keys[keyI]
var target = getValue(obj, key)
if(!target) { tmpTargets[keyI] = noTarget; continue }
if(!isPrepared(target)) target = getPrepared(target)
tmpTargets[keyI] = target
keysBitflags |= target._bitflags
}
if((searchBitflags & keysBitflags) !== searchBitflags) continue
}
if(containsSpace) for(let i=0; i<preparedSearch.spaceSearches.length; i++) keysSpacesBestScores[i] = NEGATIVE_INFINITY
for (var keyI = 0; keyI < keysLen; ++keyI) {
target = tmpTargets[keyI]
if(target === noTarget) { tmpResults[keyI] = noTarget; continue }
tmpResults[keyI] = algorithm(preparedSearch, target, /*allowSpaces=*/false, /*allowPartialMatch=*/containsSpace)
if(tmpResults[keyI] === NULL) { tmpResults[keyI] = noTarget; continue }
// todo: this seems weird and wrong. like what if our first match wasn't good. this should just replace it instead of averaging with it
// if our second match isn't good we ignore it instead of averaging with it
if(containsSpace) for(let i=0; i<preparedSearch.spaceSearches.length; i++) {
if(allowPartialMatchScores[i] > -1000) {
if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) {
var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/
if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp
}
}
if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i]
}
}
if(containsSpace) {
for(let i=0; i<preparedSearch.spaceSearches.length; i++) { if(keysSpacesBestScores[i] === NEGATIVE_INFINITY) continue outer }
} else {
var hasAtLeast1Match = false
for(let i=0; i < keysLen; i++) { if(tmpResults[i]._score !== NEGATIVE_INFINITY) { hasAtLeast1Match = true; break } }
if(!hasAtLeast1Match) continue
}
var objResults = new KeysResult(keysLen)
for(let i=0; i < keysLen; i++) { objResults[i] = tmpResults[i] }
if(containsSpace) {
var score = 0
for(let i=0; i<preparedSearch.spaceSearches.length; i++) score += keysSpacesBestScores[i]
} else {
// todo could rewrite this scoring to be more similar to when there's spaces
// if we match multiple keys give us bonus points
var score = NEGATIVE_INFINITY
for(let i=0; i<keysLen; i++) {
var result = objResults[i]
if(result._score > -1000) {
if(score > NEGATIVE_INFINITY) {
var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/
if(tmp > score) score = tmp
}
}
if(result._score > score) score = result._score
}
}
objResults.obj = obj
objResults._score = score
if(options?.scoreFn) {
score = options.scoreFn(objResults)
if(!score) continue
score = denormalizeScore(score)
objResults._score = score
}
if(score < threshold) continue
push_result(objResults)
}
// no keys
} else {
for(var i = 0; i < targetsLen; ++i) { var target = targets[i]
if(!target) continue
if(!isPrepared(target)) target = getPrepared(target)
if((searchBitflags & target._bitflags) !== searchBitflags) continue
var result = algorithm(preparedSearch, target)
if(result === NULL) continue
if(result._score < threshold) continue
push_result(result)
}
}
if(resultsLen === 0) return noResults
var results = new Array(resultsLen)
for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll()
results.total = resultsLen + limitedCount
return results
}
// this is written as 1 function instead of 2 for minification. perf seems fine ...
// except when minified. the perf is very slow
var highlight = (result, open='<b>', close='</b>') => {
var callback = typeof open === 'function' ? open : undefined
var target = result.target
var targetLen = target.length
var indexes = result.indexes
var highlighted = ''
var matchI = 0
var indexesI = 0
var opened = false
var parts = []
for(var i = 0; i < targetLen; ++i) { var char = target[i]
if(indexes[indexesI] === i) {
++indexesI
if(!opened) { opened = true
if(callback) {
parts.push(highlighted); highlighted = ''
} else {
highlighted += open
}
}
if(indexesI === indexes.length) {
if(callback) {
highlighted += char
parts.push(callback(highlighted, matchI++)); highlighted = ''
parts.push(target.substr(i+1))
} else {
highlighted += char + close + target.substr(i+1)
}
break
}
} else {
if(opened) { opened = false
if(callback) {
parts.push(callback(highlighted, matchI++)); highlighted = ''
} else {
highlighted += close
}
}
}
highlighted += char
}
return callback ? parts : highlighted
}
var prepare = (target) => {
if(typeof target === 'number') target = ''+target
else if(typeof target !== 'string') target = ''
var info = prepareLowerInfo(target)
return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags})
}
var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() }
// Below this point is only internal code
// Below this point is only internal code
// Below this point is only internal code
// Below this point is only internal code
class Result {
get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) }
set ['indexes'](indexes) { return this._indexes = indexes }
['highlight'](open, close) { return highlight(this, open, close) }
get ['score']() { return normalizeScore(this._score) }
set ['score'](score) { this._score = denormalizeScore(score) }
}
class KeysResult extends Array {
get ['score']() { return normalizeScore(this._score) }
set ['score'](score) { this._score = denormalizeScore(score) }
}
var new_result = (target, options) => {
const result = new Result()
result['target'] = target
result['obj'] = options.obj ?? NULL
result._score = options._score ?? NEGATIVE_INFINITY
result._indexes = options._indexes ?? []
result._targetLower = options._targetLower ?? ''
result._targetLowerCodes = options._targetLowerCodes ?? NULL
result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL
result._bitflags = options._bitflags ?? 0
return result
}
var normalizeScore = score => {
if(score === NEGATIVE_INFINITY) return 0
if(score > 1) return score
return Math.E ** ( ((-score + 1)**.04307 - 1) * -2)
}
var denormalizeScore = normalizedScore => {
if(normalizedScore === 0) return NEGATIVE_INFINITY
if(normalizedScore > 1) return normalizedScore
return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307)
}
var prepareSearch = (search) => {
if(typeof search === 'number') search = ''+search
else if(typeof search !== 'string') search = ''
search = search.trim()
var info = prepareLowerInfo(search)
var spaceSearches = []
if(info.containsSpace) {
var searches = search.split(/\s+/)
searches = [...new Set(searches)] // distinct
for(var i=0; i<searches.length; i++) {
if(searches[i] === '') continue
var _info = prepareLowerInfo(searches[i])
spaceSearches.push({lowerCodes:_info.lowerCodes, _lower:searches[i].toLowerCase(), containsSpace:false})
}
}
return {lowerCodes: info.lowerCodes, _lower: info._lower, containsSpace: info.containsSpace, bitflags: info.bitflags, spaceSearches: spaceSearches}
}
var getPrepared = (target) => {
if(target.length > 999) return prepare(target) // don't cache huge targets
var targetPrepared = preparedCache.get(target)
if(targetPrepared !== undefined) return targetPrepared
targetPrepared = prepare(target)
preparedCache.set(target, targetPrepared)
return targetPrepared
}
var getPreparedSearch = (search) => {
if(search.length > 999) return prepareSearch(search) // don't cache huge searches
var searchPrepared = preparedSearchCache.get(search)
if(searchPrepared !== undefined) return searchPrepared
searchPrepared = prepareSearch(search)
preparedSearchCache.set(search, searchPrepared)
return searchPrepared
}
var all = (targets, options) => {
var results = []; results.total = targets.length // this total can be wrong if some targets are skipped
var limit = options?.limit || INFINITY
if(options?.key) {
for(var i=0;i<targets.length;i++) { var obj = targets[i]
var target = getValue(obj, options.key)
if(target == NULL) continue
if(!isPrepared(target)) target = getPrepared(target)
var result = new_result(target.target, {_score: target._score, obj: obj})
results.push(result); if(results.length >= limit) return results
}
} else if(options?.keys) {
for(var i=0;i<targets.length;i++) { var obj = targets[i]
var objResults = new KeysResult(options.keys.length)
for (var keyI = options.keys.length - 1; keyI >= 0; --keyI) {
var target = getValue(obj, options.keys[keyI])
if(!target) { objResults[keyI] = noTarget; continue }
if(!isPrepared(target)) target = getPrepared(target)
target._score = NEGATIVE_INFINITY
target._indexes.len = 0
objResults[keyI] = target
}
objResults.obj = obj
objResults._score = NEGATIVE_INFINITY
results.push(objResults); if(results.length >= limit) return results
}
} else {
for(var i=0;i<targets.length;i++) { var target = targets[i]
if(target == NULL) continue
if(!isPrepared(target)) target = getPrepared(target)
target._score = NEGATIVE_INFINITY
target._indexes.len = 0
results.push(target); if(results.length >= limit) return results
}
}
return results
}
var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => {
if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch)
var searchLower = preparedSearch._lower
var searchLowerCodes = preparedSearch.lowerCodes
var searchLowerCode = searchLowerCodes[0]
var targetLowerCodes = prepared._targetLowerCodes
var searchLen = searchLowerCodes.length
var targetLen = targetLowerCodes.length
var searchI = 0 // where we at
var targetI = 0 // where you at
var matchesSimpleLen = 0
// very basic fuzzy match; to remove non-matching targets ASAP!
// walk through target. find sequential matches.
// if all chars aren't found then exit
for(;;) {
var isMatch = searchLowerCode === targetLowerCodes[targetI]
if(isMatch) {
matchesSimple[matchesSimpleLen++] = targetI
++searchI; if(searchI === searchLen) break
searchLowerCode = searchLowerCodes[searchI]
}
++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI
}
var searchI = 0
var successStrict = false
var matchesStrictLen = 0
var nextBeginningIndexes = prepared._nextBeginningIndexes
if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target)
targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1]
// Our target string successfully matched all characters in sequence!
// Let's try a more advanced and strict test to improve the score
// only count it as a match if it's consecutive or a beginning character!
var backtrackCount = 0
if(targetI !== targetLen) for(;;) {
if(targetI >= targetLen) {
// We failed to find a good spot for this search char, go back to the previous search char and force it forward
if(searchI <= 0) break // We failed to push chars forward for a better match
++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match
--searchI
var lastMatch = matchesStrict[--matchesStrictLen]
targetI = nextBeginningIndexes[lastMatch]
} else {
var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI]
if(isMatch) {
matchesStrict[matchesStrictLen++] = targetI
++searchI; if(searchI === searchLen) { successStrict = true; break }
++targetI
} else {
targetI = nextBeginningIndexes[targetI]
}
}
}
// check if it's a substring match
var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow
var isSubstring = !!~substringIndex
var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex
// if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score
if(isSubstring && !isSubstringBeginning) {
for(var i=0; i<nextBeginningIndexes.length; i=nextBeginningIndexes[i]) {
if(i <= substringIndex) continue
for(var s=0; s<searchLen; s++) if(searchLowerCodes[s] !== prepared._targetLowerCodes[i+s]) break
if(s === searchLen) { substringIndex = i; isSubstringBeginning = true; break }
}
}
// tally up the score & keep track of matches for highlighting later
// if it's a simple match, we'll switch to a substring match if a substring exists
// if it's a strict match, we'll switch to a substring match only if that's a better score
var calculateScore = matches => {
var score = 0
var extraMatchGroupCount = 0
for(var i = 1; i < searchLen; ++i) {
if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount}
}
var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1)
score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups
if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning
if(!successStrict) {
score *= 1000
} else {
// successStrict on a target with too many beginning indexes loses points for being a bad target
var uniqueBeginningIndexes = 1
for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes
if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ...
}
score -= (targetLen - searchLen)/2 // penality for longer targets
if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring
if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex
score -= (targetLen - searchLen)/2 // penality for longer targets
return score
}
if(!successStrict) {
if(isSubstring) for(var i=0; i<searchLen; ++i) matchesSimple[i] = substringIndex+i // at this point it's safe to overwrite matchehsSimple with substr matches
var matchesBest = matchesSimple
var score = calculateScore(matchesBest)
} else {
if(isSubstringBeginning) {
for(var i=0; i<searchLen; ++i) matchesSimple[i] = substringIndex+i // at this point it's safe to overwrite matchehsSimple with substr matches
var matchesBest = matchesSimple
var score = calculateScore(matchesSimple)
} else {
var matchesBest = matchesStrict
var score = calculateScore(matchesStrict)
}
}
prepared._score = score
for(var i = 0; i < searchLen; ++i) prepared._indexes[i] = matchesBest[i]
prepared._indexes.len = searchLen
const result = new Result()
result.target = prepared.target
result._score = prepared._score
result._indexes = prepared._indexes
return result
}
var algorithmSpaces = (preparedSearch, target, allowPartialMatch) => {
var seen_indexes = new Set()
var score = 0
var result = NULL
var first_seen_index_last_search = 0
var searches = preparedSearch.spaceSearches
var searchesLen = searches.length
var changeslen = 0
// Return _nextBeginningIndexes back to its normal state
var resetNextBeginningIndexes = () => {
for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1]
}
var hasAtLeast1Match = false
for(var i=0; i<searchesLen; ++i) {
allowPartialMatchScores[i] = NEGATIVE_INFINITY
var search = searches[i]
result = algorithm(search, target)
if(allowPartialMatch) {
if(result === NULL) continue
hasAtLeast1Match = true
} else {
if(result === NULL) {resetNextBeginningIndexes(); return NULL}
}
// if not the last search, we need to mutate _nextBeginningIndexes for the next search
var isTheLastSearch = i === searchesLen - 1
if(!isTheLastSearch) {
var indexes = result._indexes
var indexesIsConsecutiveSubstring = true
for(let i=0; i<indexes.len-1; i++) {
if(indexes[i+1] - indexes[i] !== 1) {
indexesIsConsecutiveSubstring = false; break;
}
}
if(indexesIsConsecutiveSubstring) {
var newBeginningIndex = indexes[indexes.len-1] + 1
var toReplace = target._nextBeginningIndexes[newBeginningIndex-1]
for(let i=newBeginningIndex-1; i>=0; i--) {
if(toReplace !== target._nextBeginningIndexes[i]) break
target._nextBeginningIndexes[i] = newBeginningIndex
nextBeginningIndexesChanges[changeslen*2 + 0] = i
nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace
changeslen++
}
}
}
score += result._score / searchesLen
allowPartialMatchScores[i] = result._score / searchesLen
// dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h
if(result._indexes[0] < first_seen_index_last_search) {
score -= (first_seen_index_last_search - result._indexes[0]) * 2
}
first_seen_index_last_search = result._indexes[0]
for(var j=0; j<result._indexes.len; ++j) seen_indexes.add(result._indexes[j])
}
if(allowPartialMatch && !hasAtLeast1Match) return NULL
resetNextBeginningIndexes()
// allows a search with spaces that's an exact substring to score well
var allowSpacesResult = algorithm(preparedSearch, target, /*allowSpaces=*/true)
if(allowSpacesResult !== NULL && allowSpacesResult._score > score) {
if(allowPartialMatch) {
for(var i=0; i<searchesLen; ++i) {
allowPartialMatchScores[i] = allowSpacesResult._score / searchesLen
}
}
return allowSpacesResult
}
if(allowPartialMatch) result = target
result._score = score
var i = 0
for (let index of seen_indexes) result._indexes[i++] = index
result._indexes.len = i
return result
}
// we use this instead of just .normalize('NFD').replace(/[\u0300-\u036f]/g, '') because that screws with japanese characters
var remove_accents = (str) => str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '')
var prepareLowerInfo = (str) => {
str = remove_accents(str)
var strLen = str.length
var lower = str.toLowerCase()
var lowerCodes = [] // new Array(strLen) sparse array is too slow
var bitflags = 0
var containsSpace = false // space isn't stored in bitflags because of how searching with a space works
for(var i = 0; i < strLen; ++i) {
var lowerCode = lowerCodes[i] = lower.charCodeAt(i)
if(lowerCode === 32) {
containsSpace = true
continue // it's important that we don't set any bitflags for space
}
var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet
: lowerCode>=48&&lowerCode<=57 ? 26 // numbers
// 3 bits available
: lowerCode<=127 ? 30 // other ascii
: 31 // other utf8
bitflags |= 1<<bit
}
return {lowerCodes:lowerCodes, bitflags:bitflags, containsSpace:containsSpace, _lower:lower}
}
var prepareBeginningIndexes = (target) => {
var targetLen = target.length
var beginningIndexes = []; var beginningIndexesLen = 0
var wasUpper = false
var wasAlphanum = false
for(var i = 0; i < targetLen; ++i) {
var targetCode = target.charCodeAt(i)
var isUpper = targetCode>=65&&targetCode<=90
var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57
var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum
wasUpper = isUpper
wasAlphanum = isAlphanum
if(isBeginning) beginningIndexes[beginningIndexesLen++] = i
}
return beginningIndexes
}
var prepareNextBeginningIndexes = (target) => {
target = remove_accents(target)
var targetLen = target.length
var beginningIndexes = prepareBeginningIndexes(target)
var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow
var lastIsBeginning = beginningIndexes[0]
var lastIsBeginningI = 0
for(var i = 0; i < targetLen; ++i) {
if(lastIsBeginning > i) {
nextBeginningIndexes[i] = lastIsBeginning
} else {
lastIsBeginning = beginningIndexes[++lastIsBeginningI]
nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning
}
}
return nextBeginningIndexes
}
var preparedCache = new Map()
var preparedSearchCache = new Map()
// the theory behind these being globals is to reduce garbage collection by not making new arrays
var matchesSimple = []; var matchesStrict = []
var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search
var keysSpacesBestScores = []; var allowPartialMatchScores = []
var tmpTargets = []; var tmpResults = []
// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop]
// prop = 'key1.key2' 10ms
// prop = ['key1', 'key2'] 27ms
// prop = obj => obj.tags.join() ??ms
var getValue = (obj, prop) => {
var tmp = obj[prop]; if(tmp !== undefined) return tmp
if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower
var segs = prop
if(!Array.isArray(prop)) segs = prop.split('.')
var len = segs.length
var i = -1
while (obj && (++i < len)) obj = obj[segs[i]]
return obj
}
var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' }
var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY
var noResults = []; noResults.total = 0
var NULL = null
var noTarget = prepare('')
// Hacked version of https://github.com/lemire/FastPriorityQueue.js
var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c<o;){var s=c+1;a=c,s<o&&e[s]._score<e[c]._score&&(a=s),e[a-1>>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score<e[f]._score;f=(a=f)-1>>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score<e[v]._score;v=(a=v)-1>>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a}
var q = fastpriorityqueue() // reuse this
@@ -0,0 +1,141 @@
// Original code from https://github.com/koeqaife/hyprland-material-you
// Original code license: GPLv3
// Translated to Js from Cython with an LLM and reviewed
function min3(a, b, c) {
return a < b && a < c ? a : b < c ? b : c;
}
function max3(a, b, c) {
return a > b && a > c ? a : b > c ? b : c;
}
function min2(a, b) {
return a < b ? a : b;
}
function max2(a, b) {
return a > b ? a : b;
}
function levenshteinDistance(s1, s2) {
let len1 = s1.length;
let len2 = s2.length;
if (len1 === 0) return len2;
if (len2 === 0) return len1;
if (len2 > len1) {
[s1, s2] = [s2, s1];
[len1, len2] = [len2, len1];
}
let prev = new Array(len2 + 1);
let curr = new Array(len2 + 1);
for (let j = 0; j <= len2; j++) {
prev[j] = j;
}
for (let i = 1; i <= len1; i++) {
curr[0] = i;
for (let j = 1; j <= len2; j++) {
let cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
curr[j] = min3(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
[prev, curr] = [curr, prev];
}
return prev[len2];
}
function partialRatio(shortS, longS) {
let lenS = shortS.length;
let lenL = longS.length;
let best = 0.0;
if (lenS === 0) return 1.0;
for (let i = 0; i <= lenL - lenS; i++) {
let sub = longS.slice(i, i + lenS);
let dist = levenshteinDistance(shortS, sub);
let score = 1.0 - (dist / lenS);
if (score > best) best = score;
}
return best;
}
function computeScore(s1, s2) {
if (s1 === s2) return 1.0;
let dist = levenshteinDistance(s1, s2);
let maxLen = max2(s1.length, s2.length);
if (maxLen === 0) return 1.0;
let full = 1.0 - (dist / maxLen);
let part = s1.length < s2.length ? partialRatio(s1, s2) : partialRatio(s2, s1);
let score = 0.85 * full + 0.15 * part;
if (s1 && s2 && s1[0] !== s2[0]) {
score -= 0.05;
}
let lenDiff = Math.abs(s1.length - s2.length);
if (lenDiff >= 3) {
score -= 0.05 * lenDiff / maxLen;
}
let commonPrefixLen = 0;
let minLen = min2(s1.length, s2.length);
for (let i = 0; i < minLen; i++) {
if (s1[i] === s2[i]) {
commonPrefixLen++;
} else {
break;
}
}
score += 0.02 * commonPrefixLen;
if (s1.includes(s2) || s2.includes(s1)) {
score += 0.06;
}
return Math.max(0.0, Math.min(1.0, score));
}
function computeTextMatchScore(s1, s2) {
if (s1 === s2) return 1.0;
let dist = levenshteinDistance(s1, s2);
let maxLen = max2(s1.length, s2.length);
if (maxLen === 0) return 1.0;
let full = 1.0 - (dist / maxLen);
let part = s1.length < s2.length ? partialRatio(s1, s2) : partialRatio(s2, s1);
let score = 0.4 * full + 0.6 * part;
let lenDiff = Math.abs(s1.length - s2.length);
if (lenDiff >= 10) {
score -= 0.02 * lenDiff / maxLen;
}
let commonPrefixLen = 0;
let minLen = min2(s1.length, s2.length);
for (let i = 0; i < minLen; i++) {
if (s1[i] === s2[i]) {
commonPrefixLen++;
} else {
break;
}
}
score += 0.01 * commonPrefixLen;
if (s1.includes(s2) || s2.includes(s1)) {
score += 0.2;
}
return Math.max(0.0, Math.min(1.0, score));
}
@@ -0,0 +1,27 @@
import QtQuick
import qs.modules.common
import qs.modules.common.functions
/**
* Material color scheme adapted to a given color. It's incomplete but enough for what we need...
*/
QtObject {
id: root
required property color color
readonly property bool colorIsDark: color.hslLightness < 0.5
property color colLayer0: ColorUtils.mix(Appearance.colors.colLayer0, root.color, (colorIsDark && Appearance.m3colors.darkmode) ? 0.6 : 0.5)
property color colLayer1: ColorUtils.mix(Appearance.colors.colLayer1, root.color, 0.5)
property color colOnLayer0: ColorUtils.mix(Appearance.colors.colOnLayer0, root.color, 0.5)
property color colOnLayer1: ColorUtils.mix(Appearance.colors.colOnLayer1, root.color, 0.5)
property color colSubtext: ColorUtils.mix(Appearance.colors.colOnLayer1, root.color, 0.5)
property color colPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimary, root.color), root.color, 0.5)
property color colPrimaryHover: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryHover, root.color), root.color, 0.3)
property color colPrimaryActive: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryActive, root.color), root.color, 0.3)
property color colSecondary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colSecondary, root.color), root.color, 0.5)
property color colSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, root.color, 0.15)
property color colSecondaryContainerHover: ColorUtils.mix(Appearance.colors.colSecondaryContainerHover, root.color, 0.3)
property color colSecondaryContainerActive: ColorUtils.mix(Appearance.colors.colSecondaryContainerActive, root.color, 0.5)
property color colOnPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.m3colors.m3onPrimary, root.color), root.color, 0.5)
property color colOnSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3onSecondaryContainer, root.color, 0.5)
}
@@ -0,0 +1,53 @@
import QtQuick
import Qt.labs.folderlistmodel
FolderListModel {
id: root
property list<url> folderHistory: []
property int currentFolderHistoryIndex: -1
property bool historyNavigationLock: false
function lockNextNavigation() {
historyNavigationLock = true;
}
function pushToHistory(path) {
if (folderHistory[currentFolderHistoryIndex] === path)
return;
folderHistory = folderHistory.slice(0, currentFolderHistoryIndex + 1);
folderHistory.push(path);
currentFolderHistoryIndex = folderHistory.length - 1;
}
function navigateUp() {
root.folder = root.parentFolder;
}
function navigateBack() {
if (currentFolderHistoryIndex === 0)
return;
currentFolderHistoryIndex--;
lockNextNavigation();
root.folder = folderHistory[currentFolderHistoryIndex];
}
function navigateForward() {
if (currentFolderHistoryIndex >= folderHistory.length - 1) return;
currentFolderHistoryIndex++;
lockNextNavigation();
root.folder = folderHistory[currentFolderHistoryIndex];
}
onFolderChanged: {
if (historyNavigationLock) {
historyNavigationLock = false;
return;
}
pushToHistory(folder);
}
Component.onCompleted: {
root.folderHistory = [root.folder]
root.currentFolderHistoryIndex = 0
}
}
@@ -0,0 +1,120 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
Rectangle {
id: root
required property var directory
property bool showBreadcrumb: true
onShowBreadcrumbChanged: {
addressInput.text = root.directory;
}
signal navigateToDirectory(string path)
property real padding: 6
implicitWidth: mainLayout.implicitWidth + padding * 2
implicitHeight: mainLayout.implicitHeight + padding * 2
color: Appearance.colors.colLayer2
function focusBreadcrumb() {
root.showBreadcrumb = false;
addressInput.forceActiveFocus();
}
RowLayout {
id: mainLayout
anchors {
fill: parent
margins: root.padding
}
spacing: 8
RippleButton {
id: parentDirButton
downAction: () => root.navigateToDirectory(FileUtils.parentDirectory(root.directory))
contentItem: MaterialSymbol {
text: "drive_folder_upload"
iconSize: Appearance.font.pixelSize.larger
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
id: directoryEntry
visible: !root.showBreadcrumb
anchors.fill: parent
color: Appearance.colors.colLayer1
radius: Appearance.rounding.full
implicitWidth: addressInput.implicitWidth
implicitHeight: addressInput.implicitHeight
Keys.onPressed: event => {
if (directoryEntry.visible && event.key === Qt.Key_Escape) {
root.showBreadcrumb = true;
event.accepted = true;
return;
}
event.accepted = false;
}
StyledTextInput {
id: addressInput
anchors.fill: parent
padding: 10
text: root.directory
Keys.onPressed: event => {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.navigateToDirectory(text);
root.showBreadcrumb = true;
event.accepted = true;
}
}
MouseArea {
// I-beam cursor
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
cursorShape: Qt.IBeamCursor
}
}
}
Loader {
id: breadcrumbLoader
active: root.showBreadcrumb
visible: root.showBreadcrumb
anchors.fill: parent
sourceComponent: AddressBreadcrumb {
directory: root.directory
onNavigateToDirectory: dir => {
root.navigateToDirectory(dir);
}
}
}
}
RippleButton {
id: dirEditButton
toggled: !root.showBreadcrumb
downAction: () => root.showBreadcrumb = !root.showBreadcrumb
contentItem: MaterialSymbol {
text: "edit"
iconSize: Appearance.font.pixelSize.larger
color: dirEditButton.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer2
}
StyledToolTip {
text: Translation.tr("Edit directory")
}
}
}
}
@@ -0,0 +1,41 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
ListView {
id: root
required property var directory
property var breadcrumbDirectory: ""
Component.onCompleted: breadcrumbDirectory = directory;
onDirectoryChanged: {
if (breadcrumbDirectory.startsWith(directory)) return;
breadcrumbDirectory = directory
}
signal navigateToDirectory(string path)
orientation: ListView.Horizontal
clip: true
spacing: 2
model: breadcrumbDirectory.split("/")
delegate: SelectionGroupButton {
id: folderButton
required property var modelData
required property int index
buttonText: index === 0 ? "/" : modelData
toggled: {
if (directory.trim() === "/") return index === 0;
return index === directory.split("/").length - 1
}
leftmost: index === 0
rightmost: index === breadcrumbDirectory.split("/").length - 1
onClicked: {
root.navigateToDirectory(breadcrumbDirectory.split("/").slice(0, index + 1).join("/"))
}
}
}
@@ -0,0 +1,47 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
/**
* A container that supports GroupButton children for bounciness.
* See https://m3.material.io/components/button-groups/overview
*/
Rectangle {
id: root
default property alias data: rowLayout.data
property alias uniformCellSizes: rowLayout.uniformCellSizes
property real spacing: 5
property real padding: 0
property int clickIndex: rowLayout.clickIndex
property real contentWidth: {
let total = 0;
for (let i = 0; i < rowLayout.children.length; ++i) {
const child = rowLayout.children[i];
if (!child.visible) continue;
total += child.baseWidth ?? child.implicitWidth ?? child.width;
}
return total + rowLayout.spacing * (rowLayout.children.length - 1);
}
topLeftRadius: rowLayout.children.length > 0 ? (rowLayout.children[0].radius + padding) :
Appearance?.rounding?.small
bottomLeftRadius: topLeftRadius
topRightRadius: rowLayout.children.length > 0 ? (rowLayout.children[rowLayout.children.length - 1].radius + padding) :
Appearance?.rounding?.small
bottomRightRadius: topRightRadius
color: "transparent"
width: root.contentWidth + padding * 2
implicitHeight: rowLayout.implicitHeight + padding * 2
implicitWidth: root.contentWidth + padding * 2
children: [RowLayout {
id: rowLayout
anchors.fill: parent
anchors.margins: root.padding
spacing: root.spacing
property int clickIndex: -1
}]
}
@@ -0,0 +1,88 @@
import QtQuick
import QtQuick.Shapes
import qs.modules.common
/**
* Material 3 circular progress. See https://m3.material.io/components/progress-indicators/specs
*/
Item {
id: root
property int implicitSize: 30
property int lineWidth: 2
property real value: 0
property color colPrimary: Appearance.m3colors.m3onSecondaryContainer
property color colSecondary: Appearance.colors.colSecondaryContainer
property real gapAngle: 360 / 18
property bool fill: false
property int fillOverflow: 2
property bool enableAnimation: true
property int animationDuration: 800
property var easingType: Easing.OutCubic
implicitWidth: implicitSize
implicitHeight: implicitSize
property real degree: value * 360
property real centerX: root.width / 2
property real centerY: root.height / 2
property real arcRadius: root.implicitSize / 2 - root.lineWidth
property real startAngle: -90
Behavior on degree {
enabled: root.enableAnimation
NumberAnimation {
duration: root.animationDuration
easing.type: root.easingType
}
}
Loader {
active: root.fill
anchors.fill: parent
sourceComponent: Rectangle {
radius: 9999
color: root.colSecondary
}
}
Shape {
anchors.fill: parent
layer.enabled: true
layer.smooth: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: secondaryPath
strokeColor: root.colSecondary
strokeWidth: root.lineWidth
capStyle: ShapePath.RoundCap
fillColor: "transparent"
PathAngleArc {
centerX: root.centerX
centerY: root.centerY
radiusX: root.arcRadius
radiusY: root.arcRadius
startAngle: root.startAngle - root.gapAngle
sweepAngle: -(360 - root.degree - 2 * root.gapAngle)
}
}
ShapePath {
id: primaryPath
strokeColor: root.colPrimary
strokeWidth: root.lineWidth
capStyle: ShapePath.RoundCap
fillColor: "transparent"
PathAngleArc {
centerX: root.centerX
centerY: root.centerY
radiusX: root.arcRadius
radiusY: root.arcRadius
startAngle: root.startAngle
sweepAngle: root.degree
}
}
}
}
@@ -0,0 +1,131 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import Quickshell
import Quickshell.Io
Rectangle {
id: root
property string entry
property real maxWidth
property real maxHeight
property bool blur: false
property string blurText: "Image hidden"
property string imageDecodePath: Directories.cliphistDecode
property string imageDecodeFileName: `${entryNumber}`
property string imageDecodeFilePath: `${imageDecodePath}/${imageDecodeFileName}`
property string source
property int entryNumber: {
if (!root.entry)
return 0;
const match = root.entry.match(/^(\d+)\t/);
return match ? parseInt(match[1]) : 0;
}
property int imageWidth: {
if (!root.entry)
return 0;
const match = root.entry.match(/(\d+)x(\d+)/);
return match ? parseInt(match[1]) : 0;
}
property int imageHeight: {
if (!root.entry)
return 0;
const match = root.entry.match(/(\d+)x(\d+)/);
return match ? parseInt(match[2]) : 0;
}
property real scale: {
return Math.min(root.maxWidth / imageWidth, root.maxHeight / imageHeight, 1);
}
color: Appearance.colors.colLayer1
radius: Appearance.rounding.small
implicitHeight: imageHeight * scale
implicitWidth: imageWidth * scale
Component.onCompleted: {
decodeImageProcess.running = true;
}
Process {
id: decodeImageProcess
command: ["bash", "-c", `[ -f ${imageDecodeFilePath} ] || echo '${StringUtils.shellSingleQuoteEscape(root.entry)}' | ${Cliphist.cliphistBinary} decode > '${imageDecodeFilePath}'`]
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
root.source = imageDecodeFilePath;
} else {
console.error("[CliphistImage] Failed to decode image for entry:", root.entry);
root.source = "";
}
}
}
Component.onDestruction: {
Quickshell.execDetached(["bash", "-c", `[ -f '${imageDecodeFilePath}' ] && rm -f '${imageDecodeFilePath}'`]);
}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: image.width
height: image.height
radius: root.radius
}
}
StyledImage {
id: image
anchors.fill: parent
source: Qt.resolvedUrl(root.source)
fillMode: Image.PreserveAspectFit
antialiasing: true
asynchronous: true
width: root.imageWidth * root.scale
height: root.imageHeight * root.scale
sourceSize.width: width
sourceSize.height: height
}
Loader {
id: blurLoader
active: root.blur
anchors.fill: image
sourceComponent: GaussianBlur {
source: image
radius: 35
samples: radius * 2 + 1
Rectangle {
anchors.fill: parent
color: ColorUtils.transparentize(Appearance.colors.colLayer0, 0.5)
Column {
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
}
MaterialSymbol {
visible: width <= image.width
anchors.horizontalCenter: parent.horizontalCenter
text: "visibility_off"
font.pixelSize: 28
}
StyledText {
visible: width <= image.width
anchors.horizontalCenter: parent.horizontalCenter
text: root.blurText
color: Appearance.colors.colOnSurface
font.pixelSize: Appearance.font.pixelSize.smallie
}
}
}
}
}
}
@@ -0,0 +1,98 @@
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import QtQuick.Shapes
import Qt5Compat.GraphicalEffects
Item {
id: root
property int implicitSize: 18
property int lineWidth: 2
property real value: 0
property color colPrimary: Appearance?.colors.colOnSecondaryContainer ?? "#685496"
property color colSecondary: ColorUtils.transparentize(colPrimary, 0.5) ?? "#F1D3F9"
property real gapAngle: 360 / 18
property bool fill: true
property int fillOverflow: 2
property bool enableAnimation: true
property int animationDuration: 800
property var easingType: Easing.OutCubic
property bool accountForLightBleeding: true
default property Item textMask: Item {
width: implicitSize
height: implicitSize
StyledText {
anchors.centerIn: parent
text: Math.round(root.value * 100)
font.pixelSize: 12
font.weight: Font.Medium
}
}
implicitWidth: implicitSize
implicitHeight: implicitSize
property real degree: value * 360
property real centerX: root.width / 2
property real centerY: root.height / 2
property real arcRadius: root.implicitSize / 2 - root.lineWidth / 2 - (0.5 * root.accountForLightBleeding)
property real startAngle: -90
Behavior on degree {
enabled: root.enableAnimation
NumberAnimation {
duration: root.animationDuration
easing.type: root.easingType
}
}
Rectangle {
id: contentItem
anchors.fill: parent
radius: implicitSize / 2
color: root.colSecondary
visible: false
layer.enabled: true
layer.smooth: true
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: primaryPath
pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting
strokeColor: root.colPrimary
strokeWidth: root.lineWidth
capStyle: ShapePath.RoundCap
fillColor: root.colPrimary
startX: root.centerX
startY: root.centerY
PathAngleArc {
moveToStart: false
centerX: root.centerX
centerY: root.centerY
radiusX: root.arcRadius
radiusY: root.arcRadius
startAngle: root.startAngle
sweepAngle: root.degree
}
PathLine {
x: primaryPath.startX
y: primaryPath.startY
}
}
}
}
OpacityMask {
anchors.fill: parent
source: contentItem
invert: true
maskSource: root.textMask
}
}
@@ -0,0 +1,101 @@
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
/**
* A progress bar with both ends rounded and text acts as clipping like OneUI 7's battery indicator.
*/
ProgressBar {
id: root
property bool vertical: false
property real valueBarWidth: 30
property real valueBarHeight: 18
property color highlightColor: Appearance?.colors.colOnSecondaryContainer ?? "#685496"
property color trackColor: ColorUtils.transparentize(highlightColor, 0.5) ?? "#F1D3F9"
property alias radius: contentItem.radius
property string text
default property Item textMask: Item {
width: valueBarWidth
height: valueBarHeight
StyledText {
anchors.centerIn: parent
font: root.font
text: root.text
}
}
text: Math.round(value * 100)
font {
pixelSize: 13
weight: text.length > 2 ? Font.Medium : Font.DemiBold
}
background: Item {
implicitHeight: valueBarHeight
implicitWidth: valueBarWidth
}
contentItem: Rectangle {
id: contentItem
anchors.fill: parent
radius: 9999
color: root.trackColor
visible: false
Rectangle {
id: progressFill
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
right: undefined
}
width: parent.width * root.visualPosition
height: parent.height
states: State {
name: "vertical"
when: root.vertical
AnchorChanges {
target: progressFill
anchors {
top: undefined
bottom: parent.bottom
left: parent.left
right: parent.right
}
}
PropertyChanges {
target: progressFill
width: parent.width
height: parent.height * root.visualPosition
}
}
radius: Appearance.rounding.unsharpen
color: root.highlightColor
}
}
OpacityMask {
id: roundingMask
visible: false
anchors.fill: parent
source: contentItem
maskSource: Rectangle {
width: contentItem.width
height: contentItem.height
radius: contentItem.radius
}
}
OpacityMask {
anchors.fill: parent
source: roundingMask
invert: true
maskSource: root.textMask
}
}
@@ -0,0 +1,8 @@
import QtQuick
import QtQuick.Layouts
RowLayout {
property bool uniform: false
spacing: 4
uniformCellSizes: uniform
}
@@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
Flow {
id: root
Layout.fillWidth: true
spacing: 2
property list<var> options: [
{
"displayName": "Option 1",
"icon": "check",
"value": 1
},
{
"displayName": "Option 2",
"icon": "close",
"value": 2
},
]
property var currentValue: null
signal selected(var newValue)
Repeater {
model: root.options
delegate: SelectionGroupButton {
id: paletteButton
required property var modelData
required property int index
onYChanged: {
if (index === 0) {
paletteButton.leftmost = true
} else {
var prev = root.children[index - 1]
var thisIsOnNewLine = prev && prev.y !== paletteButton.y
paletteButton.leftmost = thisIsOnNewLine
prev.rightmost = thisIsOnNewLine
}
}
leftmost: index === 0
rightmost: index === root.options.length - 1
buttonIcon: modelData.icon || ""
buttonText: modelData.displayName
toggled: root.currentValue === modelData.value
onClicked: {
root.selected(modelData.value);
}
}
}
}
@@ -0,0 +1,38 @@
import qs.modules.common.widgets
import qs.modules.common
import QtQuick
import QtQuick.Layouts
RowLayout {
id: root
property string text: ""
property string icon
property alias value: spinBoxWidget.value
property alias stepSize: spinBoxWidget.stepSize
property alias from: spinBoxWidget.from
property alias to: spinBoxWidget.to
spacing: 10
Layout.leftMargin: 8
Layout.rightMargin: 8
RowLayout {
spacing: 10
OptionalMaterialSymbol {
icon: root.icon
opacity: root.enabled ? 1 : 0.4
}
StyledText {
id: labelWidget
Layout.fillWidth: true
text: root.text
color: Appearance.colors.colOnSecondaryContainer
opacity: root.enabled ? 1 : 0.4
}
}
StyledSpinBox {
id: spinBoxWidget
Layout.fillWidth: false
value: root.value
}
}
@@ -0,0 +1,42 @@
import qs.modules.common.widgets
import qs.modules.common
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
RippleButton {
id: root
property string buttonIcon
Layout.fillWidth: true
implicitHeight: contentItem.implicitHeight + 8 * 2
font.pixelSize: Appearance.font.pixelSize.small
onClicked: checked = !checked
contentItem: RowLayout {
spacing: 10
OptionalMaterialSymbol {
icon: root.buttonIcon
opacity: root.enabled ? 1 : 0.4
iconSize: Appearance.font.pixelSize.larger
}
StyledText {
id: labelWidget
Layout.fillWidth: true
text: root.text
font: root.font
color: Appearance.colors.colOnSecondaryContainer
opacity: root.enabled ? 1 : 0.4
}
StyledSwitch {
id: switchWidget
down: root.down
scale: 0.6
Layout.fillWidth: false
checked: root.checked
onClicked: root.clicked()
}
}
}
@@ -0,0 +1,29 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
StyledFlickable {
id: root
property real baseWidth: 600
property bool forceWidth: false
property real bottomContentPadding: 100
default property alias data: contentColumn.data
clip: true
contentHeight: contentColumn.implicitHeight + root.bottomContentPadding // Add some padding at the bottom
implicitWidth: contentColumn.implicitWidth
ColumnLayout {
id: contentColumn
width: root.forceWidth ? root.baseWidth : Math.max(root.baseWidth, implicitWidth)
anchors {
top: parent.top
horizontalCenter: parent.horizontalCenter
margins: 20
}
spacing: 30
}
}
@@ -0,0 +1,36 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
ColumnLayout {
id: root
property string title
property string icon: ""
default property alias data: sectionContent.data
Layout.fillWidth: true
spacing: 6
RowLayout {
spacing: 6
OptionalMaterialSymbol {
icon: root.icon
iconSize: Appearance.font.pixelSize.hugeass
}
StyledText {
text: root.title
font.pixelSize: Appearance.font.pixelSize.larger
font.weight: Font.Medium
color: Appearance.colors.colOnSecondaryContainer
}
}
ColumnLayout {
id: sectionContent
Layout.fillWidth: true
spacing: 4
}
}
@@ -0,0 +1,46 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
ColumnLayout {
id: root
property string title: ""
property string tooltip: ""
default property alias data: sectionContent.data
Layout.fillWidth: true
Layout.topMargin: 4
spacing: 2
RowLayout {
ContentSubsectionLabel {
visible: root.title && root.title.length > 0
text: root.title
}
MaterialSymbol {
visible: root.tooltip && root.tooltip.length > 0
text: "info"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colSubtext
MouseArea {
id: infoMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.WhatsThisCursor
StyledToolTip {
extraVisibleCondition: false
alternativeVisibleCondition: infoMouseArea.containsMouse
text: root.tooltip
}
}
}
Item { Layout.fillWidth: true }
}
ColumnLayout {
id: sectionContent
Layout.fillWidth: true
spacing: 2
}
}
@@ -0,0 +1,10 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
StyledText {
text: "Subsection"
color: Appearance.colors.colSubtext
Layout.leftMargin: 2
}
@@ -0,0 +1,25 @@
import QtQuick
import qs.modules.common
import qs.modules.common.widgets
MaterialCookie {
id: root
property alias text: symbol.text
property alias iconSize: symbol.iconSize
property alias font: symbol.font
property alias colSymbol: symbol.color
property real padding: 6
color: Appearance.colors.colSecondaryContainer
colSymbol: Appearance.colors.colOnSecondaryContainer
sides: 5
implicitSize: Math.max(symbol.implicitWidth, symbol.implicitHeight) + padding * 2
MaterialSymbol {
id: symbol
anchors.centerIn: parent
}
}
@@ -0,0 +1,37 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import Qt5Compat.GraphicalEffects
Item {
id: root
property bool colorize: false
property color color
property string source: ""
property string iconFolder: Qt.resolvedUrl(Quickshell.shellPath("assets/icons")) // The folder to check first
width: 30
height: 30
IconImage {
id: iconImage
anchors.fill: parent
source: {
const fullPathWhenSourceIsIconName = iconFolder + "/" + root.source;
if (iconFolder && fullPathWhenSourceIsIconName) {
return fullPathWhenSourceIsIconName
}
return root.source
}
implicitSize: root.height
}
Loader {
active: root.colorize
anchors.fill: iconImage
sourceComponent: ColorOverlay {
source: iconImage
color: root.color
}
}
}
@@ -0,0 +1,40 @@
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
import QtQuick
/**
* Material 3 dialog button. See https://m3.material.io/components/dialogs/overview
*/
RippleButton {
id: root
property string buttonText
padding: 14
implicitHeight: 36
implicitWidth: buttonTextWidget.implicitWidth + padding * 2
buttonRadius: Appearance?.rounding.full ?? 9999
property color colEnabled: Appearance?.colors.colPrimary ?? "#65558F"
property color colDisabled: Appearance?.m3colors.m3outline ?? "#8D8C96"
colBackground: ColorUtils.transparentize(Appearance.colors.colLayer3)
colBackgroundHover: Appearance.colors.colLayer3Hover
colRipple: Appearance.colors.colLayer3Active
property alias colText: buttonTextWidget.color
contentItem: StyledText {
id: buttonTextWidget
anchors.fill: parent
anchors.leftMargin: root.padding
anchors.rightMargin: root.padding
text: buttonText
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance?.font.pixelSize.small ?? 12
color: root.enabled ? root.colEnabled : root.colDisabled
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
@@ -0,0 +1,25 @@
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
import QtQuick
RippleButton {
id: root
property bool active: false
horizontalPadding: Appearance.rounding.large
verticalPadding: 12
clip: true
pointingHandCursor: !active
implicitWidth: contentItem.implicitWidth + horizontalPadding * 2
implicitHeight: contentItem.implicitHeight + verticalPadding * 2
Behavior on implicitHeight {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
colBackground: ColorUtils.transparentize(Appearance.colors.colLayer3)
colBackgroundHover: active ? colBackground : Appearance.colors.colLayer3Hover
colRipple: Appearance.colors.colLayer3Active
buttonRadius: 0
}
@@ -0,0 +1,41 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
import qs.modules.common.functions
// From https://github.com/caelestia-dots/shell with modifications.
// License: GPLv3
Image {
id: root
required property var fileModelData
asynchronous: true
fillMode: Image.PreserveAspectFit
source: {
if (!fileModelData.fileIsDir)
return Quickshell.iconPath("application-x-zerosize");
if ([Directories.documents, Directories.downloads, Directories.music, Directories.pictures, Directories.videos].some(dir => FileUtils.trimFileProtocol(dir) === fileModelData.filePath))
return Quickshell.iconPath(`folder-${fileModelData.fileName.toLowerCase()}`);
return Quickshell.iconPath("inode-directory");
}
onStatusChanged: {
if (status === Image.Error)
source = Quickshell.iconPath("error");
}
Process {
running: !fileModelData.fileIsDir
command: ["file", "--mime", "-b", fileModelData.filePath]
stdout: StdioCollector {
onStreamFinished: {
const mime = text.split(";")[0].replace("/", "-");
root.source = Images.validImageTypes.some(t => mime === `image-${t}`) ? fileModelData.fileUrl : Quickshell.iconPath(mime, "image-missing");
}
}
}
}
@@ -0,0 +1,72 @@
import qs.modules.common
import qs.services
import QtQuick
/**
* A convenience MouseArea for handling drag events.
*/
MouseArea {
id: root
hoverEnabled: true
acceptedButtons: Qt.LeftButton
property bool interactive: true
property bool automaticallyReset: true
readonly property real dragDiffX: _dragDiffX
readonly property real dragDiffY: _dragDiffY
signal dragPressed(diffX: real, diffY: real)
signal dragReleased(diffX: real, diffY: real)
property real startX: 0
property real startY: 0
property bool dragging: false
property real _dragDiffX: 0
property real _dragDiffY: 0
function resetDrag() {
_dragDiffX = 0
_dragDiffY = 0
}
onPressed: (mouse) => {
if (!root.interactive) {
if (mouse.button === Qt.LeftButton) {
mouse.accepted = false;
}
return;
}
if (mouse.button === Qt.LeftButton) {
startX = mouse.x
startY = mouse.y
}
}
onReleased: (mouse) => {
if (!root.interactive) {
return;
}
dragging = false
root.dragReleased(_dragDiffX, _dragDiffY);
if (root.automaticallyReset) {
root.resetDrag();
}
}
onPositionChanged: (mouse) => {
if (!root.interactive) {
return;
}
if (mouse.buttons & Qt.LeftButton) {
root._dragDiffX = mouse.x - startX
root._dragDiffY = mouse.y - startY
const dist = Math.sqrt(root._dragDiffX * root._dragDiffX + root._dragDiffY * root._dragDiffY);
root.dragPressed(_dragDiffX, _dragDiffY);
root.dragging = true;
}
}
onCanceled: (mouse) => {
if (!root.interactive) {
return;
}
released(mouse);
}
}
@@ -0,0 +1,16 @@
import QtQuick
import qs.modules.common
import qs.modules.common.widgets
Loader {
id: root
property bool shown: true
opacity: shown ? 1 : 0
visible: opacity > 0
active: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
@@ -0,0 +1,48 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import Quickshell.Io
import Quickshell.Widgets
IconImage {
id: root
property string url
property string displayText
property real size: 32
property string downloadUserAgent: Config.options?.networking.userAgent ?? ""
property string faviconDownloadPath: Directories.favicons
property string domainName: url.includes("vertexaisearch") ? displayText : StringUtils.getDomain(url)
property string faviconUrl: `https://www.google.com/s2/favicons?domain=${domainName}&sz=32`
property string fileName: `${domainName}.ico`
property string faviconFilePath: `${faviconDownloadPath}/${fileName}`
property string urlToLoad
Process {
id: faviconDownloadProcess
running: false
command: ["bash", "-c", `[ -f ${faviconFilePath} ] || curl -s '${root.faviconUrl}' -o '${faviconFilePath}' -L -H 'User-Agent: ${downloadUserAgent}'`]
onExited: (exitCode, exitStatus) => {
root.urlToLoad = root.faviconFilePath
}
}
Component.onCompleted: {
faviconDownloadProcess.running = true
}
source: Qt.resolvedUrl(root.urlToLoad)
implicitSize: root.size
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: root.implicitSize
height: root.implicitSize
radius: Appearance.rounding.full
}
}
}
@@ -0,0 +1,59 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
/**
* Material 3 FAB.
*/
RippleButton {
id: root
property string iconText: "add"
property bool expanded: false
property real baseSize: 56
property real elementSpacing: 5
implicitWidth: Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize)
implicitHeight: baseSize
buttonRadius: Appearance.rounding.small
colBackground: Appearance.colors.colPrimaryContainer
colBackgroundHover: Appearance.colors.colPrimaryContainerHover
colRipple: Appearance.colors.colPrimaryContainerActive
contentItem: RowLayout {
id: contentRowLayout
property real horizontalMargins: (root.baseSize - icon.width) / 2
anchors {
verticalCenter: parent?.verticalCenter
left: parent?.left
leftMargin: contentRowLayout.horizontalMargins
}
spacing: 0
MaterialSymbol {
id: icon
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
iconSize: 24
color: Appearance.colors.colOnPrimaryContainer
text: root.iconText
}
Loader {
active: true
sourceComponent: Revealer {
visible: root.expanded || implicitWidth > 0
reveal: root.expanded
implicitWidth: reveal ? (buttonText.implicitWidth + root.elementSpacing + contentRowLayout.horizontalMargins) : 0
StyledText {
id: buttonText
anchors {
left: parent.left
leftMargin: root.elementSpacing
}
text: root.buttonText
color: Appearance.colors.colOnPrimaryContainer
font.pixelSize: 14
font.weight: 450
}
}
}
}
}
@@ -0,0 +1,8 @@
import QtQuick
/**
* This is just to make sure `RippleButton`s can be used in a Flow layout.
*/
Flow {
property int clickIndex: -1
}
@@ -0,0 +1,56 @@
import QtQuick
MouseArea { // Right side | scroll to change volume
id: root
signal scrollUp(delta: int)
signal scrollDown(delta: int)
signal movedAway()
property bool hovered: false
property real lastScrollX: 0
property real lastScrollY: 0
property bool trackingScroll: false
property real moveThreshold: 20
acceptedButtons: Qt.LeftButton
hoverEnabled: true
onEntered: {
root.hovered = true;
}
onExited: {
root.hovered = false;
root.trackingScroll = false;
}
onWheel: event => {
if (event.angleDelta.y < 0)
root.scrollDown(event.angleDelta.y);
else if (event.angleDelta.y > 0)
root.scrollUp(event.angleDelta.y);
// Store the mouse position and start tracking
root.lastScrollX = event.x;
root.lastScrollY = event.y;
root.trackingScroll = true;
}
onPositionChanged: mouse => {
if (root.trackingScroll) {
const dx = mouse.x - root.lastScrollX;
const dy = mouse.y - root.lastScrollY;
if (Math.sqrt(dx * dx + dy * dy) > root.moveThreshold) {
root.movedAway();
root.trackingScroll = false;
}
}
}
onContainsMouseChanged: {
if (!root.containsMouse && root.trackingScroll) {
root.movedAway();
root.trackingScroll = false;
}
}
}
@@ -0,0 +1,130 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/**
* Material 3 button with expressive bounciness.
* See https://m3.material.io/components/button-groups/overview
*/
Button {
id: root
property bool toggled
property string buttonText
property real buttonRadius: Appearance?.rounding?.small ?? 8
property real buttonRadiusPressed: Appearance?.rounding?.small ?? 6
property var downAction // When left clicking (down)
property var releaseAction // When left clicking (release)
property var altAction // When right clicking
property var middleClickAction // When middle clicking
property bool bounce: true
property real baseWidth: contentItem.implicitWidth + horizontalPadding * 2
property real baseHeight: contentItem.implicitHeight + verticalPadding * 2
property real clickedWidth: baseWidth + 20
property real clickedHeight: baseHeight
property var parentGroup: root.parent
property int clickIndex: parentGroup?.clickIndex ?? -1
Layout.fillWidth: (clickIndex - 1 <= parentGroup?.children.indexOf(root) && parentGroup?.children.indexOf(root) <= clickIndex + 1)
Layout.fillHeight: (clickIndex - 1 <= parentGroup?.children.indexOf(root) && parentGroup?.children.indexOf(root) <= clickIndex + 1)
implicitWidth: (root.down && bounce) ? clickedWidth : baseWidth
implicitHeight: (root.down && bounce) ? clickedHeight : baseHeight
property color colBackground: ColorUtils.transparentize(colBackgroundHover, 1) || "transparent"
property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED"
property color colBackgroundActive: Appearance?.colors.colLayer1Active ?? "#D6CEE2"
property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F"
property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C"
property color colBackgroundToggledActive: Appearance?.colors.colPrimaryActive ?? "#D6CEE2"
property real radius: root.down ? root.buttonRadiusPressed : root.buttonRadius
property real leftRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius
property real rightRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius
property color color: root.enabled ? (root.toggled ?
(root.down ? colBackgroundToggledActive :
root.hovered ? colBackgroundToggledHover :
colBackgroundToggled) :
(root.down ? colBackgroundActive :
root.hovered ? colBackgroundHover :
colBackground)) : colBackground
onDownChanged: {
if (root.down) {
if (root.parent.clickIndex !== undefined) {
root.parent.clickIndex = parent.children.indexOf(root)
}
}
}
Behavior on implicitWidth {
animation: Appearance.animation.clickBounce.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance.animation.clickBounce.numberAnimation.createObject(this)
}
Behavior on leftRadius {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on rightRadius {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onPressed: (event) => {
if(event.button === Qt.RightButton) {
if (root.altAction) root.altAction();
return;
}
if(event.button === Qt.MiddleButton) {
if (root.middleClickAction) root.middleClickAction();
return;
}
root.down = true
if (root.downAction) root.downAction();
}
onReleased: (event) => {
root.down = false
if (event.button != Qt.LeftButton) return;
if (root.releaseAction) root.releaseAction();
}
onClicked: (event) => {
if (event.button != Qt.LeftButton) return;
root.click()
}
onCanceled: (event) => {
root.down = false
}
onPressAndHold: () => {
altAction();
root.down = false;
root.clicked = false;
};
}
background: Rectangle {
id: buttonBackground
topLeftRadius: root.leftRadius
topRightRadius: root.rightRadius
bottomLeftRadius: root.leftRadius
bottomRightRadius: root.rightRadius
implicitHeight: 50
color: root.color
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
contentItem: StyledText {
text: root.buttonText
}
}
@@ -0,0 +1,42 @@
import qs.modules.common
import QtQuick
Rectangle {
id: root
property string key
property real horizontalPadding: 6
property real verticalPadding: 1
property real borderWidth: 1
property real extraBottomBorderWidth: 2
property color borderColor: Appearance.colors.colOnLayer0
property real borderRadius: 5
property color keyColor: Appearance.m3colors.m3surfaceContainerLow
implicitWidth: keyFace.implicitWidth + borderWidth * 2
implicitHeight: keyFace.implicitHeight + borderWidth * 2 + extraBottomBorderWidth
radius: borderRadius
color: borderColor
Rectangle {
id: keyFace
anchors {
fill: parent
topMargin: borderWidth
leftMargin: borderWidth
rightMargin: borderWidth
bottomMargin: extraBottomBorderWidth + borderWidth
}
implicitWidth: keyText.implicitWidth + horizontalPadding * 2
implicitHeight: keyText.implicitHeight + verticalPadding * 2
color: keyColor
radius: borderRadius - borderWidth
StyledText {
id: keyText
anchors.centerIn: parent
font.family: Appearance.font.family.monospace
font.pixelSize: Appearance.font.pixelSize.smaller
text: key
}
}
}
@@ -0,0 +1,122 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Quickshell
RippleButton {
id: lightDarkButtonRoot
required property bool dark
property color previewBg: dark ? ColorUtils.colorWithHueOf("#3f3838", Appearance.m3colors.m3primary) :
ColorUtils.colorWithHueOf("#F7F9FF", Appearance.m3colors.m3primary)
property color previewFg: dark ? Qt.lighter(previewBg, 2.2) : ColorUtils.mix(previewBg, "#292929", 0.85)
padding: 5
Layout.fillWidth: true
colBackground: Appearance.colors.colLayer2
toggled: Appearance.m3colors.darkmode === dark
onClicked: {
Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode ${dark ? "dark" : "light"} --noswitch`])
}
contentItem: Item {
anchors.centerIn: parent
implicitWidth: buttonContentLayout.implicitWidth
implicitHeight: buttonContentLayout.implicitHeight
ColumnLayout {
id: buttonContentLayout
anchors.centerIn: parent
Rectangle {
Layout.alignment: Qt.AlignHCenter
implicitWidth: 250
implicitHeight: skeletonColumnLayout.implicitHeight + 10 * 2
radius: lightDarkButtonRoot.buttonRadius - lightDarkButtonRoot.padding
color: lightDarkButtonRoot.previewBg
border {
width: 1
color: Appearance.m3colors.m3outlineVariant
}
// Some skeleton items
ColumnLayout {
id: skeletonColumnLayout
anchors.fill: parent
anchors.margins: 10
spacing: 10
RowLayout {
Rectangle {
radius: Appearance.rounding.full
color: lightDarkButtonRoot.previewFg
implicitWidth: 50
implicitHeight: 50
}
ColumnLayout {
spacing: 4
Rectangle {
radius: Appearance.rounding.unsharpenmore
color: lightDarkButtonRoot.previewFg
Layout.fillWidth: true
implicitHeight: 22
}
Rectangle {
radius: Appearance.rounding.unsharpenmore
color: lightDarkButtonRoot.previewFg
Layout.fillWidth: true
Layout.rightMargin: 45
implicitHeight: 18
}
}
}
StyledProgressBar {
Layout.topMargin: 5
Layout.bottomMargin: 5
Layout.fillWidth: true
value: 0.7
wavy: true
animateWave: lightDarkButtonRoot.toggled
highlightColor: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3primary : lightDarkButtonRoot.previewFg
trackColor: ColorUtils.mix(lightDarkButtonRoot.previewBg, lightDarkButtonRoot.previewFg, 0.5)
}
RowLayout {
spacing: 2
Rectangle {
radius: Appearance.rounding.full
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3primary : lightDarkButtonRoot.previewFg
Layout.fillWidth: true
implicitHeight: 30
MaterialSymbol {
visible: lightDarkButtonRoot.toggled
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "check"
iconSize: 20
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3onPrimary : lightDarkButtonRoot.previewBg
}
}
Rectangle {
radius: Appearance.rounding.unsharpenmore
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3secondaryContainer : lightDarkButtonRoot.previewFg
Layout.fillWidth: true
implicitHeight: 30
}
Rectangle {
topLeftRadius: Appearance.rounding.unsharpenmore
bottomLeftRadius: Appearance.rounding.unsharpenmore
topRightRadius: Appearance.rounding.full
bottomRightRadius: Appearance.rounding.full
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3secondaryContainer : lightDarkButtonRoot.previewFg
Layout.fillWidth: true
implicitHeight: 30
}
}
}
}
StyledText {
Layout.fillWidth: true
text: dark ? Translation.tr("Dark") : Translation.tr("Light")
color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2
horizontalAlignment: Text.AlignHCenter
}
}
}
}
@@ -0,0 +1,71 @@
import QtQuick
import QtQuick.Shapes
import Quickshell
import qs.modules.common
Item {
id: root
property real sides: 12
property int implicitSize: 100
property real amplitude: implicitSize / 50
property int renderPoints: 360
property color color: "#605790"
property alias strokeWidth: shapePath.strokeWidth
property bool constantlyRotate: false
implicitWidth: implicitSize
implicitHeight: implicitSize
property real shapeRotation: 0
Loader {
active: constantlyRotate
sourceComponent: FrameAnimation {
running: true
onTriggered: {
shapeRotation += 0.05
}
}
}
Behavior on sides {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Shape {
id: shape
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: shapePath
strokeWidth: 0
fillColor: root.color
pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting
PathPolyline {
property var pointsList: {
var points = []
var cx = shape.width / 2 // center x
var cy = shape.height / 2 // center y
var steps = root.renderPoints
var radius = root.implicitSize / 2 - root.amplitude
for (var i = 0; i <= steps; i++) {
var angle = (i / steps) * 2 * Math.PI
var rotatedAngle = angle * root.sides + Math.PI/2 + (root.shapeRotation * root.constantlyRotate)
var wave = Math.sin(rotatedAngle) * root.amplitude
var x = Math.cos(angle) * (radius + wave) + cx
var y = Math.sin(angle) * (radius + wave) + cy
points.push(Qt.point(x, y))
}
return points
}
path: pointsList
}
}
}
}
@@ -0,0 +1,30 @@
import qs.modules.common
import QtQuick
StyledText {
id: root
property real iconSize: Appearance?.font.pixelSize.small ?? 16
property real fill: 0
property real truncatedFill: fill.toFixed(1) // Reduce memory consumption spikes from constant font remapping
renderType: fill !== 0 ? Text.CurveRendering : Text.NativeRendering
font {
hintingPreference: Font.PreferFullHinting
family: Appearance?.font.family.iconMaterial ?? "Material Symbols Rounded"
pixelSize: iconSize
weight: Font.Normal + (Font.DemiBold - Font.Normal) * truncatedFill
variableAxes: {
"FILL": truncatedFill,
// "wght": font.weight,
// "GRAD": 0,
"opsz": iconSize,
}
}
Behavior on fill { // Leaky leaky, no good
NumberAnimation {
duration: Appearance?.animation.elementMoveFast.duration ?? 200
easing.type: Appearance?.animation.elementMoveFast.type ?? Easing.BezierSpline
easing.bezierCurve: Appearance?.animation.elementMoveFast.bezierCurve ?? [0.34, 0.80, 0.34, 1.00, 1, 1]
}
}
}
@@ -0,0 +1,52 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
/**
* Material 3 styled TextArea (filled style)
* https://m3.material.io/components/text-fields/overview
* Note: We don't use NativeRendering because it makes the small placeholder text look weird
*/
TextArea {
id: root
Material.theme: Material.System
Material.accent: Appearance.m3colors.m3primary
Material.primary: Appearance.m3colors.m3primary
Material.background: Appearance.m3colors.m3surface
Material.foreground: Appearance.m3colors.m3onSurface
Material.containerStyle: Material.Filled
renderType: Text.QtRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
placeholderTextColor: Appearance.m3colors.m3outline
background: Rectangle {
implicitHeight: 56
color: Appearance.m3colors.m3surface
topLeftRadius: 4
topRightRadius: 4
Rectangle {
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
}
height: 1
color: root.focus ? Appearance.m3colors.m3primary :
root.hovered ? Appearance.m3colors.m3outline : Appearance.m3colors.m3outlineVariant
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
font {
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
hintingPreference: Font.PreferFullHinting
}
wrapMode: TextEdit.Wrap
}
@@ -0,0 +1,39 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
/**
* Material 3 styled TextField (filled style)
* https://m3.material.io/components/text-fields/overview
* Note: We don't use NativeRendering because it makes the small placeholder text look weird
*/
TextField {
id: root
Material.theme: Material.System
Material.accent: Appearance.m3colors.m3primary
Material.primary: Appearance.m3colors.m3primary
Material.background: Appearance.m3colors.m3surface
Material.foreground: Appearance.m3colors.m3onSurface
Material.containerStyle: Material.Outlined
renderType: Text.QtRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
placeholderTextColor: Appearance.m3colors.m3outline
clip: true
font {
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
hintingPreference: Font.PreferFullHinting
}
wrapMode: TextEdit.Wrap
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
cursorShape: Qt.IBeamCursor
}
}
@@ -0,0 +1,26 @@
import qs.modules.common
import QtQuick
RippleButton {
id: root
buttonRadius: 0
implicitHeight: 36
implicitWidth: buttonTextWidget.implicitWidth + 14 * 2
contentItem: StyledText {
id: buttonTextWidget
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
text: root.buttonText
horizontalAlignment: Text.AlignLeft
font.pixelSize: Appearance.font.pixelSize.small
color: root.enabled ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3outline
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
@@ -0,0 +1,11 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
ColumnLayout { // Window content with navigation rail and content pane
id: root
property bool expanded: true
property int currentIndex: 0
spacing: 5
}
@@ -0,0 +1,150 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
TabButton {
id: root
property bool toggled: TabBar.tabBar.currentIndex === TabBar.index
property string buttonIcon
property real buttonIconRotation: 0
property string buttonText
property bool expanded: false
property bool showToggledHighlight: true
readonly property real visualWidth: root.expanded ? root.baseSize + 20 + itemText.implicitWidth : root.baseSize
property real baseSize: 56
property real baseHighlightHeight: 32
property real highlightCollapsedTopMargin: 8
padding: 0
// The navigation items target area always spans the full width of the
// nav rail, even if the item container hugs its contents.
Layout.fillWidth: true
// implicitWidth: contentItem.implicitWidth
implicitHeight: baseSize
background: null
PointingHandInteraction {}
// Real stuff
contentItem: Item {
id: buttonContent
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
right: undefined
}
implicitWidth: root.visualWidth
implicitHeight: root.expanded ? itemIconBackground.implicitHeight : itemIconBackground.implicitHeight + itemText.implicitHeight
Rectangle {
id: itemBackground
anchors.top: itemIconBackground.top
anchors.left: itemIconBackground.left
anchors.bottom: itemIconBackground.bottom
implicitWidth: root.visualWidth
radius: Appearance.rounding.full
color: toggled ?
root.showToggledHighlight ?
(root.down ? Appearance.colors.colSecondaryContainerActive : root.hovered ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer)
: ColorUtils.transparentize(Appearance.colors.colSecondaryContainer) :
(root.down ? Appearance.colors.colLayer1Active : root.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1))
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: itemBackground
anchors.top: buttonContent.top
anchors.left: buttonContent.left
anchors.bottom: buttonContent.bottom
}
PropertyChanges {
target: itemBackground
implicitWidth: root.visualWidth
}
}
transitions: Transition {
AnchorAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
PropertyAnimation {
target: itemBackground
property: "implicitWidth"
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
Item {
id: itemIconBackground
implicitWidth: root.baseSize
implicitHeight: root.baseHighlightHeight
anchors {
left: parent.left
verticalCenter: parent.verticalCenter
}
MaterialSymbol {
id: navRailButtonIcon
rotation: root.buttonIconRotation
anchors.centerIn: parent
iconSize: 24
fill: toggled ? 1 : 0
font.weight: (toggled || root.hovered) ? Font.DemiBold : Font.Normal
text: buttonIcon
color: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer1
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
StyledText {
id: itemText
anchors {
top: itemIconBackground.bottom
topMargin: 2
horizontalCenter: itemIconBackground.horizontalCenter
}
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: itemText
anchors {
top: undefined
horizontalCenter: undefined
left: itemIconBackground.right
verticalCenter: itemIconBackground.verticalCenter
}
}
}
transitions: Transition {
AnchorAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
text: buttonText
font.pixelSize: 14
color: Appearance.colors.colOnLayer1
}
}
}
@@ -0,0 +1,30 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
RippleButton {
id: root
Layout.alignment: Qt.AlignLeft
implicitWidth: 40
implicitHeight: 40
Layout.leftMargin: 8
downAction: () => {
parent.expanded = !parent.expanded;
}
buttonRadius: Appearance.rounding.full
rotation: root.parent.expanded ? 0 : -180
Behavior on rotation {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
contentItem: MaterialSymbol {
id: icon
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
iconSize: 24
color: Appearance.colors.colOnLayer1
text: root.parent.expanded ? "menu_open" : "menu"
}
}
@@ -0,0 +1,42 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
Item {
id: root
property int currentIndex: 0
property bool expanded: false
default property alias data: tabBarColumn.data
implicitHeight: tabBarColumn.implicitHeight
implicitWidth: tabBarColumn.implicitWidth
Layout.topMargin: 25
Rectangle {
property real itemHeight: tabBarColumn.children[0]?.baseSize ?? 56
property real baseHighlightHeight: tabBarColumn.children[0]?.baseHighlightHeight ?? 56
anchors {
top: tabBarColumn.top
left: tabBarColumn.left
topMargin: itemHeight * root.currentIndex + (root.expanded ? 0 : ((itemHeight - baseHighlightHeight) / 2))
}
radius: Appearance.rounding.full
color: Appearance.colors.colSecondaryContainer
implicitHeight: root.expanded ? itemHeight : baseHighlightHeight
implicitWidth: tabBarColumn?.children[root.currentIndex]?.visualWidth ?? 100
Behavior on anchors.topMargin {
NumberAnimation {
duration: Appearance.animationCurves.expressiveFastSpatialDuration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial
}
}
}
ColumnLayout {
id: tabBarColumn
anchors.fill: parent
spacing: 0
}
}
@@ -0,0 +1,40 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
Rectangle {
id: root
property alias materialIcon: icon.text
property alias text: noticeText.text
radius: Appearance.rounding.normal
color: Appearance.colors.colPrimaryContainer
implicitWidth: mainRowLayout.implicitWidth + mainRowLayout.anchors.margins * 2
implicitHeight: mainRowLayout.implicitHeight + mainRowLayout.anchors.margins * 2
RowLayout {
id: mainRowLayout
anchors.fill: parent
anchors.margins: 8
spacing: 8
MaterialSymbol {
id: icon
Layout.fillWidth: false
Layout.alignment: Qt.AlignTop
text: "info"
iconSize: Appearance.font.pixelSize.huge
color: Appearance.colors.colOnPrimaryContainer
}
StyledText {
id: noticeText
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: "Notice message"
color: Appearance.colors.colOnPrimaryContainer
wrapMode: Text.WordWrap
}
}
}
@@ -0,0 +1,24 @@
import qs.modules.common
import qs.services
import QtQuick
import Quickshell.Services.Notifications
RippleButton {
id: button
property string buttonText
property string urgency
implicitHeight: 30
leftPadding: 15
rightPadding: 15
buttonRadius: Appearance.rounding.small
colBackground: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainer : Appearance.colors.colLayer4
colBackgroundHover: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colLayer4Hover
colRipple: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colLayer4Active
contentItem: StyledText {
horizontalAlignment: Text.AlignHCenter
text: buttonText
color: (urgency == NotificationUrgency.Critical) ? Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
}
}
@@ -0,0 +1,101 @@
import qs.modules.common
import "./notification_utils.js" as NotificationUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Notifications
MaterialCookie { // App icon
id: root
property var appIcon: ""
property var summary: ""
property var urgency: NotificationUrgency.Normal
property bool isUrgent: urgency === NotificationUrgency.Critical
property var image: ""
property real materialIconScale: 0.57
property real appIconScale: 0.8
property real smallAppIconScale: 0.49
property real materialIconSize: implicitSize * materialIconScale
property real appIconSize: implicitSize * appIconScale
property real smallAppIconSize: implicitSize * smallAppIconScale
implicitSize: 38 * scale
sides: isUrgent ? 10 : 0
amplitude: implicitSize / 24
color: isUrgent ? Appearance.colors.colPrimary : Appearance.colors.colSecondaryContainer
Loader {
id: materialSymbolLoader
active: root.appIcon == ""
anchors.fill: parent
sourceComponent: MaterialSymbol {
text: {
const defaultIcon = NotificationUtils.findSuitableMaterialSymbol("")
const guessedIcon = NotificationUtils.findSuitableMaterialSymbol(root.summary)
return (root.urgency == NotificationUrgency.Critical && guessedIcon === defaultIcon) ?
"priority_high" : guessedIcon
}
anchors.fill: parent
color: isUrgent ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
iconSize: root.materialIconSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Loader {
id: appIconLoader
active: root.image == "" && root.appIcon != ""
anchors.centerIn: parent
sourceComponent: IconImage {
id: appIconImage
implicitSize: root.appIconSize
asynchronous: true
source: Quickshell.iconPath(root.appIcon, "image-missing")
}
}
Loader {
id: notifImageLoader
active: root.image != ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
Image {
id: notifImage
anchors.fill: parent
readonly property int size: parent.width
source: root.image
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
width: size
height: size
sourceSize.width: size
sourceSize.height: size
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: notifImage.size
height: notifImage.size
radius: Appearance.rounding.full
}
}
}
Loader {
id: notifImageAppIconLoader
active: root.appIcon != ""
anchors.bottom: parent.bottom
anchors.right: parent.right
sourceComponent: IconImage {
implicitSize: root.smallAppIconSize
asynchronous: true
source: Quickshell.iconPath(root.appIcon, "image-missing")
}
}
}
}
}
@@ -0,0 +1,255 @@
import qs.services
import qs.modules.common
import qs.modules.common.functions
import "./notification_utils.js" as NotificationUtils
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
/**
* A group of notifications from the same app.
* Similar to Android's notifications
*/
MouseArea { // Notification group area
id: root
property var notificationGroup
property var notifications: notificationGroup?.notifications ?? []
property int notificationCount: notifications.length
property bool multipleNotifications: notificationCount > 1
property bool expanded: false
property bool popup: false
property real padding: 10
implicitHeight: background.implicitHeight
property real dragConfirmThreshold: 70 // Drag further to discard notification
property real dismissOvershoot: 20 // Account for gaps and bouncy animations
property var qmlParent: root.parent.parent // There's something between this and the parent ListView
property var parentDragIndex: qmlParent.dragIndex
property var parentDragDistance: qmlParent.dragDistance
property var dragIndexDiff: Math.abs(parentDragIndex - index)
property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) :
parentDragDistance > dragConfirmThreshold ? 0 :
dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) :
dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0
function destroyWithAnimation() {
root.qmlParent.resetDrag()
background.anchors.leftMargin = background.anchors.leftMargin; // Break binding
destroyAnimation.running = true;
}
hoverEnabled: true
onContainsMouseChanged: {
if (!root.popup) return;
if (root.containsMouse) root.notifications.forEach(notif => {
Notifications.cancelTimeout(notif.notificationId);
});
else root.notifications.forEach(notif => {
Notifications.timeoutNotification(notif.notificationId);
});
}
SequentialAnimation { // Drag finish animation
id: destroyAnimation
running: false
NumberAnimation {
target: background.anchors
property: "leftMargin"
to: root.width + root.dismissOvershoot
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
onFinished: () => {
root.notifications.forEach((notif) => {
Qt.callLater(() => {
Notifications.discardNotification(notif.notificationId);
});
});
}
}
function toggleExpanded() {
if (expanded) implicitHeightAnim.enabled = true;
else implicitHeightAnim.enabled = false;
root.expanded = !root.expanded;
}
DragManager { // Drag manager
id: dragManager
anchors.fill: parent
interactive: !expanded
automaticallyReset: false
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton)
root.toggleExpanded();
else if (mouse.button === Qt.MiddleButton)
root.destroyWithAnimation();
}
onDraggingChanged: () => {
if (dragging) {
root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root);
}
}
onDragDiffXChanged: () => {
root.qmlParent.dragDistance = dragDiffX;
}
onDragReleased: (diffX, diffY) => {
if (diffX > root.dragConfirmThreshold)
root.destroyWithAnimation();
else
dragManager.resetDrag();
}
}
StyledRectangularShadow {
target: background
visible: popup
}
Rectangle { // Background of the notification
id: background
anchors.left: parent.left
width: parent.width
color: popup ? ColorUtils.applyAlpha(Appearance.colors.colLayer2, 1 - Appearance.backgroundTransparency) : Appearance.colors.colLayer2
radius: Appearance.rounding.normal
anchors.leftMargin: root.xOffset
Behavior on anchors.leftMargin {
enabled: !dragManager.dragging
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial
}
}
clip: true
implicitHeight: expanded ?
row.implicitHeight + padding * 2 :
Math.min(80, row.implicitHeight + padding * 2)
Behavior on implicitHeight {
id: implicitHeightAnim
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout { // Left column for icon, right column for content
id: row
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: root.padding
spacing: 10
NotificationAppIcon { // Icons
Layout.alignment: Qt.AlignTop
Layout.fillWidth: false
image: root?.multipleNotifications ? "" : notificationGroup?.notifications[0]?.image ?? ""
appIcon: notificationGroup?.appIcon
summary: notificationGroup?.notifications[root.notificationCount - 1]?.summary
urgency: root.notifications.some(n => n.urgency === NotificationUrgency.Critical.toString()) ?
NotificationUrgency.Critical : NotificationUrgency.Normal
}
ColumnLayout { // Content
Layout.fillWidth: true
spacing: expanded ? (root.multipleNotifications ?
(notificationGroup?.notifications[root.notificationCount - 1].image != "") ? 35 :
5 : 0) : 0
// spacing: 00
Behavior on spacing {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Item { // App name (or summary when there's only 1 notif) and time
id: topRow
// spacing: 0
Layout.fillWidth: true
property real fontSize: Appearance.font.pixelSize.smaller
property bool showAppName: root.multipleNotifications
implicitHeight: Math.max(topTextRow.implicitHeight, expandButton.implicitHeight)
RowLayout {
id: topTextRow
anchors.left: parent.left
anchors.right: expandButton.left
anchors.verticalCenter: parent.verticalCenter
spacing: 5
StyledText {
id: appName
elide: Text.ElideRight
Layout.fillWidth: true
text: (topRow.showAppName ?
notificationGroup?.appName :
notificationGroup?.notifications[0]?.summary) || ""
font.pixelSize: topRow.showAppName ?
topRow.fontSize :
Appearance.font.pixelSize.small
color: topRow.showAppName ?
Appearance.colors.colSubtext :
Appearance.colors.colOnLayer2
}
StyledText {
id: timeText
// Layout.fillWidth: true
Layout.rightMargin: 10
horizontalAlignment: Text.AlignLeft
text: NotificationUtils.getFriendlyNotifTimeString(notificationGroup?.time)
font.pixelSize: topRow.fontSize
color: Appearance.colors.colSubtext
}
}
NotificationGroupExpandButton {
id: expandButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
count: root.notificationCount
expanded: root.expanded
fontSize: topRow.fontSize
onClicked: { root.toggleExpanded() }
altAction: () => { root.toggleExpanded() }
StyledToolTip {
text: Translation.tr("Tip: right-clicking a group\nalso expands it")
}
}
}
StyledListView { // Notification body (expanded)
id: notificationsColumn
implicitHeight: contentHeight
Layout.fillWidth: true
spacing: expanded ? 5 : 3
// clip: true
interactive: false
Behavior on spacing {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
model: ScriptModel {
values: root.expanded ? root.notifications.slice().reverse() :
root.notifications.slice().reverse().slice(0, 2)
}
delegate: NotificationItem {
required property int index
required property var modelData
notificationObject: modelData
expanded: root.expanded
onlyNotification: (root.notificationCount === 1)
opacity: (!root.expanded && index == 1 && root.notificationCount > 2) ? 0.5 : 1
visible: root.expanded || (index < 2)
anchors.left: parent?.left
anchors.right: parent?.right
}
}
}
}
}
}
@@ -0,0 +1,47 @@
import qs.services
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
RippleButton { // Expand button
id: root
required property int count
required property bool expanded
property real fontSize: Appearance?.font.pixelSize.small ?? 12
property real iconSize: Appearance?.font.pixelSize.normal ?? 16
implicitHeight: fontSize + 4 * 2
implicitWidth: Math.max(contentItem.implicitWidth + 5 * 2, 30)
Layout.alignment: Qt.AlignVCenter
Layout.fillHeight: false
buttonRadius: Appearance.rounding.full
colBackground: ColorUtils.mix(Appearance?.colors.colLayer2, Appearance?.colors.colLayer2Hover, 0.5)
colBackgroundHover: Appearance?.colors.colLayer2Hover ?? "#E5DFED"
colRipple: Appearance?.colors.colLayer2Active ?? "#D6CEE2"
contentItem: Item {
anchors.centerIn: parent
implicitWidth: contentRow.implicitWidth
RowLayout {
id: contentRow
anchors.centerIn: parent
spacing: 3
StyledText {
Layout.leftMargin: 4
visible: root.count > 1
text: root.count
font.pixelSize: root.fontSize
}
MaterialSymbol {
text: "keyboard_arrow_down"
iconSize: root.iconSize
color: Appearance.colors.colOnLayer2
rotation: expanded ? 180 : 0
Behavior on rotation {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
}
}
}
@@ -0,0 +1,340 @@
import qs
import qs.modules.common
import qs.services
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.Notifications
Item { // Notification item area
id: root
property var notificationObject
property bool expanded: false
property bool onlyNotification: false
property real fontSize: Appearance.font.pixelSize.small
property real padding: onlyNotification ? 0 : 8
property real summaryElideRatio: 0.85
property real dragConfirmThreshold: 70 // Drag further to discard notification
property real dismissOvershoot: notificationIcon.implicitWidth + 20 // Account for gaps and bouncy animations
property var qmlParent: root?.parent?.parent // There's something between this and the parent ListView
property var parentDragIndex: qmlParent?.dragIndex ?? -1
property var parentDragDistance: qmlParent?.dragDistance ?? 0
property var dragIndexDiff: Math.abs(parentDragIndex - index)
property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) :
parentDragDistance > dragConfirmThreshold ? 0 :
dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) :
dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0
implicitHeight: background.implicitHeight
function processNotificationBody(body, appName) {
let processedBody = body
// Clean Chromium-based browsers notifications - remove first line
if (appName) {
const lowerApp = appName.toLowerCase()
const chromiumBrowsers = [
"brave", "chrome", "chromium", "vivaldi", "opera", "microsoft edge"
]
if (chromiumBrowsers.some(name => lowerApp.includes(name))) {
const lines = body.split('\n\n')
if (lines.length > 1 && lines[0].startsWith('<a')) {
processedBody = lines.slice(1).join('\n\n')
}
}
}
return processedBody
}
function destroyWithAnimation() {
root.qmlParent.resetDrag()
background.anchors.leftMargin = background.anchors.leftMargin; // Break binding
destroyAnimation.running = true;
}
TextMetrics {
id: summaryTextMetrics
font.pixelSize: root.fontSize
text: root.notificationObject.summary || ""
}
SequentialAnimation { // Drag finish animation
id: destroyAnimation
running: false
NumberAnimation {
target: background.anchors
property: "leftMargin"
to: root.width + root.dismissOvershoot
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
onFinished: () => {
Notifications.discardNotification(notificationObject.notificationId);
}
}
DragManager { // Drag manager
id: dragManager
anchors.fill: root
anchors.leftMargin: root.expanded ? -notificationIcon.implicitWidth : 0
interactive: expanded
automaticallyReset: false
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: (mouse) => {
if (mouse.button === Qt.MiddleButton) {
root.destroyWithAnimation();
}
}
onDraggingChanged: () => {
if (dragging) {
root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root);
}
}
onDragDiffXChanged: () => {
root.qmlParent.dragDistance = dragDiffX;
}
onDragReleased: (diffX, diffY) => {
if (diffX > root.dragConfirmThreshold)
root.destroyWithAnimation();
else
dragManager.resetDrag();
}
}
NotificationAppIcon { // App icon
id: notificationIcon
opacity: (!onlyNotification && notificationObject.image != "" && expanded) ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
image: notificationObject.image
anchors.right: background.left
anchors.top: background.top
anchors.rightMargin: 10
}
Rectangle { // Background of notification item
id: background
width: parent.width
anchors.left: parent.left
radius: Appearance.rounding.small
anchors.leftMargin: root.xOffset
Behavior on anchors.leftMargin {
enabled: !dragManager.dragging
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial
}
}
color: (expanded && !onlyNotification) ?
(notificationObject.urgency == NotificationUrgency.Critical) ?
ColorUtils.mix(Appearance.colors.colSecondaryContainer, Appearance.colors.colLayer2, 0.35) :
(Appearance.colors.colLayer3) :
ColorUtils.transparentize(Appearance.colors.colLayer3)
implicitHeight: expanded ? (contentColumn.implicitHeight + padding * 2) : summaryRow.implicitHeight
Behavior on implicitHeight {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
ColumnLayout { // Content column
id: contentColumn
anchors.fill: parent
anchors.margins: expanded ? root.padding : 0
spacing: 3
Behavior on anchors.margins {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout { // Summary row
id: summaryRow
visible: !root.onlyNotification || !root.expanded
Layout.fillWidth: true
implicitHeight: summaryText.implicitHeight
StyledText {
id: summaryText
Layout.fillWidth: summaryTextMetrics.width >= summaryRow.implicitWidth * root.summaryElideRatio
visible: !root.onlyNotification
font.pixelSize: root.fontSize
color: Appearance.colors.colOnLayer3
elide: Text.ElideRight
text: root.notificationObject.summary || ""
}
StyledText {
opacity: !root.expanded ? 1 : 0
visible: opacity > 0
Layout.fillWidth: true
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
font.pixelSize: root.fontSize
color: Appearance.colors.colSubtext
elide: Text.ElideRight
wrapMode: Text.Wrap // Needed for proper eliding????
maximumLineCount: 1
textFormat: Text.StyledText
text: {
return processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "<br/>")
}
}
}
ColumnLayout { // Expanded content
Layout.fillWidth: true
opacity: root.expanded ? 1 : 0
visible: opacity > 0
StyledText { // Notification body (expanded)
id: notificationBodyText
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Layout.fillWidth: true
font.pixelSize: root.fontSize
color: Appearance.colors.colSubtext
wrapMode: Text.Wrap
elide: Text.ElideRight
textFormat: Text.RichText
text: {
return `<style>img{max-width:${300 /* binding to notificationBodyText.width would cause a binding loop */}px;}</style>` +
`${processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "<br/>")}`
}
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
GlobalStates.sidebarRightOpen = false
}
PointingHandLinkHover {}
}
Item {
Layout.fillWidth: true
implicitWidth: actionsFlickable.implicitWidth
implicitHeight: actionsFlickable.implicitHeight
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: actionsFlickable.width
height: actionsFlickable.height
radius: Appearance.rounding.small
}
}
ScrollEdgeFade {
target: actionsFlickable
vertical: false
}
StyledFlickable { // Notification actions
id: actionsFlickable
anchors.fill: parent
implicitHeight: actionRowLayout.implicitHeight
contentWidth: actionRowLayout.implicitWidth
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout {
id: actionRowLayout
Layout.alignment: Qt.AlignBottom
NotificationActionButton {
Layout.fillWidth: true
buttonText: Translation.tr("Close")
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
root.destroyWithAnimation()
}
contentItem: MaterialSymbol {
iconSize: Appearance.font.pixelSize.large
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "close"
}
}
Repeater {
id: actionRepeater
model: notificationObject.actions
NotificationActionButton {
Layout.fillWidth: true
buttonText: modelData.text
urgency: notificationObject.urgency
onClicked: {
Notifications.attemptInvokeAction(notificationObject.notificationId, modelData.identifier);
}
}
}
NotificationActionButton {
Layout.fillWidth: true
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
Quickshell.clipboardText = notificationObject.body
copyIcon.text = "inventory"
copyIconTimer.restart()
}
Timer {
id: copyIconTimer
interval: 1500
repeat: false
onTriggered: {
copyIcon.text = "content_copy"
}
}
contentItem: MaterialSymbol {
id: copyIcon
iconSize: Appearance.font.pixelSize.large
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "content_copy"
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,27 @@
pragma ComponentBehavior: Bound
import qs.modules.common.widgets
import qs.services
import QtQuick
import Quickshell
StyledListView { // Scrollable window
id: root
property bool popup: false
spacing: 3
model: ScriptModel {
values: root.popup ? Notifications.popupAppNameList : Notifications.appNameList
}
delegate: NotificationGroup {
required property int index
required property var modelData
popup: root.popup
anchors.left: parent?.left
anchors.right: parent?.right
notificationGroup: popup ?
Notifications.popupGroupsByAppName[modelData] :
Notifications.groupsByAppName[modelData]
}
}
@@ -0,0 +1,27 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
Loader {
id: root
required property string icon
property real iconSize: Appearance.font.pixelSize.larger
Layout.alignment: Qt.AlignVCenter
active: root.icon && root.icon.length > 0
visible: active
sourceComponent: Item {
implicitWidth: materialSymbol.implicitWidth
MaterialSymbol {
id: materialSymbol
anchors.centerIn: parent
iconSize: root.iconSize
color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
text: root.icon
}
}
}
@@ -0,0 +1,7 @@
import QtQuick
MouseArea {
anchors.fill: parent
onPressed: (mouse) => mouse.accepted = false
cursorShape: Qt.PointingHandCursor
}
@@ -0,0 +1,8 @@
import QtQuick
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton // Only for hover
hoverEnabled: true
cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
}
@@ -0,0 +1,52 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
Item {
id: root
property string text: ""
property bool extraVisibleCondition: true
property bool alternativeVisibleCondition: false
property real horizontalPadding: 10
property real verticalPadding: 5
property var anchorEdges: Edges.Top
property var anchorGravity: anchorEdges
readonly property bool internalVisibleCondition: (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition
Loader {
id: tooltipLoader
anchors.fill: parent
active: internalVisibleCondition
sourceComponent: PopupWindow {
visible: true
anchor {
window: root.QsWindow.window
item: root.parent
edges: root.anchorEdges
gravity: root.anchorGravity
}
mask: Region {
item: null
}
color: "transparent"
implicitWidth: contentItem.implicitWidth + root.horizontalPadding * 2
implicitHeight: contentItem.implicitHeight + root.verticalPadding * 2
StyledToolTipContent {
id: contentItem
anchors.centerIn: parent
text: root.text
shown: false
Component.onCompleted: shown = true
horizontalPadding: root.horizontalPadding
verticalPadding: root.verticalPadding
}
}
}
}
@@ -0,0 +1,97 @@
import qs.modules.common
import qs.services
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ColumnLayout {
id: root
spacing: 0
required property var tabButtonList // Something like [{"icon": "notifications", "name": Translation.tr("Notifications")}, {"icon": "volume_up", "name": Translation.tr("Volume mixer")}]
required property var externalTrackedTab
property bool enableIndicatorAnimation: false
property color colIndicator: Appearance?.colors.colPrimary ?? "#65558F"
property color colBorder: Appearance?.m3colors.m3outlineVariant ?? "#C6C6D0"
signal currentIndexChanged(int index)
property bool centerTabBar: parent.width > 500
Layout.fillWidth: !centerTabBar
Layout.alignment: Qt.AlignHCenter
implicitWidth: Math.max(tabBar.implicitWidth, 600)
TabBar {
id: tabBar
Layout.fillWidth: true
currentIndex: root.externalTrackedTab
onCurrentIndexChanged: {
root.onCurrentIndexChanged(currentIndex)
}
background: Item {
WheelHandler {
onWheel: (event) => {
if (event.angleDelta.y < 0)
tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1)
else if (event.angleDelta.y > 0)
tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0)
}
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
}
}
Repeater {
model: root.tabButtonList
delegate: PrimaryTabButton {
selected: (index == root.externalTrackedTab)
buttonText: modelData.name
buttonIcon: modelData.icon
minimumWidth: 160
}
}
}
Item { // Tab indicator
id: tabIndicator
Layout.fillWidth: true
height: 3
Connections {
target: root
function onExternalTrackedTabChanged() {
root.enableIndicatorAnimation = true
}
}
Rectangle {
id: indicator
property int tabCount: root.tabButtonList.length
property real fullTabSize: root.width / tabCount;
property real targetWidth: tabBar.contentItem?.children[0]?.children[tabBar.currentIndex]?.tabContentWidth ?? 0
implicitWidth: targetWidth
anchors {
top: parent.top
bottom: parent.bottom
}
x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2
color: root.colIndicator
radius: Appearance?.rounding.full ?? 9999
Behavior on x {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on implicitWidth {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
}
}
Rectangle { // Tabbar bottom border
id: tabBarBottomBorder
Layout.fillWidth: true
implicitHeight: 1
color: root.colBorder
}
}
@@ -0,0 +1,172 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
TabButton {
id: button
property string buttonText
property string buttonIcon
property real minimumWidth: 110
property bool selected: false
property int tabContentWidth: contentItem.children[0].implicitWidth
property int rippleDuration: 1200
height: buttonBackground.height
implicitWidth: Math.max(tabContentWidth, buttonBackground.implicitWidth, minimumWidth)
property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent"
property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED"
property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2"
property color colActive: Appearance?.colors.colPrimary ?? "#65558F"
property color colInactive: Appearance?.colors.colOnLayer1 ?? "#45464F"
component RippleAnim: NumberAnimation {
duration: rippleDuration
easing.type: Appearance?.animation.elementMoveEnter.type
easing.bezierCurve: Appearance?.animationCurves.standardDecel
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: (event) => {
button.click() // Because the MouseArea already consumed the event
const {x,y} = event
const stateY = buttonBackground.y;
rippleAnim.x = x;
rippleAnim.y = y - stateY;
const dist = (ox,oy) => ox*ox + oy*oy
const stateEndY = stateY + buttonBackground.height
rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)))
rippleFadeAnim.complete();
rippleAnim.restart();
}
onReleased: (event) => {
rippleFadeAnim.restart();
}
}
RippleAnim {
id: rippleFadeAnim
duration: rippleDuration * 2
target: ripple
property: "opacity"
to: 0
}
SequentialAnimation {
id: rippleAnim
property real x
property real y
property real radius
PropertyAction {
target: ripple
property: "x"
value: rippleAnim.x
}
PropertyAction {
target: ripple
property: "y"
value: rippleAnim.y
}
PropertyAction {
target: ripple
property: "opacity"
value: 1
}
ParallelAnimation {
RippleAnim {
target: ripple
properties: "implicitWidth,implicitHeight"
from: 0
to: rippleAnim.radius * 2
}
}
}
background: Rectangle {
id: buttonBackground
radius: Appearance?.rounding.small
implicitHeight: 50
color: (button.hovered ? button.colBackgroundHover : button.colBackground)
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: buttonBackground.width
height: buttonBackground.height
radius: buttonBackground.radius
}
}
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
Item {
id: ripple
width: ripple.implicitWidth
height: ripple.implicitHeight
opacity: 0
property real implicitWidth: 0
property real implicitHeight: 0
visible: width > 0 && height > 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
RadialGradient {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: button.colRipple }
GradientStop { position: 0.3; color: button.colRipple }
GradientStop { position: 0.5 ; color: Qt.rgba(button.colRipple.r, button.colRipple.g, button.colRipple.b, 0) }
}
}
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
}
contentItem: Item {
anchors.centerIn: buttonBackground
ColumnLayout {
anchors.centerIn: parent
spacing: 0
MaterialSymbol {
visible: buttonIcon?.length > 0
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
text: buttonIcon
iconSize: Appearance?.font.pixelSize.hugeass ?? 25
fill: selected ? 1 : 0
color: selected ? button.colActive : button.colInactive
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
StyledText {
id: buttonTextWidget
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance?.font.pixelSize.small
color: selected ? button.colActive : button.colInactive
text: buttonText
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
}
}
@@ -0,0 +1,25 @@
import qs.modules.common
import QtQuick
/**
* Recreation of GTK revealer. Expects one single child.
*/
Item {
id: root
property bool reveal
property bool vertical: false
clip: true
implicitWidth: (reveal || vertical) ? childrenRect.width : 0
implicitHeight: (reveal || !vertical) ? childrenRect.height : 0
visible: reveal || (width > 0 && height > 0)
Behavior on implicitWidth {
enabled: !vertical
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
enabled: vertical
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
}
@@ -0,0 +1,186 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
/**
* A button with ripple effect similar to in Material Design.
*/
Button {
id: root
property bool toggled
property string buttonText
property bool pointingHandCursor: true
property real buttonRadius: Appearance?.rounding?.small ?? 4
property real buttonRadiusPressed: buttonRadius
property real buttonEffectiveRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius
property int rippleDuration: 1200
property bool rippleEnabled: true
property var downAction // When left clicking (down)
property var releaseAction // When left clicking (release)
property var altAction // When right clicking
property var middleClickAction // When middle clicking
property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent"
property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED"
property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F"
property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C"
property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2"
property color colRippleToggled: Appearance?.colors.colPrimaryActive ?? "#D6CEE2"
opacity: root.enabled ? 1 : 0.4
property color buttonColor: ColorUtils.transparentize(root.toggled ?
(root.hovered ? colBackgroundToggledHover :
colBackgroundToggled) :
(root.hovered ? colBackgroundHover :
colBackground), root.enabled ? 0 : 1)
property color rippleColor: root.toggled ? colRippleToggled : colRipple
function startRipple(x, y) {
const stateY = buttonBackground.y;
rippleAnim.x = x;
rippleAnim.y = y - stateY;
const dist = (ox,oy) => ox*ox + oy*oy
const stateEndY = stateY + buttonBackground.height
rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)))
rippleFadeAnim.complete();
rippleAnim.restart();
}
component RippleAnim: NumberAnimation {
duration: rippleDuration
easing.type: Appearance?.animation.elementMoveEnter.type
easing.bezierCurve: Appearance?.animationCurves.standardDecel
}
MouseArea {
anchors.fill: parent
cursorShape: root.pointingHandCursor ? Qt.PointingHandCursor : Qt.ArrowCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onPressed: (event) => {
if(event.button === Qt.RightButton) {
if (root.altAction) root.altAction(event);
return;
}
if(event.button === Qt.MiddleButton) {
if (root.middleClickAction) root.middleClickAction();
return;
}
root.down = true
if (root.downAction) root.downAction();
if (!root.rippleEnabled) return;
const {x,y} = event
startRipple(x, y)
}
onReleased: (event) => {
root.down = false
if (event.button != Qt.LeftButton) return;
if (root.releaseAction) root.releaseAction();
root.click() // Because the MouseArea already consumed the event
if (!root.rippleEnabled) return;
rippleFadeAnim.restart();
}
onCanceled: (event) => {
root.down = false
if (!root.rippleEnabled) return;
rippleFadeAnim.restart();
}
}
RippleAnim {
id: rippleFadeAnim
duration: rippleDuration * 2
target: ripple
property: "opacity"
to: 0
}
SequentialAnimation {
id: rippleAnim
property real x
property real y
property real radius
PropertyAction {
target: ripple
property: "x"
value: rippleAnim.x
}
PropertyAction {
target: ripple
property: "y"
value: rippleAnim.y
}
PropertyAction {
target: ripple
property: "opacity"
value: 1
}
ParallelAnimation {
RippleAnim {
target: ripple
properties: "implicitWidth,implicitHeight"
from: 0
to: rippleAnim.radius * 2
}
}
}
background: Rectangle {
id: buttonBackground
radius: root.buttonEffectiveRadius
implicitHeight: 30
color: root.buttonColor
Behavior on color {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: buttonBackground.width
height: buttonBackground.height
radius: root.buttonEffectiveRadius
}
}
Item {
id: ripple
width: ripple.implicitWidth
height: ripple.implicitHeight
opacity: 0
visible: width > 0 && height > 0
property real implicitWidth: 0
property real implicitHeight: 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
RadialGradient {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: root.rippleColor }
GradientStop { position: 0.3; color: root.rippleColor }
GradientStop { position: 0.5; color: Qt.rgba(root.rippleColor.r, root.rippleColor.g, root.rippleColor.b, 0) }
}
}
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
}
contentItem: StyledText {
text: root.buttonText
}
}
@@ -0,0 +1,57 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
RippleButton {
id: buttonWithIconRoot
property string nerdIcon
property string materialIcon
property bool materialIconFill: true
property string mainText: "Button text"
property Component mainContentComponent: Component {
StyledText {
text: buttonWithIconRoot.mainText
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnSecondaryContainer
}
}
implicitHeight: 35
horizontalPadding: 10
buttonRadius: Appearance.rounding.small
colBackground: Appearance.colors.colLayer2
contentItem: RowLayout {
Item {
Layout.fillWidth: false
implicitWidth: Math.max(materialIconLoader.implicitWidth, nerdIconLoader.implicitWidth)
Loader {
id: materialIconLoader
anchors.centerIn: parent
active: !nerdIcon
sourceComponent: MaterialSymbol {
text: buttonWithIconRoot.materialIcon
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnSecondaryContainer
fill: buttonWithIconRoot.materialIconFill ? 1 : 0
}
}
Loader {
id: nerdIconLoader
anchors.centerIn: parent
active: nerdIcon
sourceComponent: StyledText {
text: buttonWithIconRoot.nerdIcon
font.pixelSize: Appearance.font.pixelSize.larger
font.family: Appearance.font.family.iconNerd
color: Appearance.colors.colOnSecondaryContainer
}
}
}
Loader {
Layout.fillWidth: true
sourceComponent: buttonWithIconRoot.mainContentComponent
Layout.alignment: Qt.AlignVCenter
}
}
}
@@ -0,0 +1,80 @@
import QtQuick
import QtQuick.Shapes
Item {
id: root
enum CornerEnum { TopLeft, TopRight, BottomLeft, BottomRight }
property var corner: RoundCorner.CornerEnum.TopLeft
property alias leftVisualMargin: shape.anchors.leftMargin
property alias topVisualMargin: shape.anchors.topMargin
property alias rightVisualMargin: shape.anchors.rightMargin
property alias bottomVisualMargin: shape.anchors.bottomMargin
property int implicitSize: 25
property color color: "#000000"
implicitWidth: implicitSize
implicitHeight: implicitSize
property bool isTopLeft: corner === RoundCorner.CornerEnum.TopLeft
property bool isBottomLeft: corner === RoundCorner.CornerEnum.BottomLeft
property bool isTopRight: corner === RoundCorner.CornerEnum.TopRight
property bool isBottomRight: corner === RoundCorner.CornerEnum.BottomRight
property bool isTop: isTopLeft || isTopRight
property bool isBottom: isBottomLeft || isBottomRight
property bool isLeft: isTopLeft || isBottomLeft
property bool isRight: isTopRight || isBottomRight
Shape {
id: shape
anchors {
top: root.isTop ? parent.top : undefined
bottom: root.isBottom ? parent.bottom : undefined
left: root.isLeft ? parent.left : undefined
right: root.isRight ? parent.right : undefined
}
layer.enabled: true
layer.smooth: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: shapePath
strokeWidth: 0
fillColor: root.color
pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting
startX: switch (root.corner) {
case RoundCorner.CornerEnum.TopLeft: return 0;
case RoundCorner.CornerEnum.TopRight: return root.implicitSize;
case RoundCorner.CornerEnum.BottomLeft: return 0;
case RoundCorner.CornerEnum.BottomRight: return root.implicitSize;
}
startY: switch (root.corner) {
case RoundCorner.CornerEnum.TopLeft: return 0;
case RoundCorner.CornerEnum.TopRight: return 0;
case RoundCorner.CornerEnum.BottomLeft: return root.implicitSize;
case RoundCorner.CornerEnum.BottomRight: return root.implicitSize;
}
PathAngleArc {
moveToStart: false
centerX: root.implicitSize - shapePath.startX
centerY: root.implicitSize - shapePath.startY
radiusX: root.implicitSize
radiusY: root.implicitSize
startAngle: switch (root.corner) {
case RoundCorner.CornerEnum.TopLeft: return 180;
case RoundCorner.CornerEnum.TopRight: return -90;
case RoundCorner.CornerEnum.BottomLeft: return 90;
case RoundCorner.CornerEnum.BottomRight: return 0;
}
sweepAngle: 90
}
PathLine {
x: shapePath.startX
y: shapePath.startY
}
}
}
}
@@ -0,0 +1,59 @@
import QtQuick
import qs.modules.common
import qs.modules.common.functions
Item {
id: root
z: 99
required property Item target
property real fadeSize: Appearance.m3colors.darkmode ? 40 : 20
property color color: ColorUtils.transparentize(Appearance.colors.colShadow, Appearance.m3colors.darkmode ? 0 : 0.7)
property bool vertical: true
anchors.fill: target
EndGradient {
anchors {
top: parent.top
left: parent.left
right: vertical ? parent.right : undefined
bottom: vertical ? undefined : parent.bottom
}
shown: !(root.vertical ? root.target.atYBeginning : root.target.atXBeginning)
}
EndGradient {
anchors {
bottom: parent.bottom
right: parent.right
left: vertical ? parent.left : undefined
top: vertical ? undefined : parent.top
}
shown: !(root.vertical ? root.target.atYEnd : root.target.atXEnd)
rotation: 180
}
component EndGradient: Rectangle {
required property bool shown
height: vertical ? root.fadeSize : parent.height
width: vertical ? parent.width : root.fadeSize
opacity: shown ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
gradient: Gradient {
orientation: root.vertical ? Gradient.Vertical : Gradient.Horizontal
GradientStop {
position: 0.0
color: root.color
}
GradientStop {
position: 1.0
color: ColorUtils.transparentize(root.color)
}
}
}
}
@@ -0,0 +1,178 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
TabButton {
id: root
property string buttonText
property string buttonIcon
property bool selected: false
property int rippleDuration: 1200
height: buttonBackground.height
property int tabContentWidth: buttonBackground.width - buttonBackground.radius*2
property color colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1)
property color colBackgroundHover: Appearance.colors.colLayer1Hover
property color colRipple: Appearance.colors.colLayer1Active
PointingHandInteraction {}
component RippleAnim: NumberAnimation {
duration: rippleDuration
easing.type: Appearance.animation.elementMoveEnter.type
easing.bezierCurve: Appearance.animationCurves.standardDecel
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: (event) => {
root.click() // Because the MouseArea already consumed the event
const {x,y} = event
const stateY = buttonBackground.y;
rippleAnim.x = x;
rippleAnim.y = y - stateY;
const dist = (ox,oy) => ox*ox + oy*oy
const stateEndY = stateY + buttonBackground.height
rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)))
rippleFadeAnim.complete();
rippleAnim.restart();
}
onReleased: (event) => {
rippleFadeAnim.restart();
}
}
RippleAnim {
id: rippleFadeAnim
duration: rippleDuration * 2
target: ripple
property: "opacity"
to: 0
}
SequentialAnimation {
id: rippleAnim
property real x
property real y
property real radius
PropertyAction {
target: ripple
property: "x"
value: rippleAnim.x
}
PropertyAction {
target: ripple
property: "y"
value: rippleAnim.y
}
PropertyAction {
target: ripple
property: "opacity"
value: 1
}
ParallelAnimation {
RippleAnim {
target: ripple
properties: "implicitWidth,implicitHeight"
from: 0
to: rippleAnim.radius * 2
}
}
}
background: Rectangle {
id: buttonBackground
radius: Appearance?.rounding.small ?? 7
implicitHeight: 37
color: (root.hovered ? root.colBackgroundHover : root.colBackground)
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: buttonBackground.width
height: buttonBackground.height
radius: buttonBackground.radius
}
}
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
Item {
id: ripple
width: ripple.implicitWidth
height: ripple.implicitHeight
opacity: 0
property real implicitWidth: 0
property real implicitHeight: 0
visible: width > 0 && height > 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this)
}
RadialGradient {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: root.colRipple }
GradientStop { position: 0.3; color: root.colRipple }
GradientStop { position: 0.5 ; color: Qt.rgba(root.colRipple.r, root.colRipple.g, root.colRipple.b, 0) }
}
}
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
}
contentItem: Item {
anchors.centerIn: buttonBackground
RowLayout {
anchors.centerIn: parent
spacing: 0
Loader {
id: iconLoader
active: buttonIcon?.length > 0
sourceComponent: buttonIcon?.length > 0 ? materialSymbolComponent : null
Layout.rightMargin: 5
}
Component {
id: materialSymbolComponent
MaterialSymbol {
verticalAlignment: Text.AlignVCenter
text: buttonIcon
iconSize: Appearance.font.pixelSize.huge
fill: selected ? 1 : 0
color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
StyledText {
id: buttonTextWidget
verticalAlignment: Text.AlignVCenter
font.pixelSize: Appearance.font.pixelSize.small
color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1
text: buttonText
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
}
}
@@ -0,0 +1,128 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
import QtQuick.Layouts
import Quickshell
Item {
id: root
property real dialogPadding: 15
property real dialogMargin: 30
property string titleText: "Selection Dialog"
property alias items: choiceModel.values
property int selectedId: choiceListView.currentIndex
property var defaultChoice
signal canceled();
signal selected(var result);
Rectangle { // Scrim
id: scrimOverlay
anchors.fill: parent
radius: Appearance.rounding.small
color: Appearance.colors.colScrim
MouseArea {
hoverEnabled: true
anchors.fill: parent
preventStealing: true
propagateComposedEvents: false
}
}
Rectangle { // The dialog
id: dialog
color: Appearance.colors.colSurfaceContainerHigh
radius: Appearance.rounding.normal
anchors.fill: parent
anchors.margins: dialogMargin
implicitHeight: dialogColumnLayout.implicitHeight
ColumnLayout {
id: dialogColumnLayout
anchors.fill: parent
spacing: 16
StyledText {
id: dialogTitle
Layout.topMargin: dialogPadding
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
Layout.alignment: Qt.AlignLeft
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.larger
text: root.titleText
}
Rectangle {
color: Appearance.m3colors.m3outline
implicitHeight: 1
Layout.fillWidth: true
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
}
StyledListView {
id: choiceListView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1
spacing: 6
model: ScriptModel {
id: choiceModel
}
delegate: StyledRadioButton {
id: radioButton
required property var modelData
required property int index
anchors {
left: parent?.left
right: parent?.right
leftMargin: root.dialogPadding
rightMargin: root.dialogPadding
}
description: modelData.toString()
checked: index === choiceListView.currentIndex
onCheckedChanged: {
if (checked) {
choiceListView.currentIndex = index;
}
}
}
}
Rectangle {
color: Appearance.m3colors.m3outline
implicitHeight: 1
Layout.fillWidth: true
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
}
RowLayout {
id: dialogButtonsRowLayout
Layout.bottomMargin: dialogPadding
Layout.leftMargin: dialogPadding
Layout.rightMargin: dialogPadding
Layout.alignment: Qt.AlignRight
DialogButton {
buttonText: Translation.tr("Cancel")
onClicked: root.canceled()
}
DialogButton {
buttonText: Translation.tr("OK")
onClicked: root.selected(
root.selectedId === -1 ? null :
root.items[root.selectedId]
)
}
}
}
}
}
@@ -0,0 +1,62 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import qs.services
import qs.modules.common
import qs.modules.common.widgets
GroupButton {
id: root
horizontalPadding: 12
verticalPadding: 8
bounce: false
property string buttonIcon
property bool leftmost: false
property bool rightmost: false
leftRadius: (toggled || leftmost) ? (height / 2) : Appearance.rounding.unsharpenmore
rightRadius: (toggled || rightmost) ? (height / 2) : Appearance.rounding.unsharpenmore
colBackground: Appearance.colors.colSecondaryContainer
colBackgroundHover: Appearance.colors.colSecondaryContainerHover
colBackgroundActive: Appearance.colors.colSecondaryContainerActive
contentItem: RowLayout {
spacing: 4 * (root.buttonText?.length > 0)
Loader {
Layout.alignment: Qt.AlignVCenter
active: root.buttonIcon && root.buttonIcon.length > 0
visible: active
sourceComponent: Item {
implicitWidth: materialSymbol.implicitWidth
MaterialSymbol {
id: materialSymbol
anchors.centerIn: parent
text: root.buttonIcon
iconSize: Appearance.font.pixelSize.larger
color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
}
}
}
Item {
implicitWidth: root.buttonText?.length > 0 ? textItem.implicitWidth : 0
implicitHeight: textMetrics.height // Force height to that of regular text
TextMetrics {
id: textMetrics
font.family: Appearance.font.family.main
text: "Abc"
}
StyledText {
id: textItem
anchors.centerIn: parent
color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
text: root.buttonText
}
}
}
}
@@ -0,0 +1,12 @@
import QtQuick
import QtQuick.Effects
MultiEffect {
id: root
source: wallpaper
anchors.fill: source
saturation: 0.2
blurEnabled: true
blurMax: 100
blur: 1
}
@@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Controls
import qs.modules.common
Flickable {
id: root
maximumFlickVelocity: 3500
boundsBehavior: Flickable.DragOverBounds
property real touchpadScrollFactor: Config?.options.interactions.scrolling.touchpadScrollFactor ?? 100
property real mouseScrollFactor: Config?.options.interactions.scrolling.mouseScrollFactor ?? 50
property real mouseScrollDeltaThreshold: Config?.options.interactions.scrolling.mouseScrollDeltaThreshold ?? 120
// Accumulated scroll destination so wheel deltas stack while animating
property real scrollTargetY: 0
ScrollBar.vertical: StyledScrollBar {}
MouseArea {
visible: Config?.options.interactions.scrolling.fasterTouchpadScroll
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: function(wheelEvent) {
const delta = wheelEvent.angleDelta.y / root.mouseScrollDeltaThreshold;
// The angleDelta.y of a touchpad is usually small and continuous,
// while that of a mouse wheel is typically in multiples of ±120.
var scrollFactor = Math.abs(wheelEvent.angleDelta.y) >= root.mouseScrollDeltaThreshold ? root.mouseScrollFactor : root.touchpadScrollFactor;
const maxY = Math.max(0, root.contentHeight - root.height);
const base = scrollAnim.running ? root.scrollTargetY : root.contentY;
var targetY = Math.max(0, Math.min(base - delta * scrollFactor, maxY));
root.scrollTargetY = targetY;
root.contentY = targetY;
wheelEvent.accepted = true;
}
}
Behavior on contentY {
NumberAnimation {
id: scrollAnim
duration: Appearance.animation.scroll.duration
easing.type: Appearance.animation.scroll.type
easing.bezierCurve: Appearance.animation.scroll.bezierCurve
}
}
// Keep target synced when not animating (e.g., drag/flick or programmatic changes)
onContentYChanged: {
if (!scrollAnim.running) {
root.scrollTargetY = root.contentY;
}
}
}
@@ -0,0 +1,15 @@
import QtQuick
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
Image {
asynchronous: true
retainWhileLoading: true
visible: opacity > 0
opacity: (status === Image.Ready) ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
}
@@ -0,0 +1,9 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
ProgressBar {
indeterminate: true
Material.accent: Appearance.colors.colPrimary
}
@@ -0,0 +1,15 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls
Label {
renderType: Text.NativeRendering
verticalAlignment: Text.AlignVCenter
font {
hintingPreference: Font.PreferFullHinting
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
}
color: Appearance?.m3colors.m3onBackground ?? "black"
linkColor: Appearance?.m3colors.m3primary
}
@@ -0,0 +1,152 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
/**
* A ListView with animations.
*/
ListView {
id: root
spacing: 5
property real removeOvershoot: 20 // Account for gaps and bouncy animations
property int dragIndex: -1
property real dragDistance: 0
property bool popin: true
property bool animateAppearance: true
property bool animateMovement: false
// Accumulated scroll destination so wheel deltas stack while animating
property real scrollTargetY: 0
property real touchpadScrollFactor: Config?.options.interactions.scrolling.touchpadScrollFactor ?? 100
property real mouseScrollFactor: Config?.options.interactions.scrolling.mouseScrollFactor ?? 50
property real mouseScrollDeltaThreshold: Config?.options.interactions.scrolling.mouseScrollDeltaThreshold ?? 120
function resetDrag() {
root.dragIndex = -1
root.dragDistance = 0
}
maximumFlickVelocity: 3500
boundsBehavior: Flickable.DragOverBounds
ScrollBar.vertical: StyledScrollBar {}
MouseArea {
visible: Config?.options.interactions.scrolling.fasterTouchpadScroll
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: function(wheelEvent) {
const delta = wheelEvent.angleDelta.y / root.mouseScrollDeltaThreshold;
// The angleDelta.y of a touchpad is usually small and continuous,
// while that of a mouse wheel is typically in multiples of ±120.
var scrollFactor = Math.abs(wheelEvent.angleDelta.y) >= root.mouseScrollDeltaThreshold ? root.mouseScrollFactor : root.touchpadScrollFactor;
const maxY = Math.max(0, root.contentHeight - root.height);
const base = scrollAnim.running ? root.scrollTargetY : root.contentY;
var targetY = Math.max(0, Math.min(base - delta * scrollFactor, maxY));
root.scrollTargetY = targetY;
root.contentY = targetY;
wheelEvent.accepted = true;
}
}
Behavior on contentY {
NumberAnimation {
id: scrollAnim
duration: Appearance.animation.scroll.duration
easing.type: Appearance.animation.scroll.type
easing.bezierCurve: Appearance.animation.scroll.bezierCurve
}
}
// Keep target synced when not animating (e.g., drag/flick or programmatic changes)
onContentYChanged: {
if (!scrollAnim.running) {
root.scrollTargetY = root.contentY;
}
}
add: Transition {
animations: animateAppearance ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: popin ? "opacity,scale" : "opacity",
from: 0,
to: 1,
}),
] : []
}
addDisplaced: Transition {
animations: animateAppearance ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: popin ? "opacity,scale" : "opacity",
to: 1,
}),
] : []
}
displaced: Transition {
animations: root.animateMovement ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
] : []
}
move: Transition {
animations: root.animateMovement ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
] : []
}
moveDisplaced: Transition {
animations: root.animateMovement ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
] : []
}
remove: Transition {
animations: animateAppearance ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "x",
to: root.width + root.removeOvershoot,
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "opacity",
to: 0,
})
] : []
}
// This is movement when something is removed, not removing animation!
removeDisplaced: Transition {
animations: animateAppearance ? [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
] : []
}
}
@@ -0,0 +1,97 @@
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
/**
* Material 3 progress bar. See https://m3.material.io/components/progress-indicators/overview
*/
ProgressBar {
id: root
property real valueBarWidth: 120
property real valueBarHeight: 4
property real valueBarGap: 4
property color highlightColor: Appearance?.colors.colPrimary ?? "#685496"
property color trackColor: Appearance?.m3colors.m3secondaryContainer ?? "#F1D3F9"
property bool wavy: false // If true, the progress bar will have a wavy fill effect
property bool animateWave: true
property real waveAmplitudeMultiplier: wavy ? 0.5 : 0
property real waveFrequency: 6
property real waveFps: 60
Behavior on waveAmplitudeMultiplier {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on value {
animation: Appearance?.animation.elementMoveEnter.numberAnimation.createObject(this)
}
background: Item {
implicitHeight: valueBarHeight
implicitWidth: valueBarWidth
}
contentItem: Item {
id: contentItem
anchors.fill: parent
Loader {
anchors {
left: parent.left
verticalCenter: parent.verticalCenter
}
active: root.wavy
sourceComponent: WavyLine {
id: wavyFill
frequency: root.waveFrequency
color: root.highlightColor
amplitudeMultiplier: root.wavy ? 0.5 : 0
height: contentItem.height * 6
width: contentItem.width * root.visualPosition
lineWidth: contentItem.height
fullLength: root.width
Connections {
target: root
function onValueChanged() { wavyFill.requestPaint(); }
function onHighlightColorChanged() { wavyFill.requestPaint(); }
}
FrameAnimation {
running: root.animateWave
onTriggered: {
wavyFill.requestPaint()
}
}
}
}
Loader {
active: !root.wavy
sourceComponent: Rectangle {
anchors.left: parent.left
width: contentItem.width * root.visualPosition
height: contentItem.height
radius: height / 2
color: root.highlightColor
}
}
Rectangle { // Right remaining part fill
anchors.right: parent.right
width: (1 - root.visualPosition) * parent.width - valueBarGap
height: parent.height
radius: height / 2
color: root.trackColor
}
Rectangle { // Stop point
anchors.right: parent.right
width: valueBarGap
height: valueBarGap
radius: height / 2
color: root.highlightColor
}
}
}
@@ -0,0 +1,88 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
import Quickshell.Services.Pipewire
RadioButton {
id: root
padding: 4
implicitHeight: contentItem.implicitHeight + padding * 2
property string description
property color activeColor: Appearance?.colors.colPrimary ?? "#685496"
property color inactiveColor: Appearance?.m3colors.m3onSurfaceVariant ?? "#45464F"
PointingHandInteraction {}
indicator: Item{}
contentItem: RowLayout {
id: contentItem
Layout.fillWidth: true
spacing: 12
Rectangle {
id: radio
Layout.fillWidth: false
Layout.alignment: Qt.AlignVCenter
width: 20
height: 20
radius: Appearance?.rounding.full
border.color: checked ? root.activeColor : root.inactiveColor
border.width: 2
color: "transparent"
// Checked indicator
Rectangle {
anchors.centerIn: parent
width: checked ? 10 : 4
height: checked ? 10 : 4
radius: Appearance?.rounding.full
color: Appearance?.colors.colPrimary
opacity: checked ? 1 : 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on width {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
}
// Hover
Rectangle {
anchors.centerIn: parent
width: root.hovered ? 40 : 20
height: root.hovered ? 40 : 20
radius: Appearance?.rounding.full
color: Appearance?.m3colors.m3onSurface
opacity: root.hovered ? 0.1 : 0
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on width {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance?.animation.elementMove.numberAnimation.createObject(this)
}
}
}
StyledText {
text: root.description
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
wrapMode: Text.Wrap
color: Appearance?.m3colors.m3onSurface
}
}
}
@@ -0,0 +1,14 @@
import QtQuick
import QtQuick.Effects
import qs.modules.common
RectangularShadow {
required property var target
anchors.fill: target
radius: target.radius
blur: 0.9 * Appearance.sizes.elevationMargin
offset: Qt.vector2d(0.0, 1.0)
spread: 1
color: Appearance.colors.colShadow
cached: true
}
@@ -0,0 +1,28 @@
import QtQuick
import QtQuick.Controls
import qs.modules.common
import qs.modules.common.functions
ScrollBar {
id: root
policy: ScrollBar.AsNeeded
topPadding: Appearance.rounding.normal
bottomPadding: Appearance.rounding.normal
contentItem: Rectangle {
implicitWidth: 4
implicitHeight: root.visualSize
radius: width / 2
color: Appearance.colors.colOnSurfaceVariant
opacity: root.policy === ScrollBar.AlwaysOn || (root.active && root.size < 1.0) ? 0.5 : 0
Behavior on opacity {
NumberAnimation {
duration: 350
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
}
}
@@ -0,0 +1,192 @@
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
/**
* Material 3 slider. See https://m3.material.io/components/sliders/overview
* It doesn't exactly match the spec because it does not make sense to have stuff on a computer that fucking huge.
* Should be at 3/4 scale...
*/
Slider {
id: root
property list<real> stopIndicatorValues: [1]
enum Configuration {
Wavy = 4,
XS = 12,
S = 18,
M = 30,
L = 42,
XL = 72
}
property var configuration: StyledSlider.Configuration.S
property real handleDefaultWidth: 3
property real handlePressedWidth: 1.5
property color highlightColor: Appearance.colors.colPrimary
property color trackColor: Appearance.colors.colSecondaryContainer
property color handleColor: Appearance.colors.colPrimary
property color dotColor: Appearance.m3colors.m3onSecondaryContainer
property color dotColorHighlighted: Appearance.m3colors.m3onPrimary
property real unsharpenRadius: Appearance.rounding.unsharpen
property real trackWidth: configuration
property real trackRadius: trackWidth >= StyledSlider.Configuration.XL ? 21
: trackWidth >= StyledSlider.Configuration.L ? 12
: trackWidth >= StyledSlider.Configuration.M ? 9
: trackWidth >= StyledSlider.Configuration.S ? 6
: height / 2
property real handleHeight: (configuration === StyledSlider.Configuration.Wavy) ? 24 : Math.max(33, trackWidth + 9)
property real handleWidth: root.pressed ? handlePressedWidth : handleDefaultWidth
property real handleMargins: 4
property real trackDotSize: 3
property string tooltipContent: `${Math.round(value * 100)}%`
property bool wavy: configuration === StyledSlider.Configuration.Wavy // If true, the progress bar will have a wavy fill effect
property bool animateWave: true
property real waveAmplitudeMultiplier: wavy ? 0.5 : 0
property real waveFrequency: 6
property real waveFps: 60
leftPadding: handleMargins
rightPadding: handleMargins
property real effectiveDraggingWidth: width - leftPadding - rightPadding
Layout.fillWidth: true
from: 0
to: 1
Behavior on value { // This makes the adjusted value (like volume) shift smoothly
SmoothedAnimation {
velocity: Appearance.animation.elementMoveFast.velocity
}
}
Behavior on handleMargins {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
component TrackDot: Rectangle {
required property real value
anchors.verticalCenter: parent.verticalCenter
x: root.handleMargins + (value * root.effectiveDraggingWidth) - (root.trackDotSize / 2)
width: root.trackDotSize
height: root.trackDotSize
radius: Appearance.rounding.full
color: value > root.visualPosition ? root.dotColor : root.dotColorHighlighted
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
MouseArea {
anchors.fill: parent
onPressed: (mouse) => mouse.accepted = false
cursorShape: root.pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor
}
background: Item {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
implicitHeight: trackWidth
// Fill left
Loader {
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
}
width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins)
height: root.trackWidth
active: !root.wavy
sourceComponent: Rectangle {
color: root.highlightColor
topLeftRadius: root.trackRadius
bottomLeftRadius: root.trackRadius
topRightRadius: root.unsharpenRadius
bottomRightRadius: root.unsharpenRadius
}
}
Loader {
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
}
width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins)
height: root.height
active: root.wavy
sourceComponent: WavyLine {
id: wavyFill
frequency: root.waveFrequency
fullLength: root.width
color: root.highlightColor
amplitudeMultiplier: root.wavy ? 0.5 : 0
width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins)
height: root.trackWidth
Connections {
target: root
function onValueChanged() { wavyFill.requestPaint(); }
function onHighlightColorChanged() { wavyFill.requestPaint(); }
}
FrameAnimation {
running: root.animateWave
onTriggered: {
wavyFill.requestPaint()
}
}
}
}
// Fill right
Rectangle {
anchors {
verticalCenter: parent.verticalCenter
right: parent.right
}
width: root.handleMargins + ((1 - root.visualPosition) * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins)
height: trackWidth
color: root.trackColor
topRightRadius: root.trackRadius
bottomRightRadius: root.trackRadius
topLeftRadius: root.unsharpenRadius
bottomLeftRadius: root.unsharpenRadius
}
// Stop indicators
Repeater {
model: root.stopIndicatorValues
TrackDot {
required property real modelData
value: modelData
anchors.verticalCenter: parent.verticalCenter
}
}
}
handle: Rectangle {
id: handle
implicitWidth: root.handleWidth
implicitHeight: root.handleHeight
x: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2)
anchors.verticalCenter: parent.verticalCenter
radius: Appearance.rounding.full
color: root.handleColor
Behavior on implicitWidth {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
StyledToolTip {
extraVisibleCondition: root.pressed
text: root.tooltipContent
}
}
}
@@ -0,0 +1,94 @@
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
/**
* Material 3 styled SpinBox component.
*/
SpinBox {
id: root
property real baseHeight: 35
property real radius: Appearance.rounding.small
property real innerButtonRadius: Appearance.rounding.unsharpen
editable: true
opacity: root.enabled ? 1 : 0.4
background: Rectangle {
color: Appearance.colors.colLayer2
radius: root.radius
}
contentItem: Item {
implicitHeight: root.baseHeight
implicitWidth: Math.max(labelText.implicitWidth, 40)
StyledTextInput {
id: labelText
anchors.centerIn: parent
text: root.value // displayText would make the numbers weird like 1,000 instead of 1000
color: Appearance.colors.colOnLayer2
font.pixelSize: Appearance.font.pixelSize.small
validator: root.validator
onTextChanged: {
root.value = parseFloat(text);
}
}
}
down.indicator: Rectangle {
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
}
implicitHeight: root.baseHeight
implicitWidth: root.baseHeight
topLeftRadius: root.radius
bottomLeftRadius: root.radius
topRightRadius: root.innerButtonRadius
bottomRightRadius: root.innerButtonRadius
color: root.down.pressed ? Appearance.colors.colLayer2Active :
root.down.hovered ? Appearance.colors.colLayer2Hover :
ColorUtils.transparentize(Appearance.colors.colLayer2)
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
MaterialSymbol {
anchors.centerIn: parent
text: "remove"
iconSize: 20
color: Appearance.colors.colOnLayer2
}
}
up.indicator: Rectangle {
anchors {
verticalCenter: parent.verticalCenter
right: parent.right
}
implicitHeight: root.baseHeight
implicitWidth: root.baseHeight
topRightRadius: root.radius
bottomRightRadius: root.radius
topLeftRadius: root.innerButtonRadius
bottomLeftRadius: root.innerButtonRadius
color: root.up.pressed ? Appearance.colors.colLayer2Active :
root.up.hovered ? Appearance.colors.colLayer2Hover :
ColorUtils.transparentize(Appearance.colors.colLayer2)
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
MaterialSymbol {
anchors.centerIn: parent
text: "add"
iconSize: 20
color: Appearance.colors.colOnLayer2
}
}
}
@@ -0,0 +1,60 @@
import qs.modules.common
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
/**
* Material 3 switch. See https://m3.material.io/components/switch/overview
*/
Switch {
id: root
property real scale: 0.6 // Default in m3 spec is huge af
implicitHeight: 32 * root.scale
implicitWidth: 52 * root.scale
property color activeColor: Appearance?.colors.colPrimary ?? "#685496"
property color inactiveColor: Appearance?.colors.colSurfaceContainerHighest ?? "#45464F"
PointingHandInteraction {}
// Custom track styling
background: Rectangle {
width: parent.width
height: parent.height
radius: Appearance?.rounding.full ?? 9999
color: root.checked ? root.activeColor : root.inactiveColor
border.width: 2 * root.scale
border.color: root.checked ? root.activeColor : Appearance.m3colors.m3outline
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
Behavior on border.color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
// Custom thumb styling
indicator: Rectangle {
width: (root.pressed || root.down) ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale)
height: (root.pressed || root.down) ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale)
radius: Appearance.rounding.full
color: root.checked ? Appearance.m3colors.m3onPrimary : Appearance.m3colors.m3outline
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: root.checked ? ((root.pressed || root.down) ? (22 * root.scale) : 24 * root.scale) : ((root.pressed || root.down) ? (2 * root.scale) : 8 * root.scale)
Behavior on anchors.leftMargin {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on width {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
@@ -0,0 +1,87 @@
import qs.modules.common
import QtQuick
Text {
id: root
property bool animateChange: false
property real animationDistanceX: 0
property real animationDistanceY: 6
renderType: Text.NativeRendering
verticalAlignment: Text.AlignVCenter
font {
hintingPreference: Font.PreferDefaultHinting
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
}
color: Appearance?.m3colors.m3onBackground ?? "black"
linkColor: Appearance?.m3colors.m3primary
component Anim: NumberAnimation {
target: root
duration: 300 / 2
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
Component.onCompleted: {
textAnimationBehavior.originalX = root.x;
textAnimationBehavior.originalY = root.y;
}
Behavior on text {
id: textAnimationBehavior
property real originalX: root.x
property real originalY: root.y
enabled: root.animateChange
SequentialAnimation {
alwaysRunToEnd: true
ParallelAnimation {
Anim {
property: "x"
to: textAnimationBehavior.originalX - root.animationDistanceX
easing.type: Easing.InSine
}
Anim {
property: "y"
to: textAnimationBehavior.originalY - root.animationDistanceY
easing.type: Easing.InSine
}
Anim {
property: "opacity"
to: 0
easing.type: Easing.InSine
}
}
PropertyAction {} // Tie the text update to this point (we don't want it to happen during the first slide+fade)
PropertyAction {
target: root
property: "x"
value: textAnimationBehavior.originalX + root.animationDistanceX
}
PropertyAction {
target: root
property: "y"
value: textAnimationBehavior.originalY + root.animationDistanceY
}
ParallelAnimation {
Anim {
property: "x"
to: textAnimationBehavior.originalX
easing.type: Easing.OutSine
}
Anim {
property: "y"
to: textAnimationBehavior.originalY
easing.type: Easing.OutSine
}
Anim {
property: "opacity"
to: 1
easing.type: Easing.OutSine
}
}
}
}
}
@@ -0,0 +1,18 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls
/**
* Does not include visual layout, but includes the easily neglected colors.
*/
TextArea {
renderType: Text.NativeRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
placeholderTextColor: Appearance.m3colors.m3outline
font {
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
hintingPreference: Font.PreferFullHinting
}
}
@@ -0,0 +1,18 @@
import qs.modules.common
import QtQuick
import QtQuick.Controls
/**
* Does not include visual layout, but includes the easily neglected colors.
*/
TextInput {
color: Appearance.colors.colOnLayer1
renderType: Text.NativeRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
font {
family: Appearance?.font.family.main ?? "sans-serif"
pixelSize: Appearance?.font.pixelSize.small ?? 15
hintingPreference: Font.PreferFullHinting
}
}
@@ -0,0 +1,25 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ToolTip {
id: root
property bool extraVisibleCondition: true
property bool alternativeVisibleCondition: false
readonly property bool internalVisibleCondition: (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition
verticalPadding: 5
horizontalPadding: 10
background: null
visible: internalVisibleCondition
contentItem: StyledToolTipContent {
id: contentItem
text: root.text
shown: root.internalVisibleCondition
horizontalPadding: root.horizontalPadding
verticalPadding: root.verticalPadding
}
}
@@ -0,0 +1,52 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
required property string text
property bool shown: false
property real horizontalPadding: 10
property real verticalPadding: 5
implicitWidth: tooltipTextObject.implicitWidth + 2 * root.horizontalPadding
implicitHeight: tooltipTextObject.implicitHeight + 2 * root.verticalPadding
property bool isVisible: backgroundRectangle.implicitHeight > 0
Rectangle {
id: backgroundRectangle
anchors {
bottom: root.bottom
horizontalCenter: root.horizontalCenter
}
color: Appearance?.colors.colTooltip ?? "#3C4043"
radius: Appearance?.rounding.verysmall ?? 7
opacity: shown ? 1 : 0
implicitWidth: shown ? (tooltipTextObject.implicitWidth + 2 * root.horizontalPadding) : 0
implicitHeight: shown ? (tooltipTextObject.implicitHeight + 2 * root.verticalPadding) : 0
clip: true
Behavior on implicitWidth {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on opacity {
animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this)
}
StyledText {
id: tooltipTextObject
anchors.centerIn: parent
text: root.text
font.pixelSize: Appearance?.font.pixelSize.smaller ?? 14
font.hintingPreference: Font.PreferNoHinting // Prevent shaky text
color: Appearance?.colors.colOnTooltip ?? "#FFFFFF"
wrapMode: Text.Wrap
}
}
}
@@ -0,0 +1,56 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
/**
* Thumbnail image. It currently generates to the right place at the right size, but does not handle metadata/maintenance on modification.
* See Freedesktop's spec: https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html
*/
StyledImage {
id: root
property bool generateThumbnail: true
required property string sourcePath
property string thumbnailSizeName: Images.thumbnailSizeNameForDimensions(sourceSize.width, sourceSize.height)
property string thumbnailPath: {
if (sourcePath.length == 0) return;
const resolvedUrlWithoutFileProtocol = FileUtils.trimFileProtocol(`${Qt.resolvedUrl(sourcePath)}`);
const encodedUrlWithoutFileProtocol = resolvedUrlWithoutFileProtocol.split("/").map(part => encodeURIComponent(part)).join("/");
const md5Hash = Qt.md5(`file://${encodedUrlWithoutFileProtocol}`);
return `${Directories.genericCache}/thumbnails/${thumbnailSizeName}/${md5Hash}.png`;
}
source: thumbnailPath
asynchronous: true
smooth: true
mipmap: false
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
onSourceSizeChanged: {
if (!root.generateThumbnail) return;
thumbnailGeneration.running = false;
thumbnailGeneration.running = true;
}
Process {
id: thumbnailGeneration
command: {
const maxSize = Images.thumbnailSizes[root.thumbnailSizeName];
return ["bash", "-c",
`[ -f '${FileUtils.trimFileProtocol(root.thumbnailPath)}' ] && exit 0 || { magick '${root.sourcePath}' -resize ${maxSize}x${maxSize} '${FileUtils.trimFileProtocol(root.thumbnailPath)}' && exit 1; }`
]
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 1) { // Force reload if thumbnail had to be generated
root.source = "";
root.source = root.thumbnailPath; // Force reload
}
}
}
}
@@ -0,0 +1,42 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
/**
* Material 3 expressive style toolbar.
* https://m3.material.io/components/toolbars
*/
Item {
id: root
property real padding: 8
property alias colBackground: background.color
property alias spacing: toolbarLayout.spacing
default property alias data: toolbarLayout.data
implicitWidth: background.implicitWidth
implicitHeight: background.implicitHeight
property alias radius: background.radius
StyledRectangularShadow {
target: background
}
Rectangle {
id: background
anchors.fill: parent
color: Appearance.m3colors.m3surfaceContainer // Needs to be opaque
implicitHeight: Math.max(toolbarLayout.implicitHeight + root.padding * 2, 56)
implicitWidth: toolbarLayout.implicitWidth + root.padding * 2
radius: height / 2
RowLayout {
id: toolbarLayout
spacing: 4
anchors {
fill: parent
margins: root.padding
}
}
}
}
@@ -0,0 +1,8 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
RippleButton {
Layout.fillHeight: true
buttonRadius: Appearance.rounding.full
}
@@ -0,0 +1,28 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import qs.modules.common
import qs.modules.common.widgets
TextField {
id: filterField
property alias colBackground: background.color
Layout.fillHeight: true
implicitWidth: 200
padding: 10
placeholderTextColor: Appearance.colors.colSubtext
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.small
renderType: Text.NativeRendering
selectedTextColor: Appearance.colors.colOnSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
background: Rectangle {
id: background
color: Appearance.colors.colLayer1
radius: Appearance.rounding.full
}
}
@@ -0,0 +1,45 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
/**
* A container that supports GroupButton children for bounciness.
* See https://m3.material.io/components/button-groups/overview
*/
Rectangle {
id: root
default property alias content: columnLayout.data
property real spacing: 5
property real padding: 0
property int clickIndex: columnLayout.clickIndex
property real contentHeight: {
let total = 0;
for (let i = 0; i < columnLayout.children.length; ++i) {
const child = columnLayout.children[i];
total += child.baseHeight ?? child.implicitHeight ?? child.height;
}
return total + columnLayout.spacing * (columnLayout.children.length - 1);
}
topLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[0].radius + padding) :
Appearance?.rounding?.small
topRightRadius: topLeftRadius
bottomLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[columnLayout.children.length - 1].radius + padding) :
Appearance?.rounding?.small
bottomRightRadius: bottomLeftRadius
color: "transparent"
height: root.contentHeight + padding * 2
implicitWidth: columnLayout.implicitWidth + padding * 2
implicitHeight: root.contentHeight + padding * 2
children: [ColumnLayout {
id: columnLayout
anchors.fill: parent
anchors.margins: root.padding
spacing: root.spacing
property int clickIndex: -1
}]
}
@@ -0,0 +1,10 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.functions
ToolbarButton {
colBackground: ColorUtils.transparentize(Appearance.colors.colPrimaryContainer)
colBackgroundHover: Appearance.colors.colPrimaryContainerHover
colRipple: Appearance.colors.colPrimaryContainerActive
}

Some files were not shown because too many files have changed in this diff Show More