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
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// Escape must cancel out of a form, and the panel's key wiring makes that
|
||||
// non-obvious enough to be worth pinning down. Two separate traps live here.
|
||||
//
|
||||
// 1. PanelKeyCatcher goes `blocked` on every screen built around a text field
|
||||
// -- the item form, PIN, fingerprint, the Send composer -- and a blocked
|
||||
// catcher drops ALL keys, Escape included. So Escape is dispatched from the
|
||||
// shortcut interceptor, which the catcher reaches through Keys.forwardTo
|
||||
// before its own handler and regardless of `blocked`.
|
||||
//
|
||||
// 2. Qt does NOT clear active focus when an item is hidden. The search field
|
||||
// keeps focus behind the item form, and its own Keys.onEscapePressed used
|
||||
// to fire from back there and close the whole panel. Two things stop that:
|
||||
// the handler ignores Escape unless the search box is the current screen,
|
||||
// and focus is re-homed whenever the screen changes.
|
||||
//
|
||||
// Needs Qt, which any machine running the plugin already has:
|
||||
//
|
||||
// QT_QPA_PLATFORM=offscreen qmltestrunner -input tests/qml
|
||||
//
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtTest
|
||||
// Namespaced so the kit's own TextField (which needs the shell's import path)
|
||||
// does not shadow the plain QtQuick.Controls one used below.
|
||||
import "file:/usr/share/omarchy/shell/Ui" as OmarchyUi
|
||||
|
||||
TestCase {
|
||||
id: tc
|
||||
name: "EscapeRouting"
|
||||
when: windowShown
|
||||
width: 300; height: 200
|
||||
visible: true
|
||||
|
||||
property string screenName: "main"
|
||||
property int interceptorEscapes: 0
|
||||
property int catcherCloses: 0
|
||||
property int panelCloses: 0
|
||||
property string trail: ""
|
||||
|
||||
// Stands in for Panel.qml's handleEscape().
|
||||
function handleEscape() {
|
||||
tc.interceptorEscapes++
|
||||
tc.trail += "dispatch(" + tc.screenName + ") "
|
||||
if (tc.screenName === "edit") tc.screenName = "main"
|
||||
else tc.panelCloses++
|
||||
}
|
||||
|
||||
onScreenNameChanged: restoreScreenFocus()
|
||||
|
||||
function restoreScreenFocus() {
|
||||
if (tc.screenName === "main") searchField.forceActiveFocus()
|
||||
else if (tc.screenName === "edit") formField.forceActiveFocus()
|
||||
}
|
||||
|
||||
// Stands in for Panel.qml's shortcutInterceptor.
|
||||
Item {
|
||||
id: interceptor
|
||||
Keys.onPressed: function(event) {
|
||||
if (event.key === Qt.Key_Escape && !(event.modifiers & ~Qt.KeypadModifier)) {
|
||||
tc.handleEscape()
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OmarchyUi.PanelKeyCatcher {
|
||||
id: catcher
|
||||
anchors.fill: parent
|
||||
Keys.forwardTo: [interceptor]
|
||||
blocked: searchField.activeFocus || tc.screenName === "edit"
|
||||
onCloseRequested: tc.catcherCloses++
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
|
||||
Column {
|
||||
visible: tc.screenName === "main"
|
||||
TextField {
|
||||
id: searchField
|
||||
Keys.onEscapePressed: function(event) {
|
||||
tc.trail += "searchField "
|
||||
if (tc.screenName !== "main") { event.accepted = false; return }
|
||||
if (text) text = ""
|
||||
else tc.panelCloses++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: tc.screenName === "edit"
|
||||
TextField { id: formField }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
tc.interceptorEscapes = 0
|
||||
tc.catcherCloses = 0
|
||||
tc.panelCloses = 0
|
||||
tc.trail = ""
|
||||
}
|
||||
|
||||
// The bug behind "Escape does nothing on the item form": on that screen the
|
||||
// catcher is blocked and a field holds focus.
|
||||
function test_1_escape_survives_a_blocked_catcher() {
|
||||
tc.screenName = "edit"
|
||||
formField.forceActiveFocus()
|
||||
verify(formField.activeFocus, "the form field should hold focus")
|
||||
keyClick(Qt.Key_Escape)
|
||||
compare(tc.interceptorEscapes, 1, "Escape must reach the dispatch through a blocked catcher")
|
||||
compare(tc.catcherCloses, 0, "a blocked catcher never fires closeRequested -- that was the bug")
|
||||
compare(tc.panelCloses, 0, "and it must not close the panel")
|
||||
}
|
||||
|
||||
// The bug behind "Escape closes the whole panel": hiding the main screen
|
||||
// does not take focus off the search box, so it kept answering Escape.
|
||||
function test_2_hidden_search_field_does_not_answer_escape() {
|
||||
tc.screenName = "edit"
|
||||
wait(0)
|
||||
// Force the stale-owner state directly: focus the hidden search field
|
||||
// while the form is showing. Re-homing normally prevents this, so this
|
||||
// isolates the handler's own guard rather than leaning on that.
|
||||
searchField.forceActiveFocus()
|
||||
verify(searchField.activeFocus, "the hidden search field holds focus")
|
||||
verify(!searchField.visible, "and it is hidden behind the form")
|
||||
|
||||
keyClick(Qt.Key_Escape)
|
||||
compare(tc.panelCloses, 0, "a hidden search field must not close the panel: " + tc.trail)
|
||||
compare(tc.screenName, "main", "Escape should cancel the edit instead")
|
||||
}
|
||||
|
||||
// Re-homing focus on a screen change is what stops the stale owner
|
||||
// accumulating in the first place.
|
||||
function test_3_focus_follows_the_screen() {
|
||||
tc.screenName = "main"
|
||||
searchField.forceActiveFocus()
|
||||
tc.screenName = "edit"
|
||||
wait(0)
|
||||
verify(formField.activeFocus, "the form field should take focus when the form opens")
|
||||
verify(!searchField.activeFocus, "the hidden search field should not still hold it")
|
||||
}
|
||||
|
||||
// Blocking exists so letters are typed rather than read as shortcuts;
|
||||
// intercepting Escape must not cost that.
|
||||
function test_4_typing_still_reaches_the_field() {
|
||||
tc.screenName = "edit"
|
||||
formField.text = ""
|
||||
formField.forceActiveFocus()
|
||||
keyClick(Qt.Key_J)
|
||||
compare(formField.text, "j", "letters must still land in the field")
|
||||
compare(tc.interceptorEscapes, 0, "no stray Escape")
|
||||
}
|
||||
|
||||
// On unblocked screens both handlers are live; the interceptor accepting the
|
||||
// event is what keeps the dispatch from running twice.
|
||||
function test_5_escape_is_dispatched_once_when_unblocked() {
|
||||
tc.screenName = "settings"
|
||||
catcher.forceActiveFocus()
|
||||
keyClick(Qt.Key_Escape)
|
||||
compare(tc.interceptorEscapes, 1, "interceptor handles Escape")
|
||||
compare(tc.catcherCloses, 0, "catcher must not fire once the interceptor accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// A Text left on its default textFormat sniffs its own string and renders it
|
||||
// as HTML the moment it looks like markup. That is a real hazard in a panel
|
||||
// whose strings come out of a vault, and it is invisible in code review --
|
||||
// nothing in the QML says "HTML". So it is pinned here against Qt itself
|
||||
// rather than against our reading of the docs.
|
||||
//
|
||||
// Rendering is observed through contentWidth: markup that Qt parsed is markup
|
||||
// Qt did not draw, so the parsed line is narrower than the literal one.
|
||||
//
|
||||
// QT_QPA_PLATFORM=offscreen qmltestrunner -input tests/qml
|
||||
//
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import "../../BitwardenModel.js" as Model
|
||||
|
||||
TestCase {
|
||||
id: tc
|
||||
name: "RichText"
|
||||
when: windowShown
|
||||
|
||||
// A vault value crafted to be read as markup. The tags are what an attacker
|
||||
// controls; the visible text is what the user is entitled to see.
|
||||
readonly property string vaultName: "<b>Work</b> & Home"
|
||||
|
||||
// Default textFormat -- Text.AutoText -- exactly as the shared kit controls
|
||||
// render the labels we hand them.
|
||||
Text { id: sniffing; font.pixelSize: 14 }
|
||||
|
||||
// What the plugin's own Text elements now declare.
|
||||
Text { id: literal; textFormat: Text.PlainText; font.pixelSize: 14 }
|
||||
|
||||
function test_auto_text_swallows_markup_in_a_vault_value() {
|
||||
literal.text = tc.vaultName
|
||||
sniffing.text = tc.vaultName
|
||||
verify(sniffing.contentWidth > 0)
|
||||
verify(sniffing.contentWidth < literal.contentWidth - 1)
|
||||
}
|
||||
|
||||
function test_plain_text_draws_the_value_the_vault_holds() {
|
||||
literal.text = tc.vaultName
|
||||
compare(literal.textFormat, Text.PlainText)
|
||||
verify(literal.contentWidth > 0)
|
||||
}
|
||||
|
||||
function test_plainLabel_restores_the_literal_value_for_a_sniffing_control() {
|
||||
literal.text = tc.vaultName
|
||||
sniffing.text = Model.plainLabel(tc.vaultName)
|
||||
// Same glyphs, so the same width: nothing was parsed away and no entity
|
||||
// leaked through as "&".
|
||||
fuzzyCompare(sniffing.contentWidth, literal.contentWidth, 2.0)
|
||||
}
|
||||
|
||||
function test_plainLabel_leaves_an_ordinary_name_alone() {
|
||||
compare(Model.plainLabel("Work"), "Work")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import "../../BitwardenModel.js" as Model
|
||||
|
||||
// The supervision logic runs inside QML's own JavaScript engine, not Node's.
|
||||
// These cases re-prove the properties the panel depends on -- the inert
|
||||
// default, the one transition that opens the signing gate, and the bounded
|
||||
// failure paths -- against that engine, and check that the reducer never asks
|
||||
// the caller to wait for anything.
|
||||
TestCase {
|
||||
name: "SshAgent"
|
||||
|
||||
readonly property string readyLine: JSON.stringify({
|
||||
v: 1, type: "ready", socketPath: "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock",
|
||||
fifoPath: "/run/user/1000/qs-bitwarden-cli/ssh-keys.fifo", agentVersion: "0.1.0"
|
||||
})
|
||||
|
||||
function drive(state, events) {
|
||||
var actions = []
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
var step = Model.sshAgentReduce(state, events[i])
|
||||
state = step.state
|
||||
actions.push(step.action)
|
||||
}
|
||||
return { state: state, actions: actions, last: actions[actions.length - 1] }
|
||||
}
|
||||
|
||||
function ready() {
|
||||
return drive(Model.sshAgentInitialState(), [
|
||||
{ kind: "enabled", value: true, nowMs: 0 },
|
||||
{ kind: "started", nowMs: 1 },
|
||||
{ kind: "line", line: readyLine, nowMs: 2 }
|
||||
])
|
||||
}
|
||||
|
||||
function test_disabled_supervisor_starts_nothing() {
|
||||
var run = drive(Model.sshAgentInitialState(), [
|
||||
{ kind: "started", nowMs: 0 },
|
||||
{ kind: "line", line: readyLine, nowMs: 1 },
|
||||
{ kind: "restartTimer", nowMs: 2 }
|
||||
])
|
||||
compare(run.state.phase, "disabled")
|
||||
compare(run.state.gateOpen, false)
|
||||
for (var i = 0; i < run.actions.length; i++) {
|
||||
verify(!run.actions[i].start)
|
||||
verify(!run.actions[i].writeHello)
|
||||
}
|
||||
}
|
||||
|
||||
function test_handshake_opens_the_signing_gate() {
|
||||
var run = ready()
|
||||
compare(run.state.phase, "ready")
|
||||
compare(run.state.gateOpen, true)
|
||||
compare(run.state.socketPath, "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock")
|
||||
compare(run.state.agentVersion, "0.1.0")
|
||||
}
|
||||
|
||||
function test_reductions_never_ask_qml_to_wait() {
|
||||
var run = ready()
|
||||
for (var i = 0; i < run.actions.length; i++) {
|
||||
verify(run.actions[i].wait === undefined)
|
||||
verify(run.actions[i].waitMs === undefined)
|
||||
verify(typeof run.actions[i].restartInMs === "number")
|
||||
}
|
||||
}
|
||||
|
||||
function test_helper_is_launched_by_absolute_plugin_path() {
|
||||
var dir = Model.pluginDirFromUrl("file:///home/u/.config/omarchy/plugins/bw/")
|
||||
compare(dir, "/home/u/.config/omarchy/plugins/bw")
|
||||
// The source comes from the bundle inspection; the command follows it
|
||||
// rather than guessing which binary to run.
|
||||
var cmd = Model.sshAgentHelperCommand(dir, "bundled")
|
||||
compare(cmd.length, 1)
|
||||
compare(cmd[0].charAt(0), "/")
|
||||
verify(cmd[0].indexOf("/home/u/.config/omarchy/plugins/bw/") === 0)
|
||||
compare(Model.sshAgentHelperCommand(Model.pluginDirFromUrl("file:///opt/bw/../etc/"), "bundled").length, 0)
|
||||
compare(Model.sshAgentHelperCommand(dir, "").length, 0)
|
||||
}
|
||||
|
||||
function test_helper_environment_is_minimal() {
|
||||
var env = Model.sshAgentHelperEnv("/run/user/1000")
|
||||
var keys = []
|
||||
for (var k in env) keys.push(k)
|
||||
compare(keys.length, 1)
|
||||
compare(keys[0], "XDG_RUNTIME_DIR")
|
||||
compare(Model.sshAgentHelperEnv("relative/dir"), null)
|
||||
}
|
||||
|
||||
function test_bad_output_closes_the_gate() {
|
||||
var cases = [
|
||||
{ line: "not json", code: "MALFORMED" },
|
||||
{ line: '{"v":2,"type":"locked","epoch":1}', code: "VERSION_MISMATCH" },
|
||||
{ line: '{"v":1,"type":"exec"}', code: "UNKNOWN_TYPE" },
|
||||
{ line: readyLine, code: "PROTOCOL" }
|
||||
]
|
||||
for (var i = 0; i < cases.length; i++) {
|
||||
var run = drive(ready().state, [{ kind: "line", line: cases[i].line, nowMs: 100 }])
|
||||
compare(run.state.errorCode, cases[i].code)
|
||||
compare(run.state.gateOpen, false)
|
||||
compare(run.last.stop, true)
|
||||
}
|
||||
}
|
||||
|
||||
function test_overlong_output_is_rejected_by_bytes() {
|
||||
var filler = new Array(Model.sshAgentMaxLineBytes()).join("é")
|
||||
var run = drive(ready().state, [{
|
||||
kind: "line", nowMs: 100,
|
||||
line: '{"v":1,"type":"error","code":"X","message":"' + filler + '"}'
|
||||
}])
|
||||
compare(run.state.errorCode, "LINE_TOO_LONG")
|
||||
compare(run.state.gateOpen, false)
|
||||
}
|
||||
|
||||
function test_blank_output_is_ignored() {
|
||||
var run = drive(ready().state, [{ kind: "line", line: "", nowMs: 100 }])
|
||||
compare(run.state.phase, "ready")
|
||||
compare(run.state.gateOpen, true)
|
||||
}
|
||||
|
||||
function test_stalled_handshake_is_bounded() {
|
||||
var run = drive(Model.sshAgentInitialState(), [
|
||||
{ kind: "enabled", value: true, nowMs: 0 },
|
||||
{ kind: "started", nowMs: 1 },
|
||||
{ kind: "handshakeTimeout", nowMs: Model.sshAgentHandshakeTimeoutMs() }
|
||||
])
|
||||
compare(run.state.errorCode, "HANDSHAKE_TIMEOUT")
|
||||
compare(run.state.gateOpen, false)
|
||||
compare(run.last.stop, true)
|
||||
}
|
||||
|
||||
function test_eof_closes_the_gate_and_backs_off() {
|
||||
var run = drive(ready().state, [{ kind: "exited", exitCode: 0, nowMs: 100 }])
|
||||
compare(run.state.gateOpen, false)
|
||||
compare(run.state.phase, "backoff")
|
||||
compare(run.last.restartInMs, Model.sshAgentRestartDelayMs(1))
|
||||
}
|
||||
|
||||
function test_crash_loop_stops_restarting() {
|
||||
var state = Model.sshAgentReduce(Model.sshAgentInitialState(),
|
||||
{ kind: "enabled", value: true, nowMs: 0 }).state
|
||||
var scheduled = 0
|
||||
var clock = 0
|
||||
for (var i = 0; i < Model.sshAgentMaxRestarts() + 2; i++) {
|
||||
clock += 10
|
||||
state = Model.sshAgentReduce(state, { kind: "started", nowMs: clock }).state
|
||||
clock += 10
|
||||
var step = Model.sshAgentReduce(state, { kind: "exited", exitCode: 101, nowMs: clock })
|
||||
state = step.state
|
||||
if (step.action.restartInMs >= 0) scheduled++
|
||||
if (state.phase !== "backoff") break
|
||||
clock += 10
|
||||
state = Model.sshAgentReduce(state, { kind: "restartTimer", nowMs: clock }).state
|
||||
}
|
||||
compare(state.phase, "failed")
|
||||
compare(state.errorCode, "CRASH_LOOP")
|
||||
compare(state.gateOpen, false)
|
||||
compare(scheduled, Model.sshAgentMaxRestarts())
|
||||
}
|
||||
|
||||
function test_backoff_is_capped() {
|
||||
verify(Model.sshAgentRestartDelayMs(1) < Model.sshAgentRestartDelayMs(2))
|
||||
compare(Model.sshAgentRestartDelayMs(99), Model.sshAgentRestartDelayMs(100))
|
||||
}
|
||||
|
||||
function test_disabling_stops_everything() {
|
||||
var run = drive(ready().state, [{ kind: "enabled", value: false, nowMs: 100 }])
|
||||
compare(run.state.phase, "disabled")
|
||||
compare(run.state.gateOpen, false)
|
||||
compare(run.last.stop, true)
|
||||
compare(run.last.cancelRestart, true)
|
||||
}
|
||||
|
||||
function test_reenabling_clears_a_crash_loop() {
|
||||
var failed = Model.sshAgentInitialState()
|
||||
failed.phase = "failed"
|
||||
failed.errorCode = "CRASH_LOOP"
|
||||
failed.failures = Model.sshAgentMaxRestarts() + 1
|
||||
var run = drive(failed, [{ kind: "enabled", value: true, nowMs: 0 }])
|
||||
compare(run.state.phase, "starting")
|
||||
compare(run.state.errorCode, "")
|
||||
compare(run.last.start, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import "../../BitwardenModel.js" as Model
|
||||
|
||||
TestCase {
|
||||
name: "SshItems"
|
||||
|
||||
function test_sanitized_items_are_filterable_and_public() {
|
||||
var items = Model.parseSanitizedItems(JSON.stringify({
|
||||
items: [{ id: "login", type: 1, name: "Login", login: { username: "u" } }],
|
||||
sshKeys: [{ id: "ssh", type: 5, name: "Deploy", favorite: true,
|
||||
sshKey: { publicKey: "ssh-ed25519 AAAA", fingerprint: "SHA256:fp" } }]
|
||||
}))
|
||||
compare(items.length, 2)
|
||||
compare(Model.filterItems(items, "AAAA", "all", "all", "all").length, 1)
|
||||
compare(Model.filterItems(items, "", "sshKey", "all", "all")[0].id, "ssh")
|
||||
verify(Model.itemDetailFromObject(items[1].rawObject).password === "")
|
||||
}
|
||||
|
||||
function test_ssh_generic_actions_fail_closed() {
|
||||
compare(Model.buildCreatePayload(5, "Deploy"), null)
|
||||
compare(Model.getItemCommand("ssh", 5).length, 0)
|
||||
compare(Model.editItemCommand("ssh", 5).length, 0)
|
||||
compare(Model.deleteItemCommand("ssh", 5).length, 0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user