Files
asepharyana 1cdb82a76f Sync config from arch
- hypr/apps.lua
- hypr/autostart.lua
- hypr/envs.lua
- hypr/hyprland.lua
- hypr/hyprsunset.conf
- hypr/input.lua
- hypr/looknfeel.lua
- hypr/omasettings.lua
- hypr/xdph.conf
- omarchy/branding/about.txt
- omarchy/branding/screensaver.txt
- omarchy/extensions/omarchy-menu.jsonc
- omarchy/hooks/battery-low.d/play-warning-sound.sample
- omarchy/hooks/font-set.d/show-font-notification.sample
- omarchy/hooks/post-boot.d/weather.sample
- omarchy/hooks/post-update.d/install-voxtype.hook
- omarchy/hooks/post-update.d/setup-agent.hook
- omarchy/hooks/post-update.d/setup-fingerprint.hook
- omarchy/hooks/post-update.d/show-update-notification.sample
- omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample
- omarchy/hooks/theme-set.d/show-theme-notification.sample
- omarchy/shell.json
- omarchy/shell.toml
- omarchy/theme.name
- omarchy/themes/azure-glow/README.md
- omarchy/themes/azure-glow/alacritty.toml
- omarchy/themes/azure-glow/btop.theme
- omarchy/themes/azure-glow/hyprland.conf
- omarchy/themes/azure-glow/hyprlock.conf
- omarchy/themes/azure-glow/icons.theme
- … 269 more
2026-09-23 15:19:12 +07:00

478 lines
20 KiB
QML

// A row of buttons must fit the panel it is drawn in.
//
// QtQuick's Row is a positioner, not a layout: it cannot shrink a child and it
// cannot start a second line. Anything wider than the panel is simply laid out
// past the right edge, and the control that lands there is gone -- not clipped
// with a scrollbar, not wrapped, just off the panel with no way to reach it.
//
// That is how "Suggested here" cost the detail view its Delete button. The
// header holds four buttons only when the active window matched a login, and
// the pinned label is one character wider than the unpinned one -- so the row
// fit at 441px until the moment you clicked, and 454.5px after. Nothing in the
// panel said so; the button was just missing.
//
// So this measures. It reads the panel's own QML, finds every Row of buttons,
// rebuilds each label with the real font, and adds up what the kit will make
// of them. A Row that does not fit fails here instead of in a screenshot.
//
// Rows declared as Flow or RowLayout are reported but not failed: those two
// CAN wrap or shrink, which is the fix this test exists to push people toward.
//
// Needs the QML sources readable from QML, which Qt gates behind an env var:
//
// QML_XHR_ALLOW_FILE_READ=1 QT_QPA_PLATFORM=offscreen \
// /usr/lib/qt6/bin/qmltestrunner -input tests/qml
//
import QtQuick
import QtTest
TestCase {
id: tc
name: "RowWidths"
when: windowShown
width: 600; height: 200
visible: true
// ---------------------------------------------------------------- budgets
//
// Panel.qml draws into `fittedContentWidth(Style.space(450))`. Rows inside a
// Flickable lose `scrollGutter` (Style.space(10)) on top of that, and rather
// than track which rows are and are not inside one, every panel row is held
// to the narrower 440. The SSH prompt is its own card: Style.space(460) less
// panelPadding (18) and the card border (2) on each side.
//
// These are the sizes at the default [font] base-size of 12. A theme scales
// fonts and spacing by the same factor -- Style.space() multiplies by
// fontScale too -- so the ratio this test pins holds at any base size.
readonly property int panelBudget: 440
readonly property int popupBudget: 420
// ------------------------------------------------------- the kit's Button
//
// qs.Ui.Button cannot be instantiated here: it imports qs.Commons, which
// imports Quickshell, whose plugin only loads inside the quickshell runtime.
// So its geometry is restated, from Ui/Button.qml:
//
// implicitWidth: row.implicitWidth + horizontalPadding * 2
// + _reservedBorderLeft + _reservedBorderRight
//
// where the inner row is `icon + Style.spacing.controlGap + label`, the
// padding is Style.spacing.controlPaddingX, and the reserved border is the
// widest any state can paint (1px a side at the default border width).
// `verify_button_geometry_is_still_the_kits` below fails if that changes.
readonly property int controlGap: 8
readonly property int controlPaddingX: 10
readonly property int reservedBorder: 2
// Style.font tokens at base-size 12: caption .833, body-small .917, body 1.0,
// and `icon` defaults to `title` (1.167).
readonly property var fontPx: ({
"Style.font.caption": 10,
"Style.font.bodySmall": 11,
"Style.font.body": 12
})
readonly property int iconPx: 14
TextMetrics { id: labelMetrics; font.family: "monospace" }
TextMetrics { id: iconMetrics; font.family: "monospace"; font.pixelSize: tc.iconPx }
function labelWidth(text, px) {
labelMetrics.font.pixelSize = px
labelMetrics.text = text
return labelMetrics.advanceWidth
}
// One monospace cell at the icon size, NOT the glyph itself.
//
// These icons are Nerd Font private-use codepoints, which exist on a desktop
// running the shell and not on a CI runner -- and a missing glyph measures as
// the fallback's notdef box, so measuring them directly would quietly change
// every total the moment this runs somewhere without the font. In a patched
// monospace font a Nerd glyph occupies exactly one cell, so an ordinary
// character at the same pixel size is the same width and is everywhere.
// (Locally both measure 8.390625.)
function iconWidth(glyph) {
if (!glyph) return 0
iconMetrics.text = "M"
return iconMetrics.advanceWidth
}
function buttonWidth(button) {
var icon = iconWidth(button.icon)
var label = button.label === "" ? 0 : labelWidth(button.label, button.fontSize)
var gap = (icon > 0 && label > 0) ? tc.controlGap : 0
return icon + gap + label + button.paddingX * 2 + tc.reservedBorder
}
// ------------------------------------------------------------ reading QML
function readFile(relativePath) {
var xhr = new XMLHttpRequest()
xhr.open("GET", Qt.resolvedUrl("../../" + relativePath), false)
xhr.send()
return xhr.responseText || ""
}
// Every string literal in a binding, longest first. A label is often a
// conditional -- `root.sendMode === "create" ? "Back to Sends" : "Back"` --
// and the widest branch is the one that has to fit.
function widestLiteral(binding) {
var found = binding.match(/"((?:[^"\\]|\\.)*)"/g)
if (!found) return null
var widest = ""
for (var i = 0; i < found.length; i++) {
var value = found[i].slice(1, -1).replace(/\\"/g, "\"")
if (value.length > widest.length) widest = value
}
return widest
}
function propertyIn(body, name) {
var m = body.match(new RegExp("^\\s*" + name + ":\\s*(.*)$", "m"))
return m ? m[1].trim() : null
}
// Direct children only. A nested Row -- the per-item action buttons inside a
// list delegate, say -- is found and measured as a row in its own right, and
// must not also be counted as part of its parent.
function directChildren(body, type) {
var out = []
var open = new RegExp("(?:^|\\n)(\\s*)(?:" + type + ")\\s*\\{")
var rest = body
var depth = 0
var lines = body.split("\n")
var i
for (i = 0; i < lines.length; i++) {
var line = lines[i]
var isChild = depth === 1 && new RegExp("^\\s*(?:" + type + ")\\s*\\{").test(line)
if (isChild) {
var childDepth = 0
var collected = []
for (var j = i; j < lines.length; j++) {
collected.push(lines[j])
childDepth += (lines[j].match(/\{/g) || []).length
childDepth -= (lines[j].match(/\}/g) || []).length
if (childDepth === 0 && j > i) break
}
out.push(collected.join("\n"))
}
depth += (line.match(/\{/g) || []).length
depth -= (line.match(/\}/g) || []).length
}
return out
}
// Every Row / Flow / RowLayout in a file, with the buttons directly in it.
function rowsIn(source, file) {
var lines = source.split("\n")
var rows = []
for (var i = 0; i < lines.length; i++) {
var opener = lines[i].match(/^(\s*)(Row|Flow|RowLayout)\s*\{\s*$/)
if (!opener) continue
var depth = 0
var block = []
for (var j = i; j < lines.length; j++) {
block.push(lines[j])
depth += (lines[j].match(/\{/g) || []).length
depth -= (lines[j].match(/\}/g) || []).length
if (depth === 0 && j > i) break
}
var body = block.join("\n")
var buttons = []
var declarations = directChildren(body, "Button|VaultFilterButton")
for (var k = 0; k < declarations.length; k++) {
var declaration = declarations[k]
var textBinding = propertyIn(declaration, "text")
var label = textBinding === null ? null : widestLiteral(textBinding)
// A label with no literal in it is vault text or a computed string.
// Its width is not ours to know, so it is reported, never measured.
if (label === null) { buttons.push(null); continue }
var sizeBinding = propertyIn(declaration, "fontSize")
var iconBinding = propertyIn(declaration, "iconText")
var padBinding = propertyIn(declaration, "horizontalPadding")
buttons.push({
label: label,
icon: iconBinding === null ? "" : (widestLiteral(iconBinding) || ""),
fontSize: (sizeBinding && tc.fontPx[sizeBinding] !== undefined)
? tc.fontPx[sizeBinding] : tc.fontPx["Style.font.body"],
paddingX: padBinding === null ? tc.controlPaddingX : tc.controlPaddingX
})
}
var spacingBinding = propertyIn(body, "spacing")
var spacingMatch = spacingBinding ? spacingBinding.match(/Style\.space\((\d+)\)/) : null
rows.push({
file: file,
line: i + 1,
kind: opener[2],
spacing: spacingMatch ? parseInt(spacingMatch[1], 10) : 0,
buttons: buttons
})
i = j
}
return rows
}
function measure(row) {
var total = 0
for (var i = 0; i < row.buttons.length; i++) {
if (row.buttons[i] === null) return -1
total += buttonWidth(row.buttons[i])
}
return total + row.spacing * Math.max(0, row.buttons.length - 1)
}
// ------------------------------------------------------------------ tests
readonly property var sources: [
{ file: "Panel.qml", budget: panelBudget },
{ file: "SshAgentSettings.qml", budget: panelBudget },
{ file: "SshApprovalScreen.qml", budget: popupBudget },
{ file: "SshUnlockScreen.qml", budget: popupBudget }
]
// Every budget here is a pixel count, and pixel counts only mean anything
// while the font puts every character in the same width of cell. If this
// machine resolves `monospace` to something proportional, the numbers below
// are measuring a different panel than the one that ships -- say so rather
// than report a pass or a failure that was never about the layout.
function test_the_font_is_monospaced() {
var narrow = labelWidth("iiiiiiiiii", 11)
var wide = labelWidth("MMMMMMMMMM", 11)
verify(narrow > 0 && Math.abs(narrow - wide) < 0.01,
"`monospace` resolved to a proportional font here (i=" + narrow
+ ", M=" + wide + "), so these width budgets do not describe the panel")
}
function test_the_sources_are_readable() {
// Without QML_XHR_ALLOW_FILE_READ every parse silently finds nothing, and
// a test that measures nothing passes. Fail loudly instead.
for (var i = 0; i < sources.length; i++) {
var body = readFile(sources[i].file)
verify(body.length > 0,
sources[i].file + " read back empty -- set QML_XHR_ALLOW_FILE_READ=1")
}
}
function test_every_row_of_buttons_fits_its_panel() {
var offenders = []
var measured = 0
for (var i = 0; i < sources.length; i++) {
var rows = rowsIn(readFile(sources[i].file), sources[i].file)
for (var j = 0; j < rows.length; j++) {
var row = rows[j]
if (row.buttons.length < 2) continue
var total = measure(row)
if (total < 0) continue
measured++
// Flow wraps and RowLayout shrinks; neither can push a button off the
// panel, so neither is held to the single-line budget.
if (row.kind !== "Row") continue
if (total > sources[i].budget) {
offenders.push(row.file + ":" + row.line + " (" + row.buttons.length
+ " buttons) needs " + total.toFixed(1)
+ "px, panel gives " + sources[i].budget + "px")
}
}
}
verify(measured >= 12, "only measured " + measured + " rows -- the parser stopped seeing them")
// Every button is counted, including ones a `visible:` binding makes
// mutually exclusive -- the parser cannot evaluate those, and a row whose
// contents depend on runtime state is exactly the row that should wrap
// rather than be trusted to a hand-checked worst case. The remedy either
// way is one word: Flow.
verify(offenders.length === 0,
"these Rows lay a button out past the panel edge; make them a Flow, or "
+ "shorten the labels:\n " + offenders.join("\n "))
}
// The header that started this. Pinned and unpinned are measured separately
// because only the pinned label overflowed, which is why it survived review.
function test_the_detail_header_fits_with_the_suggestion_button_showing() {
var header = { spacing: 8, buttons: [
{ label: "Back (Esc)", icon: "\u{f040d}", fontSize: 11, paddingX: 10 },
{ label: "Suggested here", icon: "\u{f043e}", fontSize: 11, paddingX: 10 },
{ label: "Edit", icon: "\u{f03eb}", fontSize: 11, paddingX: 10 },
{ label: "Delete", icon: "\u{f01b4}", fontSize: 11, paddingX: 10 }
] }
var pinned = measure(header)
header.buttons[1].label = "Suggest here"
header.buttons[1].icon = "\u{f043d}"
var unpinned = measure(header)
verify(pinned <= panelBudget,
"pinned header needs " + pinned.toFixed(1) + "px of " + panelBudget)
verify(unpinned <= panelBudget,
"unpinned header needs " + unpinned.toFixed(1) + "px of " + panelBudget)
}
// The filter row names each filter as well as showing its value, because
// three chips reading "All" say nothing about which is which. That costs
// width, and the row is allowed to wrap to pay for it -- so what has to hold
// is not that every combination fits one line, but these two things.
function filterChip(name, value, glyph) {
// Model.clipLabel(value, 20), restated.
var clipped = value.length <= 20 ? value : value.slice(0, 17) + "..."
return { label: name + ": " + clipped, icon: glyph, fontSize: 10, paddingX: 10 }
}
// One: the state the panel actually opens in stays on a single line. If this
// fails the row wraps by default, which is a worse row than a shorter label.
function test_the_unfiltered_filter_row_is_one_line() {
var row = { spacing: 6, buttons: [
filterChip("Folders", "All", "\u{f024b}"),
filterChip("Organizations", "All", "\u{f0991}"),
filterChip("Types", "All", "\u{f003b}")
] }
var total = measure(row)
verify(total <= panelBudget,
"the default filter row needs " + total.toFixed(1) + "px of " + panelBudget
+ " -- it would open already wrapped")
}
// Two: no single chip can be wider than the panel, whatever is in the vault.
// A Flow can move a button to the next line but never make one narrower, so
// this is the one thing wrapping cannot rescue -- it is what the clip is for.
function test_no_filter_chip_can_outgrow_the_panel_on_its_own() {
var monstrous = "Acme Corporation Holdings International Limited"
var names = [["Folders", "\u{f024b}"], ["Organizations", "\u{f0991}"], ["Types", "\u{f003b}"]]
for (var i = 0; i < names.length; i++) {
var chip = filterChip(names[i][0], monstrous, names[i][1])
var width = buttonWidth(chip)
verify(width <= panelBudget,
names[i][0] + " chip reaches " + width.toFixed(1) + "px of " + panelBudget
+ " with a long vault name -- the clip is not holding")
}
}
// --------------------------------------------------- the wrapping is real
//
// Everything above is arithmetic. This part instantiates the two containers
// the fix relies on and checks they behave, because both do something a Row
// does not: the header wraps, and the filter row shrink-wraps so it can stay
// centred while it fits and take the whole panel when it cannot.
//
// The chips stand in for qs.Ui.Button -- which will not load here -- using
// the same implicitWidth this file already restates.
Component {
id: chip
Item {
// Named, because inside a Component `parent` is the Flow it is created
// in, not this Item -- reaching the label through `parent` silently
// measures an empty string and nothing ever wraps.
id: chipRoot
property string label: ""
property int px: 11
implicitWidth: chipMetrics.advanceWidth + tc.controlGap + tc.iconWidth("\u{f01b4}")
+ tc.controlPaddingX * 2 + tc.reservedBorder
width: implicitWidth
height: 26
TextMetrics {
id: chipMetrics
font.family: "monospace"
font.pixelSize: chipRoot.px
text: chipRoot.label
}
}
}
Item {
id: headerHost
width: tc.panelBudget
height: 100
Flow {
id: headerFlow
width: parent.width
spacing: 8
}
}
Item {
id: filterHost
width: tc.panelBudget
height: 100
Flow {
id: filterFlow
anchors.horizontalCenter: parent.horizontalCenter
spacing: 6
// The binding under test, copied from Panel.qml: it reads the chips'
// implicitWidth and never their width, so it cannot feed itself.
readonly property real naturalWidth: {
var total = 0
for (var i = 0; i < children.length; i++) total += children[i].implicitWidth
return total + spacing * Math.max(0, children.length - 1)
}
width: Math.min(parent.width, naturalWidth)
}
}
function fill(flow, labels, px) {
for (var i = flow.children.length - 1; i >= 0; i--) flow.children[i].destroy()
wait(20) // destroy() is deferred; the children are still there until it runs
for (var j = 0; j < labels.length; j++) {
chip.createObject(flow, { label: labels[j], px: px })
}
wait(20)
compare(flow.children.length, labels.length, "the stub chips did not all get created")
for (var k = 0; k < flow.children.length; k++) {
verify(flow.children[k].implicitWidth > 0,
"a stub chip measured as zero-width -- it is not measuring its label")
}
}
function overhang(flow, host) {
var worst = 0
for (var i = 0; i < flow.children.length; i++) {
var child = flow.children[i]
var edge = child.mapToItem(host, child.width, 0).x
if (edge - host.width > worst) worst = edge - host.width
}
return worst
}
function test_the_header_wraps_instead_of_pushing_a_button_off_the_panel() {
fill(headerFlow, ["Back (Esc)", "Suggested here", "Edit", "Delete"], 11)
var oneLine = headerFlow.height
compare(overhang(headerFlow, headerHost), 0, "a button hangs past the panel at full width")
// The narrow panel a small screen actually produces.
headerHost.width = 300
wait(20)
compare(overhang(headerFlow, headerHost), 0, "a button hangs past a 300px panel")
verify(headerFlow.height > oneLine, "the header should have taken a second line")
headerHost.width = tc.panelBudget
wait(20)
compare(headerFlow.height, oneLine, "and should return to one line")
}
function test_the_filter_row_stays_centred_while_it_fits_and_wraps_when_it_does_not() {
fill(filterFlow, ["Unfiled", "Personal", "Favorites"], 10)
verify(filterFlow.width < filterHost.width,
"the row should shrink-wrap so it can be centred, got " + filterFlow.width)
var left = filterFlow.x
var right = filterHost.width - (filterFlow.x + filterFlow.width)
verify(Math.abs(left - right) < 1.5, "not centred: left " + left + ", right " + right)
filterHost.width = 200
wait(20)
compare(filterFlow.width, 200, "the row should take the whole panel once it must wrap")
compare(overhang(filterFlow, filterHost), 0, "a filter chip hangs past the panel")
filterHost.width = tc.panelBudget
wait(20)
verify(filterFlow.width < tc.panelBudget, "the row should shrink-wrap and re-centre")
}
// The restated geometry above is only right while the kit's is unchanged.
function test_button_geometry_is_still_the_kits() {
var xhr = new XMLHttpRequest()
xhr.open("GET", "file:///usr/share/omarchy/shell/Ui/Button.qml", false)
xhr.send()
var source = xhr.responseText || ""
if (source.length === 0) return // kit not installed here; nothing to check
verify(source.indexOf(
"implicitWidth: row.implicitWidth + horizontalPadding * 2 "
+ "+ _reservedBorderLeft + _reservedBorderRight") >= 0,
"Ui/Button.qml no longer sizes itself the way this test assumes")
verify(/spacing:\s*Style\.spacing\.controlGap/.test(source),
"Ui/Button.qml no longer gaps its icon and label by controlGap")
}
}