Unified look

This commit is contained in:
David Heinemeier Hansson
2026-05-22 08:59:00 +02:00
parent 181ce06a5b
commit 8cc5dde373
5 changed files with 202 additions and 150 deletions
+30 -14
View File
@@ -616,11 +616,11 @@ Panel {
width: scrollArea.availableWidth width: scrollArea.availableWidth
spacing: Style.space(14) spacing: Style.space(14)
// ---------- Hero: speaker icon · mood · % ---------- // ---------- Hero: speaker icon · title/status · % ----------
Item { Item {
id: heroItem id: heroItem
width: parent.width width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroPercent.implicitHeight) implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, heroPercent.implicitHeight)
Text { Text {
id: heroIcon id: heroIcon
@@ -639,23 +639,39 @@ Panel {
} }
} }
Text { Column {
id: heroLabel id: heroLabels
text: root.outputVolumeName(
outputSlider.dragging ? outputSlider.liveValue : root.outputVolume,
root.outputMuted
).toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
anchors.left: heroIcon.right anchors.left: heroIcon.right
anchors.leftMargin: Style.space(14) anchors.leftMargin: Style.space(14)
anchors.right: heroPercent.left anchors.right: heroPercent.left
anchors.rightMargin: Style.space(10) anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight spacing: Style.space(2)
Text {
text: "Audio"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
id: heroLabel
text: root.outputVolumeName(
outputSlider.dragging ? outputSlider.liveValue : root.outputVolume,
root.outputMuted
).toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
width: parent.width
}
} }
Text { Text {
+117 -113
View File
@@ -88,19 +88,34 @@ Panel {
return "󰂯" return "󰂯"
} }
property int phraseIndex: 0
readonly property var activePhrases: [
"Untangling wires",
"Streaming vikings",
"Pairing mysteries",
"Herding headsets",
"Taming radios",
"Summoning speakers",
"Wrangling codecs",
"Polishing packets"
]
readonly property bool rotatingPhrases: adapter && adapter.enabled
readonly property string heroStatusText: {
if (!adapter) return "No adapter"
if (!adapter.enabled) return "Bluetooth off"
return activePhrases[phraseIndex % activePhrases.length]
}
// Single cursor model shared by keyboard and mouse. Sections: // Single cursor model shared by keyboard and mouse. Sections:
// "header" — 2 action pills (scan, toggle); h/l moves between
// them, Enter activates.
// "connected" — currently connected devices; Enter disconnects. // "connected" — currently connected devices; Enter disconnects.
// "known" — remembered devices; Enter connects. // "known" — remembered devices; Enter connects.
// "discovered" — unremembered devices visible while scanning; Enter connects. // "discovered" — unremembered devices visible while scanning; Enter connects.
// Visuals always come from CursorSurface (hasCursor / current), // Visuals always come from CursorSurface (hasCursor / current),
// never from containsMouse. Mouse hover updates root cursor state too, // never from containsMouse. Mouse hover updates root cursor state too,
// guaranteeing one highlight on screen. // guaranteeing one highlight on screen.
property string focusSection: "header" property string focusSection: "connected"
property int selectedIndex: 1 // default = toggle pill once the cursor is revealed property int selectedIndex: 0
property bool cursorActive: false property bool cursorActive: false
readonly property int headerPillCount: 2
// Stable identity for the focused device. Devices move between sections as // Stable identity for the focused device. Devices move between sections as
// they connect, disconnect, pair, or get forgotten, so follow the BlueZ // they connect, disconnect, pair, or get forgotten, so follow the BlueZ
@@ -115,7 +130,6 @@ Panel {
: "transparent" : "transparent"
function sectionCount(section) { function sectionCount(section) {
if (section === "header") return headerPillCount
if (section === "connected") return connectedDevices.length if (section === "connected") return connectedDevices.length
if (section === "known") return knownDevices.length if (section === "known") return knownDevices.length
if (section === "discovered") return discoveredDevices.length if (section === "discovered") return discoveredDevices.length
@@ -123,7 +137,6 @@ Panel {
} }
function sectionVisible(section) { function sectionVisible(section) {
if (section === "header") return true
if (section === "connected") return connectedDevices.length > 0 if (section === "connected") return connectedDevices.length > 0
if (section === "known") return knownDevices.length > 0 if (section === "known") return knownDevices.length > 0
if (section === "discovered") return adapter && adapter.discovering && discoveredDevices.length > 0 if (section === "discovered") return adapter && adapter.discovering && discoveredDevices.length > 0
@@ -131,7 +144,7 @@ Panel {
} }
readonly property var visibleSections: { readonly property var visibleSections: {
var list = ["header"] var list = []
if (sectionVisible("connected")) list.push("connected") if (sectionVisible("connected")) list.push("connected")
if (sectionVisible("known")) list.push("known") if (sectionVisible("known")) list.push("known")
if (sectionVisible("discovered")) list.push("discovered") if (sectionVisible("discovered")) list.push("discovered")
@@ -228,10 +241,7 @@ Panel {
if (changed) pendingActions = next if (changed) pendingActions = next
} }
// j/k navigates between sections row-by-row. The header is treated as a // j/k navigates between device sections row-by-row.
// SINGLE row (its pills sit on one horizontal line), so j/k from devices
// jumps to/from the header as a unit, and h/l moves between the three
// pills inside it. This matches wifi's DNS-pill behaviour.
function moveCursor(delta) { function moveCursor(delta) {
var sections = visibleSections var sections = visibleSections
if (!sections || sections.length === 0) return if (!sections || sections.length === 0) return
@@ -239,48 +249,21 @@ Panel {
if (sIdx < 0) { focusSection = sections[0]; selectedIndex = 0; return } if (sIdx < 0) { focusSection = sections[0]; selectedIndex = 0; return }
var idx = selectedIndex var idx = selectedIndex
var inHeader = focusSection === "header" var max = sectionCount(focusSection) - 1
var max = inHeader ? 0 : sectionCount(focusSection) - 1
if (delta > 0) { if (delta > 0) {
if (!inHeader && idx < max) { selectedIndex = idx + 1; return } if (idx < max) { selectedIndex = idx + 1; return }
if (sIdx < sections.length - 1) { if (sIdx < sections.length - 1) {
focusSection = sections[sIdx + 1] focusSection = sections[sIdx + 1]
// Entering the header from below shouldn't happen (header is first),
// but other entries start at 0.
selectedIndex = 0 selectedIndex = 0
} }
} else { } else {
if (!inHeader && idx > 0) { selectedIndex = idx - 1; return } if (idx > 0) { selectedIndex = idx - 1; return }
if (sIdx > 0) { if (sIdx > 0) { focusSection = sections[sIdx - 1]; selectedIndex = sectionCount(focusSection) - 1 }
focusSection = sections[sIdx - 1]
// Entering the header always lands on the toggle pill — the most
// common action and consistent with the on-open default. h/l from
// there moves to scan.
selectedIndex = focusSection === "header" ? 1 : sectionCount(focusSection) - 1
}
} }
} }
// h/l: only meaningful in the header. In device sections it's a no-op
// — j/k is the canonical row navigator there.
function moveCursorH(delta) {
if (focusSection !== "header") return
var next = selectedIndex + delta
if (next < 0) next = 0
if (next > headerPillCount - 1) next = headerPillCount - 1
selectedIndex = next
}
function activateCursor() { function activateCursor() {
if (focusSection === "header") {
if (selectedIndex === 0) {
if (adapter && adapter.enabled) adapter.discovering = !adapter.discovering
} else if (selectedIndex === 1) {
if (adapter) adapter.enabled = !adapter.enabled
}
return
}
if (focusSection === "connected" || focusSection === "known") { if (focusSection === "connected" || focusSection === "known") {
var dev = deviceAt(focusSection, selectedIndex) var dev = deviceAt(focusSection, selectedIndex)
if (!dev) return if (!dev) return
@@ -313,16 +296,12 @@ Panel {
if (adapter && adapter.enabled && !adapter.discovering) adapter.discovering = true if (adapter && adapter.enabled && !adapter.discovering) adapter.discovering = true
if (connectedDevices.length > 0) { focusSection = "connected"; selectedIndex = 0 } if (connectedDevices.length > 0) { focusSection = "connected"; selectedIndex = 0 }
else if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 } else if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 }
else { focusSection = "header"; selectedIndex = 1 } else if (discoveredDevices.length > 0) { focusSection = "discovered"; selectedIndex = 0 }
cursorActive = false cursorActive = false
} }
} }
function updateFocusedAddress() { function updateFocusedAddress() {
if (focusSection === "header") {
focusedDeviceAddress = ""
return
}
var d = deviceAt(focusSection, selectedIndex) var d = deviceAt(focusSection, selectedIndex)
focusedDeviceAddress = d ? (d.address || "") : "" focusedDeviceAddress = d ? (d.address || "") : ""
} }
@@ -376,7 +355,10 @@ Panel {
function clampCursor() { function clampCursor() {
var sections = visibleSections var sections = visibleSections
if (!sections || !sections.length) return if (!sections || !sections.length) {
selectedIndex = 0
return
}
if (sections.indexOf(focusSection) < 0) { if (sections.indexOf(focusSection) < 0) {
focusSection = sections[0] focusSection = sections[0]
selectedIndex = 0 selectedIndex = 0
@@ -413,13 +395,54 @@ Panel {
onTriggered: root.pendingActions = ({}) onTriggered: root.pendingActions = ({})
} }
Timer {
id: phraseTimer
interval: 2800
running: root.opened && root.rotatingPhrases
repeat: true
onTriggered: phraseSwap.restart()
}
SequentialAnimation {
id: phraseSwap
PropertyAnimation {
target: heroStatus; property: "opacity"
to: 0.0; duration: 180; easing.type: Easing.OutQuad
}
ScriptAction {
script: root.phraseIndex = (root.phraseIndex + 1) % root.activePhrases.length
}
PropertyAnimation {
target: heroStatus; property: "opacity"
to: 1.0; duration: 260; easing.type: Easing.InQuad
}
}
Connections {
target: root
function onRotatingPhrasesChanged() {
if (!root.rotatingPhrases) {
phraseSwap.stop()
heroStatus.opacity = 1.0
}
}
}
function toggleBluetooth() {
if (!adapter) return
adapter.enabled = !adapter.enabled
if (adapter.enabled) Qt.callLater(function() {
if (root.adapter) root.adapter.discovering = true
})
}
WidgetButton { WidgetButton {
id: button id: button
anchors.fill: parent anchors.fill: parent
bar: root.bar bar: root.bar
text: root.icon text: root.icon
onPressed: function(b) { onPressed: function(b) {
if (b === Qt.RightButton && root.adapter) root.adapter.enabled = !root.adapter.enabled if (b === Qt.RightButton) root.toggleBluetooth()
else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-bluetooth") else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-bluetooth")
else root.toggle() else root.toggle()
} }
@@ -441,7 +464,6 @@ Panel {
onMoveRequested: function(dx, dy) { onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return } if (!root.cursorActive) { root.cursorActive = true; return }
if (dy !== 0) root.moveCursor(dy) if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) root.moveCursorH(dx)
} }
onActivateRequested: if (root.cursorActive) root.activateCursor() onActivateRequested: if (root.cursorActive) root.activateCursor()
onCloseRequested: root.close() onCloseRequested: root.close()
@@ -452,54 +474,65 @@ Panel {
anchors.fill: parent anchors.fill: parent
spacing: Style.space(10) spacing: Style.space(10)
// Header: title left, on/off toggle + actions right. // ---------- Hero: Bluetooth icon · status ----------
Item { Item {
width: parent.width width: parent.width
height: titleText.implicitHeight implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
Row { Text {
id: heroIcon
anchors.left: parent.left anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(8) text: root.icon
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
opacity: root.adapter && root.adapter.enabled ? 1.0 : 0.5
PanelSectionHeader { MouseArea {
id: titleText id: heroIconMouse
text: "Bluetooth" anchors.fill: parent
foreground: root.bar.foreground hoverEnabled: true
fontFamily: root.bar.fontFamily cursorShape: root.adapter ? Qt.PointingHandCursor : Qt.ArrowCursor
fontSize: Style.font.bodySmall enabled: !!root.adapter
anchors.verticalCenter: parent.verticalCenter onClicked: root.toggleBluetooth()
} }
Text { PanelToolTip {
text: "· " + (root.adapter && root.adapter.enabled ? "On" : "Off") visible: heroIconMouse.containsMouse
color: Qt.darker(root.bar.foreground, 1.8) text: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on"
font.family: root.bar.fontFamily fontFamily: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
anchors.verticalCenter: parent.verticalCenter
} }
} }
Row { Column {
id: heroLabels
anchors.left: heroIcon.right
anchors.leftMargin: Style.space(14)
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(4) spacing: Style.space(2)
HeaderPill { Text {
pillIndex: 0 text: "Bluetooth"
iconText: "󰑐" color: root.bar.foreground
iconSpinning: root.adapter && root.adapter.discovering font.family: root.bar.fontFamily
tooltipText: !root.adapter ? "" : !root.adapter.enabled ? "Bluetooth is off" font.pixelSize: Style.font.title
: root.adapter.discovering ? "Stop scanning" : "Scan for devices" font.bold: true
pillEnabled: root.adapter !== null && root.adapter.enabled elide: Text.ElideRight
onActivated: if (root.adapter) root.adapter.discovering = !root.adapter.discovering width: parent.width
} }
HeaderPill { Text {
pillIndex: 1 id: heroStatus
iconText: root.adapter && root.adapter.enabled ? "󰂲" : "󰂯" text: root.heroStatusText.toUpperCase()
tooltipText: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on" color: Qt.darker(root.bar.foreground, 1.4)
onActivated: if (root.adapter) root.adapter.enabled = !root.adapter.enabled font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
width: parent.width
} }
} }
} }
@@ -592,7 +625,7 @@ Panel {
text: !root.adapter ? "No Bluetooth adapter" text: !root.adapter ? "No Bluetooth adapter"
: !root.adapter.enabled ? "Turn Bluetooth on to scan" : !root.adapter.enabled ? "Turn Bluetooth on to scan"
: root.adapter.discovering ? "Scanning for devices…" : root.adapter.discovering ? "Scanning for devices…"
: "No paired devices. Tap the scan icon to find new ones." : "No paired devices. Reopen this panel to scan again."
color: Qt.darker(root.bar.foreground, 1.5) color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall font.pixelSize: Style.font.bodySmall
@@ -605,35 +638,6 @@ Panel {
} }
} }
// Header pill: a Button bound into the panel's "header" cursor
// section. Button collapses what used to be a Button subclass +
// overlay MouseArea into one component; we keep the pillIndex / activated
// shim here so the three header pill instantiations stay readable.
component HeaderPill: Button {
id: pill
required property int pillIndex
property bool pillEnabled: true
signal activated()
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
horizontalPadding: Style.spacing.md
verticalPadding: Style.spacing.labelGap
iconSize: 14
enabled: pillEnabled
opacity: pillEnabled ? 1 : 0.4
hasCursor: root.cursorActive && root.focusSection === "header" && root.selectedIndex === pillIndex
onClicked: pill.activated()
onHovered: function(isHovered) {
if (!isHovered) return
root.cursorActive = true
root.focusSection = "header"
root.selectedIndex = pill.pillIndex
}
}
// Two-line device row showing name + live status. Pending state is owned // Two-line device row showing name + live status. Pending state is owned
// by the panel so it survives rows moving between sections. // by the panel so it survives rows moving between sections.
component DeviceRow: CursorSurface { component DeviceRow: CursorSurface {
+27 -11
View File
@@ -399,11 +399,11 @@ Panel {
width: scrollArea.availableWidth width: scrollArea.availableWidth
spacing: Style.space(14) spacing: Style.space(14)
// ---------- Hero: sun icon · BRIGHTNESS · percentage ---------- // ---------- Hero: display icon · title/status · percentage ----------
Item { Item {
width: parent.width width: parent.width
visible: root.brightnessAvailable visible: root.brightnessAvailable
implicitHeight: Math.max(heroIcon.implicitHeight, heroPercent.implicitHeight) implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, heroPercent.implicitHeight)
Text { Text {
id: heroIcon id: heroIcon
@@ -415,20 +415,36 @@ Panel {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
Text { Column {
id: heroLabel id: heroLabels
text: root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
anchors.left: heroIcon.right anchors.left: heroIcon.right
anchors.leftMargin: Style.space(14) anchors.leftMargin: Style.space(14)
anchors.right: heroPercent.left anchors.right: heroPercent.left
anchors.rightMargin: Style.space(10) anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight spacing: Style.space(2)
Text {
text: "Display"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
id: heroLabel
text: root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
width: parent.width
}
} }
Text { Text {
+1 -1
View File
@@ -749,10 +749,10 @@ Panel {
Text { Text {
id: heroSsid id: heroSsid
visible: root.info.type !== "ethernet"
width: parent.width width: parent.width
text: { text: {
if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi" if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi"
if (root.info.type === "ethernet") return "Ethernet"
return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection") return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection")
} }
color: root.bar.foreground color: root.bar.foreground
+27 -11
View File
@@ -293,10 +293,10 @@ Panel {
anchors.top: parent.top anchors.top: parent.top
spacing: Style.space(14) spacing: Style.space(14)
// ---------- Hero: battery icon · status · percentage ---------- // ---------- Hero: battery icon · title/status · percentage ----------
Item { Item {
width: parent.width width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroPercent.implicitHeight) implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, heroPercent.implicitHeight)
Text { Text {
id: heroIcon id: heroIcon
@@ -310,20 +310,36 @@ Panel {
Behavior on color { ColorAnimation { duration: 200 } } Behavior on color { ColorAnimation { duration: 200 } }
} }
Text { Column {
id: heroStatus id: heroLabels
text: root.heroStatusText.toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
anchors.left: heroIcon.right anchors.left: heroIcon.right
anchors.leftMargin: Style.space(14) anchors.leftMargin: Style.space(14)
anchors.right: heroPercent.left anchors.right: heroPercent.left
anchors.rightMargin: Style.space(10) anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight spacing: Style.space(2)
Text {
text: "Battery"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
id: heroStatus
text: root.heroStatusText.toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
width: parent.width
}
} }
Text { Text {