diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/ConnectionHeader.qml | 32 | ||||
| -rw-r--r-- | components/ConnectionInfoSection.qml | 60 | ||||
| -rw-r--r-- | components/PropertyRow.qml | 27 | ||||
| -rw-r--r-- | components/SectionContainer.qml | 35 | ||||
| -rw-r--r-- | components/SectionHeader.qml | 28 | ||||
| -rw-r--r-- | components/StateLayer.qml | 3 | ||||
| -rw-r--r-- | components/controls/CollapsibleSection.qml | 135 | ||||
| -rw-r--r-- | components/controls/CustomSpinBox.qml | 74 | ||||
| -rw-r--r-- | components/controls/FilledSlider.qml | 2 | ||||
| -rw-r--r-- | components/controls/IconTextButton.qml | 9 | ||||
| -rw-r--r-- | components/controls/SpinBoxRow.qml | 53 | ||||
| -rw-r--r-- | components/controls/StyledInputField.qml | 80 | ||||
| -rw-r--r-- | components/controls/StyledScrollBar.qml | 96 | ||||
| -rw-r--r-- | components/controls/StyledSlider.qml | 2 | ||||
| -rw-r--r-- | components/controls/StyledTextField.qml | 2 | ||||
| -rw-r--r-- | components/controls/SwitchRow.qml | 49 | ||||
| -rw-r--r-- | components/controls/ToggleButton.qml | 127 | ||||
| -rw-r--r-- | components/controls/ToggleRow.qml | 29 | ||||
| -rw-r--r-- | components/controls/Tooltip.qml | 172 |
19 files changed, 995 insertions, 20 deletions
diff --git a/components/ConnectionHeader.qml b/components/ConnectionHeader.qml new file mode 100644 index 0000000..3f77fd9 --- /dev/null +++ b/components/ConnectionHeader.qml @@ -0,0 +1,32 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property string icon + required property string title + + spacing: Appearance.spacing.normal + Layout.alignment: Qt.AlignHCenter + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + animate: true + text: root.icon + font.pointSize: Appearance.font.size.extraLarge * 3 + font.bold: true + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + animate: true + text: root.title + font.pointSize: Appearance.font.size.large + font.bold: true + } +} + diff --git a/components/ConnectionInfoSection.qml b/components/ConnectionInfoSection.qml new file mode 100644 index 0000000..88c6b3a --- /dev/null +++ b/components/ConnectionInfoSection.qml @@ -0,0 +1,60 @@ +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var deviceDetails + + spacing: Appearance.spacing.small / 2 + + StyledText { + text: qsTr("IP Address") + } + + StyledText { + text: root.deviceDetails?.ipAddress || qsTr("Not available") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Subnet Mask") + } + + StyledText { + text: root.deviceDetails?.subnet || qsTr("Not available") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Gateway") + } + + StyledText { + text: root.deviceDetails?.gateway || qsTr("Not available") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("DNS Servers") + } + + StyledText { + text: (root.deviceDetails && root.deviceDetails.dns && root.deviceDetails.dns.length > 0) ? root.deviceDetails.dns.join(", ") : qsTr("Not available") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + wrapMode: Text.Wrap + Layout.maximumWidth: parent.width + } +} + diff --git a/components/PropertyRow.qml b/components/PropertyRow.qml new file mode 100644 index 0000000..697830a --- /dev/null +++ b/components/PropertyRow.qml @@ -0,0 +1,27 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property string label + required property string value + property bool showTopMargin: false + + spacing: Appearance.spacing.small / 2 + + StyledText { + Layout.topMargin: root.showTopMargin ? Appearance.spacing.normal : 0 + text: root.label + } + + StyledText { + text: root.value + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } +} + diff --git a/components/SectionContainer.qml b/components/SectionContainer.qml new file mode 100644 index 0000000..f133e19 --- /dev/null +++ b/components/SectionContainer.qml @@ -0,0 +1,35 @@ +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + default property alias content: contentColumn.data + property real contentSpacing: Appearance.spacing.larger + property bool alignTop: false + + Layout.fillWidth: true + implicitHeight: contentColumn.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.transparency.enabled + ? Colours.layer(Colours.palette.m3surfaceContainer, 2) + : Colours.palette.m3surfaceContainerHigh + + ColumnLayout { + id: contentColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: root.alignTop ? parent.top : undefined + anchors.verticalCenter: root.alignTop ? undefined : parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: root.contentSpacing + } +} + diff --git a/components/SectionHeader.qml b/components/SectionHeader.qml new file mode 100644 index 0000000..897e63a --- /dev/null +++ b/components/SectionHeader.qml @@ -0,0 +1,28 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property string title + property string description: "" + + spacing: 0 + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: root.title + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + visible: root.description !== "" + text: root.description + color: Colours.palette.m3outline + } +} + diff --git a/components/StateLayer.qml b/components/StateLayer.qml index d86e782..a20e266 100644 --- a/components/StateLayer.qml +++ b/components/StateLayer.qml @@ -6,6 +6,7 @@ MouseArea { id: root property bool disabled + property bool showHoverBackground: true property color color: Colours.palette.m3onSurface property real radius: parent?.radius ?? 0 property alias rect: hoverLayer @@ -75,7 +76,7 @@ MouseArea { anchors.fill: parent - color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.1 : root.containsMouse ? 0.08 : 0) + color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.12 : (root.showHoverBackground && root.containsMouse) ? 0.08 : 0) radius: root.radius StyledRect { 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(); + } + } +} + |