Skip to content

Commit 5cf5c5a

Browse files
feat: add nexus displays configuration page
This adds the Displays/Monitors settings page to Nexus, allowing users to identify, arrange, scale, rotate, and set refresh rates for their monitors. Includes: - Monitors service (services/Monitors.qml) wrapping Hyprland keyword command batching. - Hyprctl service (services/Hyprctl.qml) for querying active monitor status. - Monitor identifier overlay (modules/MonitorIdentifier.qml).
1 parent 90a1b46 commit 5cf5c5a

10 files changed

Lines changed: 1195 additions & 7 deletions

File tree

modules/MonitorIdentifier.qml

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
pragma ComponentBehavior: Bound
2+
3+
import QtQuick
4+
import QtQuick.Layouts
5+
import Quickshell
6+
import Quickshell.Wayland
7+
import Caelestia.Config
8+
import qs.components
9+
import qs.components.containers
10+
import qs.services
11+
12+
Variants {
13+
id: root
14+
15+
readonly property bool active: Monitors.identifying
16+
17+
model: Quickshell.screens
18+
19+
StyledWindow {
20+
id: win
21+
22+
required property ShellScreen modelData
23+
readonly property var monitor: Hypr.monitorFor(modelData)
24+
25+
screen: modelData
26+
name: "monitor-identifier"
27+
visible: root.active || identifierRect.opacity > 0
28+
29+
WlrLayershell.layer: WlrLayer.Overlay
30+
WlrLayershell.exclusionMode: ExclusionMode.Ignore
31+
32+
anchors.top: true
33+
anchors.bottom: true
34+
anchors.left: true
35+
anchors.right: true
36+
37+
// Click anywhere on the overlay to dismiss
38+
MouseArea {
39+
anchors.fill: parent
40+
onClicked: Monitors.stopIdentification()
41+
}
42+
43+
StyledRect {
44+
id: identifierRect
45+
46+
anchors.centerIn: parent
47+
implicitWidth: Tokens.padding.large * 14
48+
implicitHeight: Tokens.padding.large * 14
49+
radius: Tokens.rounding.large
50+
color: Colours.tPalette.m3surfaceContainer
51+
opacity: root.active ? 0.92 : 0
52+
53+
// Prevent the MouseArea behind from stealing this click
54+
MouseArea {
55+
anchors.fill: parent
56+
onClicked: Monitors.stopIdentification()
57+
}
58+
59+
ColumnLayout {
60+
anchors.centerIn: parent
61+
spacing: Tokens.spacing.small
62+
63+
StyledText {
64+
Layout.alignment: Qt.AlignHCenter
65+
text: win.monitor?.id ?? "?"
66+
font.pointSize: 96
67+
font.bold: true
68+
color: Colours.palette.m3primary
69+
}
70+
71+
StyledText {
72+
Layout.alignment: Qt.AlignHCenter
73+
text: win.monitor?.name ?? ""
74+
font: Tokens.font.body.medium
75+
color: Colours.palette.m3onSurfaceVariant
76+
}
77+
}
78+
79+
Behavior on opacity {
80+
Anim {}
81+
}
82+
}
83+
}
84+
}

modules/dashboard/Monitors.qml

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
pragma ComponentBehavior: Bound
2+
3+
import QtQuick
4+
import QtQuick.Layouts
5+
import Caelestia.Config
6+
import qs.components
7+
import qs.components.controls
8+
import qs.services
9+
10+
ColumnLayout {
11+
id: root
12+
13+
spacing: Tokens.spacing.large
14+
15+
RowLayout {
16+
Layout.fillWidth: true
17+
Layout.margins: Tokens.padding.medium
18+
19+
StyledText {
20+
text: qsTr("Monitors")
21+
font: Tokens.font.title.large
22+
Layout.fillWidth: true
23+
}
24+
25+
IconTextButton {
26+
icon: "info"
27+
text: qsTr("Identify")
28+
isToggle: true
29+
checked: Monitors.identifying
30+
onClicked: Monitors.toggleIdentification()
31+
}
32+
}
33+
34+
Flickable {
35+
Layout.fillWidth: true
36+
Layout.fillHeight: true
37+
contentHeight: monitorsLayout.implicitHeight
38+
clip: true
39+
40+
ColumnLayout {
41+
id: monitorsLayout
42+
43+
anchors.left: parent.left
44+
anchors.right: parent.right
45+
spacing: Tokens.spacing.medium
46+
47+
Repeater {
48+
model: Hyprctl.monitors
49+
50+
delegate: StyledRect {
51+
id: monitorDelegate
52+
53+
required property var modelData
54+
55+
readonly property var mon: monitorDelegate.modelData
56+
readonly property var brightnessMon: Brightness.getMonitor(monitorDelegate.mon.name)
57+
58+
Layout.fillWidth: true
59+
implicitHeight: monitorContent.implicitHeight + Tokens.padding.large * 2
60+
color: Colours.tPalette.m3surfaceContainerHigh
61+
radius: Tokens.rounding.large
62+
63+
ColumnLayout {
64+
id: monitorContent
65+
66+
anchors.fill: parent
67+
anchors.margins: Tokens.padding.large
68+
spacing: Tokens.spacing.medium
69+
70+
RowLayout {
71+
Layout.fillWidth: true
72+
MaterialIcon {
73+
text: "monitor"
74+
color: Colours.palette.m3primary
75+
}
76+
ColumnLayout {
77+
Layout.fillWidth: true
78+
spacing: 0
79+
80+
StyledText {
81+
text: `${monitorDelegate.mon.name} - ${monitorDelegate.mon.make} ${monitorDelegate.mon.model}`
82+
font: Tokens.font.title.medium
83+
Layout.fillWidth: true
84+
}
85+
StyledText {
86+
text: `${monitorDelegate.mon.width}x${monitorDelegate.mon.height}@${(monitorDelegate.mon.refreshRate ?? 0).toFixed(2)}Hz`
87+
color: Colours.palette.m3onSurfaceVariant
88+
font: Tokens.font.body.small
89+
}
90+
}
91+
StyledText {
92+
text: `ID: ${monitorDelegate.mon.id}`
93+
color: Colours.palette.m3onSurfaceVariant
94+
}
95+
}
96+
97+
// Brightness
98+
RowLayout {
99+
Layout.fillWidth: true
100+
visible: !!monitorDelegate.brightnessMon
101+
102+
MaterialIcon {
103+
text: "brightness_medium"
104+
fontStyle: Tokens.font.icon.medium
105+
}
106+
107+
StyledSlider {
108+
Layout.fillWidth: true
109+
value: monitorDelegate.brightnessMon?.brightness ?? 0
110+
onMoved: if (monitorDelegate.brightnessMon)
111+
monitorDelegate.brightnessMon.setBrightness(value)
112+
}
113+
114+
StyledText {
115+
text: `${Math.round((monitorDelegate.brightnessMon?.brightness ?? 0) * 100)}%`
116+
Layout.preferredWidth: 40
117+
}
118+
}
119+
120+
// Scaling
121+
RowLayout {
122+
Layout.fillWidth: true
123+
124+
MaterialIcon {
125+
text: "zoom_in"
126+
fontStyle: Tokens.font.icon.medium
127+
}
128+
129+
StyledSlider {
130+
Layout.fillWidth: true
131+
from: 0.5
132+
to: 3.0
133+
value: monitorDelegate.mon.scale
134+
onMoved: Monitors.setScale(monitorDelegate.mon.name, value)
135+
}
136+
137+
StyledText {
138+
text: `${monitorDelegate.mon.scale.toFixed(2)}x`
139+
Layout.preferredWidth: 40
140+
}
141+
}
142+
143+
// Refresh Rate
144+
RowLayout {
145+
Layout.fillWidth: true
146+
spacing: Tokens.spacing.small
147+
148+
StyledText {
149+
text: qsTr("Refresh Rate")
150+
Layout.fillWidth: true
151+
}
152+
153+
CustomSpinBox {
154+
id: rrSelector
155+
156+
min: 10
157+
max: 1000
158+
step: 1
159+
value: monitorDelegate.mon.refreshRate
160+
onValueModified: val => Monitors.setRefreshRate(monitorDelegate.mon.name, val)
161+
}
162+
}
163+
164+
// Rotation
165+
RowLayout {
166+
Layout.fillWidth: true
167+
spacing: Tokens.spacing.small
168+
169+
StyledText {
170+
text: qsTr("Rotation")
171+
Layout.fillWidth: true
172+
}
173+
174+
Repeater {
175+
model: [
176+
{
177+
label: "0°",
178+
val: 0,
179+
icon: "screen_rotation"
180+
},
181+
{
182+
label: "90°",
183+
val: 1,
184+
icon: "screen_rotation"
185+
},
186+
{
187+
label: "180°",
188+
val: 2,
189+
icon: "screen_rotation"
190+
},
191+
{
192+
label: "270°",
193+
val: 3,
194+
icon: "screen_rotation"
195+
}
196+
]
197+
198+
delegate: IconButton {
199+
required property var modelData
200+
required property int index
201+
202+
icon: modelData.icon
203+
isToggle: true
204+
checked: monitorDelegate.mon.transform === modelData.val
205+
onClicked: Monitors.rotate(monitorDelegate.mon.name, modelData.val * 90)
206+
}
207+
}
208+
}
209+
210+
// Arrangement
211+
RowLayout {
212+
Layout.fillWidth: true
213+
spacing: Tokens.spacing.small
214+
215+
StyledText {
216+
text: qsTr("Position relative to:")
217+
Layout.fillWidth: true
218+
}
219+
220+
CustomSpinBox {
221+
id: targetMonSelector
222+
223+
min: 0
224+
max: Math.max(0, (Hyprctl.monitors.length ?? 1) - 1)
225+
value: 0
226+
}
227+
228+
IconButton {
229+
icon: "arrow_back"
230+
onClicked: Monitors.arrange(monitorDelegate.mon.name, "left", targetMonSelector.value)
231+
}
232+
IconButton {
233+
icon: "arrow_forward"
234+
onClicked: Monitors.arrange(monitorDelegate.mon.name, "right", targetMonSelector.value)
235+
}
236+
IconButton {
237+
icon: "arrow_upward"
238+
onClicked: Monitors.arrange(monitorDelegate.mon.name, "top", targetMonSelector.value)
239+
}
240+
IconButton {
241+
icon: "arrow_downward"
242+
onClicked: Monitors.arrange(monitorDelegate.mon.name, "bottom", targetMonSelector.value)
243+
}
244+
}
245+
}
246+
}
247+
}
248+
}
249+
}
250+
}

modules/nexus/NexusState.qml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ QtObject {
1414
property BluetoothDevice selectedBtDevice
1515
property DesktopEntry selectedApp
1616
property string selectedEthernetInterface
17+
property var selectedMonitor
1718

1819
signal close
1920
signal subPageOpened(idx: int)

modules/nexus/PageCompRegistry.qml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import qs.modules.nexus.pages
1010
import qs.modules.nexus.pages.apps
1111
import qs.modules.nexus.pages.audio
1212
import qs.modules.nexus.pages.bluetooth
13+
import qs.modules.nexus.pages.monitors
1314
import qs.modules.nexus.pages.network
1415
import qs.modules.nexus.pages.panels
1516
import qs.modules.nexus.pages.services
@@ -38,6 +39,17 @@ QtObject {
3839
}
3940
}
4041
},
42+
Component {
43+
// Display / Monitors
44+
StackPage {
45+
Component {
46+
MonitorsPane {}
47+
}
48+
Component {
49+
MonitorDetail {}
50+
}
51+
}
52+
},
4153

4254
// Connectivity
4355
Component {

modules/nexus/PageRegistry.qml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@ QtObject {
1515
},
1616

1717
// Connectivity
18-
// TODO
19-
// {
20-
// label: qsTr("Display"),
21-
// icon: "monitor",
22-
// description: qsTr("Output configuration"),
23-
// category: "connectivity"
24-
// },
18+
{
19+
label: qsTr("Display"),
20+
icon: "monitor",
21+
description: qsTr("Output configuration"),
22+
category: "connectivity"
23+
},
2524
{
2625
label: qsTr("Network"),
2726
icon: "wifi",

0 commit comments

Comments
 (0)