Files
qbz/crates/qbz-ui/ui/shell/KioskShell.slint
T
vicrodh 5ebe418805 fix(kiosk): dismiss on-screen keyboard on accept and tap-outside
The OSK opened on kiosk search/inputs but never closed: focus stayed on
the search field. Clear focus in the field's `accepted` handler and add a
full-bleed background TouchArea that clears focus on any tap outside the
input, so the on-screen keyboard dismisses.

cc
2026-07-18 19:50:27 -06:00

541 lines
26 KiB
Plaintext

// KioskShell — small-panel touch shell (2.0.2 frente #3, Axis B).
//
// A SIBLING of AppShell inside the same AppWindow, mounted when
// `AppScreen.kiosk` is active. It shares every `*State` global verbatim with the
// desktop shell (no mirroring), so it re-hosts the SAME view components; the
// only differences from AppShell are the chrome: a thin back bar + bottom
// NavRail replace the HeaderBar + Sidebar, the transport is forced to
// PlayerBarSmall, and there is no queue/lyrics side panel.
//
// The content-mount block below is a VERBATIM copy of AppShell's (every
// ContentView route + the offline placeholder), so navigation reaches the same
// views by construction. Keeping it in sync with AppShell is temporary debt: a
// shared `ShellContent` extraction is the DRY follow-up, deferred because the
// content-height arithmetic references `root.height` (= the shell/window height)
// and extracting it into a sub-component needs that height re-threaded — a
// change worth doing only once the kiosk shell is build-verified. Kept isolated
// here so the desktop shell is untouched and cannot regress.
import { LineEdit } from "std-widgets.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState } from "../state.slint";
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
import { PlayerBarSmall } from "PlayerBarSmall.slint";
import { NavRail } from "NavRail.slint";
import { WindowControls } from "WindowControls.slint";
import { KioskDiscover } from "KioskDiscover.slint";
import { DiscoverBrowseView } from "../discover/DiscoverBrowseView.slint";
import { RecentAlbumsView } from "../discover/RecentAlbumsView.slint";
import { KioskAlbum } from "KioskAlbum.slint";
import { LocalAlbumView } from "../album/LocalAlbumView.slint";
import { KioskArtist } from "KioskArtist.slint";
import { KioskNowPlaying } from "KioskNowPlaying.slint";
import { ArtistReleasesView } from "../artist/ArtistReleasesView.slint";
import { MusicianPageView } from "../musician/MusicianPageView.slint";
import { LabelReleasesView } from "../label/LabelReleasesView.slint";
import { LabelPageView } from "../label/LabelPageView.slint";
import { AwardView } from "../award/AwardView.slint";
import { AwardAlbumsView } from "../award/AwardAlbumsView.slint";
import { PurchasesView } from "../purchases/PurchasesView.slint";
import { PurchaseDetailView } from "../purchases/PurchaseDetailView.slint";
import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint";
import { KioskLibrary } from "KioskLibrary.slint";
import { FavoritesOfflineRail } from "../favorites/FavoritesOfflineRail.slint";
import { MixView } from "../mix/MixView.slint";
import { PlaylistView } from "../playlist/PlaylistView.slint";
import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint";
import { KioskMyQBZ } from "KioskMyQBZ.slint";
import { MixtapeDetailView } from "../myqbz/MixtapeDetailView.slint";
import { DiscographyBuilderView } from "../myqbz/DiscographyBuilderView.slint";
import { OfflineManagerView } from "../offline/OfflineManagerView.slint";
import { BlacklistManagerView } from "../settings/BlacklistManagerView.slint";
import { OfflinePlaceholder } from "../offline/OfflinePlaceholder.slint";
import { KioskLocalLibrary } from "KioskLocalLibrary.slint";
import { SettingsView } from "../settings/SettingsView.slint";
import { KioskSearch } from "KioskSearch.slint";
export component KioskShell inherits Rectangle {
// Public interface mirrors AppShell 1:1 (app.slint forwards these to the
// AppWindow root callbacks handled in Rust).
callback open-album(string);
callback open-artist(string);
callback open-purchase-album(string);
callback media-action(string, string, string);
callback header-menu-navigate(string);
callback discover-view-all(string, string);
callback settings-bool(string, bool);
callback settings-select(string, int);
callback settings-slider(string, int);
callback settings-string(string, string);
callback settings-reset();
callback settings-release-device();
callback logout();
callback close-app();
callback recovery-login();
// Kiosk chrome heights. The back bar + transport are one header-row tall
// each; the NavRail is a fat touch target.
property <length> navrail-height: 80px;
// Offline gate (verbatim from AppShell:110-130): Qobuz-only views mount only
// online; local-DB views (LocalLibrary, local album, offline manager, My QBZ,
// Settings) keep mounting offline.
property <bool> qobuz-view-blocked: OfflineState.offline && (
NavState.view == ContentView.home
|| NavState.view == ContentView.discover-browse
|| NavState.view == ContentView.search
|| NavState.view == ContentView.favorites
|| NavState.view == ContentView.album
|| NavState.view == ContentView.artist
|| NavState.view == ContentView.artist-releases
|| NavState.view == ContentView.musician
|| NavState.view == ContentView.label
|| NavState.view == ContentView.label-releases
|| NavState.view == ContentView.award
|| NavState.view == ContentView.award-albums
|| NavState.view == ContentView.purchases
|| NavState.view == ContentView.purchase-album
|| NavState.view == ContentView.location
|| NavState.view == ContentView.mix
|| (NavState.view == ContentView.playlist
&& !PlaylistState.is-local && !PlaylistState.offline-subset));
VerticalLayout {
spacing: 0px;
// --- Back bar: persistent global back (workstream B, wired to NavState
// history, ADR-004) + the kiosk search entry. The desktop search box
// lives in the HeaderBar, which the kiosk drops, so a persistent search
// field here is what makes Search functional; Enter submits through the
// same SearchActions seam the header uses and lands on the results view.
backbar := Rectangle {
height: Layout.header-height;
background: Theme.surface-card;
// The kiosk carries no HeaderBar, so it owns the window chrome too.
// macOS overlay leaves the native traffic-light inset; Linux frameless
// draws the custom WindowControls. OS-agnostic on purpose (the kiosk
// is not restricted to any platform).
property <length> mac-inset: AppearanceState.is-macos && !AppearanceState.system-title-bar-active ? 78px : 0px;
property <bool> lin-chrome: !AppearanceState.is-macos && !AppearanceState.system-title-bar-active && AppearanceState.show-window-controls;
HorizontalLayout {
padding-left: 6px + backbar.mac-inset;
padding-right: backbar.lin-chrome ? 118px : 8px;
padding-top: 4px;
padding-bottom: 4px;
spacing: 6px;
back-btn := Rectangle {
width: 44px;
border-radius: Radius.sm;
background: back-ta.has-hover && NavState.can-back ? Theme.alpha-8 : transparent;
opacity: NavState.can-back ? 1.0 : 0.32;
Image {
source: @image-url("../assets/icons/chevron-left.svg");
colorize: Theme.text-secondary;
width: 22px;
height: 22px;
}
back-ta := TouchArea {
enabled: NavState.can-back;
clicked => {
NavState.request-back();
}
}
}
fwd-btn := Rectangle {
width: 44px;
border-radius: Radius.sm;
background: fwd-ta.has-hover && NavState.can-forward ? Theme.alpha-8 : transparent;
opacity: NavState.can-forward ? 1.0 : 0.32;
Image {
source: @image-url("../assets/icons/chevron-right.svg");
colorize: Theme.text-secondary;
width: 22px;
height: 22px;
}
fwd-ta := TouchArea {
enabled: NavState.can-forward;
clicked => {
NavState.request-forward();
}
}
}
search-field := LineEdit {
horizontal-stretch: 1;
placeholder-text: @tr("Search");
// Suppress the global single-key hotkeys while typing (the
// desktop search box drives the same guard); without this,
// letters fire hotkeys instead of reaching the field.
// Probe property because a use-site `changed has-focus` on a
// std-widgets input never fires (has-focus is an alias into
// the inner TextInput) — #619.
property <bool> guard-focused: self.has-focus;
changed guard-focused => {
UiFocusState.text-input-focused = self.guard-focused;
}
accepted(text) => {
SearchActions.submit(text);
NavState.view = ContentView.search;
// Release focus so Slint sends text-input DISABLE → the OS
// on-screen keyboard (squeekboard) closes. Otherwise the
// search field keeps focus forever and the OSK never hides.
search-field.clear-focus();
}
}
}
// Linux custom window controls at the right edge.
if backbar.lin-chrome: WindowControls {
x: backbar.width - self.preferred-width - 8px;
y: (backbar.height - self.preferred-height) / 2;
}
}
// --- Content frame (mirrors AppShell:237-279 arithmetic-height pattern).
// A conditionally-mounted view will not stretch to fill a layout cell, so
// the inner Rectangle takes an EXPLICIT height and the views fill it via
// clip — this is the documented layout-collapse footgun. --------------
Rectangle {
vertical-stretch: 1;
background: Theme.surface-card;
// Tap anywhere in the content area to dismiss the OS on-screen
// keyboard: release the search field's focus → Slint sends the
// text-input DISABLE → squeekboard closes. Declared FIRST so it sits
// BEHIND the views (which handle their own taps); it only catches
// empty-area taps.
dismiss-osk-ta := TouchArea {
clicked => { search-field.clear-focus(); }
}
VerticalLayout {
vertical-stretch: 1;
padding-left: 8px;
padding-right: 8px;
padding-top: 0px;
padding-bottom: 8px;
Rectangle {
// window - back bar - transport(PlayerBarSmall = one header
// row) - NavRail - this VL's bottom padding. The transport
// row is HIDDEN on Now Playing (redundant there), so DON'T
// subtract it then — otherwise the content stays short and the
// NavRail rides up, leaving dead space below the footer.
height: root.height - Layout.header-height
- (NavState.view == ContentView.now-playing ? 0px : Layout.header-height)
- root.navrail-height - 8px;
vertical-stretch: 1;
background: Theme.surface-main;
border-radius: Radius.md;
clip: true;
// --- Verbatim copy of AppShell's view mounts (286-526). ---
if root.qobuz-view-blocked && NavState.view != ContentView.favorites: OfflinePlaceholder { }
if root.qobuz-view-blocked && NavState.view == ContentView.favorites: OfflinePlaceholder {
FavoritesOfflineRail { }
}
if NavState.view == ContentView.home && !OfflineState.offline: KioskDiscover {
album-clicked(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
discover-view-all(e, t) => {
root.discover-view-all(e, t);
}
}
if NavState.view == ContentView.discover-browse && !OfflineState.offline: DiscoverBrowseView {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.recent-albums: RecentAlbumsView {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.album && !OfflineState.offline: KioskAlbum {
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.local-album: LocalAlbumView {
open-artist(name) => {
root.open-artist(name);
}
}
if NavState.view == ContentView.artist && !OfflineState.offline: KioskArtist {
open-album(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.artist-releases && !OfflineState.offline: ArtistReleasesView {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.musician && !OfflineState.offline: MusicianPageView {
open-album(id) => {
root.open-album(id);
}
}
if NavState.view == ContentView.label && !OfflineState.offline: LabelPageView {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.label-releases && !OfflineState.offline: LabelReleasesView {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.award && !OfflineState.offline: AwardView {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.award-albums && !OfflineState.offline: AwardAlbumsView {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.location && !OfflineState.offline: ArtistsByLocationView {
open-artist(id) => {
root.open-artist(id);
}
}
if NavState.view == ContentView.favorites && !OfflineState.offline: KioskLibrary {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
}
if NavState.view == ContentView.purchases && !OfflineState.offline: PurchasesView {
open-album(id) => {
root.open-purchase-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.purchase-album
&& !OfflineState.offline
&& PurchaseDetailState.album-id != "": PurchaseDetailView { }
if NavState.view == ContentView.playlist-manager: PlaylistManagerView { }
if NavState.view == ContentView.mixtapes: KioskMyQBZ {
navigate(r) => {
root.header-menu-navigate(r);
}
}
if NavState.view == ContentView.collections: KioskMyQBZ {
navigate(r) => {
root.header-menu-navigate(r);
}
}
if NavState.view == ContentView.mixtape-detail: MixtapeDetailView { }
if NavState.view == ContentView.discography-builder: DiscographyBuilderView { }
if NavState.view == ContentView.offline-manager: OfflineManagerView { }
if NavState.view == ContentView.blacklist-manager: BlacklistManagerView { }
if NavState.view == ContentView.local-library: KioskLocalLibrary { }
if NavState.view == ContentView.mix && !OfflineState.offline: MixView {
play-track(id) => {
root.media-action("track", id, "play");
}
play-all => {
root.media-action("mix", "", "play-all");
}
shuffle => {
root.media-action("mix", "", "shuffle");
}
refresh => {
root.media-action("mix", "", "refresh");
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.playlist
&& (!OfflineState.offline
|| PlaylistState.is-local
|| PlaylistState.offline-subset): PlaylistView {
play-track(id) => {
root.media-action("track", id, "play");
}
play-all => {
root.media-action("playlist", "", "play-all");
}
shuffle => {
root.media-action("playlist", "", "shuffle");
}
toggle-favorite => {
root.media-action("playlist", "", "favorite");
}
edit => {
root.media-action("playlist", "", "edit");
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.settings: SettingsView {
settings-bool(k, v) => {
root.settings-bool(k, v);
}
settings-select(k, i) => {
root.settings-select(k, i);
}
settings-slider(k, v) => {
root.settings-slider(k, v);
}
settings-string(k, s) => {
root.settings-string(k, s);
}
settings-reset => {
root.settings-reset();
}
settings-release-device => {
root.settings-release-device();
}
}
if NavState.view == ContentView.search && !OfflineState.offline: KioskSearch {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.now-playing: KioskNowPlaying {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
}
}
// --- Persistent transport: force PlayerBarSmall (npb=2 seam). Reused
// verbatim; it self-heights to one header row and reads its own globals.
// Hidden on the Now Playing view — that view already carries the full
// transport, so the bar would be redundant there.
if NavState.view != ContentView.now-playing: PlayerBarSmall {
height: Layout.header-height;
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
settings-bool(k, v) => {
root.settings-bool(k, v);
}
}
// --- Bottom NavRail (net-new). Each tile maps to the real route the
// Sidebar/Rust dispatcher already uses. ------------------------------
NavRail {
height: root.navrail-height;
discover => {
root.header-menu-navigate("discover-home");
}
library => {
root.header-menu-navigate("favorites-tracks");
}
local-library => {
root.header-menu-navigate("local-albums");
}
myqbz => {
root.header-menu-navigate("myqbz-mixtapes");
}
playing => {
NavState.view = ContentView.now-playing;
}
visualizer => {
root.media-action("npb-view", "", "immersive");
}
menu => {
NavState.request-settings();
}
}
}
// Immersive / visualizer overlay — the same app-level view the desktop
// mounts, but the desktop hangs it inside AppShell, so the kiosk shell must
// mount its own copy. Reached from the NavRail visualizer tile (which fires
// the "npb-view"/"immersive" action). Rendered last = on top of everything.
if ImmersiveState.open: ImmersiveView {
width: 100%;
height: 100%;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}