feat(qbz-slint): app shell frame
M1 progress. Adds the post-login shell: HeaderBar (nav tabs and search), Sidebar (playlists list and user card), PlayerBar (transport, now-playing, volume), and AppShell composing them with the content region as a placeholder. app.slint gains an AppScreen enum; clicking sign-in or start-offline navigates login -> shell (no real auth yet — that is M2). New layout foundation tokens (sidebar width, header/player-bar heights) plus border and hover semantic colors. cc
This commit is contained in:
9 files changed
+464
-5
No files matched your search
@@ -8,8 +8,9 @@
|
||||
//! is compiled from `ui/app.slint` by `build.rs`; `include_modules!` pulls in
|
||||
//! the generated Rust bindings.
|
||||
//!
|
||||
//! M1 status: foundation tokens + login screen. UI callbacks reach the
|
||||
//! typed-command layer; M2 wires that layer to `AppRuntime`.
|
||||
//! M1 status: foundation tokens, login screen, and the app shell frame
|
||||
//! (sidebar / header / content / player bar). UI callbacks reach the typed-
|
||||
//! command layer; M2 wires that layer to `AppRuntime` and real auth.
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
@@ -28,9 +29,26 @@ fn main() -> Result<(), slint::PlatformError> {
|
||||
|
||||
let window = AppWindow::new()?;
|
||||
|
||||
window.on_sign_in_via_browser(|| dispatch(AppCommand::SignInViaBrowser));
|
||||
// Sign-in and offline both reveal the app shell. During M1 this happens
|
||||
// without real authentication; M2 gates the transition on a real session.
|
||||
let weak = window.as_weak();
|
||||
window.on_sign_in_via_browser(move || {
|
||||
dispatch(AppCommand::SignInViaBrowser);
|
||||
if let Some(win) = weak.upgrade() {
|
||||
win.set_screen(AppScreen::Shell);
|
||||
}
|
||||
});
|
||||
|
||||
window.on_use_system_browser(|| dispatch(AppCommand::UseSystemBrowser));
|
||||
window.on_start_offline(|| dispatch(AppCommand::StartOffline));
|
||||
|
||||
let weak = window.as_weak();
|
||||
window.on_start_offline(move || {
|
||||
dispatch(AppCommand::StartOffline);
|
||||
if let Some(win) = weak.upgrade() {
|
||||
win.set_screen(AppScreen::Shell);
|
||||
}
|
||||
});
|
||||
|
||||
window.on_open_tos(|| dispatch(AppCommand::OpenTermsOfService));
|
||||
|
||||
log::info!("[qbz-slint] window ready — login screen");
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
|
||||
import { Theme } from "foundation/semantic-colors.slint";
|
||||
import { LoginScreen } from "login/LoginScreen.slint";
|
||||
import { AppShell } from "shell/AppShell.slint";
|
||||
|
||||
// Which top-level screen is shown. During M1 the screen switches on a
|
||||
// login-button click without real authentication; M2 ties the switch
|
||||
// to a real session.
|
||||
export enum AppScreen { login, shell }
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
title: "QBZ";
|
||||
@@ -15,13 +21,15 @@ export component AppWindow inherits Window {
|
||||
min-height: 600px;
|
||||
background: Theme.surface-main;
|
||||
|
||||
in-out property <AppScreen> screen: AppScreen.login;
|
||||
|
||||
// Forwarded to the Rust layer, which maps them to typed AppCommands.
|
||||
callback sign-in-via-browser();
|
||||
callback use-system-browser();
|
||||
callback start-offline();
|
||||
callback open-tos();
|
||||
|
||||
LoginScreen {
|
||||
if root.screen == AppScreen.login: LoginScreen {
|
||||
sign-in-via-browser => {
|
||||
root.sign-in-via-browser();
|
||||
}
|
||||
@@ -35,4 +43,6 @@ export component AppWindow inherits Window {
|
||||
root.open-tos();
|
||||
}
|
||||
}
|
||||
|
||||
if root.screen == AppScreen.shell: AppShell { }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Shell layout metrics.
|
||||
|
||||
export global Layout {
|
||||
out property <length> sidebar-width: 280px;
|
||||
out property <length> header-height: 48px;
|
||||
out property <length> player-bar-height: 88px;
|
||||
out property <length> hairline: 1px;
|
||||
}
|
||||
@@ -22,4 +22,7 @@ export global Palette {
|
||||
out property <color> warning: #fbbf24;
|
||||
|
||||
out property <color> shadow: #00000066; // rgba(0,0,0,0.4)
|
||||
|
||||
out property <color> alpha-6: #ffffff10; // ~6% white — hover fills
|
||||
out property <color> alpha-8: #ffffff14; // ~8% white — hairline borders
|
||||
}
|
||||
@@ -24,6 +24,12 @@ export global Theme {
|
||||
out property <color> accent-pressed: Palette.blue-active;
|
||||
out property <color> accent-text: Palette.white;
|
||||
|
||||
// Surfaces — interaction
|
||||
out property <color> surface-hover: Palette.alpha-6;
|
||||
|
||||
// Borders
|
||||
out property <color> border-subtle: Palette.alpha-8;
|
||||
|
||||
// Effects
|
||||
out property <color> card-shadow: Palette.shadow;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// App shell — the post-login frame: header bar, sidebar, content
|
||||
// region, and player bar.
|
||||
//
|
||||
// The content region is a placeholder; the Discover / Home view lands
|
||||
// in the next increment. Hairlines separate the regions.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Layout } from "../foundation/layout.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Sidebar } from "Sidebar.slint";
|
||||
import { HeaderBar } from "HeaderBar.slint";
|
||||
import { PlayerBar } from "PlayerBar.slint";
|
||||
|
||||
export component AppShell inherits Rectangle {
|
||||
background: Theme.surface-main;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
HeaderBar { }
|
||||
Rectangle {
|
||||
height: Layout.hairline;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 1;
|
||||
spacing: 0px;
|
||||
|
||||
Sidebar { }
|
||||
|
||||
// Content region — placeholder until the Discover/Home view.
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
background: Theme.surface-main;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
VerticalLayout {
|
||||
spacing: Spacing.sm;
|
||||
Text {
|
||||
text: "Discover — Home";
|
||||
color: Theme.text-primary;
|
||||
font-size: 22px;
|
||||
font-weight: Typography.semibold;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
Text {
|
||||
text: "Content view lands in the next increment";
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: Layout.hairline;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
PlayerBar { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Top header bar — primary navigation tabs and the search field.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Layout } from "../foundation/layout.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { LineEdit } from "std-widgets.slint";
|
||||
|
||||
component NavTab inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> active: false;
|
||||
callback clicked();
|
||||
|
||||
height: 32px;
|
||||
border-radius: Radius.sm;
|
||||
background: root.active ? Theme.surface-elevated
|
||||
: ta.has-hover ? Theme.surface-hover : transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Spacing.md;
|
||||
padding-right: Spacing.md;
|
||||
Text {
|
||||
text: root.label;
|
||||
color: root.active ? Theme.text-primary : Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
font-weight: root.active ? Typography.semibold : Typography.regular;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component HeaderBar inherits Rectangle {
|
||||
height: Layout.header-height;
|
||||
background: Theme.surface-main;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Spacing.md;
|
||||
padding-right: Spacing.md;
|
||||
spacing: Spacing.xs;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
spacing: Spacing.xs;
|
||||
NavTab { label: "Discover"; active: true; }
|
||||
NavTab { label: "Library"; }
|
||||
NavTab { label: "Local Library"; }
|
||||
NavTab { label: "My QBZ"; }
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 360px;
|
||||
search := LineEdit {
|
||||
placeholder-text: "Search";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Bottom now-playing / transport bar.
|
||||
//
|
||||
// Shell-frame increment: controls are laid out and hover-reactive but
|
||||
// not yet wired to playback. Real playback state arrives in a later
|
||||
// milestone.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Layout } from "../foundation/layout.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
|
||||
component GlyphButton inherits Rectangle {
|
||||
in property <string> glyph;
|
||||
in property <length> glyph-size: 18px;
|
||||
callback clicked();
|
||||
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: Radius.sm;
|
||||
background: ta.has-hover ? Theme.surface-hover : transparent;
|
||||
|
||||
Text {
|
||||
text: root.glyph;
|
||||
font-size: root.glyph-size;
|
||||
color: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component PlayerBar inherits Rectangle {
|
||||
height: Layout.player-bar-height;
|
||||
background: Theme.surface-main;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
// Progress line across the top edge.
|
||||
Rectangle {
|
||||
height: 2px;
|
||||
background: Theme.surface-elevated;
|
||||
Rectangle {
|
||||
x: 0;
|
||||
width: parent.width * 0.32;
|
||||
background: Theme.accent;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 1;
|
||||
padding-left: Spacing.md;
|
||||
padding-right: Spacing.md;
|
||||
spacing: Spacing.md;
|
||||
|
||||
// Transport controls.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
spacing: 2px;
|
||||
GlyphButton { glyph: "⇄"; }
|
||||
GlyphButton { glyph: "⏮"; }
|
||||
GlyphButton { glyph: "▶"; glyph-size: 22px; }
|
||||
GlyphButton { glyph: "⏭"; }
|
||||
GlyphButton { glyph: "↻"; }
|
||||
}
|
||||
}
|
||||
|
||||
// Now-playing.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
spacing: Spacing.sm;
|
||||
Rectangle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: Radius.sm;
|
||||
background: Theme.surface-elevated;
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: "Nothing playing";
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
Text {
|
||||
text: "QBZ Slint POC";
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right-side controls.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
alignment: end;
|
||||
spacing: Spacing.sm;
|
||||
GlyphButton { glyph: "♡"; }
|
||||
GlyphButton { glyph: "⤢"; }
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 90px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: Theme.surface-elevated;
|
||||
Rectangle {
|
||||
x: 0;
|
||||
width: parent.width * 0.6;
|
||||
border-radius: 2px;
|
||||
background: Theme.accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Left navigation sidebar — playlists list plus the user card.
|
||||
//
|
||||
// Shell-frame increment: the playlist rows are placeholder names for
|
||||
// visual reference. Real playlist data is wired in a later milestone.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Layout } from "../foundation/layout.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
|
||||
component PlaylistRow inherits Rectangle {
|
||||
in property <string> name;
|
||||
height: 34px;
|
||||
border-radius: Radius.sm;
|
||||
background: ta.has-hover ? Theme.surface-hover : transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Spacing.sm;
|
||||
padding-right: Spacing.sm;
|
||||
spacing: Spacing.sm;
|
||||
|
||||
Rectangle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: 3px;
|
||||
background: Theme.accent;
|
||||
opacity: 0.55;
|
||||
}
|
||||
Text {
|
||||
text: root.name;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Text {
|
||||
text: "›";
|
||||
color: Theme.text-muted;
|
||||
font-size: 16px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
export component Sidebar inherits Rectangle {
|
||||
width: Layout.sidebar-width;
|
||||
background: Theme.surface-main;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Spacing.md;
|
||||
spacing: Spacing.md;
|
||||
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
text: "PLAYLISTS";
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.5px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Text {
|
||||
text: "+";
|
||||
color: Theme.text-muted;
|
||||
font-size: 18px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
vertical-stretch: 1;
|
||||
VerticalLayout {
|
||||
spacing: 2px;
|
||||
for name in [
|
||||
"N.W.O.B.H.M.", "Beatles", "Progressive Metal",
|
||||
"Progressive Rock", "Rock en español", "Español",
|
||||
"Metal of the world", "Curated Metal", "Soundtracks",
|
||||
"Rock Mix", "Anime", "Classic Punk", "grls",
|
||||
"One Hit Wonder",
|
||||
] : PlaylistRow {
|
||||
name: name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 56px;
|
||||
border-radius: Radius.md;
|
||||
background: Theme.surface-elevated;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 10px;
|
||||
spacing: Spacing.sm;
|
||||
|
||||
Rectangle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: Radius.pill;
|
||||
background: Theme.accent;
|
||||
Text {
|
||||
text: "Q";
|
||||
color: Theme.accent-text;
|
||||
font-weight: Typography.semibold;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: "QBZ User";
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
Text {
|
||||
text: "Slint POC";
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: "⚙";
|
||||
color: Theme.text-muted;
|
||||
font-size: 16px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user