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,57 @@
import QtQuick
import Quickshell
import qs.config
Item {
id: root
property real value: 0.65 // 0.0 → 1.0
property real strokeWidth: 2
property color bgColor: Appearance.m3colors.m3secondaryContainer
property color fgColor: Appearance.m3colors.m3primary
property string icon: "battery_full"
property int iconSize: Metrics.iconSize(20)
property bool fillIcon: false
width: 22
height: 24
onValueChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
onPaint: {
const ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2;
const r = (width - root.strokeWidth) / 2;
const start = -Math.PI / 2;
const end = start + 2 * Math.PI * root.value;
ctx.lineWidth = root.strokeWidth;
ctx.lineCap = "round";
// background ring
ctx.strokeStyle = root.bgColor;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
ctx.stroke();
// progress ring
ctx.strokeStyle = root.fgColor;
ctx.beginPath();
ctx.arc(cx, cy, r, start, end);
ctx.stroke();
}
}
// CENTER ICON
MaterialSymbol {
anchors.centerIn: parent
icon: root.icon
iconSize: root.iconSize
font.variableAxes: {
"FILL": root.fillIcon ? 1 : 0
}
}
}

View File

@@ -0,0 +1,38 @@
import qs.config
import QtQuick
import QtQuick.Layouts
Item {
id: contentCard
implicitWidth: parent ? parent.width : 600
implicitHeight: contentArea.implicitHeight + verticalPadding
default property alias content: contentArea.data
property alias color: bg.color
property alias radius: bg.radius
property int cardMargin: Metrics.margin("normal")
property int cardSpacing: Metrics.margin("small")
property int verticalPadding: Metrics.margin("verylarge")
property bool useAnims: true
Rectangle {
id: bg
anchors.fill: parent
radius: Metrics.radius("normal")
color: Appearance.colors.colLayer1
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: !contentCard.useAnims ? 0 : Metrics.chronoDuration("fast")
}
}
}
ColumnLayout {
id: contentArea
anchors.fill: parent
anchors.margins: cardMargin
spacing: cardSpacing
}
}

View File

@@ -0,0 +1,101 @@
import qs.config
import QtQuick
import QtQuick.Layouts
Item {
id: contentMenu
Layout.fillWidth: true
Layout.fillHeight: true
opacity: visible ? 1 : 0
scale: visible ? 1 : 0.95
Behavior on opacity {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.curves.standard[0] // using standard easing
}
}
Behavior on scale {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.curves.standard[0]
}
}
property string title: ""
property string description: ""
default property alias content: stackedSections.data
Item {
id: headerArea
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: Metrics.margin("verylarge")
anchors.leftMargin: Metrics.margin("verylarge")
anchors.rightMargin: Metrics.margin("verylarge")
width: parent.width
ColumnLayout {
id: headerContent
anchors.left: parent.left
anchors.right: parent.right
spacing: Metrics.margin("small")
ColumnLayout {
StyledText {
text: contentMenu.title
font.pixelSize: Metrics.fontSize("huge")
font.bold: true
font.family: Metrics.fontFamily("title")
}
StyledText {
text: contentMenu.description
font.pixelSize: Metrics.fontSize("small")
}
}
Rectangle {
id: hr
Layout.alignment: Qt.AlignLeft | Qt.AlignRight
implicitHeight: 1
}
}
height: headerContent.implicitHeight
}
Flickable {
id: mainScroll
anchors.left: parent.left
anchors.right: parent.right
anchors.top: headerArea.bottom
anchors.bottom: parent.bottom
anchors.leftMargin: Metrics.margin("verylarge")
anchors.rightMargin: Metrics.margin("verylarge")
anchors.topMargin: Metrics.margin("normal")
clip: true
interactive: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
contentHeight: mainContent.childrenRect.height + Appearance.margin.small
contentWidth: width
Item {
id: mainContent
width: mainScroll.width
height: mainContent.childrenRect.height
Column {
id: stackedSections
width: Math.min(mainScroll.width, 1000)
x: (mainContent.width - width) / 2
spacing: Appearance.margin.normal
}
}
}
}

View File

@@ -0,0 +1,51 @@
import qs.config
import QtQuick
import QtQuick.Layouts
Item {
id: baseCard
Layout.fillWidth: true
implicitHeight: wpBG.implicitHeight
default property alias content: contentArea.data
property alias color: wpBG.color
property int cardMargin: Metrics.margin(20)
property int cardSpacing: Metrics.spacing(10)
property int radius: Metrics.radius("large")
property int verticalPadding: Metrics.padding(40)
Rectangle {
id: wpBG
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: contentArea.implicitHeight + baseCard.verticalPadding
Behavior on implicitHeight {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.InOutExpo
}
}
color: Appearance.m3colors.m3surfaceContainerLow
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.InOutExpo
}
}
radius: baseCard.radius
}
RowLayout {
id: contentArea
anchors.top: wpBG.top
anchors.left: wpBG.left
anchors.right: wpBG.right
anchors.margins: baseCard.cardMargin
spacing: baseCard.cardSpacing
}
}

View File

@@ -0,0 +1,58 @@
import qs.config
import QtQuick
import QtQuick.Layouts
ContentRowCard {
id: infoCard
// --- Properties ---
property string icon: "info"
property color backgroundColor: Appearance.m3colors.darkMode ? Qt.lighter(Appearance.m3colors.m3error, 3.5) : Qt.lighter(Appearance.m3colors.m3error, 1)
property color contentColor: Appearance.m3colors.m3onPrimary
property string title: "Title"
property string description: "Description"
color: backgroundColor
cardSpacing: Metrics.spacing(12) // nice spacing between elements
RowLayout {
id: mainLayout
Layout.fillHeight: true
Layout.fillWidth: true
spacing: Metrics.spacing(16)
Layout.alignment: Qt.AlignVCenter
// --- Icon ---
MaterialSymbol {
id: infoIcon
icon: infoCard.icon
iconSize: Metrics.iconSize(26)
color: contentColor
Layout.alignment: Qt.AlignVCenter
}
// --- Text column ---
ColumnLayout {
spacing: Metrics.spacing(2)
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
StyledText {
text: infoCard.title
font.bold: true
color: contentColor
font.pixelSize: Metrics.fontSize(14)
Layout.fillWidth: true
}
StyledText {
text: infoCard.description
color: contentColor
font.pixelSize: Metrics.fontSize(12)
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
}
}

View File

@@ -0,0 +1,26 @@
import QtQuick
import qs.config
Item {
id: root
property alias icon: mIcon.icon
property real size: Metrics.iconSize(28)
width: size
height: size
MaterialSymbol {
id: mIcon
anchors.centerIn: parent
icon: "progress_activity"
font.pixelSize: root.size
color: Appearance.m3colors.m3primary
renderType: Text.QtRendering
}
RotationAnimator on rotation {
target: mIcon
running: true
loops: Animation.Infinite
from: 0
to: 360
duration: Metrics.chronoDuration(1000)
}
}

View File

@@ -0,0 +1,15 @@
import QtQuick
import qs.config
StyledText {
property string icon: ""
property int fill: 0
property int iconSize: Metrics.iconSize("large")
font.family: Appearance.font.family.materialIcons
font.pixelSize: iconSize
text: icon
font.variableAxes: {
"FILL": fill
}
}

View File

@@ -0,0 +1,56 @@
import QtQuick
import Quickshell
import qs.config
MaterialSymbol {
id: root
// Expose mouse props
property alias enabled: ma.enabled
property alias hoverEnabled: ma.hoverEnabled
property alias pressed: ma.pressed
property string tooltipText: ""
// Renamed signals (no collisions possible)
signal buttonClicked()
signal buttonEntered()
signal buttonExited()
signal buttonPressAndHold()
signal buttonPressedChanged(bool pressed)
MouseArea {
id: ma
anchors.fill: parent
hoverEnabled: true
onClicked: root.buttonClicked()
onEntered: root.buttonEntered()
onExited: root.buttonExited()
onPressAndHold: root.buttonPressAndHold()
onPressedChanged: root.buttonPressedChanged(pressed)
}
HoverHandler {
id: hover
enabled: root.tooltipText !== ""
}
LazyLoader {
active: root.tooltipText !== ""
StyledPopout {
hoverTarget: hover
hoverDelay: Metrics.chronoDuration(500)
Component {
StyledText {
text: root.tooltipText
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.config
RowLayout {
id: root
property string label: ""
property string description: ""
property string prefField: ""
property double step: 1.0
property double minimum: -2.14748e+09 // Largest num I could find and type ig
property double maximum: 2.14748e+09
// Floating-point value
property double value: readValue()
function readValue() {
if (!prefField)
return 0;
var parts = prefField.split('.');
var cur = Config.runtime;
for (var i = 0; i < parts.length; ++i) {
if (cur === undefined || cur === null)
return 0;
cur = cur[parts[i]];
}
var n = Number(cur);
return isNaN(n) ? 0 : n;
}
function writeValue(v) {
if (!prefField)
return;
var nv = Math.max(minimum, Math.min(maximum, v));
nv = Number(nv.toFixed(2)); // precision control (adjust if needed)
Config.updateKey(prefField, nv);
}
spacing: Metrics.spacing(8)
Layout.alignment: Qt.AlignVCenter
ColumnLayout {
spacing: Metrics.spacing(2)
StyledText {
text: root.label
font.pixelSize: Metrics.fontSize(14)
}
StyledText {
text: root.description
font.pixelSize: Metrics.fontSize(10)
}
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: Metrics.spacing(6)
StyledButton {
text: "-"
implicitWidth: 36
onClicked: writeValue(readValue() - step)
}
StyledText {
text: value.toFixed(2)
font.pixelSize: Metrics.fontSize(14)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
width: 72
elide: Text.ElideRight
}
StyledButton {
text: "+"
implicitWidth: 36
onClicked: writeValue(readValue() + step)
}
}
}

View File

@@ -0,0 +1,185 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.config
import qs.modules.functions
Control {
id: root
property alias text: label.text
property string icon: ""
property int iconSize: Metrics.iconSize(20)
property alias radius: background.radius
property alias topLeftRadius: background.topLeftRadius
property alias topRightRadius: background.topRightRadius
property alias bottomLeftRadius: background.bottomLeftRadius
property alias bottomRightRadius: background.bottomRightRadius
property bool checkable: false
property bool checked: true
property bool secondary: false
property string tooltipText: ""
property bool usePrimary: secondary ? false : checked
property color base_bg: usePrimary ? Appearance.m3colors.m3primary : Appearance.m3colors.m3secondaryContainer
property color base_fg: usePrimary ? Appearance.m3colors.m3onPrimary : Appearance.m3colors.m3onSecondaryContainer
property color disabled_bg: ColorUtils.transparentize(base_bg, 0.4)
property color disabled_fg: ColorUtils.transparentize(base_fg, 0.4)
property color hover_bg: Qt.lighter(base_bg, 1.1)
property color pressed_bg: Qt.darker(base_bg, 1.2)
property color backgroundColor: !root.enabled ? disabled_bg : mouse_area.pressed ? pressed_bg : mouse_area.containsMouse ? hover_bg : base_bg
property color textColor: !root.enabled ? disabled_fg : base_fg
property bool beingHovered: mouse_area.containsMouse
signal clicked()
signal toggled(bool checked)
implicitWidth: (label.text === "" && icon !== "") ? implicitHeight : row.implicitWidth + implicitHeight
implicitHeight: 40
MouseArea {
id: mouse_area
anchors.fill: parent
hoverEnabled: root.enabled
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
onClicked: {
if (!root.enabled)
return ;
if (root.checkable) {
root.checked = !root.checked;
root.toggled(root.checked);
}
root.clicked();
}
}
HoverHandler {
id: hover
enabled: root.tooltipText !== ""
}
LazyLoader {
active: root.tooltipText !== ""
StyledPopout {
hoverTarget: hover
hoverDelay: Metrics.chronoDuration(500)
Component {
StyledText {
text: root.tooltipText
}
}
}
}
contentItem: Item {
anchors.fill: parent
Row {
id: row
anchors.centerIn: parent
spacing: root.icon !== "" && label.text !== "" ? 5 : 0
MaterialSymbol {
visible: root.icon !== ""
icon: root.icon
font.pixelSize: root.iconSize
color: root.textColor
anchors.verticalCenter: parent.verticalCenter
Behavior on color {
ColorAnimation {
duration: Metrics.chronoDuration("small") / 2
easing.type: Appearance.animation.easing
}
}
}
StyledText {
id: label
color: root.textColor
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
Behavior on color {
ColorAnimation {
duration: Metrics.chronoDuration("small") / 2
easing.type: Appearance.animation.easing
}
}
}
}
}
background: Rectangle {
id: background
radius: Metrics.radius("large")
color: root.backgroundColor
Behavior on color {
ColorAnimation {
duration: Metrics.chronoDuration("small") / 2
easing.type: Appearance.animation.easing
}
}
Behavior on radius {
NumberAnimation {
duration: Metrics.chronoDuration("small") / 2
easing.type: Appearance.animation.easing
}
}
Behavior on topLeftRadius {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on topRightRadius {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on bottomLeftRadius {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on bottomRightRadius {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
}

View File

@@ -0,0 +1,196 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
import qs.config
import qs.modules.functions
Item {
id: root
width: 200
height: 56
property string label: "Select option"
property var model: ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"]
property int currentIndex: -1
property string currentText: {
if (currentIndex < 0)
return ""
if (textRole && model && model.get)
return model.get(currentIndex)[textRole] ?? ""
return model[currentIndex] ?? ""
}
property bool enabled: true
property string textRole: ""
signal selectedIndexChanged(int index)
Rectangle {
id: container
anchors.fill: parent
color: "transparent"
border.color: dropdown.activeFocus ? Appearance.m3colors.m3primary : Appearance.m3colors.m3outline
border.width: dropdown.activeFocus ? 2 : 1
radius: Metrics.radius("unsharpen")
Behavior on border.color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
Behavior on border.width {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
MouseArea {
id: mouseArea
anchors.fill: parent
enabled: root.enabled
hoverEnabled: true
onClicked: dropdown.popup.visible ? dropdown.popup.close() : dropdown.popup.open()
Rectangle {
anchors.fill: parent
radius: parent.parent.radius
color: Appearance.m3colors.m3primary
opacity: mouseArea.pressed ? 0.12 : mouseArea.containsMouse ? 0.08 : 0
Behavior on opacity {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Metrics.margin(16)
anchors.rightMargin: Metrics.margin(12)
spacing: Metrics.spacing(12)
StyledText {
id: labelText
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: root.currentIndex >= 0 ? root.currentText : root.label
color: root.currentIndex >= 0
? Appearance.m3colors.m3onSurface
: ColorUtils.transparentize(Appearance.m3colors.m3onSurfaceVariant, 0.7)
font.pixelSize: Metrics.fontSize(16)
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
MaterialSymbol {
id: dropdownIcon
Layout.alignment: Qt.AlignVCenter
icon: dropdown.popup.visible ? "arrow_drop_up" : "arrow_drop_down"
iconSize: Metrics.iconSize(20)
color: Appearance.m3colors.m3onSurfaceVariant
}
}
}
ComboBox {
id: dropdown
visible: false
model: root.model
currentIndex: root.currentIndex >= 0 ? root.currentIndex : -1
enabled: root.enabled
textRole: root.textRole
onCurrentIndexChanged: {
if (currentIndex >= 0) {
root.currentIndex = currentIndex
root.selectedIndexChanged(currentIndex)
}
}
popup: Popup {
y: root.height + 4
width: root.width
padding: 0
background: Rectangle {
color: Appearance.m3colors.m3surfaceContainer
radius: Metrics.radius(4)
border.color: Appearance.m3colors.m3outline
border.width: 1
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: ColorUtils.transparentize(Appearance.m3colors.m3shadow, 0.25)
shadowBlur: 0.4
shadowVerticalOffset: 8
shadowHorizontalOffset: 0
}
}
contentItem: ListView {
id: listView
clip: true
implicitHeight: Math.min(contentHeight, 300)
model: dropdown.popup.visible ? dropdown.model : []
currentIndex: Math.max(0, dropdown.currentIndex)
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
delegate: ItemDelegate {
width: listView.width
height: 48
background: Rectangle {
color: {
if (itemMouse.pressed) return ColorUtils.transparentize(Appearance.m3colors.m3primaryContainer, 0.12)
if (itemMouse.containsMouse) return ColorUtils.transparentize(Appearance.m3colors.m3primaryContainer, 0.08)
if (index === root.currentIndex) return ColorUtils.transparentize(Appearance.m3colors.m3primaryContainer, 0.08)
return "transparent"
}
Behavior on color {
ColorAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
}
contentItem: StyledText {
text: modelData
color: index === root.currentIndex ? Appearance.m3colors.m3primary : Appearance.m3colors.m3onSurface
font.pixelSize: Metrics.fontSize(16)
verticalAlignment: Text.AlignVCenter
leftPadding: Metrics.fontSize(16)
}
MouseArea {
id: itemMouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
dropdown.currentIndex = index
dropdown.popup.close()
}
}
}
}
enter: Transition {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {property: "opacity"; from: 0.0; to: 1.0; duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
NumberAnimation {property: "scale"; from: 0.9; to: 1.0; duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
exit: Transition {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {property: "opacity"; from: 1.0; to: 0.0; duration: Metrics.chronoDuration(Appearance.animation.fast * 0.67); easing.type: Easing.InOutCubic }
}
}
}
focus: true
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Space || event.key === Qt.Key_Return) {
dropdown.popup.visible ? dropdown.popup.close() : dropdown.popup.open()
event.accepted = true
}
}
}

View File

@@ -0,0 +1,13 @@
import QtQuick
import qs.services
import qs.config
Image {
asynchronous: true
retainWhileLoading: true
visible: opacity > 0
opacity: (status === Image.Ready) ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
}

View File

@@ -0,0 +1,327 @@
import qs.config
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
LazyLoader {
id: root
property string displayName: screen?.name ?? ""
property PanelWindow instance: null
property HoverHandler hoverTarget
property real margin: Metrics.margin(10)
default property list<Component> content
property bool startAnim: false
property bool isVisible: false
property bool keepAlive: false
property bool interactable: false
property bool hasHitbox: true
property bool hCenterOnItem: false
property bool followMouse: false
property list<StyledPopout> childPopouts: []
property bool requiresHover: true
property bool _manualControl: false
property int hoverDelay: Metrics.chronoDuration(250)
property bool targetHovered: hoverTarget && hoverTarget.hovered
property bool containerHovered: interactable && root.item && root.item.containerHovered
property bool selfHovered: targetHovered || containerHovered
property bool childrenHovered: {
for (let i = 0; i < childPopouts.length; i++) {
if (childPopouts[i].selfHovered)
return true;
}
return false;
}
property bool hoverActive: selfHovered || childrenHovered
property Timer showDelayTimer: Timer {
interval: root.hoverDelay
repeat: false
onTriggered: {
root.keepAlive = true;
root.isVisible = true;
root.startAnim = true;
}
}
property Timer hangTimer: Timer {
interval: Metrics.chronoDuration(200)
repeat: false
onTriggered: {
root.startAnim = false;
cleanupTimer.restart();
}
}
property Timer cleanupTimer: Timer {
interval: Metrics.chronoDuration("small")
repeat: false
onTriggered: {
root.isVisible = false;
root.keepAlive = false;
root._manualControl = false;
root.instance = null;
}
}
onHoverActiveChanged: {
if (_manualControl)
return;
if (!requiresHover)
return;
if (hoverActive) {
hangTimer.stop();
cleanupTimer.stop();
if (hoverDelay > 0) {
showDelayTimer.restart();
} else {
root.keepAlive = true;
root.isVisible = true;
root.startAnim = true;
}
} else {
showDelayTimer.stop();
hangTimer.restart();
}
}
function show() {
hangTimer.stop();
cleanupTimer.stop();
showDelayTimer.stop();
_manualControl = true;
keepAlive = true;
isVisible = true;
startAnim = true;
}
function hide() {
_manualControl = true;
showDelayTimer.stop();
startAnim = false;
hangTimer.stop();
cleanupTimer.restart();
}
active: keepAlive
component: PanelWindow {
id: popoutWindow
color: "transparent"
visible: root.isVisible
WlrLayershell.namespace: "whisker:popout"
WlrLayershell.layer: WlrLayer.Overlay
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
anchors {
left: true
top: true
right: true
bottom: true
}
property bool exceedingHalf: false
property var parentPopoutWindow: null
property point mousePos: Qt.point(0, 0)
property bool containerHovered: root.interactable && containerHoverHandler.hovered
HoverHandler {
id: windowHover
onPointChanged: point => {
if (root.followMouse)
popoutWindow.mousePos = point.position;
}
}
mask: Region {
x: !root.hasHitbox ? 0 : !requiresHover ? 0 : container.x
y: !root.hasHitbox ? 0 : !requiresHover ? 0 : container.y
width: !root.hasHitbox ? 0 : !requiresHover ? popoutWindow.width : container.implicitWidth
height: !root.hasHitbox ? 0 : !requiresHover ? popoutWindow.height : container.implicitHeight
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: false
onPressed: mouse => {
if (!containerHoverHandler.containsMouse && root.isVisible) {
root.hide();
}
}
}
Item {
id: container
implicitWidth: contentArea.implicitWidth + root.margin * 2
implicitHeight: contentArea.implicitHeight + root.margin * 2
x: {
let xValue;
if (root.followMouse)
xValue = mousePos.x + 10;
else {
let targetItem = hoverTarget?.parent;
if (!targetItem)
xValue = 0;
else {
let baseX = targetItem.mapToGlobal(Qt.point(0, 0)).x;
if (parentPopoutWindow)
baseX += parentPopoutWindow.x;
let targetWidth = targetItem.width;
let popupWidth = container.implicitWidth;
if (root.hCenterOnItem) {
let centeredX = baseX + (targetWidth - popupWidth) / 2;
if (centeredX + popupWidth > screen.width)
centeredX = screen.width - popupWidth - 10;
if (centeredX < 10)
centeredX = 10;
xValue = centeredX;
} else {
let xPos = baseX - ((ConfigResolver.bar(root.displayName).position === "top" || ConfigResolver.bar(root.displayName).position === "top") ? 20 : -40);
if (xPos + popupWidth > screen.width) {
exceedingHalf = true;
xValue = baseX - popupWidth;
} else {
exceedingHalf = false;
xValue = xPos;
}
}
}
}
return root.cleanupTimer.running ? xValue : Math.round(xValue);
}
y: {
let yValue;
if (root.followMouse)
yValue = mousePos.y + 10;
else {
let targetItem = hoverTarget?.parent;
if (!targetItem)
yValue = 0;
else {
let baseY = targetItem.mapToGlobal(Qt.point(0, 0)).y;
if (parentPopoutWindow)
baseY += parentPopoutWindow.y;
let targetHeight = targetItem.height;
let popupHeight = container.implicitHeight;
let yPos = baseY + ((ConfigResolver.bar(root.displayName).position === "top" || ConfigResolver.bar(root.displayName).position === "top") ? targetHeight : 0);
if (yPos > screen.height / 2)
yPos = baseY - popupHeight;
if (yPos + popupHeight > screen.height)
yPos = screen.height - popupHeight - 10;
if (yPos < 10)
yPos = 10;
yValue = yPos;
}
}
return root.cleanupTimer.running ? yValue : Math.round(yValue);
}
opacity: root.startAnim ? 1 : 0
scale: root.interactable ? 1 : root.startAnim ? 1 : 0.9
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowOpacity: 1
shadowColor: Appearance.m3colors.m3shadow
shadowBlur: 1
shadowScale: 1
}
Behavior on opacity {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on scale {
enabled: !root.interactable
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on implicitWidth {
enabled: root.interactable
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on implicitHeight {
enabled: root.interactable
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
ClippingRectangle {
id: popupBackground
anchors.fill: parent
color: Appearance.m3colors.m3surface
radius: Appearance.rounding.normal
ColumnLayout {
id: contentArea
anchors.fill: parent
anchors.margins: root.margin
}
}
HoverHandler {
id: containerHoverHandler
enabled: root.interactable
}
}
Component.onCompleted: {
root.instance = popoutWindow;
for (let i = 0; i < root.content.length; i++) {
const comp = root.content[i];
if (comp && comp.createObject) {
comp.createObject(contentArea);
} else {
console.warn("StyledPopout: invalid content:", comp);
}
}
let parentPopout = root.parent;
while (parentPopout && !parentPopout.childPopouts)
parentPopout = parentPopout.parent;
if (parentPopout) {
parentPopout.childPopouts.push(root);
if (parentPopout.item)
popoutWindow.parentPopoutWindow = parentPopout.item;
}
}
}
}

View File

@@ -0,0 +1,15 @@
import qs.config
import QtQuick
Rectangle {
id: root
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration(600)
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.standard
}
}
}

View File

@@ -0,0 +1,118 @@
import qs.config
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Slider {
id: root
property real trackHeightDiff: 15
property real handleGap: Metrics.spacing(4)
property real trackDotSize: Metrics.iconSize(4)
property real trackNearHandleRadius: Appearance.rounding.unsharpen
property bool useAnim: true
property int iconSize: Appearance.font.size.large
property string icon: ""
Layout.fillWidth: true
implicitWidth: 200
implicitHeight: 40
from: 0
to: 100
value: 0
stepSize: 0
snapMode: stepSize > 0 ? Slider.SnapAlways : Slider.NoSnap
MouseArea {
anchors.fill: parent
onPressed: (mouse) => mouse.accepted = false
cursorShape: root.pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor
}
MaterialSymbol {
id: icon
icon: root.icon
iconSize: root.iconSize
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: Metrics.margin(16)
}
background: Item {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: parent.height
// Filled Left Segment
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: root.handleGap + (root.visualPosition * (root.width - root.handleGap * 2))
- ((root.pressed ? 1.5 : 3) / 2 + root.handleGap)
height: root.height - root.trackHeightDiff
color: Appearance.colors.colPrimary
radius: Metrics.radius("small")
topRightRadius: root.trackNearHandleRadius
bottomRightRadius: root.trackNearHandleRadius
Behavior on width {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: !root.useAnim ? 0 : Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
}
// Remaining Right Segment
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
width: root.handleGap + ((1 - root.visualPosition) * (root.width - root.handleGap * 2))
- ((root.pressed ? 1.5 : 3) / 2 + root.handleGap)
height: root.height - root.trackHeightDiff
color: Appearance.colors.colSecondaryContainer
radius: Metrics.radius("small")
topLeftRadius: root.trackNearHandleRadius
bottomLeftRadius: root.trackNearHandleRadius
Behavior on width {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: !root.useAnim ? 0 : Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
}
}
handle: Rectangle {
width: 5
height: root.height
radius: (width / 2) * Config.runtime.appearance.rounding.factor
x: root.handleGap + (root.visualPosition * (root.width - root.handleGap * 2)) - width / 2
anchors.verticalCenter: parent.verticalCenter
color: Appearance.colors.colPrimary
Behavior on x {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: !root.useAnim ? 0 : Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
}
}

View File

@@ -0,0 +1,138 @@
import qs.config
import QtQuick
import QtQuick.Controls
Item {
id: root
width: 60
height: 34
property bool checked: false
signal toggled(bool checked)
// Colors
property color trackOn: Appearance.colors.colPrimary
property color trackOff: Appearance.colors.colLayer2
property color outline: Appearance.colors.colOutline
property color thumbOn: Appearance.colors.colOnPrimary
property color thumbOff: Appearance.colors.colOnLayer2
property color iconOn: Appearance.colors.colPrimary
property color iconOff: Appearance.colors.colOnPrimary
// Dimensions
property int trackRadius: (height / 2) * Config.runtime.appearance.rounding.factor
property int thumbSize: height - (checked ? 10 : 14)
Behavior on thumbSize {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("normal")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
// TRACK
Rectangle {
id: track
anchors.fill: parent
radius: trackRadius
color: root.checked ? trackOn : trackOff
border.width: root.checked ? 0 : 2
border.color: outline
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("normal")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
}
// THUMB
Rectangle {
id: thumb
width: thumbSize
height: thumbSize
radius: (thumbSize / 2) * Config.runtime.appearance.rounding.factor
anchors.verticalCenter: parent.verticalCenter
x: root.checked ? parent.width - width - 6 : 6
color: root.checked ? thumbOn : thumbOff
Behavior on x {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
// ✓ CHECK ICON
MaterialSymbol {
anchors.centerIn: parent
icon: "check"
iconSize: parent.width * 0.7
color: iconOn
opacity: root.checked ? 1 : 0
scale: root.checked ? 1 : 0.6
Behavior on opacity { NumberAnimation { duration: 120 } }
Behavior on scale {
NumberAnimation {
duration: 160
easing.type: Easing.OutBack
}
}
}
// ✕ CROSS ICON (more visible)
MaterialSymbol {
anchors.centerIn: parent
icon: "close"
iconSize: parent.width * 0.72
color: iconOff
opacity: root.checked ? 0 : 1
scale: root.checked ? 0.6 : 1
Behavior on opacity { NumberAnimation { duration: 120 } }
Behavior on scale {
NumberAnimation {
duration: 160
easing.type: Easing.OutBack
}
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.checked = !root.checked
root.toggled(root.checked)
}
}
}

View File

@@ -0,0 +1,37 @@
import qs.config
import Quickshell
import QtQuick
import QtQuick.Layouts
RowLayout {
id: main
property string title: "Title"
property string description: "Description"
property string prefField: ''
ColumnLayout {
StyledText { text: main.title; font.pixelSize: Metrics.fontSize(16); }
StyledText { text: main.description; font.pixelSize: Metrics.fontSize(12); }
}
Item { Layout.fillWidth: true }
StyledSwitch {
// Safely resolve nested key (e.g. "background.showClock" or "bar.modules.some.setting")
checked: {
if (!main.prefField) return false;
var parts = main.prefField.split('.');
var cur = Config.runtime;
for (var i = 0; i < parts.length; ++i) {
if (cur === undefined || cur === null) return false;
cur = cur[parts[i]];
}
// If the config value is undefined, default to false
return cur === undefined || cur === null ? false : cur;
}
onToggled: {
// Persist change (updateKey will create missing objects)
Config.updateKey(main.prefField, checked);
}
}
}

View File

@@ -0,0 +1,54 @@
pragma ComponentBehavior: Bound
import qs.config
import QtQuick
Text {
id: root
// from github.com/yannpelletier/twinshell with modifications
property bool animate: true
property string animateProp: "scale"
property real animateFrom: 0
property real animateTo: 1
property int animateDuration: Metrics.chronoDuration("small")
renderType: Text.NativeRendering
textFormat: Text.PlainText
color: Appearance.syntaxHighlightingTheme
font.family: Metrics.fontFamily("main")
font.pixelSize: Metrics.fontSize("normal")
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.standard
}
}
Behavior on text {
enabled: Config.runtime.appearance.animations.enabled && root.animate
SequentialAnimation {
Anim {
to: root.animateFrom
easing.bezierCurve: Appearance.animation.curves.standardAccel
}
PropertyAction {}
Anim {
to: root.animateTo
easing.bezierCurve: Appearance.animation.curves.standardDecel
}
}
}
component Anim: NumberAnimation {
target: root
property: root.animateProp
duration: root.animateDuration / 2
easing.type: Easing.BezierSpline
}
}

View File

@@ -0,0 +1,195 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import qs.config
import qs.modules.functions
TextField {
id: control
property string icon: ""
property color iconColor: Appearance.m3colors.m3onSurfaceVariant
property string placeholder: ""
property real iconSize: Metrics.iconSize(24)
property alias radius: bg.radius
property bool outline: true
property alias topLeftRadius: bg.topLeftRadius
property alias topRightRadius: bg.topRightRadius
property alias bottomLeftRadius: bg.bottomLeftRadius
property alias bottomRightRadius: bg.bottomRightRadius
property color backgroundColor: filled ? Appearance.m3colors.m3surfaceContainerHigh : "transparent"
property int fieldPadding: Metrics.padding(20)
property int iconSpacing: Metrics.spacing(14)
property int iconMargin: Metrics.margin(20)
property bool filled: true
property bool highlight: true
width: parent ? parent.width - 40 : 300
placeholderText: placeholder
leftPadding: icon !== "" ? iconSize + iconSpacing + iconMargin : fieldPadding
padding: fieldPadding
verticalAlignment: TextInput.AlignVCenter
color: Appearance.m3colors.m3onSurface
placeholderTextColor: Appearance.m3colors.m3onSurfaceVariant
font.family: "Outfit"
font.pixelSize: Metrics.fontSize(14)
cursorVisible: control.focus
MaterialSymbol {
icon: control.icon
anchors.left: parent.left
anchors.leftMargin: icon !== "" ? iconMargin : 0
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: control.iconSize
color: control.iconColor
visible: control.icon !== ""
Behavior on color {
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
cursorDelegate: Rectangle {
width: 2
color: Appearance.m3colors.m3primary
visible: control.focus
SequentialAnimation on opacity {
loops: Animation.Infinite
running: control.focus && Config.runtime.appearance.animations.enabled
NumberAnimation {
from: 1
to: 0
duration: Metrics.chronoDuration("lrage") * 2
}
NumberAnimation {
from: 0
to: 1
duration: Metrics.chronoDuration("lrage") * 2
}
}
}
background: Item {
Rectangle {
id: bg
anchors.fill: parent
radius: Metrics.radius("unsharpenmore")
color: control.backgroundColor
Rectangle {
anchors.fill: parent
radius: parent.radius
color: {
if (control.activeFocus && control.highlight)
return ColorUtils.transparentize(Appearance.m3colors.m3primary, 0.8);
if (control.hovered && control.highlight)
return ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.9);
return "transparent";
}
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
}
Rectangle {
id: indicator
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: control.activeFocus ? 2 : 1
color: {
if (control.activeFocus)
return Appearance.m3colors.m3primary;
if (control.hovered)
return Appearance.m3colors.m3onSurface;
return Appearance.m3colors.m3onSurface;
}
visible: filled
Behavior on height {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
Rectangle {
id: outline
anchors.fill: parent
radius: bg.radius
color: "transparent"
border.width: control.activeFocus ? 2 : 1
border.color: {
if (control.activeFocus)
return Appearance.m3colors.m3primary;
if (control.hovered)
return Appearance.m3colors.m3onSurface;
return Appearance.m3colors.m3outline;
}
visible: !filled && control.outline
Behavior on border.width {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on border.color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Effects
import qs.config
Item {
property var sourceItem: null
Loader {
active: Config.runtime.appearance.tintIcons
anchors.fill: parent
sourceComponent: MultiEffect {
source: sourceItem
saturation: -1.0
contrast: 0.10
brightness: -0.08
blur: 0.0
}
}
}

View File

@@ -0,0 +1,103 @@
import QtQuick
import "shapes/morph.js" as Morph
import qs.config
// From github.com/end-4/rounded-polygons-qmljs
Canvas {
id: root
property color color: "#685496"
property var roundedPolygon: null
property bool polygonIsNormalized: true
property real borderWidth: 0
property color borderColor: color
property bool debug: false
// Internals: size
property var bounds: roundedPolygon.calculateBounds()
implicitWidth: bounds[2] - bounds[0]
implicitHeight: bounds[3] - bounds[1]
// Internals: anim
property var prevRoundedPolygon: null
property double progress: 1
property var morph: new Morph.Morph(roundedPolygon, roundedPolygon)
property Animation animation: NumberAnimation {
duration: Metrics.chronoDuration(350)
easing.type: Easing.BezierSpline
easing.bezierCurve: [0.42, 1.67, 0.21, 0.90, 1, 1] // Material 3 Expressive fast spatial (https://m3.material.io/styles/motion/overview/specs)
}
onRoundedPolygonChanged: {
delete root.morph
root.morph = new Morph.Morph(root.prevRoundedPolygon ?? root.roundedPolygon, root.roundedPolygon)
morphBehavior.enabled = false;
root.progress = 0
morphBehavior.enabled = true;
root.progress = 1
root.prevRoundedPolygon = root.roundedPolygon
}
Behavior on progress {
id: morphBehavior
animation: root.animation
}
onProgressChanged: requestPaint()
onColorChanged: requestPaint()
onBorderWidthChanged: requestPaint()
onBorderColorChanged: requestPaint()
onDebugChanged: requestPaint()
onPaint: {
var ctx = getContext("2d")
ctx.fillStyle = root.color
ctx.clearRect(0, 0, width, height)
if (!root.morph) return
const cubics = root.morph.asCubics(root.progress)
if (cubics.length === 0) return
const size = Math.min(root.width, root.height)
ctx.save()
if (root.polygonIsNormalized) ctx.scale(size, size)
ctx.beginPath()
ctx.moveTo(cubics[0].anchor0X, cubics[0].anchor0Y)
for (const cubic of cubics) {
ctx.bezierCurveTo(
cubic.control0X, cubic.control0Y,
cubic.control1X, cubic.control1Y,
cubic.anchor1X, cubic.anchor1Y
)
}
ctx.closePath()
ctx.fill()
if (root.borderWidth > 0) {
ctx.strokeStyle = root.borderColor
ctx.lineWidth = root.borderWidth
ctx.stroke()
}
if (root.debug) {
const points = []
for (let i = 0; i < cubics.length; ++i) {
const c = cubics[i]
if (i === 0)
points.push({ x: c.anchor0X, y: c.anchor0Y })
points.push({ x: c.anchor1X, y: c.anchor1Y })
}
let radius = Metrics.radius(2)
ctx.fillStyle = "red"
for (const p of points) {
ctx.beginPath()
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2)
ctx.fill()
}
}
ctx.restore()
}
}

View File

@@ -0,0 +1,177 @@
.pragma library
/**
* @param {number} x
* @param {number} y
* @returns {Offset}
*/
function createOffset(x, y) {
return new Offset(x, y);
}
class Offset {
/**
* @param {number} x
* @param {number} y
*/
constructor(x, y) {
this.x = x;
this.y = y;
}
/**
* @param {number} x
* @param {number} y
* @returns {Offset}
*/
copy(x = this.x, y = this.y) {
return new Offset(x, y);
}
/**
* @returns {number}
*/
getDistance() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
/**
* @returns {number}
*/
getDistanceSquared() {
return this.x * this.x + this.y * this.y;
}
/**
* @returns {boolean}
*/
isValid() {
return isFinite(this.x) && isFinite(this.y);
}
/**
* @returns {boolean}
*/
get isFinite() {
return isFinite(this.x) && isFinite(this.y);
}
/**
* @returns {boolean}
*/
get isSpecified() {
return !this.isUnspecified;
}
/**
* @returns {boolean}
*/
get isUnspecified() {
return Object.is(this.x, NaN) && Object.is(this.y, NaN);
}
/**
* @returns {Offset}
*/
negate() {
return new Offset(-this.x, -this.y);
}
/**
* @param {Offset} other
* @returns {Offset}
*/
minus(other) {
return new Offset(this.x - other.x, this.y - other.y);
}
/**
* @param {Offset} other
* @returns {Offset}
*/
plus(other) {
return new Offset(this.x + other.x, this.y + other.y);
}
/**
* @param {number} operand
* @returns {Offset}
*/
times(operand) {
return new Offset(this.x * operand, this.y * operand);
}
/**
* @param {number} operand
* @returns {Offset}
*/
div(operand) {
return new Offset(this.x / operand, this.y / operand);
}
/**
* @param {number} operand
* @returns {Offset}
*/
rem(operand) {
return new Offset(this.x % operand, this.y % operand);
}
/**
* @returns {string}
*/
toString() {
if (this.isSpecified) {
return `Offset(${this.x.toFixed(1)}, ${this.y.toFixed(1)})`;
} else {
return 'Offset.Unspecified';
}
}
/**
* @param {Offset} start
* @param {Offset} stop
* @param {number} fraction
* @returns {Offset}
*/
static lerp(start, stop, fraction) {
return new Offset(
start.x + (stop.x - start.x) * fraction,
start.y + (stop.y - start.y) * fraction
);
}
/**
* @param {function(): Offset} block
* @returns {Offset}
*/
takeOrElse(block) {
return this.isSpecified ? this : block();
}
/**
* @returns {number}
*/
angleDegrees() {
return Math.atan2(this.y, this.x) * 180 / Math.PI;
}
/**
* @param {number} angle
* @param {Offset} center
* @returns {Offset}
*/
rotateDegrees(angle, center = Offset.Zero) {
const a = angle * Math.PI / 180;
const off = this.minus(center);
const cosA = Math.cos(a);
const sinA = Math.sin(a);
const newX = off.x * cosA - off.y * sinA;
const newY = off.x * sinA + off.y * cosA;
return new Offset(newX, newY).plus(center);
}
}
Offset.Zero = new Offset(0, 0);
Offset.Infinite = new Offset(Infinity, Infinity);
Offset.Unspecified = new Offset(NaN, NaN);

View File

@@ -0,0 +1,198 @@
.pragma library
.import "../geometry/offset.js" as Offset
class Matrix {
constructor(values = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) {
this.values = values;
}
get(row, column) {
return this.values[(row * 4) + column];
}
set(row, column, v) {
this.values[(row * 4) + column] = v;
}
/** Does the 3D transform on [point] and returns the `x` and `y` values in an [Offset]. */
map(point) {
if (this.values.length < 16) return point;
const v00 = this.get(0, 0);
const v01 = this.get(0, 1);
const v03 = this.get(0, 3);
const v10 = this.get(1, 0);
const v11 = this.get(1, 1);
const v13 = this.get(1, 3);
const v30 = this.get(3, 0);
const v31 = this.get(3, 1);
const v33 = this.get(3, 3);
const x = point.x;
const y = point.y;
const z = v03 * x + v13 * y + v33;
const inverseZ = 1 / z;
const pZ = isFinite(inverseZ) ? inverseZ : 0;
return new Offset.Offset(pZ * (v00 * x + v10 * y + v30), pZ * (v01 * x + v11 * y + v31));
}
/** Multiply this matrix by [m] and assign the result to this matrix. */
timesAssign(m) {
const v = this.values;
if (v.length < 16) return;
if (m.values.length < 16) return;
const v00 = this.dot(0, m, 0);
const v01 = this.dot(0, m, 1);
const v02 = this.dot(0, m, 2);
const v03 = this.dot(0, m, 3);
const v10 = this.dot(1, m, 0);
const v11 = this.dot(1, m, 1);
const v12 = this.dot(1, m, 2);
const v13 = this.dot(1, m, 3);
const v20 = this.dot(2, m, 0);
const v21 = this.dot(2, m, 1);
const v22 = this.dot(2, m, 2);
const v23 = this.dot(2, m, 3);
const v30 = this.dot(3, m, 0);
const v31 = this.dot(3, m, 1);
const v32 = this.dot(3, m, 2);
const v33 = this.dot(3, m, 3);
v[0] = v00;
v[1] = v01;
v[2] = v02;
v[3] = v03;
v[4] = v10;
v[5] = v11;
v[6] = v12;
v[7] = v13;
v[8] = v20;
v[9] = v21;
v[10] = v22;
v[11] = v23;
v[12] = v30;
v[13] = v31;
v[14] = v32;
v[15] = v33;
}
dot(row, m, column) {
return this.get(row, 0) * m.get(0, column) +
this.get(row, 1) * m.get(1, column) +
this.get(row, 2) * m.get(2, column) +
this.get(row, 3) * m.get(3, column);
}
/** Resets the `this` to the identity matrix. */
reset() {
const v = this.values;
if (v.length < 16) return;
v[0] = 1;
v[1] = 0;
v[2] = 0;
v[3] = 0;
v[4] = 0;
v[5] = 1;
v[6] = 0;
v[7] = 0;
v[8] = 0;
v[9] = 0;
v[10] = 1;
v[11] = 0;
v[12] = 0;
v[13] = 0;
v[14] = 0;
v[15] = 1;
}
/** Applies a [degrees] rotation around Z to `this`. */
rotateZ(degrees) {
if (this.values.length < 16) return;
const r = degrees * (Math.PI / 180.0);
const s = Math.sin(r);
const c = Math.cos(r);
const a00 = this.get(0, 0);
const a10 = this.get(1, 0);
const v00 = c * a00 + s * a10;
const v10 = -s * a00 + c * a10;
const a01 = this.get(0, 1);
const a11 = this.get(1, 1);
const v01 = c * a01 + s * a11;
const v11 = -s * a01 + c * a11;
const a02 = this.get(0, 2);
const a12 = this.get(1, 2);
const v02 = c * a02 + s * a12;
const v12 = -s * a02 + c * a12;
const a03 = this.get(0, 3);
const a13 = this.get(1, 3);
const v03 = c * a03 + s * a13;
const v13 = -s * a03 + c * a13;
this.set(0, 0, v00);
this.set(0, 1, v01);
this.set(0, 2, v02);
this.set(0, 3, v03);
this.set(1, 0, v10);
this.set(1, 1, v11);
this.set(1, 2, v12);
this.set(1, 3, v13);
}
/** Scale this matrix by [x], [y], [z] */
scale(x = 1, y = 1, z = 1) {
if (this.values.length < 16) return;
this.set(0, 0, this.get(0, 0) * x);
this.set(0, 1, this.get(0, 1) * x);
this.set(0, 2, this.get(0, 2) * x);
this.set(0, 3, this.get(0, 3) * x);
this.set(1, 0, this.get(1, 0) * y);
this.set(1, 1, this.get(1, 1) * y);
this.set(1, 2, this.get(1, 2) * y);
this.set(1, 3, this.get(1, 3) * y);
this.set(2, 0, this.get(2, 0) * z);
this.set(2, 1, this.get(2, 1) * z);
this.set(2, 2, this.get(2, 2) * z);
this.set(2, 3, this.get(2, 3) * z);
}
/** Translate this matrix by [x], [y], [z] */
translate(x = 0, y = 0, z = 0) {
if (this.values.length < 16) return;
const t1 = this.get(0, 0) * x + this.get(1, 0) * y + this.get(2, 0) * z + this.get(3, 0);
const t2 = this.get(0, 1) * x + this.get(1, 1) * y + this.get(2, 1) * z + this.get(3, 1);
const t3 = this.get(0, 2) * x + this.get(1, 2) * y + this.get(2, 2) * z + this.get(3, 2);
const t4 = this.get(0, 3) * x + this.get(1, 3) * y + this.get(2, 3) * z + this.get(3, 3);
this.set(3, 0, t1);
this.set(3, 1, t2);
this.set(3, 2, t3);
this.set(3, 3, t4);
}
toString() {
return `${this.get(0, 0)} ${this.get(0, 1)} ${this.get(0, 2)} ${this.get(0, 3)}\n` +
`${this.get(1, 0)} ${this.get(1, 1)} ${this.get(1, 2)} ${this.get(1, 3)}\n` +
`${this.get(2, 0)} ${this.get(2, 1)} ${this.get(2, 2)} ${this.get(2, 3)}\n` +
`${this.get(3, 0)} ${this.get(3, 1)} ${this.get(3, 2)} ${this.get(3, 3)}`;
}
}
// Companion object constants
Matrix.ScaleX = 0;
Matrix.SkewY = 1;
Matrix.Perspective0 = 3;
Matrix.SkewX = 4;
Matrix.ScaleY = 5;
Matrix.Perspective1 = 7;
Matrix.ScaleZ = 10;
Matrix.TranslateX = 12;
Matrix.TranslateY = 13;
Matrix.TranslateZ = 14;
Matrix.Perspective2 = 15;

View File

@@ -0,0 +1,712 @@
.pragma library
.import "shapes/point.js" as Point
.import "shapes/rounded-polygon.js" as RoundedPolygon
.import "shapes/corner-rounding.js" as CornerRounding
.import "geometry/offset.js" as Offset
.import "graphics/matrix.js" as Matrix
var _circle = null
var _square = null
var _slanted = null
var _arch = null
var _fan = null
var _arrow = null
var _semiCircle = null
var _oval = null
var _pill = null
var _triangle = null
var _diamond = null
var _clamShell = null
var _pentagon = null
var _gem = null
var _verySunny = null
var _sunny = null
var _cookie4Sided = null
var _cookie6Sided = null
var _cookie7Sided = null
var _cookie9Sided = null
var _cookie12Sided = null
var _ghostish = null
var _clover4Leaf = null
var _clover8Leaf = null
var _burst = null
var _softBurst = null
var _boom = null
var _softBoom = null
var _flower = null
var _puffy = null
var _puffyDiamond = null
var _pixelCircle = null
var _pixelTriangle = null
var _bun = null
var _heart = null
var cornerRound15 = new CornerRounding.CornerRounding(0.15)
var cornerRound20 = new CornerRounding.CornerRounding(0.2)
var cornerRound30 = new CornerRounding.CornerRounding(0.3)
var cornerRound50 = new CornerRounding.CornerRounding(0.5)
var cornerRound100 = new CornerRounding.CornerRounding(1.0)
var rotateNeg30 = new Matrix.Matrix();
rotateNeg30.rotateZ(-30);
var rotateNeg45 = new Matrix.Matrix();
rotateNeg45.rotateZ(-45);
var rotateNeg90 = new Matrix.Matrix();
rotateNeg90.rotateZ(-90);
var rotateNeg135 = new Matrix.Matrix();
rotateNeg135.rotateZ(-135);
var rotate30 = new Matrix.Matrix();
rotate30.rotateZ(30);
var rotate45 = new Matrix.Matrix();
rotate45.rotateZ(45);
var rotate60 = new Matrix.Matrix();
rotate60.rotateZ(60);
var rotate90 = new Matrix.Matrix();
rotate90.rotateZ(90);
var rotate120 = new Matrix.Matrix();
rotate120.rotateZ(120);
var rotate135 = new Matrix.Matrix();
rotate135.rotateZ(135);
var rotate180 = new Matrix.Matrix();
rotate180.rotateZ(180);
var rotate28th = new Matrix.Matrix();
rotate28th.rotateZ(360/28);
var rotateNeg16th = new Matrix.Matrix();
rotateNeg16th.rotateZ(-360/16);
function getCircle() {
if (_circle !== null) return _circle;
_circle = circle();
return _circle;
}
function getSquare() {
if (_square !== null) return _square;
_square = square();
return _square;
}
function getSlanted() {
if (_slanted !== null) return _slanted;
_slanted = slanted();
return _slanted;
}
function getArch() {
if (_arch !== null) return _arch;
_arch = arch();
return _arch;
}
function getFan() {
if (_fan !== null) return _fan;
_fan = fan();
return _fan;
}
function getArrow() {
if (_arrow !== null) return _arrow;
_arrow = arrow();
return _arrow;
}
function getSemiCircle() {
if (_semiCircle !== null) return _semiCircle;
_semiCircle = semiCircle();
return _semiCircle;
}
function getOval() {
if (_oval !== null) return _oval;
_oval = oval();
return _oval;
}
function getPill() {
if (_pill !== null) return _pill;
_pill = pill();
return _pill;
}
function getTriangle() {
if (_triangle !== null) return _triangle;
_triangle = triangle();
return _triangle;
}
function getDiamond() {
if (_diamond !== null) return _diamond;
_diamond = diamond();
return _diamond;
}
function getClamShell() {
if (_clamShell !== null) return _clamShell;
_clamShell = clamShell();
return _clamShell;
}
function getPentagon() {
if (_pentagon !== null) return _pentagon;
_pentagon = pentagon();
return _pentagon;
}
function getGem() {
if (_gem !== null) return _gem;
_gem = gem();
return _gem;
}
function getSunny() {
if (_sunny !== null) return _sunny;
_sunny = sunny();
return _sunny;
}
function getVerySunny() {
if (_verySunny !== null) return _verySunny;
_verySunny = verySunny();
return _verySunny;
}
function getCookie4Sided() {
if (_cookie4Sided !== null) return _cookie4Sided;
_cookie4Sided = cookie4();
return _cookie4Sided;
}
function getCookie6Sided() {
if (_cookie6Sided !== null) return _cookie6Sided;
_cookie6Sided = cookie6();
return _cookie6Sided;
}
function getCookie7Sided() {
if (_cookie7Sided !== null) return _cookie7Sided;
_cookie7Sided = cookie7();
return _cookie7Sided;
}
function getCookie9Sided() {
if (_cookie9Sided !== null) return _cookie9Sided;
_cookie9Sided = cookie9();
return _cookie9Sided;
}
function getCookie12Sided() {
if (_cookie12Sided !== null) return _cookie12Sided;
_cookie12Sided = cookie12();
return _cookie12Sided;
}
function getGhostish() {
if (_ghostish !== null) return _ghostish;
_ghostish = ghostish();
return _ghostish;
}
function getClover4Leaf() {
if (_clover4Leaf !== null) return _clover4Leaf;
_clover4Leaf = clover4();
return _clover4Leaf;
}
function getClover8Leaf() {
if (_clover8Leaf !== null) return _clover8Leaf;
_clover8Leaf = clover8();
return _clover8Leaf;
}
function getBurst() {
if (_burst !== null) return _burst;
_burst = burst();
return _burst;
}
function getSoftBurst() {
if (_softBurst !== null) return _softBurst;
_softBurst = softBurst();
return _softBurst;
}
function getBoom() {
if (_boom !== null) return _boom;
_boom = boom();
return _boom;
}
function getSoftBoom() {
if (_softBoom !== null) return _softBoom;
_softBoom = softBoom();
return _softBoom;
}
function getFlower() {
if (_flower !== null) return _flower;
_flower = flower();
return _flower;
}
function getPuffy() {
if (_puffy !== null) return _puffy;
_puffy = puffy();
return _puffy;
}
function getPuffyDiamond() {
if (_puffyDiamond !== null) return _puffyDiamond;
_puffyDiamond = puffyDiamond();
return _puffyDiamond;
}
function getPixelCircle() {
if (_pixelCircle !== null) return _pixelCircle;
_pixelCircle = pixelCircle();
return _pixelCircle;
}
function getPixelTriangle() {
if (_pixelTriangle !== null) return _pixelTriangle;
_pixelTriangle = pixelTriangle();
return _pixelTriangle;
}
function getBun() {
if (_bun !== null) return _bun;
_bun = bun();
return _bun;
}
function getHeart() {
if (_heart !== null) return _heart;
_heart = heart();
return _heart;
}
function circle() {
return RoundedPolygon.RoundedPolygon.circle(10)
.transformed((x, y) => rotate45.map(new Offset.Offset(x, y)))
.normalized();
}
function square() {
return RoundedPolygon.RoundedPolygon.rectangle(1, 1, cornerRound30).normalized();
}
function slanted() {
return customPolygon([
new PointNRound(new Offset.Offset(0.926, 0.970), new CornerRounding.CornerRounding(0.189, 0.811)),
new PointNRound(new Offset.Offset(-0.021, 0.967), new CornerRounding.CornerRounding(0.187, 0.057)),
], 2).normalized();
}
function arch() {
return RoundedPolygon.RoundedPolygon.rectangle(1, 1, CornerRounding.Unrounded, [cornerRound20, cornerRound20, cornerRound100, cornerRound100])
.normalized();
}
function fan() {
return customPolygon([
new PointNRound(new Offset.Offset(1.004, 1.000), new CornerRounding.CornerRounding(0.148, 0.417)),
new PointNRound(new Offset.Offset(0.000, 1.000), new CornerRounding.CornerRounding(0.151)),
new PointNRound(new Offset.Offset(0.000, -0.003), new CornerRounding.CornerRounding(0.148)),
new PointNRound(new Offset.Offset(0.978, 0.020), new CornerRounding.CornerRounding(0.803)),
], 1).normalized();
}
function arrow() {
return customPolygon([
new PointNRound(new Offset.Offset(1.225, 1.060), new CornerRounding.CornerRounding(0.211)),
new PointNRound(new Offset.Offset(0.500, 0.892), new CornerRounding.CornerRounding(0.313)),
new PointNRound(new Offset.Offset(-0.216, 1.050), new CornerRounding.CornerRounding(0.207)),
new PointNRound(new Offset.Offset(0.499, -0.160), new CornerRounding.CornerRounding(0.215, 1.000)),
], 1).normalized();
}
function semiCircle() {
return RoundedPolygon.RoundedPolygon.rectangle(1.6, 1, CornerRounding.Unrounded, [cornerRound20, cornerRound20, cornerRound100, cornerRound100]).normalized();
}
function oval() {
const scaleMatrix = new Matrix.Matrix();
scaleMatrix.scale(1, 0.64);
return RoundedPolygon.RoundedPolygon.circle()
.transformed((x, y) => rotateNeg90.map(new Offset.Offset(x, y)))
.transformed((x, y) => scaleMatrix.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate135.map(new Offset.Offset(x, y)))
.normalized();
}
function pill() {
return customPolygon([
// new PointNRound(new Offset.Offset(0.609, 0.000), new CornerRounding.CornerRounding(1.000)),
new PointNRound(new Offset.Offset(0.428, -0.001), new CornerRounding.CornerRounding(0.426)),
new PointNRound(new Offset.Offset(0.961, 0.039), new CornerRounding.CornerRounding(0.426)),
new PointNRound(new Offset.Offset(1.001, 0.428)),
new PointNRound(new Offset.Offset(1.000, 0.609), new CornerRounding.CornerRounding(1.000)),
], 2)
.transformed((x, y) => rotate180.map(new Offset.Offset(x, y)))
.normalized();
}
function triangle() {
return RoundedPolygon.RoundedPolygon.fromNumVertices(3, 1, 0.5, 0.5, cornerRound20)
.transformed((x, y) => rotate30.map(new Offset.Offset(x, y)))
.normalized()
}
function diamond() {
return customPolygon([
new PointNRound(new Offset.Offset(0.500, 1.096), new CornerRounding.CornerRounding(0.151, 0.524)),
new PointNRound(new Offset.Offset(0.040, 0.500), new CornerRounding.CornerRounding(0.159)),
], 2).normalized();
}
function clamShell() {
return customPolygon([
new PointNRound(new Offset.Offset(0.829, 0.841), new CornerRounding.CornerRounding(0.159)),
new PointNRound(new Offset.Offset(0.171, 0.841), new CornerRounding.CornerRounding(0.159)),
new PointNRound(new Offset.Offset(-0.020, 0.500), new CornerRounding.CornerRounding(0.140)),
], 2).normalized();
}
function pentagon() {
return customPolygon([
new PointNRound(new Offset.Offset(0.828, 0.970), new CornerRounding.CornerRounding(0.169)),
new PointNRound(new Offset.Offset(0.172, 0.970), new CornerRounding.CornerRounding(0.169)),
new PointNRound(new Offset.Offset(-0.030, 0.365), new CornerRounding.CornerRounding(0.164)),
new PointNRound(new Offset.Offset(0.500, -0.009), new CornerRounding.CornerRounding(0.172)),
new PointNRound(new Offset.Offset(1.030, 0.365), new CornerRounding.CornerRounding(0.164)),
], 1).normalized();
}
function gem() {
return customPolygon([
new PointNRound(new Offset.Offset(1.005, 0.792), new CornerRounding.CornerRounding(0.208)),
new PointNRound(new Offset.Offset(0.5, 1.023), new CornerRounding.CornerRounding(0.241, 0.778)),
new PointNRound(new Offset.Offset(-0.005, 0.792), new CornerRounding.CornerRounding(0.208)),
new PointNRound(new Offset.Offset(0.073, 0.258), new CornerRounding.CornerRounding(0.228)),
new PointNRound(new Offset.Offset(0.5, 0.000), new CornerRounding.CornerRounding(0.241, 0.778)),
new PointNRound(new Offset.Offset(0.927, 0.258), new CornerRounding.CornerRounding(0.228)),
], 1).normalized();
}
function sunny() {
return RoundedPolygon.RoundedPolygon.star(8, 1, 0.8, cornerRound15)
.transformed((x, y) => rotate45.map(new Offset.Offset(x, y)))
.normalized();
}
function verySunny() {
return customPolygon([
new PointNRound(new Offset.Offset(0.500, 1.080), new CornerRounding.CornerRounding(0.085)),
new PointNRound(new Offset.Offset(0.358, 0.843), new CornerRounding.CornerRounding(0.085)),
], 8)
.transformed((x, y) => rotateNeg45.map(new Offset.Offset(x, y)))
.normalized();
}
function cookie4() {
return customPolygon([
new PointNRound(new Offset.Offset(1.237, 1.236), new CornerRounding.CornerRounding(0.258)),
new PointNRound(new Offset.Offset(0.500, 0.918), new CornerRounding.CornerRounding(0.233)),
], 4).normalized();
}
function cookie6() {
return customPolygon([
new PointNRound(new Offset.Offset(0.723, 0.884), new CornerRounding.CornerRounding(0.394)),
new PointNRound(new Offset.Offset(0.500, 1.099), new CornerRounding.CornerRounding(0.398)),
], 6).normalized();
}
function cookie7() {
return RoundedPolygon.RoundedPolygon.star(7, 1, 0.75, cornerRound50)
.normalized()
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.normalized();
}
function cookie9() {
return RoundedPolygon.RoundedPolygon.star(9, 1, 0.8, cornerRound50)
.transformed((x, y) => rotate30.map(new Offset.Offset(x, y)))
.normalized();
}
function cookie12() {
return RoundedPolygon.RoundedPolygon.star(12, 1, 0.8, cornerRound50)
.transformed((x, y) => rotate30.map(new Offset.Offset(x, y)))
.normalized();
}
function ghostish() {
return customPolygon([
new PointNRound(new Offset.Offset(1.000, 1.140), new CornerRounding.CornerRounding(0.254, 0.106)),
new PointNRound(new Offset.Offset(0.575, 0.906), new CornerRounding.CornerRounding(0.253)),
new PointNRound(new Offset.Offset(0.425, 0.906), new CornerRounding.CornerRounding(0.253)),
new PointNRound(new Offset.Offset(0.000, 1.140), new CornerRounding.CornerRounding(0.254, 0.106)),
new PointNRound(new Offset.Offset(0.000, 0.000), new CornerRounding.CornerRounding(1.0)),
new PointNRound(new Offset.Offset(0.500, 0.000), new CornerRounding.CornerRounding(1.0)),
new PointNRound(new Offset.Offset(1.000, 0.000), new CornerRounding.CornerRounding(1.0)),
], 1).normalized();
}
function clover4() {
return customPolygon([
new PointNRound(new Offset.Offset(1.099, 0.725), new CornerRounding.CornerRounding(0.476)),
new PointNRound(new Offset.Offset(0.725, 1.099), new CornerRounding.CornerRounding(0.476)),
new PointNRound(new Offset.Offset(0.500, 0.926)),
], 4).normalized();
}
function clover8() {
return customPolygon([
new PointNRound(new Offset.Offset(0.758, 1.101), new CornerRounding.CornerRounding(0.209)),
new PointNRound(new Offset.Offset(0.500, 0.964)),
], 8).normalized();
}
function burst() {
return customPolygon([
new PointNRound(new Offset.Offset(0.592, 0.842), new CornerRounding.CornerRounding(0.006)),
new PointNRound(new Offset.Offset(0.500, 1.006), new CornerRounding.CornerRounding(0.006)),
], 12)
.transformed((x, y) => rotateNeg30.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotateNeg30.map(new Offset.Offset(x, y)))
.normalized();
}
function softBurst() {
return customPolygon([
new PointNRound(new Offset.Offset(0.193, 0.277), new CornerRounding.CornerRounding(0.053)),
new PointNRound(new Offset.Offset(0.176, 0.055), new CornerRounding.CornerRounding(0.053)),
], 10)
.transformed((x, y) => rotate180.map(new Offset.Offset(x, y)))
.normalized();
}
function boom() {
return customPolygon([
new PointNRound(new Offset.Offset(0.457, 0.296), new CornerRounding.CornerRounding(0.007)),
new PointNRound(new Offset.Offset(0.500, -0.051), new CornerRounding.CornerRounding(0.007)),
], 15)
.transformed((x, y) => rotate120.map(new Offset.Offset(x, y)))
.normalized();
}
function softBoom() {
return customPolygon([
new PointNRound(new Offset.Offset(0.733, 0.454)),
new PointNRound(new Offset.Offset(0.839, 0.437), new CornerRounding.CornerRounding(0.532)),
new PointNRound(new Offset.Offset(0.949, 0.449), new CornerRounding.CornerRounding(0.439, 1.000)),
new PointNRound(new Offset.Offset(0.998, 0.478), new CornerRounding.CornerRounding(0.174)),
// mirrored points
new PointNRound(new Offset.Offset(0.998, 0.522), new CornerRounding.CornerRounding(0.174)),
new PointNRound(new Offset.Offset(0.949, 0.551), new CornerRounding.CornerRounding(0.439, 1.000)),
new PointNRound(new Offset.Offset(0.839, 0.563), new CornerRounding.CornerRounding(0.532)),
new PointNRound(new Offset.Offset(0.733, 0.546)),
], 16)
.transformed((x, y) => rotate45.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotateNeg16th.map(new Offset.Offset(x, y)))
.normalized();
}
function flower() {
return customPolygon([
new PointNRound(new Offset.Offset(0.370, 0.187)),
new PointNRound(new Offset.Offset(0.416, 0.049), new CornerRounding.CornerRounding(0.381)),
new PointNRound(new Offset.Offset(0.479, 0.001), new CornerRounding.CornerRounding(0.095)),
// mirrored points
new PointNRound(new Offset.Offset(0.521, 0.001), new CornerRounding.CornerRounding(0.095)),
new PointNRound(new Offset.Offset(0.584, 0.049), new CornerRounding.CornerRounding(0.381)),
new PointNRound(new Offset.Offset(0.630, 0.187)),
], 8)
.transformed((x, y) => rotate135.map(new Offset.Offset(x, y)))
.normalized();
}
function puffy() {
const m = new Matrix.Matrix();
m.scale(1, 0.742);
const shape = customPolygon([
// mirrored points
new PointNRound(new Offset.Offset(1.003, 0.563), new CornerRounding.CornerRounding(0.255)),
new PointNRound(new Offset.Offset(0.940, 0.656), new CornerRounding.CornerRounding(0.126)),
new PointNRound(new Offset.Offset(0.881, 0.654)),
new PointNRound(new Offset.Offset(0.926, 0.711), new CornerRounding.CornerRounding(0.660)),
new PointNRound(new Offset.Offset(0.914, 0.851), new CornerRounding.CornerRounding(0.660)),
new PointNRound(new Offset.Offset(0.777, 0.998), new CornerRounding.CornerRounding(0.360)),
new PointNRound(new Offset.Offset(0.722, 0.872)),
new PointNRound(new Offset.Offset(0.717, 0.934), new CornerRounding.CornerRounding(0.574)),
new PointNRound(new Offset.Offset(0.670, 1.035), new CornerRounding.CornerRounding(0.426)),
new PointNRound(new Offset.Offset(0.545, 1.040), new CornerRounding.CornerRounding(0.405)),
new PointNRound(new Offset.Offset(0.500, 0.947)),
// original points
new PointNRound(new Offset.Offset(0.500, 1-0.053)),
new PointNRound(new Offset.Offset(1-0.545, 1+0.040), new CornerRounding.CornerRounding(0.405)),
new PointNRound(new Offset.Offset(1-0.670, 1+0.035), new CornerRounding.CornerRounding(0.426)),
new PointNRound(new Offset.Offset(1-0.717, 1-0.066), new CornerRounding.CornerRounding(0.574)),
new PointNRound(new Offset.Offset(1-0.722, 1-0.128)),
new PointNRound(new Offset.Offset(1-0.777, 1-0.002), new CornerRounding.CornerRounding(0.360)),
new PointNRound(new Offset.Offset(1-0.914, 1-0.149), new CornerRounding.CornerRounding(0.660)),
new PointNRound(new Offset.Offset(1-0.926, 1-0.289), new CornerRounding.CornerRounding(0.660)),
new PointNRound(new Offset.Offset(1-0.881, 1-0.346)),
new PointNRound(new Offset.Offset(1-0.940, 1-0.344), new CornerRounding.CornerRounding(0.126)),
new PointNRound(new Offset.Offset(1-1.003, 1-0.437), new CornerRounding.CornerRounding(0.255)),
], 2);
return shape.transformed((x, y) => m.map(new Offset.Offset(x, y))).normalized();
}
function puffyDiamond() {
return customPolygon([
// original points
new PointNRound(new Offset.Offset(0.870, 0.130), new CornerRounding.CornerRounding(0.146)),
new PointNRound(new Offset.Offset(0.818, 0.357)),
new PointNRound(new Offset.Offset(1.000, 0.332), new CornerRounding.CornerRounding(0.853)),
// mirrored points
new PointNRound(new Offset.Offset(1.000, 1-0.332), new CornerRounding.CornerRounding(0.853)),
new PointNRound(new Offset.Offset(0.818, 1-0.357)),
], 4)
.transformed((x, y) => rotate90.map(new Offset.Offset(x, y)))
.normalized();
}
function pixelCircle() {
return customPolygon([
new PointNRound(new Offset.Offset(1.000, 0.704)),
new PointNRound(new Offset.Offset(0.926, 0.704)),
new PointNRound(new Offset.Offset(0.926, 0.852)),
new PointNRound(new Offset.Offset(0.843, 0.852)),
new PointNRound(new Offset.Offset(0.843, 0.935)),
new PointNRound(new Offset.Offset(0.704, 0.935)),
new PointNRound(new Offset.Offset(0.704, 1.000)),
new PointNRound(new Offset.Offset(0.500, 1.000)),
new PointNRound(new Offset.Offset(1-0.704, 1.000)),
new PointNRound(new Offset.Offset(1-0.704, 0.935)),
new PointNRound(new Offset.Offset(1-0.843, 0.935)),
new PointNRound(new Offset.Offset(1-0.843, 0.852)),
new PointNRound(new Offset.Offset(1-0.926, 0.852)),
new PointNRound(new Offset.Offset(1-0.926, 0.704)),
new PointNRound(new Offset.Offset(1-1.000, 0.704)),
], 2)
.normalized();
}
function pixelTriangle() {
return customPolygon([
// mirrored points
new PointNRound(new Offset.Offset(0.888, 1-0.439)),
new PointNRound(new Offset.Offset(0.789, 1-0.439)),
new PointNRound(new Offset.Offset(0.789, 1-0.344)),
new PointNRound(new Offset.Offset(0.675, 1-0.344)),
new PointNRound(new Offset.Offset(0.674, 1-0.265)),
new PointNRound(new Offset.Offset(0.560, 1-0.265)),
new PointNRound(new Offset.Offset(0.560, 1-0.170)),
new PointNRound(new Offset.Offset(0.421, 1-0.170)),
new PointNRound(new Offset.Offset(0.421, 1-0.087)),
new PointNRound(new Offset.Offset(0.287, 1-0.087)),
new PointNRound(new Offset.Offset(0.287, 1-0.000)),
new PointNRound(new Offset.Offset(0.113, 1-0.000)),
// original points
new PointNRound(new Offset.Offset(0.110, 0.500)),
new PointNRound(new Offset.Offset(0.113, 0.000)),
new PointNRound(new Offset.Offset(0.287, 0.000)),
new PointNRound(new Offset.Offset(0.287, 0.087)),
new PointNRound(new Offset.Offset(0.421, 0.087)),
new PointNRound(new Offset.Offset(0.421, 0.170)),
new PointNRound(new Offset.Offset(0.560, 0.170)),
new PointNRound(new Offset.Offset(0.560, 0.265)),
new PointNRound(new Offset.Offset(0.674, 0.265)),
new PointNRound(new Offset.Offset(0.675, 0.344)),
new PointNRound(new Offset.Offset(0.789, 0.344)),
new PointNRound(new Offset.Offset(0.789, 0.439)),
new PointNRound(new Offset.Offset(0.888, 0.439)),
], 1).normalized();
}
function bun() {
return customPolygon([
// original points
new PointNRound(new Offset.Offset(0.796, 0.500)),
new PointNRound(new Offset.Offset(0.853, 0.518), cornerRound100),
new PointNRound(new Offset.Offset(0.992, 0.631), cornerRound100),
new PointNRound(new Offset.Offset(0.968, 1.000), cornerRound100),
// mirrored points
new PointNRound(new Offset.Offset(0.032, 1-0.000), cornerRound100),
new PointNRound(new Offset.Offset(0.008, 1-0.369), cornerRound100),
new PointNRound(new Offset.Offset(0.147, 1-0.482), cornerRound100),
new PointNRound(new Offset.Offset(0.204, 1-0.500)),
], 2).normalized();
}
function heart() {
return customPolygon([
new PointNRound(new Offset.Offset(0.782, 0.611)),
new PointNRound(new Offset.Offset(0.499, 0.946), new CornerRounding.CornerRounding(0.000)),
new PointNRound(new Offset.Offset(0.2175, 0.611)),
new PointNRound(new Offset.Offset(-0.064, 0.276), new CornerRounding.CornerRounding(1.000)),
new PointNRound(new Offset.Offset(0.208, -0.066), new CornerRounding.CornerRounding(0.958)),
new PointNRound(new Offset.Offset(0.500, 0.268), new CornerRounding.CornerRounding(0.016)),
new PointNRound(new Offset.Offset(0.792, -0.066), new CornerRounding.CornerRounding(0.958)),
new PointNRound(new Offset.Offset(1.064, 0.276), new CornerRounding.CornerRounding(1.000)),
], 1)
.normalized();
}
class PointNRound {
constructor(o, r = CornerRounding.Unrounded) {
this.o = o;
this.r = r;
}
}
function doRepeat(points, reps, center, mirroring) {
if (mirroring) {
const result = [];
const angles = points.map(p => p.o.minus(center).angleDegrees());
const distances = points.map(p => p.o.minus(center).getDistance());
const actualReps = reps * 2;
const sectionAngle = 360 / actualReps;
for (let it = 0; it < actualReps; it++) {
for (let index = 0; index < points.length; index++) {
const i = (it % 2 === 0) ? index : points.length - 1 - index;
if (i > 0 || it % 2 === 0) {
const baseAngle = angles[i];
const angle = it * sectionAngle + (it % 2 === 0 ? baseAngle : (2 * angles[0] - baseAngle));
const dist = distances[i];
const rad = angle * Math.PI / 180;
const x = center.x + dist * Math.cos(rad);
const y = center.y + dist * Math.sin(rad);
result.push(new PointNRound(new Offset.Offset(x, y), points[i].r));
}
}
}
return result;
} else {
const np = points.length;
const result = [];
for (let i = 0; i < np * reps; i++) {
const point = points[i % np].o.rotateDegrees(Math.floor(i / np) * 360 / reps, center);
result.push(new PointNRound(point, points[i % np].r));
}
return result;
}
}
function customPolygon(pnr, reps = 1, center = new Offset.Offset(0.5, 0.5), mirroring = false) {
const actualPoints = doRepeat(pnr, reps, center, mirroring);
const vertices = [];
for (const p of actualPoints) {
vertices.push(p.o.x);
vertices.push(p.o.y);
}
const perVertexRounding = actualPoints.map(p => p.r);
return RoundedPolygon.RoundedPolygon.fromVertices(vertices, CornerRounding.Unrounded, perVertexRounding, center.x, center.y);
}

View File

@@ -0,0 +1,18 @@
.pragma library
/**
* Represents corner rounding configuration
*/
class CornerRounding {
/**
* @param {float} [radius=0]
* @param {float} [smoothing=0]
*/
constructor(radius = 0, smoothing = 0) {
this.radius = radius;
this.smoothing = smoothing;
}
}
// Static property
CornerRounding.Unrounded = new CornerRounding();

View File

@@ -0,0 +1,371 @@
.pragma library
.import "point.js" as PointModule
.import "utils.js" as UtilsModule
var Point = PointModule.Point;
var DistanceEpsilon = UtilsModule.DistanceEpsilon;
var interpolate = UtilsModule.interpolate;
var directionVector = UtilsModule.directionVector;
var distance = UtilsModule.distance;
/**
* Represents a cubic Bézier curve with anchor and control points
*/
class Cubic {
/**
* @param {Array<float>} points Array of 8 numbers [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]
*/
constructor(points) {
this.points = points;
}
get anchor0X() { return this.points[0]; }
get anchor0Y() { return this.points[1]; }
get control0X() { return this.points[2]; }
get control0Y() { return this.points[3]; }
get control1X() { return this.points[4]; }
get control1Y() { return this.points[5]; }
get anchor1X() { return this.points[6]; }
get anchor1Y() { return this.points[7]; }
/**
* @param {Point} anchor0
* @param {Point} control0
* @param {Point} control1
* @param {Point} anchor1
* @returns {Cubic}
*/
static create(anchor0, control0, control1, anchor1) {
return new Cubic([
anchor0.x, anchor0.y,
control0.x, control0.y,
control1.x, control1.y,
anchor1.x, anchor1.y
]);
}
/**
* @param {float} t
* @returns {Point}
*/
pointOnCurve(t) {
const u = 1 - t;
return new Point(
this.anchor0X * (u * u * u) +
this.control0X * (3 * t * u * u) +
this.control1X * (3 * t * t * u) +
this.anchor1X * (t * t * t),
this.anchor0Y * (u * u * u) +
this.control0Y * (3 * t * u * u) +
this.control1Y * (3 * t * t * u) +
this.anchor1Y * (t * t * t)
);
}
/**
* @returns {boolean}
*/
zeroLength() {
return Math.abs(this.anchor0X - this.anchor1X) < DistanceEpsilon &&
Math.abs(this.anchor0Y - this.anchor1Y) < DistanceEpsilon;
}
/**
* @param {Cubic} next
* @returns {boolean}
*/
convexTo(next) {
const prevVertex = new Point(this.anchor0X, this.anchor0Y);
const currVertex = new Point(this.anchor1X, this.anchor1Y);
const nextVertex = new Point(next.anchor1X, next.anchor1Y);
return convex(prevVertex, currVertex, nextVertex);
}
/**
* @param {float} value
* @returns {boolean}
*/
zeroIsh(value) {
return Math.abs(value) < DistanceEpsilon;
}
/**
* @param {Array<float>} bounds
* @param {boolean} [approximate=false]
*/
calculateBounds(bounds, approximate = false) {
if (this.zeroLength()) {
bounds[0] = this.anchor0X;
bounds[1] = this.anchor0Y;
bounds[2] = this.anchor0X;
bounds[3] = this.anchor0Y;
return;
}
let minX = Math.min(this.anchor0X, this.anchor1X);
let minY = Math.min(this.anchor0Y, this.anchor1Y);
let maxX = Math.max(this.anchor0X, this.anchor1X);
let maxY = Math.max(this.anchor0Y, this.anchor1Y);
if (approximate) {
bounds[0] = Math.min(minX, Math.min(this.control0X, this.control1X));
bounds[1] = Math.min(minY, Math.min(this.control0Y, this.control1Y));
bounds[2] = Math.max(maxX, Math.max(this.control0X, this.control1X));
bounds[3] = Math.max(maxY, Math.max(this.control0Y, this.control1Y));
return;
}
// Find extrema using derivatives
const xa = -this.anchor0X + 3 * this.control0X - 3 * this.control1X + this.anchor1X;
const xb = 2 * this.anchor0X - 4 * this.control0X + 2 * this.control1X;
const xc = -this.anchor0X + this.control0X;
if (this.zeroIsh(xa)) {
if (xb != 0) {
const t = 2 * xc / (-2 * xb);
if (t >= 0 && t <= 1) {
const it = this.pointOnCurve(t).x;
if (it < minX) minX = it;
if (it > maxX) maxX = it;
}
}
} else {
const xs = xb * xb - 4 * xa * xc;
if (xs >= 0) {
const t1 = (-xb + Math.sqrt(xs)) / (2 * xa);
if (t1 >= 0 && t1 <= 1) {
const it = this.pointOnCurve(t1).x;
if (it < minX) minX = it;
if (it > maxX) maxX = it;
}
const t2 = (-xb - Math.sqrt(xs)) / (2 * xa);
if (t2 >= 0 && t2 <= 1) {
const it = this.pointOnCurve(t2).x;
if (it < minX) minX = it;
if (it > maxX) maxX = it;
}
}
}
// Repeat for y coord
const ya = -this.anchor0Y + 3 * this.control0Y - 3 * this.control1Y + this.anchor1Y;
const yb = 2 * this.anchor0Y - 4 * this.control0Y + 2 * this.control1Y;
const yc = -this.anchor0Y + this.control0Y;
if (this.zeroIsh(ya)) {
if (yb != 0) {
const t = 2 * yc / (-2 * yb);
if (t >= 0 && t <= 1) {
const it = this.pointOnCurve(t).y;
if (it < minY) minY = it;
if (it > maxY) maxY = it;
}
}
} else {
const ys = yb * yb - 4 * ya * yc;
if (ys >= 0) {
const t1 = (-yb + Math.sqrt(ys)) / (2 * ya);
if (t1 >= 0 && t1 <= 1) {
const it = this.pointOnCurve(t1).y;
if (it < minY) minY = it;
if (it > maxY) maxY = it;
}
const t2 = (-yb - Math.sqrt(ys)) / (2 * ya);
if (t2 >= 0 && t2 <= 1) {
const it = this.pointOnCurve(t2).y;
if (it < minY) minY = it;
if (it > maxY) maxY = it;
}
}
}
bounds[0] = minX;
bounds[1] = minY;
bounds[2] = maxX;
bounds[3] = maxY;
}
/**
* @param {float} t
* @returns {{a: Cubic, b: Cubic}}
*/
split(t) {
const u = 1 - t;
const pointOnCurve = this.pointOnCurve(t);
return {
a: new Cubic([
this.anchor0X,
this.anchor0Y,
this.anchor0X * u + this.control0X * t,
this.anchor0Y * u + this.control0Y * t,
this.anchor0X * (u * u) + this.control0X * (2 * u * t) + this.control1X * (t * t),
this.anchor0Y * (u * u) + this.control0Y * (2 * u * t) + this.control1Y * (t * t),
pointOnCurve.x,
pointOnCurve.y
]),
b: new Cubic([
pointOnCurve.x,
pointOnCurve.y,
this.control0X * (u * u) + this.control1X * (2 * u * t) + this.anchor1X * (t * t),
this.control0Y * (u * u) + this.control1Y * (2 * u * t) + this.anchor1Y * (t * t),
this.control1X * u + this.anchor1X * t,
this.control1Y * u + this.anchor1Y * t,
this.anchor1X,
this.anchor1Y
])
};
}
/**
* @returns {Cubic}
*/
reverse() {
return new Cubic([
this.anchor1X, this.anchor1Y,
this.control1X, this.control1Y,
this.control0X, this.control0Y,
this.anchor0X, this.anchor0Y
]);
}
/**
* @param {Cubic} other
* @returns {Cubic}
*/
plus(other) {
return new Cubic(other.points.map((_, index) => this.points[index] + other.points[index]));
}
/**
* @param {float} x
* @returns {Cubic}
*/
times(x) {
return new Cubic(this.points.map(v => v * x));
}
/**
* @param {float} x
* @returns {Cubic}
*/
div(x) {
return this.times(1 / x);
}
/**
* @param {Cubic} other
* @returns {boolean}
*/
equals(other) {
return this.points.every((p, i) => other.points[i] === p);
}
/**
* @param {function(float, float): Point} f
* @returns {Cubic}
*/
transformed(f) {
const newCubic = new MutableCubic([...this.points]);
newCubic.transform(f);
return newCubic;
}
/**
* @param {float} x0
* @param {float} y0
* @param {float} x1
* @param {float} y1
* @returns {Cubic}
*/
static straightLine(x0, y0, x1, y1) {
return new Cubic([
x0,
y0,
interpolate(x0, x1, 1/3),
interpolate(y0, y1, 1/3),
interpolate(x0, x1, 2/3),
interpolate(y0, y1, 2/3),
x1,
y1
]);
}
/**
* @param {float} centerX
* @param {float} centerY
* @param {float} x0
* @param {float} y0
* @param {float} x1
* @param {float} y1
* @returns {Cubic}
*/
static circularArc(centerX, centerY, x0, y0, x1, y1) {
const p0d = directionVector(x0 - centerX, y0 - centerY);
const p1d = directionVector(x1 - centerX, y1 - centerY);
const rotatedP0 = p0d.rotate90();
const rotatedP1 = p1d.rotate90();
const clockwise = rotatedP0.dotProductScalar(x1 - centerX, y1 - centerY) >= 0;
const cosa = p0d.dotProduct(p1d);
if (cosa > 0.999) {
return Cubic.straightLine(x0, y0, x1, y1);
}
const k = distance(x0 - centerX, y0 - centerY) * 4/3 *
(Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa)) /
(1 - cosa) * (clockwise ? 1 : -1);
return new Cubic([
x0, y0,
x0 + rotatedP0.x * k,
y0 + rotatedP0.y * k,
x1 - rotatedP1.x * k,
y1 - rotatedP1.y * k,
x1, y1
]);
}
/**
* @param {float} x0
* @param {float} y0
* @returns {Cubic}
*/
static empty(x0, y0) {
return new Cubic([x0, y0, x0, y0, x0, y0, x0, y0]);
}
}
class MutableCubic extends Cubic {
/**
* @param {function(float, float): Point} f
*/
transform(f) {
this.transformOnePoint(f, 0);
this.transformOnePoint(f, 2);
this.transformOnePoint(f, 4);
this.transformOnePoint(f, 6);
}
/**
* @param {Cubic} c1
* @param {Cubic} c2
* @param {float} progress
*/
interpolate(c1, c2, progress) {
for (let i = 0; i < 8; i++) {
this.points[i] = interpolate(c1.points[i], c2.points[i], progress);
}
}
/**
* @private
* @param {function(float, float): Point} f
* @param {number} ix
*/
transformOnePoint(f, ix) {
const result = f(this.points[ix], this.points[ix + 1]);
this.points[ix] = result.x;
this.points[ix + 1] = result.y;
}
}

View File

@@ -0,0 +1,166 @@
.pragma library
.import "feature.js" as FeatureModule
.import "float-mapping.js" as MappingModule
.import "point.js" as PointModule
.import "utils.js" as UtilsModule
var Feature = FeatureModule.Feature;
var Corner = FeatureModule.Corner;
var Point = PointModule.Point;
var DoubleMapper = MappingModule.DoubleMapper;
var progressInRange = MappingModule.progressInRange;
var DistanceEpsilon = UtilsModule.DistanceEpsilon;
var IdentityMapping = [{ a: 0, b: 0 }, { a: 0.5, b: 0.5 }];
class ProgressableFeature {
/**
* @param {float} progress
* @param {Feature} feature
*/
constructor(progress, feature) {
this.progress = progress;
this.feature = feature;
}
}
class DistanceVertex {
/**
* @param {float} distance
* @param {ProgressableFeature} f1
* @param {ProgressableFeature} f2
*/
constructor(distance, f1, f2) {
this.distance = distance;
this.f1 = f1;
this.f2 = f2;
}
}
class MappingHelper {
constructor() {
this.mapping = [];
this.usedF1 = new Set();
this.usedF2 = new Set();
}
/**
* @param {ProgressableFeature} f1
* @param {ProgressableFeature} f2
*/
addMapping(f1, f2) {
if (this.usedF1.has(f1) || this.usedF2.has(f2)) {
return;
}
const index = this.mapping.findIndex(x => x.a === f1.progress);
const insertionIndex = -index - 1;
const n = this.mapping.length;
if (n >= 1) {
const { a: before1, b: before2 } = this.mapping[(insertionIndex + n - 1) % n];
const { a: after1, b: after2 } = this.mapping[insertionIndex % n];
if (
progressDistance(f1.progress, before1) < DistanceEpsilon ||
progressDistance(f1.progress, after1) < DistanceEpsilon ||
progressDistance(f2.progress, before2) < DistanceEpsilon ||
progressDistance(f2.progress, after2) < DistanceEpsilon
) {
return;
}
if (n > 1 && !progressInRange(f2.progress, before2, after2)) {
return;
}
}
this.mapping.splice(insertionIndex, 0, { a: f1.progress, b: f2.progress });
this.usedF1.add(f1);
this.usedF2.add(f2);
}
}
/**
* @param {Array<ProgressableFeature>} features1
* @param {Array<ProgressableFeature>} features2
* @returns {DoubleMapper}
*/
function featureMapper(features1, features2) {
const filteredFeatures1 = features1.filter(f => f.feature instanceof Corner);
const filteredFeatures2 = features2.filter(f => f.feature instanceof Corner);
const featureProgressMapping = doMapping(filteredFeatures1, filteredFeatures2);
return new DoubleMapper(...featureProgressMapping);
}
/**
* @param {Array<ProgressableFeature>} features1
* @param {Array<ProgressableFeature>} features2
* @returns {Array<{a: float, b: float}>}
*/
function doMapping(features1, features2) {
const distanceVertexList = [];
for (const f1 of features1) {
for (const f2 of features2) {
const d = featureDistSquared(f1.feature, f2.feature);
if (d !== Number.MAX_VALUE) {
distanceVertexList.push(new DistanceVertex(d, f1, f2));
}
}
}
distanceVertexList.sort((a, b) => a.distance - b.distance);
// Special cases
if (distanceVertexList.length === 0) {
return IdentityMapping;
} else if (distanceVertexList.length === 1) {
const { f1, f2 } = distanceVertexList[0];
const p1 = f1.progress;
const p2 = f2.progress;
return [
{ a: p1, b: p2 },
{ a: (p1 + 0.5) % 1, b: (p2 + 0.5) % 1 }
];
}
const helper = new MappingHelper();
distanceVertexList.forEach(({ f1, f2 }) => helper.addMapping(f1, f2));
return helper.mapping;
}
/**
* @param {Feature} f1
* @param {Feature} f2
* @returns {float}
*/
function featureDistSquared(f1, f2) {
if (f1 instanceof Corner && f2 instanceof Corner && f1.convex != f2.convex) {
return Number.MAX_VALUE;
}
return featureRepresentativePoint(f1).minus(featureRepresentativePoint(f2)).getDistanceSquared();
}
/**
* @param {Feature} feature
* @returns {Point}
*/
function featureRepresentativePoint(feature) {
const firstCubic = feature.cubics[0];
const lastCubic = feature.cubics[feature.cubics.length - 1];
const x = (firstCubic.anchor0X + lastCubic.anchor1X) / 2;
const y = (firstCubic.anchor0Y + lastCubic.anchor1Y) / 2;
return new Point(x, y);
}
/**
* @param {float} p1
* @param {float} p2
* @returns {float}
*/
function progressDistance(p1, p2) {
const it = Math.abs(p1 - p2);
return Math.min(it, 1 - it);
}

View File

@@ -0,0 +1,103 @@
.pragma library
.import "cubic.js" as CubicModule
var Cubic = CubicModule.Cubic;
/**
* Base class for shape features (edges and corners)
*/
class Feature {
/**
* @param {Array<Cubic>} cubics
*/
constructor(cubics) {
this.cubics = cubics;
}
/**
* @param {Array<Cubic>} cubics
* @returns {Edge}
*/
buildIgnorableFeature(cubics) {
return new Edge(cubics);
}
/**
* @param {Cubic} cubic
* @returns {Edge}
*/
buildEdge(cubic) {
return new Edge([cubic]);
}
/**
* @param {Array<Cubic>} cubics
* @returns {Corner}
*/
buildConvexCorner(cubics) {
return new Corner(cubics, true);
}
/**
* @param {Array<Cubic>} cubics
* @returns {Corner}
*/
buildConcaveCorner(cubics) {
return new Corner(cubics, false);
}
}
class Edge extends Feature {
constructor(cubics) {
super(cubics);
this.isIgnorableFeature = true;
this.isEdge = true;
this.isConvexCorner = false;
this.isConcaveCorner = false;
}
/**
* @param {function(float, float): Point} f
* @returns {Feature}
*/
transformed(f) {
return new Edge(this.cubics.map(c => c.transformed(f)));
}
/**
* @returns {Feature}
*/
reversed() {
return new Edge(this.cubics.map(c => c.reverse()));
}
}
class Corner extends Feature {
/**
* @param {Array<Cubic>} cubics
* @param {boolean} convex
*/
constructor(cubics, convex) {
super(cubics);
this.convex = convex;
this.isIgnorableFeature = false;
this.isEdge = false;
this.isConvexCorner = convex;
this.isConcaveCorner = !convex;
}
/**
* @param {function(float, float): Point} f
* @returns {Feature}
*/
transformed(f) {
return new Corner(this.cubics.map(c => c.transformed(f)), this.convex);
}
/**
* @returns {Feature}
*/
reversed() {
return new Corner(this.cubics.map(c => c.reverse()), !this.convex);
}
}

View File

@@ -0,0 +1,86 @@
.pragma library
.import "utils.js" as UtilsModule
var positiveModulo = UtilsModule.positiveModulo;
/**
* Maps values between two ranges
*/
class DoubleMapper {
constructor(...mappings) {
this.sourceValues = [];
this.targetValues = [];
for (const mapping of mappings) {
this.sourceValues.push(mapping.a);
this.targetValues.push(mapping.b);
}
}
/**
* @param {float} x
* @returns {float}
*/
map(x) {
return linearMap(this.sourceValues, this.targetValues, x);
}
/**
* @param {float} x
* @returns {float}
*/
mapBack(x) {
return linearMap(this.targetValues, this.sourceValues, x);
}
}
// Static property
DoubleMapper.Identity = new DoubleMapper({ a: 0, b: 0 }, { a: 0.5, b: 0.5 });
/**
* @param {Array<float>} xValues
* @param {Array<float>} yValues
* @param {float} x
* @returns {float}
*/
function linearMap(xValues, yValues, x) {
let segmentStartIndex = -1;
for (let i = 0; i < xValues.length; i++) {
const nextIndex = (i + 1) % xValues.length;
if (progressInRange(x, xValues[i], xValues[nextIndex])) {
segmentStartIndex = i;
break;
}
}
if (segmentStartIndex === -1) {
throw new Error("No valid segment found");
}
const segmentEndIndex = (segmentStartIndex + 1) % xValues.length;
const segmentSizeX = positiveModulo(xValues[segmentEndIndex] - xValues[segmentStartIndex], 1);
const segmentSizeY = positiveModulo(yValues[segmentEndIndex] - yValues[segmentStartIndex], 1);
let positionInSegment;
if (segmentSizeX < 0.001) {
positionInSegment = 0.5;
} else {
positionInSegment = positiveModulo(x - xValues[segmentStartIndex], 1) / segmentSizeX;
}
return positiveModulo(yValues[segmentStartIndex] + segmentSizeY * positionInSegment, 1);
}
/**
* @param {float} progress
* @param {float} progressFrom
* @param {float} progressTo
* @returns {boolean}
*/
function progressInRange(progress, progressFrom, progressTo) {
if (progressTo >= progressFrom) {
return progress >= progressFrom && progress <= progressTo;
} else {
return progress >= progressFrom || progress <= progressTo;
}
}

View File

@@ -0,0 +1,94 @@
.pragma library
.import "rounded-polygon.js" as RoundedPolygon
.import "cubic.js" as Cubic
.import "polygon-measure.js" as PolygonMeasure
.import "feature-mapping.js" as FeatureMapping
.import "utils.js" as Utils
class Morph {
constructor(start, end) {
this.morphMatch = this.match(start, end)
}
asCubics(progress) {
const ret = []
// The first/last mechanism here ensures that the final anchor point in the shape
// exactly matches the first anchor point. There can be rendering artifacts introduced
// by those points being slightly off, even by much less than a pixel
let firstCubic = null
let lastCubic = null
for (let i = 0; i < this.morphMatch.length; i++) {
const cubic = new Cubic.Cubic(Array.from({ length: 8 }).map((_, it) => Utils.interpolate(
this.morphMatch[i].a.points[it],
this.morphMatch[i].b.points[it],
progress,
)))
if (firstCubic == null)
firstCubic = cubic
if (lastCubic != null)
ret.push(lastCubic)
lastCubic = cubic
}
if (lastCubic != null && firstCubic != null)
ret.push(
new Cubic.Cubic([
lastCubic.anchor0X,
lastCubic.anchor0Y,
lastCubic.control0X,
lastCubic.control0Y,
lastCubic.control1X,
lastCubic.control1Y,
firstCubic.anchor0X,
firstCubic.anchor0Y,
])
)
return ret
}
forEachCubic(progress, mutableCubic, callback) {
for (let i = 0; i < this.morphMatch.length; i++) {
mutableCubic.interpolate(this.morphMatch[i].a, this.morphMatch[i].b, progress)
callback(mutableCubic)
}
}
match(p1, p2) {
const measurer = new PolygonMeasure.LengthMeasurer()
const measuredPolygon1 = PolygonMeasure.MeasuredPolygon.measurePolygon(measurer, p1)
const measuredPolygon2 = PolygonMeasure.MeasuredPolygon.measurePolygon(measurer, p2)
const features1 = measuredPolygon1.features
const features2 = measuredPolygon2.features
const doubleMapper = FeatureMapping.featureMapper(features1, features2)
const polygon2CutPoint = doubleMapper.map(0)
const bs1 = measuredPolygon1
const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint)
const ret = []
let i1 = 0
let i2 = 0
let b1 = bs1.cubics[i1++]
let b2 = bs2.cubics[i2++]
while (b1 != null && b2 != null) {
const b1a = (i1 == bs1.cubics.length) ? 1 : b1.endOutlineProgress
const b2a = (i2 == bs2.cubics.length) ? 1 : doubleMapper.mapBack(Utils.positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1))
const minb = Math.min(b1a, b2a)
const { a: seg1, b: newb1 } = b1a > minb + Utils.AngleEpsilon ? b1.cutAtProgress(minb) : { a: b1, b: bs1.cubics[i1++] }
const { a: seg2, b: newb2 } = b2a > minb + Utils.AngleEpsilon ? b2.cutAtProgress(Utils.positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1)) : { a: b2, b: bs2.cubics[i2++] }
ret.push({ a: seg1.cubic, b: seg2.cubic })
b1 = newb1
b2 = newb2
}
return ret
}
}

View File

@@ -0,0 +1,154 @@
.pragma library
/**
* @param {number} x
* @param {number} y
* @returns {Point}
*/
function createPoint(x, y) {
return new Point(x, y);
}
class Point {
/**
* @param {float} x
* @param {float} y
*/
constructor(x, y) {
this.x = x;
this.y = y;
}
/**
* @param {float} x
* @param {float} y
* @returns {Point}
*/
copy(x = this.x, y = this.y) {
return new Point(x, y);
}
/**
* @returns {float}
*/
getDistance() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
/**
* @returns {float}
*/
getDistanceSquared() {
return this.x * this.x + this.y * this.y;
}
/**
* @param {Point} other
* @returns {float}
*/
dotProduct(other) {
return this.x * other.x + this.y * other.y;
}
/**
* @param {float} otherX
* @param {float} otherY
* @returns {float}
*/
dotProductScalar(otherX, otherY) {
return this.x * otherX + this.y * otherY;
}
/**
* @param {Point} other
* @returns {boolean}
*/
clockwise(other) {
return this.x * other.y - this.y * other.x > 0;
}
/**
* @returns {Point}
*/
getDirection() {
const d = this.getDistance();
return this.div(d);
}
/**
* @returns {Point}
*/
negate() {
return new Point(-this.x, -this.y);
}
/**
* @param {Point} other
* @returns {Point}
*/
minus(other) {
return new Point(this.x - other.x, this.y - other.y);
}
/**
* @param {Point} other
* @returns {Point}
*/
plus(other) {
return new Point(this.x + other.x, this.y + other.y);
}
/**
* @param {float} operand
* @returns {Point}
*/
times(operand) {
return new Point(this.x * operand, this.y * operand);
}
/**
* @param {float} operand
* @returns {Point}
*/
div(operand) {
return new Point(this.x / operand, this.y / operand);
}
/**
* @param {float} operand
* @returns {Point}
*/
rem(operand) {
return new Point(this.x % operand, this.y % operand);
}
/**
* @param {Point} start
* @param {Point} stop
* @param {float} fraction
* @returns {Point}
*/
static interpolate(start, stop, fraction) {
return new Point(
start.x + (stop.x - start.x) * fraction,
start.y + (stop.y - start.y) * fraction
);
}
/**
* @param {function(float, float): Point} f
* @returns {Point}
*/
transformed(f) {
const result = f(this.x, this.y);
return new Point(result.x, result.y);
}
/**
* @returns {Point}
*/
rotate90() {
return new Point(-this.y, this.x);
}
}

View File

@@ -0,0 +1,192 @@
.pragma library
.import "cubic.js" as Cubic
.import "point.js" as Point
.import "feature-mapping.js" as FeatureMapping
.import "utils.js" as Utils
.import "feature.js" as Feature
class MeasuredPolygon {
constructor(measurer, features, cubics, outlineProgress) {
this.measurer = measurer
this.features = features
this.outlineProgress = outlineProgress
this.cubics = []
const measuredCubics = []
let startOutlineProgress = 0
for(let i = 0; i < cubics.length; i++) {
if ((outlineProgress[i + 1] - outlineProgress[i]) > Utils.DistanceEpsilon) {
measuredCubics.push(
new MeasuredCubic(this, cubics[i], startOutlineProgress, outlineProgress[i + 1])
)
// The next measured cubic will start exactly where this one ends.
startOutlineProgress = outlineProgress[i + 1]
}
}
measuredCubics[measuredCubics.length - 1].updateProgressRange(measuredCubics[measuredCubics.length - 1].startOutlineProgress, 1)
this.cubics = measuredCubics
}
cutAndShift(cuttingPoint) {
if (cuttingPoint < Utils.DistanceEpsilon) return this
// Find the index of cubic we want to cut
const targetIndex = this.cubics.findIndex(it => cuttingPoint >= it.startOutlineProgress && cuttingPoint <= it.endOutlineProgress)
const target = this.cubics[targetIndex]
// Cut the target cubic.
// b1, b2 are two resulting cubics after cut
const { a: b1, b: b2 } = target.cutAtProgress(cuttingPoint)
// Construct the list of the cubics we need:
// * The second part of the target cubic (after the cut)
// * All cubics after the target, until the end + All cubics from the start, before the
// target cubic
// * The first part of the target cubic (before the cut)
const retCubics = [b2.cubic]
for(let i = 1; i < this.cubics.length; i++) {
retCubics.push(this.cubics[(i + targetIndex) % this.cubics.length].cubic)
}
retCubics.push(b1.cubic)
// Construct the array of outline progress.
// For example, if we have 3 cubics with outline progress [0 .. 0.3], [0.3 .. 0.8] &
// [0.8 .. 1.0], and we cut + shift at 0.6:
// 0. 0123456789
// |--|--/-|-|
// The outline progresses will start at 0 (the cutting point, that shifs to 0.0),
// then 0.8 - 0.6 = 0.2, then 1 - 0.6 = 0.4, then 0.3 - 0.6 + 1 = 0.7,
// then 1 (the cutting point again),
// all together: (0.0, 0.2, 0.4, 0.7, 1.0)
const retOutlineProgress = []
for (let i = 0; i < this.cubics.length + 2; i++) {
if (i === 0) {
retOutlineProgress.push(0)
} else if(i === this.cubics.length + 1) {
retOutlineProgress.push(1)
} else {
const cubicIndex = (targetIndex + i - 1) % this.cubics.length
retOutlineProgress.push(Utils.positiveModulo(this.cubics[cubicIndex].endOutlineProgress - cuttingPoint, 1))
}
}
// Shift the feature's outline progress too.
const newFeatures = []
for(let i = 0; i < this.features.length; i++) {
newFeatures.push(new FeatureMapping.ProgressableFeature(Utils.positiveModulo(this.features[i].progress - cuttingPoint, 1), this.features[i].feature))
}
// Filter out all empty cubics (i.e. start and end anchor are (almost) the same point.)
return new MeasuredPolygon(this.measurer, newFeatures, retCubics, retOutlineProgress)
}
static measurePolygon(measurer, polygon) {
const cubics = []
const featureToCubic = []
for (let featureIndex = 0; featureIndex < polygon.features.length; featureIndex++) {
const feature = polygon.features[featureIndex]
for (let cubicIndex = 0; cubicIndex < feature.cubics.length; cubicIndex++) {
if (feature instanceof Feature.Corner && cubicIndex == feature.cubics.length / 2) {
featureToCubic.push({ a: feature, b: cubics.length })
}
cubics.push(feature.cubics[cubicIndex])
}
}
const measures = [0] // Initialize with 0 like in Kotlin's scan
for (const cubic of cubics) {
const measurement = measurer.measureCubic(cubic)
if (measurement < 0) {
throw new Error("Measured cubic is expected to be greater or equal to zero")
}
const lastMeasure = measures[measures.length - 1]
measures.push(lastMeasure + measurement)
}
const totalMeasure = measures[measures.length - 1]
const outlineProgress = []
for (let i = 0; i < measures.length; i++) {
outlineProgress.push(measures[i] / totalMeasure)
}
const features = []
for (let i = 0; i < featureToCubic.length; i++) {
const ix = featureToCubic[i].b
features.push(
new FeatureMapping.ProgressableFeature(Utils.positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1), featureToCubic[i].a))
}
return new MeasuredPolygon(measurer, features, cubics, outlineProgress)
}
}
class MeasuredCubic {
constructor(polygon, cubic, startOutlineProgress, endOutlineProgress) {
this.polygon = polygon
this.cubic = cubic
this.startOutlineProgress = startOutlineProgress
this.endOutlineProgress = endOutlineProgress
this.measuredSize = this.polygon.measurer.measureCubic(cubic)
}
updateProgressRange(
startOutlineProgress = this.startOutlineProgress,
endOutlineProgress = this.endOutlineProgress,
) {
this.startOutlineProgress = startOutlineProgress
this.endOutlineProgress = endOutlineProgress
}
cutAtProgress(cutOutlineProgress) {
const boundedCutOutlineProgress = Utils.coerceIn(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress)
const outlineProgressSize = this.endOutlineProgress - this.startOutlineProgress
const progressFromStart = boundedCutOutlineProgress - this.startOutlineProgress
const relativeProgress = progressFromStart / outlineProgressSize
const t = this.polygon.measurer.findCubicCutPoint(this.cubic, relativeProgress * this.measuredSize)
const {a: c1, b: c2} = this.cubic.split(t)
return {
a: new MeasuredCubic(this.polygon, c1, this.startOutlineProgress, boundedCutOutlineProgress),
b: new MeasuredCubic(this.polygon, c2, boundedCutOutlineProgress, this.endOutlineProgress)
}
}
}
class LengthMeasurer {
constructor() {
this.segments = 3
}
measureCubic(c) {
return this.closestProgressTo(c, Number.POSITIVE_INFINITY).y
}
findCubicCutPoint(c, m) {
return this.closestProgressTo(c, m).x
}
closestProgressTo(cubic, threshold) {
let total = 0
let remainder = threshold
let prev = new Point.Point(cubic.anchor0X, cubic.anchor0Y)
for (let i = 1; i < this.segments; i++) {
const progress = i / this.segments
const point = cubic.pointOnCurve(progress)
const segment = point.minus(prev).getDistance()
if (segment >= remainder) {
return new Point.Point(progress - (1.0 - remainder / segment) / this.segments, threshold)
}
remainder -= segment
total += segment
prev = point
}
return new Point.Point(1.0, total)
}
}

View File

@@ -0,0 +1,229 @@
.pragma library
.import "point.js" as PointModule
.import "corner-rounding.js" as RoundingModule
.import "utils.js" as UtilsModule
.import "cubic.js" as CubicModule
var Point = PointModule.Point;
var CornerRounding = RoundingModule.CornerRounding;
var DistanceEpsilon = UtilsModule.DistanceEpsilon;
var directionVector = UtilsModule.directionVector;
var Cubic = CubicModule.Cubic;
class RoundedCorner {
/**
* @param {Point} p0
* @param {Point} p1
* @param {Point} p2
* @param {CornerRounding} [rounding=null]
*/
constructor(p0, p1, p2, rounding = null) {
this.p0 = p0;
this.p1 = p1;
this.p2 = p2;
this.rounding = rounding;
this.center = new Point(0, 0);
const v01 = p0.minus(p1);
const v21 = p2.minus(p1);
const d01 = v01.getDistance();
const d21 = v21.getDistance();
if (d01 > 0 && d21 > 0) {
this.d1 = v01.div(d01);
this.d2 = v21.div(d21);
this.cornerRadius = rounding?.radius ?? 0;
this.smoothing = rounding?.smoothing ?? 0;
// cosine of angle at p1 is dot product of unit vectors to the other two vertices
this.cosAngle = this.d1.dotProduct(this.d2);
// identity: sin^2 + cos^2 = 1
// sinAngle gives us the intersection
this.sinAngle = Math.sqrt(1 - Math.pow(this.cosAngle, 2));
// How much we need to cut, as measured on a side, to get the required radius
// calculating where the rounding circle hits the edge
// This uses the identity of tan(A/2) = sinA/(1 + cosA), where tan(A/2) = radius/cut
this.expectedRoundCut = this.sinAngle > 1e-3 ? this.cornerRadius * (this.cosAngle + 1) / this.sinAngle : 0;
} else {
// One (or both) of the sides is empty, not much we can do.
this.d1 = new Point(0, 0);
this.d2 = new Point(0, 0);
this.cornerRadius = 0;
this.smoothing = 0;
this.cosAngle = 0;
this.sinAngle = 0;
this.expectedRoundCut = 0;
}
}
get expectedCut() {
return ((1 + this.smoothing) * this.expectedRoundCut);
}
/**
* @param {float} allowedCut0
* @param {float} [allowedCut1]
* @returns {Array<Cubic>}
*/
getCubics(allowedCut0, allowedCut1 = allowedCut0) {
// We use the minimum of both cuts to determine the radius, but if there is more space
// in one side we can use it for smoothing.
const allowedCut = Math.min(allowedCut0, allowedCut1);
// Nothing to do, just use lines, or a point
if (
this.expectedRoundCut < DistanceEpsilon ||
allowedCut < DistanceEpsilon ||
this.cornerRadius < DistanceEpsilon
) {
this.center = this.p1;
return [Cubic.straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)];
}
// How much of the cut is required for the rounding part.
const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut);
// We have two smoothing values, one for each side of the vertex
// Space is used for rounding values first. If there is space left over, then we
// apply smoothing, if it was requested
const actualSmoothing0 = this.calculateActualSmoothingValue(allowedCut0);
const actualSmoothing1 = this.calculateActualSmoothingValue(allowedCut1);
// Scale the radius if needed
const actualR = this.cornerRadius * actualRoundCut / this.expectedRoundCut;
// Distance from the corner (p1) to the center
const centerDistance = Math.sqrt(Math.pow(actualR, 2) + Math.pow(actualRoundCut, 2));
// Center of the arc we will use for rounding
this.center = this.p1.plus(this.d1.plus(this.d2).div(2).getDirection().times(centerDistance));
const circleIntersection0 = this.p1.plus(this.d1.times(actualRoundCut));
const circleIntersection2 = this.p1.plus(this.d2.times(actualRoundCut));
const flanking0 = this.computeFlankingCurve(
actualRoundCut,
actualSmoothing0,
this.p1,
this.p0,
circleIntersection0,
circleIntersection2,
this.center,
actualR
);
const flanking2 = this.computeFlankingCurve(
actualRoundCut,
actualSmoothing1,
this.p1,
this.p2,
circleIntersection2,
circleIntersection0,
this.center,
actualR
).reverse();
return [
flanking0,
Cubic.circularArc(
this.center.x,
this.center.y,
flanking0.anchor1X,
flanking0.anchor1Y,
flanking2.anchor0X,
flanking2.anchor0Y
),
flanking2
];
}
/**
* @private
* @param {float} allowedCut
* @returns {float}
*/
calculateActualSmoothingValue(allowedCut) {
if (allowedCut > this.expectedCut) {
return this.smoothing;
} else if (allowedCut > this.expectedRoundCut) {
return this.smoothing * (allowedCut - this.expectedRoundCut) / (this.expectedCut - this.expectedRoundCut);
} else {
return 0;
}
}
/**
* @private
* @param {float} actualRoundCut
* @param {float} actualSmoothingValues
* @param {Point} corner
* @param {Point} sideStart
* @param {Point} circleSegmentIntersection
* @param {Point} otherCircleSegmentIntersection
* @param {Point} circleCenter
* @param {float} actualR
* @returns {Cubic}
*/
computeFlankingCurve(
actualRoundCut,
actualSmoothingValues,
corner,
sideStart,
circleSegmentIntersection,
otherCircleSegmentIntersection,
circleCenter,
actualR
) {
// sideStart is the anchor, 'anchor' is actual control point
const sideDirection = (sideStart.minus(corner)).getDirection();
const curveStart = corner.plus(sideDirection.times(actualRoundCut * (1 + actualSmoothingValues)));
// We use an approximation to cut a part of the circle section proportional to 1 - smooth,
// When smooth = 0, we take the full section, when smooth = 1, we take nothing.
const p = Point.interpolate(
circleSegmentIntersection,
(circleSegmentIntersection.plus(otherCircleSegmentIntersection)).div(2),
actualSmoothingValues
);
// The flanking curve ends on the circle
const curveEnd = circleCenter.plus(
directionVector(p.x - circleCenter.x, p.y - circleCenter.y).times(actualR)
);
// The anchor on the circle segment side is in the intersection between the tangent to the
// circle in the circle/flanking curve boundary and the linear segment.
const circleTangent = (curveEnd.minus(circleCenter)).rotate90();
const anchorEnd = this.lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? circleSegmentIntersection;
// From what remains, we pick a point for the start anchor.
// 2/3 seems to come from design tools?
const anchorStart = (curveStart.plus(anchorEnd.times(2))).div(3);
return Cubic.create(curveStart, anchorStart, anchorEnd, curveEnd);
}
/**
* @private
* @param {Point} p0
* @param {Point} d0
* @param {Point} p1
* @param {Point} d1
* @returns {Point|null}
*/
lineIntersection(p0, d0, p1, d1) {
const rotatedD1 = d1.rotate90();
const den = d0.dotProduct(rotatedD1);
if (Math.abs(den) < DistanceEpsilon) return null;
const num = (p1.minus(p0)).dotProduct(rotatedD1);
// Also check the relative value. This is equivalent to abs(den/num) < DistanceEpsilon,
// but avoid doing a division
if (Math.abs(den) < DistanceEpsilon * Math.abs(num)) return null;
const k = num / den;
return p0.plus(d0.times(k));
}
}

View File

@@ -0,0 +1,343 @@
.pragma library
.import "feature.js" as Feature
.import "point.js" as Point
.import "cubic.js" as Cubic
.import "utils.js" as Utils
.import "corner-rounding.js" as CornerRounding
.import "rounded-corner.js" as RoundedCorner
class RoundedPolygon {
constructor(features, center) {
this.features = features
this.center = center
this.cubics = this.buildCubicList()
}
get centerX() {
return this.center.x
}
get centerY() {
return this.center.y
}
transformed(f) {
const center = this.center.transformed(f)
return new RoundedPolygon(this.features.map(x => x.transformed(f)), center)
}
normalized() {
const bounds = this.calculateBounds()
const width = bounds[2] - bounds[0]
const height = bounds[3] - bounds[1]
const side = Math.max(width, height)
// Center the shape if bounds are not a square
const offsetX = (side - width) / 2 - bounds[0] /* left */
const offsetY = (side - height) / 2 - bounds[1] /* top */
return this.transformed((x, y) => {
return new Point.Point((x + offsetX) / side, (y + offsetY) / side)
})
}
calculateMaxBounds(bounds = []) {
let maxDistSquared = 0
for (let i = 0; i < this.cubics.length; i++) {
const cubic = this.cubics[i]
const anchorDistance = Utils.distanceSquared(cubic.anchor0X - this.centerX, cubic.anchor0Y - this.centerY)
const middlePoint = cubic.pointOnCurve(.5)
const middleDistance = Utils.distanceSquared(middlePoint.x - this.centerX, middlePoint.y - this.centerY)
maxDistSquared = Math.max(maxDistSquared, Math.max(anchorDistance, middleDistance))
}
const distance = Math.sqrt(maxDistSquared)
bounds[0] = this.centerX - distance
bounds[1] = this.centerY - distance
bounds[2] = this.centerX + distance
bounds[3] = this.centerY + distance
return bounds
}
calculateBounds(bounds = [], approximate = true) {
let minX = Number.MAX_SAFE_INTEGER
let minY = Number.MAX_SAFE_INTEGER
let maxX = Number.MIN_SAFE_INTEGER
let maxY = Number.MIN_SAFE_INTEGER
for (let i = 0; i < this.cubics.length; i++) {
const cubic = this.cubics[i]
cubic.calculateBounds(bounds, approximate)
minX = Math.min(minX, bounds[0])
minY = Math.min(minY, bounds[1])
maxX = Math.max(maxX, bounds[2])
maxY = Math.max(maxY, bounds[3])
}
bounds[0] = minX
bounds[1] = minY
bounds[2] = maxX
bounds[3] = maxY
return bounds
}
buildCubicList() {
const result = []
// The first/last mechanism here ensures that the final anchor point in the shape
// exactly matches the first anchor point. There can be rendering artifacts introduced
// by those points being slightly off, even by much less than a pixel
let firstCubic = null
let lastCubic = null
let firstFeatureSplitStart = null
let firstFeatureSplitEnd = null
if (this.features.length > 0 && this.features[0].cubics.length == 3) {
const centerCubic = this.features[0].cubics[1]
const { a: start, b: end } = centerCubic.split(.5)
firstFeatureSplitStart = [this.features[0].cubics[0], start]
firstFeatureSplitEnd = [end, this.features[0].cubics[2]]
}
// iterating one past the features list size allows us to insert the initial split
// cubic if it exists
for (let i = 0; i <= this.features.length; i++) {
let featureCubics
if (i == 0 && firstFeatureSplitEnd != null) {
featureCubics = firstFeatureSplitEnd
} else if (i == this.features.length) {
if (firstFeatureSplitStart != null) {
featureCubics = firstFeatureSplitStart
} else {
break
}
} else {
featureCubics = this.features[i].cubics
}
for (let j = 0; j < featureCubics.length; j++) {
// Skip zero-length curves; they add nothing and can trigger rendering artifacts
const cubic = featureCubics[j]
if (!cubic.zeroLength()) {
if (lastCubic != null)
result.push(lastCubic)
lastCubic = cubic
if (firstCubic == null)
firstCubic = cubic
} else {
if (lastCubic != null) {
// Dropping several zero-ish length curves in a row can lead to
// enough discontinuity to throw an exception later, even though the
// distances are quite small. Account for that by making the last
// cubic use the latest anchor point, always.
lastCubic = new Cubic.Cubic([...lastCubic.points]) // Make a copy before mutating
lastCubic.points[6] = cubic.anchor1X
lastCubic.points[7] = cubic.anchor1Y
}
}
}
}
if (lastCubic != null && firstCubic != null) {
result.push(
new Cubic.Cubic([
lastCubic.anchor0X,
lastCubic.anchor0Y,
lastCubic.control0X,
lastCubic.control0Y,
lastCubic.control1X,
lastCubic.control1Y,
firstCubic.anchor0X,
firstCubic.anchor0Y,
])
)
} else {
// Empty / 0-sized polygon.
result.push(new Cubic.Cubic([this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY]))
}
return result
}
static calculateCenter(vertices) {
let cumulativeX = 0
let cumulativeY = 0
let index = 0
while (index < vertices.length) {
cumulativeX += vertices[index++]
cumulativeY += vertices[index++]
}
return new Point.Point(cumulativeX / (vertices.length / 2), cumulativeY / (vertices.length / 2))
}
static verticesFromNumVerts(numVertices, radius, centerX, centerY) {
const result = []
let arrayIndex = 0
for (let i = 0; i < numVertices; i++) {
const vertex = Utils.radialToCartesian(radius, (Math.PI / numVertices * 2 * i)).plus(new Point.Point(centerX, centerY))
result[arrayIndex++] = vertex.x
result[arrayIndex++] = vertex.y
}
return result
}
static fromNumVertices(numVertices, radius = 1, centerX = 0, centerY = 0, rounding = CornerRounding.Unrounded, perVertexRounding = null) {
return RoundedPolygon.fromVertices(this.verticesFromNumVerts(numVertices, radius, centerX, centerY), rounding, perVertexRounding, centerX, centerY)
}
static fromVertices(vertices, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = Number.MIN_SAFE_INTEGER, centerY = Number.MAX_SAFE_INTEGER) {
const corners = []
const n = vertices.length / 2
const roundedCorners = []
for (let i = 0; i < n; i++) {
const vtxRounding = perVertexRounding?.[i] ?? rounding
const prevIndex = ((i + n - 1) % n) * 2
const nextIndex = ((i + 1) % n) * 2
roundedCorners.push(
new RoundedCorner.RoundedCorner(
new Point.Point(vertices[prevIndex], vertices[prevIndex + 1]),
new Point.Point(vertices[i * 2], vertices[i * 2 + 1]),
new Point.Point(vertices[nextIndex], vertices[nextIndex + 1]),
vtxRounding
)
)
}
// For each side, check if we have enough space to do the cuts needed, and if not split
// the available space, first for round cuts, then for smoothing if there is space left.
// Each element in this list is a pair, that represent how much we can do of the cut for
// the given side (side i goes from corner i to corner i+1), the elements of the pair are:
// first is how much we can use of expectedRoundCut, second how much of expectedCut
const cutAdjusts = Array.from({ length: n }).map((_, ix) => {
const expectedRoundCut = roundedCorners[ix].expectedRoundCut + roundedCorners[(ix + 1) % n].expectedRoundCut
const expectedCut = roundedCorners[ix].expectedCut + roundedCorners[(ix + 1) % n].expectedCut
const vtxX = vertices[ix * 2]
const vtxY = vertices[ix * 2 + 1]
const nextVtxX = vertices[((ix + 1) % n) * 2]
const nextVtxY = vertices[((ix + 1) % n) * 2 + 1]
const sideSize = Utils.distance(vtxX - nextVtxX, vtxY - nextVtxY)
// Check expectedRoundCut first, and ensure we fulfill rounding needs first for
// both corners before using space for smoothing
if (expectedRoundCut > sideSize) {
// Not enough room for fully rounding, see how much we can actually do.
return { a: sideSize / expectedRoundCut, b: 0 }
} else if (expectedCut > sideSize) {
// We can do full rounding, but not full smoothing.
return { a: 1, b: (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut) }
} else {
// There is enough room for rounding & smoothing.
return { a: 1, b: 1 }
}
})
// Create and store list of beziers for each [potentially] rounded corner
for (let i = 0; i < n; i++) {
// allowedCuts[0] is for the side from the previous corner to this one,
// allowedCuts[1] is for the side from this corner to the next one.
const allowedCuts = []
for(const delta of [0, 1]) {
const { a: roundCutRatio, b: cutRatio } = cutAdjusts[(i + n - 1 + delta) % n]
allowedCuts.push(
roundedCorners[i].expectedRoundCut * roundCutRatio +
(roundedCorners[i].expectedCut - roundedCorners[i].expectedRoundCut) * cutRatio
)
}
corners.push(
roundedCorners[i].getCubics(allowedCuts[0], allowedCuts[1])
)
}
const tempFeatures = []
for (let i = 0; i < n; i++) {
// Note that these indices are for pairs of values (points), they need to be
// doubled to access the xy values in the vertices float array
const prevVtxIndex = (i + n - 1) % n
const nextVtxIndex = (i + 1) % n
const currVertex = new Point.Point(vertices[i * 2], vertices[i * 2 + 1])
const prevVertex = new Point.Point(vertices[prevVtxIndex * 2], vertices[prevVtxIndex * 2 + 1])
const nextVertex = new Point.Point(vertices[nextVtxIndex * 2], vertices[nextVtxIndex * 2 + 1])
const cnvx = Utils.convex(prevVertex, currVertex, nextVertex)
tempFeatures.push(new Feature.Corner(corners[i], cnvx))
tempFeatures.push(
new Feature.Edge([Cubic.Cubic.straightLine(
corners[i][corners[i].length - 1].anchor1X,
corners[i][corners[i].length - 1].anchor1Y,
corners[(i + 1) % n][0].anchor0X,
corners[(i + 1) % n][0].anchor0Y,
)])
)
}
let center
if (centerX == Number.MIN_SAFE_INTEGER || centerY == Number.MIN_SAFE_INTEGER) {
center = RoundedPolygon.calculateCenter(vertices)
} else {
center = new Point.Point(centerX, centerY)
}
return RoundedPolygon.fromFeatures(tempFeatures, center.x, center.y)
}
static fromFeatures(features, centerX, centerY) {
const vertices = []
for (const feature of features) {
for (const cubic of feature.cubics) {
vertices.push(cubic.anchor0X)
vertices.push(cubic.anchor0Y)
}
}
if (Number.isNaN(centerX)) {
centerX = this.calculateCenter(vertices).x
}
if (Number.isNaN(centerY)) {
centerY = this.calculateCenter(vertices).y
}
return new RoundedPolygon(features, new Point.Point(centerX, centerY))
}
static circle(numVertices = 8, radius = 1, centerX = 0, centerY = 0) {
// Half of the angle between two adjacent vertices on the polygon
const theta = Math.PI / numVertices
// Radius of the underlying RoundedPolygon object given the desired radius of the circle
const polygonRadius = radius / Math.cos(theta)
return RoundedPolygon.fromNumVertices(
numVertices,
polygonRadius,
centerX,
centerY,
new CornerRounding.CornerRounding(radius)
)
}
static rectangle(width, height, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = 0, centerY = 0) {
const left = centerX - width / 2
const top = centerY - height / 2
const right = centerX + width / 2
const bottom = centerY + height / 2
return RoundedPolygon.fromVertices([right, bottom, left, bottom, left, top, right, top], rounding, perVertexRounding, centerX, centerY)
}
static star(numVerticesPerRadius, radius = 1, innerRadius = .5, rounding = CornerRounding.Unrounded, innerRounding = null, perVertexRounding = null, centerX = 0, centerY = 0) {
let pvRounding = perVertexRounding
// If no per-vertex rounding supplied and caller asked for inner rounding,
// create per-vertex rounding list based on supplied outer/inner rounding parameters
if (pvRounding == null && innerRounding != null) {
pvRounding = Array.from({ length: numVerticesPerRadius * 2 }).flatMap(() => [rounding, innerRounding])
}
return RoundedPolygon.fromVertices(RoundedPolygon.starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY), rounding, perVertexRounding, centerX, centerY)
}
static starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY) {
const result = []
let arrayIndex = 0
for (let i = 0; i < numVerticesPerRadius; i++) {
let vertex = Utils.radialToCartesian(radius, (Math.PI / numVerticesPerRadius * 2 * i))
result[arrayIndex++] = vertex.x + centerX
result[arrayIndex++] = vertex.y + centerY
vertex = Utils.radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius * (2 * i + 1)))
result[arrayIndex++] = vertex.x + centerX
result[arrayIndex++] = vertex.y + centerY
}
return result
}
}

View File

@@ -0,0 +1,94 @@
.pragma library
.import "point.js" as PointModule
var Point = PointModule.Point;
var DistanceEpsilon = 1e-4;
var AngleEpsilon = 1e-6;
/**
* @param {Point} previous
* @param {Point} current
* @param {Point} next
* @returns {boolean}
*/
function convex(previous, current, next) {
return (current.minus(previous)).clockwise(next.minus(current));
}
/**
* @param {float} start
* @param {float} stop
* @param {float} fraction
* @returns {float}
*/
function interpolate(start, stop, fraction) {
return (1 - fraction) * start + fraction * stop;
}
/**
* @param {float} x
* @param {float} y
* @returns {Point}
*/
function directionVector(x, y) {
const d = distance(x, y);
return new Point(x / d, y / d);
}
/**
* @param {float} x
* @param {float} y
* @returns {float}
*/
function distance(x, y) {
return Math.sqrt(x * x + y * y);
}
/**
* @param {float} x
* @param {float} y
* @returns {float}
*/
function distanceSquared(x, y) {
return x * x + y * y;
}
/**
* @param {float} radius
* @param {float} angleRadians
* @param {Point} [center]
* @returns {Point}
*/
function radialToCartesian(radius, angleRadians, center = new Point(0, 0)) {
return new Point(Math.cos(angleRadians), Math.sin(angleRadians))
.times(radius)
.plus(center);
}
/**
* @param {float} value
* @param {float|object} min
* @param {float} [max]
* @returns {float}
*/
function coerceIn(value, min, max) {
if (max === undefined) {
if (typeof min === 'object' && 'start' in min && 'endInclusive' in min) {
return Math.max(min.start, Math.min(min.endInclusive, value));
}
throw new Error("Invalid arguments for coerceIn");
}
const [actualMin, actualMax] = min <= max ? [min, max] : [max, min];
return Math.max(actualMin, Math.min(actualMax, value));
}
/**
* @param {float} value
* @param {float} mod
* @returns {float}
*/
function positiveModulo(value, mod) {
return ((value % mod) + mod) % mod;
}

View File

@@ -0,0 +1,70 @@
pragma Singleton
import Quickshell
// From github.com/end-4/dots-hyprland with modifications
Singleton {
id: root
function colorWithHueOf(color1, color2) {
var c1 = Qt.color(color1);
var c2 = Qt.color(color2);
var hue = c2.hsvHue;
var sat = c1.hsvSaturation;
var val = c1.hsvValue;
var alpha = c1.a;
return Qt.hsva(hue, sat, val, alpha);
}
function colorWithSaturationOf(color1, color2) {
var c1 = Qt.color(color1);
var c2 = Qt.color(color2);
var hue = c1.hsvHue;
var sat = c2.hsvSaturation;
var val = c1.hsvValue;
var alpha = c1.a;
return Qt.hsva(hue, sat, val, alpha);
}
function colorWithLightness(color, lightness) {
var c = Qt.color(color);
return Qt.hsla(c.hslHue, c.hslSaturation, lightness, c.a);
}
function colorWithLightnessOf(color1, color2) {
var c2 = Qt.color(color2);
return colorWithLightness(color1, c2.hslLightness);
}
function adaptToAccent(color1, color2) {
var c1 = Qt.color(color1);
var c2 = Qt.color(color2);
var hue = c2.hslHue;
var sat = c2.hslSaturation;
var light = c1.hslLightness;
var alpha = c1.a;
return Qt.hsla(hue, sat, light, alpha);
}
function mix(color1, color2, percentage = 0.5) {
var c1 = Qt.color(color1);
var c2 = Qt.color(color2);
return Qt.rgba(
percentage * c1.r + (1 - percentage) * c2.r,
percentage * c1.g + (1 - percentage) * c2.g,
percentage * c1.b + (1 - percentage) * c2.b,
percentage * c1.a + (1 - percentage) * c2.a
);
}
function transparentize(color, percentage = 1) {
var c = Qt.color(color);
return Qt.rgba(c.r, c.g, c.b, c.a * (1 - percentage));
}
function applyAlpha(color, alpha) {
var c = Qt.color(color);
var a = Math.max(0, Math.min(1, alpha));
return Qt.rgba(c.r, c.g, c.b, a);
}
}

View File

@@ -0,0 +1,15 @@
pragma Singleton
import Quickshell
import QtQuick
import qs.services
Singleton {
// Prefer Compositor scales because niri and hyprland have diffrent scaling factors
function scaledWidth(ratio) {
return Compositor.screenW * ratio / Compositor.screenScale
}
function scaledHeight(ratio) {
return Compositor.screenH * ratio / Compositor.screenScale
}
}

View File

@@ -0,0 +1,106 @@
import Quickshell
import Quickshell.Io
pragma Singleton
Singleton {
id: root
function resolveIcon(className) {
if (!className || className.length === 0)
return "";
const original = className;
const normalized = className.toLowerCase();
// 1. Exact icon name
if (Quickshell.iconPath(original, true).length > 0)
return original;
// 2. Normalized guess
if (Quickshell.iconPath(normalized, true).length > 0)
return normalized;
// 3. Dashed guess
const dashed = normalized.replace(/\s+/g, "-");
if (Quickshell.iconPath(dashed, true).length > 0)
return dashed;
// 4. Extension guess
const ext = original.split(".").pop().toLowerCase();
if (Quickshell.iconPath(ext, true).length > 0)
return ext;
return "";
}
function trimFileProtocol(str) {
let s = str;
if (typeof s !== "string")
s = str.toString();
// Convert to string if it's an url or whatever
return s.startsWith("file://") ? s.slice(7) : s;
}
function isVideo(path) {
if (!path)
return false;
// Convert QUrl → string if needed
let p = path.toString ? path.toString() : path;
// Strip file://
if (p.startsWith("file://"))
p = p.replace("file://", "");
const ext = p.split(".").pop().toLowerCase();
return ["mp4", "mkv", "webm", "mov", "avi", "m4v"].includes(ext);
}
function createFile(filePath, callback) {
if (!filePath)
return ;
let p = Qt.createQmlObject('import QtQuick; import Quickshell.Io; Process {}', root);
p.command = ["touch", filePath];
p.onExited.connect(function() {
console.debug("Created file:", filePath, "exit code:", p.exitCode);
p.destroy();
if (callback)
callback(true);
});
p.running = true;
}
function removeFile(filePath, callback) {
if (!filePath)
return ;
let p = Qt.createQmlObject('import QtQuick; import Quickshell.Io; Process {}', root);
p.command = ["rm", "-f", filePath];
p.onExited.connect(function() {
console.debug("Removed file:", filePath, "exit code:", p.exitCode);
p.destroy();
if (callback)
callback(true);
});
p.running = true;
}
function renameFile(oldPath, newPath, callback) {
if (!oldPath || !newPath || oldPath === newPath)
return ;
let p = Qt.createQmlObject('import QtQuick; import Quickshell.Io; Process {}', root);
p.command = ["mv", oldPath, newPath];
p.onExited.connect(function() {
console.debug("Renamed file:", oldPath, "→", newPath, "exit code:", p.exitCode);
p.destroy();
if (callback)
callback(true);
});
p.running = true;
}
}

View File

@@ -0,0 +1,42 @@
pragma Singleton
import Quickshell
Singleton {
id: root
function shortText(str, len = 25) {
if (!str)
return ""
return str.length > len ? str.slice(0, len) + "..." : str
}
function verticalize(text) {
return text.split("").join("\n")
}
function markdownToHtml(md) {
if (!md) return "";
let html = md
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>") // bold
.replace(/\*(.*?)\*/g, "<i>$1</i>") // italic
.replace(/`([^`]+)`/g, "<code>$1</code>") // inline code
.replace(/^### (.*)$/gm, "<h3>$1</h3>") // headers
.replace(/```([\s\S]+?)```/g, '<pre style="font-family:monospace">$1</pre>') // code blocks
.replace(/^## (.*)$/gm, "<h2>$1</h2>")
.replace(/^# (.*)$/gm, "<h1>$1</h1>")
.replace(/^- (.*)$/gm, "<li>$1</li>"); // simple lists
// Wrap list items in <ul> without `s` flag
html = html.replace(/(<li>[\s\S]*?<\/li>)/g, "<ul>$1</ul>");
// Replace newlines with <br> for normal text
html = html.replace(/\n/g, "<br>");
return html;
}
}

View File

@@ -0,0 +1,679 @@
.pragma library
var single = (search, target) => {
if(!search || !target) return NULL
var preparedSearch = getPreparedSearch(search)
if(!isPrepared(target)) target = getPrepared(target)
var searchBitflags = preparedSearch.bitflags
if((searchBitflags & target._bitflags) !== searchBitflags) return NULL
return algorithm(preparedSearch, target)
}
var go = (search, targets, options) => {
if(!search) return options?.all ? all(targets, options) : noResults
var preparedSearch = getPreparedSearch(search)
var searchBitflags = preparedSearch.bitflags
var containsSpace = preparedSearch.containsSpace
var threshold = denormalizeScore( options?.threshold || 0 )
var limit = options?.limit || INFINITY
var resultsLen = 0; var limitedCount = 0
var targetsLen = targets.length
function push_result(result) {
if(resultsLen < limit) { q.add(result); ++resultsLen }
else {
++limitedCount
if(result._score > q.peek()._score) q.replaceTop(result)
}
}
// This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys]
// options.key
if(options?.key) {
var key = options.key
for(var i = 0; i < targetsLen; ++i) { var obj = targets[i]
var target = getValue(obj, key)
if(!target) continue
if(!isPrepared(target)) target = getPrepared(target)
if((searchBitflags & target._bitflags) !== searchBitflags) continue
var result = algorithm(preparedSearch, target)
if(result === NULL) continue
if(result._score < threshold) continue
result.obj = obj
push_result(result)
}
// options.keys
} else if(options?.keys) {
var keys = options.keys
var keysLen = keys.length
outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i]
{ // early out based on bitflags
var keysBitflags = 0
for (var keyI = 0; keyI < keysLen; ++keyI) {
var key = keys[keyI]
var target = getValue(obj, key)
if(!target) { tmpTargets[keyI] = noTarget; continue }
if(!isPrepared(target)) target = getPrepared(target)
tmpTargets[keyI] = target
keysBitflags |= target._bitflags
}
if((searchBitflags & keysBitflags) !== searchBitflags) continue
}
if(containsSpace) for(let i=0; i<preparedSearch.spaceSearches.length; i++) keysSpacesBestScores[i] = NEGATIVE_INFINITY
for (var keyI = 0; keyI < keysLen; ++keyI) {
target = tmpTargets[keyI]
if(target === noTarget) { tmpResults[keyI] = noTarget; continue }
tmpResults[keyI] = algorithm(preparedSearch, target, /*allowSpaces=*/false, /*allowPartialMatch=*/containsSpace)
if(tmpResults[keyI] === NULL) { tmpResults[keyI] = noTarget; continue }
// todo: this seems weird and wrong. like what if our first match wasn't good. this should just replace it instead of averaging with it
// if our second match isn't good we ignore it instead of averaging with it
if(containsSpace) for(let i=0; i<preparedSearch.spaceSearches.length; i++) {
if(allowPartialMatchScores[i] > -1000) {
if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) {
var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/
if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp
}
}
if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i]
}
}
if(containsSpace) {
for(let i=0; i<preparedSearch.spaceSearches.length; i++) { if(keysSpacesBestScores[i] === NEGATIVE_INFINITY) continue outer }
} else {
var hasAtLeast1Match = false
for(let i=0; i < keysLen; i++) { if(tmpResults[i]._score !== NEGATIVE_INFINITY) { hasAtLeast1Match = true; break } }
if(!hasAtLeast1Match) continue
}
var objResults = new KeysResult(keysLen)
for(let i=0; i < keysLen; i++) { objResults[i] = tmpResults[i] }
if(containsSpace) {
var score = 0
for(let i=0; i<preparedSearch.spaceSearches.length; i++) score += keysSpacesBestScores[i]
} else {
// todo could rewrite this scoring to be more similar to when there's spaces
// if we match multiple keys give us bonus points
var score = NEGATIVE_INFINITY
for(let i=0; i<keysLen; i++) {
var result = objResults[i]
if(result._score > -1000) {
if(score > NEGATIVE_INFINITY) {
var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/
if(tmp > score) score = tmp
}
}
if(result._score > score) score = result._score
}
}
objResults.obj = obj
objResults._score = score
if(options?.scoreFn) {
score = options.scoreFn(objResults)
if(!score) continue
score = denormalizeScore(score)
objResults._score = score
}
if(score < threshold) continue
push_result(objResults)
}
// no keys
} else {
for(var i = 0; i < targetsLen; ++i) { var target = targets[i]
if(!target) continue
if(!isPrepared(target)) target = getPrepared(target)
if((searchBitflags & target._bitflags) !== searchBitflags) continue
var result = algorithm(preparedSearch, target)
if(result === NULL) continue
if(result._score < threshold) continue
push_result(result)
}
}
if(resultsLen === 0) return noResults
var results = new Array(resultsLen)
for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll()
results.total = resultsLen + limitedCount
return results
}
// this is written as 1 function instead of 2 for minification. perf seems fine ...
// except when minified. the perf is very slow
var highlight = (result, open='<b>', close='</b>') => {
var callback = typeof open === 'function' ? open : undefined
var target = result.target
var targetLen = target.length
var indexes = result.indexes
var highlighted = ''
var matchI = 0
var indexesI = 0
var opened = false
var parts = []
for(var i = 0; i < targetLen; ++i) { var char = target[i]
if(indexes[indexesI] === i) {
++indexesI
if(!opened) { opened = true
if(callback) {
parts.push(highlighted); highlighted = ''
} else {
highlighted += open
}
}
if(indexesI === indexes.length) {
if(callback) {
highlighted += char
parts.push(callback(highlighted, matchI++)); highlighted = ''
parts.push(target.substr(i+1))
} else {
highlighted += char + close + target.substr(i+1)
}
break
}
} else {
if(opened) { opened = false
if(callback) {
parts.push(callback(highlighted, matchI++)); highlighted = ''
} else {
highlighted += close
}
}
}
highlighted += char
}
return callback ? parts : highlighted
}
var prepare = (target) => {
if(typeof target === 'number') target = ''+target
else if(typeof target !== 'string') target = ''
var info = prepareLowerInfo(target)
return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags})
}
var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() }
// Below this point is only internal code
// Below this point is only internal code
// Below this point is only internal code
// Below this point is only internal code
class Result {
get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) }
set ['indexes'](indexes) { return this._indexes = indexes }
['highlight'](open, close) { return highlight(this, open, close) }
get ['score']() { return normalizeScore(this._score) }
set ['score'](score) { this._score = denormalizeScore(score) }
}
class KeysResult extends Array {
get ['score']() { return normalizeScore(this._score) }
set ['score'](score) { this._score = denormalizeScore(score) }
}
var new_result = (target, options) => {
const result = new Result()
result['target'] = target
result['obj'] = options.obj ?? NULL
result._score = options._score ?? NEGATIVE_INFINITY
result._indexes = options._indexes ?? []
result._targetLower = options._targetLower ?? ''
result._targetLowerCodes = options._targetLowerCodes ?? NULL
result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL
result._bitflags = options._bitflags ?? 0
return result
}
var normalizeScore = score => {
if(score === NEGATIVE_INFINITY) return 0
if(score > 1) return score
return Math.E ** ( ((-score + 1)**.04307 - 1) * -2)
}
var denormalizeScore = normalizedScore => {
if(normalizedScore === 0) return NEGATIVE_INFINITY
if(normalizedScore > 1) return normalizedScore
return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307)
}
var prepareSearch = (search) => {
if(typeof search === 'number') search = ''+search
else if(typeof search !== 'string') search = ''
search = search.trim()
var info = prepareLowerInfo(search)
var spaceSearches = []
if(info.containsSpace) {
var searches = search.split(/\s+/)
searches = [...new Set(searches)] // distinct
for(var i=0; i<searches.length; i++) {
if(searches[i] === '') continue
var _info = prepareLowerInfo(searches[i])
spaceSearches.push({lowerCodes:_info.lowerCodes, _lower:searches[i].toLowerCase(), containsSpace:false})
}
}
return {lowerCodes: info.lowerCodes, _lower: info._lower, containsSpace: info.containsSpace, bitflags: info.bitflags, spaceSearches: spaceSearches}
}
var getPrepared = (target) => {
if(target.length > 999) return prepare(target) // don't cache huge targets
var targetPrepared = preparedCache.get(target)
if(targetPrepared !== undefined) return targetPrepared
targetPrepared = prepare(target)
preparedCache.set(target, targetPrepared)
return targetPrepared
}
var getPreparedSearch = (search) => {
if(search.length > 999) return prepareSearch(search) // don't cache huge searches
var searchPrepared = preparedSearchCache.get(search)
if(searchPrepared !== undefined) return searchPrepared
searchPrepared = prepareSearch(search)
preparedSearchCache.set(search, searchPrepared)
return searchPrepared
}
var all = (targets, options) => {
var results = []; results.total = targets.length // this total can be wrong if some targets are skipped
var limit = options?.limit || INFINITY
if(options?.key) {
for(var i=0;i<targets.length;i++) { var obj = targets[i]
var target = getValue(obj, options.key)
if(target == NULL) continue
if(!isPrepared(target)) target = getPrepared(target)
var result = new_result(target.target, {_score: target._score, obj: obj})
results.push(result); if(results.length >= limit) return results
}
} else if(options?.keys) {
for(var i=0;i<targets.length;i++) { var obj = targets[i]
var objResults = new KeysResult(options.keys.length)
for (var keyI = options.keys.length - 1; keyI >= 0; --keyI) {
var target = getValue(obj, options.keys[keyI])
if(!target) { objResults[keyI] = noTarget; continue }
if(!isPrepared(target)) target = getPrepared(target)
target._score = NEGATIVE_INFINITY
target._indexes.len = 0
objResults[keyI] = target
}
objResults.obj = obj
objResults._score = NEGATIVE_INFINITY
results.push(objResults); if(results.length >= limit) return results
}
} else {
for(var i=0;i<targets.length;i++) { var target = targets[i]
if(target == NULL) continue
if(!isPrepared(target)) target = getPrepared(target)
target._score = NEGATIVE_INFINITY
target._indexes.len = 0
results.push(target); if(results.length >= limit) return results
}
}
return results
}
var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => {
if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch)
var searchLower = preparedSearch._lower
var searchLowerCodes = preparedSearch.lowerCodes
var searchLowerCode = searchLowerCodes[0]
var targetLowerCodes = prepared._targetLowerCodes
var searchLen = searchLowerCodes.length
var targetLen = targetLowerCodes.length
var searchI = 0 // where we at
var targetI = 0 // where you at
var matchesSimpleLen = 0
// very basic fuzzy match; to remove non-matching targets ASAP!
// walk through target. find sequential matches.
// if all chars aren't found then exit
for(;;) {
var isMatch = searchLowerCode === targetLowerCodes[targetI]
if(isMatch) {
matchesSimple[matchesSimpleLen++] = targetI
++searchI; if(searchI === searchLen) break
searchLowerCode = searchLowerCodes[searchI]
}
++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI
}
var searchI = 0
var successStrict = false
var matchesStrictLen = 0
var nextBeginningIndexes = prepared._nextBeginningIndexes
if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target)
targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1]
// Our target string successfully matched all characters in sequence!
// Let's try a more advanced and strict test to improve the score
// only count it as a match if it's consecutive or a beginning character!
var backtrackCount = 0
if(targetI !== targetLen) for(;;) {
if(targetI >= targetLen) {
// We failed to find a good spot for this search char, go back to the previous search char and force it forward
if(searchI <= 0) break // We failed to push chars forward for a better match
++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match
--searchI
var lastMatch = matchesStrict[--matchesStrictLen]
targetI = nextBeginningIndexes[lastMatch]
} else {
var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI]
if(isMatch) {
matchesStrict[matchesStrictLen++] = targetI
++searchI; if(searchI === searchLen) { successStrict = true; break }
++targetI
} else {
targetI = nextBeginningIndexes[targetI]
}
}
}
// check if it's a substring match
var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow
var isSubstring = !!~substringIndex
var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex
// if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score
if(isSubstring && !isSubstringBeginning) {
for(var i=0; i<nextBeginningIndexes.length; i=nextBeginningIndexes[i]) {
if(i <= substringIndex) continue
for(var s=0; s<searchLen; s++) if(searchLowerCodes[s] !== prepared._targetLowerCodes[i+s]) break
if(s === searchLen) { substringIndex = i; isSubstringBeginning = true; break }
}
}
// tally up the score & keep track of matches for highlighting later
// if it's a simple match, we'll switch to a substring match if a substring exists
// if it's a strict match, we'll switch to a substring match only if that's a better score
var calculateScore = matches => {
var score = 0
var extraMatchGroupCount = 0
for(var i = 1; i < searchLen; ++i) {
if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount}
}
var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1)
score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups
if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning
if(!successStrict) {
score *= 1000
} else {
// successStrict on a target with too many beginning indexes loses points for being a bad target
var uniqueBeginningIndexes = 1
for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes
if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ...
}
score -= (targetLen - searchLen)/2 // penality for longer targets
if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring
if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex
score -= (targetLen - searchLen)/2 // penality for longer targets
return score
}
if(!successStrict) {
if(isSubstring) for(var i=0; i<searchLen; ++i) matchesSimple[i] = substringIndex+i // at this point it's safe to overwrite matchehsSimple with substr matches
var matchesBest = matchesSimple
var score = calculateScore(matchesBest)
} else {
if(isSubstringBeginning) {
for(var i=0; i<searchLen; ++i) matchesSimple[i] = substringIndex+i // at this point it's safe to overwrite matchehsSimple with substr matches
var matchesBest = matchesSimple
var score = calculateScore(matchesSimple)
} else {
var matchesBest = matchesStrict
var score = calculateScore(matchesStrict)
}
}
prepared._score = score
for(var i = 0; i < searchLen; ++i) prepared._indexes[i] = matchesBest[i]
prepared._indexes.len = searchLen
const result = new Result()
result.target = prepared.target
result._score = prepared._score
result._indexes = prepared._indexes
return result
}
var algorithmSpaces = (preparedSearch, target, allowPartialMatch) => {
var seen_indexes = new Set()
var score = 0
var result = NULL
var first_seen_index_last_search = 0
var searches = preparedSearch.spaceSearches
var searchesLen = searches.length
var changeslen = 0
// Return _nextBeginningIndexes back to its normal state
var resetNextBeginningIndexes = () => {
for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1]
}
var hasAtLeast1Match = false
for(var i=0; i<searchesLen; ++i) {
allowPartialMatchScores[i] = NEGATIVE_INFINITY
var search = searches[i]
result = algorithm(search, target)
if(allowPartialMatch) {
if(result === NULL) continue
hasAtLeast1Match = true
} else {
if(result === NULL) {resetNextBeginningIndexes(); return NULL}
}
// if not the last search, we need to mutate _nextBeginningIndexes for the next search
var isTheLastSearch = i === searchesLen - 1
if(!isTheLastSearch) {
var indexes = result._indexes
var indexesIsConsecutiveSubstring = true
for(let i=0; i<indexes.len-1; i++) {
if(indexes[i+1] - indexes[i] !== 1) {
indexesIsConsecutiveSubstring = false; break;
}
}
if(indexesIsConsecutiveSubstring) {
var newBeginningIndex = indexes[indexes.len-1] + 1
var toReplace = target._nextBeginningIndexes[newBeginningIndex-1]
for(let i=newBeginningIndex-1; i>=0; i--) {
if(toReplace !== target._nextBeginningIndexes[i]) break
target._nextBeginningIndexes[i] = newBeginningIndex
nextBeginningIndexesChanges[changeslen*2 + 0] = i
nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace
changeslen++
}
}
}
score += result._score / searchesLen
allowPartialMatchScores[i] = result._score / searchesLen
// dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h
if(result._indexes[0] < first_seen_index_last_search) {
score -= (first_seen_index_last_search - result._indexes[0]) * 2
}
first_seen_index_last_search = result._indexes[0]
for(var j=0; j<result._indexes.len; ++j) seen_indexes.add(result._indexes[j])
}
if(allowPartialMatch && !hasAtLeast1Match) return NULL
resetNextBeginningIndexes()
// allows a search with spaces that's an exact substring to score well
var allowSpacesResult = algorithm(preparedSearch, target, /*allowSpaces=*/true)
if(allowSpacesResult !== NULL && allowSpacesResult._score > score) {
if(allowPartialMatch) {
for(var i=0; i<searchesLen; ++i) {
allowPartialMatchScores[i] = allowSpacesResult._score / searchesLen
}
}
return allowSpacesResult
}
if(allowPartialMatch) result = target
result._score = score
var i = 0
for (let index of seen_indexes) result._indexes[i++] = index
result._indexes.len = i
return result
}
// we use this instead of just .normalize('NFD').replace(/[\u0300-\u036f]/g, '') because that screws with japanese characters
var remove_accents = (str) => str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '')
var prepareLowerInfo = (str) => {
str = remove_accents(str)
var strLen = str.length
var lower = str.toLowerCase()
var lowerCodes = [] // new Array(strLen) sparse array is too slow
var bitflags = 0
var containsSpace = false // space isn't stored in bitflags because of how searching with a space works
for(var i = 0; i < strLen; ++i) {
var lowerCode = lowerCodes[i] = lower.charCodeAt(i)
if(lowerCode === 32) {
containsSpace = true
continue // it's important that we don't set any bitflags for space
}
var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet
: lowerCode>=48&&lowerCode<=57 ? 26 // numbers
// 3 bits available
: lowerCode<=127 ? 30 // other ascii
: 31 // other utf8
bitflags |= 1<<bit
}
return {lowerCodes:lowerCodes, bitflags:bitflags, containsSpace:containsSpace, _lower:lower}
}
var prepareBeginningIndexes = (target) => {
var targetLen = target.length
var beginningIndexes = []; var beginningIndexesLen = 0
var wasUpper = false
var wasAlphanum = false
for(var i = 0; i < targetLen; ++i) {
var targetCode = target.charCodeAt(i)
var isUpper = targetCode>=65&&targetCode<=90
var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57
var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum
wasUpper = isUpper
wasAlphanum = isAlphanum
if(isBeginning) beginningIndexes[beginningIndexesLen++] = i
}
return beginningIndexes
}
var prepareNextBeginningIndexes = (target) => {
target = remove_accents(target)
var targetLen = target.length
var beginningIndexes = prepareBeginningIndexes(target)
var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow
var lastIsBeginning = beginningIndexes[0]
var lastIsBeginningI = 0
for(var i = 0; i < targetLen; ++i) {
if(lastIsBeginning > i) {
nextBeginningIndexes[i] = lastIsBeginning
} else {
lastIsBeginning = beginningIndexes[++lastIsBeginningI]
nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning
}
}
return nextBeginningIndexes
}
var preparedCache = new Map()
var preparedSearchCache = new Map()
// the theory behind these being globals is to reduce garbage collection by not making new arrays
var matchesSimple = []; var matchesStrict = []
var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search
var keysSpacesBestScores = []; var allowPartialMatchScores = []
var tmpTargets = []; var tmpResults = []
// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop]
// prop = 'key1.key2' 10ms
// prop = ['key1', 'key2'] 27ms
// prop = obj => obj.tags.join() ??ms
var getValue = (obj, prop) => {
var tmp = obj[prop]; if(tmp !== undefined) return tmp
if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower
var segs = prop
if(!Array.isArray(prop)) segs = prop.split('.')
var len = segs.length
var i = -1
while (obj && (++i < len)) obj = obj[segs[i]]
return obj
}
var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' }
var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY
var noResults = []; noResults.total = 0
var NULL = null
var noTarget = prepare('')
// Hacked version of https://github.com/lemire/FastPriorityQueue.js
var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c<o;){var s=c+1;a=c,s<o&&e[s]._score<e[c]._score&&(a=s),e[a-1>>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score<e[f]._score;f=(a=f)-1>>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score<e[v]._score;v=(a=v)-1>>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a}
var q = fastpriorityqueue() // reuse this

View File

@@ -0,0 +1,267 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
import qs.config
import qs.modules.functions
import qs.modules.components
import qs.services
Scope {
id: root
Variants {
model: Quickshell.screens
PanelWindow {
id: backgroundContainer
required property var modelData
property string displayName: modelData.name
property url wallpaperPath: {
const displays = Config.runtime.monitors
const fallback = Config.runtime.appearance.background.defaultPath
if (!displays)
return fallback
const monitor = displays?.[displayName]
return monitor?.wallpaper ?? fallback
}
// parallax config
property bool parallaxEnabled: Config.runtime.appearance.background.parallax.enabled
property real parallaxZoom: Config.runtime.appearance.background.parallax.zoom
property int workspaceRange: Config.runtime.bar.modules.workspaces.workspaceIndicators
// hyprland
property int activeWorkspaceId: Hyprland.focusedWorkspace?.id ?? 1
// wallpaper geometry
property real wallpaperWidth: bgImg.implicitWidth
property real wallpaperHeight: bgImg.implicitHeight
property real wallpaperToScreenRatio: {
if (wallpaperWidth <= 0 || wallpaperHeight <= 0)
return 1
return Math.min(
wallpaperWidth / width,
wallpaperHeight / height
)
}
property real effectiveScale: parallaxEnabled ? parallaxZoom : 1
property real movableXSpace: Math.max(
0,
((wallpaperWidth / wallpaperToScreenRatio * effectiveScale) - width) / 2
)
// workspace mapping
property int lowerWorkspace: Math.floor((activeWorkspaceId - 1) / workspaceRange) * workspaceRange + 1
property int upperWorkspace: lowerWorkspace + workspaceRange
property int workspaceSpan: Math.max(1, upperWorkspace - lowerWorkspace)
property real valueX: {
if (!parallaxEnabled)
return 0.5
return (activeWorkspaceId - lowerWorkspace) / workspaceSpan
}
// sidebar globals
property bool sidebarLeftOpen: Globals.visiblility.sidebarLeft
&& Config.runtime.appearance.background.parallax.enableSidebarLeft
property bool sidebarRightOpen: Globals.visiblility.sidebarRight
&& Config.runtime.appearance.background.parallax.enableSidebarRight
property real sidebarOffset: {
if (sidebarLeftOpen && !sidebarRightOpen)
if (Config.runtime.bar.position === "right")
return 0.15
else return -0.15
if (sidebarRightOpen && !sidebarLeftOpen)
if (Config.runtime.bar.position === "left")
return -0.15
else return 0.15
return 0
}
property real effectiveValueX: Math.max(
0,
Math.min(
1,
valueX + sidebarOffset
)
)
// window
color: (bgImg.status === Image.Error) ? Appearance.colors.colLayer2 : "transparent"
WlrLayershell.namespace: "nucleus:background"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Background
screen: modelData
visible: Config.initialized && Config.runtime.appearance.background.enabled
anchors {
top: true
left: true
right: true
bottom: true
}
// wallpaper picker
Process {
id: wallpaperProc
command: ["bash", "-c", Directories.scriptsPath + "/interface/changebg.sh"]
stdout: StdioCollector {
onStreamFinished: {
const out = text.trim()
if (out !== "null" && out.length > 0) {
const parts = out.split("|")
if (parts.length === 2) {
const monitor = parts[0]
const wallpaper = parts[1]
Config.updateKey(
"monitors." + monitor + ".wallpaper",
wallpaper
)
}
}
Quickshell.execDetached([
"nucleus", "ipc", "call", "clock", "changePosition"
])
if (Config.runtime.appearance.colors.autogenerated) {
Quickshell.execDetached([
"nucleus", "ipc", "call", "global", "regenColors"
]);
}
}
}
}
// wallpaper
Item {
anchors.fill: parent
clip: true
StyledImage {
id: bgImg
visible: status === Image.Ready
smooth: false
cache: false
fillMode: Image.PreserveAspectCrop
source: wallpaperPath + "?t=" + Date.now()
width: wallpaperWidth / wallpaperToScreenRatio * effectiveScale
height: wallpaperHeight / wallpaperToScreenRatio * effectiveScale
x: -movableXSpace - (effectiveValueX - 0.5) * 2 * movableXSpace
y: 0
Behavior on x {
NumberAnimation {
duration: Metrics.chronoDuration(600)
easing.type: Easing.OutCubic
}
}
onStatusChanged: {
if (status === Image.Ready) {
backgroundContainer.wallpaperWidth = implicitWidth
backgroundContainer.wallpaperHeight = implicitHeight
}
}
}
MouseArea {
id: widgetCanvas
anchors.fill: parent
}
// error ui
Item {
anchors.centerIn: parent
visible: bgImg.status === Image.Error
Rectangle {
width: 550
height: 400
radius: Appearance.rounding.windowRounding
color: "transparent"
anchors.centerIn: parent
ColumnLayout {
anchors.centerIn: parent
anchors.margins: Metrics.margin("normal")
spacing: Metrics.margin("small")
MaterialSymbol {
text: "wallpaper"
font.pixelSize: Metrics.fontSize("wildass")
color: Appearance.colors.colOnLayer2
Layout.alignment: Qt.AlignHCenter
}
StyledText {
text: "Wallpaper Missing"
font.pixelSize: Metrics.fontSize("hugeass")
font.bold: true
color: Appearance.colors.colOnLayer2
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
StyledText {
text: "Seems like you haven't set a wallpaper yet."
font.pixelSize: Metrics.fontSize("small")
color: Appearance.colors.colSubtext
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
Layout.alignment: Qt.AlignHCenter
}
Item { Layout.fillHeight: true }
StyledButton {
text: "Set wallpaper"
icon: "wallpaper"
secondary: true
radius: Metrics.radius("large")
Layout.alignment: Qt.AlignHCenter
onClicked: wallpaperProc.running = true
}
}
}
}
}
IpcHandler {
target: "background"
function change() {
wallpaperProc.running = true
}
function next() {
WallpaperSlideshow.nextWallpaper()
}
}
}
}
Clock {
id: clock
}
}

View File

@@ -0,0 +1,287 @@
import "../../components/morphedPolygons/geometry/offset.js" as Offset
import "../../components/morphedPolygons/material-shapes.js" as MaterialShapes // For polygons
import "../../components/morphedPolygons/shapes/corner-rounding.js" as CornerRounding
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.config
import qs.modules.components
import qs.modules.components.morphedPolygons
import qs.services
Scope {
id: root
property bool imageFailed: false
Variants {
model: Quickshell.screens
PanelWindow {
id: clock
required property var modelData
property int padding: Config.runtime.appearance.background.clock.edgeSpacing
property int clockHeight: Config.runtime.appearance.background.clock.isAnalog ? 250 : 160
property int clockWidth: Config.runtime.appearance.background.clock.isAnalog ? 250 : 360
function setRandomPosition() {
const x = Math.floor(Math.random() * (width - clockWidth));
const y = Math.floor(Math.random() * (height - clockHeight));
animX.to = x;
animY.to = y;
moveAnim.start();
Config.updateKey("appearance.background.clock.xPos", x);
Config.updateKey("appearance.background.clock.yPos", y);
}
color: "transparent"
visible: (Config.runtime.appearance.background.clock.enabled && Config.initialized && !imageFailed)
exclusiveZone: 0
WlrLayershell.layer: WlrLayer.Bottom
screen: modelData
ParallelAnimation {
id: moveAnim
NumberAnimation {
id: animX
target: rootContentContainer
property: "x"
duration: Metrics.chronoDuration(400)
easing.type: Easing.InOutCubic
}
NumberAnimation {
id: animY
target: rootContentContainer
property: "y"
duration: Metrics.chronoDuration(400)
easing.type: Easing.InOutCubic
}
}
anchors {
top: true
bottom: true
left: true
right: true
}
margins {
top: padding
bottom: padding
left: padding
right: padding
}
Item {
id: rootContentContainer
property real releasedX: 0
property real releasedY: 0
height: clockHeight
width: clockWidth
Component.onCompleted: {
Qt.callLater(() => {
x = Config.runtime.appearance.background.clock.xPos;
y = Config.runtime.appearance.background.clock.yPos;
});
}
MouseArea {
id: ma
anchors.fill: parent
drag.target: rootContentContainer
drag.axis: Drag.XAndYAxis
acceptedButtons: Qt.RightButton
onReleased: {
if (ma.button === Qt.RightButton)
return
Config.updateKey("appearance.background.clock.xPos", rootContentContainer.x);
Config.updateKey("appearance.background.clock.yPos", rootContentContainer.y);
}
}
Item {
id: digitalClockContainer
visible: !Config.runtime.appearance.background.clock.isAnalog
Column {
spacing: Metrics.spacing(-40)
StyledText {
animate: false
text: Time.format("hh:mm")
font.pixelSize: Metrics.fontSize(Appearance.font.size.wildass * 3)
font.family: Metrics.fontFamily("main")
font.bold: true
}
StyledText {
anchors.left: parent.left
anchors.leftMargin: Metrics.margin(8)
animate: false
text: Time.format("dddd, dd/MM")
font.pixelSize: Metrics.fontSize(32)
font.family: Metrics.fontFamily("main")
font.bold: true
}
}
}
Item {
id: analogClockContainer
property int hours: parseInt(Time.format("hh"))
property int minutes: parseInt(Time.format("mm"))
property int seconds: parseInt(Time.format("ss"))
readonly property real cx: width / 2
readonly property real cy: height / 2
property var shapes: [MaterialShapes.getCookie7Sided, MaterialShapes.getCookie9Sided, MaterialShapes.getCookie12Sided, MaterialShapes.getPixelCircle, MaterialShapes.getCircle, MaterialShapes.getGhostish]
anchors.fill: parent
visible: Config.runtime.appearance.background.clock.isAnalog
width: clock.width / 1.1
height: clock.height / 1.1
// Polygon
MorphedPolygon {
id: shapeCanvas
anchors.fill: parent
color: Appearance.m3colors.m3secondaryContainer
roundedPolygon: analogClockContainer.shapes[Config.runtime.appearance.background.clock.shape]()
transform: Rotation {
origin.x: shapeCanvas.width / 2
origin.y: shapeCanvas.height / 2
angle: shapeCanvas.rotation
}
NumberAnimation on rotation {
from: 0
to: 360
running: Config.runtime.appearance.animations.enabled && Config.runtime.appearance.background.clock.rotatePolygonBg
duration: Config.runtime.appearance.background.clock.rotationDuration * 1000
loops: Animation.Infinite
}
}
ClockDial {
id: dial
anchors.fill: parent
anchors.margins: parent.width * 0.12
color: Appearance.colors.colOnSecondaryContainer
z: 0
}
// Hour hand
StyledRect {
z: 2
width: 10
height: parent.height * 0.3
radius: Metrics.radius("full")
color: Qt.darker(Appearance.m3colors.m3secondary, 0.8)
x: analogClockContainer.cx - width / 2
y: analogClockContainer.cy - height
transformOrigin: Item.Bottom
rotation: (analogClockContainer.hours % 12 + analogClockContainer.minutes / 60) * 30
}
StyledRect {
anchors.centerIn: parent
width: 16
height: 16
radius: width / 2
color: Appearance.m3colors.m3secondary
z: 99 // Ensures its on top of everthing
// Inner dot
StyledRect {
width: parent.width / 2
height: parent.height / 2
radius: width / 2
anchors.centerIn: parent
z: 100
color: Appearance.m3colors.m3primaryContainer
}
}
// Minute hand
StyledRect {
width: 18
height: parent.height * 0.35
radius: Metrics.radius("full")
color: Appearance.m3colors.m3secondary
x: analogClockContainer.cx - width / 2
y: analogClockContainer.cy - height
transformOrigin: Item.Bottom
rotation: analogClockContainer.minutes * 6
z: 10 // On top of all hands
}
// Second hand
StyledRect {
visible: true
width: 4
height: parent.height * 0.28
radius: Metrics.radius("full")
color: Appearance.m3colors.m3error
x: analogClockContainer.cx - width / 2
y: analogClockContainer.cy - height
transformOrigin: Item.Bottom
rotation: analogClockContainer.seconds * 6
z: 2
}
StyledText {
text: Time.format("hh")
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: Metrics.margin(30)
font.pixelSize: Metrics.fontSize(80)
font.bold: true
opacity: 0.3
animate: false
}
StyledText {
text: Time.format("mm")
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: Metrics.margin(110)
font.pixelSize: Metrics.fontSize(80)
font.bold: true
opacity: 0.3
animate: false
}
IpcHandler {
function changePosition() {
clock.setRandomPosition();
}
target: "clock"
}
}
}
}
}
}

View File

@@ -0,0 +1,56 @@
import QtQuick
import qs.modules.components
Item {
id: root
property color color: "white"
readonly property real cx: width / 2
readonly property real cy: height / 2
readonly property real radius: Math.min(width, height) / 2
opacity: 0.4
// Hour marks (12 ticks)
Repeater {
model: 12
Item {
width: root.width
height: root.height
anchors.centerIn: parent
rotation: index * 30
transformOrigin: Item.Center
Rectangle {
width: 3 // thickness of tick
height: 15 // length of tick
color: root.color
anchors.horizontalCenter: parent.horizontalCenter
y: -root.radius * 0.15 / 2
radius: width / 2
}
}
}
// Minute marks (60 ticks)
Repeater {
model: 60
Item {
width: root.width
height: root.height
anchors.centerIn: parent
rotation: index * 6
transformOrigin: Item.Center
Rectangle {
width: index % 5 === 0 ? 3 : 2 // thicker for 5-minute marks
height: index % 5 === 0 ? 15 : 8 // longer for 5-minute marks
color: root.color
anchors.horizontalCenter: parent.horizontalCenter
y: -root.radius * 0.15 / 2
radius: width / 2
}
}
}
}

View File

@@ -0,0 +1,163 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.config
import qs.services
import qs.modules.components
Scope {
id: root
GothCorners {
opacity: ConfigResolver.bar(bar.displayName).gothCorners && !ConfigResolver.bar(bar.displayName).floating && ConfigResolver.bar(bar.displayName).enabled && !ConfigResolver.bar(bar.displayName).merged ? 1 : 0
}
Variants {
model: Quickshell.screens
PanelWindow {
// some exclusiveSpacing so it won't look like its sticking into the window when floating
id: bar
required property var modelData
property string displayName: modelData.name
property int rd: ConfigResolver.bar(displayName).radius * Config.runtime.appearance.rounding.factor // So it won't be modified when factor is 0
property int margin: ConfigResolver.bar(displayName).margins
property bool floating: ConfigResolver.bar(displayName).floating
property bool merged: ConfigResolver.bar(displayName).merged
property string pos: ConfigResolver.bar(displayName).position
property bool vertical: pos === "left" || pos === "right"
// Simple position properties
property bool attachedTop: pos === "top"
property bool attachedBottom: pos === "bottom"
property bool attachedLeft: pos === "left"
property bool attachedRight: pos === "right"
screen: modelData // Show bar on all screens
visible: ConfigResolver.bar(displayName).enabled && Config.initialized
WlrLayershell.namespace: "nucleus:bar"
exclusiveZone: ConfigResolver.bar(displayName).floating ? ConfigResolver.bar(displayName).density + Metrics.margin("tiny") : ConfigResolver.bar(displayName).density
implicitHeight: ConfigResolver.bar(displayName).density // density === height. (horizontal orientation)
implicitWidth: ConfigResolver.bar(displayName).density // density === width. (vertical orientation)
color: "transparent" // Keep panel window's color transparent, so that it can be modified by background rect
// This is probably a little weird way to set anchors but I think it's the best way. (and it works)
anchors {
top: ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right"
bottom: ConfigResolver.bar(displayName).position === "bottom" || ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right"
left: ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom"
right: ConfigResolver.bar(displayName).position === "right" || ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom"
}
margins {
top: {
if (floating)
return margin;
if (merged && vertical)
return margin;
return 0;
}
bottom: {
if (floating)
return margin;
if (merged && vertical)
return margin;
return 0;
}
left: {
if (floating)
return margin;
if (merged && !vertical)
return margin;
return 0;
}
right: {
if (floating)
return margin;
if (merged && !vertical)
return margin;
return 0;
}
}
StyledRect {
id: background
color: Appearance.m3colors.m3background
anchors.fill: parent
topLeftRadius: {
if (floating)
return rd;
if (!merged)
return 0;
return attachedBottom || attachedRight ? rd : 0;
}
topRightRadius: {
if (floating)
return rd;
if (!merged)
return 0;
return attachedBottom || attachedLeft ? rd : 0;
}
bottomLeftRadius: {
if (floating)
return rd;
if (!merged)
return 0;
return attachedTop || attachedRight ? rd : 0;
}
bottomRightRadius: {
if (floating)
return rd;
if (!merged)
return 0;
return attachedTop || attachedLeft ? rd : 0;
}
BarContent {
anchors.fill: parent
}
Behavior on bottomLeftRadius {
enabled: Config.runtime.appearance.animations.enabled
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on topLeftRadius {
enabled: Config.runtime.appearance.animations.enabled
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on bottomRightRadius {
enabled: Config.runtime.appearance.animations.enabled
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Behavior on topRightRadius {
enabled: Config.runtime.appearance.animations.enabled
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
}
}
}
}

View File

@@ -0,0 +1,178 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import "content/"
import qs.config
import qs.services
import qs.modules.components
Item {
property string displayName: screen?.name ?? ""
property bool isHorizontal: (ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom")
Row {
id: hCenterRow
visible: isHorizontal
anchors.centerIn: parent
spacing: Metrics.spacing(4)
SystemUsageModule {}
MediaPlayerModule {}
ActiveWindowModule {}
ClockModule {}
BatteryIndicatorModule {}
}
RowLayout {
id: hLeftRow
visible: isHorizontal
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Metrics.spacing(4)
anchors.leftMargin: ConfigResolver.bar(displayName).density * 0.3
ToggleModule {
icon: "menu"
iconSize: Metrics.iconSize(22)
iconColor: Appearance.m3colors.m3error
toggle: Globals.visiblility.sidebarLeft
onToggled: function(value) {
Globals.visiblility.sidebarLeft = value
}
}
WorkspaceModule {}
}
RowLayout {
id: hRightRow
visible: isHorizontal
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Metrics.spacing(4)
anchors.rightMargin: ConfigResolver.bar(displayName).density * 0.3
SystemTray {
id: sysTray
}
StyledText {
id: seperator
visible: (sysTray.items.count > 0) && ConfigResolver.bar(displayName).modules.statusIcons.enabled
Layout.alignment: Qt.AlignLeft
font.pixelSize: Metrics.fontSize("hugeass")
text: "·"
}
StatusIconsModule {}
StyledText {
id: seperator2
Layout.alignment: Qt.AlignLeft
font.pixelSize: Metrics.fontSize("hugeass")
text: "·"
}
ToggleModule {
icon: "power_settings_new"
iconSize: Metrics.iconSize(22)
iconColor: Appearance.m3colors.m3error
toggle: Globals.visiblility.powermenu
onToggled: function(value) {
Globals.visiblility.powermenu = value
}
}
}
// Vertical Layout
Item {
visible: !isHorizontal
anchors.top: parent.top
anchors.topMargin: ConfigResolver.bar(displayName).density * 0.1
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: vRow.implicitHeight
implicitHeight: vRow.implicitWidth
Row {
id: vRow
anchors.centerIn: parent
spacing: Metrics.spacing(8)
rotation: 90
ToggleModule {
icon: "menu"
iconSize: Metrics.iconSize(22)
iconColor: Appearance.m3colors.m3error
toggle: Globals.visiblility.sidebarLeft
rotation: 270
onToggled: function(value) {
Globals.visiblility.sidebarLeft = value
}
}
SystemUsageModule {}
MediaPlayerModule {}
SystemTray {
rotation: 0
}
}
}
Item {
visible: !isHorizontal
anchors.centerIn: parent
anchors.verticalCenterOffset: 35
implicitWidth: centerRow.implicitHeight
implicitHeight: centerRow.implicitWidth
Row {
id: centerRow
anchors.centerIn: parent
WorkspaceModule {
rotation: 90
}
}
}
Item {
visible: !isHorizontal
anchors.bottom: parent.bottom
anchors.bottomMargin: ConfigResolver.bar(displayName).density * 0.1
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: row.implicitHeight
implicitHeight: row.implicitWidth
Row {
id: row
anchors.centerIn: parent
spacing: Metrics.spacing(6)
rotation: 90
ClockModule {
rotation: 270
}
StatusIconsModule {}
BatteryIndicatorModule {}
ToggleModule {
icon: "power_settings_new"
iconSize: Metrics.iconSize(22)
iconColor: Appearance.m3colors.m3error
toggle: Globals.visiblility.powermenu
rotation: 270
onToggled: function(value) {
Globals.visiblility.powermenu = value
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Wayland
import qs.config
import qs.modules.components
PanelWindow {
id: root
property int opacity: 0
color: "transparent"
visible: Config.initialized
WlrLayershell.layer: WlrLayer.Top
anchors {
top: true
left: true
bottom: true
right: true
}
Item {
id: container
anchors.fill: parent
StyledRect {
anchors.fill: parent
color: Appearance.m3colors.m3background
layer.enabled: true
opacity: root.opacity
layer.effect: MultiEffect {
maskSource: mask
maskEnabled: true
maskInverted: true
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
Behavior on opacity {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("large")
easing.type: Easing.InOutExpo
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
}
Item {
id: mask
anchors.fill: parent
layer.enabled: true
visible: false
StyledRect {
anchors.fill: parent
anchors.topMargin: Config.runtime.bar.position === "bottom" ? -15 : 0
anchors.bottomMargin: Config.runtime.bar.position === "top" ? -15 : 0
anchors.leftMargin: Config.runtime.bar.position === "right" ? -15 : 0
anchors.rightMargin: Config.runtime.bar.position === "left" ? -15 : 0
radius: Metrics.radius("normal")
}
}
}
mask: Region {
item: container
intersection: Intersection.Xor
}
}

View File

@@ -0,0 +1,136 @@
import qs.config
import qs.modules.components
import qs.modules.functions
import qs.services
import QtQuick
import Quickshell
import Quickshell.Wayland
import QtQuick.Layouts
Item {
id: container
property string displayName: screen?.name ?? ""
Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
property Toplevel activeToplevel: Compositor.isWorkspaceOccupied(Compositor.focusedWorkspaceId)
? Compositor.activeToplevel
: null
implicitWidth: row.implicitWidth + 30
implicitHeight: ConfigResolver.bar(displayName).modules.height
function simplifyTitle(title) {
if (!title)
return ""
title = title.replace(/[●⬤○◉◌◎]/g, "") // Symbols to remove
// Normalize separators
title = title
.replace(/\s*[|—]\s*/g, " - ")
.replace(/\s+/g, " ")
.trim()
const parts = title.split(" - ").map(p => p.trim()).filter(Boolean)
if (parts.length === 1)
return parts[0]
// Known app names (extend freely my fellow contributors)
const apps = [
"Firefox", "Mozilla Firefox",
"Chromium", "Google Chrome",
"Neovim", "VS Code", "Code",
"Kitty", "Alacritty", "Terminal",
"Discord", "Spotify", "Steam",
"Settings - Nucleus", "Settings"
]
let app = ""
for (let i = parts.length - 1; i >= 0; i--) { // loop over
for (let a of apps) {
if (parts[i].includes(a)) {
app = a
break
}
}
if (app) break
}
if (!app)
app = parts[parts.length - 1]
const context = parts.find(p => p !== app)
return context ? `${app} · ${context}` : app
}
function formatAppId(appId) { // Random ass function to make it look good
if (!appId || appId.length === 0)
return "";
// split on dashes/underscores
const parts = appId.split(/[-_]/);
// capitalize each segment
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
parts[i] = p.charAt(0).toUpperCase() + p.slice(1);
}
return parts.join("-");
}
/* Column {
id: col
anchors.centerIn: parent
StyledText {
id: workspaceText
font.pixelSize: Metrics.fontSize("smallie")
text: {
if (!activeToplevel)
return "Desktop"
const id = activeToplevel.appId || ""
return id // Just for aesthetics
}
horizontalAlignment: Text.AlignHCenter
}
StyledText {
id: titleText
text: StringUtils.shortText(simplifyTitle(activeToplevel?.title, 24) || `Workspace ${Hyprland.focusedWorkspaceId}`)
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Metrics.fontSize("smalle")
}
} */
Rectangle {
visible: (ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom")
color: Appearance.m3colors.m3paddingContainer
anchors.fill: parent
height: 34
width: row.height + 30
radius: ConfigResolver.bar(displayName).modules.radius
}
RowLayout {
id: row
spacing: 12
anchors.centerIn: parent
MaterialSymbol {
icon: "desktop_windows"
rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0
}
StyledText {
text: StringUtils.shortText(simplifyTitle(activeToplevel?.title), 24) || `Workspace ${Hyprland.focusedWorkspaceId}`
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance.font.size.small
}
}
}

View File

@@ -0,0 +1,57 @@
import QtQuick
import QtQuick.Layouts
import qs.config
import qs.modules.components
import qs.services
Item {
id: batteryIndicatorModuleContainer
visible: UPower.batteryPresent
Layout.alignment: Qt.AlignVCenter
// Determine if bar is isVertical
property bool isVertical: ConfigResolver.bar(screen?.name ?? "").position === "left" || ConfigResolver.bar(screen?.name ?? "").position === "right"
implicitWidth: bgRect.implicitWidth
implicitHeight: bgRect.implicitHeight
Rectangle {
id: bgRect
color: isVertical ? Appearance.m3colors.m3primary : Appearance.m3colors.m3paddingContainer
radius: ConfigResolver.bar(screen?.name ?? "").modules.radius * Config.runtime.appearance.rounding.factor // No need to use metrics here...
implicitWidth: child.implicitWidth + Appearance.margin.large - (isVertical ? 10 : 0)
implicitHeight: ConfigResolver.bar(screen?.name ?? "").modules.height
}
RowLayout {
id: child
anchors.centerIn: parent
spacing: isVertical ? 0 : Metrics.spacing(8)
// Icon for isVertical bars
MaterialSymbol {
visible: isVertical
icon: UPower.battIcon
iconSize: Metrics.iconSize(20)
}
// Battery percentage text
StyledText {
animate: false
font.pixelSize: Metrics.fontSize(16)
rotation: isVertical ? 270 : 0
text: (isVertical ? UPower.percentage : UPower.percentage + "%")
}
// Circular progress for horizontal bars
CircularProgressBar {
visible: !isVertical
value: UPower.percentage / 100
icon: UPower.battIcon
iconSize: Metrics.iconSize(18)
Layout.bottomMargin: Metrics.margin(2)
}
}
}

View File

@@ -0,0 +1,27 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.config
import qs.modules.components
Item {
id: clockContainer
property string format: isVertical ? "hh\nmm\nAP" : "hh:mm • dd/MM"
property bool isVertical: (ConfigResolver.bar(screen?.name ?? "").position === "left" || ConfigResolver.bar(screen?.name ?? "").position === "right")
Layout.alignment: Qt.AlignVCenter
implicitWidth: 37
implicitHeight: 30
AnimatedImage {
id: art
anchors.fill: parent
source: Directories.assetsPath + "/gifs/bongo-cat.gif"
cache: false // this is important
smooth: true // smooooooth
rotation: isVertical ? 270 : 0
}
}

View File

@@ -0,0 +1,36 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.config
import qs.modules.components
Item {
id: clockContainer
property string format: isVertical ? "hh\nmm\nAP" : "hh:mm • dd/MM"
property bool isVertical: (ConfigResolver.bar(screen?.name ?? "").position === "left" || ConfigResolver.bar(screen?.name ?? "").position === "right")
Layout.alignment: Qt.AlignVCenter
implicitWidth: bgRect.implicitWidth
implicitHeight: bgRect.implicitHeight
// Let the layout compute size automatically
Rectangle {
id: bgRect
color: isVertical ? "transparent" : Appearance.m3colors.m3paddingContainer
radius: ConfigResolver.bar(screen?.name ?? "").modules.radius * Config.runtime.appearance.rounding.factor
// Padding around the text
implicitWidth: isVertical ? textItem.implicitWidth + 40 : textItem.implicitWidth + Metrics.margin("large")
implicitHeight: ConfigResolver.bar(screen?.name ?? "").modules.height
}
StyledText {
id: textItem
anchors.centerIn: parent
animate: false
text: Time.format(clockContainer.format)
}
}

View File

@@ -0,0 +1,127 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Io
import qs.config
import qs.modules.functions
import qs.modules.components
import qs.services
Item {
id: mediaPlayer
property bool isVertical: (
ConfigResolver.bar(screen?.name ?? "").position === "left" ||
ConfigResolver.bar(screen?.name ?? "").position === "right"
)
Layout.alignment: Qt.AlignCenter | Qt.AlignVCenter
implicitWidth: bgRect.implicitWidth
implicitHeight: bgRect.implicitHeight
Rectangle {
id: bgRect
color: Appearance.m3colors.m3paddingContainer
radius: ConfigResolver.bar(screen?.name ?? "").modules.radius *
Config.runtime.appearance.rounding.factor
implicitWidth: isVertical
? row.implicitWidth + Metrics.margin("large") - 10
: row.implicitWidth + Metrics.margin("large")
implicitHeight: ConfigResolver.bar(screen?.name ?? "").modules.height
}
Row {
id: row
anchors.centerIn: parent
spacing: Metrics.margin("small")
ClippingRectangle {
id: iconButton
width: 24
height: 24
radius: ConfigResolver.bar(screen?.name ?? "").modules.radius / 1.2
color: Appearance.colors.colLayer1Hover
opacity: 0.9
clip: true
layer.enabled: true
Item {
anchors.fill: parent
Image {
id: art
anchors.fill: parent
visible: Mpris.artUrl !== ""
source: Mpris.artUrl
fillMode: Image.PreserveAspectCrop
smooth: true
mipmap: true
}
MaterialSymbol {
anchors.centerIn: parent
visible: Mpris.artUrl === ""
icon: "music_note"
iconSize: 18
color: Config.runtime.appearance.theme === "dark"
? "#b1a4a4"
: "grey"
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: Mpris.playPause()
onEntered: iconButton.opacity = 1
onExited: iconButton.opacity = 0.9
}
RotationAnimation on rotation {
from: 0
to: 360
duration: Metrics.chronoDuration(4000)
loops: Animation.Infinite
running: Mpris.isPlaying &&
Config.runtime.appearance.animations.enabled
}
}
StyledText {
id: textItem
anchors.verticalCenter: parent.verticalCenter
text: StringUtils.shortText(Mpris.title, 16)
visible: !mediaPlayer.isVertical
}
}
}

View File

@@ -0,0 +1,65 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.config
import qs.modules.components
import qs.services
Item {
id: statusIconsContainer
property bool isVertical: (ConfigResolver.bar(screen?.name ?? "").position === "left" || ConfigResolver.bar(screen?.name ?? "").position === "right")
Layout.alignment: Qt.AlignVCenter
visible: ConfigResolver.bar(screen?.name ?? "").modules.statusIcons.enabled
implicitWidth: bgRect.implicitWidth
implicitHeight: bgRect.implicitHeight
StyledRect {
id: bgRect
color: Globals.visiblility.sidebarRight ? Appearance.m3colors.m3paddingContainer : "transparent"
radius: ConfigResolver.bar(screen?.name ?? "").modules.radius * Config.runtime.appearance.rounding.factor
implicitWidth: isVertical ? contentRow.implicitWidth + Metrics.margin("large") - 8 : contentRow.implicitWidth + Metrics.margin("large")
implicitHeight: ConfigResolver.bar(screen?.name ?? "").modules.height
RowLayout {
id: contentRow
anchors.centerIn: parent
spacing: isVertical ? Metrics.spacing(8) : Metrics.spacing(16)
MaterialSymbol {
id: wifi
animate: false
visible: ConfigResolver.bar(screen?.name ?? "").modules.statusIcons.networkStatusEnabled
rotation: isVertical ? 270 : 0
icon: Network.icon
iconSize: Metrics.fontSize("huge")
}
MaterialSymbol {
id: btIcon
animate: false
visible: ConfigResolver.bar(screen?.name ?? "").modules.statusIcons.bluetoothStatusEnabled
rotation: isVertical ? 270 : 0
icon: Bluetooth.icon
iconSize: Metrics.fontSize("huge")
}
}
MouseArea {
anchors.fill: parent
onClicked: {
if (Globals.visiblility.sidebarLeft)
return
Globals.visiblility.sidebarRight = !Globals.visiblility.sidebarRight
}
}
}
}

View File

@@ -0,0 +1,165 @@
import qs.modules.components
import qs.config
import qs.services
import Quickshell.Services.SystemTray
import QtQuick
import Quickshell
import Quickshell.Widgets
import QtQuick.Layouts
Item {
id: root
readonly property Repeater items: items
property bool horizontalMode: (ConfigResolver.bar(screen?.name ?? "").position === "top" || ConfigResolver.bar(screen?.name ?? "").position === "bottom")
clip: true
implicitWidth: layout.implicitWidth + Metrics.margin("verylarge")
implicitHeight: 34
Rectangle {
visible: (items.count > 0) ? 1 : 0
id: padding
implicitHeight: padding.height
anchors.fill: parent
radius: ConfigResolver.bar(screen?.name ?? "").modules.radius
color: "transparent"
}
GridLayout {
id: layout
anchors.centerIn: parent
rows: 1
columns: items.count
rowSpacing: Metrics.spacing(10)
columnSpacing: Metrics.spacing(10)
Repeater {
id: items
model: SystemTray.items
delegate: Item {
id: trayItemRoot
required property SystemTrayItem modelData
implicitWidth: 20
implicitHeight: 20
IconImage {
visible: trayItemRoot.modelData.icon !== ""
source: trayItemRoot.modelData.icon
asynchronous: true
anchors.fill: parent
rotation: root.horizontalMode ? 0 : 270
}
HoverHandler {
id: hover
}
QsMenuOpener {
id: menuOpener
menu: trayItemRoot.modelData.menu
}
StyledPopout {
id: popout
hoverTarget: hover
interactable: true
hCenterOnItem: true
requiresHover: false
Component {
Item {
width: childColumn.implicitWidth
height: childColumn.height
ColumnLayout {
id: childColumn
spacing: Metrics.spacing(5)
Repeater {
model: menuOpener.children
delegate: TrayMenuItem {
parentColumn: childColumn
Layout.preferredWidth: childColumn.width > 0 ? childColumn.width : implicitWidth
}
}
}
}
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
hoverEnabled: true
onClicked: {
if (popout.isVisible)
popout.hide();
else
popout.show();
}
}
}
}
}
component TrayMenuItem: Item {
id: itemRoot
required property QsMenuEntry modelData
required property ColumnLayout parentColumn
Layout.fillWidth: true
implicitWidth: rowLayout.implicitWidth + 10
implicitHeight: !itemRoot.modelData.isSeparator ? rowLayout.implicitHeight + 10 : 1
MouseArea {
id: hover
hoverEnabled: itemRoot.modelData.enabled
anchors.fill: parent
onClicked: {
if (!itemRoot.modelData.hasChildren)
itemRoot.modelData.triggered();
}
}
Rectangle {
id: itemBg
anchors.fill: parent
opacity: itemRoot.modelData.isSeparator ? 0.5 : 1
color: itemRoot.modelData.isSeparator ? Appearance.m3colors.m3outline : hover.containsMouse ? Appearance.m3colors.m3surfaceContainer : Appearance.m3colors.m3surface
}
RowLayout {
id: rowLayout
visible: !itemRoot.modelData.isSeparator
opacity: itemRoot.modelData.isSeparator ? 0.5 : 1
spacing: Metrics.spacing(5)
anchors {
left: itemBg.left
leftMargin: Metrics.margin(5)
top: itemBg.top
topMargin:Metrics.margin(5)
}
IconImage {
visible: itemRoot.modelData.icon !== ""
source: itemRoot.modelData.icon
width: 15
height: 15
}
StyledText {
text: itemRoot.modelData.text
font.pixelSize: Metrics.fontSize(14)
color: Appearance.m3colors.m3onSurface
}
MaterialSymbol {
visible: itemRoot.modelData.hasChildren
icon: "chevron_right"
iconSize: Metrics.iconSize(16)
color: Appearance.m3colors.m3onSurface
}
}
}
}

View File

@@ -0,0 +1,99 @@
import QtQuick
import QtQuick.Layouts
import qs.config
import qs.modules.components
import qs.services
Item {
id: systemUsageContainer
property string displayName: screen?.name ?? ""
property bool isHorizontal: (ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom")
visible: ConfigResolver.bar(displayName).modules.systemUsage.enabled && haveWidth
Layout.alignment: Qt.AlignVCenter
implicitWidth: bgRect.implicitWidth
implicitHeight: bgRect.implicitHeight
property bool haveWidth:
ConfigResolver.bar(displayName).modules.systemUsage.tempStatsEnabled ||
ConfigResolver.bar(displayName).modules.systemUsage.cpuStatsEnabled ||
ConfigResolver.bar(displayName).modules.systemUsage.memoryStatsEnabled
// Normalize values so UI always receives correct ranges
function normalize(v) {
if (v > 1) return v / 100
return v
}
function percent(v) {
if (v <= 1) return Math.round(v * 100)
return Math.round(v)
}
Rectangle {
id: bgRect
color: Appearance.m3colors.m3paddingContainer
radius: ConfigResolver.bar(displayName).modules.radius * Config.runtime.appearance.rounding.factor
implicitWidth: child.implicitWidth + Metrics.margin("large")
implicitHeight: ConfigResolver.bar(displayName).modules.height
}
RowLayout {
id: child
anchors.centerIn: parent
spacing: Metrics.spacing(4)
// CPU
CircularProgressBar {
rotation: !isHorizontal ? 270 : 0
icon: "developer_board"
visible: ConfigResolver.bar(displayName).modules.systemUsage.cpuStatsEnabled
iconSize: Metrics.iconSize(14)
value: normalize(SystemDetails.cpuPercent)
Layout.bottomMargin: Metrics.margin(2)
}
StyledText {
visible: ConfigResolver.bar(displayName).modules.systemUsage.cpuStatsEnabled && isHorizontal
animate: false
text: percent(SystemDetails.cpuPercent) + "%"
}
// RAM
CircularProgressBar {
rotation: !isHorizontal ? 270 : 0
Layout.leftMargin: Metrics.margin(4)
icon: "memory_alt"
visible: ConfigResolver.bar(displayName).modules.systemUsage.memoryStatsEnabled
iconSize: Metrics.iconSize(14)
value: normalize(SystemDetails.ramPercent)
Layout.bottomMargin: Metrics.margin(2)
}
StyledText {
visible: ConfigResolver.bar(displayName).modules.systemUsage.memoryStatsEnabled && isHorizontal
animate: false
text: percent(SystemDetails.ramPercent) + "%"
}
// Temperature
CircularProgressBar {
rotation: !isHorizontal ? 270 : 0
visible: ConfigResolver.bar(displayName).modules.systemUsage.tempStatsEnabled
Layout.leftMargin: Metrics.margin(4)
icon: "device_thermostat"
iconSize: Metrics.iconSize(14)
value: normalize(SystemDetails.cpuTempPercent)
Layout.bottomMargin: Metrics.margin(2)
}
StyledText {
visible: ConfigResolver.bar(displayName).modules.systemUsage.tempStatsEnabled && isHorizontal
animate: false
text: percent(SystemDetails.cpuTempPercent) + "%"
}
}
}

View File

@@ -0,0 +1,43 @@
import qs.config
import qs.modules.components
import QtQuick
import Quickshell
import QtQuick.Layouts
StyledRect {
id: bg
property string icon
property color iconColor: Appearance.syntaxHighlightingTheme
property int iconSize
property bool toggle
property bool transparentBg: false
signal toggled(bool value)
color: (ma.containsMouse && !transparentBg)
? Appearance.m3colors.m3paddingContainer
: "transparent"
radius: Metrics.radius("childish")
implicitWidth: textItem.implicitWidth + 12
implicitHeight: textItem.implicitHeight + 6
MaterialSymbol {
id: textItem
anchors.centerIn: parent
anchors.verticalCenterOffset: 0.4
anchors.horizontalCenterOffset: 0.499
iconSize: bg.iconSize
icon: bg.icon
color: bg.iconColor
}
MouseArea {
id: ma
anchors.fill: parent
hoverEnabled: true
onClicked: bg.toggled(!bg.toggle)
}
}

View File

@@ -0,0 +1,254 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.modules.functions
import qs.services
Item {
id: workspaceContainer
property string displayName: screen?.name ?? ""
property int numWorkspaces: ConfigResolver.bar(displayName).modules.workspaces.workspaceIndicators
property var workspaceOccupied: []
property var occupiedRanges: []
function japaneseNumber(num) {
var kanjiMap = {
"0": "零",
"1": "一",
"2": "二",
"3": "三",
"4": "四",
"5": "五",
"6": "六",
"7": "七",
"8": "八",
"9": "九",
"10": "十"
};
return kanjiMap[num] !== undefined ? kanjiMap[num] : "Number out of range";
}
function updateWorkspaceOccupied() {
const offset = 1;
workspaceOccupied = Array.from({
"length": numWorkspaces
}, (_, i) => {
return Compositor.isWorkspaceOccupied(i + 1);
});
const ranges = [];
let start = -1;
for (let i = 0; i < workspaceOccupied.length; i++) {
if (workspaceOccupied[i]) {
if (start === -1)
start = i;
} else if (start !== -1) {
ranges.push({
"start": start,
"end": i - 1
});
start = -1;
}
}
if (start !== -1)
ranges.push({
"start": start,
"end": workspaceOccupied.length - 1
});
occupiedRanges = ranges;
}
visible: ConfigResolver.bar(displayName).modules.workspaces.enabled
implicitWidth: bg.implicitWidth
implicitHeight: ConfigResolver.bar(displayName).modules.height
Component.onCompleted: updateWorkspaceOccupied()
Connections {
function onStateChanged() {
updateWorkspaceOccupied();
}
target: Compositor
}
Rectangle {
id: bg
color: Appearance.m3colors.m3paddingContainer
radius: ConfigResolver.bar(displayName).modules.radius * Config.runtime.appearance.rounding.factor
implicitWidth: workspaceRow.implicitWidth + Metrics.margin("large") - 8
implicitHeight: ConfigResolver.bar(displayName).modules.height
// occupied background highlight
Item {
id: occupiedStretchLayer
anchors.centerIn: workspaceRow
width: workspaceRow.width
height: 26
z: 0
visible: Compositor.require("hyprland") // Hyprland only
Repeater {
model: occupiedRanges
Rectangle {
height: 26
radius: ConfigResolver.bar(displayName).modules.radius * Config.runtime.appearance.rounding.factor
color: ColorUtils.mix(Appearance.m3colors.m3tertiary, Appearance.m3colors.m3surfaceContainerLowest)
opacity: 0.8
x: modelData.start * (26 + workspaceRow.spacing)
width: (modelData.end - modelData.start + 1) * 26 + (modelData.end - modelData.start) * workspaceRow.spacing
}
}
}
// workspace highlight
Rectangle {
id: highlight
property int offset: Compositor.require("hyprland") ? 1 : 0
property int index: Math.max(0, Compositor.focusedWorkspaceId - 1 - offset)
property real itemWidth: 26
property real spacing: workspaceRow.spacing
property int highlightIndex: {
if (!Compositor.focusedWorkspaceId)
return 0;
if (Compositor.require("hyprland"))
return Compositor.focusedWorkspaceId - 1;
// Hyprland starts at 2 internally
return Compositor.focusedWorkspaceId - 2; // Niri or default
}
property real targetX: Math.min(highlightIndex, numWorkspaces - 1) * (itemWidth + spacing) + 7.3
property real animatedX1: targetX
property real animatedX2: targetX
x: Math.min(animatedX1, animatedX2)
anchors.verticalCenter: parent.verticalCenter
width: Math.abs(animatedX2 - animatedX1) + itemWidth - 1
height: 24
radius: ConfigResolver.bar(displayName).modules.radius * Config.runtime.appearance.rounding.factor
color: Appearance.m3colors.m3tertiary
onTargetXChanged: {
animatedX1 = targetX;
animatedX2 = targetX;
}
Behavior on animatedX1 {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration(400)
easing.type: Easing.OutSine
}
}
Behavior on animatedX2 {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration(133)
easing.type: Easing.OutSine
}
}
}
RowLayout {
id: workspaceRow
anchors.centerIn: parent
spacing: Metrics.spacing(10)
Repeater {
model: numWorkspaces
Item {
property int wsIndex: index + 1
property bool occupied: Compositor.isWorkspaceOccupied(wsIndex)
property bool focused: wsIndex === Compositor.focusedWorkspaceId
width: 26
height: 26
// Icon container — only used on Hyprland
ClippingRectangle {
id: iconContainer
anchors.centerIn: parent
width: 20
height: 20
color: "transparent"
radius: Appearance.rounding.small
clip: true
IconImage {
id: appIcon
anchors.fill: parent
visible: Compositor.require("hyprland") && ConfigResolver.bar(displayName).modules.workspaces.showAppIcons && occupied
rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0
source: {
const win = Compositor.focusedWindowForWorkspace(wsIndex);
return win ? AppRegistry.iconForClass(win.class) : "";
}
layer.enabled: true
layer.effect: MultiEffect {
saturation: (Config.runtime.appearance.tintIcons || (Config.runtime.appearance.colors.matugenScheme === "scheme-monochrome" && Config.runtime.appearance.colors.autogenerated) || Config.runtime.appearance.colors.scheme.toLowerCase() === "monochrome") ? -1.0 : 1.0
}
}
}
// Kanji mode — only if not Hyprland
StyledText {
anchors.centerIn: parent
visible: ConfigResolver.bar(displayName).modules.workspaces.showJapaneseNumbers && !ConfigResolver.bar(displayName).modules.workspaces.showAppIcons
text: japaneseNumber(index + 1)
rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0
}
// Numbers mode — only if not Hyprland
StyledText {
anchors.centerIn: parent
visible: !ConfigResolver.bar(displayName).modules.workspaces.showJapaneseNumbers && !ConfigResolver.bar(displayName).modules.workspaces.showAppIcons
text: index + 1
rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0
}
// Symbols for unoccupied workspaces — only for Hyprland icons
MaterialSymbol {
property string displayText: Config.runtime.appearance.rounding.factor === 0 ? "crop_square" : "fiber_manual_record"
anchors.centerIn: parent
visible: Compositor.require("hyprland") && ConfigResolver.bar(displayName).modules.workspaces.showAppIcons && !occupied
text: displayText
rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0
font.pixelSize: Metrics.iconSize(10)
fill: 1
}
MouseArea {
anchors.fill: parent
onClicked: Compositor.changeWorkspace(wsIndex)
}
}
}
}
}
}

View File

@@ -0,0 +1,576 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import Quickshell
import Quickshell.Io
import qs.config
import qs.modules.functions
import qs.modules.components
import qs.services
FloatingWindow {
id: appWin
color: Appearance.m3colors.m3background
property bool initialChatSelected: false
property bool chatsInitialized: false
function appendMessage(sender, message) {
messageModel.append({
"sender": sender,
"message": message
});
scrollToBottom();
}
function updateChatsList(files) {
let existing = {
};
for (let i = 0; i < chatListModel.count; i++) existing[chatListModel.get(i).name] = true
for (let file of files) {
let name = file.trim();
if (!name.length)
continue;
if (name.endsWith(".txt"))
name = name.slice(0, -4);
if (!existing[name])
chatListModel.append({
"name": name
});
delete existing[name];
}
// remove chats that no longer exist
for (let name in existing) {
for (let i = 0; i < chatListModel.count; i++) {
if (chatListModel.get(i).name === name) {
chatListModel.remove(i);
break;
}
}
}
// ensure default exists
let hasDefault = false;
for (let i = 0; i < chatListModel.count; i++) if (chatListModel.get(i).name === "default") {
hasDefault = true;
}
if (!hasDefault) {
chatListModel.insert(0, {
"name": "default"
});
FileUtils.createFile(FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/default.txt");
}
}
function scrollToBottom() {
// Always scroll to end after appending
chatView.forceLayout();
chatView.positionViewAtEnd();
}
function sendMessage() {
if (userInput.text === "" || Zenith.loading)
return ;
Zenith.pendingInput = userInput.text;
appendMessage("You", userInput.text);
userInput.text = "";
Zenith.loading = true;
Zenith.send();
}
function loadChatHistory(chatName) {
messageModel.clear();
Zenith.loadChat(chatName);
}
function selectDefaultChat() {
let defaultIndex = -1;
for (let i = 0; i < chatListModel.count; i++) {
if (chatListModel.get(i).name === "default") {
defaultIndex = i;
break;
}
}
if (defaultIndex !== -1) {
chatSelector.currentIndex = defaultIndex;
Zenith.currentChat = "default";
loadChatHistory("default");
} else if (chatListModel.count > 0) {
chatSelector.currentIndex = 0;
Zenith.currentChat = chatListModel.get(0).name;
loadChatHistory(Zenith.currentChat);
}
}
visible: Globals.states.intelligenceWindowOpen
onVisibleChanged: {
if (!visible)
return ;
chatsInitialized = false;
messageModel.clear();
}
IpcHandler {
function openWindow() {
Globals.states.intelligenceWindowOpen = true;
}
function closeWindow() {
Globals.states.intelligenceWindowOpen = false;
}
target: "intelligence"
}
ListModel {
// { sender: "You" | "AI", message: string }
id: messageModel
}
ListModel {
id: chatListModel
}
ColumnLayout {
spacing: Metrics.spacing(8)
anchors.centerIn: parent
StyledText {
visible: !Config.runtime.misc.intelligence.enabled
text: "Intelligence is disabled!"
Layout.leftMargin: Metrics.margin(24)
font.pixelSize: Metrics.fontSize("huge")
}
StyledText {
visible: !Config.runtime.misc.intelligence.enabled
text: "Go to the settings to enable intelligence"
}
}
StyledRect {
anchors.fill: parent
color: "transparent"
visible: Config.runtime.misc.intelligence.enabled
ColumnLayout {
anchors.fill: parent
anchors.margins: Metrics.margin(16)
spacing: Metrics.spacing(10)
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(10)
StyledDropDown {
id: chatSelector
Layout.fillWidth: true
model: chatListModel
textRole: "name"
Layout.preferredHeight: 40
onCurrentIndexChanged: {
if (currentIndex < 0)
return ;
let name = chatListModel.get(currentIndex).name;
if (name === Zenith.currentChat)
return ;
Zenith.currentChat = name;
loadChatHistory(name);
}
}
StyledButton {
icon: "add"
Layout.preferredWidth: 40
onClicked: {
let name = "new-chat-" + chatListModel.count;
let path = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + name + ".txt";
FileUtils.createFile(path, function(success) {
if (success) {
chatListModel.append({
"name": name
});
chatSelector.currentIndex = chatListModel.count - 1;
Zenith.currentChat = name;
messageModel.clear();
}
});
}
}
StyledButton {
icon: "edit"
Layout.preferredWidth: 40
enabled: chatSelector.currentIndex >= 0
onClicked: renameDialog.open()
}
StyledButton {
icon: "delete"
Layout.preferredWidth: 40
enabled: chatSelector.currentIndex >= 0 && chatSelector.currentText !== "default"
onClicked: {
let name = chatSelector.currentText;
let path = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + name + ".txt";
FileUtils.removeFile(path, function(success) {
if (success) {
chatListModel.remove(chatSelector.currentIndex);
selectDefaultChat();
}
});
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(10)
StyledDropDown {
id: modelSelector
Layout.fillWidth: true
model: ["openai/gpt-4o","openai/gpt-4","openai/gpt-3.5-turbo","openai/gpt-4o-mini","anthropic/claude-3.5-sonnet","anthropic/claude-3-haiku","meta-llama/llama-3.3-70b-instruct:free","deepseek/deepseek-r1-0528:free","qwen/qwen3-coder:free"]
currentIndex: 0
Layout.preferredHeight: 40
onCurrentTextChanged: Zenith.currentModel = currentText
}
StyledButton {
icon: "close_fullscreen"
Layout.preferredWidth: 40
onClicked: {
Quickshell.execDetached(["nucleus", "ipc", "call", "intelligence", "closeWindow"]);
Globals.visiblility.sidebarLeft = false;
}
}
}
StyledRect {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Metrics.radius("normal")
color: Appearance.m3colors.m3surfaceContainerLow
ScrollView {
anchors.fill: parent
clip: true
ListView {
id: chatView
model: messageModel
spacing: Metrics.spacing(8)
anchors.fill: parent
anchors.margins: Metrics.margin(12)
clip: true
delegate: Item {
property bool isCodeBlock: message.split("\n").length > 2 && message.includes("import ") // simple heuristic
width: chatView.width
height: bubble.implicitHeight + 6
Component.onCompleted: {
chatView.forceLayout();
}
Row {
width: parent.width
spacing: Metrics.spacing(8)
Item {
width: sender === "AI" ? 0 : parent.width * 0.2
}
StyledRect {
id: bubble
radius: Metrics.radius("normal")
color: sender === "You" ? Appearance.m3colors.m3primaryContainer : Appearance.m3colors.m3surfaceContainerHigh
implicitWidth: Math.min(textItem.implicitWidth + 20, chatView.width * 0.8)
implicitHeight: textItem.implicitHeight
anchors.right: sender === "You" ? parent.right : undefined
anchors.left: sender === "AI" ? parent.left : undefined
anchors.topMargin: Metrics.margin(2)
TextEdit {
id: textItem
text: StringUtils.markdownToHtml(message)
wrapMode: TextEdit.Wrap
textFormat: TextEdit.RichText
readOnly: true // make it selectable but not editable
font.pixelSize: Metrics.fontSize(16)
color: Appearance.syntaxHighlightingTheme
padding: Metrics.padding(8)
anchors.fill: parent
}
MouseArea {
id: ma
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: {
let p = Qt.createQmlObject('import Quickshell; import Quickshell.Io; Process { command: ["wl-copy", "' + message + '"] }', parent);
p.running = true;
}
}
}
Item {
width: sender === "You" ? 0 : parent.width * 0.2
}
}
}
}
}
}
StyledRect {
Layout.fillWidth: true
height: 50
radius: Metrics.radius("normal")
color: Appearance.m3colors.m3surfaceContainer
RowLayout {
anchors.fill: parent
anchors.margins: Metrics.margin(6)
spacing: Metrics.spacing(10)
StyledTextField {
// Shift+Enter → insert newline
// Enter → send message
id: userInput
Layout.fillWidth: true
placeholderText: "Type your message..."
font.pixelSize: Metrics.iconSize(14)
padding: Metrics.spacing(8)
Keys.onPressed: {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (event.modifiers & Qt.ShiftModifier)
insert("\n");
else
sendMessage();
event.accepted = true;
}
}
}
StyledButton {
text: "Send"
enabled: userInput.text.trim().length > 0 && !Zenith.loading
opacity: enabled ? 1 : 0.5
onClicked: sendMessage()
}
}
}
}
Dialog {
id: renameDialog
title: "Rename Chat"
modal: true
visible: false
standardButtons: Dialog.NoButton
x: (appWin.width - 360) / 2 // center horizontally
y: (appWin.height - 160) / 2 // center vertically
width: 360
height: 200
ColumnLayout {
anchors.fill: parent
anchors.margins: Metrics.margin(16)
spacing: Metrics.spacing(12)
StyledText {
text: "Enter a new name for the chat"
font.pixelSize: Metrics.fontSize(18)
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
}
StyledTextField {
id: renameInput
Layout.fillWidth: true
placeholderText: "New name"
filled: false
highlight: false
text: chatSelector.currentText
font.pixelSize: Metrics.fontSize(16)
Layout.preferredHeight: 45
padding: Metrics.padding(8)
}
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(12)
Layout.alignment: Qt.AlignRight
StyledButton {
text: "Cancel"
Layout.preferredWidth: 80
onClicked: renameDialog.close()
}
StyledButton {
text: "Rename"
Layout.preferredWidth: 100
enabled: renameInput.text.trim().length > 0 && renameInput.text !== chatSelector.currentText
onClicked: {
let oldName = chatSelector.currentText;
let newName = renameInput.text.trim();
let oldPath = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + oldName + ".txt";
let newPath = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + newName + ".txt";
FileUtils.renameFile(oldPath, newPath, function(success) {
if (success) {
chatListModel.set(chatSelector.currentIndex, {
"name": newName
});
Zenith.currentChat = newName;
renameDialog.close();
}
});
}
}
}
}
background: StyledRect {
color: Appearance.m3colors.m3surfaceContainer
radius: Metrics.radius("normal")
border.color: Appearance.colors.colOutline
border.width: 1
}
header: StyledRect {
color: Appearance.m3colors.m3surfaceContainer
radius: Metrics.radius("normal")
border.color: Appearance.colors.colOutline
border.width: 1
}
}
StyledText {
text: "Thinking…"
visible: Zenith.loading
color: Appearance.colors.colSubtext
font.pixelSize: Metrics.fontSize(14)
anchors {
left: parent.left
bottom: parent.bottom
leftMargin: Metrics.margin(22)
bottomMargin: Metrics.margin(76)
}
}
}
Connections {
// only auto-select once
function onChatsListed(text) {
let lines = text.split(/\r?\n/);
let previousChat = Zenith.currentChat;
updateChatsList(lines);
// select & load once
if (!chatsInitialized) {
chatsInitialized = true;
let index = -1;
for (let i = 0; i < chatListModel.count; i++) {
if (chatListModel.get(i).name === previousChat) {
index = i;
break;
}
}
if (index === -1 && chatListModel.count > 0)
index = 0;
if (index !== -1) {
chatSelector.currentIndex = index;
Zenith.currentChat = chatListModel.get(index).name;
loadChatHistory(Zenith.currentChat);
}
return ;
}
// AFTER init: only react if current chat vanished
let stillExists = false;
for (let i = 0; i < chatListModel.count; i++) {
if (chatListModel.get(i).name === Zenith.currentChat) {
stillExists = true;
break;
}
}
if (!stillExists && chatListModel.count > 0) {
chatSelector.currentIndex = 0;
Zenith.currentChat = chatListModel.get(0).name;
loadChatHistory(Zenith.currentChat);
}
}
function onAiReply(text) {
appendMessage("AI", text.slice(5));
Zenith.loading = false;
}
function onChatLoaded(text) {
let lines = text.split(/\r?\n/);
let batch = [];
for (let l of lines) {
let line = l.trim();
if (!line.length)
continue;
let u = line.match(/^\[\d{4}-.*\] User: (.*)$/);
let a = line.match(/^\[\d{4}-.*\] AI: (.*)$/);
if (u)
batch.push({
"sender": "You",
"message": u[1]
});
else if (a)
batch.push({
"sender": "AI",
"message": a[1]
});
else if (batch.length)
batch[batch.length - 1].message += "\n" + line;
}
messageModel.clear();
for (let m of batch) messageModel.append(m)
scrollToBottom();
}
target: Zenith
}
}

View File

@@ -0,0 +1,122 @@
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
import QtQuick.Effects
import QtQuick.Controls
import qs.config
import qs.modules.components
import qs.modules.functions
StyledRect {
id: root
property bool hovered: false
property bool selected: false
required property int parentWidth
width: parentWidth
height: 50
color: {
if (selected || hovered)
return Appearance.m3colors.m3surfaceContainerHigh
else
return Appearance.m3colors.m3surface
}
radius: Metrics.radius(15)
Behavior on color {
PropertyAnimation {
duration: Metrics.chronoDuration(200)
easing.type: Easing.InSine
}
}
ClippingWrapperRectangle {
id: entryIcon
anchors.left: parent.left
anchors.leftMargin: Metrics.margin(10)
anchors.top: parent.top
anchors.topMargin: (parent.height / 2) - (size / 2)
property int size: 25
height: size
width: size
radius: Metrics.radius(1000)
color: "transparent"
child: Image {
source: Quickshell.iconPath(modelData.icon, "application-x-executable")
layer.enabled: true
layer.effect: MultiEffect { // Tint if needed, ngl this looks fucking cool when you use monochrome
saturation: (Config.runtime.appearance.tintIcons || (Config.runtime.appearance.colors.matugenScheme === "scheme-monochrome" && Config.runtime.appearance.colors.autogenerated) || Config.runtime.appearance.colors.scheme.toLowerCase() === "monochrome") ? -1.0 : 1.0
}
}
}
ColumnLayout {
anchors.left: entryIcon.right
anchors.leftMargin: Metrics.margin(10)
anchors.top: parent.top
anchors.topMargin: (parent.height / 2) - (height / 2)
height: 40
spacing: Metrics.spacing(-5)
StyledText {
font.weight: 400
text: modelData.name
font.pixelSize: Metrics.fontSize(14)
color: {
if (root.hovered || root.selected)
return Appearance.m3colors.m3onSurface
else
return Appearance.colors.colOutline
}
Behavior on color {
PropertyAnimation {
duration: Metrics.chronoDuration(200)
easing.type: Easing.InSine
}
}
}
StyledText {
font.weight: 400
text: StringUtils.shortText(modelData.comment, 65) // Limit maximum chars to 65
font.pixelSize: Metrics.fontSize(12)
color: {
if (root.hovered || root.selected)
return Qt.alpha(Appearance.m3colors.m3onSurface, 0.7)
else
return Qt.alpha(Appearance.colors.colOutline, 0.7)
}
Behavior on color {
PropertyAnimation {
duration: Metrics.chronoDuration(200)
easing.type: Easing.InSine
}
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.hovered = true
onExited: root.hovered = false
onClicked: {
modelData.execute()
IPCLoader.toggleLauncher()
}
}
}

View File

@@ -0,0 +1,170 @@
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import Quickshell.Wayland
import QtQuick
import QtQuick.Layouts
import QtQuick.Effects
import QtQuick.Controls
import qs.modules.components
import qs.modules.functions
import qs.config
import qs.services
PanelWindow {
id: launcherWindow
readonly property bool launcherOpen: Globals.visiblility.launcher
visible: launcherOpen
focusable: true
aboveWindows: true // btw I never knew this was a property (read docs)
color: "transparent"
anchors {
top: true
bottom: true
left: true
right: true
}
exclusionMode: ExclusionMode.Ignore // why this? idk but it works atleast
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
ScrollView {
id: maskId
implicitHeight: DisplayMetrics.scaledHeight(0.623)
implicitWidth: DisplayMetrics.scaledWidth(0.3)
anchors.top: parent.top
anchors.left: parent.left
anchors.leftMargin: (parent.width / 2) - (implicitWidth / 2)
anchors.topMargin: (parent.height / 2) - (implicitHeight / 2)
clip: true
focus: true
Rectangle {
id: launcher
property string currentSearch: ""
property int entryIndex: 0
property list<DesktopEntry> appList: Apps.list
Connections {
target: launcherWindow
function onLauncherOpenChanged() {
if (!launcherWindow.launcherOpen) {
launcher.currentSearch = ""
launcher.entryIndex = 0
launcher.appList = Apps.list
}
}
}
anchors.fill: parent
color: Appearance.m3colors.m3surface
radius: Metrics.radius(21)
StyledRect {
id: searchBox
anchors.top: parent.top
anchors.topMargin: Metrics.margin(10)
color: Appearance.m3colors.m3surfaceContainerLow
width: parent.width - 20
anchors.left: parent.left
anchors.leftMargin: (parent.width / 2) - (width / 2)
height: 45
radius: Metrics.radius(15)
z: 2
focus: true
Keys.onDownPressed: launcher.entryIndex += 1
Keys.onUpPressed: {
if (launcher.entryIndex != 0)
launcher.entryIndex -= 1
}
Keys.onEscapePressed: Globals.visiblility.launcher = false
Keys.onPressed: event => {
if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
launcher.appList[launcher.entryIndex].execute()
Globals.visiblility.launcher = false
} else if (event.key === Qt.Key_Backspace) {
launcher.currentSearch = launcher.currentSearch.slice(0, -1)
} else if (" abcdefghijklmnopqrstuvwxyz1234567890`~!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?".includes(event.text.toLowerCase())) {
launcher.currentSearch += event.text
}
launcher.appList = Apps.fuzzyQuery(launcher.currentSearch)
launcher.entryIndex = 0
}
MaterialSymbol {
id: iconText
anchors.left: parent.left
anchors.leftMargin: Metrics.margin(10)
icon: "search"
font.pixelSize: Metrics.fontSize(14)
font.weight: 600
anchors.top: parent.top
anchors.topMargin: (parent.height / 2) - ((font.pixelSize + 5) / 2)
opacity: 0.8
}
StyledText {
id: placeHolderText
anchors.left: iconText.right
anchors.leftMargin: Metrics.margin(10)
color: (launcher.currentSearch != "") ? Appearance.m3colors.m3onSurface : Appearance.colors.colOutline
text: (launcher.currentSearch != "") ? launcher.currentSearch : "Start typing to search ..."
font.pixelSize: Metrics.fontSize(13)
anchors.top: parent.top
anchors.topMargin: (parent.height / 2) - ((font.pixelSize + 5) / 2)
animate: false
opacity: 0.8
}
}
ScrollView {
anchors.top: searchBox.bottom
anchors.topMargin: Metrics.margin(10)
anchors.left: parent.left
anchors.leftMargin: (parent.width / 2) - (width / 2)
width: parent.width - 20
height: parent.height - searchBox.height - 20
ListView {
id: appList
anchors.fill: parent
spacing: Metrics.spacing(10)
anchors.bottomMargin: Metrics.margin(4)
model: launcher.appList
currentIndex: launcher.entryIndex
delegate: AppItem {
required property int index
required property DesktopEntry modelData
selected: index === launcher.entryIndex
parentWidth: appList.width
}
}
}
}
}
IpcHandler {
function toggle() {
Globals.visiblility.launcher = !Globals.visiblility.launcher;
}
target: "launcher"
}
}

View File

@@ -0,0 +1,313 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.config
import qs.modules.functions
import qs.modules.components
import qs.services
/*
This LauncherContent has been depricated.
And yet not used. (4/3/26)
*/
Item {
id: content
property int selectedIndex: -1
property string searchQuery: ""
property var calcVars: ({})
property alias listView: listView
property alias filteredModel: filteredModel
function launchCurrent() {
launchApp(listView.currentIndex)
}
function webSearchUrl(query) {
const engine = (Config.runtime.launcher.webSearchEngine || "").toLowerCase()
if (engine.startsWith("http"))
return engine.replace("%s", encodeURIComponent(query))
const engines = {
"google": "https://www.google.com/search?q=%s",
"duckduckgo": "https://duckduckgo.com/?q=%s",
"brave": "https://search.brave.com/search?q=%s",
"bing": "https://www.bing.com/search?q=%s",
"startpage": "https://www.startpage.com/search?q=%s"
}
const template = engines[engine] || engines["duckduckgo"]
return template.replace("%s", encodeURIComponent(query))
}
function moveSelection(delta) {
if (filteredModel.count === 0) return
selectedIndex = Math.max(0, Math.min(selectedIndex + delta, filteredModel.count - 1))
listView.currentIndex = selectedIndex
listView.positionViewAtIndex(selectedIndex, ListView.Contain)
}
function fuzzyMatch(text, pattern) {
text = text.toLowerCase()
pattern = pattern.toLowerCase()
let ti = 0, pi = 0
while (ti < text.length && pi < pattern.length) {
if (text[ti] === pattern[pi]) pi++
ti++
}
return pi === pattern.length
}
function evalExpression(expr) {
try {
const fn = new Function("vars", `
with (vars) { with (Math) { return (${expr}); } }
`)
const res = fn(calcVars)
if (res === undefined || Number.isNaN(res)) return null
return res
} catch (e) {
return null
}
}
function updateFilter() {
filteredModel.clear()
const query = searchQuery.toLowerCase().trim()
const calcVal = evalExpression(query)
if (calcVal !== null && query !== "") {
filteredModel.append({
name: String(calcVal),
displayName: String(calcVal),
comment: "Calculation",
icon: "",
exec: "",
isCalc: true,
isWeb: false
})
}
const sourceApps = AppRegistry.apps
if (query === "") {
for (let app of sourceApps) {
filteredModel.append({
name: app.name,
displayName: app.name,
comment: app.comment,
icon: AppRegistry.iconForDesktopIcon(app.icon),
exec: app.exec,
isCalc: false,
isWeb: false
})
}
selectedIndex = filteredModel.count > 0 ? 0 : -1
listView.currentIndex = selectedIndex
return
}
let exactMatches = []
let startsWithMatches = []
let containsMatches = []
let fuzzyMatches = []
for (let app of sourceApps) {
const name = app.name ? app.name.toLowerCase() : ""
const comment = app.comment ? app.comment.toLowerCase() : ""
if (name === query) exactMatches.push(app)
else if (name.startsWith(query)) startsWithMatches.push(app)
else if (name.includes(query) || comment.includes(query)) containsMatches.push(app)
else if (Config.runtime.launcher.fuzzySearchEnabled && fuzzyMatch(name, query)) fuzzyMatches.push(app)
}
const sortedResults = [
...exactMatches,
...startsWithMatches,
...containsMatches,
...fuzzyMatches
]
for (let app of sortedResults) {
filteredModel.append({
name: app.name,
displayName: app.name,
comment: app.comment,
icon: AppRegistry.iconForDesktopIcon(app.icon),
exec: app.exec,
isCalc: false,
isWeb: false
})
}
if (filteredModel.count === 0 && query !== "") {
filteredModel.append({
name: query,
displayName: "Search the web for \"" + query + "\"",
comment: "Web search",
icon: "public",
exec: webSearchUrl(query),
isCalc: false,
isWeb: true
})
}
selectedIndex = filteredModel.count > 0 ? 0 : -1
listView.currentIndex = selectedIndex
listView.positionViewAtBeginning()
}
function launchApp(idx) {
if (idx < 0 || idx >= filteredModel.count) return
const app = filteredModel.get(idx)
if (app.isCalc) return
if (app.isWeb)
Quickshell.execDetached(["xdg-open", app.exec])
else
Quickshell.execDetached(["bash", "-c", app.exec + " &"])
closeLauncher()
}
function closeLauncher() {
Globals.visiblility.launcher = false
}
function resetSearch() {
searchQuery = ""
updateFilter()
selectedIndex = -1
listView.currentIndex = -1
}
Connections {
target: AppRegistry
function onReady() {
updateFilter()
}
}
anchors.fill: parent
opacity: Globals.visiblility.launcher ? 1 : 0
anchors.margins: Metrics.margin(10)
ListModel { id: filteredModel }
ColumnLayout {
anchors.fill: parent
anchors.margins: Metrics.margin(16)
spacing: Metrics.spacing(12)
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ListView {
id: listView
model: filteredModel
spacing: Metrics.spacing(8)
clip: true
boundsBehavior: Flickable.StopAtBounds
highlightRangeMode: ListView.StrictlyEnforceRange
preferredHighlightBegin: 0
preferredHighlightEnd: height
highlightMoveDuration: 120
currentIndex: selectedIndex
delegate: Rectangle {
property bool isSelected: listView.currentIndex === index
width: listView.width
height: 60
radius: Appearance.rounding.normal
color: isSelected ? Appearance.m3colors.m3surfaceContainerHighest : "transparent"
Row {
anchors.fill: parent
anchors.margins: Metrics.margin(10)
spacing: Metrics.spacing(12)
Item {
width: 32
height: 32
Image {
anchors.fill: parent
visible: !model.isCalc && !model.isWeb
smooth: true
mipmap: true
antialiasing: true
fillMode: Image.PreserveAspectFit
sourceSize.width: 128
sourceSize.height: 128
source: model.icon
}
MaterialSymbol {
anchors.centerIn: parent
visible: model.isCalc
icon: "calculate"
iconSize: Metrics.iconSize(28)
color: Appearance.m3colors.m3onSurfaceVariant
}
MaterialSymbol {
anchors.centerIn: parent
visible: model.isWeb
icon: "public"
iconSize: Metrics.iconSize(28)
color: Appearance.m3colors.m3onSurfaceVariant
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: listView.width - 120
spacing: Metrics.spacing(4)
Text {
text: model.displayName
font.pixelSize: Metrics.fontSize(14)
font.bold: true
elide: Text.ElideRight
color: Appearance.m3colors.m3onSurface
}
Text {
text: model.comment
font.pixelSize: Metrics.fontSize(11)
elide: Text.ElideRight
color: Appearance.m3colors.m3onSurfaceVariant
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: launchApp(index)
onEntered: listView.currentIndex = index
}
}
}
}
}
Behavior on opacity {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration(400)
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.standard
}
}
}

View File

@@ -0,0 +1,56 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pam
// I just copied the default example and modified it. lol
Scope {
id: root
signal unlocked()
signal failed()
// These properties are in the context and not individual lock surfaces
// so all surfaces can share the same state.
property string currentText: ""
property bool unlockInProgress: false
property bool showFailure: false
// Clear the failure text once the user starts typing.
onCurrentTextChanged: showFailure = false;
function tryUnlock() {
if (currentText === "") return;
root.unlockInProgress = true;
pam.start();
}
PamContext {
id: pam
// Its best to have a custom pam config for quickshell, as the system one
// might not be what your interface expects, and break in some way.
// This particular example only supports passwords.
configDirectory: "pam"
config: "password.conf"
// pam_unix will ask for a response for the password prompt
onPamMessage: {
if (this.responseRequired) {
this.respond(root.currentText);
}
}
// pam_unix won't send any important messages so all we need is the completion status.
onCompleted: result => {
if (result == PamResult.Success) {
root.unlocked();
} else {
root.currentText = "";
root.showFailure = true;
}
root.unlockInProgress = false;
}
}
}

View File

@@ -0,0 +1,41 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
Scope {
// This stores all the information shared between the lock surfaces on each screen.
LockContext {
id: lockContext
onUnlocked: {
// Unlock the screen before exiting, or the compositor will display a
// fallback lock you can't interact with.
lock.locked = false;
}
}
WlSessionLock {
id: lock
// Lock the session immediately when quickshell starts.
locked: false
WlSessionLockSurface {
LockSurface {
anchors.fill: parent
context: lockContext
}
}
}
IpcHandler {
target: "lockscreen"
function lock() {
lock.locked = true;
}
function unlock() {
lock.locked = false;
}
}
}

View File

@@ -0,0 +1,267 @@
import "../../components/morphedPolygons/geometry/offset.js" as Offset
import "../../components/morphedPolygons/material-shapes.js" as MaterialShapes // For polygons
import "../../components/morphedPolygons/shapes/corner-rounding.js" as CornerRounding
import QtQuick
import QtQuick.Controls.Fusion
import QtQuick.Layouts
import Quickshell.Wayland
import qs.config
import qs.modules.functions
import qs.modules.interface.background
import qs.modules.components
import qs.modules.components.morphedPolygons
import qs.services
Rectangle {
id: root
required property LockContext context
color: "transparent"
Image {
anchors.fill: parent
z: -1
source: Config.runtime.appearance.background.path
}
RowLayout {
spacing: Metrics.spacing(20)
anchors {
top: parent.top
right: parent.right
topMargin: Metrics.spacing(20)
rightMargin: Metrics.spacing(30)
}
MaterialSymbol {
id: themeIcon
fill: 1
icon: Config.runtime.appearance.theme === "light" ? "light_mode" : "dark_mode"
iconSize: Metrics.fontSize("hugeass")
}
MaterialSymbol {
id: wifi
icon: Network.icon
iconSize: Metrics.fontSize("hugeass")
}
MaterialSymbol {
id: btIcon
icon: Bluetooth.icon
iconSize: Metrics.fontSize("hugeass")
}
StyledText {
id: keyboardLayoutIcon
text: SystemDetails.keyboardLayout
font.pixelSize: Metrics.fontSize(Appearance.font.size.huge - 4)
}
}
ColumnLayout {
anchors {
horizontalCenter: parent.horizontalCenter
top: parent.top
topMargin: Metrics.margin(150)
}
StyledText {
id: clock
visible: !Config.runtime.appearance.background.clock.isAnalog
Layout.alignment: Qt.AlignBottom
animate: false
renderType: Text.NativeRendering
font.pixelSize: Metrics.fontSize(180)
text: Time.format("hh:mm")
}
StyledText {
id: date
visible: !Config.runtime.appearance.background.clock.isAnalog
Layout.alignment: Qt.AlignCenter
animate: false
renderType: Text.NativeRendering
font.pixelSize: Metrics.fontSize(50)
text: Time.format("dddd, dd/MM")
}
Item {
id: analogClockContainer
property int hours: parseInt(Time.format("hh"))
property int minutes: parseInt(Time.format("mm"))
property int seconds: parseInt(Time.format("ss"))
readonly property real cx: width / 2
readonly property real cy: height / 2
property var shapes: [MaterialShapes.getCookie7Sided, MaterialShapes.getCookie9Sided, MaterialShapes.getCookie12Sided, MaterialShapes.getPixelCircle, MaterialShapes.getCircle, MaterialShapes.getGhostish]
visible: Config.runtime.appearance.background.clock.isAnalog
width: 350
height: 350
// Polygon
MorphedPolygon {
id: shapeCanvas
anchors.fill: parent
color: Appearance.m3colors.m3secondaryContainer
roundedPolygon: analogClockContainer.shapes[Config.runtime.appearance.background.clock.shape]()
}
ClockDial {
anchors.fill: parent
anchors.margins: parent.width * 0.12
color: Appearance.colors.colOnSecondaryContainer
z: 0
}
// Hour hand
StyledRect {
z: 2
width: 10
height: parent.height * 0.3
radius: Metrics.radius("full")
color: Qt.darker(Appearance.m3colors.m3secondary, 0.8)
x: analogClockContainer.cx - width / 2
y: analogClockContainer.cy - height
transformOrigin: Item.Bottom
rotation: (analogClockContainer.hours % 12 + analogClockContainer.minutes / 60) * 30
}
StyledRect {
anchors.centerIn: parent
width: 16
height: 16
radius: width / 2
color: Appearance.m3colors.m3secondary
z: 99 // Ensures its on top of everthing
// Inner dot
StyledRect {
width: parent.width / 2
height: parent.height / 2
radius: width / 2
anchors.centerIn: parent
z: 100
color: Appearance.m3colors.m3primaryContainer
}
}
// Minute hand
StyledRect {
width: 14
height: parent.height * 0.35
radius: Metrics.radius("full")
color: Appearance.m3colors.m3secondary
x: analogClockContainer.cx - width / 2
y: analogClockContainer.cy - height
transformOrigin: Item.Bottom
rotation: analogClockContainer.minutes * 6
z: 10 // On top of all hands
}
// Second hand
StyledRect {
visible: true
width: 4
height: parent.height * 0.28
radius: Metrics.radius("full")
color: Appearance.m3colors.m3error
x: analogClockContainer.cx - width / 2
y: analogClockContainer.cy - height
transformOrigin: Item.Bottom
rotation: analogClockContainer.seconds * 6
z: 2
}
StyledText {
text: Time.format("hh")
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: Metrics.margin(60)
font.pixelSize: Metrics.fontSize(100)
font.bold: true
opacity: 0.3
animate: false
}
StyledText {
text: Time.format("mm")
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: Metrics.margin(150)
font.pixelSize: Metrics.fontSize(100)
font.bold: true
opacity: 0.3
animate: false
}
}
}
ColumnLayout {
// Commenting this will make the password entry visible on all monitors.
visible: Window.active
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: Metrics.margin(20)
}
RowLayout {
StyledTextField {
id: passwordBox
implicitWidth: 300
padding: Metrics.padding(10)
placeholder: root.context.showFailure ? "Incorrect Password" : "Enter Password"
focus: true
enabled: !root.context.unlockInProgress
echoMode: TextInput.Password
inputMethodHints: Qt.ImhSensitiveData
// Update the text in the context when the text in the box changes.
onTextChanged: root.context.currentText = this.text
// Try to unlock when enter is pressed.
onAccepted: root.context.tryUnlock()
// Update the text in the box to match the text in the context.
// This makes sure multiple monitors have the same text.
Connections {
function onCurrentTextChanged() {
passwordBox.text = root.context.currentText;
}
target: root.context
}
}
StyledButton {
icon: "chevron_right"
padding: Metrics.padding(10)
radius: Metrics.radius("unsharpenmore")
// don't steal focus from the text box
focusPolicy: Qt.NoFocus
enabled: !root.context.unlockInProgress && root.context.currentText !== ""
onClicked: root.context.tryUnlock()
}
}
}
}

View File

@@ -0,0 +1 @@
auth required pam_unix.so

View File

@@ -0,0 +1,144 @@
import qs.config
import qs.modules.components
import qs.services
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
Rectangle {
id: root
property bool startAnim: false
property string title: "No Title"
property string body: "No content"
property var rawNotif: null
property bool tracked: false
property string image: ""
property var buttons: [
{ label: "Okay!", onClick: () => console.log("Okay") }
]
opacity: tracked ? 1 : (startAnim ? 1 : 0)
Behavior on opacity {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.InOutExpo
}
}
Layout.fillWidth: true
radius: Metrics.radius("large")
property bool hovered: mouseHandler.containsMouse
property bool clicked: mouseHandler.containsPress
color: hovered ? (clicked ? Appearance.m3colors.m3surfaceContainerHigh : Appearance.m3colors.m3surfaceContainerLow) : Appearance.m3colors.m3surface
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.InOutExpo
}
}
implicitHeight: Math.max(content.implicitHeight + 30, 80)
RowLayout {
id: content
anchors.fill: parent
anchors.margins: Metrics.margin(10)
spacing: Metrics.spacing(10)
ClippingRectangle {
width: 50
height: 50
radius: Metrics.radius("large")
clip: true
color: root.image === "" ? Appearance.m3colors.m3surfaceContainer : "transparent"
Image {
anchors.fill: parent
source: root.image
fillMode: Image.PreserveAspectCrop
smooth: true
}
MaterialSymbol {
icon: "chat"
color: Appearance.m3colors.m3onSurfaceVariant
anchors.centerIn: parent
visible: root.image === ""
iconSize: Metrics.iconSize(22)
}
}
ColumnLayout {
StyledText {
text: root.title
font.bold: true
font.pixelSize: Metrics.fontSize(18)
wrapMode: Text.Wrap
color: Appearance.m3colors.m3onSurface
Layout.fillWidth: true
}
StyledText {
text: root.body.length > 123 ? root.body.substr(0, 120) + "..." : root.body
visible: root.body.length > 0
font.pixelSize: Metrics.fontSize(12)
color: Appearance.m3colors.m3onSurfaceVariant
wrapMode: Text.Wrap
Layout.fillWidth: true
}
RowLayout {
visible: root.buttons.length > 1
Layout.preferredHeight: 40
Layout.fillWidth: true
spacing: Metrics.spacing(10)
Repeater {
model: buttons
StyledButton {
Layout.fillWidth: true
implicitHeight: 30
implicitWidth: 0
text: modelData.label
base_bg: index !== 0
? Appearance.m3colors.m3secondaryContainer
: Appearance.m3colors.m3primary
base_fg: index !== 0
? Appearance.m3colors.m3onSecondaryContainer
: Appearance.m3colors.m3onPrimary
onClicked: modelData.onClick()
}
}
}
}
}
MouseArea {
id: mouseHandler
anchors.fill: parent
hoverEnabled: true
visible: root.buttons.length === 0 || root.buttons.length === 1
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.buttons.length === 1 && root.buttons[0].onClick) {
root.buttons[0].onClick()
root.rawNotif?.notification.dismiss()
} else if (root.buttons.length === 0) {
console.log("[Notification] Dismissed a notification with no action.")
root.rawNotif.notification.tracked = false
root.rawNotif.popup = false
root.rawNotif?.notification.dismiss()
} else {
console.log("[Notification] Dismissed a notification with multiple actions.")
root.rawNotif?.notification.dismiss()
}
}
}
Component.onCompleted: {
startAnim = true
}
}

View File

@@ -0,0 +1,154 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Wayland
import Quickshell.Widgets
import qs.services
import qs.config
import qs.modules.components
Scope {
id: root
property int innerSpacing: Metrics.spacing(10)
PanelWindow {
id: window
implicitWidth: 520
visible: true
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusionMode: ExclusionMode.Normal
WlrLayershell.namespace: "nucleus:notification"
anchors {
top: true
left: Config.runtime.notifications.position.endsWith("left")
bottom: true
right: Config.runtime.notifications.position.endsWith("right")
}
Item {
id: notificationList
anchors.leftMargin: 0
anchors.topMargin: Metrics.margin(10)
anchors.rightMargin: 0
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
Rectangle {
id: bgRectangle
layer.enabled: true
anchors.top: parent.top
anchors.left: parent.left
anchors.leftMargin: Metrics.margin(20)
anchors.rightMargin: Metrics.margin(20)
anchors.right: parent.right
height: window.mask.height > 0 ? window.mask.height + 40 : 0
color: Appearance.m3colors.m3background
radius: Metrics.radius("large")
layer.effect: MultiEffect {
shadowEnabled: true
shadowOpacity: 1
shadowColor: Appearance.m3colors.m3shadow
shadowBlur: 1
shadowScale: 1
}
Behavior on height {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.InOutExpo
}
}
}
Item {
id: notificationColumn
anchors.left: parent.left
anchors.right: parent.right
Repeater {
id: rep
model: (!Config.runtime.notifications.doNotDisturb && Config.runtime.notifications.enabled) ? NotifServer.popups : []
NotificationChild {
id: child
width: notificationColumn.width - 80
anchors.horizontalCenter: notificationColumn.horizontalCenter
y: {
var pos = 0;
for (let i = 0; i < index; i++) {
var prev = rep.itemAt(i);
if (prev)
pos += prev.height + root.innerSpacing;
}
return pos + 20;
}
Component.onCompleted: {
if (!modelData.shown)
modelData.shown = true;
}
title: modelData.summary
body: modelData.body
image: modelData.image || modelData.appIcon
rawNotif: modelData
tracked: modelData.shown
buttons: modelData.actions.map((action) => {
return ({
"label": action.text,
"onClick": () => {
return action.invoke();
}
});
})
Behavior on y {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("normal")
easing.type: Easing.InOutExpo
}
}
}
}
}
}
mask: Region {
width: window.width
height: {
var total = 0;
for (let i = 0; i < rep.count; i++) {
var child = rep.itemAt(i);
if (child)
total += child.height + (i < rep.count - 1 ? root.innerSpacing : 0);
}
return total;
}
}
}
}

View File

@@ -0,0 +1,99 @@
import qs.config
import qs.modules.components
import qs.services
import QtQuick
import QtQuick.Layouts
import Quickshell.Wayland
import Quickshell
import Quickshell.Widgets
Scope {
id: root
Connections {
target: Brightness
function onBrightnessChanged() {
root.shouldShowOsd = true;
hideTimer.restart();
}
}
property var monitor: Brightness.monitors.length > 0 ? Brightness.monitors[0] : null
property bool shouldShowOsd: false
Timer {
id: hideTimer
interval: 3000
onTriggered: root.shouldShowOsd = false
}
LazyLoader {
active: root.shouldShowOsd
PanelWindow {
visible: Config.runtime.overlays.brightnessOverlayEnabled && Config.runtime.overlays.enabled
WlrLayershell.namespace: "nucleus:brightnessOsd"
exclusiveZone: 0
anchors.top: Config.runtime.overlays.brightnessOverlayPosition.startsWith("top")
anchors.bottom: Config.runtime.overlays.brightnessOverlayPosition.startsWith("bottom")
anchors.right: Config.runtime.overlays.brightnessOverlayPosition.endsWith("right")
anchors.left: Config.runtime.overlays.brightnessOverlayPosition.endsWith("left")
margins {
top: Metrics.margin(10)
bottom: Metrics.margin(10)
left: Metrics.margin(10)
right: Metrics.margin(10)
}
implicitWidth: 460
implicitHeight: 105
color: "transparent"
mask: Region {}
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.childish
color: Appearance.m3colors.m3background
RowLayout {
spacing: Metrics.spacing(10)
anchors {
fill: parent
leftMargin: Metrics.margin(15)
rightMargin: Metrics.margin(25)
}
MaterialSymbol {
property real brightnessLevel: Math.floor(Brightness.getMonitorForScreen(Hyprland.focusedMonitor)?.multipliedBrightness*100)
icon: {
if (brightnessLevel > 66) return "brightness_high"
else if (brightnessLevel > 33) return "brightness_medium"
else return "brightness_low"
}
iconSize: Metrics.iconSize(30)
}
ColumnLayout {
implicitHeight: 40
spacing: Metrics.spacing(5)
StyledText {
animate: false
text: "Brightness - " + Math.round(monitor.brightness * 100) + '%'
font.pixelSize: Metrics.fontSize(18)
}
StyledSlider {
implicitHeight: 35
from: 0
to: 100
value: Math.round(monitor.brightness * 100)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
import QtQuick
import Quickshell
Scope {
id: root
VolumeOverlay{}
BrightnessOverlay{}
}

View File

@@ -0,0 +1,109 @@
import qs.config
import qs.modules.components
import qs.services
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Pipewire
import Quickshell.Widgets
Scope {
id: root
PwObjectTracker {
objects: [ Pipewire.defaultAudioSink ]
}
Connections {
target: Pipewire.defaultAudioSink?.audio ?? null
function onVolumeChanged() {
root.shouldShowOsd = true;
hideTimer.restart();
}
function onMutedChanged() {
root.shouldShowOsd = true;
hideTimer.restart();
}
}
property bool shouldShowOsd: false
Timer {
id: hideTimer
interval: 3000
onTriggered: root.shouldShowOsd = false
}
LazyLoader {
active: root.shouldShowOsd
PanelWindow {
visible: Config.runtime.overlays.volumeOverlayEnabled && Config.runtime.overlays.enabled
WlrLayershell.namespace: "nucleus:brightnessOsd"
exclusiveZone: 0
anchors.top: Config.runtime.overlays.volumeOverlayPosition.startsWith("top")
anchors.bottom: Config.runtime.overlays.volumeOverlayPosition.startsWith("bottom")
anchors.right: Config.runtime.overlays.volumeOverlayPosition.endsWith("right")
anchors.left: Config.runtime.overlays.volumeOverlayPosition.endsWith("left")
margins {
top: Metrics.margin(10)
bottom: Metrics.margin(10)
left: Metrics.margin(10)
right: Metrics.margin(10)
}
implicitWidth: 460
implicitHeight: 105
color: "transparent"
mask: Region {}
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.childish
color: Appearance.m3colors.m3background
RowLayout {
spacing: Metrics.spacing(10)
anchors {
fill: parent
leftMargin: Metrics.margin(15)
rightMargin: Metrics.margin(25)
}
MaterialSymbol {
property real volume: Pipewire.defaultAudioSink?.audio.muted ? 0 : Pipewire.defaultAudioSink?.audio.volume * 100
icon: volume > 50 ? "volume_up" : volume > 0 ? "volume_down" : 'volume_off'
iconSize: Metrics.iconSize(34);
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: Metrics.spacing(2)
StyledText {
Layout.fillWidth: true
elide: Text.ElideRight
animate: false
text: (Pipewire.defaultAudioSink?.description ?? "Unknown") + " - " +
(Pipewire.defaultAudioSink?.audio.muted ? 'Muted' : Math.floor(Pipewire.defaultAudioSink?.audio.volume * 100) + '%')
font.pixelSize: Metrics.fontSize(18)
}
StyledSlider {
Layout.fillWidth: true
implicitHeight: 35
value: (Pipewire.defaultAudioSink?.audio.muted ? 0 : Pipewire.defaultAudioSink?.audio.volume) * 100
}
}
}
}
}
}
}

View File

@@ -0,0 +1,207 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import qs.config
import qs.services
import qs.modules.components
Scope {
id: root
property bool active: false
property var window: null
Connections {
target: Polkit
function onIsActiveChanged() {
if (Polkit.isActive) {
root.active = true;
} else if (root.active && window) {
window.closeWithAnimation();
}
}
}
LazyLoader {
active: root.active
component: Prompt {
id: window
Component.onCompleted: root.window = window
Component.onDestruction: root.window = null
onFadeOutFinished: root.active = false
Item {
id: promptContainer
property bool showPassword: false
property bool authenticating: false
anchors.centerIn: parent
width: promptBg.width
height: promptBg.height
Item {
Component.onCompleted: {
parent.layer.enabled = true;
parent.layer.effect = effectComponent;
}
Component {
id: effectComponent
MultiEffect {
shadowEnabled: true
shadowOpacity: 1
shadowColor: Appearance.colors.m3shadow
shadowBlur: 1
shadowScale: 1
}
}
}
Rectangle {
id: promptBg
width: promptLayout.width + 40
height: promptLayout.height + 40
color: Appearance.m3colors.m3surface
radius: Metrics.radius(20)
Behavior on height {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
ColumnLayout {
id: promptLayout
spacing: Metrics.spacing(10)
anchors {
left: promptBg.left
leftMargin: Metrics.margin(20)
top: promptBg.top
topMargin: Metrics.margin(20)
}
ColumnLayout {
spacing: Metrics.spacing(5)
MaterialSymbol {
icon: "security"
color: Appearance.m3colors.m3primary
font.pixelSize: Metrics.fontSize(22)
Layout.alignment: Qt.AlignHCenter
}
StyledText {
text: "Authentication required"
font.family: "Outfit SemiBold"
font.pixelSize: Metrics.fontSize(20)
Layout.alignment: Qt.AlignHCenter
}
StyledText {
text: Polkit.flow.message
Layout.alignment: Qt.AlignHCenter
}
}
RowLayout {
spacing: Metrics.spacing(5)
StyledTextField {
id: textfield
Layout.fillWidth: true
leftPadding: undefined
padding: Metrics.padding(10)
filled: false
enabled: !promptContainer.authenticating
placeholder: Polkit.flow.inputPrompt.substring(0, Polkit.flow.inputPrompt.length - 2)
echoMode: promptContainer.showPassword ? TextInput.Normal : TextInput.Password
inputMethodHints: Qt.ImhSensitiveData
focus: true
Keys.onReturnPressed: okButton.clicked()
}
StyledButton {
Layout.fillHeight: true
width: height
radius: Metrics.radius(10)
topLeftRadius: Metrics.radius(5)
bottomLeftRadius: Metrics.radius(5)
enabled: !promptContainer.authenticating
checkable: true
checked: promptContainer.showPassword
icon: promptContainer.showPassword ? 'visibility' : 'visibility_off'
onToggled: promptContainer.showPassword = !promptContainer.showPassword
}
}
RowLayout {
RowLayout {
visible: Polkit.flow.failed && !Polkit.flow.isSuccessful && !promptContainer.authenticating
MaterialSymbol {
icon: "warning"
color: Appearance.m3colors.m3error
font.pixelSize: Metrics.fontSize(15)
}
StyledText {
text: "Failed to authenticate, incorrect password."
color: Appearance.m3colors.m3error
font.pixelSize: Metrics.fontSize(15)
}
}
LoadingIcon {
visible: promptContainer.authenticating
Layout.alignment: Qt.AlignLeft
}
Item {
Layout.fillWidth: true
}
StyledButton {
radius: Metrics.radius(10)
topRightRadius: Metrics.radius(5)
bottomRightRadius: Metrics.radius(5)
secondary: true
text: "Cancel"
// enabled: !promptContainer.authenticating (Allows to cancel if stuck in loop)
onClicked: Polkit.flow.cancelAuthenticationRequest()
}
StyledButton {
id: okButton
radius: Metrics.radius(10)
topLeftRadius: Metrics.radius(5)
bottomLeftRadius: Metrics.radius(5)
text: promptContainer.authenticating ? "Authenticating..." : "OK"
enabled: !promptContainer.authenticating
onClicked: {
promptContainer.authenticating = true;
Polkit.flow.submit(textfield.text);
}
}
}
}
Connections {
target: Polkit.flow
function onIsCompletedChanged() {
if (Polkit.flow.isCompleted) {
promptContainer.authenticating = false;
}
}
function onFailedChanged() {
if (Polkit.flow.failed) {
promptContainer.authenticating = false;
}
}
function onIsCancelledChanged() {
if (Polkit.flow.isCancelled) {
promptContainer.authenticating = false;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,151 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import qs.config
import qs.services
import qs.modules.components
PanelWindow {
id: window
property bool isClosing: false
default property alias content: contentContainer.data
signal fadeOutFinished()
anchors {
top: true
left: true
right: true
bottom: true
}
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
color: "transparent"
WlrLayershell.namespace: "nucleus:prompt"
function closeWithAnimation() {
if (isClosing) return
isClosing = true
fadeOutAnim.start()
}
Item {
anchors.fill: parent
Keys.onPressed: {
if (event.key === Qt.Key_Escape) {
window.closeWithAnimation()
}
}
ScreencopyView {
id: screencopy
visible: hasContent
captureSource: window.screen
anchors.fill: parent
opacity: 0
scale: 1
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 1
blurMax: 32
brightness: -0.05
layer.enabled: true
layer.effect: MultiEffect {
autoPaddingEnabled: false
blurEnabled: true
blur: 1
blurMax: 32
}
}
}
NumberAnimation {
id: fadeInAnim
target: screencopy
property: "opacity"
from: 0
to: 1
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
running: screencopy.visible && !window.isClosing
}
ParallelAnimation {
id: scaleInAnim
running: screencopy.visible && !window.isClosing
NumberAnimation {
target: contentContainer
property: "scale"
from: 0.9
to: 1
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
ColorAnimation {
target: window
property: "color"
from: "transparent"
to: Appearance.m3colors.m3surface
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: contentContainer
property: "opacity"
from: 0
to: 1
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
}
ParallelAnimation {
id: fadeOutAnim
NumberAnimation {
target: screencopy
property: "opacity"
to: 0
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
ColorAnimation {
target: window
property: "color"
to: "transparent"
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: contentContainer
property: "opacity"
to: 0
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: contentContainer
property: "scale"
to: 0.9
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
onFinished: {
window.visible = false
window.fadeOutFinished()
}
}
Item {
id: contentContainer
anchors.fill: parent
opacity: 0
scale: 0.9
}
}
}

View File

@@ -0,0 +1,153 @@
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Services.Pipewire
import Quickshell.Wayland
import qs.config
import qs.modules.functions
import qs.services
import qs.modules.interface.lockscreen
import qs.modules.components
PanelWindow {
id: powermenu
WlrLayershell.keyboardFocus: Compositor.require("hyprland") && Globals.visiblility.powermenu
function togglepowermenu() {
Globals.visiblility.powermenu = !Globals.visiblility.powermenu; // Simple toggle logic kept in a function as it might have more things to it later on.
}
WlrLayershell.namespace: "nucleus:powermenu"
WlrLayershell.layer: WlrLayer.Top
visible: Config.initialized && Globals.visiblility.powermenu
color: "transparent"
exclusiveZone: 0
implicitWidth: DisplayMetrics.scaledWidth(0.25)
implicitHeight: DisplayMetrics.scaledWidth(0.168)
HyprlandFocusGrab {
id: grab
active: Compositor.require("hyprland")
windows: [powermenu]
}
StyledRect {
id: container
color: Appearance.m3colors.m3background
radius: Metrics.radius("verylarge")
implicitWidth: powermenu.implicitWidth
anchors.fill: parent
FocusScope {
focus: true
anchors.fill: parent
Keys.onPressed: {
if (event.key === Qt.Key_Escape)
Globals.visiblility.powermenu = false;
}
Item {
id: content
anchors.margins: Metrics.radius(12)
anchors.topMargin: Metrics.radius(16)
anchors.leftMargin: Metrics.radius(18)
anchors.fill: parent
Grid {
columns: 3
rows: 3
rowSpacing: Metrics.spacing(10)
columnSpacing: Metrics.spacing(10)
anchors.fill: parent
PowerMenuButton {
buttonIcon: "power_settings_new"
onClicked: {
Quickshell.execDetached(["poweroff"]);
Globals.visiblility.powermenu = false;
}
}
PowerMenuButton {
buttonIcon: "logout"
onClicked: {
Quickshell.execDetached(["hyprctl", "dispatch", "exit"]);
Globals.visiblility.powermenu = false;
}
}
PowerMenuButton {
buttonIcon: "sleep"
onClicked: {
Quickshell.execDetached(["systemctl", "suspend"]);
Globals.visiblility.powermenu = false;
}
}
PowerMenuButton {
buttonIcon: "lock"
onClicked: {
Quickshell.execDetached(["nucleus", "ipc", "call", "lockscreen", "lock"]);
Globals.visiblility.powermenu = false;
}
}
PowerMenuButton {
buttonIcon: "restart_alt"
onClicked: {
Quickshell.execDetached(["reboot"]);
Globals.visiblility.powermenu = false;
}
}
PowerMenuButton {
buttonIcon: "light_off"
onClicked: {
Quickshell.execDetached(["systemctl", "hibernate"]);
Globals.visiblility.powermenu = false;
}
}
}
component Anim: NumberAnimation {
running: Config.runtime.appearance.animations.enabled
duration: Metrics.chronoDuration(400)
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.standard
}
}
}
}
IpcHandler {
function toggle() {
togglepowermenu();
}
target: "powermenu"
}
component PowerMenuButton: StyledButton {
property string buttonIcon
icon: buttonIcon
iconSize: Metrics.iconSize(50)
width: powermenu.implicitWidth / 3.4
height: powermenu.implicitHeight / 2.3
radius: beingHovered ? Metrics.radius("verylarge") * 2 : Metrics.radius("large")
}
}

View File

@@ -0,0 +1,606 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell.Wayland
import qs.config
import qs.modules.components
Scope {
id: root
property bool active: false
property rect selectedRegion: Qt.rect(0, 0, 0, 0)
property string tempScreenshot: ""
IpcHandler {
target: "screen"
function capture() {
if (root.active) {
console.info("screencap", "already active");
return;
}
console.info("screencap", "starting capture");
root.active = true;
}
}
LazyLoader {
active: root.active
component: PanelWindow {
id: win
property bool closing: false
property bool ready: false
property bool processing: false
property bool windowMode: false
property string savedPath: ""
property bool savedSuccess: false
color: Appearance.m3colors.m3surface
anchors { top: true; left: true; right: true; bottom: true }
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "nucleus:screencapture"
Component.onCompleted: {
var ts = Qt.formatDateTime(new Date(), "yyyy-MM-dd_hh-mm-ss");
root.tempScreenshot = "/tmp/screenshot_" + ts + ".png";
}
function close() {
if (closing) return;
closing = true;
closeAnim.start();
}
function saveFullscreen() {
console.info("screencap", "saveFullscreen started");
win.processing = true;
screencopy.grabToImage(function(result) {
console.info("screencap", "fullscreen grabbed");
var ts = Qt.formatDateTime(new Date(), "yyyy-MM-dd_hh-mm-ss");
win.savedPath = Quickshell.env("HOME") + "/Pictures/Screenshots/screenshot_" + ts + ".png";
console.info("screencap", "saving to: " + win.savedPath);
if (result.saveToFile(win.savedPath)) {
console.info("screencap", "saved, copying");
Quickshell.execDetached({
command: ["sh", "-c", "cat '" + win.savedPath + "' | wl-copy --type image/png"]
});
win.savedSuccess = true;
} else {
console.info("screencap", "save failed");
win.savedSuccess = false;
}
win.processing = false;
console.info("screencap", "closing window");
win.close();
});
}
Component {
id: ffmpegProc
Process {
property string outputPath
property bool success: false
onExited: (code) => {
console.info("screencap", "ffmpeg exited: " + code);
success = code === 0;
if (success) {
console.info("screencap", "copying to clipboard");
Quickshell.execDetached({
command: ["sh", "-c", "cat '" + outputPath + "' | wl-copy --type image/png"]
});
}
Quickshell.execDetached({ command: ["rm", root.tempScreenshot] });
win.savedSuccess = success;
win.processing = false;
console.info("screencap", "done, closing");
win.close();
destroy();
}
}
}
function saveRegion(rect, suffix) {
console.info("screencap", "saveRegion started: " + rect.x + "," + rect.y + " " + rect.width + "x" + rect.height);
screencopy.grabToImage(function(result) {
console.info("screencap", "full screenshot grabbed for cropping");
if (!result.saveToFile(root.tempScreenshot)) {
console.info("screencap", "ERROR: failed to save temp screenshot");
win.savedSuccess = false;
win.processing = false;
win.close();
return;
}
console.info("screencap", "temp saved, cropping with ffmpeg");
var ts = Qt.formatDateTime(new Date(), "yyyy-MM-dd_hh-mm-ss");
win.savedPath = Quickshell.env("HOME") + "/Pictures/Screenshots/screenshot_" + ts + suffix + ".png";
ffmpegProc.createObject(win, {
command: ["ffmpeg", "-i", root.tempScreenshot, "-vf", "crop=" + Math.floor(rect.width) + ":" + Math.floor(rect.height) + ":" + Math.floor(rect.x) + ":" + Math.floor(rect.y), "-y", win.savedPath],
outputPath: win.savedPath,
running: true
});
});
}
function captureFullscreen() {
win.processing = true;
saveFullscreen();
}
function captureWindow(rect) {
win.processing = true;
saveRegion(rect, "_window");
}
function captureRegion() {
if (!ready || !selection.hasSelection) return;
win.processing = true;
saveRegion(root.selectedRegion, "_region");
}
ScreencopyView {
id: screencopy
anchors.fill: parent
captureSource: win.screen
z: -999
live: false
onHasContentChanged: {
console.info("screencap", "hasContent: " + hasContent);
if (hasContent) {
console.info("screencap", "grabbing for preview");
grabToImage(function(result) {
console.info("screencap", "preview grabbed: " + result.url);
frozen.source = result.url;
readyTimer.start();
});
}
}
}
Timer {
id: readyTimer
interval: Metrics.chronoDuration("normal") + 50
onTriggered: {
console.info("screencap", "UI ready");
win.ready = true;
}
}
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: win.close()
Keys.onPressed: event => {
if (event.key === Qt.Key_F) {
win.captureFullscreen();
event.accepted = true;
} else if (event.key === Qt.Key_W) {
win.windowMode = !win.windowMode;
event.accepted = true;
}
}
Image {
id: bg
anchors.fill: parent
source: Config.runtime.appearance.background.path
fillMode: Image.PreserveAspectCrop
opacity: 0
scale: 1
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 1.0
blurMax: 64
brightness: -0.1
}
onStatusChanged: {
if (status === Image.Ready) fadeIn.start();
}
NumberAnimation on opacity {
id: fadeIn
to: 1
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
}
Item {
id: container
anchors.centerIn: parent
width: win.width
height: win.height
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowOpacity: 1
shadowColor: Appearance.m3colors.m3shadow
}
Image {
id: frozen
anchors.fill: parent
fillMode: Image.PreserveAspectFit
smooth: true
cache: false
}
Item {
id: darkOverlay
anchors.fill: parent
visible: (selection.hasSelection || selection.selecting) && !win.windowMode
Rectangle {
y: 0
width: parent.width
height: selection.sy
color: "black"
opacity: 0.5
}
Rectangle {
y: selection.sy + selection.h
width: parent.width
height: parent.height - (selection.sy + selection.h)
color: "black"
opacity: 0.5
}
Rectangle {
x: 0
y: selection.sy
width: selection.sx
height: selection.h
color: "black"
opacity: 0.5
}
Rectangle {
x: selection.sx + selection.w
y: selection.sy
width: parent.width - (selection.sx + selection.w)
height: selection.h
color: "black"
opacity: 0.5
}
Rectangle {
x: selection.sx
y: selection.sy
width: selection.w
height: selection.h
color: "black"
opacity: win.processing ? 0.6 : 0
Behavior on opacity {
NumberAnimation { duration: 200 }
}
LoadingIcon {
anchors.centerIn: parent
visible: win.processing
}
}
}
Rectangle {
id: outline
x: selection.sx
y: selection.sy
width: selection.w
height: selection.h
color: "transparent"
border.color: Appearance.m3colors.m3primary
border.width: 2
visible: (selection.selecting || selection.hasSelection) && !win.windowMode
}
Rectangle {
visible: selection.selecting
anchors.top: outline.bottom
anchors.topMargin: Metrics.margin(10)
anchors.horizontalCenter: outline.horizontalCenter
width: coords.width + 10
height: coords.height + 10
color: Appearance.m3colors.m3surface
radius: Metrics.radius(20)
StyledText {
id: coords
anchors.centerIn: parent
font.pixelSize: Metrics.fontSize(14)
animate: false
color: Appearance.m3colors.m3onSurface
property real scaleX: container.width / win.width
property real scaleY: container.height / win.height
text: Math.floor(selection.sx/scaleX) + "," + Math.floor(selection.sy/scaleY) + " " + Math.floor(selection.w/scaleX) + "x" + Math.floor(selection.h/scaleY)
}
}
MouseArea {
id: selection
anchors.fill: parent
enabled: win.ready && !win.windowMode
property real x1: 0
property real y1: 0
property real x2: 0
property real y2: 0
property bool selecting: false
property bool hasSelection: false
property real xp: 0
property real yp: 0
property real wp: 0
property real hp: 0
property real sx: xp * parent.width
property real sy: yp * parent.height
property real w: wp * parent.width
property real h: hp * parent.height
onPressed: mouse => {
if (!win.ready) return;
x1 = Math.max(0, Math.min(mouse.x, width));
y1 = Math.max(0, Math.min(mouse.y, height));
x2 = x1;
y2 = y1;
selecting = true;
hasSelection = false;
}
onPositionChanged: mouse => {
if (selecting) {
x2 = Math.max(0, Math.min(mouse.x, width));
y2 = Math.max(0, Math.min(mouse.y, height));
xp = Math.min(x1, x2) / width;
yp = Math.min(y1, y2) / height;
wp = Math.abs(x2 - x1) / width;
hp = Math.abs(y2 - y1) / height;
}
}
onReleased: mouse => {
if (!selecting) return;
x2 = Math.max(0, Math.min(mouse.x, width));
y2 = Math.max(0, Math.min(mouse.y, height));
selecting = false;
hasSelection = Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5;
if (hasSelection) {
xp = Math.min(x1, x2) / width;
yp = Math.min(y1, y2) / height;
wp = Math.abs(x2 - x1) / width;
hp = Math.abs(y2 - y1) / height;
root.selectedRegion = Qt.rect(
Math.min(x1, x2) * win.screen.width / width,
Math.min(y1, y2) * win.screen.height / height,
Math.abs(x2 - x1) * win.screen.width / width,
Math.abs(y2 - y1) * win.screen.height / height
);
win.captureRegion();
} else {
win.close();
}
}
}
Repeater {
model: {
if (!win.windowMode || !win.ready) return [];
var ws = Hyprland.focusedMonitor?.activeWorkspace;
return ws?.toplevels ? ws.toplevels.values : [];
}
delegate: Item {
required property var modelData
property var w: modelData?.lastIpcObject
visible: w?.at && w?.size
property real barX: 0
property real barY: 0
property real sx: container.width / (win.screen.width - barX)
property real sy: container.height / (win.screen.height - barY)
x: visible ? (w.at[0] - barX) * sx : 0
y: visible ? (w.at[1] - barY) * sy : 0
width: visible ? w.size[0] * sx : 0
height: visible ? w.size[1] * sy : 0
z: w?.floating ? (hover.containsMouse ? 1000 : 100) : (hover.containsMouse ? 50 : 0)
Rectangle {
anchors.fill: parent
color: "transparent"
border.color: Appearance.m3colors.m3primary
border.width: hover.containsMouse ? 3 : 0
radius: Metrics.radius(8)
Behavior on border.width {
NumberAnimation { duration: Metrics.chronoDuration(150) }
}
}
Rectangle {
anchors.fill: parent
color: Appearance.m3colors.m3primary
opacity: hover.containsMouse ? 0.15 : 0
radius: Metrics.radius(8)
Behavior on opacity {
NumberAnimation { duration: Metrics.chronoDuration(150) }
}
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
win.captureWindow(Qt.rect(w.at[0], w.at[1], w.size[0], w.size[1]));
}
}
}
}
ParallelAnimation {
running: win.visible && !win.closing && frozen.source != ""
NumberAnimation {
target: bg
property: "scale"
to: bg.scale + 0.05
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: container
property: "width"
to: win.width * 0.8
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: container
property: "height"
to: win.height * 0.8
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
}
ParallelAnimation {
id: closeAnim
NumberAnimation {
target: bg
property: "scale"
to: bg.scale - 0.05
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: container
property: "width"
to: win.width
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: container
property: "height"
to: win.height
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: darkOverlay
property: "opacity"
to: 0
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: outline
property: "opacity"
to: 0
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
onFinished: {
root.active = false;
if (win.savedSuccess) {
Quickshell.execDetached({
command: ["notify-send", "Screenshot saved", win.savedPath.split("/").pop() + " (copied)"]
});
} else if (win.savedPath !== "") {
Quickshell.execDetached({
command: ["notify-send", "Screenshot failed", "Could not save"]
});
}
}
}
}
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: Metrics.margin(30)
width: row.width + 20
height: row.height + 20
visible: true
Rectangle {
anchors.fill: parent
color: Appearance.m3colors.m3surface
radius: Metrics.radius("large")
}
RowLayout {
id: row
anchors.centerIn: parent
StyledButton {
icon: "fullscreen"
text: "Full screen"
tooltipText: "Capture the whole screen [F]"
onClicked: win.captureFullscreen()
}
Rectangle {
Layout.fillHeight: true
width: 2
color: Appearance.m3colors.m3onSurfaceVariant
opacity: 0.2
}
StyledButton {
icon: "window"
checkable: true
checked: win.windowMode
text: "Window"
tooltipText: "Hover and click a window [W]"
onClicked: win.windowMode = !win.windowMode
}
StyledButton {
secondary: true
icon: "close"
tooltipText: "Exit [Escape]"
onClicked: win.close()
}
}
}
}
HyprlandFocusGrab {
id: grab
windows: [win]
}
onVisibleChanged: {
if (visible) grab.active = true
}
Connections {
target: grab
function onActiveChanged() {
if (!grab.active && !win.closing) win.close();
}
}
}
}
}

View File

@@ -0,0 +1,115 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.config
import qs.services
Item {
id: root
Layout.fillWidth: true
Layout.fillHeight: true
property int logoOffset: -30
Column {
anchors.centerIn: parent
width: 460
spacing: Metrics.spacing(12)
Item {
width: parent.width
height: Metrics.fontSize(200)
StyledText {
text: SystemDetails.osIcon
anchors.centerIn: parent
x: root.logoOffset
font.pixelSize: Metrics.fontSize(200)
}
}
StyledText {
text: "Nucleus Shell"
width: parent.width
horizontalAlignment: Text.AlignHCenter
font.family: "Outfit ExtraBold"
font.pixelSize: Metrics.fontSize(26)
}
StyledText {
text: "A shell built to get things done."
width: parent.width
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Metrics.fontSize(14)
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Metrics.spacing(10)
StyledButton {
text: "View on GitHub"
icon: "code"
secondary: true
onClicked: Qt.openUrlExternally("https://github.com/xZepyx/nucleus-shell")
}
StyledButton {
text: "Report Issue"
icon: "bug_report"
secondary: true
onClicked: Qt.openUrlExternally("https://github.com/xZepyx/nucleus-shell/issues")
}
}
}
StyledText {
text: "Nucleus-Shell v" + Config.runtime.shell.version
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: Metrics.margin(24)
font.pixelSize: Metrics.fontSize(12)
}
StyledRect {
width: 52
height: 52
radius: Appearance.rounding.small
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Metrics.margin(24)
StyledText {
text: "↻"
anchors.centerIn: parent
font.pixelSize: Metrics.fontSize(22)
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: {
Globals.states.settingsOpen = false
Quickshell.execDetached(["notify-send", "Updating Nucleus Shell"])
Quickshell.execDetached([
"kitty",
"--hold",
"bash",
"-c",
Directories.scriptsPath + "/system/update.sh"
])
}
}
}
}

View File

@@ -0,0 +1,364 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.services
import qs.plugins
ContentMenu {
title: "Appearance"
description: "Adjust how the desktop looks like."
ContentCard {
ColumnLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(16)
ColumnLayout {
spacing: Metrics.spacing(4)
StyledText {
text: "Select Theme"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Choose between dark or light mode."
font.pixelSize: Metrics.fontSize(12)
color: "#888888"
}
}
RowLayout {
Layout.leftMargin: Metrics.margin(15)
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
spacing: Metrics.spacing(16)
StyledButton {
Layout.preferredHeight: 300
Layout.preferredWidth: 460
Layout.maximumHeight: 400
Layout.maximumWidth: 500
icon: "dark_mode"
iconSize: Metrics.iconSize(64)
checked: Config.runtime.appearance.theme === "dark"
hoverEnabled: true
onClicked: {
if (!Config.runtime.appearance.colors.autogenerated) {
const scheme = Config.runtime.appearance.colors.scheme
const file = Theme.map[scheme]?.dark
if (!file) {
Theme.notifyMissingVariant(scheme, "dark")
return
}
Config.updateKey("appearance.theme", "dark")
Quickshell.execDetached([
"nucleus", "theme", "switch", file
])
} else {
Config.updateKey("appearance.theme", "dark")
Quickshell.execDetached([
"nucleus", "ipc", "call", "global", "regenColors"
])
}
}
}
StyledButton {
Layout.preferredHeight: 300
Layout.preferredWidth: 460
Layout.maximumHeight: 400
Layout.maximumWidth: 500
icon: "light_mode"
iconSize: Metrics.iconSize(64)
checked: Config.runtime.appearance.theme === "light"
hoverEnabled: true
onClicked: {
if (!Config.runtime.appearance.colors.autogenerated) {
const scheme = Config.runtime.appearance.colors.scheme
const file = Theme.map[scheme]?.light
if (!file) {
Theme.notifyMissingVariant(scheme, "light")
return
}
Config.updateKey("appearance.theme", "light")
Quickshell.execDetached([
"nucleus", "theme", "switch", file
])
} else {
Config.updateKey("appearance.theme", "light")
Quickshell.execDetached([
"nucleus", "ipc", "call", "global", "regenColors"
])
}
}
}
}
Item {
width: Metrics.spacing(30)
}
}
}
ContentCard {
RowLayout {
opacity: autogeneratedColorsSelector.enabled ? 1 : 0.8
ColumnLayout {
StyledText {
text: "Color Generation Schemes:"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Choose the scheme for autogenerated color generation."
font.pixelSize: Metrics.fontSize(12)
}
}
Item { Layout.fillWidth: true }
StyledDropDown {
id: autogeneratedColorsSelector
label: "Color Scheme"
model: [
"scheme-content",
"scheme-expressive",
"scheme-fidelity",
"scheme-fruit-salad",
"scheme-monochrome",
"scheme-neutral",
"scheme-rainbow",
"scheme-tonal-spot"
]
currentIndex: model.indexOf(Config.runtime.appearance.colors.matugenScheme)
onSelectedIndexChanged: (index) => {
if (!Config.runtime.appearance.colors.autogenerated)
return
const selectedScheme = model[index]
Config.updateKey("appearance.colors.matugenScheme", selectedScheme)
Quickshell.execDetached([
"nucleus", "ipc", "call", "global", "regenColors"
])
}
enabled: Config.runtime.appearance.colors.autogenerated
}
}
RowLayout {
opacity: predefinedThemeSelector.enabled ? 1 : 0.8
ColumnLayout {
StyledText {
font.pixelSize: Metrics.fontSize(16)
text: "Predefined/Custom Themes:"
}
StyledText {
font.pixelSize: Metrics.fontSize(12)
text: "Choose a pre-defined theme for your interface."
}
}
Item { Layout.fillWidth: true }
StyledDropDown {
id: predefinedThemeSelector
label: "Theme"
model: Object.keys(Theme.map)
currentIndex: model.indexOf(Config.runtime.appearance.colors.scheme)
onSelectedIndexChanged: (index) => {
if (Config.runtime.appearance.colors.autogenerated)
return
const selectedTheme = model[index]
const variant = Config.runtime.appearance.theme
const file = Theme.map[selectedTheme][variant]
if (!file) return
Config.updateKey("appearance.colors.scheme", selectedTheme)
Quickshell.execDetached([
"nucleus", "theme", "switch", file
])
}
enabled: !Config.runtime.appearance.colors.autogenerated
}
}
}
ContentCard {
StyledSwitchOption {
title: "Tint Icons"
description: "Either tint icons across the shell or keep them colorized."
prefField: "appearance.tintIcons"
}
StyledSwitchOption {
title: "Use Autogenerated Themes"
description: "Use autogenerated themes."
prefField: "appearance.colors.autogenerated"
}
StyledSwitchOption {
title: "Use User Defined Themes"
description: "Enabling this will also run the default `config.toml` in `~/.config/matugen` dir."
prefField: "appearance.colors.runMatugenUserWide"
}
}
ContentCard {
StyledText {
text: "Clock"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledSwitchOption {
title: "Show Clock"
description: "Whether to show or disable the clock on the background."
prefField: "appearance.background.clock.enabled"
}
StyledSwitchOption {
title: "Analog Variant"
description: "Whether to use analog clock or not."
prefField: "appearance.background.clock.isAnalog"
}
StyledSwitchOption {
title: "Rotate Clock Polygon"
description: "Rotate the shape polygon of the analog clock."
prefField: "appearance.background.clock.rotatePolygonBg"
enabled: Config.runtime.appearance.background.clock.isAnalog
opacity: enabled ? 1 : 0.8
}
NumberStepper {
label: "Rotation Duration"
description: "Adjust the duration in which the clock rotates 360* (Seconds)."
prefField: "appearance.background.clock.rotationDuration"
minimum: 1
maximum: 40
step: 1
}
RowLayout {
id: shapeSelector
ColumnLayout {
StyledText {
text: "Analog Clock Shape"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Choose the analog clock's shape."
font.pixelSize: Metrics.fontSize(12)
}
}
Item { Layout.fillWidth: true }
StyledDropDown {
label: "Shape Type"
model: ["Cookie 7 Sided", "Cookie 9 Sided", "Cookie 12 Sided", "Pixelated Circle", "Circle"]
currentIndex: Config.runtime.appearance.background.clock.shape
onSelectedIndexChanged: (index) => {
Config.updateKey(
"appearance.background.clock.shape",
index
)
}
}
}
}
ContentCard {
StyledText {
text: "Rounding"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
NumberStepper {
label: "Factor"
description: "Adjust the rounding factor."
prefField: "appearance.rounding.factor"
minimum: 0
maximum: 1
step: 0.1
}
}
ContentCard {
StyledText {
text: "Font"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
NumberStepper {
label: "Scale"
description: "Adjust the font scale."
prefField: "appearance.font.scale"
minimum: 0.1
maximum: 2
step: 0.1
}
}
ContentCard {
StyledText {
text: "Transparency"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledSwitchOption {
title: "Enabled"
description: "Whether to enable or disable transparency."
prefField: "appearance.transparency.enabled"
}
NumberStepper {
label: "Factor"
description: "Adjust the alpha value for transparency."
prefField: "appearance.transparency.alpha"
minimum: 0.1
maximum: 1
step: 0.1
}
}
ContentCard {
StyledText {
text: "Animations"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledSwitchOption {
title: "Enabled"
description: "Whether to enable or disable animations (applies everywhere in the shell)."
prefField: "appearance.animations.enabled"
}
NumberStepper {
label: "Duration Scale"
description: "Adjust the duration scale of the animations."
prefField: "appearance.animations.durationScale"
minimum: 0.1
maximum: 1
step: 0.1
}
}
}

View File

@@ -0,0 +1,355 @@
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import qs.modules.functions
import qs.config
import qs.modules.components
import qs.services
ContentMenu {
title: "Sound"
description: "Volume and audio devices"
ContentCard {
ColumnLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(20)
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(16)
Rectangle {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
radius: Metrics.radius("large")
color: Appearance.m3colors.m3primaryContainer
MaterialSymbol {
anchors.centerIn: parent
icon: "volume_up"
color: Appearance.m3colors.m3onPrimaryContainer
iconSize: Metrics.iconSize(24)
}
}
ColumnLayout {
Layout.fillWidth: true
StyledText {
text: "Output"
font.pixelSize: Metrics.fontSize(16)
font.family: Metrics.fontFamily("Outfit Medium")
color: Appearance.m3colors.m3onSurface
}
StyledText {
text: Volume.defaultSink.description
font.pixelSize: Metrics.fontSize(13)
color: Appearance.m3colors.m3onSurfaceVariant
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Appearance.m3colors.m3outlineVariant
opacity: 0.4
}
ColumnLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(12)
RowLayout {
Layout.fillWidth: true
StyledText {
text: "Volume"
font.pixelSize: Metrics.fontSize(14)
font.family: Metrics.fontFamily("Outfit Medium")
color: Appearance.m3colors.m3onSurface
}
Item { Layout.fillWidth: true }
StyledText {
animate: false
text: Math.round(Volume.defaultSink.audio.volume * 100) + "%"
font.pixelSize: Metrics.fontSize(14)
font.family: Metrics.fontFamily("Outfit SemiBold")
color: Appearance.m3colors.m3primary
}
}
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(16)
MaterialSymbol {
icon: Volume.defaultSink.audio.muted ? "volume_off"
: Volume.defaultSink.audio.volume < 0.33 ? "volume_mute"
: Volume.defaultSink.audio.volume < 0.66 ? "volume_down"
: "volume_up"
color: Appearance.m3colors.m3onSurfaceVariant
iconSize: Metrics.iconSize(24)
}
StyledSlider {
id: outputVolumeSlider
Layout.fillWidth: true
value: Volume.defaultSink.audio.volume * 100
onValueChanged: Volume.setVolume(value / 100)
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(8)
StyledText {
text: "Device"
font.pixelSize: Metrics.fontSize(14)
font.family: Metrics.fontFamily("Outfit Medium")
color: Appearance.m3colors.m3onSurface
}
StyledDropDown {
Layout.fillWidth: true
label: "Output device"
model: Volume.sinks.map(d => d.description)
currentIndex: {
for (let i = 0; i < Volume.sinks.length; i++)
if (Volume.sinks[i].name === Volume.defaultSink.name) return i
return -1
}
onSelectedIndexChanged: index => {
if (index >= 0 && index < Volume.sinks.length)
Volume.setDefaultSink(Volume.sinks[index])
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 56
radius: Metrics.radius("small")
color: Appearance.m3colors.m3surfaceContainerHigh
RowLayout {
anchors.fill: parent
anchors.leftMargin: Metrics.margin(16)
anchors.rightMargin: Metrics.margin(16)
spacing: Metrics.spacing(12)
MaterialSymbol {
icon: Volume.defaultSink.audio.muted ? "volume_off" : "volume_up"
color: Volume.defaultSink.audio.muted ? Appearance.m3colors.m3error : Appearance.m3colors.m3onSurfaceVariant
iconSize: Metrics.iconSize(24)
}
StyledText {
Layout.fillWidth: true
text: "Mute output"
font.pixelSize: Metrics.fontSize(14)
color: Appearance.m3colors.m3onSurface
}
StyledSwitch {
checked: Volume.defaultSink.audio.muted
onToggled: Volume.toggleMuted(Volume.defaultSink)
}
}
}
}
}
ContentCard {
ColumnLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(20)
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(16)
Rectangle {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
radius: Metrics.radius("large")
color: Appearance.m3colors.m3secondaryContainer
MaterialSymbol {
anchors.centerIn: parent
icon: "mic"
color: Appearance.m3colors.m3onSecondaryContainer
iconSize: Metrics.iconSize(24)
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(2)
StyledText {
text: "Input"
font.pixelSize: Metrics.fontSize(16)
font.family: Metrics.fontFamily("Outfit Medium")
color: Appearance.m3colors.m3onSurface
}
StyledText {
visible: Volume.sources.length > 0
text: Volume.defaultSource.description
font.pixelSize: Metrics.fontSize(13)
color: Appearance.m3colors.m3onSurfaceVariant
}
}
}
Rectangle {
visible: Volume.sources.length === 0
Layout.fillWidth: true
Layout.preferredHeight: 120
radius: Metrics.radius("small")
color: Appearance.m3colors.m3surfaceContainerHigh
ColumnLayout {
anchors.centerIn: parent
spacing: Metrics.spacing(8)
MaterialSymbol {
icon: "mic_off"
iconSize: Metrics.iconSize(48)
color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.3)
Layout.alignment: Qt.AlignHCenter
}
StyledText {
text: "No input devices"
font.pixelSize: Metrics.fontSize(14)
font.family: Metrics.fontFamily("Outfit Medium")
color: Appearance.m3colors.m3onSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
}
Rectangle {
visible: Volume.sources.length > 0
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Appearance.m3colors.m3outlineVariant
opacity: 0.4
}
ColumnLayout {
visible: Volume.sources.length > 0
Layout.fillWidth: true
spacing: Metrics.spacing(12)
RowLayout {
Layout.fillWidth: true
StyledText {
text: "Volume"
font.pixelSize: Metrics.fontSize(14)
font.family: Metrics.fontFamily("Outfit Medium")
color: Appearance.m3colors.m3onSurface
}
Item { Layout.fillWidth: true }
StyledText {
animate: false
text: Math.round(Volume.defaultSource.audio.volume * 100) + "%"
font.pixelSize: Metrics.fontSize(14)
font.family: Metrics.fontFamily("Outfit SemiBold")
color: Appearance.m3colors.m3primary
}
}
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(16)
MaterialSymbol {
icon: Volume.defaultSource.audio.muted ? "mic_off" : "mic"
color: Appearance.m3colors.m3onSurfaceVariant
iconSize: Metrics.iconSize(24)
}
StyledSlider {
id: inputVolumeSlider
Layout.fillWidth: true
value: Volume.defaultSource.audio.volume * 100
onValueChanged: Volume.setSourceVolume(value / 100)
}
}
}
ColumnLayout {
visible: Volume.sources.length > 0
Layout.fillWidth: true
spacing: Metrics.spacing(8)
StyledText {
text: "Device"
font.pixelSize: Metrics.fontSize(14)
font.family: Metrics.fontFamily("Outfit Medium")
color: Appearance.m3colors.m3onSurface
}
StyledDropDown {
Layout.fillWidth: true
label: "Input device"
model: Volume.sources.map(d => d.description)
currentIndex: {
for (let i = 0; i < Volume.sources.length; i++)
if (Volume.sources[i].name === Volume.defaultSource.name) return i
return -1
}
onSelectedIndexChanged: index => {
if (index >= 0 && index < Volume.sources.length)
Volume.setDefaultSource(Volume.sources[index])
}
}
}
Rectangle {
visible: Volume.sources.length > 0
Layout.fillWidth: true
Layout.preferredHeight: 56
radius: Metrics.radius("small")
color: Appearance.m3colors.m3surfaceContainerHigh
RowLayout {
anchors.fill: parent
anchors.leftMargin: Metrics.margin(16)
anchors.rightMargin: Metrics.margin(16)
spacing: Metrics.spacing(12)
MaterialSymbol {
icon: Volume.defaultSource.audio.muted ? "mic_off" : "mic"
color: Volume.defaultSource.audio.muted ? Appearance.m3colors.m3error : Appearance.m3colors.m3onSurfaceVariant
iconSize: Metrics.iconSize(24)
}
StyledText {
Layout.fillWidth: true
text: "Mute input"
font.pixelSize: Metrics.fontSize(14)
color: Appearance.m3colors.m3onSurface
}
StyledSwitch {
checked: Volume.defaultSource.audio.muted
onToggled: Volume.toggleMuted(Volume.defaultSource)
}
}
}
}
}
}

View File

@@ -0,0 +1,300 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import QtQuick.Controls
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.services
ContentMenu {
property string barKey: "bar"
title: "Bar"
description: "Adjust the bar's look."
ContentCard {
id: monitorSelectorCard
StyledText {
text: "Monitor Bar Configuration"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledText {
text: (Config.runtime.monitors?.[monitorSelector.model[monitorSelector.currentIndex]]?.bar)
? "This monitor has its own bar configuration."
: "This monitor currently uses the global bar."
wrapMode: Text.WordWrap
}
RowLayout {
spacing: Metrics.spacing("normal")
StyledDropDown {
id: monitorSelector
Layout.preferredWidth: 220
model: Xrandr.monitors.map(m => m.name)
currentIndex: 0
onCurrentIndexChanged: monitorSelectorCard.updateMonitorProperties()
}
Item { Layout.fillWidth: true }
StyledButton {
id: createButton
icon: "add"
text: "Override Bar: (" + monitorSelector.model[monitorSelector.currentIndex] + ")"
Layout.preferredWidth: 280
onClicked: {
const monitorName = monitorSelector.model[monitorSelector.currentIndex]
if (!monitorName) return
if (!Config.runtime.monitors) Config.runtime.monitors = {}
if (!Config.runtime.monitors[monitorName])
Config.runtime.monitors[monitorName] = {}
const defaultBar = {
density: 50,
enabled: true,
floating: false,
gothCorners: true,
margins: 16,
merged: false,
modules: {
height: 34,
paddingColor: "#1f1f1f",
radius: 17,
statusIcons: {
bluetoothStatusEnabled: true,
enabled: true,
networkStatusEnabled: true
},
systemUsage: {
cpuStatsEnabled: true,
enabled: true,
memoryStatsEnabled: true,
tempStatsEnabled: true
},
workspaces: {
enabled: true,
showAppIcons: true,
showJapaneseNumbers: false,
workspaceIndicators: 8
}
},
position: "top",
radius: 23
}
Config.updateKey("monitors." + monitorName + ".bar", defaultBar)
monitorSelectorCard.updateMonitorProperties()
}
}
StyledButton {
id: deleteButton
icon: "delete"
text: "Use Global Bar: (" + monitorSelector.model[monitorSelector.currentIndex] + ")"
secondary: true
Layout.preferredWidth: 280
onClicked: {
const monitorName = monitorSelector.model[monitorSelector.currentIndex]
if (!monitorName) return
Config.updateKey("monitors." + monitorName + ".bar", undefined)
monitorSelectorCard.updateMonitorProperties()
}
}
}
function updateMonitorProperties() {
const monitorName = monitorSelector.model[monitorSelector.currentIndex]
const monitorBar = Config.runtime.monitors?.[monitorName]?.bar
barKey = monitorBar ? "monitors." + monitorName + ".bar" : "bar"
createButton.enabled = !monitorBar
deleteButton.enabled = !!monitorBar
monitorSelector.model = Xrandr.monitors.map(m => m.name)
monitorSelector.currentIndex = Xrandr.monitors.findIndex(m => m.name === monitorName)
}
}
ContentCard {
StyledText {
text: "Bar"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
ColumnLayout {
StyledText {
text: "Position"
font.pixelSize: Metrics.fontSize(16)
}
RowLayout {
spacing: Metrics.spacing(8)
Repeater {
model: ["Top", "Bottom", "Left", "Right"]
delegate: StyledButton {
property string pos: modelData.toLowerCase()
text: modelData
Layout.fillWidth: true
checked: ConfigResolver.bar(monitorSelector.model[monitorSelector.currentIndex]).position === pos
topLeftRadius: Metrics.radius("normal")
topRightRadius: Metrics.radius("normal")
bottomLeftRadius: Metrics.radius("normal")
bottomRightRadius: Metrics.radius("normal")
onClicked: Config.updateKey(barKey + ".position", pos)
}
}
}
}
StyledSwitchOption {
title: "Enabled"
description: "Toggle the bar visibility on/off"
prefField: barKey + ".enabled"
}
StyledSwitchOption {
title: "Floating Bar"
description: "Make the bar float above other windows instead of being part of the desktop"
prefField: barKey + ".floating"
}
StyledSwitchOption {
title: "Goth Corners"
description: "Apply gothic-style corner cutouts to the bar"
prefField: barKey + ".gothCorners"
}
StyledSwitchOption {
title: "Merged Layout"
description: "Merge all modules into a single continuous layout"
prefField: barKey + ".merged"
}
}
ContentCard {
StyledText {
text: "Bar Rounding & Size"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
NumberStepper {
label: "Bar Density"
prefField: barKey + ".density"
description: "Modify the bar's density"
minimum: 40
maximum: 128
}
NumberStepper {
label: "Bar Radius"
prefField: barKey + ".radius"
description: "Modify the bar's radius"
minimum: 10
maximum: 128
}
NumberStepper {
label: "Module Container Radius"
prefField: barKey + ".modules.radius"
description: "Modify the bar's module.radius"
minimum: 10
maximum: 128
}
NumberStepper {
label: "Module Height"
prefField: barKey + ".modules.height"
description: "Modify the bar's module.height"
minimum: 10
maximum: 128
}
NumberStepper {
label: "Workspace Indicators"
prefField: barKey + ".modules.workspaces.workspaceIndicators"
description: "Adjust how many workspace indicators to show."
minimum: 1
maximum: 10
}
}
ContentCard {
StyledText {
text: "Bar Modules"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledText {
text: "Workspaces"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
StyledSwitchOption {
title: "Enabled"
description: "Show workspace indicator module"
prefField: barKey + ".modules.workspaces.enabled"
}
StyledSwitchOption {
title: "Show App Icons"
description: "Display application icons in workspace indicators"
prefField: barKey + ".modules.workspaces.showAppIcons"
enabled: !barKey.modules.workspaces.showJapaneseNumbers && Compositor.require("hyprland")
opacity: !barKey.modules.workspaces.showJapaneseNumbers && Compositor.require("hyprland") ? 1 : 0.8
}
StyledSwitchOption {
title: "Show Japanese Numbers"
description: "Use Japanese-style numbers instead of standard numerals"
prefField: barKey + ".modules.workspaces.showJapaneseNumbers"
enabled: !barKey.modules.workspaces.showAppIcons
opacity: !barKey.modules.workspaces.showAppIcons ? 1 : 0.8
}
StyledText {
text: "Status Icons"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
StyledSwitchOption {
title: "Enabled"
description: "Show status icons module (wifi, bluetooth)"
prefField: barKey + ".modules.statusIcons.enabled"
}
StyledSwitchOption {
title: "Show Wifi Status"
description: "Display wifi connection status and signal strength"
prefField: barKey + ".modules.statusIcons.networkStatusEnabled"
}
StyledSwitchOption {
title: "Show Bluetooth Status"
description: "Display bluetooth connection status"
prefField: barKey + ".modules.statusIcons.bluetoothStatusEnabled"
}
StyledText {
text: "System Stats"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
StyledSwitchOption {
title: "Enabled"
description: "Show system resource monitoring module"
prefField: barKey + ".modules.systemUsage.enabled"
}
StyledSwitchOption {
title: "Show Cpu Usage Stats"
description: "Display CPU usage percentage and load"
prefField: barKey + ".modules.systemUsage.cpuStatsEnabled"
}
StyledSwitchOption {
title: "Show Memory Usage Stats"
description: "Display RAM usage and available memory"
prefField: barKey + ".modules.systemUsage.memoryStatsEnabled"
}
StyledSwitchOption {
title: "Show Cpu Temperature Stats"
description: "Display CPU temperature readings"
prefField: barKey + ".modules.systemUsage.tempStatsEnabled"
}
}
}

View File

@@ -0,0 +1,187 @@
import QtQuick
import QtQuick.Layouts
import qs.config
import qs.modules.components
import qs.modules.functions
import qs.services
import Quickshell.Bluetooth as QsBluetooth
ContentMenu {
title: "Bluetooth"
description: "Manage Bluetooth devices and connections."
ContentCard {
ContentRowCard {
cardSpacing: Metrics.spacing(0)
verticalPadding: Bluetooth.defaultAdapter.enabled ? Metrics.padding(10) : Metrics.padding(0)
cardMargin: Metrics.margin(0)
StyledText {
text: powerSwitch.checked ? "Power: On" : "Power: Off"
font.pixelSize: Metrics.fontSize(16)
font.bold: true
}
Item { Layout.fillWidth: true }
StyledSwitch {
id: powerSwitch
checked: Bluetooth.defaultAdapter?.enabled
onToggled: Bluetooth.defaultAdapter.enabled = checked
}
}
ContentRowCard {
visible: Bluetooth.defaultAdapter.enabled
cardSpacing: Metrics.spacing(0)
verticalPadding: Metrics.padding(10)
cardMargin: Metrics.margin(0)
ColumnLayout {
spacing: Metrics.spacing(2)
StyledText {
text: "Discoverable"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Allow other devices to find this computer."
font.pixelSize: Metrics.fontSize(12)
color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6)
}
}
Item { Layout.fillWidth: true }
StyledSwitch {
checked: Bluetooth.defaultAdapter?.discoverable
onToggled: Bluetooth.defaultAdapter.discoverable = checked
}
}
ContentRowCard {
visible: Bluetooth.defaultAdapter.enabled
cardSpacing: Metrics.spacing(0)
verticalPadding: Metrics.padding(0)
cardMargin: Metrics.margin(0)
ColumnLayout {
spacing: Metrics.spacing(2)
StyledText {
text: "Scanning"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Search for nearby Bluetooth devices."
font.pixelSize: Metrics.fontSize(12)
color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6)
}
}
Item { Layout.fillWidth: true }
StyledSwitch {
checked: Bluetooth.defaultAdapter?.discovering
onToggled: Bluetooth.defaultAdapter.discovering = checked
}
}
}
ContentCard {
visible: connectedDevices.count > 0
StyledText {
text: "Connected Devices"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
Repeater {
id: connectedDevices
model: Bluetooth.devices.filter(d => d.connected)
delegate: BluetoothDeviceCard {
device: modelData
statusText: modelData.batteryAvailable
? "Connected, " + Math.floor(modelData.battery * 100) + "% left"
: "Connected"
showDisconnect: true
showRemove: true
usePrimary: true
}
}
}
ContentCard {
visible: Bluetooth.defaultAdapter?.enabled
StyledText {
text: "Paired Devices"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
Item {
visible: pairedDevices.count === 0
width: parent.width
height: Metrics.spacing(40)
StyledText {
anchors.left: parent.left
text: "No paired devices"
font.pixelSize: Metrics.fontSize(14)
color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6)
}
}
Repeater {
id: pairedDevices
model: Bluetooth.devices.filter(d => !d.connected && d.paired)
delegate: BluetoothDeviceCard {
device: modelData
statusText: "Not connected"
showConnect: true
showRemove: true
}
}
}
ContentCard {
visible: Bluetooth.defaultAdapter?.enabled
StyledText {
text: "Available Devices"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
Item {
visible: discoveredDevices.count === 0 && !Bluetooth.defaultAdapter.discovering
width: parent.width
height: Metrics.spacing(40)
StyledText {
Layout.alignment: Qt.AlignHCenter
text: "No new devices found"
font.pixelSize: Metrics.fontSize(14)
color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6)
}
}
Repeater {
id: discoveredDevices
model: Bluetooth.devices.filter(d => !d.paired && !d.connected)
delegate: BluetoothDeviceCard {
device: modelData
statusText: "Discovered"
showConnect: true
showPair: true
}
}
}
}

View File

@@ -0,0 +1,90 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.components
import qs.config
import qs.modules.functions
import Quickshell.Bluetooth as QsBluetooth
ContentRowCard {
id: deviceRow
property var device
property string statusText: ""
property bool usePrimary: false
property bool showConnect: false
property bool showDisconnect: false
property bool showPair: false
property bool showRemove: false
cardMargin: Metrics.margin(0)
cardSpacing: Metrics.spacing(10)
verticalPadding: Metrics.padding(0)
opacity: device.state === QsBluetooth.BluetoothDeviceState.Connecting ||
device.state === QsBluetooth.BluetoothDeviceState.Disconnecting ? 0.6 : 1
function mapBluetoothIcon(dbusIcon, name) {
console.log(dbusIcon, " / ", name)
const iconMap = {
"audio-headset": "headset",
"audio-headphones": "headphones",
"input-keyboard": "keyboard",
"input-mouse": "mouse",
"input-gaming": "sports_esports",
"phone": "phone_android",
"computer": "computer",
"printer": "print",
"camera": "photo_camera",
"unknown": "bluetooth"
}
return iconMap[dbusIcon] || "bluetooth"
}
MaterialSymbol {
icon: mapBluetoothIcon(device.icon, device.name)
font.pixelSize: Metrics.fontSize(32)
}
ColumnLayout {
Layout.alignment: Qt.AlignVCenter
spacing: Metrics.spacing(0)
StyledText {
text: device.name || device.address
font.pixelSize: Metrics.fontSize(16)
font.bold: true
}
StyledText {
text: statusText
font.pixelSize: Metrics.fontSize(12)
color: usePrimary
? Appearance.m3colors.m3primary
: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6)
}
}
Item { Layout.fillWidth: true }
StyledButton {
visible: showConnect
icon: "link"
onClicked: device.connect()
}
StyledButton {
visible: showDisconnect
icon: "link_off"
onClicked: device.disconnect()
}
StyledButton {
visible: showPair
icon: "add"
onClicked: device.pair()
}
StyledButton {
visible: showRemove
icon: "delete"
onClicked: Bluetooth.removeDevice(device)
}
}

View File

@@ -0,0 +1,79 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.services
ContentMenu {
title: "Launcher"
description: "Adjust launcher's settings."
ContentCard {
StyledText {
text: "Filters & Search"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledSwitchOption {
title: "Fuzzy Search"
description: "Enable or disable fuzzy search."
prefField: "launcher.fuzzySearchEnabled"
}
RowLayout {
id: webEngineSelector
property string title: "Web Search Engine"
property string description: "Choose the web search engine for web searches."
property string prefField: ''
ColumnLayout {
StyledText {
text: webEngineSelector.title
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: webEngineSelector.description
font.pixelSize: Metrics.fontSize(12)
}
}
Item {
Layout.fillWidth: true
}
StyledDropDown {
label: "Engine"
model: ["Google", "Brave", "DuckDuckGo", "Bing"]
// Set the initial index based on the lowercase value in Config
currentIndex: {
switch (Config.runtime.launcher.webSearchEngine.toLowerCase()) {
case "google":
return 0;
case "brave":
return 1;
case "duckduckgo":
return 2;
case "bing":
return 3;
default:
return 0;
}
}
onSelectedIndexChanged: (index) => {
// Update Config with lowercase version of selected model
Config.updateKey("launcher.webSearchEngine", model[index].toLowerCase());
}
}
}
}
}

View File

@@ -0,0 +1,109 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.services
ContentMenu {
title: "Miscellaneous"
description: "Configure misc settings."
ContentCard {
StyledText {
text: "Versions"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
RowLayout {
id: releaseChannelSelector
property string title: "Release Channel"
property string description: "Choose the release channel for updates."
property string prefField: ''
ColumnLayout {
StyledText {
text: releaseChannelSelector.title
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: releaseChannelSelector.description
font.pixelSize: Metrics.fontSize(12)
}
}
Item {
Layout.fillWidth: true
}
StyledDropDown {
label: "Type"
model: ["Stable", "Edge (indev)"]
currentIndex: Config.runtime.shell.releaseChannel === "edge" ? 1 : 0
onSelectedIndexChanged: (index) => {
Config.updateKey("shell.releaseChannel", index === 1 ? "edge" : "stable");
UpdateNotifier.notified = false;
}
}
}
}
ContentCard {
StyledText {
text: "Intelligence"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledSwitchOption {
title: "Enabled"
description: "Enable or disable intelligence."
prefField: "misc.intelligence.enabled"
}
}
ContentCard {
StyledText {
text: "Intelligence Bearer/API"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledTextField {
id: apiKeyTextField
clip: true
horizontalAlignment: Text.AlignLeft
placeholderText: Config.runtime.misc.intelligence.apiKey !== "" ? Config.runtime.misc.intelligence.apiKey : "Bearer Key"
Layout.fillWidth: true
Keys.onPressed: (event) => {
if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) {
event.accepted = true;
Config.updateKey("misc.intelligence.apiKey", apiKeyTextField.text);
Quickshell.execDetached(["notify-send", "Saved Bearer/API Key"])
}
}
font.pixelSize: Metrics.fontSize(16)
}
Item {
width: 20
}
InfoCard {
title: "How to save the api key"
description: "In order to save the api key press Ctrl+S and it will save the api key to the config."
}
}
}

View File

@@ -0,0 +1,136 @@
import QtQuick
import QtQuick.Layouts
import qs.config
import qs.modules.components
import qs.services
import qs.modules.functions
Item {
id: networkRow
property var connection
property bool isActive: false
property bool showConnect: false
property bool showDisconnect: false
property bool showPasswordField: false
property string password: ""
width: parent.width
implicitHeight: mainLayout.implicitHeight
function signalIcon(strength, secure) {
if (!connection) return "network_wifi";
if (connection.type === "ethernet") return "settings_ethernet";
if (strength >= 75) return "network_wifi";
if (strength >= 50) return "network_wifi_3_bar";
if (strength >= 25) return "network_wifi_2_bar";
if (strength > 0) return "network_wifi_1_bar";
return "network_wifi_1_bar";
}
ColumnLayout {
id: mainLayout
anchors.fill: parent
spacing: Metrics.spacing(10)
RowLayout {
spacing: Metrics.spacing(10)
// Signal icon with lock overlay
Item {
width: Metrics.spacing(32)
height: Metrics.spacing(32)
MaterialSymbol {
anchors.fill: parent
icon: connection ? signalIcon(connection.strength, connection.isSecure) : "network_wifi"
font.pixelSize: Metrics.fontSize(32)
}
// Lock overlay (anchors are safe because Item is not layout-managed)
MaterialSymbol {
icon: "lock"
visible: connection && connection.type === "wifi" && connection.isSecure
font.pixelSize: Metrics.fontSize(12)
anchors.right: parent.right
anchors.bottom: parent.bottom
}
}
ColumnLayout {
Layout.alignment: Qt.AlignVCenter
spacing: Metrics.spacing(0)
StyledText {
text: connection ? connection.name : ""
font.pixelSize: Metrics.fontSize(16)
font.bold: true
}
StyledText {
text: connection ? (
isActive ? "Connected" :
connection.type === "ethernet" ? connection.device || "Ethernet" :
connection.isSecure ? "Secured" : "Open"
) : ""
font.pixelSize: Metrics.fontSize(12)
color: isActive
? Appearance.m3colors.m3primary
: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.4)
}
}
Item { Layout.fillWidth: true }
StyledButton {
visible: showConnect && !showPasswordField
icon: "link"
onClicked: {
if (!connection) return;
if (connection.type === "ethernet") Network.connect(connection, "")
else if (connection.isSecure) showPasswordField = true
else Network.connect(connection, "")
}
}
StyledButton {
visible: showDisconnect && !showPasswordField
icon: "link_off"
onClicked: Network.disconnect()
}
}
// Password row
RowLayout {
visible: showPasswordField && connection && connection.type === "wifi"
spacing: Metrics.spacing(10)
StyledTextField {
padding: Metrics.padding(10)
Layout.fillWidth: true
placeholderText: "Enter password"
echoMode: parent.showPassword ? TextInput.Normal : TextInput.Password
onTextChanged: networkRow.password = text
onAccepted: {
if (!connection) return;
Network.connect(connection, networkRow.password)
showPasswordField = false
}
}
StyledButton {
property bool showPassword: false
icon: parent.showPassword ? "visibility" : "visibility_off"
onClicked: parent.showPassword = !parent.showPassword
}
StyledButton {
icon: "link"
onClicked: {
if (!connection) return;
Network.connect(connection, networkRow.password)
showPasswordField = false
}
}
}
}
}

View File

@@ -0,0 +1,172 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.functions
import qs.config
import qs.modules.components
import qs.services
ContentMenu {
title: "Network"
description: "Manage network connections."
ContentCard {
ContentRowCard {
cardSpacing: Metrics.spacing(0)
verticalPadding: Network.wifiEnabled ? Metrics.padding(10) : Metrics.padding(0)
cardMargin: Metrics.margin(0)
StyledText {
text: powerSwitch.checked ? "Wi-Fi: On" : "Wi-Fi: Off"
font.pixelSize: Metrics.fontSize(16)
font.bold: true
}
Item { Layout.fillWidth: true }
StyledSwitch {
id: powerSwitch
checked: Network.wifiEnabled
onToggled: Network.enableWifi(checked)
}
}
ContentRowCard {
visible: Network.wifiEnabled
cardSpacing: Metrics.spacing(0)
verticalPadding: Metrics.padding(10)
cardMargin: Metrics.margin(0)
ColumnLayout {
spacing: Metrics.spacing(2)
StyledText {
text: "Scanning"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Search for nearby Wi-Fi networks."
font.pixelSize: Metrics.fontSize(12)
color: ColorUtils.transparentize(
Appearance.m3colors.m3onSurface, 0.4
)
}
}
Item { Layout.fillWidth: true }
StyledSwitch {
checked: Network.scanning
onToggled: {
if (checked)
Network.rescan()
}
}
}
}
InfoCard {
visible: Network.message !== "" && Network.message !== "ok"
icon: "error"
backgroundColor: Appearance.m3colors.m3error
contentColor: Appearance.m3colors.m3onError
title: "Failed to connect to " + Network.lastNetworkAttempt
description: Network.message
}
ContentCard {
visible: Network.active !== null
StyledText {
text: "Active Connection"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
NetworkCard {
connection: Network.active
isActive: true
showDisconnect: Network.active?.type === "wifi"
}
}
ContentCard {
visible: Network.connections.filter(c => c.type === "ethernet").length > 0
StyledText {
text: "Ethernet"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
Repeater {
model: Network.connections.filter(c => c.type === "ethernet" && !c.active)
delegate: NetworkCard {
connection: modelData
showConnect: true
}
}
}
ContentCard {
visible: Network.wifiEnabled
StyledText {
text: "Available Wi-Fi Networks"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
Item {
visible: Network.connections.filter(c => c.type === "wifi").length === 0 && !Network.scanning
width: parent.width
height: Metrics.spacing(40)
StyledText {
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
text: "No networks found"
font.pixelSize: Metrics.fontSize(14)
color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.4)
}
}
Repeater {
model: Network.connections.filter(c => c.type === "wifi" && !c.active)
delegate: NetworkCard {
connection: modelData
showConnect: true
}
}
}
ContentCard {
visible: Network.savedNetworks.length > 0
StyledText {
text: "Remembered Networks"
font.pixelSize: Metrics.fontSize(18)
font.bold: true
}
Item {
visible: Network.savedNetworks.length === 0
width: parent.width
height: Metrics.spacing(40)
StyledText {
anchors.left: parent.left
text: "No remembered networks"
font.pixelSize: Metrics.fontSize(14)
color: Appearance.colors.colSubtext
}
}
Repeater {
model: Network.connections.filter(c => c.type === "wifi" && c.saved && !c.active)
delegate: NetworkCard {
connection: modelData
showConnect: false
showDisconnect: false
}
}
}
}

View File

@@ -0,0 +1,215 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.services
import qs.modules.functions
ContentMenu {
title: "Notifications & Overlays"
description: "Adjust notification and overlay settings."
function indexFromPosition(pos, model) {
pos = pos.toLowerCase()
for (let i = 0; i < model.length; i++) {
if (model[i].toLowerCase().replace(" ", "-") === pos)
return i
}
return 0
}
ContentCard {
StyledText {
text: "Notifications"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledSwitchOption {
title: "Enabled"
description: "Enable or disable built-in notification daemon."
prefField: "notifications.enabled"
}
StyledSwitchOption {
title: "Do not disturb enabled"
description: "Enable or disable dnd."
prefField: "notifications.doNotDisturb"
}
RowLayout {
ColumnLayout {
StyledText {
text: "Notification Position"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Select where notification will be shown."
font.pixelSize: Metrics.fontSize(12)
}
}
Item { Layout.fillWidth: true }
StyledDropDown {
id: notificationDropdown
label: "Position"
property var positions: ["Top Left", "Top Right", "Top"]
model: positions
currentIndex:
indexFromPosition(
Config.runtime.notifications.position,
positions
)
onSelectedIndexChanged: function(index) {
Config.updateKey(
"notifications.position",
positions[index].toLowerCase().replace(" ", "-")
)
}
}
}
RowLayout {
ColumnLayout {
StyledText {
text: "Test Notifications"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Run a test notification."
font.pixelSize: Metrics.fontSize(12)
}
}
Item { Layout.fillWidth: true }
StyledButton {
text: "Test"
icon: "chat"
onClicked:
Quickshell.execDetached([
"notify-send",
"Quickshell",
"This is a test notification"
])
}
}
}
ContentCard {
StyledText {
text: "Overlays / OSDs"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
}
StyledSwitchOption {
title: "Enabled"
description: "Enable or disable built-in osd daemon."
prefField: "overlays.enabled"
}
StyledSwitchOption {
title: "Volume OSD enabled"
description: "Enable or disable volume osd."
prefField: "overlays.volumeOverlayEnabled"
}
StyledSwitchOption {
title: "Brightness OSD enabled"
description: "Enable or disable brightness osd."
prefField: "overlays.brightnessOverlayEnabled"
}
RowLayout {
ColumnLayout {
StyledText {
text: "Brightness OSD Position"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Choose where brightness OSD is shown."
font.pixelSize: Metrics.fontSize(12)
}
}
Item { Layout.fillWidth: true }
StyledDropDown {
property var positions:
["Top Left","Top Right","Bottom Left","Bottom Right","Top","Bottom"]
model: positions
currentIndex:
indexFromPosition(
Config.runtime.overlays.brightnessOverlayPosition,
positions
)
onSelectedIndexChanged: function(index) {
Config.updateKey(
"overlays.brightnessOverlayPosition",
positions[index].toLowerCase().replace(" ", "-")
)
}
}
}
RowLayout {
ColumnLayout {
StyledText {
text: "Volume OSD Position"
font.pixelSize: Metrics.fontSize(16)
}
StyledText {
text: "Choose where volume OSD is shown."
font.pixelSize: Metrics.fontSize(12)
}
}
Item { Layout.fillWidth: true }
StyledDropDown {
property var positions:
["Top Left","Top Right","Bottom Left","Bottom Right","Top","Bottom"]
model: positions
currentIndex:
indexFromPosition(
Config.runtime.overlays.volumeOverlayPosition,
positions
)
onSelectedIndexChanged: function(index) {
Config.updateKey(
"overlays.volumeOverlayPosition",
positions[index].toLowerCase().replace(" ", "-")
)
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.plugins
ContentMenu {
title: "Plugins"
description: "Modify and Customize Installed Plugins."
ContentCard {
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
color: "transparent"
GridLayout {
id: grid
columns: 1
Layout.fillWidth: true
columnSpacing: Metrics.spacing(16)
rowSpacing: Metrics.spacing(16)
StyledText {
text: "Plugins not found!"
font.pixelSize: Metrics.fontSize(20)
font.bold: true
visible: PluginLoader.plugins.length === 0
Layout.alignment: Qt.AlignHCenter
}
Repeater {
model: PluginLoader.plugins
delegate: ContentCard {
Layout.fillWidth: true
Loader {
Layout.fillWidth: true
asynchronous: true
source: Qt.resolvedUrl(
Directories.shellConfig + "/plugins/" + modelData + "/Settings.qml"
)
}
}
}
}
}
}

View File

@@ -0,0 +1,201 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.config
import qs.modules.functions
import qs.modules.components
import qs.services
Scope {
property var settingsWindow: null
IpcHandler {
function open(menu: string) {
Globals.states.settingsOpen = true;
if (menu !== "" && settingsWindow !== null) {
for (var i = 0; i < settingsWindow.menuModel.length; i++) {
var item = settingsWindow.menuModel[i];
if (!item.header && item.label.toLowerCase() === menu.toLowerCase()) {
settingsWindow.selectedIndex = item.page;
break;
}
}
}
}
target: "settings"
}
LazyLoader {
active: Globals.states.settingsOpen
Window {
id: root
width: 1280
height: 720
visible: true
title: "Nucleus - Settings"
color: Appearance.m3colors.m3background
onClosing: Globals.states.settingsOpen = false
Component.onCompleted: settingsWindow = root
property int selectedIndex: 0
property bool sidebarCollapsed: false
property var menuModel: [
{ "header": true, "label": "System" },
{ "icon": "bluetooth", "label": "Bluetooth", "page": 0 },
{ "icon": "network_wifi", "label": "Network", "page": 1 },
{ "icon": "volume_up", "label": "Audio", "page": 2 },
{ "icon": "instant_mix", "label": "Appearance", "page": 3 },
{ "header": true, "label": "Customization" },
{ "icon": "toolbar", "label": "Bar", "page": 4 },
{ "icon": "wallpaper", "label": "Wallpapers", "page": 5 },
{ "icon": "apps", "label": "Launcher", "page": 6 },
{ "icon": "chat", "label": "Notifications", "page": 7 },
{ "icon": "extension", "label": "Plugins", "page": 8 },
{ "icon": "apps", "label": "Store", "page": 9 },
{ "icon": "build", "label": "Miscellaneous", "page": 10 },
{ "header": true, "label": "About" },
{ "icon": "info", "label": "About", "page": 11 }
]
Item {
anchors.fill: parent
Rectangle {
id: sidebarBG
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: root.sidebarCollapsed ? 80 : 350
color: Appearance.m3colors.m3surfaceContainerLow
ColumnLayout {
anchors.fill: parent
anchors.margins: Metrics.margin(40)
spacing: Metrics.spacing(5)
RowLayout {
Layout.fillWidth: true
StyledText {
Layout.fillWidth: true
text: "Settings"
font.family: "Outfit ExtraBold"
font.pixelSize: Metrics.fontSize(28)
visible: !root.sidebarCollapsed
}
StyledButton {
Layout.preferredHeight: 40
icon: root.sidebarCollapsed ? "chevron_right" : "chevron_left"
secondary: true
onClicked: root.sidebarCollapsed = !root.sidebarCollapsed
}
}
ListView {
id: sidebarList
Layout.fillWidth: true
Layout.fillHeight: true
model: root.menuModel
spacing: Metrics.spacing(5)
clip: true
delegate: Item {
width: sidebarList.width
height: modelData.header ? (root.sidebarCollapsed ? 0 : 30) : 42
visible: !modelData.header || !root.sidebarCollapsed
// header
Item {
width: parent.width
height: parent.height
StyledText {
y: (parent.height - height) * 0.5
x: 10
text: modelData.label
font.pixelSize: Metrics.fontSize(14)
font.bold: true
opacity: modelData.header ? 1 : 0
}
}
Rectangle {
anchors.fill: parent
visible: !modelData.header
radius: Appearance.rounding.large
color: root.selectedIndex === modelData.page
? Appearance.m3colors.m3primary
: "transparent"
RowLayout {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 10
spacing: 10
MaterialSymbol {
visible: !modelData.header
icon: modelData.icon ? modelData.icon : ""
iconSize: Metrics.iconSize(24)
}
StyledText {
text: modelData.label
visible: !root.sidebarCollapsed
}
}
}
MouseArea {
anchors.fill: parent
enabled: modelData.page !== undefined
onClicked: {
root.selectedIndex = modelData.page
settingsStack.currentIndex = modelData.page
}
}
}
}
}
Behavior on width {
NumberAnimation { duration: 180; easing.type: Easing.InOutCubic }
}
}
StackLayout {
id: settingsStack
anchors.left: sidebarBG.right
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
currentIndex: root.selectedIndex
BluetoothConfig { Layout.fillWidth: true; Layout.fillHeight: true }
NetworkConfig { Layout.fillWidth: true; Layout.fillHeight: true }
AudioConfig { Layout.fillWidth: true; Layout.fillHeight: true }
AppearanceConfig { Layout.fillWidth: true; Layout.fillHeight: true }
BarConfig { Layout.fillWidth: true; Layout.fillHeight: true }
WallpaperConfig { Layout.fillWidth: true; Layout.fillHeight: true }
LauncherConfig { Layout.fillWidth: true; Layout.fillHeight: true }
NotificationConfig { Layout.fillWidth: true; Layout.fillHeight: true }
Plugins { Layout.fillWidth: true; Layout.fillHeight: true }
Store { Layout.fillWidth: true; Layout.fillHeight: true }
MiscConfig { Layout.fillWidth: true; Layout.fillHeight: true }
About { Layout.fillWidth: true; Layout.fillHeight: true }
}
}
}
}
}

View File

@@ -0,0 +1,104 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.plugins
ContentMenu {
title: "Store"
description: "Manage plugins and other stuff for the shell."
ContentCard {
Layout.fillWidth: true
GridLayout {
columns: 1
Layout.fillWidth: true
columnSpacing: Metrics.spacing(16)
rowSpacing: Metrics.spacing(16)
Repeater {
model: PluginParser.model
delegate: StyledRect {
Layout.preferredHeight: 90
Layout.fillWidth: true
radius: Metrics.radius("small")
color: Appearance.m3colors.m3surfaceContainer
RowLayout {
anchors.fill: parent
anchors.margins: Metrics.margin("normal")
spacing: Metrics.spacing(12)
Column {
Layout.fillWidth: true
spacing: Metrics.spacing(2)
StyledText {
font.pixelSize: Metrics.fontSize("large")
text: name
}
RowLayout {
spacing: Metrics.spacing(6)
StyledText {
font.pixelSize: Metrics.fontSize("small")
text: author
color: Appearance.colors.colSubtext
}
StyledText {
font.pixelSize: Metrics.fontSize("small")
text: "| Requires Nucleus " + requires_nucleus
color: Appearance.colors.colSubtext
}
}
StyledText {
font.pixelSize: Metrics.fontSize("normal")
text: description
color: Appearance.colors.colSubtext
}
}
RowLayout {
spacing: Metrics.spacing(8)
StyledButton {
icon: "download"
text: "Install"
visible: !installed
secondary: true
Layout.preferredWidth: 140
onClicked: PluginParser.install(id)
}
StyledButton {
icon: "update"
text: "Update"
visible: installed
secondary: true
Layout.preferredWidth: 140
onClicked: PluginParser.update(id)
}
StyledButton {
icon: "delete"
text: "Remove"
visible: installed
secondary: true
Layout.preferredWidth: 140
onClicked: PluginParser.uninstall(id)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,294 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.config
import qs.modules.components
import qs.services
ContentMenu {
property string displayName: root.screen?.name ?? ""
property var intervalOptions: [{
"value": 5,
"label": "5 minutes"
}, {
"value": 15,
"label": "15 minutes"
}, {
"value": 30,
"label": "30 minutes"
}, {
"value": 60,
"label": "1 hour"
}, {
"value": 120,
"label": "2 hours"
}, {
"value": 360,
"label": "6 hours"
}]
function getIntervalIndex(minutes) {
for (let i = 0; i < intervalOptions.length; i++) {
if (intervalOptions[i].value === minutes)
return i;
}
return 0;
}
title: "Wallpaper"
description: "Manage your wallpapers"
ContentCard {
ClippingRectangle {
id: wpContainer
Layout.alignment: Qt.AlignHCenter
width: root.screen.width / 2
height: width * root.screen.height / root.screen.width
radius: Metrics.radius("unsharpenmore")
color: Appearance.m3colors.m3surfaceContainer
StyledText {
text: "Current Wallpaper:"
font.pixelSize: Metrics.fontSize("big")
font.bold: true
}
ClippingRectangle {
id: wpPreview
Layout.alignment: Qt.AlignHCenter | Qt.AlignCenter
anchors.fill: parent
radius: Metrics.radius("unsharpenmore")
color: Appearance.m3colors.m3paddingContainer
layer.enabled: true
StyledText {
opacity: !Config.runtime.appearance.background.enabled ? 1 : 0
font.pixelSize: Metrics.fontSize("title")
text: "Wallpaper Manager Disabled"
anchors.centerIn: parent
Behavior on opacity {
enabled: Config.runtime.appearance.animations.enabled
Anim { }
}
}
Image {
opacity: Config.runtime.appearance.background.enabled ? 1 : 0
anchors.fill: parent
source: previewImg + "?t=" + Date.now()
property string previewImg: {
const displays = Config.runtime.monitors
const fallback = Config.runtime.appearance.background.defaultPath
if (!displays)
return fallback
const monitor = displays?.[displayName]
return monitor?.wallpaper ?? fallback
}
fillMode: Image.PreserveAspectCrop
cache: true
Behavior on opacity {
enabled: Config.runtime.appearance.animations.enabled
Anim { }
}
}
}
}
StyledButton {
icon: "wallpaper"
text: "Change Wallpaper"
Layout.fillWidth: true
onClicked: {
Quickshell.execDetached(["nucleus", "ipc", "call", "background", "change"]);
}
}
StyledSwitchOption {
title: "Enabled"
description: "Enabled or disable built-in wallpaper daemon."
prefField: "appearance.background.enabled"
}
}
ContentCard {
StyledText {
text: "Parallax Effect"
font.pixelSize: Metrics.fontSize("big")
font.bold: true
}
StyledSwitchOption {
title: "Enabled"
description: "Enabled or disable wallpaper parallax effect."
prefField: "appearance.background.parallax.enabled"
}
StyledSwitchOption {
title: "Enabled for Sidebar Left"
description: "Show parralax effect when sidebarLeft is opened."
prefField: "appearance.background.parallax.enableSidebarLeft"
}
StyledSwitchOption {
title: "Enabled for Sidebar Right"
description: "Show parralax effect when sidebarRight is opened."
prefField: "appearance.background.parallax.enableSidebarRight"
}
NumberStepper {
label: "Zoom Amount"
description: "Adjust the zoom of the parallax effect."
prefField: "appearance.background.parallax.zoom"
step: 0.1
minimum: 1.10
maximum: 2
}
}
ContentCard {
StyledText {
text: "Wallpaper Slideshow"
font.pixelSize: Metrics.fontSize("big")
font.bold: true
}
StyledSwitchOption {
title: "Enable Slideshow"
description: "Automatically rotate wallpapers from a folder."
prefField: "appearance.background.slideshow.enabled"
}
StyledSwitchOption {
title: "Include Subfolders"
description: "Also search for wallpapers in subfolders."
prefField: "appearance.background.slideshow.includeSubfolders"
}
ColumnLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(8)
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(12)
ColumnLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(4)
StyledText {
text: "Wallpaper Folder"
font.pixelSize: Metrics.fontSize("normal")
}
StyledText {
text: Config.runtime.appearance.background.slideshow.folder || "No folder selected"
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurfaceVariant
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
StyledButton {
icon: "folder_open"
text: "Browse"
onClicked: folderPickerProc.running = true
}
}
}
RowLayout {
id: skipWallpaper
property string title: "Skip To Next Wallpaper"
property string description: "Skip to the next wallpaper in the wallpaper directory."
property string prefField: ''
ColumnLayout {
StyledText {
text: skipWallpaper.title
font.pixelSize: Metrics.fontSize("normal")
}
StyledText {
text: skipWallpaper.description
font.pixelSize: Metrics.fontSize("small")
}
}
Item { Layout.fillWidth: true }
StyledButton {
icon: "skip_next"
text: "Skip Next"
enabled: WallpaperSlideshow.wallpapers.length > 0
onClicked: {
Quickshell.execDetached(["nucleus", "ipc", "call", "background", "next"]);
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(12)
ColumnLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(4)
StyledText {
text: "Change Interval"
font.pixelSize: Metrics.fontSize("normal")
}
StyledText {
text: "How often to change the wallpaper."
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurfaceVariant
}
}
Item { Layout.fillWidth: true }
StyledDropDown {
label: "Interval"
model: intervalOptions.map((opt) => {
return opt.label;
})
currentIndex: getIntervalIndex(Config.runtime.appearance.background.slideshow.interval)
onSelectedIndexChanged: (index) => {
Config.updateKey("appearance.background.slideshow.interval", intervalOptions[index].value);
}
}
}
}
Process {
id: folderPickerProc
command: ["bash", Directories.scriptsPath + "/interface/selectfolder.sh", Config.runtime.appearance.background.slideshow.folder || Directories.pictures]
stdout: StdioCollector {
onStreamFinished: {
const out = text.trim();
if (out !== "null" && out.length > 0)
Config.updateKey("appearance.background.slideshow.folder", out);
}
}
}
component Anim: NumberAnimation {
duration: Metrics.chronoDuration(400)
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.standard
}
}

View File

@@ -0,0 +1,525 @@
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.config
import qs.modules.functions
import qs.modules.components
import qs.services
Item {
id: root
property bool initialChatSelected: false
function appendMessage(sender, message) {
messageModel.append({
"sender": sender,
"message": message
});
scrollToBottom();
}
function updateChatsList(files) {
let existing = {
};
for (let i = 0; i < chatListModel.count; i++) existing[chatListModel.get(i).name] = true
for (let file of files) {
let name = file.trim();
if (!name.length)
continue;
if (name.endsWith(".txt"))
name = name.slice(0, -4);
if (!existing[name])
chatListModel.append({
"name": name
});
delete existing[name];
}
// remove chats that no longer exist
for (let name in existing) {
for (let i = 0; i < chatListModel.count; i++) {
if (chatListModel.get(i).name === name) {
chatListModel.remove(i);
break;
}
}
}
// ensure default exists
let hasDefault = false;
for (let i = 0; i < chatListModel.count; i++) if (chatListModel.get(i).name === "default") {
hasDefault = true;
}
if (!hasDefault) {
chatListModel.insert(0, {
"name": "default"
});
FileUtils.createFile(FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/default.txt");
}
}
function scrollToBottom() {
chatView.positionViewAtEnd();
}
function sendMessage() {
if (userInput.text === "" || Zenith.loading)
return ;
Zenith.pendingInput = userInput.text;
appendMessage("You", userInput.text);
userInput.text = "";
Zenith.loading = true;
Zenith.send();
}
function loadChatHistory(chatName) {
messageModel.clear();
Zenith.loadChat(chatName);
}
function selectDefaultChat() {
let defaultIndex = -1;
for (let i = 0; i < chatListModel.count; i++) {
if (chatListModel.get(i).name === "default") {
defaultIndex = i;
break;
}
}
if (defaultIndex !== -1) {
chatSelector.currentIndex = defaultIndex;
Zenith.currentChat = "default";
loadChatHistory("default");
} else if (chatListModel.count > 0) {
chatSelector.currentIndex = 0;
Zenith.currentChat = chatListModel.get(0).name;
loadChatHistory(Zenith.currentChat);
}
}
ListModel {
// { sender: "You" | "AI", message: string }
id: messageModel
}
ListModel {
id: chatListModel
}
ColumnLayout {
spacing: Metrics.spacing(8)
anchors.centerIn: parent
StyledText {
visible: !Config.runtime.misc.intelligence.enabled
text: "Intelligence is disabled!"
Layout.leftMargin: Metrics.margin(24)
font.pixelSize: Appearance.font.size.huge
}
StyledText {
visible: !Config.runtime.misc.intelligence.enabled
text: "Go to the settings to enable intelligence"
}
}
StyledRect {
anchors.topMargin: Metrics.margin(74)
radius: Metrics.radius("normal")
anchors.fill: parent
color: "transparent"
visible: Config.runtime.misc.intelligence.enabled
ColumnLayout {
anchors.fill: parent
anchors.margins: Metrics.margin(16)
spacing: Metrics.spacing(10)
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(10)
StyledDropDown {
id: chatSelector
Layout.fillWidth: true
model: chatListModel
textRole: "name"
Layout.preferredHeight: 40
onCurrentIndexChanged: {
if (!initialChatSelected)
return ;
if (currentIndex < 0 || currentIndex >= chatListModel.count)
return ;
let chatName = chatListModel.get(currentIndex).name;
Zenith.currentChat = chatName;
loadChatHistory(chatName);
}
}
StyledButton {
icon: "add"
Layout.preferredWidth: 40
onClicked: {
let name = "new-chat-" + chatListModel.count;
let path = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + name + ".txt";
FileUtils.createFile(path, function(success) {
if (success) {
chatListModel.append({
"name": name
});
chatSelector.currentIndex = chatListModel.count - 1;
Zenith.currentChat = name;
messageModel.clear();
}
});
}
}
StyledButton {
icon: "edit"
Layout.preferredWidth: 40
enabled: chatSelector.currentIndex >= 0
onClicked: renameDialog.open()
}
StyledButton {
icon: "delete"
Layout.preferredWidth: 40
enabled: chatSelector.currentIndex >= 0 && chatSelector.currentText !== "default"
onClicked: {
let name = chatSelector.currentText;
let path = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + name + ".txt";
FileUtils.removeFile(path, function(success) {
if (success) {
chatListModel.remove(chatSelector.currentIndex);
selectDefaultChat();
}
});
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(10)
StyledDropDown {
id: modelSelector
Layout.fillWidth: true
model: ["openai/gpt-4o","openai/gpt-4","openai/gpt-3.5-turbo","openai/gpt-4o-mini","anthropic/claude-3.5-sonnet","anthropic/claude-3-haiku","meta-llama/llama-3.3-70b-instruct:free","deepseek/deepseek-r1-0528:free","qwen/qwen3-coder:free"]
currentIndex: 0
Layout.preferredHeight: 40
onCurrentTextChanged: Zenith.currentModel = currentText
}
StyledButton {
icon: "fullscreen"
Layout.preferredWidth: 40
onClicked: {
Quickshell.execDetached(["nucleus", "ipc", "call", "intelligence", "openWindow"]);
Globals.visiblility.sidebarLeft = false;
}
}
}
StyledRect {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Metrics.radius("normal")
color: Appearance.m3colors.m3surfaceContainerLow
ScrollView {
anchors.fill: parent
clip: true
ListView {
id: chatView
model: messageModel
spacing: Metrics.spacing(8)
anchors.fill: parent
anchors.margins: Metrics.margin(12)
clip: true
delegate: Item {
property bool isCodeBlock: message.split("\n").length > 2 && message.includes("import ") // simple heuristic
width: chatView.width
height: bubble.implicitHeight + 6
Component.onCompleted: {
chatView.forceLayout();
}
Row {
width: parent.width
spacing: Metrics.spacing(8)
Item {
width: sender === "AI" ? 0 : parent.width * 0.2
}
StyledRect {
id: bubble
radius: Metrics.radius("normal")
color: sender === "You" ? Appearance.m3colors.m3primaryContainer : Appearance.m3colors.m3surfaceContainerHigh
implicitWidth: Math.min(textItem.implicitWidth + 20, chatView.width * 0.8)
implicitHeight: textItem.implicitHeight
anchors.right: sender === "You" ? parent.right : undefined
anchors.left: sender === "AI" ? parent.left : undefined
anchors.topMargin: Metrics.margin(2)
TextEdit {
id: textItem
text: StringUtils.markdownToHtml(message)
wrapMode: TextEdit.Wrap
textFormat: TextEdit.RichText
readOnly: true // make it selectable but not editable
font.pixelSize: Metrics.fontSize(16)
anchors.leftMargin: Metrics.margin(12)
color: Appearance.syntaxHighlightingTheme
padding: Metrics.padding(8)
anchors.fill: parent
}
MouseArea {
id: ma
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: {
let p = Qt.createQmlObject('import Quickshell; import Quickshell.Io; Process { command: ["wl-copy", "' + message + '"] }', parent);
p.running = true;
}
}
}
Item {
width: sender === "You" ? 0 : parent.width * 0.2
}
}
}
}
}
}
StyledRect {
Layout.fillWidth: true
height: 50
radius: Metrics.radius("normal")
color: Appearance.m3colors.m3surfaceContainer
RowLayout {
anchors.fill: parent
anchors.margins: 6
spacing: 10
StyledTextField {
// Shift+Enter → insert newline
// Enter → send message
id: userInput
Layout.fillWidth: true
placeholderText: "Type your message..."
font.pixelSize: Metrics.fontSize(14)
padding: Metrics.padding(8)
Keys.onPressed: {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (event.modifiers & Qt.ShiftModifier)
insert("\n");
else
sendMessage();
event.accepted = true;
}
}
}
StyledButton {
text: "Send"
enabled: userInput.text.trim().length > 0 && !Zenith.loading
opacity: enabled ? 1 : 0.5
onClicked: sendMessage()
}
}
}
}
Dialog {
id: renameDialog
title: "Rename Chat"
modal: true
visible: false
standardButtons: Dialog.NoButton
x: (root.width - 360) / 2 // center horizontally
y: (root.height - 160) / 2 // center vertically
width: 360
height: 200
ColumnLayout {
anchors.fill: parent
anchors.margins: Metrics.margin(16)
spacing: Metrics.spacing(12)
StyledText {
text: "Enter a new name for the chat"
font.pixelSize: Metrics.fontSize(18)
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
}
StyledTextField {
id: renameInput
Layout.fillWidth: true
placeholderText: "New name"
filled: false
highlight: false
text: chatSelector.currentText
font.pixelSize: Metrics.iconSize(16)
Layout.preferredHeight: 45
padding: Metrics.padding(8)
}
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(12)
Layout.alignment: Qt.AlignRight
StyledButton {
text: "Cancel"
Layout.preferredWidth: 80
onClicked: renameDialog.close()
}
StyledButton {
text: "Rename"
Layout.preferredWidth: 100
enabled: renameInput.text.trim().length > 0 && renameInput.text !== chatSelector.currentText
onClicked: {
let oldName = chatSelector.currentText;
let newName = renameInput.text.trim();
let oldPath = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + oldName + ".txt";
let newPath = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + newName + ".txt";
FileUtils.renameFile(oldPath, newPath, function(success) {
if (success) {
chatListModel.set(chatSelector.currentIndex, {
"name": newName
});
Zenith.currentChat = newName;
renameDialog.close();
}
});
}
}
}
}
background: StyledRect {
color: Appearance.m3colors.m3surfaceContainer
radius: Metrics.radius("normal")
border.color: Appearance.colors.colOutline
border.width: 1
}
header: StyledRect {
color: Appearance.m3colors.m3surfaceContainer
radius: Metrics.radius("normal")
border.color: Appearance.colors.colOutline
border.width: 1
}
}
StyledText {
text: "Thinking…"
visible: Zenith.loading
color: Appearance.colors.colSubtext
font.pixelSize: Metrics.fontSize(14)
anchors {
left: parent.left
bottom: parent.bottom
leftMargin: Metrics.margin(22)
bottomMargin: Metrics.margin(76)
}
}
}
Connections {
function onChatsListed(text) {
let lines = text.split(/\r?\n/);
updateChatsList(lines);
// only auto-select once
if (!initialChatSelected) {
selectDefaultChat();
initialChatSelected = true;
}
}
function onAiReply(text) {
appendMessage("AI", text.slice(5));
Zenith.loading = false;
}
function onChatLoaded(text) {
let lines = text.split(/\r?\n/);
let batch = [];
for (let l of lines) {
let line = l.trim();
if (!line.length)
continue;
let u = line.match(/^\[\d{4}-.*\] User: (.*)$/);
let a = line.match(/^\[\d{4}-.*\] AI: (.*)$/);
if (u)
batch.push({
"sender": "You",
"message": u[1]
});
else if (a)
batch.push({
"sender": "AI",
"message": a[1]
});
else if (batch.length)
batch[batch.length - 1].message += "\n" + line;
}
messageModel.clear();
for (let m of batch) messageModel.append(m)
scrollToBottom();
}
target: Zenith
}
}

View File

@@ -0,0 +1,90 @@
import qs.config
import qs.modules.components
import qs.modules.functions
import qs.services
import QtQuick
import Quickshell
import QtQuick.Layouts
import Quickshell.Wayland
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
PanelWindow {
id: sidebarLeft
WlrLayershell.namespace: "nucleus:sidebarLeft"
WlrLayershell.layer: WlrLayer.Top
visible: Config.initialized && Globals.visiblility.sidebarLeft && !Globals.visiblility.sidebarRight
color: "transparent"
exclusiveZone: 0
WlrLayershell.keyboardFocus: Compositor.require("hyprland") && Globals.visiblility.sidebarLeft
property real sidebarLeftWidth: 500
implicitWidth: Compositor.screenW
HyprlandFocusGrab {
id: grab
active: Compositor.require("hyprland")
windows: [sidebarLeft]
}
anchors {
top: true
left: (Config.runtime.bar.position === "top" || Config.runtime.bar.position === "bottom" || Config.runtime.bar.position === "left")
bottom: true
right: (Config.runtime.bar.position === "right")
}
margins {
top: Config.runtime.bar.margins
bottom: Config.runtime.bar.margins
left: Metrics.margin("small")
right: Metrics.margin("small")
}
MouseArea {
anchors.fill: parent
z: 0
onPressed: Globals.visiblility.sidebarLeft = false
}
StyledRect {
id: container
z: 1
color: Appearance.m3colors.m3background
radius: Metrics.radius("large")
width: sidebarLeft.sidebarLeftWidth
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
}
FocusScope {
focus: true
anchors.fill: parent
Keys.onPressed: {
if (event.key === Qt.Key_Escape) {
Globals.visiblility.sidebarLeft = false;
}
}
SidebarLeftContent {}
}
}
function togglesidebarLeft() {
Globals.visiblility.sidebarLeft = !Globals.visiblility.sidebarLeft;
}
IpcHandler {
target: "sidebarLeft"
function toggle() {
togglesidebarLeft();
}
}
}

View File

@@ -0,0 +1,142 @@
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.config
import qs.modules.functions
import qs.modules.components
import qs.services
Item {
anchors.fill: parent
SwipeView {
id: view
anchors.fill: parent
// IntelligencePanel {}
SystemOverview {}
// WallpapersPage {}
}
Rectangle {
height: 2
width: parent.width - Metrics.margin("verylarge")
color: Appearance.m3colors.m3outlineVariant
opacity: 0.6
anchors {
top: view.top
topMargin: segmentedIndicator.height + Metrics.margin("verysmall")
horizontalCenter: view.horizontalCenter
}
}
Rectangle {
id: activeTabIndicator
height: 2
width: 96
radius: Metrics.radius(1)
color: Appearance.m3colors.m3primary
x: (segmentedIndicator.width / view.count) * view.currentIndex + (segmentedIndicator.width / view.count - width) / 2
anchors {
top: segmentedIndicator.bottom
topMargin: Metrics.margin(8)
}
Behavior on x {
NumberAnimation {
duration: Metrics.chronoDuration(220)
easing.type: Easing.OutCubic
}
}
}
Item {
id: segmentedIndicator
height: 56
width: parent.width
anchors {
top: parent.top
horizontalCenter: parent.horizontalCenter
}
Row {
anchors.fill: parent
spacing: 0
Repeater {
model: [
// {
// "icon": "neurology",
// "text": "Intelligence"
// },
{
"icon": "overview",
"text": "Overview"
},
// {
// "icon": "wallpaper",
// "text": "Wallpapers"
// }
]
Item {
width: segmentedIndicator.width / view.count
height: parent.height
MouseArea {
anchors.fill: parent
onClicked: view.currentIndex = index
}
// Icon (true center)
MaterialSymbol {
icon: modelData.icon
iconSize: Metrics.iconSize("huge")
color: view.currentIndex === index ? Appearance.m3colors.m3primary : Appearance.m3colors.m3onSurfaceVariant
anchors {
horizontalCenter: parent.horizontalCenter
top: parent.top
topMargin: Metrics.margin(12)
}
}
// Label (independent centering)
StyledText {
text: modelData.text
font.pixelSize: Metrics.fontSize("large")
font.weight: Font.Medium
color: view.currentIndex === index ? Appearance.m3colors.m3primary : Appearance.m3colors.m3onSurfaceVariant
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: 0
}
}
}
}
}
}
}

View File

@@ -0,0 +1,486 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.modules.functions
import qs.services
import qs.config
import qs.modules.components
Item {
id: root
implicitWidth: 300
implicitHeight: parent ? parent.height : 500
ColumnLayout {
anchors.topMargin: Metrics.margin(90)
anchors.fill: parent
anchors.margins: Metrics.margin("normal")
spacing: Metrics.margin("small")
// Header
RowLayout {
Layout.fillWidth: true
RowLayout {
spacing: Metrics.margin("normal")
StyledText {
text: SystemDetails.osIcon
font.family: Metrics.fontFamily("nerdIcons")
font.pixelSize: Metrics.fontSize(48)
color: Appearance.colors.colPrimary
}
ColumnLayout {
spacing: Metrics.spacing(2)
StyledText {
text: SystemDetails.osName
font.pixelSize: Metrics.fontSize("large")
color: Appearance.m3colors.m3onSurface
}
StyledText {
text: `${SystemDetails.username}@${SystemDetails.hostname}`
font.pixelSize: Metrics.fontSize("small")
color: Appearance.colors.colSubtext
}
}
}
Item {
Layout.fillWidth: true
}
ColumnLayout {
spacing: Metrics.spacing(2)
Layout.alignment: Qt.AlignRight
StyledText {
text: `qs ${SystemDetails.qsVersion}`
font.pixelSize: Metrics.fontSize("small")
color: Appearance.colors.colSubtext
}
StyledText {
text: `nucleus-shell v${Config.runtime.shell.version}`
font.pixelSize: Metrics.fontSize("smaller")
color: Appearance.colors.colSubtext
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 56
radius: Metrics.radius("normal")
color: Appearance.colors.colLayer2
RowLayout {
anchors.fill: parent
anchors.margins: Metrics.margin("small")
StyledText {
text: "Uptime"
font.pixelSize: Metrics.fontSize("normal")
color: Appearance.colors.colPrimary
}
Item {
Layout.fillWidth: true
}
StyledText {
text: SystemDetails.uptime
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurface
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 56
radius: Metrics.radius("normal")
color: Appearance.colors.colLayer2
RowLayout {
anchors.fill: parent
anchors.margins: Metrics.margin("small")
StyledText {
text: "Operating System"
font.pixelSize: Metrics.fontSize("normal")
color: Appearance.colors.colPrimary
}
Item {
Layout.fillWidth: true
}
StyledText {
text: SystemDetails.osName
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurface
elide: Text.ElideRight
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 320
radius: Metrics.radius("large")
color: Appearance.colors.colLayer2
ColumnLayout {
anchors.fill: parent
anchors.margins: Metrics.margin("large")
spacing: Metrics.margin("normal")
ColumnLayout {
spacing: Metrics.spacing(6)
RowLayout {
StyledText {
text: "CPU Usage"
color: Appearance.colors.colSubtext
}
Item {
Layout.fillWidth: true
}
StyledText {
animate: false
text: SystemDetails.cpuLoad
color: Appearance.colors.colSubtext
}
}
Rectangle {
Layout.fillWidth: true
height: 10
radius: Metrics.radius(5)
color: Appearance.colors.colLayer1
Rectangle {
width: parent.width * SystemDetails.cpuPercent
height: parent.height
radius: Metrics.radius(5)
color: Appearance.colors.colPrimary
}
}
}
ColumnLayout {
spacing: Metrics.spacing(6)
RowLayout {
StyledText {
text: "Ram Usage"
color: Appearance.colors.colSubtext
}
Item {
Layout.fillWidth: true
}
StyledText {
animate: false
text: SystemDetails.ramUsage
color: Appearance.colors.colSubtext
}
}
Rectangle {
Layout.fillWidth: true
height: 10
radius: Metrics.radius(5)
color: Appearance.colors.colLayer1
Rectangle {
width: parent.width * SystemDetails.ramPercent
height: parent.height
radius: Metrics.radius(5)
color: Appearance.colors.colPrimary
}
}
}
ColumnLayout {
spacing: Metrics.spacing(6)
RowLayout {
StyledText {
text: "Disk Usage"
color: Appearance.colors.colSubtext
}
Item {
Layout.fillWidth: true
}
StyledText {
animate: false
text: SystemDetails.diskUsage
color: Appearance.colors.colSubtext
}
}
Rectangle {
Layout.fillWidth: true
height: 10
radius: Metrics.radius(5)
color: Appearance.colors.colLayer1
Rectangle {
width: parent.width * SystemDetails.diskPercent
height: parent.height
radius: Metrics.radius(5)
color: Appearance.colors.colPrimary
}
}
}
ColumnLayout {
spacing: Metrics.spacing(6)
RowLayout {
StyledText {
text: "Swap Usage"
color: Appearance.colors.colSubtext
}
Item {
Layout.fillWidth: true
}
StyledText {
text: SystemDetails.swapUsage
color: Appearance.colors.colSubtext
}
}
Rectangle {
Layout.fillWidth: true
height: 10
radius: Metrics.radius(5)
color: Appearance.colors.colLayer1
Rectangle {
width: parent.width * SystemDetails.swapPercent
height: parent.height
radius: Metrics.radius(5)
color: Appearance.colors.colPrimary
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 72
radius: Metrics.radius("normal")
color: Appearance.colors.colLayer2
RowLayout {
anchors.fill: parent
anchors.margins: Metrics.margin("small")
spacing: Metrics.margin("large")
ColumnLayout {
spacing: Metrics.spacing(2)
StyledText {
text: "Kernel"
font.pixelSize: Metrics.fontSize("small")
color: Appearance.colors.colSubtext
}
StyledText {
text: SystemDetails.kernelVersion
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurface
}
}
Item {
Layout.fillWidth: true
}
ColumnLayout {
spacing: Metrics.spacing(2)
Layout.alignment: Qt.AlignRight
StyledText {
text: "Architecture"
font.pixelSize: Metrics.fontSize("small")
color: Appearance.colors.colSubtext
horizontalAlignment: Text.AlignRight
}
StyledText {
text: SystemDetails.architecture
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurface
horizontalAlignment: Text.AlignRight
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 72
radius: Metrics.radius("normal")
color: Appearance.colors.colLayer2
visible: UPower.batteryPresent
RowLayout {
anchors.fill: parent
anchors.margins: Metrics.margin("small")
spacing: Metrics.margin("large")
ColumnLayout {
spacing: Metrics.spacing(2)
StyledText {
text: "Battery"
font.pixelSize: Metrics.fontSize("small")
color: Appearance.colors.colSubtext
}
StyledText {
text: `${Math.round(UPower.percentage)}%`
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurface
}
}
Item {
Layout.fillWidth: true
}
ColumnLayout {
spacing: Metrics.spacing(2)
Layout.alignment: Qt.AlignRight
StyledText {
text: "AC"
font.pixelSize: Metrics.fontSize("small")
color: Appearance.colors.colSubtext
horizontalAlignment: Text.AlignRight
}
StyledText {
text: UPower.acOnline ? "online" : "battery"
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurface
horizontalAlignment: Text.AlignRight
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 56
radius: Metrics.radius("normal")
color: Appearance.colors.colLayer2
RowLayout {
anchors.fill: parent
anchors.margins: Metrics.margin("small")
StyledText {
text: "Running Processes"
font.pixelSize: Metrics.fontSize("normal")
color: Appearance.colors.colPrimary
}
Item {
Layout.fillWidth: true
}
StyledText {
text: SystemDetails.runningProcesses
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurface
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 56
radius: Metrics.radius("normal")
color: Appearance.colors.colLayer2
RowLayout {
anchors.fill: parent
anchors.margins: Metrics.margin("small")
StyledText {
text: "Logged-in Users"
font.pixelSize: Metrics.fontSize("normal")
color: Appearance.colors.colPrimary
}
Item {
Layout.fillWidth: true
}
StyledText {
text: SystemDetails.loggedInUsers
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurface
}
}
}
Item {
Layout.fillHeight: true
}
}
}

View File

@@ -0,0 +1,114 @@
import Qt.labs.folderlistmodel
import Qt5Compat.GraphicalEffects
import QtCore
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.modules.functions
import qs.services
import qs.config
import qs.modules.components
Item {
id: wallpapersPage
property string displayName: screen?.name ?? ""
FolderListModel {
id: wallpaperModel
folder: StandardPaths.writableLocation(StandardPaths.PicturesLocation) + "/Wallpapers"
nameFilters: ["*.png", "*.jpg", "*.jpeg", "*.webp", "mp4", "mkv", "webm", "avi", "mov", "flv", "wmv", "m4v"]
showDirs: false
showDotAndDotDot: false
}
// EMPTY STATE
StyledText {
visible: wallpaperModel.count === 0
text: "Put some wallpapers in\n~/Pictures/Wallpapers"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
color: Appearance.m3colors.m3onSurfaceVariant
font.pixelSize: Appearance.font.size.large
anchors.centerIn: parent
}
// WALLPAPER LIST
ListView {
anchors.topMargin: 90
visible: wallpaperModel.count > 0
anchors.fill: parent
model: wallpaperModel
spacing: Appearance.margin.normal
clip: true
delegate: Item {
width: ListView.view.width
height: 240
StyledRect {
id: imgContainer
property bool activeWallpaper:
Config.runtime.monitors?.[wallpapersPage.displayName]?.wallpaper === fileUrl
anchors.fill: parent
anchors.leftMargin: 20
anchors.rightMargin: 20
radius: Appearance.rounding.normal
color: activeWallpaper
? Appearance.m3colors.m3secondaryContainer
: Appearance.m3colors.m3surfaceContainerLow
layer.enabled: true
Image {
id: wallImg
anchors.fill: parent
source: fileUrl
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
clip: true
}
StyledText {
anchors.centerIn: parent
text: "Unsupported / Corrupted Image"
visible: wallImg.status === Image.Error
}
MouseArea {
anchors.fill: parent
onClicked: {
Config.updateKey(
"monitors." + wallpapersPage.displayName + ".wallpaper",
fileUrl
);
if (Config.runtime.appearance.colors.autogenerated) {
Quickshell.execDetached([
"nucleus", "ipc", "call", "global", "regenColors"
]);
}
}
cursorShape: Qt.PointingHandCursor
}
layer.effect: OpacityMask {
maskSource: Rectangle {
width: imgContainer.width
height: imgContainer.height
radius: imgContainer.radius
}
}
}
}
}
}

View File

@@ -0,0 +1,97 @@
import qs.config
import qs.modules.components
import qs.modules.functions
import qs.services
import QtQuick
import Quickshell
import QtQuick.Layouts
import Quickshell.Wayland
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick.Controls
import Quickshell.Services.Pipewire
import Qt5Compat.GraphicalEffects
PanelWindow {
id: sidebarRight
WlrLayershell.namespace: "nucleus:sidebarRight"
WlrLayershell.layer: WlrLayer.Top
visible: Config.initialized && Globals.visiblility.sidebarRight && !Globals.visiblility.sidebarLeft
color: "transparent"
exclusiveZone: 0
WlrLayershell.keyboardFocus: Compositor.require("hyprland") && Globals.visiblility.sidebarRight
property real sidebarRightWidth: 500
implicitWidth: Compositor.screenW
HyprlandFocusGrab {
id: grab
active: Compositor.require("hyprland")
windows: [sidebarRight]
}
anchors {
top: true
right: (Config.runtime.bar.position === "top" || Config.runtime.bar.position === "bottom" || Config.runtime.bar.position === "right")
bottom: true
left: (Config.runtime.bar.position === "left")
}
margins {
top: Config.runtime.bar.margins
bottom: Config.runtime.bar.margins
left: Metrics.margin("small")
right: Metrics.margin("small")
}
PwObjectTracker {
objects: [Pipewire.defaultAudioSink]
}
property var sink: Pipewire.defaultAudioSink?.audio
MouseArea {
anchors.fill: parent
z: 0
onPressed: Globals.visiblility.sidebarRight = false
}
StyledRect {
id: container
z: 1
color: Appearance.m3colors.m3background
radius: Metrics.radius("large")
width: sidebarRight.sidebarRightWidth
anchors {
top: parent.top
bottom: parent.bottom
right: parent.right
}
FocusScope {
focus: true
anchors.fill: parent
Keys.onPressed: {
if (event.key === Qt.Key_Escape) {
Globals.visiblility.sidebarRight = false;
}
}
SidebarRightContent {}
}
}
function togglesidebarRight() {
Globals.visiblility.sidebarRight = !Globals.visiblility.sidebarRight;
}
IpcHandler {
target: "sidebarRight"
function toggle() {
togglesidebarRight();
}
}
}

View File

@@ -0,0 +1,247 @@
import qs.config
import qs.modules.components
import qs.services
import qs.modules.functions
import QtQuick
import Quickshell
import QtQuick.Layouts
import Quickshell.Wayland
import Quickshell.Io
import QtQuick.Controls
import Quickshell.Services.Pipewire
import Qt5Compat.GraphicalEffects
import "content/"
Item {
anchors.fill: parent
anchors.leftMargin: Metrics.margin("normal")
anchors.rightMargin: Metrics.margin("normal")
anchors.topMargin: Metrics.margin("large")
anchors.bottomMargin: Metrics.margin("large")
ColumnLayout {
id: mainLayout
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Metrics.margin("tiny")
anchors.rightMargin: Metrics.margin("tiny")
anchors.margins: Metrics.margin("large")
spacing: Metrics.margin("large")
RowLayout {
id: topSection
Layout.fillWidth: true
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Metrics.margin(10)
Layout.alignment: Qt.AlignVCenter
spacing: Metrics.spacing(2)
RowLayout {
spacing: Metrics.spacing(8)
StyledText {
text: SystemDetails.osIcon
font.pixelSize: Metrics.fontSize("hugeass") + 6
}
StyledText {
text: SystemDetails.uptime
font.pixelSize: Metrics.fontSize("large")
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: Metrics.margin(5)
}
}
}
Item { Layout.fillWidth: true }
Row {
spacing: Metrics.spacing(6)
Layout.leftMargin: Metrics.margin(25)
Layout.alignment: Qt.AlignVCenter
StyledRect {
id: screenshotbtncontainer
color: "transparent"
radius: Metrics.radius("large")
implicitHeight: screenshotButton.height + Metrics.margin("tiny")
implicitWidth: screenshotButton.width + Metrics.margin("small")
Layout.alignment: Qt.AlignRight | Qt.AlignTop
Layout.topMargin: Metrics.margin(10)
Layout.leftMargin: Metrics.margin(15)
MaterialSymbolButton {
id: screenshotButton
icon: "edit"
anchors.centerIn: parent
iconSize: Metrics.iconSize("hugeass") + 2
tooltipText: "Take a screenshot"
onButtonClicked: {
Quickshell.execDetached(["nucleus", "ipc", "call", "screen", "capture"])
Globals.visiblility.sidebarRight = false;
}
}
}
StyledRect {
id: reloadbtncontainer
color: "transparent"
radius: Metrics.radius("large")
implicitHeight: reloadButton.height + Metrics.margin("tiny")
implicitWidth: reloadButton.width + Metrics.margin("small")
Layout.alignment: Qt.AlignRight | Qt.AlignTop
Layout.topMargin: Metrics.margin(10)
Layout.leftMargin: Metrics.margin(15)
MaterialSymbolButton {
id: reloadButton
icon: "refresh"
anchors.centerIn: parent
iconSize: Metrics.iconSize("hugeass") + 4
tooltipText: "Reload Nucleus Shell"
onButtonClicked: {
Quickshell.execDetached(["nucleus", "run", "--reload"])
}
}
}
StyledRect {
id: settingsbtncontainer
color: "transparent"
radius: Metrics.radius("large")
implicitHeight: settingsButton.height + Metrics.margin("tiny")
implicitWidth: settingsButton.width + Metrics.margin("small")
Layout.alignment: Qt.AlignRight | Qt.AlignTop
Layout.topMargin: Metrics.margin(10)
Layout.leftMargin: Metrics.margin(15)
MaterialSymbolButton {
id: settingsButton
icon: "settings"
anchors.centerIn: parent
iconSize: Metrics.iconSize("hugeass") + 2
tooltipText: "Open Settings"
onButtonClicked: {
Globals.visiblility.sidebarRight = false
Globals.states.settingsOpen = true
}
}
}
StyledRect {
id: powerbtncontainer
color: "transparent"
radius: Metrics.radius("large")
implicitHeight: settingsButton.height + Metrics.margin("tiny")
implicitWidth: settingsButton.width + Metrics.margin("small")
Layout.alignment: Qt.AlignRight | Qt.AlignTop
Layout.topMargin: Metrics.margin(10)
Layout.leftMargin: Metrics.margin(15)
MaterialSymbolButton {
id: powerButton
icon: "power_settings_new"
anchors.centerIn: parent
iconSize: Metrics.iconSize("hugeass") + 2
tooltipText: "Open PowerMenu"
onButtonClicked: {
Globals.visiblility.sidebarRight = false
Globals.visiblility.powermenu = true
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Appearance.m3colors.m3outlineVariant
radius: Metrics.radius(1)
}
ColumnLayout {
id: sliderColumn
Layout.fillWidth: true
VolumeSlider {
Layout.fillWidth: true
Layout.preferredHeight: 50
icon: "volume_up"
iconSize: Metrics.iconSize("large") + 3
}
BrightnessSlider {
Layout.fillWidth: true
Layout.preferredHeight: 50
icon: "brightness_high"
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Appearance.m3colors.m3outlineVariant
radius: Metrics.radius(1)
}
GridLayout {
id: middleGrid
Layout.fillWidth: true
columns: 1
columnSpacing: Metrics.spacing(8)
rowSpacing: Metrics.spacing(8)
Layout.preferredWidth: parent.width
RowLayout {
NetworkToggle {
Layout.fillWidth: true
Layout.preferredHeight: 80
}
FlightModeToggle {
Layout.fillWidth: true
Layout.preferredHeight: 80
}
}
RowLayout {
BluetoothToggle {
Layout.preferredWidth: 220
Layout.preferredHeight: 80
}
ThemeToggle {
Layout.preferredHeight: 80
Layout.fillWidth: true
}
NightModeToggle {
Layout.preferredHeight: 80
Layout.fillWidth: true
}
}
}
ColumnLayout {
spacing: Metrics.margin("small")
Layout.fillWidth: true
Rectangle {
Layout.fillWidth: true
height: 1
color: Appearance.m3colors.m3outlineVariant
radius: Metrics.radius(1)
Layout.topMargin: Metrics.margin(5)
Layout.bottomMargin: Metrics.margin(5)
}
NotifModal {
Layout.preferredHeight: (Config.runtime.bar.position === "left" || Config.runtime.bar.position === "right") ? 480 : 470
}
}
}
}

View File

@@ -0,0 +1,91 @@
import qs.config
import qs.modules.components
import qs.services
import QtQuick
import Quickshell
import QtQuick.Layouts
StyledRect {
id: root
width: 200
height: 80
radius: Metrics.radius("verylarge")
color: Appearance.m3colors.m3surfaceContainerHigh
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
readonly property bool adapterPresent: Bluetooth.defaultAdapter !== null
readonly property bool enabled: Bluetooth.defaultAdapter?.enabled ?? false
readonly property var activeDevice: Bluetooth.activeDevice
readonly property string iconName: Bluetooth.icon
readonly property string statusText: {
if (!adapterPresent)
return "No adapter";
if (!enabled)
return "Disabled";
if (activeDevice)
return activeDevice.name;
return Bluetooth.defaultAdapter.discovering
? "Scanning…"
: "Enabled";
}
StyledRect {
id: iconBg
width: 50
height: 50
radius: Metrics.radius("verylarge")
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Metrics.margin("small")
color: {
if (!enabled)
return Appearance.m3colors.m3surfaceContainerHigh;
if (activeDevice)
return Appearance.m3colors.m3primaryContainer;
return Appearance.m3colors.m3secondaryContainer;
}
MaterialSymbol {
anchors.centerIn: parent
iconSize: Metrics.iconSize(35)
icon: iconName
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
anchors.left: iconBg.right
anchors.leftMargin: Metrics.margin("small")
spacing: Metrics.spacing(2)
StyledText {
text: "Bluetooth"
font.pixelSize: Metrics.fontSize("large")
elide: Text.ElideRight
width: root.width - iconBg.width - 30
}
StyledText {
text: statusText
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurfaceVariant
elide: Text.ElideRight
width: root.width - iconBg.width - 30
}
}
MouseArea {
anchors.fill: parent
onClicked: {
if (!adapterPresent)
return;
Bluetooth.defaultAdapter.enabled =
!Bluetooth.defaultAdapter.enabled;
}
}
}

View File

@@ -0,0 +1,28 @@
import qs.config
import qs.modules.components
import qs.services
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Pipewire
import Quickshell.Widgets
import QtQuick.Controls
StyledSlider {
id: brightnessSlider
Layout.fillWidth: true
from: 0
to: 1
stepSize: 0.01
property var monitor: Brightness.monitors.length > 0 ? Brightness.monitors[0] : null
value: monitor ? monitor.brightness : 0.5
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
property real level: brightnessSlider.value * 100
onMoved: if (monitor) {
monitor.setBrightness(value);
}
}

View File

@@ -0,0 +1,73 @@
import qs.config
import qs.modules.components
import qs.modules.functions
import qs.services
import QtQuick
import Quickshell
import Quickshell.Io
import QtQuick.Layouts
StyledRect {
id: root
width: 150
height: 50
radius: Metrics.radius("verylarge")
color: Appearance.m3colors.m3surfaceContainerHigh
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
property bool flightMode
property string flightModeText: flightMode ? "Enabled" : "Disabled"
Process {
id: toggleflightModeProc
running: false
command: []
function toggle() {
flightMode = !flightMode;
const cmd = flightMode ? "off" : "on";
toggleflightModeProc.command = ["bash", "-c", `nmcli radio all ${cmd}`];
toggleflightModeProc.running = true;
}
}
StyledRect {
id: iconBg
width: 50
height: 50
radius: Metrics.radius("large")
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Metrics.margin(10)
color: !flightMode ? Appearance.m3colors.m3surfaceContainerHigh : Appearance.m3colors.m3primaryContainer
MaterialSymbol {
anchors.centerIn: parent
iconSize: Metrics.iconSize(35)
icon: "flight"
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
anchors.left: iconBg.right
anchors.leftMargin: Metrics.margin(10)
StyledText {
text: "Flight Mode"
font.pixelSize: Metrics.fontSize(20)
}
StyledText {
text: flightModeText
font.pixelSize: Metrics.fontSize("small")
}
}
MouseArea {
anchors.fill: parent
propagateComposedEvents: true
onClicked: toggleflightModeProc.toggle()
}
}

View File

@@ -0,0 +1,196 @@
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Io
import qs.config
import qs.modules.functions
import qs.modules.interface.notifications
import qs.modules.components
import qs.services
StyledRect {
id: root
Layout.fillWidth: true
radius: Metrics.radius("normal")
color: Appearance.m3colors.m3surfaceContainer
ClippingRectangle {
color: Appearance.colors.colLayer1
radius: Metrics.radius("normal")
implicitHeight: 90
anchors.fill: parent
RowLayout {
anchors.fill: parent
spacing: Metrics.margin("small")
ClippingRectangle {
implicitWidth: 140
implicitHeight: 140
Layout.leftMargin: Metrics.margin("large")
radius: Metrics.radius("normal")
clip: true
color: Appearance.colors.colLayer2
Image {
anchors.fill: parent
source: Mpris.artUrl
fillMode: Image.PreserveAspectCrop
cache: true
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.rightMargin: Metrics.margin("small")
spacing: Metrics.spacing(2)
Text {
text: Mpris.albumTitle
elide: Text.ElideRight
Layout.maximumWidth: 190
font.family: Metrics.fontFamily("title")
font.pixelSize: Metrics.fontSize("hugeass")
font.bold: true
color: Appearance.colors.colOnLayer2
}
Text {
text: Mpris.albumArtist
elide: Text.ElideRight
Layout.maximumWidth: 160
font.family: Metrics.fontFamily("main")
font.pixelSize: Metrics.fontSize("normal")
color: Appearance.colors.colSubtext
}
RowLayout {
Layout.fillWidth: true
spacing: Metrics.spacing(12)
Process {
id: control
}
Button {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
onClicked: Quickshell.execDetached(["playerctl", "previous"])
background: Rectangle {
radius: Metrics.radius("large")
color: Appearance.colors.colLayer2
}
contentItem: MaterialSymbol {
anchors.centerIn: parent
icon: "skip_previous"
font.pixelSize: Metrics.fontSize(24)
color: Appearance.colors.colOnLayer2
fill: 1
}
}
Button {
Layout.preferredWidth: 42
Layout.preferredHeight: 42
onClicked: Quickshell.execDetached(["playerctl", "play-pause"])
background: Rectangle {
radius: Metrics.radius("full")
color: Appearance.colors.colPrimary
}
contentItem: MaterialSymbol {
anchors.bottom: parent.bottom
anchors.top: parent.top
icon: "play_arrow"
font.pixelSize: Metrics.fontSize(36)
color: Appearance.colors.colOnPrimary
fill: 1
}
}
Button {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
onClicked: Quickshell.execDetached(["playerctl", "next"])
background: Rectangle {
radius: Metrics.radius("large")
color: Appearance.colors.colLayer2
}
contentItem: MaterialSymbol {
anchors.centerIn: parent
icon: "skip_next"
font.pixelSize: Metrics.fontSize(24)
color: Appearance.colors.colOnLayer2
fill: 1
}
}
}
RowLayout {
Layout.topMargin: Metrics.margin(15)
Layout.fillWidth: true
spacing: Metrics.spacing(12)
Text {
text: Mpris.formatTime(Mpris.positionSec)
font.pixelSize: Metrics.fontSize("smallest")
color: Appearance.colors.colSubtext
}
Item {
Layout.fillWidth: true
implicitHeight: 20
Rectangle {
anchors.fill: parent
radius: Metrics.radius("full")
color: Appearance.colors.colLayer2
}
Rectangle {
width: parent.width * (Mpris.lengthSec > 0 ? Mpris.positionSec / Mpris.lengthSec : 0)
radius: Metrics.radius("full")
color: Appearance.colors.colPrimary
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
}
}
Text {
text: Mpris.formatTime(Mpris.lengthSec)
font.pixelSize: Metrics.fontSize("smallest")
color: Appearance.colors.colSubtext
}
}
}
}
}
}

View File

@@ -0,0 +1,78 @@
import qs.config
import qs.modules.components
import qs.modules.functions
import qs.services
import QtQuick
import Quickshell
import QtQuick.Layouts
StyledRect {
id: root
width: 150
height: 50
radius: Metrics.radius("verylarge")
color: Appearance.m3colors.m3surfaceContainerHigh
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
// Service bindings
readonly property bool wifiEnabled: Network.wifiEnabled
readonly property bool hasActive: Network.active !== null
readonly property string iconName: Network.icon
readonly property string titleText: Network.label
readonly property string statusText: Network.status
// Icon background
StyledRect {
id: iconBg
width: 50
height: 50
radius: Metrics.radius("large")
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Metrics.margin(10)
color: {
if (!wifiEnabled)
return Appearance.m3colors.m3surfaceContainerHigh;
if (hasActive)
return Appearance.m3colors.m3primaryContainer;
return Appearance.m3colors.m3secondaryContainer;
}
MaterialSymbol {
anchors.centerIn: parent
icon: iconName
iconSize: Metrics.iconSize(35)
}
}
// Labels
Column {
anchors.verticalCenter: parent.verticalCenter
anchors.left: iconBg.right
anchors.leftMargin: Metrics.margin(10)
spacing: Metrics.spacing(2)
StyledText {
text: titleText
font.pixelSize: Metrics.fontSize(20)
elide: Text.ElideRight
width: root.width - iconBg.width - 30
}
StyledText {
text: statusText
font.pixelSize: Metrics.fontSize("small")
color: Appearance.m3colors.m3onSurfaceVariant
elide: Text.ElideRight
width: root.width - iconBg.width - 30
}
}
// Interaction
MouseArea {
anchors.fill: parent
onClicked: Network.toggleWifi()
}
}

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