feat(kiosk): KioskShell touch shell + NavRail (P1, axis B)

Add AppScreen.kiosk mounting a KioskShell sibling of AppShell in the same
AppWindow: a thin back bar + persistent PlayerBarSmall + a bottom NavRail of
6 tiles replace the HeaderBar/Sidebar, sharing every *State global verbatim.
The NavRail routes through the SAME NavState routes the desktop uses
(header-menu-navigate / request-settings / ContentView.search). Content
reuses the real Home/Album/Artist/Favorites/Search/Settings views (verbatim
subset of AppShell mounts) with the arithmetic content-height pattern to
avoid the layout-collapse footgun. Rust lands the Kiosk screen post-login
when the kiosk profile is active. Validated through the Slint 1.16.1
compiler (slintcheck stub); remaining routes + touch-interaction remap are
P2/P3.

cc
This commit is contained in:
vicrodh committed 2026-07-10 14:48:53 -06:00
1 parent 7e6e7ade17
commit 242a02055a
4 files changed
+426 -3

No files matched your search

+56 -1
View File
@@ -16,6 +16,7 @@ import { ThemeState } from "foundation/theme-state.slint";
import { SplashScreen } from "splash/SplashScreen.slint";
import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint";
import { KioskShell } from "shell/KioskShell.slint";
import { Toast } from "primitives/Toast.slint";
import { ShellState, MiniPlayerState, NowPlayingState, AppearanceState, UiScale, WindowControlActions } from "state.slint";
import { MiniShell } from "miniplayer/MiniShell.slint";
@@ -33,7 +34,7 @@ export { HomeState, HomeActions, RecentAlbumsState, DiscoverState, DiscoverActio
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
export enum AppScreen { splash, login, shell }
export enum AppScreen { splash, login, shell, kiosk }
export component AppWindow inherits Window {
// "Show track in window title" (Appearance settings): when enabled and a
@@ -179,6 +180,60 @@ export component AppWindow inherits Window {
}
}
// Kiosk touch shell — a sibling of AppShell (small-panel profile). Same
// conditional-mount geometry pin as AppShell (a conditional child sizes to
// preferred size, so width/height are bound explicitly). Forwards the same
// callbacks to the AppWindow root.
if root.screen == AppScreen.kiosk: KioskShell {
width: root.width;
height: root.height;
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
open-purchase-album(id) => {
root.open-purchase-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
header-menu-navigate(route) => {
root.header-menu-navigate(route);
}
discover-view-all(e, t) => {
root.discover-view-all(e, t);
}
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();
}
logout => {
root.logout();
}
close-app => {
root.close-app();
}
recovery-login => {
root.recovery-login();
}
}
// App-global in-app toast overlay. Declared last so it floats over
// every screen (splash / login / shell). Driven by ToastState via
// crate::toast.
+231
View File
@@ -0,0 +1,231 @@
// 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.
//
// P1 scope: the frame + NavRail + persistent transport + global back, routing
// the reused Home/Album/Artist/Favorites/Search/Settings views. Remaining
// ContentView routes (mix, playlist, purchases, local-library, detail views,
// offline placeholder) are P2 — the view-mount block below is a verbatim subset
// of AppShell's and grows there. NOTE: keeping this block in sync with AppShell
// is temporary; a shared `ShellContent` extraction is the DRY follow-up once the
// kiosk shell is build-verified (kept isolated here so the desktop shell is
// untouched and cannot regress).
import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, OfflineState } from "../state.slint";
import { PlayerBarSmall } from "PlayerBarSmall.slint";
import { NavRail } from "NavRail.slint";
import { HomeView } from "../discover/HomeView.slint";
import { AlbumPageView } from "../album/AlbumPageView.slint";
import { ArtistPageView } from "../artist/ArtistPageView.slint";
import { FavoritesView } from "../favorites/FavoritesView.slint";
import { SearchResultsView } from "../search/SearchResultsView.slint";
import { SettingsView } from "../settings/SettingsView.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. Topbar + transport are one header-row tall each; the
// NavRail is a fat touch target.
property <length> navrail-height: 80px;
VerticalLayout {
spacing: 0px;
// --- Back bar (workstream B: the persistent global back affordance,
// wired to NavState history, honoring ADR-004). ------------------------
Rectangle {
height: Layout.header-height;
background: Theme.surface-card;
HorizontalLayout {
padding-left: 6px;
padding-right: 6px;
alignment: start;
back-btn := Rectangle {
width: 44px;
height: 44px;
y: (parent.height - self.height) / 2;
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();
}
}
}
}
}
// --- 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 — copy this rationale, it is the documented layout-collapse
// footgun. ------------------------------------------------------------
Rectangle {
vertical-stretch: 1;
background: Theme.surface-card;
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.
height: root.height - Layout.header-height - Layout.header-height
- root.navrail-height - 8px;
vertical-stretch: 1;
background: Theme.surface-main;
border-radius: Radius.md;
clip: true;
// Verbatim subset of AppShell's view mounts — same conditions,
// same callback wiring; the views read global state.
if NavState.view == ContentView.home && !OfflineState.offline: HomeView {
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.album && !OfflineState.offline: AlbumPageView {
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.artist && !OfflineState.offline: ArtistPageView {
open-album(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.favorites && !OfflineState.offline: FavoritesView {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
}
if NavState.view == ContentView.search && !OfflineState.offline: SearchResultsView {
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.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();
}
}
}
}
}
// --- Persistent transport: force PlayerBarSmall (npb=2 seam). Reused
// verbatim; it self-heights to one header row and reads its own globals.
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). Maps each tile 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");
}
search => {
NavState.view = ContentView.search;
}
playing => {
root.media-action("npb-view", "", "immersive");
}
visualizer => {
root.media-action("npb-view", "", "immersive");
}
menu => {
NavState.request-settings();
}
}
}
}
+127
View File
@@ -0,0 +1,127 @@
// Bottom NavRail — small-panel touch navigation (2.0.2 frente #3, Axis B).
//
// Six ~80px icon+label tiles (Radius.sm rectangles, NO pills — ADR-008)
// replacing the desktop Sidebar on the kiosk panel, driving the SAME NavState
// routes. Net-new component; patterned after the Sidebar's nav rows and the
// control-alignment standard: tokens/accent/radii only, no invented values,
// icons from the existing `assets/icons` set. Labels reuse existing `@tr`
// msgids (Discover/Library/Search/Now Playing/Visualizer/Settings) so no new
// `.po` strings are introduced.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, ImmersiveState } from "../state.slint";
component NavTile inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <bool> active;
callback clicked;
horizontal-stretch: 1;
border-radius: Radius.sm;
background: root.active
? Theme.accent.with-alpha(0.12)
: ta.has-hover ? Theme.alpha-6 : transparent;
border-width: root.active ? 1px : 0px;
border-color: Theme.accent.with-alpha(0.30);
VerticalLayout {
alignment: center;
spacing: 5px;
Image {
source: root.icon;
colorize: root.active ? Theme.accent : Theme.text-muted;
width: 24px;
height: 24px;
horizontal-alignment: center;
}
Text {
text: root.label;
color: root.active ? Theme.accent : Theme.text-muted;
font-size: 11.5px;
font-weight: 600;
horizontal-alignment: center;
overflow: elide;
}
}
ta := TouchArea {
clicked => {
root.clicked();
}
}
}
export component NavRail inherits Rectangle {
// One callback per destination; KioskShell maps each to the real route.
callback discover();
callback library();
callback search();
callback playing();
callback visualizer();
callback menu();
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// Top hairline separating the rail from the transport bar.
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
HorizontalLayout {
padding: 8px;
spacing: 6px;
NavTile {
icon: @image-url("../assets/icons/compass.svg");
label: @tr("Discover");
active: NavState.view == ContentView.home;
clicked => {
root.discover();
}
}
NavTile {
icon: @image-url("../assets/icons/library-big.svg");
label: @tr("Library");
active: NavState.view == ContentView.favorites;
clicked => {
root.library();
}
}
NavTile {
icon: @image-url("../assets/icons/search.svg");
label: @tr("Search");
active: NavState.view == ContentView.search;
clicked => {
root.search();
}
}
NavTile {
icon: @image-url("../assets/icons/play-fill.svg");
label: @tr("Now Playing");
active: ImmersiveState.open;
clicked => {
root.playing();
}
}
NavTile {
icon: @image-url("../assets/icons/sliders-horizontal.svg");
label: @tr("Visualizer");
active: false;
clicked => {
root.visualizer();
}
}
NavTile {
icon: @image-url("../assets/icons/settings.svg");
label: @tr("Settings");
active: NavState.view == ContentView.settings;
clicked => {
root.menu();
}
}
}
}
}