summaryrefslogtreecommitdiff
path: root/components/controls
diff options
context:
space:
mode:
Diffstat (limited to 'components/controls')
-rw-r--r--components/controls/CollapsibleSection.qml135
-rw-r--r--components/controls/CustomSpinBox.qml74
-rw-r--r--components/controls/FilledSlider.qml2
-rw-r--r--components/controls/IconTextButton.qml9
-rw-r--r--components/controls/SpinBoxRow.qml53
-rw-r--r--components/controls/StyledInputField.qml80
-rw-r--r--components/controls/StyledScrollBar.qml96
-rw-r--r--components/controls/StyledSlider.qml2
-rw-r--r--components/controls/StyledTextField.qml2
-rw-r--r--components/controls/SwitchRow.qml49
-rw-r--r--components/controls/ToggleButton.qml127
-rw-r--r--components/controls/ToggleRow.qml29
-rw-r--r--components/controls/Tooltip.qml172
13 files changed, 811 insertions, 19 deletions
diff --git a/components/controls/CollapsibleSection.qml b/components/controls/CollapsibleSection.qml
new file mode 100644
index 0000000..8940884
--- /dev/null
+++ b/components/controls/CollapsibleSection.qml
@@ -0,0 +1,135 @@
+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
+ }
+ }
+ }
+}
+
diff --git a/components/controls/CustomSpinBox.qml b/components/controls/CustomSpinBox.qml
index e2ed508..438dc08 100644
--- a/components/controls/CustomSpinBox.qml
+++ b/components/controls/CustomSpinBox.qml
@@ -9,19 +9,69 @@ import QtQuick.Layouts
RowLayout {
id: root
- property int value
+ property real value
property real max: Infinity
property real min: -Infinity
+ property real step: 1
property alias repeatRate: timer.interval
- signal valueModified(value: int)
+ 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.value
- onAccepted: root.valueModified(text)
+ 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
@@ -50,7 +100,13 @@ RowLayout {
onReleased: timer.stop()
function onClicked(): void {
- root.valueModified(Math.min(root.max, root.value + 1));
+ 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);
}
}
@@ -79,7 +135,13 @@ RowLayout {
onReleased: timer.stop()
function onClicked(): void {
- root.valueModified(Math.max(root.min, root.value - 1));
+ 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);
}
}
diff --git a/components/controls/FilledSlider.qml b/components/controls/FilledSlider.qml
index 78b8a5c..80dd44c 100644
--- a/components/controls/FilledSlider.qml
+++ b/components/controls/FilledSlider.qml
@@ -15,7 +15,7 @@ Slider {
orientation: Qt.Vertical
background: StyledRect {
- color: Colours.tPalette.m3surfaceContainer
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.full
StyledRect {
diff --git a/components/controls/IconTextButton.qml b/components/controls/IconTextButton.qml
index 78e7c5b..0badd7a 100644
--- a/components/controls/IconTextButton.qml
+++ b/components/controls/IconTextButton.qml
@@ -2,6 +2,7 @@ import ".."
import qs.services
import qs.config
import QtQuick
+import QtQuick.Layouts
StyledRect {
id: root
@@ -53,7 +54,7 @@ StyledRect {
}
}
- Row {
+ RowLayout {
id: row
anchors.centerIn: parent
@@ -62,7 +63,8 @@ StyledRect {
MaterialIcon {
id: iconLabel
- anchors.verticalCenter: parent.verticalCenter
+ Layout.alignment: Qt.AlignVCenter
+ Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575)
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
fill: root.internalChecked ? 1 : 0
@@ -74,7 +76,8 @@ StyledRect {
StyledText {
id: label
- anchors.verticalCenter: parent.verticalCenter
+ Layout.alignment: Qt.AlignVCenter
+ Layout.topMargin: -Math.round(iconLabel.fontInfo.pointSize * 0.0575)
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
}
}
diff --git a/components/controls/SpinBoxRow.qml b/components/controls/SpinBoxRow.qml
new file mode 100644
index 0000000..4902627
--- /dev/null
+++ b/components/controls/SpinBoxRow.qml
@@ -0,0 +1,53 @@
+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);
+ }
+ }
+ }
+}
+
diff --git a/components/controls/StyledInputField.qml b/components/controls/StyledInputField.qml
new file mode 100644
index 0000000..fcd0a33
--- /dev/null
+++ b/components/controls/StyledInputField.qml
@@ -0,0 +1,80 @@
+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();
+ }
+ }
+ }
+}
+
diff --git a/components/controls/StyledScrollBar.qml b/components/controls/StyledScrollBar.qml
index fc641b5..de8b679 100644
--- a/components/controls/StyledScrollBar.qml
+++ b/components/controls/StyledScrollBar.qml
@@ -19,14 +19,51 @@ ScrollBar {
shouldBeActive = flickable.moving;
}
+ property bool _updatingFromFlickable: false
+ property bool _updatingFromUser: false
+
+ // Sync nonAnimPosition with Qt's automatic position binding
onPositionChanged: {
- if (position === nonAnimPosition)
+ if (_updatingFromUser) {
+ _updatingFromUser = false;
+ return;
+ }
+ if (position === nonAnimPosition) {
animating = false;
- else if (!animating)
+ return;
+ }
+ if (!animating && !_updatingFromFlickable && !fullMouse.pressed) {
nonAnimPosition = position;
+ }
}
- position: nonAnimPosition
+ // 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 {
@@ -86,17 +123,62 @@ ScrollBar {
onPressed: event => {
root.animating = true;
- root.nonAnimPosition = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2));
+ 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.nonAnimPosition = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2))
+ 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)
- root.nonAnimPosition = Math.max(0, root.nonAnimPosition - 0.1);
+ newPos = Math.max(0, root.nonAnimPosition - 0.1);
else if (event.angleDelta.y < 0)
- root.nonAnimPosition = Math.min(1 - root.size, root.nonAnimPosition + 0.1);
+ 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));
+ }
+ }
}
}
diff --git a/components/controls/StyledSlider.qml b/components/controls/StyledSlider.qml
index 92c8aa8..0ef229d 100644
--- a/components/controls/StyledSlider.qml
+++ b/components/controls/StyledSlider.qml
@@ -32,7 +32,7 @@ Slider {
implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6
- color: Colours.tPalette.m3surfaceContainer
+ color: Colours.palette.m3surfaceContainerHighest
radius: Appearance.rounding.full
topLeftRadius: root.implicitHeight / 15
bottomLeftRadius: root.implicitHeight / 15
diff --git a/components/controls/StyledTextField.qml b/components/controls/StyledTextField.qml
index 4db87e9..60bcff2 100644
--- a/components/controls/StyledTextField.qml
+++ b/components/controls/StyledTextField.qml
@@ -13,7 +13,7 @@ TextField {
placeholderTextColor: Colours.palette.m3outline
font.family: Appearance.font.family.sans
font.pointSize: Appearance.font.size.smaller
- renderType: TextField.NativeRendering
+ renderType: echoMode === TextField.Password ? TextField.QtRendering : TextField.NativeRendering
cursorVisible: !readOnly
background: null
diff --git a/components/controls/SwitchRow.qml b/components/controls/SwitchRow.qml
new file mode 100644
index 0000000..7fa3e1b
--- /dev/null
+++ b/components/controls/SwitchRow.qml
@@ -0,0 +1,49 @@
+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);
+ }
+ }
+ }
+}
+
diff --git a/components/controls/ToggleButton.qml b/components/controls/ToggleButton.qml
new file mode 100644
index 0000000..b2c2afe
--- /dev/null
+++ b/components/controls/ToggleButton.qml
@@ -0,0 +1,127 @@
+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 {
+ asynchronous: true
+ 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 !== ""
+ asynchronous: true
+ 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
+ }
+}
+
diff --git a/components/controls/ToggleRow.qml b/components/controls/ToggleRow.qml
new file mode 100644
index 0000000..23dc2a2
--- /dev/null
+++ b/components/controls/ToggleRow.qml
@@ -0,0 +1,29 @@
+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
+ }
+}
+
diff --git a/components/controls/Tooltip.qml b/components/controls/Tooltip.qml
new file mode 100644
index 0000000..d665083
--- /dev/null
+++ b/components/controls/Tooltip.qml
@@ -0,0 +1,172 @@
+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();
+ }
+ }
+}
+