buninu-browser 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/LICENSES/jsmdcui.txt +22 -0
  2. package/README.md +19 -4
  3. package/frontends/wayland/LICENSE-land +21 -0
  4. package/frontends/wayland/README.md +123 -0
  5. package/frontends/wayland/casty/LICENSE +21 -0
  6. package/frontends/wayland/casty/bookmarks.js +32 -0
  7. package/frontends/wayland/casty/click.js +96 -0
  8. package/frontends/wayland/casty/commands.js +61 -0
  9. package/frontends/wayland/casty/config.js +45 -0
  10. package/frontends/wayland/casty/download-directory.js +28 -0
  11. package/frontends/wayland/casty/download.js +49 -0
  12. package/frontends/wayland/casty/hints.js +334 -0
  13. package/frontends/wayland/casty/keys.js +56 -0
  14. package/frontends/wayland/casty/page-commands.js +80 -0
  15. package/frontends/wayland/casty/prompt-width.js +148 -0
  16. package/frontends/wayland/casty/urlbar.js +606 -0
  17. package/frontends/wayland/client.js +88 -0
  18. package/frontends/wayland/clipboard.js +80 -0
  19. package/frontends/wayland/index.js +116 -0
  20. package/frontends/wayland/input.js +56 -0
  21. package/frontends/wayland/protocol.js +55 -0
  22. package/frontends/wayland/protocols/wayland.xml +3248 -0
  23. package/frontends/wayland/protocols/xdg-shell.xml +1415 -0
  24. package/frontends/wayland/transport.js +120 -0
  25. package/frontends/wayland/urlbar.js +158 -0
  26. package/index.js +28 -1
  27. package/lib/clipboard-command.js +10 -0
  28. package/lib/clipboard-powershell.js +22 -0
  29. package/lib/clipboard.js +37 -2
  30. package/lib/document-types.js +11 -0
  31. package/lib/headless-shell.js +47 -8
  32. package/lib/layout/text-layout.js +17 -9
  33. package/lib/paint/display-list.js +5 -1
  34. package/lib/qml/README.md +971 -0
  35. package/lib/qml/builtins.js +700 -0
  36. package/lib/qml/canvas-dom.js +115 -0
  37. package/lib/qml/document.js +1176 -0
  38. package/lib/qml/format.js +9 -0
  39. package/lib/qml/host.js +324 -0
  40. package/lib/qml/images.js +199 -0
  41. package/lib/qml/launcher.js +132 -0
  42. package/lib/qml/loader.js +517 -0
  43. package/lib/qml/media-renderer.js +49 -0
  44. package/lib/qml/media.js +86 -0
  45. package/lib/qml/models.js +622 -0
  46. package/lib/qml/parser.js +381 -0
  47. package/lib/qml/qs +5 -0
  48. package/lib/qml/qscasty +3 -0
  49. package/lib/qml/qsheadless +3 -0
  50. package/lib/qml/qswayland +3 -0
  51. package/lib/qml/reactive.js +159 -0
  52. package/lib/qml/runtime.js +3381 -0
  53. package/lib/qml/shapes.js +243 -0
  54. package/lib/qml/text.js +109 -0
  55. package/lib/qml/textures.js +123 -0
  56. package/lib/qml/wayland-clipboard.js +32 -0
  57. package/lib/qml/worker-thread.js +70 -0
  58. package/lib/render-tree/index.js +16 -1
  59. package/lib/renderer/bindings.js +11 -0
  60. package/lib/renderer/canvas-2d.js +13 -0
  61. package/lib/renderer/host.js +31 -6
  62. package/lib/renderer/main.js +19 -6
  63. package/lib/renderer/page-realm.js +16 -1
  64. package/lib/renderer/page-renderer.js +230 -13
  65. package/lib/renderer/text-input-paint.js +54 -0
  66. package/lib/renderer/text-input.js +57 -0
  67. package/lib/style/computed-style.js +2 -2
  68. package/package.json +1 -1
  69. package/test/clipboard.test.js +19 -0
  70. package/test/fixtures/flea-helpers/Dockerfile +36 -0
  71. package/test/fixtures/flea-helpers/bat.mjs +43 -0
  72. package/test/fixtures/flea-helpers/bin/bat +3 -0
  73. package/test/fixtures/flea-helpers/bin/bwrap +10 -0
  74. package/test/fixtures/flea-helpers/bin/bwrap-real-filter +9 -0
  75. package/test/fixtures/flea-helpers/bin/glow +3 -0
  76. package/test/fixtures/flea-helpers/bin/qs +3 -0
  77. package/test/fixtures/flea-helpers/bin/unsupported +3 -0
  78. package/test/fixtures/flea-helpers/check-highlighting.mjs +47 -0
  79. package/test/fixtures/flea-helpers/check-media.mjs +29 -0
  80. package/test/fixtures/flea-helpers/check-preview.mjs +27 -0
  81. package/test/fixtures/flea-helpers/check.mjs +36 -0
  82. package/test/fixtures/flea-helpers/highlight-cases.json +227 -0
  83. package/test/fixtures/flea-helpers/source-mime.xml +28 -0
  84. package/test/fixtures/flea-helpers/syntax-compat.mjs +23 -0
  85. package/test/fixtures/flea-thumbnail-stub/flea-placeholder.thumbnailer +4 -0
  86. package/test/fixtures/flea-thumbnail-stub/flea-thumbnail-stub +3 -0
  87. package/test/fixtures/qml/flea/EscapeHint.qml +11 -0
  88. package/test/fixtures/qml/flea/LICENSE +21 -0
  89. package/test/fixtures/qml/flea/README.md +25 -0
  90. package/test/fixtures/qml/flea/Theme.qml +8 -0
  91. package/test/fixtures/qml/flea/demo.qml +24 -0
  92. package/test/fixtures/qml/flea/qmldir +3 -0
  93. package/test/fixtures/qml/flea-theme/LICENSE-Flea +21 -0
  94. package/test/fixtures/qml/flea-theme/LICENSE-Omarchy +20 -0
  95. package/test/fixtures/qml/flea-theme/README.md +70 -0
  96. package/test/fixtures/qml/flea-theme/button.qml +17 -0
  97. package/test/fixtures/qml/flea-theme/demo.qml +27 -0
  98. package/test/fixtures/qml/flea-theme/field.qml +21 -0
  99. package/test/fixtures/qml/flea-theme/imports/qs/Commons/BorderGeometry.js +373 -0
  100. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Color.qml +258 -0
  101. package/test/fixtures/qml/flea-theme/imports/qs/Commons/I18n.qml +118 -0
  102. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Numbers.js +19 -0
  103. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Plural.js +91 -0
  104. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Style.qml +561 -0
  105. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Util.qml +188 -0
  106. package/test/fixtures/qml/flea-theme/imports/qs/Commons/qmldir +13 -0
  107. package/test/fixtures/qml/flea-theme/segment.qml +17 -0
  108. package/test/fixtures/qml/flea-theme/settings.qml +58 -0
  109. package/test/fixtures/qml/flea-theme/text-metrics.qml +10 -0
  110. package/test/fixtures/qml/flea-theme/ui/DialogButton.qml +150 -0
  111. package/test/fixtures/qml/flea-theme/ui/DialogField.qml +147 -0
  112. package/test/fixtures/qml/flea-theme/ui/EscapeHint.qml +11 -0
  113. package/test/fixtures/qml/flea-theme/ui/Glass.qml +114 -0
  114. package/test/fixtures/qml/flea-theme/ui/Glyph.qml +54 -0
  115. package/test/fixtures/qml/flea-theme/ui/GlyphSolid.qml +22 -0
  116. package/test/fixtures/qml/flea-theme/ui/Reveal.qml +54 -0
  117. package/test/fixtures/qml/flea-theme/ui/SettingsRuler.qml +85 -0
  118. package/test/fixtures/qml/flea-theme/ui/SettingsSegment.qml +85 -0
  119. package/test/fixtures/qml/flea-theme/ui/StableModel.qml +89 -0
  120. package/test/fixtures/qml/flea-theme/ui/Theme.qml +460 -0
  121. package/test/fixtures/qml/flea-theme/ui/ThemeColors.qml +43 -0
  122. package/test/fixtures/qml/flea-theme/ui/ViewState.qml +565 -0
  123. package/test/fixtures/qml/flea-theme/ui/js/Anchor.js +379 -0
  124. package/test/fixtures/qml/flea-theme/ui/js/AnchorHold.js +162 -0
  125. package/test/fixtures/qml/flea-theme/ui/js/Archive.js +157 -0
  126. package/test/fixtures/qml/flea-theme/ui/js/BackReturn.js +110 -0
  127. package/test/fixtures/qml/flea-theme/ui/js/Buttons.js +54 -0
  128. package/test/fixtures/qml/flea-theme/ui/js/ColumnMenu.js +111 -0
  129. package/test/fixtures/qml/flea-theme/ui/js/Columns.js +298 -0
  130. package/test/fixtures/qml/flea-theme/ui/js/Contrast.js +157 -0
  131. package/test/fixtures/qml/flea-theme/ui/js/Density.js +44 -0
  132. package/test/fixtures/qml/flea-theme/ui/js/DirSizes.js +129 -0
  133. package/test/fixtures/qml/flea-theme/ui/js/ExtThumbs.js +115 -0
  134. package/test/fixtures/qml/flea-theme/ui/js/Filter.js +284 -0
  135. package/test/fixtures/qml/flea-theme/ui/js/FolderSorts.js +108 -0
  136. package/test/fixtures/qml/flea-theme/ui/js/Format.js +305 -0
  137. package/test/fixtures/qml/flea-theme/ui/js/Icons.js +206 -0
  138. package/test/fixtures/qml/flea-theme/ui/js/Input.js +39 -0
  139. package/test/fixtures/qml/flea-theme/ui/js/KeyName.js +64 -0
  140. package/test/fixtures/qml/flea-theme/ui/js/Keymap.js +370 -0
  141. package/test/fixtures/qml/flea-theme/ui/js/Kinds.js +145 -0
  142. package/test/fixtures/qml/flea-theme/ui/js/Lists.js +11 -0
  143. package/test/fixtures/qml/flea-theme/ui/js/MakeDefault.js +186 -0
  144. package/test/fixtures/qml/flea-theme/ui/js/Match.js +27 -0
  145. package/test/fixtures/qml/flea-theme/ui/js/Material.js +109 -0
  146. package/test/fixtures/qml/flea-theme/ui/js/Menu.js +513 -0
  147. package/test/fixtures/qml/flea-theme/ui/js/Motion.js +16 -0
  148. package/test/fixtures/qml/flea-theme/ui/js/Mounts.js +320 -0
  149. package/test/fixtures/qml/flea-theme/ui/js/MouseNav.js +13 -0
  150. package/test/fixtures/qml/flea-theme/ui/js/Nav.js +328 -0
  151. package/test/fixtures/qml/flea-theme/ui/js/Palette.js +33 -0
  152. package/test/fixtures/qml/flea-theme/ui/js/Places.js +236 -0
  153. package/test/fixtures/qml/flea-theme/ui/js/Protocols.js +296 -0
  154. package/test/fixtures/qml/flea-theme/ui/js/RecentMode.js +154 -0
  155. package/test/fixtures/qml/flea-theme/ui/js/Search.js +396 -0
  156. package/test/fixtures/qml/flea-theme/ui/js/Settings.js +476 -0
  157. package/test/fixtures/qml/flea-theme/ui/js/SettingsAbout.js +99 -0
  158. package/test/fixtures/qml/flea-theme/ui/js/SettingsShelf.js +52 -0
  159. package/test/fixtures/qml/flea-theme/ui/js/Sort.js +127 -0
  160. package/test/fixtures/qml/flea-theme/ui/js/StateWords.js +42 -0
  161. package/test/fixtures/qml/flea-theme/ui/js/Swap.js +166 -0
  162. package/test/fixtures/qml/flea-theme/ui/js/TextSize.js +107 -0
  163. package/test/fixtures/qml/flea-theme/ui/js/Thumbs.js +158 -0
  164. package/test/fixtures/qml/flea-theme/ui/js/UiState.js +438 -0
  165. package/test/fixtures/qml/flea-theme/ui/js/Update.js +132 -0
  166. package/test/fixtures/qml/flea-theme/ui/js/UserDirs.js +56 -0
  167. package/test/fixtures/qml/flea-theme/ui/qmldir +227 -0
  168. package/test/fixtures/qml/hello.qml +26 -0
  169. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabel.qml +2 -0
  170. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabelNew.qml +2 -0
  171. package/test/fixtures/qml/imports/Demo/Toolkit/qmldir +3 -0
  172. package/test/fixtures/qml/imports/components/Badge.qml +15 -0
  173. package/test/fixtures/qml/imports/logic/base.js +2 -0
  174. package/test/fixtures/qml/imports/logic/counter.js +4 -0
  175. package/test/fixtures/qml/imports/logic/math.js +3 -0
  176. package/test/fixtures/qml/imports/logic/shared.js +3 -0
  177. package/test/fixtures/qml/imports/main.qml +21 -0
  178. package/test/fixtures/qml/imports/theme/Theme.qml +8 -0
  179. package/test/fixtures/qml/imports/theme/qmldir +2 -0
  180. package/test/fixtures/qml/multiply.qml +61 -0
  181. package/test/fixtures/qml-canvas-dom.js +90 -0
  182. package/test/fixtures/qml-media-audio.js +39 -0
  183. package/test/helpers/flea-sandbox.js +48 -0
  184. package/test/helpers/quickshell-fixture.js +125 -0
  185. package/test/html-clipboard-wayland.test.js +47 -0
  186. package/test/index.test.js +13 -0
  187. package/test/manual-casty-without-qml.js +87 -0
  188. package/test/manual-flea-application.js +49 -0
  189. package/test/manual-flea-binary.js +62 -0
  190. package/test/manual-flea-qml.js +9 -0
  191. package/test/manual-flea-settings.js +40 -0
  192. package/test/manual-flea-theme.js +40 -0
  193. package/test/manual-qml-removal.js +46 -0
  194. package/test/manual-without-qml.js +45 -0
  195. package/test/qml-canvas-dom.test.js +37 -0
  196. package/test/qml-clipboard-bridge.test.js +25 -0
  197. package/test/qml-clipboard-wayland.test.js +40 -0
  198. package/test/qml-connections-timing.test.js +73 -0
  199. package/test/qml-dynamic.test.js +401 -0
  200. package/test/qml-flea-app.test.js +146 -0
  201. package/test/qml-flea-backend.test.js +78 -0
  202. package/test/qml-flea-columns-backend.test.js +38 -0
  203. package/test/qml-flea-compile.test.js +78 -0
  204. package/test/qml-flea-launcher.test.js +88 -0
  205. package/test/qml-flea-markdown-backend.test.js +31 -0
  206. package/test/qml-flea-media.test.js +28 -0
  207. package/test/qml-flea-navigation-backend.test.js +35 -0
  208. package/test/qml-flea-navigation-timing.test.js +147 -0
  209. package/test/qml-flea-preview-backend.test.js +53 -0
  210. package/test/qml-flea-previews.test.js +163 -0
  211. package/test/qml-flea-rows.test.js +101 -0
  212. package/test/qml-flea-textures.test.js +59 -0
  213. package/test/qml-flea-view-switch.test.js +33 -0
  214. package/test/qml-flea-wayland.test.js +68 -0
  215. package/test/qml-flea.test.js +34 -0
  216. package/test/qml-host.test.js +238 -0
  217. package/test/qml-imports.test.js +102 -0
  218. package/test/qml-inherited-handlers.test.js +51 -0
  219. package/test/qml-input-selection.test.js +66 -0
  220. package/test/qml-interactions.test.js +309 -0
  221. package/test/qml-language.test.js +167 -0
  222. package/test/qml-launcher.test.js +54 -0
  223. package/test/qml-layout-text.test.js +205 -0
  224. package/test/qml-media.test.js +63 -0
  225. package/test/qml-persisted-delegates.test.js +45 -0
  226. package/test/qml-quickshell-controls.test.js +65 -0
  227. package/test/qml-quickshell-settings.test.js +62 -0
  228. package/test/qml-repeater.test.js +72 -0
  229. package/test/qml-style-updates.test.js +124 -0
  230. package/test/qml-textures.test.js +66 -0
  231. package/test/qml-views.test.js +332 -0
  232. package/test/qml-worker.test.js +69 -0
  233. package/test/qml.test.js +83 -0
  234. package/test/renderer-host.test.js +11 -0
  235. package/test/text-input-pointer.test.js +31 -0
  236. package/test/text-input-scroll.test.js +29 -0
  237. package/test/text-input.test.js +47 -0
  238. package/test/wayland-frontend.test.js +83 -0
  239. package/test/wayland-urlbar.test.js +38 -0
@@ -0,0 +1,373 @@
1
+ .pragma library
2
+
3
+ function clamp(value, min, max) {
4
+ var n = Number(value)
5
+ if (!isFinite(n)) return min
6
+ return Math.max(min, Math.min(max, n))
7
+ }
8
+
9
+ function clampAlpha(value) {
10
+ return clamp(value, 0, 1)
11
+ }
12
+
13
+ function padHex(value) {
14
+ var n = clamp(Math.round(Number(value)), 0, 255)
15
+ var h = n.toString(16)
16
+ return h.length < 2 ? "0" + h : h
17
+ }
18
+
19
+ function qmlHexColor(rgb, alphaByte) {
20
+ var rgbPart = String(rgb || "").replace(/^#/, "")
21
+ var a = clamp(Math.round(Number(alphaByte)), 0, 255)
22
+
23
+ if (typeof Qt !== "undefined" && Qt.rgba && rgbPart.length >= 6) {
24
+ return Qt.rgba(
25
+ parseInt(rgbPart.substring(0, 2), 16) / 255,
26
+ parseInt(rgbPart.substring(2, 4), 16) / 255,
27
+ parseInt(rgbPart.substring(4, 6), 16) / 255,
28
+ a / 255
29
+ )
30
+ }
31
+
32
+ var aHex = padHex(a)
33
+ return aHex.toLowerCase() === "ff" ? "#" + rgbPart : "#" + rgbPart + aHex
34
+ }
35
+
36
+ function canonicalColor(value, alpha) {
37
+ var a = alpha === undefined || alpha === null ? 1 : clampAlpha(alpha)
38
+ var s = String(value || "").replace(/^\s+|\s+$/g, "")
39
+ var m
40
+
41
+ m = s.match(/^#([0-9A-Fa-f]{3})$/)
42
+ if (m) {
43
+ var sh = m[1]
44
+ return qmlHexColor(
45
+ sh.charAt(0) + sh.charAt(0)
46
+ + sh.charAt(1) + sh.charAt(1)
47
+ + sh.charAt(2) + sh.charAt(2),
48
+ a * 255
49
+ )
50
+ }
51
+
52
+ m = s.match(/^#([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?$/)
53
+ if (m) {
54
+ var colorAlpha = m[2] ? parseInt(m[2], 16) / 255 : 1
55
+ return qmlHexColor(m[1], colorAlpha * a * 255)
56
+ }
57
+
58
+ m = s.match(/^[Rr][Gg][Bb]\(([0-9A-Fa-f]{6})\)$/)
59
+ if (m) return qmlHexColor(m[1], a * 255)
60
+
61
+ m = s.match(/^[Rr][Gg][Bb][Aa]\(([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})\)$/)
62
+ if (m) return qmlHexColor(m[1], (parseInt(m[2], 16) / 255) * a * 255)
63
+
64
+ m = s.match(/^[Rr][Gg][Bb]\(([0-9]+),([0-9]+),([0-9]+)\)$/)
65
+ if (m) return qmlHexColor(padHex(m[1]) + padHex(m[2]) + padHex(m[3]), a * 255)
66
+
67
+ m = s.match(/^[Rr][Gg][Bb][Aa]\(([0-9]+),([0-9]+),([0-9]+),([0-9.]+)\)$/)
68
+ if (m) return qmlHexColor(padHex(m[1]) + padHex(m[2]) + padHex(m[3]), clampAlpha(m[4]) * a * 255)
69
+
70
+ m = s.match(/^0x([0-9A-Fa-f]{2})([0-9A-Fa-f]{6})$/)
71
+ if (m) return qmlHexColor(m[2], (parseInt(m[1], 16) / 255) * a * 255)
72
+
73
+ return s
74
+ }
75
+
76
+ function parseWidthSpec(value, fallback) {
77
+ var fb = Number(fallback)
78
+ if (!isFinite(fb) || fb < 0) fb = 0
79
+
80
+ if (value === undefined || value === null || value === "") {
81
+ return { top: fb, right: fb, bottom: fb, left: fb }
82
+ }
83
+
84
+ var parts = String(value).match(/-?\d+(?:\.\d+)?/g) || []
85
+ var nums = []
86
+ for (var i = 0; i < parts.length && i < 4; i++) {
87
+ var n = Number(parts[i])
88
+ nums.push(isFinite(n) && n > 0 ? n : 0)
89
+ }
90
+
91
+ if (nums.length === 0) nums = [fb]
92
+ if (nums.length === 1) return { top: nums[0], right: nums[0], bottom: nums[0], left: nums[0] }
93
+ if (nums.length === 2) return { top: nums[0], right: nums[1], bottom: nums[0], left: nums[1] }
94
+ if (nums.length === 3) return { top: nums[0], right: nums[1], bottom: nums[2], left: nums[1] }
95
+ return { top: nums[0], right: nums[1], bottom: nums[2], left: nums[3] }
96
+ }
97
+
98
+ function withSideOverrides(widths, top, right, bottom, left) {
99
+ var out = {
100
+ top: Number(widths && widths.top) || 0,
101
+ right: Number(widths && widths.right) || 0,
102
+ bottom: Number(widths && widths.bottom) || 0,
103
+ left: Number(widths && widths.left) || 0,
104
+ }
105
+ if (top !== undefined && top !== null && top !== "") out.top = Math.max(0, Number(top) || 0)
106
+ if (right !== undefined && right !== null && right !== "") out.right = Math.max(0, Number(right) || 0)
107
+ if (bottom !== undefined && bottom !== null && bottom !== "") out.bottom = Math.max(0, Number(bottom) || 0)
108
+ if (left !== undefined && left !== null && left !== "") out.left = Math.max(0, Number(left) || 0)
109
+ return out
110
+ }
111
+
112
+ function parseGradientSpec(value, fallbackColor, alpha) {
113
+ var s = String(value || "").replace(/^\s+|\s+$/g, "")
114
+ var colors = []
115
+ var angle = 0
116
+ var parts = s.length > 0 ? s.split(/\s+/) : []
117
+
118
+ for (var i = 0; i < parts.length; i++) {
119
+ var part = parts[i]
120
+ var angleMatch = part.match(/^(-?\d+(?:\.\d+)?)deg$/)
121
+ if (angleMatch) angle = Number(angleMatch[1])
122
+ else colors.push(canonicalColor(part, alpha))
123
+ }
124
+
125
+ if (colors.length === 0 && fallbackColor !== undefined && fallbackColor !== null)
126
+ colors.push(canonicalColor(fallbackColor, alpha))
127
+
128
+ return {
129
+ colors: colors,
130
+ angle: isFinite(angle) ? angle : 0,
131
+ enabled: colors.length > 1,
132
+ }
133
+ }
134
+
135
+ function isUniform(widths) {
136
+ if (!widths) return true
137
+ return widths.top === widths.right && widths.top === widths.bottom && widths.top === widths.left
138
+ }
139
+
140
+ function maxWidth(widths) {
141
+ if (!widths) return 0
142
+ return Math.max(widths.top || 0, widths.right || 0, widths.bottom || 0, widths.left || 0)
143
+ }
144
+
145
+ function needsOverlay(spec) {
146
+ if (!spec) return false
147
+ if (maxWidth(spec.widths) <= 0) return false
148
+ return !!(spec.gradient && spec.gradient.enabled) || !isUniform(spec.widths)
149
+ }
150
+
151
+ function canUseNative(spec) {
152
+ return !!spec && maxWidth(spec.widths) > 0 && !needsOverlay(spec)
153
+ }
154
+
155
+ function normalizeRadii(w, h, r) {
156
+ var tl = { rx: Math.max(0, Number(r.tlrx) || 0), ry: Math.max(0, Number(r.tlry) || 0) }
157
+ var tr = { rx: Math.max(0, Number(r.trrx) || 0), ry: Math.max(0, Number(r.trry) || 0) }
158
+ var br = { rx: Math.max(0, Number(r.brrx) || 0), ry: Math.max(0, Number(r.brry) || 0) }
159
+ var bl = { rx: Math.max(0, Number(r.blrx) || 0), ry: Math.max(0, Number(r.blry) || 0) }
160
+ var scale = 1
161
+
162
+ if (tl.rx + tr.rx > w && tl.rx + tr.rx > 0) scale = Math.min(scale, w / (tl.rx + tr.rx))
163
+ if (bl.rx + br.rx > w && bl.rx + br.rx > 0) scale = Math.min(scale, w / (bl.rx + br.rx))
164
+ if (tl.ry + bl.ry > h && tl.ry + bl.ry > 0) scale = Math.min(scale, h / (tl.ry + bl.ry))
165
+ if (tr.ry + br.ry > h && tr.ry + br.ry > 0) scale = Math.min(scale, h / (tr.ry + br.ry))
166
+
167
+ if (scale < 1) {
168
+ tl.rx *= scale; tr.rx *= scale; br.rx *= scale; bl.rx *= scale
169
+ tl.ry *= scale; tr.ry *= scale; br.ry *= scale; bl.ry *= scale
170
+ }
171
+
172
+ return { tl: tl, tr: tr, br: br, bl: bl }
173
+ }
174
+
175
+ function appendArc(path, rx, ry, sweep, point) {
176
+ if (rx > 0 && ry > 0) path.push("A", rx, ry, 0, 0, sweep, point.x, point.y)
177
+ else path.push("L", point.x, point.y)
178
+ }
179
+
180
+ function roundedRectPath(x, y, w, h, radii) {
181
+ if (w <= 0 || h <= 0) return ""
182
+ var r = normalizeRadii(w, h, radii)
183
+ var right = x + w
184
+ var bottom = y + h
185
+ var p = []
186
+
187
+ p.push("M", x + r.tl.rx, y)
188
+ p.push("H", right - r.tr.rx)
189
+ appendArc(p, r.tr.rx, r.tr.ry, 1, { x: right, y: y + r.tr.ry })
190
+ p.push("V", bottom - r.br.ry)
191
+ appendArc(p, r.br.rx, r.br.ry, 1, { x: right - r.br.rx, y: bottom })
192
+ p.push("H", x + r.bl.rx)
193
+ appendArc(p, r.bl.rx, r.bl.ry, 1, { x: x, y: bottom - r.bl.ry })
194
+ p.push("V", y + r.tl.ry)
195
+ appendArc(p, r.tl.rx, r.tl.ry, 1, { x: x + r.tl.rx, y: y })
196
+ p.push("Z")
197
+
198
+ return p.join(" ")
199
+ }
200
+
201
+ function borderBoundary(x, y, w, h, radii) {
202
+ var r = radii && radii.tl ? radii : normalizeRadii(w, h, radii)
203
+ var right = x + w
204
+ var bottom = y + h
205
+ return {
206
+ start: [
207
+ { x: x + r.tl.rx, y: y },
208
+ { x: right, y: y + r.tr.ry },
209
+ { x: right - r.br.rx, y: bottom },
210
+ { x: x, y: bottom - r.bl.ry },
211
+ ],
212
+ end: [
213
+ { x: right - r.tr.rx, y: y },
214
+ { x: right, y: bottom - r.br.ry },
215
+ { x: x + r.bl.rx, y: bottom },
216
+ { x: x, y: y + r.tl.ry },
217
+ ],
218
+ corner: [r.tr, r.br, r.bl, r.tl],
219
+ }
220
+ }
221
+
222
+ function appendForwardCorner(path, boundary, side) {
223
+ var corner = boundary.corner[side]
224
+ appendArc(path, corner.rx, corner.ry, 1, boundary.start[(side + 1) % 4])
225
+ }
226
+
227
+ function appendReverseCorner(path, boundary, side) {
228
+ var corner = boundary.corner[side]
229
+ appendArc(path, corner.rx, corner.ry, 0, boundary.end[side])
230
+ }
231
+
232
+ function reverseBoundaryPath(boundary) {
233
+ var p = ["M", boundary.start[0].x, boundary.start[0].y]
234
+ for (var side = 3; side >= 0; side--) {
235
+ appendReverseCorner(p, boundary, side)
236
+ p.push("L", boundary.start[side].x, boundary.start[side].y)
237
+ }
238
+ p.push("Z")
239
+ return p.join(" ")
240
+ }
241
+
242
+ function runPath(outer, inner, start, length) {
243
+ var previous = (start + 3) % 4
244
+ var next = (start + length) % 4
245
+ var p = ["M", outer.end[previous].x, outer.end[previous].y]
246
+
247
+ appendForwardCorner(p, outer, previous)
248
+ for (var offset = 0; offset < length; offset++) {
249
+ var side = (start + offset) % 4
250
+ p.push("L", outer.end[side].x, outer.end[side].y)
251
+ appendForwardCorner(p, outer, side)
252
+ }
253
+
254
+ p.push("L", inner.start[next].x, inner.start[next].y)
255
+ for (var reverseOffset = length - 1; reverseOffset >= 0; reverseOffset--) {
256
+ var reverseSide = (start + reverseOffset) % 4
257
+ appendReverseCorner(p, inner, reverseSide)
258
+ p.push("L", inner.start[reverseSide].x, inner.start[reverseSide].y)
259
+ }
260
+ appendReverseCorner(p, inner, previous)
261
+ p.push("Z")
262
+ return p.join(" ")
263
+ }
264
+
265
+ function radiiFit(w, h, r) {
266
+ return r.tlrx + r.trrx <= w
267
+ && r.blrx + r.brrx <= w
268
+ && r.tlry + r.blry <= h
269
+ && r.trry + r.brry <= h
270
+ }
271
+
272
+ // Internal geometry output used by ringPath and focused topology tests.
273
+ // Connected enabled-side runs share one closed contour; opposite-only sides
274
+ // need two. The all-sides case is one compound winding path with a reversed
275
+ // inner loop. Disabled sides never require touching or epsilon-offset inner
276
+ // geometry, so a zero/zero rounded corner emits no border pixels.
277
+ function borderPaths(w, h, radius, widths) {
278
+ w = Math.max(0, Number(w) || 0)
279
+ h = Math.max(0, Number(h) || 0)
280
+ radius = Math.max(0, Number(radius) || 0)
281
+ widths = widths || { top: 0, right: 0, bottom: 0, left: 0 }
282
+ if (w <= 0 || h <= 0) return []
283
+
284
+ var top = Math.max(0, Number(widths.top) || 0)
285
+ var right = Math.max(0, Number(widths.right) || 0)
286
+ var bottom = Math.max(0, Number(widths.bottom) || 0)
287
+ var left = Math.max(0, Number(widths.left) || 0)
288
+ var enabled = [top > 0, right > 0, bottom > 0, left > 0]
289
+ if (!enabled[0] && !enabled[1] && !enabled[2] && !enabled[3]) return []
290
+
291
+ var outerRadii = normalizeRadii(w, h, {
292
+ tlrx: radius, tlry: radius,
293
+ trrx: radius, trry: radius,
294
+ brrx: radius, brry: radius,
295
+ blrx: radius, blry: radius,
296
+ })
297
+ var outerPath = roundedRectPath(0, 0, w, h, {
298
+ tlrx: outerRadii.tl.rx, tlry: outerRadii.tl.ry,
299
+ trrx: outerRadii.tr.rx, trry: outerRadii.tr.ry,
300
+ brrx: outerRadii.br.rx, brry: outerRadii.br.ry,
301
+ blrx: outerRadii.bl.rx, blry: outerRadii.bl.ry,
302
+ })
303
+
304
+ var iw = w - left - right
305
+ var ih = h - top - bottom
306
+ if (iw <= 0 || ih <= 0) return [outerPath]
307
+
308
+ var desiredInnerRadii = {
309
+ tlrx: Math.max(0, outerRadii.tl.rx - left),
310
+ tlry: Math.max(0, outerRadii.tl.ry - top),
311
+ trrx: Math.max(0, outerRadii.tr.rx - right),
312
+ trry: Math.max(0, outerRadii.tr.ry - top),
313
+ brrx: Math.max(0, outerRadii.br.rx - right),
314
+ brry: Math.max(0, outerRadii.br.ry - bottom),
315
+ blrx: Math.max(0, outerRadii.bl.rx - left),
316
+ blry: Math.max(0, outerRadii.bl.ry - bottom),
317
+ }
318
+
319
+ // Normalizing an inner radius that cannot fit can move its tangent beyond
320
+ // the outer rounded boundary. Winding fill may then paint outside the outer
321
+ // contour. Conservatively treat that rounded interior as consumed instead.
322
+ if (!radiiFit(iw, ih, desiredInnerRadii)) return [outerPath]
323
+
324
+ var innerRadii = normalizeRadii(iw, ih, desiredInnerRadii)
325
+ var outer = borderBoundary(0, 0, w, h, outerRadii)
326
+ var inner = borderBoundary(left, top, iw, ih, innerRadii)
327
+
328
+ if (enabled[0] && enabled[1] && enabled[2] && enabled[3])
329
+ return [outerPath + " " + reverseBoundaryPath(inner)]
330
+
331
+ var paths = []
332
+ for (var start = 0; start < 4; start++) {
333
+ if (!enabled[start] || enabled[(start + 3) % 4]) continue
334
+ var length = 1
335
+ while (length < 4 && enabled[(start + length) % 4]) length++
336
+ paths.push(runPath(outer, inner, start, length))
337
+ }
338
+ return paths
339
+ }
340
+
341
+ function ringPath(w, h, radius, widths) {
342
+ return borderPaths(w, h, radius, widths).join(" ")
343
+ }
344
+
345
+ function gradientEndpoints(w, h, angle) {
346
+ w = Math.max(1, Number(w) || 1)
347
+ h = Math.max(1, Number(h) || 1)
348
+ var rad = (Number(angle) || 0) * Math.PI / 180
349
+ var dx = Math.cos(rad)
350
+ var dy = Math.sin(rad)
351
+ var len = (Math.abs(w * dx) + Math.abs(h * dy)) / 2
352
+ var cx = w / 2
353
+ var cy = h / 2
354
+ return {
355
+ x1: cx - dx * len,
356
+ y1: cy - dy * len,
357
+ x2: cx + dx * len,
358
+ y2: cy + dy * len,
359
+ }
360
+ }
361
+
362
+ function stopColor(colors, index) {
363
+ if (!colors || colors.length === 0) return "transparent"
364
+ if (index < colors.length) return colors[index]
365
+ return colors[colors.length - 1]
366
+ }
367
+
368
+ function stopPosition(colors, index) {
369
+ var count = colors ? colors.length : 0
370
+ if (count <= 1) return index === 0 ? 0 : 1
371
+ if (index >= count) return 1
372
+ return index / (count - 1)
373
+ }
@@ -0,0 +1,258 @@
1
+ pragma Singleton
2
+ import QtQuick
3
+ import Quickshell
4
+ import Quickshell.Io
5
+ import "BorderGeometry.js" as Geometry
6
+
7
+ // Color surfaces for the shell. Foundational palette (foreground, background,
8
+ // accent, urgent) comes from theme/colors.toml. Per-surface roles come from
9
+ // theme/shell.toml — generated per theme from default/themed/shell.toml.tpl,
10
+ // or shipped directly by a theme to replace the generated file. Surfaces that
11
+ // don't appear in shell.toml fall back to the foundational palette.
12
+ QtObject {
13
+ id: root
14
+
15
+ readonly property string home: Quickshell.env("HOME")
16
+ readonly property string stateHome: home + "/.local/state"
17
+ readonly property string currentThemePath: stateHome + "/omarchy/current/theme"
18
+
19
+ property color foreground: "#cacccc"
20
+ property color background: "#101315"
21
+ property color accent: "#cacccc"
22
+ property color urgent: "#a55555"
23
+ property color muted: "#707880"
24
+
25
+ // Flat dictionary of "section.key" -> raw string from shell.toml.
26
+ // Reassigning this whole property is what makes surface bindings below
27
+ // re-evaluate when the theme swaps; mutating it in place would not.
28
+ property var shellValues: ({})
29
+
30
+ function pick(key, fallback) {
31
+ var v = shellValues[key]
32
+ return (typeof v === "string" && v.length > 0) ? v : fallback
33
+ }
34
+
35
+ function pickAlpha(key, fallback) {
36
+ var v = shellValues[key]
37
+ if (typeof v !== "string" || v.length === 0) return fallback
38
+ var n = Number(v)
39
+ if (!isFinite(n)) return fallback
40
+ return Util.clampAlpha(n)
41
+ }
42
+
43
+ function firstColorToken(value) {
44
+ var parts = String(value || "").replace(/^\s+|\s+$/g, "").split(/\s+/)
45
+ for (var i = 0; i < parts.length; i++) {
46
+ if (!parts[i].match(/^-?\d+(?:\.\d+)?deg$/)) return parts[i]
47
+ }
48
+ return value
49
+ }
50
+
51
+ function flatColor(value, fallback) {
52
+ var seen = Object.create(null)
53
+ var token = firstColorToken(value)
54
+ var role = String(token || "").replace(/^\s+|\s+$/g, "").toLowerCase()
55
+ while (root.shellValues[role] && root.shellValues[role] !== token) {
56
+ if (seen[role]) return fallback
57
+ seen[role] = true
58
+ token = firstColorToken(root.shellValues[role])
59
+ role = String(token || "").replace(/^\s+|\s+$/g, "").toLowerCase()
60
+ }
61
+ if (role === "foreground" || role === "text") return root.foreground
62
+ if (role === "accent") return root.accent
63
+ if (role === "urgent") return root.urgent
64
+ if (role === "muted") return root.muted
65
+ if (role === "background") return root.background
66
+ if (role === "transparent") return Qt.rgba(0, 0, 0, 0)
67
+
68
+ var color = Geometry.canonicalColor(token, 1)
69
+ if (typeof color === "string" && color === token && token.charAt(0) !== "#") return fallback
70
+ return color
71
+ }
72
+
73
+ // Compose a color from a base-color key and its `-alpha` companion. If the
74
+ // base token is a gradient, color-only consumers use the first stop.
75
+ function composed(colorKey, alphaKey, colorFallback, alphaFallback) {
76
+ return Util.alpha(flatColor(pick(colorKey, colorFallback), colorFallback), pickAlpha(alphaKey, alphaFallback))
77
+ }
78
+
79
+ readonly property QtObject bar: QtObject {
80
+ property color background: root.composed("bar.background", "bar.background-alpha", root.background, 1.0)
81
+ property color text: root.pick("bar.text", root.foreground)
82
+ property color active: root.pick("bar.active", root.urgent)
83
+ }
84
+ readonly property QtObject popups: QtObject {
85
+ property color background: root.composed("popups.background", "popups.background-alpha", root.background, 1.0)
86
+ property color text: root.pick("popups.text", root.foreground)
87
+ property color border: root.composed("popups.border", "popups.border-alpha", root.accent, 1.0)
88
+ }
89
+ readonly property QtObject tooltip: QtObject {
90
+ property color background: root.composed("tooltip.background", "tooltip.background-alpha", root.background, 1.0)
91
+ property color text: root.pick("tooltip.text", root.foreground)
92
+ property color border: root.composed("tooltip.border", "tooltip.border-alpha", root.foreground, 1.0)
93
+ }
94
+ readonly property QtObject notifications: QtObject {
95
+ property color background: root.composed("notifications.background", "notifications.background-alpha", root.background, 1.0)
96
+ property color text: root.pick("notifications.text", root.foreground)
97
+ property color border: root.composed("notifications.border", "notifications.border-alpha", root.accent, 1.0)
98
+ property color countdown: root.pick("notifications.countdown", root.accent)
99
+ }
100
+ readonly property QtObject menu: QtObject {
101
+ property color background: root.composed("menu.background", "menu.background-alpha", root.background, 1.0)
102
+ property color text: root.pick("menu.text", root.foreground)
103
+ property color border: root.composed("menu.border", "menu.border-alpha", root.foreground, 1.0)
104
+ property color scrim: root.composed("menu.scrim", "menu.scrim-alpha", root.background, 0.5)
105
+ property color selectedBackground: root.composed("menu.selected-background", "menu.selected-background-alpha", root.foreground, 0.08)
106
+ property color selectedText: root.pick("menu.selected-text", root.accent)
107
+ property color selectedBorder: root.composed("menu.selected-border", "menu.selected-border-alpha", root.foreground, 0.0)
108
+ }
109
+ // polkit + lock share a single border-alpha across border / border-active /
110
+ // border-error: the three states are mutually exclusive in time, so one
111
+ // companion is enough.
112
+ readonly property QtObject polkit: QtObject {
113
+ property color background: root.composed("polkit.background", "polkit.background-alpha", root.background, 1.0)
114
+ property color text: root.pick("polkit.text", root.foreground)
115
+ property color textError: root.pick("polkit.text-error", root.urgent)
116
+ property color border: root.composed("polkit.border", "polkit.border-alpha", root.accent, 1.0)
117
+ property color borderError: root.composed("polkit.border-error", "polkit.border-alpha", root.urgent, 1.0)
118
+ property color accent: root.pick("polkit.accent", root.accent)
119
+ property color scrim: root.composed("polkit.scrim", "polkit.scrim-alpha", root.background, 0.5)
120
+ }
121
+ readonly property QtObject lock: QtObject {
122
+ property color background: root.composed("lock.background", "lock.background-alpha", root.background, 0.8)
123
+ property color text: root.pick("lock.text", root.foreground)
124
+ property color placeholder: root.shellValues["lock.placeholder"] ? root.flatColor(root.shellValues["lock.placeholder"], Util.alpha(root.foreground, 0.66)) : Util.alpha(root.foreground, 0.66)
125
+ property color textError: root.pick("lock.text-error", root.urgent)
126
+ property color border: root.composed("lock.border", "lock.border-alpha", root.foreground, 1.0)
127
+ property color borderActive: root.composed("lock.border-active", "lock.border-alpha", root.accent, 1.0)
128
+ property color borderError: root.composed("lock.border-error", "lock.border-alpha", root.urgent, 1.0)
129
+ property color selection: root.composed("lock.selection", "lock.selection-alpha", root.accent, 0.45)
130
+ }
131
+ // The image picker has no card surface or backdrop; per-slice dim
132
+ // overlays / text outlines use the foundational `background` color directly.
133
+ readonly property QtObject imagePicker: QtObject {
134
+ property color text: root.pick("image-picker.text", root.foreground)
135
+ property color selectedBorder: root.composed("image-picker.selected-border", "image-picker.selected-border-alpha", root.accent, 1.0)
136
+ property color unselectedBorder: root.composed("image-picker.unselected-border", "image-picker.unselected-border-alpha", root.foreground, 0.28)
137
+ }
138
+
139
+ function loadColors(raw) {
140
+ var lines = String(raw || "").split("\n")
141
+ var foundAccent = false
142
+ var foundMuted = false
143
+ var loadedForeground = false
144
+ var loadedBackground = false
145
+ var color0Value = ""
146
+ var color4Value = ""
147
+ var color7Value = ""
148
+ var color8Value = ""
149
+ for (var i = 0; i < lines.length; i++) {
150
+ var match = lines[i].match(/^\s*([A-Za-z0-9_-]+)\s*=\s*["']?(#[0-9A-Fa-f]{6})/)
151
+ if (!match) continue
152
+ if (match[1] === "foreground") { foreground = match[2]; loadedForeground = true }
153
+ else if (match[1] === "background") { background = match[2]; loadedBackground = true }
154
+ // Prefer the explicit `accent` key; only fall back to color4 when the
155
+ // theme doesn't define a separate accent. color4 appears later in the
156
+ // file so the old single-property approach clobbered accent with it.
157
+ else if (match[1] === "accent") { accent = match[2]; foundAccent = true }
158
+ else if (match[1] === "muted") { muted = match[2]; foundMuted = true }
159
+ else if (match[1] === "color0") color0Value = match[2]
160
+ else if (match[1] === "color4") color4Value = match[2]
161
+ else if (match[1] === "color7") color7Value = match[2]
162
+ else if (match[1] === "color8") color8Value = match[2]
163
+ else if (match[1] === "red" || match[1] === "color1") urgent = match[2]
164
+ }
165
+ if (!loadedBackground && color0Value.length > 0) background = color0Value
166
+ if (!loadedForeground && color7Value.length > 0) foreground = color7Value
167
+ if (!foundAccent && color4Value.length > 0) accent = color4Value
168
+ if (!foundMuted) muted = color8Value.length > 0 ? color8Value : foreground
169
+ }
170
+
171
+ // Last theme-supplied and user-supplied shell.toml dicts, kept separate so
172
+ // either can be reloaded without re-reading the other. `shellValues` is
173
+ // always the merge of theme (base) and user (override) — see mergeShell.
174
+ property var themeShellValues: ({})
175
+ property var userShellValues: ({})
176
+
177
+ // Single TOML walker for shell.toml. Both Color (surface roles) and Style
178
+ // (typography, spacing, bar, control states) consume the resulting dict.
179
+ // Accepts quoted strings, bare numeric values, bare width lists, and bare
180
+ // role names; tolerates inline comments. Numbers are kept as strings here —
181
+ // readers coerce when they pull a value.
182
+ function parseShell(raw) {
183
+ var parsed = {}
184
+ var text = String(raw || "")
185
+ if (text) {
186
+ var lines = text.split("\n")
187
+ var section = ""
188
+ for (var i = 0; i < lines.length; i++) {
189
+ var line = lines[i].replace(/^\s+|\s+$/g, "")
190
+ if (!line || line.charAt(0) === "#") continue
191
+ var sectionMatch = line.match(/^\[([A-Za-z0-9_-]+)\]\s*(#.*)?$/)
192
+ if (sectionMatch) { section = sectionMatch[1]; continue }
193
+ var stringKv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*["']([^"']+)["']\s*(#.*)?$/)
194
+ var numKv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(-?\d+(?:\.\d+)?)\s*(#.*)?$/)
195
+ var widthKv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(-?\d+(?:\.\d+)?(?:\s+-?\d+(?:\.\d+)?){1,3})\s*(#.*)?$/)
196
+ var bareKv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*([A-Za-z][A-Za-z0-9_-]*)\s*(#.*)?$/)
197
+ var kv = stringKv || numKv || widthKv || bareKv
198
+ if (!kv || !section) continue
199
+ parsed[section + "." + kv[1]] = kv[2]
200
+ }
201
+ }
202
+ return parsed
203
+ }
204
+
205
+ // Re-derive `shellValues` from theme base + user override and push it to
206
+ // Style. User keys win, so a machine-level `~/.config/omarchy/shell.toml`
207
+ // survives theme switches (which replace only themeShellValues).
208
+ function mergeShell() {
209
+ var merged = {}
210
+ for (var tk in themeShellValues) merged[tk] = themeShellValues[tk]
211
+ for (var uk in userShellValues) merged[uk] = userShellValues[uk]
212
+ shellValues = merged
213
+ Style.applyShellValues(merged)
214
+ }
215
+
216
+ function loadShell(raw) {
217
+ themeShellValues = parseShell(raw)
218
+ mergeShell()
219
+ }
220
+
221
+ function loadUserShell(raw) {
222
+ userShellValues = parseShell(raw)
223
+ mergeShell()
224
+ }
225
+
226
+ // Startup load only. Runtime theme switches push the payload explicitly
227
+ // through shell IPC.
228
+ property FileView colorsFile: FileView {
229
+ id: colorsFile
230
+ path: root.currentThemePath + "/colors.toml"
231
+ watchChanges: false
232
+ printErrors: false
233
+ onLoaded: root.loadColors(text())
234
+ }
235
+ property FileView shellFile: FileView {
236
+ id: shellFile
237
+ path: root.currentThemePath + "/shell.toml"
238
+ watchChanges: false
239
+ printErrors: false
240
+ onLoaded: root.loadShell(text())
241
+ onLoadFailed: root.loadShell("")
242
+ }
243
+ // Machine-level override, layered on top of whatever theme is active. This
244
+ // is where `omarchy display text size` writes `[font] base-size`. Watched so the
245
+ // CLI takes effect live without restarting the shell; absent by default.
246
+ property FileView userShellFile: FileView {
247
+ id: userShellFile
248
+ path: root.home + "/.config/omarchy/shell.toml"
249
+ watchChanges: true
250
+ printErrors: false
251
+ onLoaded: root.loadUserShell(text())
252
+ // Re-read on change (including first creation) before loading — `text()`
253
+ // is stale in the change signal itself, so route both paths through reload
254
+ // → onLoaded to always parse fresh content.
255
+ onFileChanged: reload()
256
+ onLoadFailed: root.loadUserShell("")
257
+ }
258
+ }