@vestra-player/sdk 1.5.43 → 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.
@@ -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
- import { TextEdit } from "std-widgets.slint";
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
+ }