quickshell and hyprland additions

This commit is contained in:
2026-03-15 13:56:00 +02:00
parent c9c27d1554
commit 1ad06b82a6
509 changed files with 68371 additions and 19 deletions

View File

@@ -0,0 +1,108 @@
import ".."
import qs.services
import qs.config
import Caelestia.Internal
import QtQuick
import QtQuick.Templates
BusyIndicator {
id: root
enum AnimType {
Advance = 0,
Retreat
}
enum AnimState {
Stopped,
Running,
Completing
}
property real implicitSize: Appearance.font.size.normal * 3
property real strokeWidth: Appearance.padding.small * 0.8
property color fgColour: Colours.palette.m3primary
property color bgColour: Colours.palette.m3secondaryContainer
property alias type: manager.indeterminateAnimationType
readonly property alias progress: manager.progress
property real internalStrokeWidth: strokeWidth
property int animState
padding: 0
implicitWidth: implicitSize
implicitHeight: implicitSize
Component.onCompleted: {
if (running) {
running = false;
running = true;
}
}
onRunningChanged: {
if (running) {
manager.completeEndProgress = 0;
animState = CircularIndicator.Running;
} else {
if (animState == CircularIndicator.Running)
animState = CircularIndicator.Completing;
}
}
states: State {
name: "stopped"
when: !root.running
PropertyChanges {
root.opacity: 0
root.internalStrokeWidth: root.strokeWidth / 3
}
}
transitions: Transition {
Anim {
properties: "opacity,internalStrokeWidth"
duration: manager.completeEndDuration * Appearance.anim.durations.scale
}
}
contentItem: CircularProgress {
anchors.fill: parent
strokeWidth: root.internalStrokeWidth
fgColour: root.fgColour
bgColour: root.bgColour
padding: root.padding
rotation: manager.rotation
startAngle: manager.startFraction * 360
value: manager.endFraction - manager.startFraction
}
CircularIndicatorManager {
id: manager
}
NumberAnimation {
running: root.animState !== CircularIndicator.Stopped
loops: Animation.Infinite
target: manager
property: "progress"
from: 0
to: 1
duration: manager.duration * Appearance.anim.durations.scale
}
NumberAnimation {
running: root.animState === CircularIndicator.Completing
target: manager
property: "completeEndProgress"
from: 0
to: 1
duration: manager.completeEndDuration * Appearance.anim.durations.scale
onFinished: {
if (root.animState === CircularIndicator.Completing)
root.animState = CircularIndicator.Stopped;
}
}
}

View File

@@ -0,0 +1,69 @@
import ".."
import qs.services
import qs.config
import QtQuick
import QtQuick.Shapes
Shape {
id: root
property real value
property int startAngle: -90
property int strokeWidth: Appearance.padding.smaller
property int padding: 0
property int spacing: Appearance.spacing.small
property color fgColour: Colours.palette.m3primary
property color bgColour: Colours.palette.m3secondaryContainer
readonly property real size: Math.min(width, height)
readonly property real arcRadius: (size - padding - strokeWidth) / 2
readonly property real vValue: value || 1 / 360
readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI)
preferredRendererType: Shape.CurveRenderer
asynchronous: true
ShapePath {
fillColor: "transparent"
strokeColor: root.bgColour
strokeWidth: root.strokeWidth
capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
PathAngleArc {
startAngle: root.startAngle + 360 * root.vValue + root.gapAngle
sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2)
radiusX: root.arcRadius
radiusY: root.arcRadius
centerX: root.size / 2
centerY: root.size / 2
}
Behavior on strokeColor {
CAnim {
duration: Appearance.anim.durations.large
}
}
}
ShapePath {
fillColor: "transparent"
strokeColor: root.fgColour
strokeWidth: root.strokeWidth
capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
PathAngleArc {
startAngle: root.startAngle
sweepAngle: 360 * root.vValue
radiusX: root.arcRadius
radiusY: root.arcRadius
centerX: root.size / 2
centerY: root.size / 2
}
Behavior on strokeColor {
CAnim {
duration: Appearance.anim.durations.large
}
}
}
}

View File

@@ -0,0 +1,132 @@
import ".."
import qs.components
import qs.components.effects
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
ColumnLayout {
id: root
required property string title
property string description: ""
property bool expanded: false
property bool showBackground: false
property bool nested: false
signal toggleRequested
spacing: Appearance.spacing.small
Layout.fillWidth: true
Item {
id: sectionHeaderItem
Layout.fillWidth: true
Layout.preferredHeight: Math.max(titleRow.implicitHeight + Appearance.padding.normal * 2, 48)
RowLayout {
id: titleRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Appearance.padding.normal
anchors.rightMargin: Appearance.padding.normal
spacing: Appearance.spacing.normal
StyledText {
text: root.title
font.pointSize: Appearance.font.size.larger
font.weight: 500
}
Item {
Layout.fillWidth: true
}
MaterialIcon {
text: "expand_more"
rotation: root.expanded ? 180 : 0
color: Colours.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.normal
Behavior on rotation {
Anim {
duration: Appearance.anim.durations.small
easing.bezierCurve: Appearance.anim.curves.standard
}
}
}
}
StateLayer {
anchors.fill: parent
color: Colours.palette.m3onSurface
radius: Appearance.rounding.normal
showHoverBackground: false
function onClicked(): void {
root.toggleRequested();
root.expanded = !root.expanded;
}
}
}
default property alias content: contentColumn.data
Item {
id: contentWrapper
Layout.fillWidth: true
Layout.preferredHeight: root.expanded ? (contentColumn.implicitHeight + Appearance.spacing.small * 2) : 0
clip: true
Behavior on Layout.preferredHeight {
Anim {
easing.bezierCurve: Appearance.anim.curves.standard
}
}
StyledRect {
id: backgroundRect
anchors.fill: parent
radius: Appearance.rounding.normal
color: Colours.transparency.enabled ? Colours.layer(Colours.palette.m3surfaceContainer, root.nested ? 3 : 2) : (root.nested ? Colours.palette.m3surfaceContainerHigh : Colours.palette.m3surfaceContainer)
opacity: root.showBackground && root.expanded ? 1.0 : 0.0
visible: root.showBackground
Behavior on opacity {
Anim {
easing.bezierCurve: Appearance.anim.curves.standard
}
}
}
ColumnLayout {
id: contentColumn
anchors.left: parent.left
anchors.right: parent.right
y: Appearance.spacing.small
anchors.leftMargin: Appearance.padding.normal
anchors.rightMargin: Appearance.padding.normal
anchors.bottomMargin: Appearance.spacing.small
spacing: Appearance.spacing.small
opacity: root.expanded ? 1.0 : 0.0
Behavior on opacity {
Anim {
easing.bezierCurve: Appearance.anim.curves.standard
}
}
StyledText {
id: descriptionText
Layout.fillWidth: true
Layout.topMargin: root.description !== "" ? Appearance.spacing.smaller : 0
Layout.bottomMargin: root.description !== "" ? Appearance.spacing.small : 0
visible: root.description !== ""
text: root.description
color: Colours.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.small
wrapMode: Text.Wrap
}
}
}
}

View File

@@ -0,0 +1,21 @@
import QtQuick
MouseArea {
property int scrollAccumulatedY: 0
function onWheel(event: WheelEvent): void {
}
onWheel: event => {
// Update accumulated scroll
if (Math.sign(event.angleDelta.y) !== Math.sign(scrollAccumulatedY))
scrollAccumulatedY = 0;
scrollAccumulatedY += event.angleDelta.y;
// Trigger handler and reset if above threshold
if (Math.abs(scrollAccumulatedY) >= 120) {
onWheel(event);
scrollAccumulatedY = 0;
}
}
}

View File

@@ -0,0 +1,170 @@
pragma ComponentBehavior: Bound
import ".."
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
RowLayout {
id: root
property real value
property real max: Infinity
property real min: -Infinity
property real step: 1
property alias repeatRate: timer.interval
signal valueModified(value: real)
spacing: Appearance.spacing.small
property bool isEditing: false
property string displayText: root.value.toString()
onValueChanged: {
if (!root.isEditing) {
root.displayText = root.value.toString();
}
}
StyledTextField {
id: textField
inputMethodHints: Qt.ImhFormattedNumbersOnly
text: root.isEditing ? text : root.displayText
validator: DoubleValidator {
bottom: root.min
top: root.max
decimals: root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0
}
onActiveFocusChanged: {
if (activeFocus) {
root.isEditing = true;
} else {
root.isEditing = false;
root.displayText = root.value.toString();
}
}
onAccepted: {
const numValue = parseFloat(text);
if (!isNaN(numValue)) {
const clampedValue = Math.max(root.min, Math.min(root.max, numValue));
root.value = clampedValue;
root.displayText = clampedValue.toString();
root.valueModified(clampedValue);
} else {
text = root.displayText;
}
root.isEditing = false;
}
onEditingFinished: {
if (text !== root.displayText) {
const numValue = parseFloat(text);
if (!isNaN(numValue)) {
const clampedValue = Math.max(root.min, Math.min(root.max, numValue));
root.value = clampedValue;
root.displayText = clampedValue.toString();
root.valueModified(clampedValue);
} else {
text = root.displayText;
}
}
root.isEditing = false;
}
padding: Appearance.padding.small
leftPadding: Appearance.padding.normal
rightPadding: Appearance.padding.normal
background: StyledRect {
implicitWidth: 100
radius: Appearance.rounding.small
color: Colours.tPalette.m3surfaceContainerHigh
}
}
StyledRect {
radius: Appearance.rounding.small
color: Colours.palette.m3primary
implicitWidth: implicitHeight
implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2
StateLayer {
id: upState
color: Colours.palette.m3onPrimary
onPressAndHold: timer.start()
onReleased: timer.stop()
function onClicked(): void {
let newValue = Math.min(root.max, root.value + root.step);
// Round to avoid floating point precision errors
const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0;
newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals);
root.value = newValue;
root.displayText = newValue.toString();
root.valueModified(newValue);
}
}
MaterialIcon {
id: upIcon
anchors.centerIn: parent
text: "keyboard_arrow_up"
color: Colours.palette.m3onPrimary
}
}
StyledRect {
radius: Appearance.rounding.small
color: Colours.palette.m3primary
implicitWidth: implicitHeight
implicitHeight: downIcon.implicitHeight + Appearance.padding.small * 2
StateLayer {
id: downState
color: Colours.palette.m3onPrimary
onPressAndHold: timer.start()
onReleased: timer.stop()
function onClicked(): void {
let newValue = Math.max(root.min, root.value - root.step);
// Round to avoid floating point precision errors
const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0;
newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals);
root.value = newValue;
root.displayText = newValue.toString();
root.valueModified(newValue);
}
}
MaterialIcon {
id: downIcon
anchors.centerIn: parent
text: "keyboard_arrow_down"
color: Colours.palette.m3onPrimary
}
}
Timer {
id: timer
interval: 100
repeat: true
triggeredOnStart: true
onTriggered: {
if (upState.pressed)
upState.onClicked();
else if (downState.pressed)
downState.onClicked();
}
}
}

View File

@@ -0,0 +1,146 @@
import ".."
import "../effects"
import qs.services
import qs.config
import QtQuick
import QtQuick.Templates
Slider {
id: root
required property string icon
property real oldValue
property bool initialized
orientation: Qt.Vertical
background: StyledRect {
color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.full
StyledRect {
anchors.left: parent.left
anchors.right: parent.right
y: root.handle.y
implicitHeight: parent.height - y
color: Colours.palette.m3secondary
radius: parent.radius
}
}
handle: Item {
id: handle
property alias moving: icon.moving
y: root.visualPosition * (root.availableHeight - height)
implicitWidth: root.width
implicitHeight: root.width
Elevation {
anchors.fill: parent
radius: rect.radius
level: handleInteraction.containsMouse ? 2 : 1
}
StyledRect {
id: rect
anchors.fill: parent
color: Colours.palette.m3inverseSurface
radius: Appearance.rounding.full
MouseArea {
id: handleInteraction
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.NoButton
}
MaterialIcon {
id: icon
property bool moving
function update(): void {
animate = !moving;
binding.when = moving;
font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.larger;
font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material;
}
text: root.icon
color: Colours.palette.m3inverseOnSurface
anchors.centerIn: parent
onMovingChanged: anim.restart()
Binding {
id: binding
target: icon
property: "text"
value: Math.round(root.value * 100)
when: false
}
SequentialAnimation {
id: anim
Anim {
target: icon
property: "scale"
to: 0
duration: Appearance.anim.durations.normal / 2
easing.bezierCurve: Appearance.anim.curves.standardAccel
}
ScriptAction {
script: icon.update()
}
Anim {
target: icon
property: "scale"
to: 1
duration: Appearance.anim.durations.normal / 2
easing.bezierCurve: Appearance.anim.curves.standardDecel
}
}
}
}
}
onPressedChanged: handle.moving = pressed
onValueChanged: {
if (!initialized) {
initialized = true;
return;
}
if (Math.abs(value - oldValue) < 0.01)
return;
oldValue = value;
handle.moving = true;
stateChangeDelay.restart();
}
Timer {
id: stateChangeDelay
interval: 500
onTriggered: {
if (!root.pressed)
handle.moving = false;
}
}
Behavior on value {
Anim {
duration: Appearance.anim.durations.large
}
}
}

View File

@@ -0,0 +1,83 @@
import ".."
import qs.services
import qs.config
import QtQuick
StyledRect {
id: root
enum Type {
Filled,
Tonal,
Text
}
property alias icon: label.text
property bool checked
property bool toggle
property real padding: type === IconButton.Text ? Appearance.padding.small / 2 : Appearance.padding.smaller
property alias font: label.font
property int type: IconButton.Filled
property bool disabled
property alias stateLayer: stateLayer
property alias label: label
property alias radiusAnim: radiusAnim
property bool internalChecked
property color activeColour: type === IconButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary
property color inactiveColour: {
if (!toggle && type === IconButton.Filled)
return Colours.palette.m3primary;
return type === IconButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer;
}
property color activeOnColour: type === IconButton.Filled ? Colours.palette.m3onPrimary : type === IconButton.Tonal ? Colours.palette.m3onSecondary : Colours.palette.m3primary
property color inactiveOnColour: {
if (!toggle && type === IconButton.Filled)
return Colours.palette.m3onPrimary;
return type === IconButton.Tonal ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant;
}
property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1)
property color disabledOnColour: Qt.alpha(Colours.palette.m3onSurface, 0.38)
signal clicked
onCheckedChanged: internalChecked = checked
radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour
implicitWidth: implicitHeight
implicitHeight: label.implicitHeight + padding * 2
StateLayer {
id: stateLayer
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
disabled: root.disabled
function onClicked(): void {
if (root.toggle)
root.internalChecked = !root.internalChecked;
root.clicked();
}
}
MaterialIcon {
id: label
anchors.centerIn: parent
color: root.disabled ? root.disabledOnColour : root.internalChecked ? root.activeOnColour : root.inactiveOnColour
fill: !root.toggle || root.internalChecked ? 1 : 0
Behavior on fill {
Anim {}
}
}
Behavior on radius {
Anim {
id: radiusAnim
}
}
}

View File

@@ -0,0 +1,88 @@
import ".."
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
StyledRect {
id: root
enum Type {
Filled,
Tonal,
Text
}
property alias icon: iconLabel.text
property alias text: label.text
property bool checked
property bool toggle
property real horizontalPadding: Appearance.padding.normal
property real verticalPadding: Appearance.padding.smaller
property alias font: label.font
property int type: IconTextButton.Filled
property alias stateLayer: stateLayer
property alias iconLabel: iconLabel
property alias label: label
property bool internalChecked
property color activeColour: type === IconTextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary
property color inactiveColour: type === IconTextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer
property color activeOnColour: type === IconTextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary
property color inactiveOnColour: type === IconTextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
signal clicked
onCheckedChanged: internalChecked = checked
radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
color: type === IconTextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour
implicitWidth: row.implicitWidth + horizontalPadding * 2
implicitHeight: row.implicitHeight + verticalPadding * 2
StateLayer {
id: stateLayer
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
function onClicked(): void {
if (root.toggle)
root.internalChecked = !root.internalChecked;
root.clicked();
}
}
RowLayout {
id: row
anchors.centerIn: parent
spacing: Appearance.spacing.small
MaterialIcon {
id: iconLabel
Layout.alignment: Qt.AlignVCenter
Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575)
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
fill: root.internalChecked ? 1 : 0
Behavior on fill {
Anim {}
}
}
StyledText {
id: label
Layout.alignment: Qt.AlignVCenter
Layout.topMargin: -Math.round(iconLabel.fontInfo.pointSize * 0.0575)
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
}
}
Behavior on radius {
Anim {}
}
}

View File

@@ -0,0 +1,113 @@
pragma ComponentBehavior: Bound
import ".."
import "../effects"
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
Elevation {
id: root
property list<MenuItem> items
property MenuItem active: items[0] ?? null
property bool expanded
signal itemSelected(item: MenuItem)
radius: Appearance.rounding.small / 2
level: 2
implicitWidth: Math.max(200, column.implicitWidth)
implicitHeight: root.expanded ? column.implicitHeight : 0
opacity: root.expanded ? 1 : 0
StyledClippingRect {
anchors.fill: parent
radius: parent.radius
color: Colours.palette.m3surfaceContainer
ColumnLayout {
id: column
anchors.left: parent.left
anchors.right: parent.right
spacing: 0
Repeater {
model: root.items
StyledRect {
id: item
required property int index
required property MenuItem modelData
readonly property bool active: modelData === root.active
Layout.fillWidth: true
implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.normal * 2
implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.normal * 2
color: Qt.alpha(Colours.palette.m3secondaryContainer, active ? 1 : 0)
StateLayer {
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
disabled: !root.expanded
function onClicked(): void {
root.itemSelected(item.modelData);
root.active = item.modelData;
root.expanded = false;
}
}
RowLayout {
id: menuOptionRow
anchors.fill: parent
anchors.margins: Appearance.padding.normal
spacing: Appearance.spacing.small
MaterialIcon {
Layout.alignment: Qt.AlignVCenter
text: item.modelData.icon
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant
}
StyledText {
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
text: item.modelData.text
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
}
Loader {
Layout.alignment: Qt.AlignVCenter
active: item.modelData.trailingIcon.length > 0
visible: active
sourceComponent: MaterialIcon {
text: item.modelData.trailingIcon
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
}
}
}
}
}
}
}
Behavior on opacity {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
}
}
Behavior on implicitHeight {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
}

View File

@@ -0,0 +1,11 @@
import QtQuick
QtObject {
required property string text
property string icon
property string trailingIcon
property string activeIcon: icon
property string activeText: text
signal clicked
}

View File

@@ -0,0 +1,52 @@
import ".."
import qs.components
import qs.components.effects
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
StyledRect {
id: root
required property string label
required property real value
required property real min
required property real max
property real step: 1
property var onValueModified: function (value) {}
Layout.fillWidth: true
implicitHeight: row.implicitHeight + Appearance.padding.large * 2
radius: Appearance.rounding.normal
color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
Behavior on implicitHeight {
Anim {}
}
RowLayout {
id: row
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Appearance.padding.large
spacing: Appearance.spacing.normal
StyledText {
Layout.fillWidth: true
text: root.label
}
CustomSpinBox {
min: root.min
max: root.max
step: root.step
value: root.value
onValueModified: value => {
root.onValueModified(value);
}
}
}
}

View File

@@ -0,0 +1,164 @@
import ".."
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
Row {
id: root
enum Type {
Filled,
Tonal
}
property real horizontalPadding: Appearance.padding.normal
property real verticalPadding: Appearance.padding.smaller
property int type: SplitButton.Filled
property bool disabled
property bool menuOnTop
property string fallbackIcon
property string fallbackText
property alias menuItems: menu.items
property alias active: menu.active
property alias expanded: menu.expanded
property alias menu: menu
property alias iconLabel: iconLabel
property alias label: label
property alias stateLayer: stateLayer
property color colour: type == SplitButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer
property color textColour: type == SplitButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer
property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1)
property color disabledTextColour: Qt.alpha(Colours.palette.m3onSurface, 0.38)
spacing: Math.floor(Appearance.spacing.small / 2)
StyledRect {
radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
topRightRadius: Appearance.rounding.small / 2
bottomRightRadius: Appearance.rounding.small / 2
color: root.disabled ? root.disabledColour : root.colour
implicitWidth: textRow.implicitWidth + root.horizontalPadding * 2
implicitHeight: expandBtn.implicitHeight
StateLayer {
id: stateLayer
rect.topRightRadius: parent.topRightRadius
rect.bottomRightRadius: parent.bottomRightRadius
color: root.textColour
disabled: root.disabled
function onClicked(): void {
root.active?.clicked();
}
}
RowLayout {
id: textRow
anchors.centerIn: parent
anchors.horizontalCenterOffset: Math.floor(root.verticalPadding / 4)
spacing: Appearance.spacing.small
MaterialIcon {
id: iconLabel
Layout.alignment: Qt.AlignVCenter
animate: true
text: root.active?.activeIcon ?? root.fallbackIcon
color: root.disabled ? root.disabledTextColour : root.textColour
fill: 1
}
StyledText {
id: label
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: implicitWidth
animate: true
text: root.active?.activeText ?? root.fallbackText
color: root.disabled ? root.disabledTextColour : root.textColour
clip: true
Behavior on Layout.preferredWidth {
Anim {
easing.bezierCurve: Appearance.anim.curves.emphasized
}
}
}
}
}
StyledRect {
id: expandBtn
property real rad: root.expanded ? implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) : Appearance.rounding.small / 2
radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
topLeftRadius: rad
bottomLeftRadius: rad
color: root.disabled ? root.disabledColour : root.colour
implicitWidth: implicitHeight
implicitHeight: expandIcon.implicitHeight + root.verticalPadding * 2
StateLayer {
id: expandStateLayer
rect.topLeftRadius: parent.topLeftRadius
rect.bottomLeftRadius: parent.bottomLeftRadius
color: root.textColour
disabled: root.disabled
function onClicked(): void {
root.expanded = !root.expanded;
}
}
MaterialIcon {
id: expandIcon
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.expanded ? 0 : -Math.floor(root.verticalPadding / 4)
text: "expand_more"
color: root.disabled ? root.disabledTextColour : root.textColour
rotation: root.expanded ? 180 : 0
Behavior on anchors.horizontalCenterOffset {
Anim {}
}
Behavior on rotation {
Anim {}
}
}
Behavior on rad {
Anim {}
}
Menu {
id: menu
states: State {
when: root.menuOnTop
AnchorChanges {
target: menu
anchors.top: undefined
anchors.bottom: expandBtn.top
}
}
anchors.top: parent.bottom
anchors.right: parent.right
anchors.topMargin: Appearance.spacing.small
anchors.bottomMargin: Appearance.spacing.small
}
}
}

View File

@@ -0,0 +1,62 @@
pragma ComponentBehavior: Bound
import ".."
import qs.components
import qs.components.effects
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
StyledRect {
id: root
required property string label
property int expandedZ: 100
property bool enabled: true
property alias menuItems: splitButton.menuItems
property alias active: splitButton.active
property alias expanded: splitButton.expanded
property alias type: splitButton.type
signal selected(item: MenuItem)
Layout.fillWidth: true
implicitHeight: row.implicitHeight + Appearance.padding.large * 2
radius: Appearance.rounding.normal
color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
clip: false
z: splitButton.menu.implicitHeight > 0 ? expandedZ : 1
opacity: enabled ? 1.0 : 0.5
RowLayout {
id: row
anchors.fill: parent
anchors.margins: Appearance.padding.large
spacing: Appearance.spacing.normal
StyledText {
Layout.fillWidth: true
text: root.label
color: root.enabled ? Colours.palette.m3onSurface : Colours.palette.m3onSurfaceVariant
}
SplitButton {
id: splitButton
enabled: root.enabled
type: SplitButton.Filled
menu.z: 1
stateLayer.onClicked: {
splitButton.expanded = !splitButton.expanded;
}
menu.onItemSelected: item => {
root.selected(item);
}
}
}
}

View File

@@ -0,0 +1,79 @@
pragma ComponentBehavior: Bound
import ".."
import qs.components
import qs.services
import qs.config
import QtQuick
Item {
id: root
property string text: ""
property var validator: null
property bool readOnly: false
property int horizontalAlignment: TextInput.AlignHCenter
property int implicitWidth: 70
property bool enabled: true
// Expose activeFocus through alias to avoid FINAL property override
readonly property alias hasFocus: inputField.activeFocus
signal textEdited(string text)
signal editingFinished
implicitHeight: inputField.implicitHeight + Appearance.padding.small * 2
StyledRect {
id: container
anchors.fill: parent
color: inputHover.containsMouse || inputField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.small
border.width: 1
border.color: inputField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
opacity: root.enabled ? 1 : 0.5
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
MouseArea {
id: inputHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.IBeamCursor
acceptedButtons: Qt.NoButton
enabled: root.enabled
}
StyledTextField {
id: inputField
anchors.centerIn: parent
width: parent.width - Appearance.padding.normal
horizontalAlignment: root.horizontalAlignment
validator: root.validator
readOnly: root.readOnly
enabled: root.enabled
Binding {
target: inputField
property: "text"
value: root.text
when: !inputField.activeFocus
}
onTextChanged: {
root.text = text;
root.textEdited(text);
}
onEditingFinished: {
root.editingFinished();
}
}
}
}

View File

@@ -0,0 +1,57 @@
import qs.components
import qs.services
import qs.config
import QtQuick
import QtQuick.Templates
RadioButton {
id: root
font.pointSize: Appearance.font.size.smaller
implicitWidth: implicitIndicatorWidth + implicitContentWidth + contentItem.anchors.leftMargin
implicitHeight: Math.max(implicitIndicatorHeight, implicitContentHeight)
indicator: Rectangle {
id: outerCircle
implicitWidth: 20
implicitHeight: 20
radius: Appearance.rounding.full
color: "transparent"
border.color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
border.width: 2
anchors.verticalCenter: parent.verticalCenter
StateLayer {
anchors.margins: -Appearance.padding.smaller
color: root.checked ? Colours.palette.m3onSurface : Colours.palette.m3primary
z: -1
function onClicked(): void {
root.click();
}
}
StyledRect {
anchors.centerIn: parent
implicitWidth: 8
implicitHeight: 8
radius: Appearance.rounding.full
color: Qt.alpha(Colours.palette.m3primary, root.checked ? 1 : 0)
}
Behavior on border.color {
CAnim {}
}
}
contentItem: StyledText {
text: root.text
font.pointSize: root.font.pointSize
anchors.verticalCenter: parent.verticalCenter
anchors.left: outerCircle.right
anchors.leftMargin: Appearance.spacing.smaller
}
}

View File

@@ -0,0 +1,190 @@
import ".."
import qs.services
import qs.config
import QtQuick
import QtQuick.Templates
ScrollBar {
id: root
required property Flickable flickable
property bool shouldBeActive
property real nonAnimPosition
property bool animating
onHoveredChanged: {
if (hovered)
shouldBeActive = true;
else
shouldBeActive = flickable.moving;
}
property bool _updatingFromFlickable: false
property bool _updatingFromUser: false
// Sync nonAnimPosition with Qt's automatic position binding
onPositionChanged: {
if (_updatingFromUser) {
_updatingFromUser = false;
return;
}
if (position === nonAnimPosition) {
animating = false;
return;
}
if (!animating && !_updatingFromFlickable && !fullMouse.pressed) {
nonAnimPosition = position;
}
}
// Sync nonAnimPosition with flickable when not animating
Connections {
target: flickable
function onContentYChanged() {
if (!animating && !fullMouse.pressed) {
_updatingFromFlickable = true;
const contentHeight = flickable.contentHeight;
const height = flickable.height;
if (contentHeight > height) {
nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height)));
} else {
nonAnimPosition = 0;
}
_updatingFromFlickable = false;
}
}
}
Component.onCompleted: {
if (flickable) {
const contentHeight = flickable.contentHeight;
const height = flickable.height;
if (contentHeight > height) {
nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height)));
}
}
}
implicitWidth: Appearance.padding.small
contentItem: StyledRect {
anchors.left: parent.left
anchors.right: parent.right
opacity: {
if (root.size === 1)
return 0;
if (fullMouse.pressed)
return 1;
if (mouse.containsMouse)
return 0.8;
if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive)
return 0.6;
return 0;
}
radius: Appearance.rounding.full
color: Colours.palette.m3secondary
MouseArea {
id: mouse
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
Behavior on opacity {
Anim {}
}
}
Connections {
target: root.flickable
function onMovingChanged(): void {
if (root.flickable.moving)
root.shouldBeActive = true;
else
hideDelay.restart();
}
}
Timer {
id: hideDelay
interval: 600
onTriggered: root.shouldBeActive = root.flickable.moving || root.hovered
}
CustomMouseArea {
id: fullMouse
anchors.fill: parent
preventStealing: true
onPressed: event => {
root.animating = true;
root._updatingFromUser = true;
const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2));
root.nonAnimPosition = newPos;
// Update flickable position
// Map scrollbar position [0, 1-size] to contentY [0, maxContentY]
if (root.flickable) {
const contentHeight = root.flickable.contentHeight;
const height = root.flickable.height;
if (contentHeight > height) {
const maxContentY = contentHeight - height;
const maxPos = 1 - root.size;
const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0;
root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY));
}
}
}
onPositionChanged: event => {
root._updatingFromUser = true;
const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2));
root.nonAnimPosition = newPos;
// Update flickable position
// Map scrollbar position [0, 1-size] to contentY [0, maxContentY]
if (root.flickable) {
const contentHeight = root.flickable.contentHeight;
const height = root.flickable.height;
if (contentHeight > height) {
const maxContentY = contentHeight - height;
const maxPos = 1 - root.size;
const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0;
root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY));
}
}
}
function onWheel(event: WheelEvent): void {
root.animating = true;
root._updatingFromUser = true;
let newPos = root.nonAnimPosition;
if (event.angleDelta.y > 0)
newPos = Math.max(0, root.nonAnimPosition - 0.1);
else if (event.angleDelta.y < 0)
newPos = Math.min(1 - root.size, root.nonAnimPosition + 0.1);
root.nonAnimPosition = newPos;
// Update flickable position
// Map scrollbar position [0, 1-size] to contentY [0, maxContentY]
if (root.flickable) {
const contentHeight = root.flickable.contentHeight;
const height = root.flickable.height;
if (contentHeight > height) {
const maxContentY = contentHeight - height;
const maxPos = 1 - root.size;
const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0;
root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY));
}
}
}
}
Behavior on position {
enabled: !fullMouse.pressed
Anim {}
}
}

View File

@@ -0,0 +1,57 @@
import qs.components
import qs.services
import qs.config
import QtQuick
import QtQuick.Templates
Slider {
id: root
background: Item {
StyledRect {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.topMargin: root.implicitHeight / 3
anchors.bottomMargin: root.implicitHeight / 3
implicitWidth: root.handle.x - root.implicitHeight / 6
color: Colours.palette.m3primary
radius: Appearance.rounding.full
topRightRadius: root.implicitHeight / 15
bottomRightRadius: root.implicitHeight / 15
}
StyledRect {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.topMargin: root.implicitHeight / 3
anchors.bottomMargin: root.implicitHeight / 3
implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6
color: Colours.palette.m3surfaceContainerHighest
radius: Appearance.rounding.full
topLeftRadius: root.implicitHeight / 15
bottomLeftRadius: root.implicitHeight / 15
}
}
handle: StyledRect {
x: root.visualPosition * root.availableWidth - implicitWidth / 2
implicitWidth: root.implicitHeight / 4.5
implicitHeight: root.implicitHeight
color: Colours.palette.m3primary
radius: Appearance.rounding.full
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: Qt.PointingHandCursor
}
}
}

View File

@@ -0,0 +1,152 @@
import ".."
import qs.services
import qs.config
import QtQuick
import QtQuick.Templates
import QtQuick.Shapes
Switch {
id: root
property int cLayer: 1
implicitWidth: implicitIndicatorWidth
implicitHeight: implicitIndicatorHeight
indicator: StyledRect {
radius: Appearance.rounding.full
color: root.checked ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHighest, root.cLayer)
implicitWidth: implicitHeight * 1.7
implicitHeight: Appearance.font.size.normal + Appearance.padding.smaller * 2
StyledRect {
readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight
radius: Appearance.rounding.full
color: root.checked ? Colours.palette.m3onPrimary : Colours.layer(Colours.palette.m3outline, root.cLayer + 1)
x: root.checked ? parent.implicitWidth - nonAnimWidth - Appearance.padding.small / 2 : Appearance.padding.small / 2
implicitWidth: nonAnimWidth
implicitHeight: parent.implicitHeight - Appearance.padding.small
anchors.verticalCenter: parent.verticalCenter
StyledRect {
anchors.fill: parent
radius: parent.radius
color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurface
opacity: root.pressed ? 0.1 : root.hovered ? 0.08 : 0
Behavior on opacity {
Anim {}
}
}
Shape {
id: icon
property point start1: {
if (root.pressed)
return Qt.point(width * 0.2, height / 2);
if (root.checked)
return Qt.point(width * 0.15, height / 2);
return Qt.point(width * 0.15, height * 0.15);
}
property point end1: {
if (root.pressed) {
if (root.checked)
return Qt.point(width * 0.4, height / 2);
return Qt.point(width * 0.8, height / 2);
}
if (root.checked)
return Qt.point(width * 0.4, height * 0.7);
return Qt.point(width * 0.85, height * 0.85);
}
property point start2: {
if (root.pressed) {
if (root.checked)
return Qt.point(width * 0.4, height / 2);
return Qt.point(width * 0.2, height / 2);
}
if (root.checked)
return Qt.point(width * 0.4, height * 0.7);
return Qt.point(width * 0.15, height * 0.85);
}
property point end2: {
if (root.pressed)
return Qt.point(width * 0.8, height / 2);
if (root.checked)
return Qt.point(width * 0.85, height * 0.2);
return Qt.point(width * 0.85, height * 0.15);
}
anchors.centerIn: parent
width: height
height: parent.implicitHeight - Appearance.padding.small * 2
preferredRendererType: Shape.CurveRenderer
asynchronous: true
ShapePath {
strokeWidth: Appearance.font.size.larger * 0.15
strokeColor: root.checked ? Colours.palette.m3primary : Colours.palette.m3surfaceContainerHighest
fillColor: "transparent"
capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
startX: icon.start1.x
startY: icon.start1.y
PathLine {
x: icon.end1.x
y: icon.end1.y
}
PathMove {
x: icon.start2.x
y: icon.start2.y
}
PathLine {
x: icon.end2.x
y: icon.end2.y
}
Behavior on strokeColor {
CAnim {}
}
}
Behavior on start1 {
PropAnim {}
}
Behavior on end1 {
PropAnim {}
}
Behavior on start2 {
PropAnim {}
}
Behavior on end2 {
PropAnim {}
}
}
Behavior on x {
Anim {}
}
Behavior on implicitWidth {
Anim {}
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
enabled: false
}
component PropAnim: PropertyAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}

View File

@@ -0,0 +1,76 @@
pragma ComponentBehavior: Bound
import ".."
import qs.services
import qs.config
import QtQuick
import QtQuick.Controls
TextField {
id: root
color: Colours.palette.m3onSurface
placeholderTextColor: Colours.palette.m3outline
font.family: Appearance.font.family.sans
font.pointSize: Appearance.font.size.smaller
renderType: echoMode === TextField.Password ? TextField.QtRendering : TextField.NativeRendering
cursorVisible: !readOnly
background: null
cursorDelegate: StyledRect {
id: cursor
property bool disableBlink
implicitWidth: 2
color: Colours.palette.m3primary
radius: Appearance.rounding.normal
Connections {
target: root
function onCursorPositionChanged(): void {
if (root.activeFocus && root.cursorVisible) {
cursor.opacity = 1;
cursor.disableBlink = true;
enableBlink.restart();
}
}
}
Timer {
id: enableBlink
interval: 100
onTriggered: cursor.disableBlink = false
}
Timer {
running: root.activeFocus && root.cursorVisible && !cursor.disableBlink
repeat: true
triggeredOnStart: true
interval: 500
onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
}
Binding {
when: !root.activeFocus || !root.cursorVisible
cursor.opacity: 0
}
Behavior on opacity {
Anim {
duration: Appearance.anim.durations.small
}
}
}
Behavior on color {
CAnim {}
}
Behavior on placeholderTextColor {
CAnim {}
}
}

View File

@@ -0,0 +1,48 @@
import ".."
import qs.components
import qs.components.effects
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
StyledRect {
id: root
required property string label
required property bool checked
property bool enabled: true
property var onToggled: function (checked) {}
Layout.fillWidth: true
implicitHeight: row.implicitHeight + Appearance.padding.large * 2
radius: Appearance.rounding.normal
color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
Behavior on implicitHeight {
Anim {}
}
RowLayout {
id: row
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Appearance.padding.large
spacing: Appearance.spacing.normal
StyledText {
Layout.fillWidth: true
text: root.label
}
StyledSwitch {
checked: root.checked
enabled: root.enabled
onToggled: {
root.onToggled(checked);
}
}
}
}

View File

@@ -0,0 +1,78 @@
import ".."
import qs.services
import qs.config
import QtQuick
StyledRect {
id: root
enum Type {
Filled,
Tonal,
Text
}
property alias text: label.text
property bool checked
property bool toggle
property real horizontalPadding: Appearance.padding.normal
property real verticalPadding: Appearance.padding.smaller
property alias font: label.font
property int type: TextButton.Filled
property alias stateLayer: stateLayer
property alias label: label
property bool internalChecked
property color activeColour: type === TextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary
property color inactiveColour: {
if (!toggle && type === TextButton.Filled)
return Colours.palette.m3primary;
return type === TextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer;
}
property color activeOnColour: {
if (type === TextButton.Text)
return Colours.palette.m3primary;
return type === TextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary;
}
property color inactiveOnColour: {
if (!toggle && type === TextButton.Filled)
return Colours.palette.m3onPrimary;
if (type === TextButton.Text)
return Colours.palette.m3primary;
return type === TextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer;
}
signal clicked
onCheckedChanged: internalChecked = checked
radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
color: type === TextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour
implicitWidth: label.implicitWidth + horizontalPadding * 2
implicitHeight: label.implicitHeight + verticalPadding * 2
StateLayer {
id: stateLayer
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
function onClicked(): void {
if (root.toggle)
root.internalChecked = !root.internalChecked;
root.clicked();
}
}
StyledText {
id: label
anchors.centerIn: parent
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
}
Behavior on radius {
Anim {}
}
}

View File

@@ -0,0 +1,124 @@
import ".."
import qs.components
import qs.components.controls
import qs.components.effects
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
StyledRect {
id: root
required property bool toggled
property string icon
property string label
property string accent: "Secondary"
property real iconSize: Appearance.font.size.large
property real horizontalPadding: Appearance.padding.large
property real verticalPadding: Appearance.padding.normal
property string tooltip: ""
property bool hovered: false
signal clicked
Component.onCompleted: {
hovered = toggleStateLayer.containsMouse;
}
Connections {
target: toggleStateLayer
function onContainsMouseChanged() {
const newHovered = toggleStateLayer.containsMouse;
if (hovered !== newHovered) {
hovered = newHovered;
}
}
}
Layout.preferredWidth: implicitWidth + (toggleStateLayer.pressed ? Appearance.padding.normal * 2 : toggled ? Appearance.padding.small * 2 : 0)
implicitWidth: toggleBtnInner.implicitWidth + horizontalPadding * 2
implicitHeight: toggleBtnIcon.implicitHeight + verticalPadding * 2
radius: toggled || toggleStateLayer.pressed ? Appearance.rounding.small : Math.min(width, height) / 2 * Math.min(1, Appearance.rounding.scale)
color: toggled ? Colours.palette[`m3${accent.toLowerCase()}`] : Colours.palette[`m3${accent.toLowerCase()}Container`]
StateLayer {
id: toggleStateLayer
color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
function onClicked(): void {
root.clicked();
}
}
RowLayout {
id: toggleBtnInner
anchors.centerIn: parent
spacing: Appearance.spacing.normal
MaterialIcon {
id: toggleBtnIcon
visible: !!text
fill: root.toggled ? 1 : 0
text: root.icon
color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
font.pointSize: root.iconSize
Behavior on fill {
Anim {}
}
}
Loader {
active: !!root.label
visible: active
sourceComponent: StyledText {
text: root.label
color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
}
}
}
Behavior on radius {
Anim {
duration: Appearance.anim.durations.expressiveFastSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
}
}
Behavior on Layout.preferredWidth {
Anim {
duration: Appearance.anim.durations.expressiveFastSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
}
}
// Tooltip - positioned absolutely, doesn't affect layout
Loader {
id: tooltipLoader
active: root.tooltip !== ""
z: 10000
width: 0
height: 0
sourceComponent: Component {
Tooltip {
target: root
text: root.tooltip
}
}
// Completely remove from layout
Layout.fillWidth: false
Layout.fillHeight: false
Layout.preferredWidth: 0
Layout.preferredHeight: 0
Layout.maximumWidth: 0
Layout.maximumHeight: 0
Layout.minimumWidth: 0
Layout.minimumHeight: 0
}
}

View File

@@ -0,0 +1,28 @@
import qs.components
import qs.components.controls
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
RowLayout {
id: root
required property string label
property alias checked: toggle.checked
property alias toggle: toggle
Layout.fillWidth: true
spacing: Appearance.spacing.normal
StyledText {
Layout.fillWidth: true
text: root.label
}
StyledSwitch {
id: toggle
cLayer: 2
}
}

View File

@@ -0,0 +1,185 @@
import ".."
import qs.components.effects
import qs.services
import qs.config
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Popup {
id: root
required property Item target
required property string text
property int delay: 500
property int timeout: 0
property bool tooltipVisible: false
property Timer showTimer: Timer {
interval: root.delay
onTriggered: root.tooltipVisible = true
}
property Timer hideTimer: Timer {
interval: root.timeout
onTriggered: root.tooltipVisible = false
}
// Popup properties - doesn't affect layout
parent: {
let p = target;
// Walk up to find the root Item (usually has anchors.fill: parent)
while (p && p.parent) {
const parentItem = p.parent;
// Check if this looks like a root pane Item
if (parentItem && parentItem.anchors && parentItem.anchors.fill !== undefined) {
return parentItem;
}
p = parentItem;
}
// Fallback
return target.parent?.parent?.parent ?? target.parent?.parent ?? target.parent ?? target;
}
visible: tooltipVisible
modal: false
closePolicy: Popup.NoAutoClose
padding: 0
margins: 0
background: Item {}
// Update position when target moves or tooltip becomes visible
onTooltipVisibleChanged: {
if (tooltipVisible) {
Qt.callLater(updatePosition);
}
}
Connections {
target: root.target
function onXChanged() {
if (root.tooltipVisible)
root.updatePosition();
}
function onYChanged() {
if (root.tooltipVisible)
root.updatePosition();
}
function onWidthChanged() {
if (root.tooltipVisible)
root.updatePosition();
}
function onHeightChanged() {
if (root.tooltipVisible)
root.updatePosition();
}
}
function updatePosition() {
if (!target || !parent)
return;
// Wait for tooltipRect to have its size calculated
Qt.callLater(() => {
if (!target || !parent || !tooltipRect)
return;
// Get target position in parent's coordinate system
const targetPos = target.mapToItem(parent, 0, 0);
const targetCenterX = targetPos.x + target.width / 2;
// Get tooltip size (use width/height if available, otherwise implicit)
const tooltipWidth = tooltipRect.width > 0 ? tooltipRect.width : tooltipRect.implicitWidth;
const tooltipHeight = tooltipRect.height > 0 ? tooltipRect.height : tooltipRect.implicitHeight;
// Center tooltip horizontally on target
let newX = targetCenterX - tooltipWidth / 2;
// Position tooltip above target
let newY = targetPos.y - tooltipHeight - Appearance.spacing.small;
// Keep within bounds
const padding = Appearance.padding.normal;
if (newX < padding) {
newX = padding;
} else if (newX + tooltipWidth > (parent.width - padding)) {
newX = parent.width - tooltipWidth - padding;
}
// Update popup position
x = newX;
y = newY;
});
}
enter: Transition {
Anim {
property: "opacity"
from: 0
to: 1
duration: Appearance.anim.durations.expressiveFastSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
}
}
exit: Transition {
Anim {
property: "opacity"
from: 1
to: 0
duration: Appearance.anim.durations.expressiveFastSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
}
}
// Monitor hover state
Connections {
target: root.target
function onHoveredChanged() {
if (target.hovered) {
showTimer.start();
if (timeout > 0) {
hideTimer.stop();
hideTimer.start();
}
} else {
showTimer.stop();
hideTimer.stop();
tooltipVisible = false;
}
}
}
contentItem: StyledRect {
id: tooltipRect
implicitWidth: tooltipText.implicitWidth + Appearance.padding.normal * 2
implicitHeight: tooltipText.implicitHeight + Appearance.padding.smaller * 2
color: Colours.palette.m3surfaceContainerHighest
radius: Appearance.rounding.small
antialiasing: true
// Add elevation for depth
Elevation {
anchors.fill: parent
radius: parent.radius
z: -1
level: 3
}
StyledText {
id: tooltipText
anchors.centerIn: parent
text: root.text
color: Colours.palette.m3onSurface
font.pointSize: Appearance.font.size.small
}
}
Component.onCompleted: {
if (tooltipVisible) {
updatePosition();
}
}
}