Add a notched text-size slider to the display panel

A macOS-style slider snapping to curated stops (9-20px), applied via
omarchy-display-text-size. Suppresses hover-driven focus changes while a
size change reflows the panel, so h/l on the slider can't let a shifted
row steal keyboard focus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-20 18:08:17 -07:00
co-authored by Claude Opus 4.8
parent 84ad03b8d2
commit ab49ec3787
+167 -8
View File
@@ -43,9 +43,28 @@ Panel {
property int selectedIndex: 0
property bool cursorActive: false
// Text size slider — curated macOS-style notches (px). The panel snaps to
// these stops; the CLI (omarchy-display-text-size) accepts any integer in range.
readonly property var textSizeStops: [9, 10, 11, 12, 14, 16, 20]
// While a change is in flight, the chosen stop index overrides the live
// base-size so the knob doesn't snap back during the file round-trip. -1 =
// no pending change; follow Style.font.baseSize.
property int textSizePreviewIndex: -1
// A text-size change reflows the whole panel (both font and spacing scale),
// which slides rows under a stationary pointer and fires synthetic hover.
// While true, hover is not allowed to hijack the keyboard focus section —
// otherwise h/l on the text-size slider can jump focus to another row.
property bool reflowingText: false
function markReflowing() {
root.reflowingText = true
reflowSettle.restart()
}
readonly property var visibleSections: {
var list = []
if (brightnessAvailable) list.push("brightness")
list.push("textsize")
list.push("scale")
if (displays.length > 1) list.push("monitors")
return list
@@ -53,18 +72,19 @@ Panel {
function sectionCount(section) {
if (section === "brightness") return 0 // only the slider sentinel at -1
if (section === "textsize") return 0 // slider sentinel at -1, like brightness
if (section === "scale") return scaleValues.length
if (section === "monitors") return displays.length
return 0
}
function sectionIsSingleRow(section) {
// brightness has only the slider; scale presets sit horizontally.
return section === "brightness" || section === "scale"
// brightness and text size are lone sliders; scale presets sit horizontally.
return section === "brightness" || section === "textsize" || section === "scale"
}
function sectionFirstIndex(section) {
if (section === "brightness") return -1
if (section === "brightness" || section === "textsize") return -1
return 0
}
@@ -137,8 +157,8 @@ Panel {
}
var count = sectionCount(focusSection)
if (sectionIsSingleRow(focusSection)) {
// brightness uses -1 sentinel; scale clamps into the preset range.
if (focusSection === "brightness") selectedIndex = -1
// brightness/text size use the -1 sentinel; scale clamps into the presets.
if (focusSection === "brightness" || focusSection === "textsize") selectedIndex = -1
else if (selectedIndex < 0 || selectedIndex >= count) selectedIndex = 0
return
}
@@ -252,6 +272,43 @@ Panel {
if (!actionProc.running) actionProc.running = true
}
// ---- Text size (shell base font + GTK text-scaling, via one CLI) ----
function nearestTextStop(px) {
var best = 0
var bestDist = 1e9
for (var i = 0; i < textSizeStops.length; i++) {
var d = Math.abs(textSizeStops[i] - px)
if (d < bestDist) { bestDist = d; best = i }
}
return best
}
// Effective stop index: the pending choice while a change is in flight,
// otherwise whatever Style's live base-size rounds to.
function currentTextIndex() {
return textSizePreviewIndex >= 0 ? textSizePreviewIndex : nearestTextStop(Style.font.baseSize)
}
// px shown in the header: the pending stop if any, else the true base-size
// (which may be an off-notch value set from the CLI).
function displayedTextPx() {
return textSizePreviewIndex >= 0 ? textSizeStops[textSizePreviewIndex] : Style.font.baseSize
}
function setTextSize(px) {
textScaleProc.command = ["omarchy-display-text-size", String(px)]
if (!textScaleProc.running) textScaleProc.running = true
}
function adjustTextSize(deltaSteps) {
var idx = currentTextIndex() + deltaSteps
if (idx < 0) idx = 0
if (idx > textSizeStops.length - 1) idx = textSizeStops.length - 1
markReflowing()
textSizePreviewIndex = idx
setTextSize(textSizeStops[idx])
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
@@ -340,6 +397,36 @@ Panel {
onRunningChanged: if (!running) root.refresh()
}
// Applies text size via the CLI, which rewrites the shell override file;
// Style picks the new base-size up through its own file watch, so there's
// nothing to refresh here.
Process {
id: textScaleProc
stdout: StdioCollector { waitForEnd: true }
}
// Clears the hover-suppression flag once the reflow triggered by a text-size
// change has settled.
Timer {
id: reflowSettle
interval: 300
repeat: false
onTriggered: root.reflowingText = false
}
// Once Style's base-size catches up to the pending choice, drop the preview
// so the slider tracks the live value again. The change itself reflows the
// panel, so suppress hover for a beat while it lands.
Connections {
target: Style
function onFontBaseSizeChanged() {
root.markReflowing()
if (root.textSizePreviewIndex >= 0
&& root.nearestTextStop(Style.font.baseSize) === root.textSizePreviewIndex)
root.textSizePreviewIndex = -1
}
}
BarIconButton {
id: button
anchors.fill: parent
@@ -369,6 +456,7 @@ Panel {
if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) {
if (root.focusSection === "brightness") root.adjustBrightness(dx * 5)
else if (root.focusSection === "textsize") root.adjustTextSize(dx)
else if (root.focusSection === "scale") root.moveCursorH(dx)
}
}
@@ -506,7 +594,7 @@ Panel {
}
HoverHandler {
onHoveredChanged: if (hovered) {
onHoveredChanged: if (hovered && !root.reflowingText) {
root.cursorActive = true
root.focusSection = "brightness"
root.selectedIndex = -1
@@ -523,6 +611,77 @@ Panel {
font.pixelSize: Style.font.bodySmall
}
// ---------- Text size ----------
PanelSeparator {
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(6)
Item {
width: parent.width
implicitHeight: Math.max(textSizeHeader.implicitHeight, textSizePx.implicitHeight)
PanelSectionHeader {
id: textSizeHeader
text: "TEXT SIZE"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: textSizePx
text: (textSizeSlider.dragging
? root.textSizeStops[Math.round(textSizeSlider.liveValue)]
: root.displayedTextPx()) + "px"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
anchors.right: parent.right
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
}
}
CursorSurface {
id: textSizeRow
width: parent.width
height: textSizeSlider.implicitHeight + Style.spacing.controlGap
hasCursor: root.cursorActive && root.focusSection === "textsize" && root.selectedIndex === -1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(textSizeRow)
foreground: root.bar.foreground
outline: true
PanelSlider {
id: textSizeSlider
bar: root.bar
anchors.fill: parent
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
minimum: 0
maximum: root.textSizeStops.length - 1
step: 1
integer: true
tickCount: root.textSizeStops.length
value: root.currentTextIndex()
onReleased: function(v) { root.setTextSize(root.textSizeStops[Math.round(v)]) }
}
HoverHandler {
onHoveredChanged: if (hovered && !root.reflowingText) {
root.cursorActive = true
root.focusSection = "textsize"
root.selectedIndex = -1
}
}
}
}
// ---------- Scale ----------
PanelSeparator {
foreground: root.bar.foreground
@@ -621,7 +780,7 @@ Panel {
onClicked: root.setScale(scaleValue)
onHovered: function(isHovered) {
if (!isHovered) return
if (!isHovered || root.reflowingText) return
root.cursorActive = true
root.focusSection = "scale"
root.selectedIndex = pill.scaleIndex
@@ -689,7 +848,7 @@ Panel {
anchors.fill: parent
hoverEnabled: true
cursorShape: monitorRow.canToggle ? Qt.PointingHandCursor : Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse) {
onContainsMouseChanged: if (containsMouse && !root.reflowingText) {
root.cursorActive = true
root.focusSection = "monitors"
root.selectedIndex = monitorRow.rowIndex