@vestra-player/sdk 1.5.42 → 1.5.44
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.
- package/CHANGELOG.md +20 -0
- package/dist/api-requests.js +1 -0
- package/dist/host-capabilities.js +2 -0
- package/dist/player-runtime.d.ts +1 -0
- package/dist/player-runtime.js +1 -0
- package/dist/plugin/patch.d.ts +101 -0
- package/dist/plugin/patch.js +337 -0
- package/dist/plugin/runtime.d.ts +6 -0
- package/dist/plugin/runtime.js +11 -0
- package/dist/plugin/services.js +24 -2
- package/dist/plugin/settings.js +2 -2
- package/dist/plugin/testing.d.ts +65 -1
- package/dist/plugin/testing.js +238 -15
- package/dist/plugin/types.d.ts +192 -1
- package/dist/plugin/ui.d.ts +2 -2
- package/dist/plugin/ui.js +217 -17
- package/dist/wire.d.ts +73 -7
- package/dist/wire.js +3 -3
- package/llms-full.txt +305 -6
- package/llms.txt +23 -23
- package/package.json +1 -1
- package/slint/vestra/markdown.slint +17 -11
- package/slint/vestra/widgets.slint +770 -1
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
// Compact controls built on the Vestra theme (core-ui-design.md §2.1):
|
|
2
2
|
// import { Button, Slider, ToolTipArea, UrlInputRow } from "@vestra/widgets.slint";
|
|
3
|
+
// import { Choice, Check, Radio, RadioGroup, RadioMark, Tab, Tabs } from "@vestra/widgets.slint";
|
|
3
4
|
// Icons: @vestra/icons.slint. Other controls come from std-widgets, which the
|
|
4
5
|
// host compiles as fluent-dark and switches to light for a light palette.
|
|
5
|
-
|
|
6
|
+
//
|
|
7
|
+
// Keyboard: the controls that take input (Slider, Choice, Check, Radio,
|
|
8
|
+
// RadioGroup, Tabs) take the focus by Tab or by `focus()`, never by a click,
|
|
9
|
+
// so a click on a panel control leaves the player's shortcuts (Space, the
|
|
10
|
+
// arrows) with the video, as on macOS. A focused control shows a 1px accent
|
|
11
|
+
// ring.
|
|
12
|
+
import { TextEdit, ListView } from "std-widgets.slint";
|
|
6
13
|
import { Theme } from "theme.slint";
|
|
14
|
+
import { Icon } from "icons.slint";
|
|
7
15
|
|
|
8
16
|
// Hover for about half a second to show `text` beside the pointer. The tip
|
|
9
17
|
// closes when the pointer leaves, or presses on content that does not take
|
|
@@ -120,12 +128,24 @@ export component Button {
|
|
|
120
128
|
// 2px line slider. Bar style (default): 8px round thumb, a white core in a
|
|
121
129
|
// 2px accent ring, gradient fill. Panel style (`panel: true`): 14×12 thumb,
|
|
122
130
|
// gradient fill. The track is `Theme.line` in both.
|
|
131
|
+
// Keyboard (focused by Tab or `focus()`; a click does not take the focus, so
|
|
132
|
+
// the bar's volume slider leaves the arrows to seeking), as std-widgets'
|
|
133
|
+
// Slider: Left/Down and Right/Up move by `step`, Page Down/Up by 10 steps,
|
|
134
|
+
// Home/End to the ends; `changed` follows every move and `released` the
|
|
135
|
+
// key's release. Screen readers can increment, decrement or set the value.
|
|
123
136
|
export component Slider {
|
|
124
137
|
in-out property <float> value;
|
|
125
138
|
in property <float> minimum: 0;
|
|
126
139
|
in property <float> maximum: 1;
|
|
140
|
+
// One key press: a twentieth of the range unless set.
|
|
141
|
+
in property <float> step: (root.maximum - root.minimum) / 20;
|
|
127
142
|
in property <bool> enabled: true;
|
|
128
143
|
in property <bool> panel;
|
|
144
|
+
// false: Tab skips the slider (the bottom bar's seek and volume sliders,
|
|
145
|
+
// so Tab never moves the arrows from seeking to the slider); `focus()`
|
|
146
|
+
// still works.
|
|
147
|
+
in property <bool> focusable: true;
|
|
148
|
+
out property <bool> has-focus: keys.has-focus;
|
|
129
149
|
callback changed(float);
|
|
130
150
|
callback released(float);
|
|
131
151
|
private property <length> thumb-width: root.panel ? 14px : 8px;
|
|
@@ -134,10 +154,26 @@ export component Slider {
|
|
|
134
154
|
private property <float> ratio: clamp((root.value - root.minimum) / root.span, 0, 1);
|
|
135
155
|
min-height: max(10px, self.thumb-height);
|
|
136
156
|
min-width: 20px;
|
|
157
|
+
forward-focus: keys;
|
|
137
158
|
accessible-role: slider;
|
|
159
|
+
accessible-enabled: root.enabled;
|
|
138
160
|
accessible-value: root.value;
|
|
139
161
|
accessible-value-minimum: root.minimum;
|
|
140
162
|
accessible-value-maximum: root.maximum;
|
|
163
|
+
accessible-value-step: root.step;
|
|
164
|
+
accessible-action-increment => { root.set-value(root.value + root.step); }
|
|
165
|
+
accessible-action-decrement => { root.set-value(root.value - root.step); }
|
|
166
|
+
accessible-action-set-value(text) => {
|
|
167
|
+
if text.is-float() { root.set-value(text.to-float()); }
|
|
168
|
+
}
|
|
169
|
+
// Clamped to the range; `changed` only when the value moves.
|
|
170
|
+
public function set-value(v: float) {
|
|
171
|
+
if !root.enabled { return; }
|
|
172
|
+
let clamped = clamp(v, root.minimum, root.maximum);
|
|
173
|
+
if clamped == root.value { return; }
|
|
174
|
+
root.value = clamped;
|
|
175
|
+
root.changed(root.value);
|
|
176
|
+
}
|
|
141
177
|
function set-from(x: length) {
|
|
142
178
|
root.value = root.minimum + clamp((x - root.thumb-width / 2) / max(1px, root.width - root.thumb-width), 0, 1) * root.span;
|
|
143
179
|
root.changed(root.value);
|
|
@@ -165,6 +201,17 @@ export component Slider {
|
|
|
165
201
|
background: !root.enabled ? Theme.icon-disabled : root.panel ? Theme.text : #ffffff;
|
|
166
202
|
border-width: root.panel || !root.enabled ? 0px : 2px;
|
|
167
203
|
border-color: touch.pressed ? Theme.accent-press : Theme.accent;
|
|
204
|
+
// Focus ring, 2px outside the thumb.
|
|
205
|
+
Rectangle {
|
|
206
|
+
visible: keys.has-focus;
|
|
207
|
+
x: -2px;
|
|
208
|
+
y: -2px;
|
|
209
|
+
width: parent.width + 4px;
|
|
210
|
+
height: parent.height + 4px;
|
|
211
|
+
border-radius: parent.border-radius + 2px;
|
|
212
|
+
border-width: 1px;
|
|
213
|
+
border-color: Theme.accent;
|
|
214
|
+
}
|
|
168
215
|
}
|
|
169
216
|
touch := TouchArea {
|
|
170
217
|
enabled: root.enabled;
|
|
@@ -174,6 +221,51 @@ export component Slider {
|
|
|
174
221
|
}
|
|
175
222
|
moved => { if self.pressed { root.set-from(self.mouse-x); } }
|
|
176
223
|
}
|
|
224
|
+
keys := FocusScope {
|
|
225
|
+
x: 0;
|
|
226
|
+
y: 0;
|
|
227
|
+
width: 0;
|
|
228
|
+
height: 0;
|
|
229
|
+
enabled: root.enabled;
|
|
230
|
+
focus-on-click: false;
|
|
231
|
+
focus-on-tab-navigation: root.focusable;
|
|
232
|
+
key-pressed(event) => {
|
|
233
|
+
if event.text == Key.RightArrow || event.text == Key.UpArrow {
|
|
234
|
+
root.set-value(root.value + root.step);
|
|
235
|
+
return accept;
|
|
236
|
+
}
|
|
237
|
+
if event.text == Key.LeftArrow || event.text == Key.DownArrow {
|
|
238
|
+
root.set-value(root.value - root.step);
|
|
239
|
+
return accept;
|
|
240
|
+
}
|
|
241
|
+
if event.text == Key.PageUp {
|
|
242
|
+
root.set-value(root.value + 10 * root.step);
|
|
243
|
+
return accept;
|
|
244
|
+
}
|
|
245
|
+
if event.text == Key.PageDown {
|
|
246
|
+
root.set-value(root.value - 10 * root.step);
|
|
247
|
+
return accept;
|
|
248
|
+
}
|
|
249
|
+
if event.text == Key.Home {
|
|
250
|
+
root.set-value(root.minimum);
|
|
251
|
+
return accept;
|
|
252
|
+
}
|
|
253
|
+
if event.text == Key.End {
|
|
254
|
+
root.set-value(root.maximum);
|
|
255
|
+
return accept;
|
|
256
|
+
}
|
|
257
|
+
reject
|
|
258
|
+
}
|
|
259
|
+
key-released(event) => {
|
|
260
|
+
if event.text == Key.RightArrow || event.text == Key.UpArrow || event.text == Key.LeftArrow
|
|
261
|
+
|| event.text == Key.DownArrow || event.text == Key.PageUp || event.text == Key.PageDown
|
|
262
|
+
|| event.text == Key.Home || event.text == Key.End {
|
|
263
|
+
root.released(root.value);
|
|
264
|
+
return accept;
|
|
265
|
+
}
|
|
266
|
+
reject
|
|
267
|
+
}
|
|
268
|
+
}
|
|
177
269
|
}
|
|
178
270
|
|
|
179
271
|
// Link input (UI-DLG-02): a multi-line input and [Open] [Cancel]. Enter or the
|
|
@@ -227,3 +319,680 @@ export component UrlInputRow {
|
|
|
227
319
|
}
|
|
228
320
|
}
|
|
229
321
|
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Form controls: one compact set for every plugin window and settings page
|
|
325
|
+
// (std-widgets' fluent ComboBox is 160px wide at least and its CheckBox cannot
|
|
326
|
+
// wrap its label). 11px text, 18px check rows, 21px drop-downs, 31px tabs.
|
|
327
|
+
|
|
328
|
+
// The 1px accent focus ring; placed 2px outside the box, mark or field
|
|
329
|
+
// (x/y -2px, 4px wider and taller) where it is used.
|
|
330
|
+
component FocusRing inherits Rectangle {
|
|
331
|
+
border-width: 1px;
|
|
332
|
+
border-color: Theme.accent;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check box with a label that wraps onto more lines when the row is narrower
|
|
336
|
+
// than the label (the box stays beside the first line). A click or Space
|
|
337
|
+
// flips `checked`, then `toggled` reports the new state, as std-widgets'
|
|
338
|
+
// CheckBox: bind it two-way (`checked <=> Bridge.on`), or set it again from
|
|
339
|
+
// the plugin's data in a `changed` handler, since the click replaces a
|
|
340
|
+
// one-way binding.
|
|
341
|
+
// Plugin-decided (`self-toggle: false`, like Radio): a click or Space does
|
|
342
|
+
// not flip the box; `toggled` reports the state asked for (`!checked`) and
|
|
343
|
+
// the plugin sets `checked` when it agrees, for example after a
|
|
344
|
+
// confirmation, so a one-way binding (`checked: Bridge.on`) stays. Both
|
|
345
|
+
// modes also call `clicked`.
|
|
346
|
+
// Default style (ui-spec §2.1: checks on accent, the mark white): a 13px box,
|
|
347
|
+
// accent when checked. Panel style (`panel: true`, the picture and audio
|
|
348
|
+
// workbenches' switches): a 12px box on `bg-bar` with a 6px `playing` square.
|
|
349
|
+
export component Check {
|
|
350
|
+
in property <string> text;
|
|
351
|
+
in-out property <bool> checked;
|
|
352
|
+
in property <bool> enabled: true;
|
|
353
|
+
in property <bool> panel;
|
|
354
|
+
// false: the plugin owns `checked` (see above).
|
|
355
|
+
in property <bool> self-toggle: true;
|
|
356
|
+
out property <bool> has-focus: keys.has-focus;
|
|
357
|
+
// The state after the click (self-toggle) or the state asked for.
|
|
358
|
+
callback toggled(bool);
|
|
359
|
+
callback clicked();
|
|
360
|
+
// What a click does; also Space and the screen reader's default action.
|
|
361
|
+
public function toggle() {
|
|
362
|
+
if !root.enabled { return; }
|
|
363
|
+
if root.self-toggle {
|
|
364
|
+
root.checked = !root.checked;
|
|
365
|
+
root.toggled(root.checked);
|
|
366
|
+
} else {
|
|
367
|
+
root.toggled(!root.checked);
|
|
368
|
+
}
|
|
369
|
+
root.clicked();
|
|
370
|
+
}
|
|
371
|
+
private property <length> box: root.panel ? 12px : 13px;
|
|
372
|
+
min-height: Theme.checkbox-row-height;
|
|
373
|
+
forward-focus: keys;
|
|
374
|
+
accessible-role: checkbox;
|
|
375
|
+
accessible-label: root.text;
|
|
376
|
+
accessible-enabled: root.enabled;
|
|
377
|
+
accessible-checkable: true;
|
|
378
|
+
accessible-checked: root.checked;
|
|
379
|
+
accessible-action-default => { root.toggle(); }
|
|
380
|
+
// Centred as one piece when the check is given more height than it needs.
|
|
381
|
+
VerticalLayout {
|
|
382
|
+
alignment: center;
|
|
383
|
+
HorizontalLayout {
|
|
384
|
+
spacing: root.panel ? 4px : 5px;
|
|
385
|
+
VerticalLayout {
|
|
386
|
+
alignment: start;
|
|
387
|
+
padding-top: (Theme.checkbox-row-height - root.box) / 2;
|
|
388
|
+
Rectangle {
|
|
389
|
+
width: root.box;
|
|
390
|
+
height: root.box;
|
|
391
|
+
border-radius: root.panel ? 0px : 3px;
|
|
392
|
+
border-width: !root.panel && root.checked ? 0px : 1px;
|
|
393
|
+
border-color: !root.enabled ? Theme.icon-disabled
|
|
394
|
+
: root.panel ? (touch.has-hover ? Theme.text-list : Theme.text-dim)
|
|
395
|
+
: (touch.has-hover ? Theme.accent : Theme.text-dim);
|
|
396
|
+
background: root.panel ? Theme.bg-bar
|
|
397
|
+
: !root.checked ? Theme.bg-list
|
|
398
|
+
: root.enabled ? Theme.accent : Theme.accent-disabled;
|
|
399
|
+
Rectangle {
|
|
400
|
+
visible: root.panel && root.checked;
|
|
401
|
+
width: 6px;
|
|
402
|
+
height: 6px;
|
|
403
|
+
background: root.enabled ? Theme.playing : Theme.icon-disabled;
|
|
404
|
+
}
|
|
405
|
+
Icon {
|
|
406
|
+
visible: !root.panel && root.checked;
|
|
407
|
+
name: "check-box";
|
|
408
|
+
color: Colors.white;
|
|
409
|
+
}
|
|
410
|
+
FocusRing {
|
|
411
|
+
visible: keys.has-focus;
|
|
412
|
+
x: -2px;
|
|
413
|
+
y: -2px;
|
|
414
|
+
width: parent.width + 4px;
|
|
415
|
+
height: parent.height + 4px;
|
|
416
|
+
border-radius: root.panel ? 2px : 5px;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
Text {
|
|
421
|
+
text: root.text;
|
|
422
|
+
color: !root.enabled ? Theme.text-dim : root.panel ? Theme.text : Theme.text-list;
|
|
423
|
+
font-size: Theme.font-body;
|
|
424
|
+
vertical-alignment: center;
|
|
425
|
+
wrap: word-wrap;
|
|
426
|
+
min-height: Theme.checkbox-row-height;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
touch := TouchArea {
|
|
431
|
+
enabled: root.enabled;
|
|
432
|
+
clicked => { root.toggle(); }
|
|
433
|
+
}
|
|
434
|
+
keys := FocusScope {
|
|
435
|
+
x: 0;
|
|
436
|
+
y: 0;
|
|
437
|
+
width: 0;
|
|
438
|
+
height: 0;
|
|
439
|
+
enabled: root.enabled;
|
|
440
|
+
focus-on-click: false;
|
|
441
|
+
key-pressed(event) => {
|
|
442
|
+
if event.text == Key.Space {
|
|
443
|
+
root.toggle();
|
|
444
|
+
return accept;
|
|
445
|
+
}
|
|
446
|
+
reject
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The 12px radio dot alone (ui-spec §6.3 ctx-radio: a 1.5px accent ring and a
|
|
452
|
+
// 6px accent dot when checked), for rows a plugin lays out itself, such as
|
|
453
|
+
// the palette list with its previews. `hover` lights the ring; `focused`
|
|
454
|
+
// draws the focus ring.
|
|
455
|
+
export component RadioMark {
|
|
456
|
+
in property <bool> checked;
|
|
457
|
+
in property <bool> hover;
|
|
458
|
+
in property <bool> enabled: true;
|
|
459
|
+
in property <bool> focused;
|
|
460
|
+
width: 12px;
|
|
461
|
+
height: 12px;
|
|
462
|
+
Rectangle {
|
|
463
|
+
border-radius: self.height / 2;
|
|
464
|
+
border-width: root.checked ? 1.5px : 1px;
|
|
465
|
+
border-color: !root.enabled ? Theme.icon-disabled
|
|
466
|
+
: root.checked || root.hover ? Theme.accent : Theme.text-dim;
|
|
467
|
+
background: Theme.bg-list;
|
|
468
|
+
Rectangle {
|
|
469
|
+
visible: root.checked;
|
|
470
|
+
width: 6px;
|
|
471
|
+
height: 6px;
|
|
472
|
+
border-radius: 3px;
|
|
473
|
+
background: root.enabled ? Theme.accent : Theme.icon-disabled;
|
|
474
|
+
}
|
|
475
|
+
FocusRing {
|
|
476
|
+
visible: root.focused;
|
|
477
|
+
x: -2px;
|
|
478
|
+
y: -2px;
|
|
479
|
+
width: parent.width + 4px;
|
|
480
|
+
height: parent.height + 4px;
|
|
481
|
+
border-radius: self.height / 2;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// One radio choice: the mark, 4px, an 11px label. The plugin owns the state:
|
|
487
|
+
// `checked` is what it shows, a click or Space asks with `clicked`. For a
|
|
488
|
+
// set of choices from one list use RadioGroup, which also takes the arrow
|
|
489
|
+
// keys. (`focusable` and `focused` are RadioGroup's: its radios are one Tab
|
|
490
|
+
// stop, the group's.)
|
|
491
|
+
export component Radio {
|
|
492
|
+
in property <string> text;
|
|
493
|
+
in property <bool> checked;
|
|
494
|
+
in property <bool> enabled: true;
|
|
495
|
+
in property <bool> focusable: true;
|
|
496
|
+
in property <bool> focused;
|
|
497
|
+
out property <bool> has-focus: keys.has-focus;
|
|
498
|
+
callback clicked();
|
|
499
|
+
min-height: Theme.checkbox-row-height;
|
|
500
|
+
forward-focus: keys;
|
|
501
|
+
accessible-role: radio-button;
|
|
502
|
+
accessible-label: root.text;
|
|
503
|
+
accessible-enabled: root.enabled;
|
|
504
|
+
accessible-checkable: true;
|
|
505
|
+
accessible-checked: root.checked;
|
|
506
|
+
accessible-action-default => { if root.enabled { root.clicked(); } }
|
|
507
|
+
HorizontalLayout {
|
|
508
|
+
spacing: 4px;
|
|
509
|
+
VerticalLayout {
|
|
510
|
+
alignment: center;
|
|
511
|
+
RadioMark {
|
|
512
|
+
checked: root.checked;
|
|
513
|
+
hover: touch.has-hover;
|
|
514
|
+
enabled: root.enabled;
|
|
515
|
+
focused: root.focused || keys.has-focus;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
Text {
|
|
519
|
+
text: root.text;
|
|
520
|
+
color: !root.enabled ? Theme.text-dim : root.checked ? Theme.text : Theme.text-list;
|
|
521
|
+
font-size: Theme.font-body;
|
|
522
|
+
vertical-alignment: center;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
touch := TouchArea {
|
|
526
|
+
enabled: root.enabled;
|
|
527
|
+
clicked => { root.clicked(); }
|
|
528
|
+
}
|
|
529
|
+
keys := FocusScope {
|
|
530
|
+
x: 0;
|
|
531
|
+
y: 0;
|
|
532
|
+
width: 0;
|
|
533
|
+
height: 0;
|
|
534
|
+
enabled: root.enabled && root.focusable;
|
|
535
|
+
focus-on-click: false;
|
|
536
|
+
key-pressed(event) => {
|
|
537
|
+
if event.text == Key.Space {
|
|
538
|
+
if root.enabled { root.clicked(); }
|
|
539
|
+
return accept;
|
|
540
|
+
}
|
|
541
|
+
reject
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Radio choices from a list of labels, in a row (12px apart) or a column
|
|
547
|
+
// (`vertical`). One Tab stop; a click, or the arrow keys while the group has
|
|
548
|
+
// the focus (they wrap around), pick one: `current-index` changes and
|
|
549
|
+
// `selected` reports it, as std-widgets' RadioGroup.
|
|
550
|
+
export component RadioGroup {
|
|
551
|
+
in property <[string]> model;
|
|
552
|
+
in-out property <int> current-index: -1;
|
|
553
|
+
in property <bool> vertical;
|
|
554
|
+
in property <bool> enabled: true;
|
|
555
|
+
in property <length> spacing: root.vertical ? 0px : 12px;
|
|
556
|
+
out property <bool> has-focus: keys.has-focus;
|
|
557
|
+
callback selected(int);
|
|
558
|
+
public function select(index: int) {
|
|
559
|
+
if !root.enabled || index < 0 || index >= root.model.length || index == root.current-index { return; }
|
|
560
|
+
root.current-index = index;
|
|
561
|
+
root.selected(index);
|
|
562
|
+
}
|
|
563
|
+
// The radio the arrows move from and the focus ring is on (the first
|
|
564
|
+
// while none is picked).
|
|
565
|
+
private property <int> anchor: root.current-index >= 0 && root.current-index < root.model.length ? root.current-index : 0;
|
|
566
|
+
forward-focus: keys;
|
|
567
|
+
accessible-role: radio-group;
|
|
568
|
+
accessible-enabled: root.enabled;
|
|
569
|
+
accessible-item-count: root.model.length;
|
|
570
|
+
accessible-delegate-focus: root.anchor;
|
|
571
|
+
VerticalLayout {
|
|
572
|
+
if root.vertical: VerticalLayout {
|
|
573
|
+
spacing: root.spacing;
|
|
574
|
+
for text[index] in root.model: Radio {
|
|
575
|
+
text: text;
|
|
576
|
+
checked: index == root.current-index;
|
|
577
|
+
enabled: root.enabled;
|
|
578
|
+
focusable: false;
|
|
579
|
+
focused: keys.has-focus && index == root.anchor;
|
|
580
|
+
accessible-item-index: index;
|
|
581
|
+
clicked => { root.select(index); }
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if !root.vertical: HorizontalLayout {
|
|
585
|
+
spacing: root.spacing;
|
|
586
|
+
alignment: start;
|
|
587
|
+
for text[index] in root.model: Radio {
|
|
588
|
+
text: text;
|
|
589
|
+
checked: index == root.current-index;
|
|
590
|
+
enabled: root.enabled;
|
|
591
|
+
focusable: false;
|
|
592
|
+
focused: keys.has-focus && index == root.anchor;
|
|
593
|
+
accessible-item-index: index;
|
|
594
|
+
clicked => { root.select(index); }
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
keys := FocusScope {
|
|
599
|
+
x: 0;
|
|
600
|
+
y: 0;
|
|
601
|
+
width: 0;
|
|
602
|
+
height: 0;
|
|
603
|
+
enabled: root.enabled && root.model.length > 0;
|
|
604
|
+
focus-on-click: false;
|
|
605
|
+
key-pressed(event) => {
|
|
606
|
+
if event.text == Key.LeftArrow || event.text == Key.UpArrow {
|
|
607
|
+
root.select(root.anchor <= 0 ? root.model.length - 1 : root.anchor - 1);
|
|
608
|
+
return accept;
|
|
609
|
+
}
|
|
610
|
+
if event.text == Key.RightArrow || event.text == Key.DownArrow {
|
|
611
|
+
root.select(root.anchor >= root.model.length - 1 ? 0 : root.anchor + 1);
|
|
612
|
+
return accept;
|
|
613
|
+
}
|
|
614
|
+
if event.text == Key.Space {
|
|
615
|
+
root.select(root.anchor);
|
|
616
|
+
return accept;
|
|
617
|
+
}
|
|
618
|
+
reject
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// One page tab (ui-spec §5.2, the playlist and sidebar strip): at least 74px,
|
|
624
|
+
// 12px label, dimmed unless `selected`; the selected tab has a 28px gradient
|
|
625
|
+
// stroke under its label, the others a rounded plate on hover. `emphasis`
|
|
626
|
+
// draws an unselected label in the accent colour (something waits there).
|
|
627
|
+
// Tabs lays them out; use Tab alone only for a strip with other content.
|
|
628
|
+
export component Tab {
|
|
629
|
+
in property <string> text;
|
|
630
|
+
in property <bool> selected;
|
|
631
|
+
in property <bool> emphasis;
|
|
632
|
+
in property <bool> focused;
|
|
633
|
+
callback clicked();
|
|
634
|
+
min-width: max(74px, label.preferred-width + 12px);
|
|
635
|
+
preferred-width: self.min-width;
|
|
636
|
+
horizontal-stretch: 0;
|
|
637
|
+
min-height: 22px;
|
|
638
|
+
preferred-height: 31px;
|
|
639
|
+
accessible-role: tab;
|
|
640
|
+
accessible-label: root.text;
|
|
641
|
+
accessible-item-selectable: true;
|
|
642
|
+
accessible-item-selected: root.selected;
|
|
643
|
+
accessible-action-default => { root.clicked(); }
|
|
644
|
+
// Persistent and hidden by `visible` (an `if` that flips with the
|
|
645
|
+
// pointer rebuilds the accessibility tree; menu-view.slint, 2026-10-27).
|
|
646
|
+
Rectangle {
|
|
647
|
+
x: 3px;
|
|
648
|
+
y: 4px;
|
|
649
|
+
width: parent.width - 6px;
|
|
650
|
+
height: parent.height - 8px;
|
|
651
|
+
border-radius: Theme.radius-control;
|
|
652
|
+
background: !root.selected && touch.has-hover ? Theme.hover-bg : transparent;
|
|
653
|
+
FocusRing {
|
|
654
|
+
visible: root.focused;
|
|
655
|
+
x: 0;
|
|
656
|
+
y: 0;
|
|
657
|
+
width: parent.width;
|
|
658
|
+
height: parent.height;
|
|
659
|
+
border-radius: Theme.radius-control;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
Rectangle {
|
|
663
|
+
visible: root.selected;
|
|
664
|
+
x: (parent.width - 28px) / 2;
|
|
665
|
+
y: parent.height - 3px;
|
|
666
|
+
width: 28px;
|
|
667
|
+
height: 2px;
|
|
668
|
+
border-radius: 1px;
|
|
669
|
+
background: Theme.accent-fill;
|
|
670
|
+
}
|
|
671
|
+
label := Text {
|
|
672
|
+
x: 6px;
|
|
673
|
+
width: parent.width - 12px;
|
|
674
|
+
text: root.text;
|
|
675
|
+
color: root.selected ? Theme.text : root.emphasis ? Theme.accent : Theme.text-dim;
|
|
676
|
+
font-size: Theme.font-tab;
|
|
677
|
+
horizontal-alignment: center;
|
|
678
|
+
vertical-alignment: center;
|
|
679
|
+
overflow: elide;
|
|
680
|
+
}
|
|
681
|
+
touch := TouchArea { clicked => { root.clicked(); } }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// A 31px tab strip on `bg-bar` (the picture, audio and properties windows):
|
|
685
|
+
// one Tab per label, left-aligned. A click, or Left/Right/Home/End while the
|
|
686
|
+
// strip has the focus, picks a tab: `current-index` changes and `selected`
|
|
687
|
+
// reports it. The pages are the plugin's (show the one for current-index).
|
|
688
|
+
// Its min-width is the tabs' width, for a window that must fit them.
|
|
689
|
+
export component Tabs inherits Rectangle {
|
|
690
|
+
in property <[string]> model;
|
|
691
|
+
in-out property <int> current-index: 0;
|
|
692
|
+
// Per tab (same order as `model`, may be shorter): Tab's `emphasis`,
|
|
693
|
+
// the accent label of a tab where something waits ("Updates (2)").
|
|
694
|
+
in property <[bool]> emphasis;
|
|
695
|
+
out property <bool> has-focus: keys.has-focus;
|
|
696
|
+
callback selected(int);
|
|
697
|
+
public function select(index: int) {
|
|
698
|
+
if index < 0 || index >= root.model.length || index == root.current-index { return; }
|
|
699
|
+
root.current-index = index;
|
|
700
|
+
root.selected(index);
|
|
701
|
+
}
|
|
702
|
+
height: 31px;
|
|
703
|
+
background: Theme.bg-bar;
|
|
704
|
+
forward-focus: keys;
|
|
705
|
+
accessible-role: tab-list;
|
|
706
|
+
accessible-item-count: root.model.length;
|
|
707
|
+
accessible-delegate-focus: root.current-index;
|
|
708
|
+
HorizontalLayout {
|
|
709
|
+
for text[index] in root.model: Tab {
|
|
710
|
+
text: text;
|
|
711
|
+
selected: index == root.current-index;
|
|
712
|
+
emphasis: index < root.emphasis.length && root.emphasis[index];
|
|
713
|
+
focused: keys.has-focus && index == root.current-index;
|
|
714
|
+
accessible-item-index: index;
|
|
715
|
+
clicked => { root.select(index); }
|
|
716
|
+
}
|
|
717
|
+
Rectangle { horizontal-stretch: 1; }
|
|
718
|
+
}
|
|
719
|
+
keys := FocusScope {
|
|
720
|
+
x: 0;
|
|
721
|
+
y: 0;
|
|
722
|
+
width: 0;
|
|
723
|
+
height: 0;
|
|
724
|
+
enabled: root.model.length > 0;
|
|
725
|
+
focus-on-click: false;
|
|
726
|
+
key-pressed(event) => {
|
|
727
|
+
if event.text == Key.LeftArrow {
|
|
728
|
+
root.select(max(0, root.current-index - 1));
|
|
729
|
+
return accept;
|
|
730
|
+
}
|
|
731
|
+
if event.text == Key.RightArrow {
|
|
732
|
+
root.select(min(root.model.length - 1, root.current-index + 1));
|
|
733
|
+
return accept;
|
|
734
|
+
}
|
|
735
|
+
if event.text == Key.Home {
|
|
736
|
+
root.select(0);
|
|
737
|
+
return accept;
|
|
738
|
+
}
|
|
739
|
+
if event.text == Key.End {
|
|
740
|
+
root.select(root.model.length - 1);
|
|
741
|
+
return accept;
|
|
742
|
+
}
|
|
743
|
+
reject
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// One 20px row of a Choice's list: a 6px `playing` dot on the current item,
|
|
749
|
+
// the lit row (pointer or arrow keys) on `press-bg` in the accent colour.
|
|
750
|
+
component ChoiceRow {
|
|
751
|
+
in property <string> text;
|
|
752
|
+
in property <bool> selected;
|
|
753
|
+
in property <bool> lit;
|
|
754
|
+
callback picked();
|
|
755
|
+
callback hovered();
|
|
756
|
+
height: 20px;
|
|
757
|
+
accessible-role: list-item;
|
|
758
|
+
accessible-label: root.text;
|
|
759
|
+
accessible-item-selectable: true;
|
|
760
|
+
accessible-item-selected: root.selected;
|
|
761
|
+
accessible-action-default => { root.picked(); }
|
|
762
|
+
Rectangle { background: root.lit ? Theme.press-bg : transparent; }
|
|
763
|
+
HorizontalLayout {
|
|
764
|
+
padding-left: 5px;
|
|
765
|
+
padding-right: 5px;
|
|
766
|
+
spacing: 4px;
|
|
767
|
+
VerticalLayout {
|
|
768
|
+
alignment: center;
|
|
769
|
+
Rectangle {
|
|
770
|
+
width: 6px;
|
|
771
|
+
height: 6px;
|
|
772
|
+
border-radius: 3px;
|
|
773
|
+
background: root.selected ? Theme.playing : transparent;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
Text {
|
|
777
|
+
text: root.text;
|
|
778
|
+
color: root.lit ? Theme.accent : Theme.text-list;
|
|
779
|
+
font-size: Theme.font-body;
|
|
780
|
+
vertical-alignment: center;
|
|
781
|
+
overflow: elide;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
touch := TouchArea {
|
|
785
|
+
clicked => { root.picked(); }
|
|
786
|
+
changed has-hover => { if self.has-hover { root.hovered(); } }
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Compact 21px drop-down: the current label (11px, elided) and a small V;
|
|
791
|
+
// a click opens the list below it (Slint keeps the popup inside the window,
|
|
792
|
+
// moving it up when there is no room below). The list is a ListView (rows
|
|
793
|
+
// are created only while visible, so long lists such as output devices or
|
|
794
|
+
// plugin ids stay cheap) of at most `max-visible-rows` 20px rows; it is at
|
|
795
|
+
// least `popup-min-width` wide. Slint moves the popup into the window but
|
|
796
|
+
// does not shorten it: in a window shorter than the list, set
|
|
797
|
+
// `window-height: root.height` (the window's height) and the list is cut
|
|
798
|
+
// to fit, scrolling to reach every row. A Choice in a mounted panel or
|
|
799
|
+
// settings page cannot see its window's height; keep `max-visible-rows`
|
|
800
|
+
// small there.
|
|
801
|
+
// Keyboard: closed and focused (Tab or `focus()`), Up/Down/Home/End change
|
|
802
|
+
// the choice, Space, Enter, F4 or Alt+Down open the list; open, the arrows,
|
|
803
|
+
// Page Up/Down and Home/End move the lit row, Enter or Space picks it,
|
|
804
|
+
// Escape or a click outside closes the list without a change.
|
|
805
|
+
// A pick sets `current-index` and `selected` reports it (only a change), as
|
|
806
|
+
// std-widgets' ComboBox; the pick replaces a one-way binding, so bind
|
|
807
|
+
// `current-index` two-way or set it again in a `changed` handler.
|
|
808
|
+
// The labels are what the list shows: give translated text (@tr in the
|
|
809
|
+
// .slint file, or text values `plugin.i18n.text` from the plugin).
|
|
810
|
+
export component Choice {
|
|
811
|
+
in property <[string]> model;
|
|
812
|
+
in-out property <int> current-index: 0;
|
|
813
|
+
in property <bool> enabled: true;
|
|
814
|
+
in property <int> max-visible-rows: 10;
|
|
815
|
+
in property <length> popup-min-width: 0px;
|
|
816
|
+
// The window's height, when known: the list is never taller.
|
|
817
|
+
in property <length> window-height: 0px;
|
|
818
|
+
out property <bool> has-focus: keys.has-focus;
|
|
819
|
+
out property <bool> open: popup.is-open;
|
|
820
|
+
out property <string> current-text: root.current-index >= 0 && root.current-index < root.model.length ? root.model[root.current-index] : "";
|
|
821
|
+
callback selected(int);
|
|
822
|
+
public function select(index: int) {
|
|
823
|
+
if !root.enabled || index < 0 || index >= root.model.length || index == root.current-index { return; }
|
|
824
|
+
root.current-index = index;
|
|
825
|
+
root.selected(index);
|
|
826
|
+
}
|
|
827
|
+
public function show-list() {
|
|
828
|
+
if root.enabled && root.model.length > 0 { popup.show(); }
|
|
829
|
+
}
|
|
830
|
+
public function close-list() { popup.close(); }
|
|
831
|
+
// The lit row of the open list.
|
|
832
|
+
private property <int> lit;
|
|
833
|
+
private property <int> rows: min(root.model.length, max(1, root.max-visible-rows));
|
|
834
|
+
private property <length> list-height: root.window-height > 0px
|
|
835
|
+
? min(root.rows * 20px + 2px, root.window-height) : root.rows * 20px + 2px;
|
|
836
|
+
// Whole rows the list shows (Page Up/Down).
|
|
837
|
+
private property <int> page: max(1, floor((root.list-height - 2px) / 20px));
|
|
838
|
+
height: Theme.input-height;
|
|
839
|
+
forward-focus: keys;
|
|
840
|
+
accessible-role: combobox;
|
|
841
|
+
accessible-enabled: root.enabled;
|
|
842
|
+
accessible-value: root.current-text;
|
|
843
|
+
accessible-expandable: true;
|
|
844
|
+
accessible-expanded: popup.is-open;
|
|
845
|
+
accessible-action-expand => { root.show-list(); }
|
|
846
|
+
accessible-action-default => { root.show-list(); }
|
|
847
|
+
Rectangle {
|
|
848
|
+
border-radius: Theme.radius-control;
|
|
849
|
+
background: !root.enabled ? Theme.button-disabled
|
|
850
|
+
: touch.pressed ? Theme.button-press
|
|
851
|
+
: touch.has-hover ? Theme.button-hover : Theme.button-bg;
|
|
852
|
+
FocusRing {
|
|
853
|
+
visible: keys.has-focus;
|
|
854
|
+
x: -2px;
|
|
855
|
+
y: -2px;
|
|
856
|
+
width: parent.width + 4px;
|
|
857
|
+
height: parent.height + 4px;
|
|
858
|
+
border-radius: Theme.radius-control + 2px;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
HorizontalLayout {
|
|
862
|
+
padding-left: 6px;
|
|
863
|
+
padding-right: 5px;
|
|
864
|
+
spacing: 2px;
|
|
865
|
+
Text {
|
|
866
|
+
horizontal-stretch: 1;
|
|
867
|
+
text: root.current-text;
|
|
868
|
+
color: root.enabled ? Theme.text-list : Theme.text-dim;
|
|
869
|
+
font-size: Theme.font-body;
|
|
870
|
+
vertical-alignment: center;
|
|
871
|
+
overflow: elide;
|
|
872
|
+
accessible-role: none;
|
|
873
|
+
}
|
|
874
|
+
VerticalLayout {
|
|
875
|
+
horizontal-stretch: 0;
|
|
876
|
+
alignment: center;
|
|
877
|
+
Icon { name: "caret-down"; color: root.enabled ? Theme.text-dim : Theme.icon-disabled; }
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
touch := TouchArea {
|
|
881
|
+
enabled: root.enabled;
|
|
882
|
+
clicked => { root.show-list(); }
|
|
883
|
+
}
|
|
884
|
+
keys := FocusScope {
|
|
885
|
+
x: 0;
|
|
886
|
+
y: 0;
|
|
887
|
+
width: 0;
|
|
888
|
+
height: 0;
|
|
889
|
+
enabled: root.enabled;
|
|
890
|
+
focus-on-click: false;
|
|
891
|
+
key-pressed(event) => {
|
|
892
|
+
if (event.text == Key.DownArrow && event.modifiers.alt) || event.text == Key.F4
|
|
893
|
+
|| event.text == Key.Space || event.text == Key.Return {
|
|
894
|
+
root.show-list();
|
|
895
|
+
return accept;
|
|
896
|
+
}
|
|
897
|
+
if event.text == Key.UpArrow {
|
|
898
|
+
root.select(max(0, root.current-index - 1));
|
|
899
|
+
return accept;
|
|
900
|
+
}
|
|
901
|
+
if event.text == Key.DownArrow {
|
|
902
|
+
root.select(min(root.model.length - 1, root.current-index + 1));
|
|
903
|
+
return accept;
|
|
904
|
+
}
|
|
905
|
+
if event.text == Key.Home {
|
|
906
|
+
root.select(0);
|
|
907
|
+
return accept;
|
|
908
|
+
}
|
|
909
|
+
if event.text == Key.End {
|
|
910
|
+
root.select(root.model.length - 1);
|
|
911
|
+
return accept;
|
|
912
|
+
}
|
|
913
|
+
reject
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
popup := PopupWindow {
|
|
917
|
+
x: 0;
|
|
918
|
+
y: root.height;
|
|
919
|
+
width: max(root.width, root.popup-min-width);
|
|
920
|
+
height: root.list-height;
|
|
921
|
+
close-policy: close-on-click-outside;
|
|
922
|
+
forward-focus: list-keys;
|
|
923
|
+
list-keys := FocusScope {
|
|
924
|
+
// Light the row and scroll it into view.
|
|
925
|
+
function light(index: int) {
|
|
926
|
+
root.lit = clamp(index, 0, root.model.length - 1);
|
|
927
|
+
let top = root.lit * 20px;
|
|
928
|
+
if top + list.content-y < 0px {
|
|
929
|
+
list.content-y = -top;
|
|
930
|
+
} else if top + 20px + list.content-y > list.visible-height {
|
|
931
|
+
list.content-y = list.visible-height - top - 20px;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
init => {
|
|
935
|
+
list.content-y = 0px;
|
|
936
|
+
self.light(root.current-index >= 0 && root.current-index < root.model.length ? root.current-index : 0);
|
|
937
|
+
}
|
|
938
|
+
key-pressed(event) => {
|
|
939
|
+
if event.text == Key.UpArrow {
|
|
940
|
+
self.light(root.lit - 1);
|
|
941
|
+
return accept;
|
|
942
|
+
}
|
|
943
|
+
if event.text == Key.DownArrow {
|
|
944
|
+
self.light(root.lit + 1);
|
|
945
|
+
return accept;
|
|
946
|
+
}
|
|
947
|
+
if event.text == Key.PageUp {
|
|
948
|
+
self.light(root.lit - root.page);
|
|
949
|
+
return accept;
|
|
950
|
+
}
|
|
951
|
+
if event.text == Key.PageDown {
|
|
952
|
+
self.light(root.lit + root.page);
|
|
953
|
+
return accept;
|
|
954
|
+
}
|
|
955
|
+
if event.text == Key.Home {
|
|
956
|
+
self.light(0);
|
|
957
|
+
return accept;
|
|
958
|
+
}
|
|
959
|
+
if event.text == Key.End {
|
|
960
|
+
self.light(root.model.length - 1);
|
|
961
|
+
return accept;
|
|
962
|
+
}
|
|
963
|
+
if event.text == Key.Return || event.text == Key.Space {
|
|
964
|
+
root.select(root.lit);
|
|
965
|
+
popup.close();
|
|
966
|
+
return accept;
|
|
967
|
+
}
|
|
968
|
+
if event.text == Key.Escape || event.text == Key.Tab {
|
|
969
|
+
popup.close();
|
|
970
|
+
return accept;
|
|
971
|
+
}
|
|
972
|
+
reject
|
|
973
|
+
}
|
|
974
|
+
Rectangle {
|
|
975
|
+
background: Theme.bg-list;
|
|
976
|
+
border-color: Theme.line;
|
|
977
|
+
border-width: 1px;
|
|
978
|
+
list := ListView {
|
|
979
|
+
x: 1px;
|
|
980
|
+
y: 1px;
|
|
981
|
+
width: parent.width - 2px;
|
|
982
|
+
height: parent.height - 2px;
|
|
983
|
+
for text[index] in root.model: ChoiceRow {
|
|
984
|
+
text: text;
|
|
985
|
+
selected: index == root.current-index;
|
|
986
|
+
lit: index == root.lit;
|
|
987
|
+
accessible-item-index: index;
|
|
988
|
+
picked => {
|
|
989
|
+
root.select(index);
|
|
990
|
+
popup.close();
|
|
991
|
+
}
|
|
992
|
+
hovered => { root.lit = index; }
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|