Smoke rounds 3-4 (owner feedback). Checkpoint during iteration. Ambient (the big one): rewrote ambient.wgsl from translating gaussian blobs to a METABALL field (r²/d² summed + smoothstep iso-surface) with 2-octave domain warp — lobes fuse/stretch/split like an amoeba / lava lamp (the Cider motion that was missing), faster clock, more contrast. Surfaces made background-aware (translucent when the dynamic background is on): - Search results view (root + toolbar), artist Network panel + its tab-row header (transparent to avoid double-translucent compounding), JUMP TO bar (JumpNavBar bar-bg passed in — can't import ShellState without a cycle). - Discover toolbars + tab pill + BrowseHeaderTools; SearchResultsView toolbar. - CircleAction (shared: playlist/album/artist/etc. action buttons) on-surface secondary fill goes glass; playlist Sort dropdown; QbzSelect closed control. - New app-background-bar-alpha knob (0.3, env QBZ_BG_BAR_ALPHA): thin bars go MORE transparent than panels (surface-alpha 0.5). - Content well: frosted tint (0.22) + 1px hairline border → distinct panel. - Album/Artist header gradient auto-suppressed under the dynamic background (they clashed); header text stays light on the dynamic backdrop. - Discover carousel edge-fade "shadows" hidden when active (Carousel / PlaylistCarousel / ArtistCarousel / PinnedCarousel). Base:76ea5a82. Prior checkpoint:4e405338. cc
327 lines
14 KiB
Plaintext
327 lines
14 KiB
Plaintext
// Dropdown select primitive — the standard option picker for settings.
|
|
//
|
|
// The QBZ UI uses dropdowns, never pill-style option buttons. The select
|
|
// shows the current option and opens a PopupWindow list on click; picking
|
|
// an option emits `selected(index)` and the caller updates the bound
|
|
// state.
|
|
//
|
|
// Two opt-in affordances for long lists (the audio Output-device picker):
|
|
// - `searchable`: a case-insensitive filter box at the top of the open
|
|
// list. It hides non-matching rows by visibility only — the model is
|
|
// never reordered — so `selected(i)` still emits the original index.
|
|
// - the open list is height-capped and scrolls, so a machine with many
|
|
// outputs never produces a list taller than the screen.
|
|
//
|
|
// `badges` drives two mutually-exclusive trailing markers per option: a
|
|
// "BP" tag for bit-perfect-capable outputs, and a speaker glyph for the
|
|
// rest (real outputs that play audio but not bit-perfect). The speaker is
|
|
// an addition, never a replacement for "BP".
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Typography } from "../foundation/typography.slint";
|
|
import { Radius } from "../foundation/radius.slint";
|
|
import { QbzIcon } from "QbzIcon.slint";
|
|
import { TextUtil, UiFocusState, ShellState, AppearanceState } from "../state.slint";
|
|
|
|
// "BP" marker for bit-perfect-capable options. Plain accent text, no
|
|
// background — the QBZ UI never uses pill-style chips.
|
|
component BpBadge inherits Text {
|
|
text: "BP";
|
|
color: Theme.accent;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 0.5px;
|
|
vertical-alignment: center;
|
|
}
|
|
|
|
// Speaker glyph marking a non-bit-perfect output that still plays audio.
|
|
// Sits where BP would, so the trailing column stays aligned.
|
|
component AudioGlyph inherits QbzIcon {
|
|
source: @image-url("../assets/icons/volume-2.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
|
|
// Non-selectable section header row shown in the open list above the
|
|
// first option of a group. Plain muted-caps text, no background — the
|
|
// QBZ UI never uses pill-style chips.
|
|
component SectionHeader inherits Text {
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 0.5px;
|
|
vertical-alignment: center;
|
|
}
|
|
|
|
export component QbzSelect inherits Rectangle {
|
|
in property <[string]> options;
|
|
// Optional, parallel to `options`: each entry shows either a "BP" badge
|
|
// (true) or a speaker glyph (false). When `badges` is empty (every
|
|
// non-device caller) no trailing marker is drawn at all.
|
|
in property <[bool]> badges: [];
|
|
// Optional, parallel to `options`: a non-empty entry renders a
|
|
// non-selectable section-header row immediately above that option.
|
|
// Empty (the default) means no headers — backward compatible with
|
|
// every other QbzSelect caller (backend, sample-rate, ...).
|
|
in property <[string]> groups: [];
|
|
in property <int> current-index: 0;
|
|
in property <length> menu-width: 240px;
|
|
// Optional: a wider open list than the collapsed control, for long
|
|
// option labels (device names). 0px keeps the list at `menu-width`.
|
|
in property <length> popup-width: 0px;
|
|
in property <bool> enabled: true;
|
|
// Bootstrap-style small variant. Default is the standard size (used by
|
|
// Settings); toolbars opt in with `sm: true`.
|
|
in property <bool> sm: false;
|
|
// Opt-in filter box for long lists. Off by default so existing callers
|
|
// are untouched.
|
|
in property <bool> searchable: false;
|
|
callback selected(int);
|
|
|
|
// Live filter text (searchable lists only); reset every time the list
|
|
// opens so a stale query never hides the current selection.
|
|
private property <string> filter: "";
|
|
|
|
// Toolbar (sm) selects — Group / Sort / filter — were unnecessarily wide;
|
|
// trim 40px from the effective width (button + list).
|
|
private property <length> eff-width: root.menu-width - (root.sm ? 40px : 0px);
|
|
// Resolved width of the open list.
|
|
private property <length> list-width:
|
|
root.popup-width > root.eff-width ? root.popup-width : root.eff-width;
|
|
|
|
// Height of one option row and one section-header row.
|
|
private property <length> row-height: 32px;
|
|
private property <length> header-height: 24px;
|
|
// Search box height (0 when not searchable) and the cap past which the
|
|
// option list scrolls instead of growing.
|
|
private property <length> search-height: root.searchable ? 42px : 0px;
|
|
private property <length> max-list-height: 360px;
|
|
|
|
width: root.eff-width;
|
|
height: root.sm ? 30px : 34px;
|
|
// sm matches the favorites toolbar buttons (6px, no outline); the
|
|
// standard size keeps the settings-style bordered look.
|
|
border-radius: root.sm ? 6px : Radius.sm;
|
|
border-width: root.sm ? 0px : 1px;
|
|
border-color: Theme.border-subtle;
|
|
// Glass look when the app-wide dynamic background is active: the resting
|
|
// fill goes translucent so the moving background shows through the control.
|
|
background: ta.has-hover && root.enabled
|
|
? Theme.surface-hover
|
|
: (ShellState.app-background-active
|
|
? Theme.surface-elevated.with-alpha(AppearanceState.app-background-surface-alpha)
|
|
: Theme.surface-elevated);
|
|
opacity: root.enabled ? 1.0 : 0.4;
|
|
|
|
HorizontalLayout {
|
|
padding-left: root.sm ? 10px : 12px;
|
|
padding-right: root.sm ? 8px : 10px;
|
|
spacing: 8px;
|
|
Text {
|
|
text: root.current-index >= 0 && root.current-index < root.options.length
|
|
? root.options[root.current-index]
|
|
: "";
|
|
color: Theme.text-primary;
|
|
font-size: root.sm ? 12px : Typography.body;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
overflow: elide;
|
|
}
|
|
if root.current-index >= 0
|
|
&& root.current-index < root.badges.length
|
|
&& root.badges[root.current-index]: VerticalLayout {
|
|
alignment: center;
|
|
BpBadge { }
|
|
}
|
|
if root.current-index >= 0
|
|
&& root.current-index < root.badges.length
|
|
&& !root.badges[root.current-index]: VerticalLayout {
|
|
alignment: center;
|
|
AudioGlyph { }
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/chevron-down.svg");
|
|
width: root.sm ? 14px : 16px;
|
|
height: root.sm ? 14px : 16px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
}
|
|
|
|
ta := TouchArea {
|
|
mouse-cursor: root.enabled ? pointer : default;
|
|
clicked => {
|
|
if (root.enabled) {
|
|
root.filter = "";
|
|
popup.show();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keyboard activation: Enter/Space open the option list, same as a click.
|
|
// The TouchArea stays the pointer source; this only adds key activation +
|
|
// focusability on the collapsed control.
|
|
fs := FocusScope {
|
|
enabled: root.enabled;
|
|
// See QbzPrimaryButton: declared after `ta`, hit-tested first;
|
|
// focus-on-click:false stops it eating the first press (two-click bug).
|
|
focus-on-click: false;
|
|
key-pressed(event) => {
|
|
if (event.text == Key.Return || event.text == " ") {
|
|
root.filter = "";
|
|
popup.show();
|
|
return accept;
|
|
}
|
|
return reject;
|
|
}
|
|
}
|
|
|
|
// Non-layout focus ring — transparent-bordered overlay shown only on
|
|
// keyboard focus, matching the control's radius so it never reflows.
|
|
Rectangle {
|
|
x: 0;
|
|
y: 0;
|
|
width: parent.width;
|
|
height: parent.height;
|
|
background: transparent;
|
|
border-width: 2px;
|
|
border-color: fs.has-focus ? Theme.focus-ring : transparent;
|
|
border-radius: parent.border-radius;
|
|
}
|
|
|
|
popup := PopupWindow {
|
|
// Right-anchored so a list wider than the control grows leftward
|
|
// and stays on-screen.
|
|
x: root.eff-width - root.list-width;
|
|
y: root.height + 4px;
|
|
width: root.list-width;
|
|
height: root.search-height
|
|
+ Math.min(rows-col.preferred-height, root.max-list-height)
|
|
+ 10px;
|
|
|
|
Rectangle {
|
|
background: Theme.surface-main;
|
|
border-radius: Radius.sm;
|
|
border-width: 1px;
|
|
border-color: Theme.border-muted;
|
|
drop-shadow-blur: 20px;
|
|
drop-shadow-color: Theme.card-shadow;
|
|
drop-shadow-offset-y: 5px;
|
|
|
|
VerticalLayout {
|
|
padding-top: 5px;
|
|
padding-bottom: 5px;
|
|
|
|
if root.searchable: Rectangle {
|
|
height: root.search-height;
|
|
HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
padding-bottom: 6px;
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/search.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
search-input := TextInput {
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
vertical-alignment: center;
|
|
single-line: true;
|
|
text <=> root.filter;
|
|
changed has-focus => { UiFocusState.text-input-focused = self.has-focus; }
|
|
}
|
|
Text {
|
|
visible: search-input.text == "";
|
|
text: @tr("Search…");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Flickable {
|
|
height: Math.min(rows-col.preferred-height, root.max-list-height);
|
|
viewport-width: self.width;
|
|
viewport-height: rows-col.preferred-height;
|
|
|
|
rows-col := VerticalLayout {
|
|
// Each entry is a group-header row (optional,
|
|
// non-selectable) stacked above its option row. A
|
|
// `groups[i]` of "" means the option carries no header
|
|
// — every non-device caller leaves `groups` empty, so
|
|
// they render exactly as before. When a filter is
|
|
// active, rows that don't match collapse to zero
|
|
// height (model order is untouched, so `selected(i)`
|
|
// keeps the original index).
|
|
for opt[i] in root.options: row-wrap := VerticalLayout {
|
|
property <bool> shown:
|
|
root.filter == "" || TextUtil.contains-ci(opt, root.filter);
|
|
|
|
if row-wrap.shown
|
|
&& i < root.groups.length
|
|
&& root.groups[i] != "": Rectangle {
|
|
height: root.header-height;
|
|
HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
padding-top: 4px;
|
|
SectionHeader {
|
|
text: root.groups[i];
|
|
horizontal-stretch: 1;
|
|
}
|
|
}
|
|
}
|
|
if row-wrap.shown: Rectangle {
|
|
height: root.row-height;
|
|
background: opt-ta.has-hover ? Theme.surface-hover : transparent;
|
|
HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
spacing: 8px;
|
|
Text {
|
|
text: opt;
|
|
color: i == root.current-index ? Theme.accent : Theme.text-secondary;
|
|
font-size: root.sm ? 12px : Typography.body;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
overflow: elide;
|
|
}
|
|
if i < root.badges.length && root.badges[i]: VerticalLayout {
|
|
alignment: center;
|
|
BpBadge { }
|
|
}
|
|
if i < root.badges.length && !root.badges[i]: VerticalLayout {
|
|
alignment: center;
|
|
AudioGlyph { }
|
|
}
|
|
}
|
|
opt-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.selected(i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|