SidebarNavRow (mini sidebar) opened its section flyout with no header; the closed-sidebar CompactNavBtn dropdowns show the section name + a hairline. Set HeaderMenuState.title to the row label so the mini flyouts match (same @tr strings). cc
927 lines
41 KiB
Plaintext
927 lines
41 KiB
Plaintext
// Left navigation sidebar — the playlists list.
|
|
//
|
|
// The playlist list is empty until real playlist data is wired (the
|
|
// sidebar is a later milestone). The user element now lives in the
|
|
// header, not at the bottom of the sidebar.
|
|
//
|
|
// The sidebar is collapsible: its rendered width is driven by
|
|
// ShellState (0px when collapsed, otherwise the user-set width). A thin
|
|
// drag handle on the right edge resizes it, clamped to [200px, 420px];
|
|
// dragging below ~160px collapses it entirely.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Spacing } from "../foundation/spacing.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { SidebarPlaylistCollage } from "../primitives/SidebarPlaylistCollage.slint";
|
|
import { ContextMenu } from "../primitives/ContextMenu.slint";
|
|
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
|
|
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState, HeaderMenuState, NavMenuEntry, SidebarFolderPopupState, SidebarTooltipState } from "../state.slint";
|
|
import { ScrollView, LineEdit } from "std-widgets.slint";
|
|
|
|
// A single context-menu item row.
|
|
component MenuItem inherits Rectangle {
|
|
in property <string> label;
|
|
in property <bool> danger: false;
|
|
in property <bool> selected: false;
|
|
in property <image> icon;
|
|
in property <bool> has-icon: false;
|
|
callback activated();
|
|
height: 30px;
|
|
border-radius: 5px;
|
|
background: mi-ta.has-hover ? Theme.surface-hover : transparent;
|
|
HorizontalLayout {
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
spacing: 8px;
|
|
if root.has-icon: VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: 14px;
|
|
height: 14px;
|
|
tint: root.danger ? #ef4444 : Theme.text-secondary;
|
|
}
|
|
}
|
|
Text {
|
|
text: root.label;
|
|
color: root.danger ? #ef4444 : Theme.text-secondary;
|
|
font-size: 13px;
|
|
font-weight: root.selected ? 600 : 400;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
overflow: elide;
|
|
}
|
|
// Selected check (sort options).
|
|
if root.selected: VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/check.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: Theme.accent;
|
|
}
|
|
}
|
|
}
|
|
mi-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.activated();
|
|
}
|
|
}
|
|
}
|
|
|
|
// A square header toolbar button (search / + / ... / collapse).
|
|
component HeaderIconButton inherits Rectangle {
|
|
in property <image> icon;
|
|
in property <bool> active: false;
|
|
in property <length> icon-size: 15px;
|
|
callback clicked();
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 4px;
|
|
background: (btn-ta.has-hover || root.active) ? Theme.surface-hover : transparent;
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: root.icon-size;
|
|
height: root.icon-size;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: (btn-ta.has-hover || root.active) ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
btn-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
|
|
// One sidebar row — a folder header or a playlist (indented when in a
|
|
// folder), driven by the entry's `kind`.
|
|
//
|
|
// Folders: the leading icon is accent-tinted; the row's primary
|
|
// affordance is a trailing chevron that opens/closes the folder. The
|
|
// playlist count is hidden until the row is hovered. Folder actions
|
|
// (delete) remain reachable through the hover "..." button.
|
|
//
|
|
// Playlists: render a micro-collage of track covers (opt-out, via
|
|
// SidebarState.playlist-collage) in place of the list-music glyph; a
|
|
// hover "..." button opens the move-to-folder menu.
|
|
component SidebarRow inherits Rectangle {
|
|
in property <SidebarEntry> entry;
|
|
in property <bool> active: false;
|
|
in property <bool> show-collage: true;
|
|
callback open-playlist(string);
|
|
callback toggle-folder(string);
|
|
callback move-playlist(string, string);
|
|
callback delete-folder(string);
|
|
callback edit-playlist(string);
|
|
callback edit-folder(string);
|
|
callback hide-playlist(string);
|
|
callback hide-folder(string);
|
|
// Fires on row hover enter/leave so the sidebar can reveal the scrollbar
|
|
// while the pointer is anywhere over the scrollable list.
|
|
callback hover-changed(bool);
|
|
|
|
property <bool> is-folder: root.entry.kind == "folder";
|
|
// Live filter for the move-to-folder list (per-row search input).
|
|
property <string> folder-query;
|
|
|
|
// Track-drag drop target: a playlist row self-detects whether the
|
|
// global drag pointer is within its bounds (the source grabs the
|
|
// pointer, so normal hover doesn't fire during a drag). It claims
|
|
// the drop target while hot, and releases it when the pointer
|
|
// leaves.
|
|
//
|
|
// absolute-position must be read in an event handler (recompute),
|
|
// NOT a declarative binding — reading it during the layout phase
|
|
// is a recursion. So we mirror the drag pointer/active flag and
|
|
// recompute on their change.
|
|
property <length> drag-px: DragState.pointer-x;
|
|
property <length> drag-py: DragState.pointer-y;
|
|
property <bool> drag-on: DragState.active;
|
|
property <bool> drop-hot: false;
|
|
function recompute-drop() {
|
|
// Same Flickable double-count as the folder flyout anchor below:
|
|
// absolute-position adds this row's own layout offset twice, so the
|
|
// hit box drifted down by one row-pitch per row. Subtract root.{x,y}
|
|
// to get the true window bounds (the drag pointer in DragState is
|
|
// already in true window coords — TrackRow corrects it the same way).
|
|
root.drop-hot = root.drag-on
|
|
&& !root.is-folder
|
|
&& root.drag-px >= root.absolute-position.x - root.x
|
|
&& root.drag-px <= root.absolute-position.x - root.x + root.width
|
|
&& root.drag-py >= root.absolute-position.y - root.y
|
|
&& root.drag-py <= root.absolute-position.y - root.y + root.height;
|
|
if (root.drop-hot) {
|
|
DragState.over-playlist-id = root.entry.id;
|
|
} else if (DragState.over-playlist-id == root.entry.id) {
|
|
DragState.over-playlist-id = "";
|
|
}
|
|
}
|
|
changed drag-px => { root.recompute-drop(); }
|
|
changed drag-py => { root.recompute-drop(); }
|
|
changed drag-on => { root.recompute-drop(); }
|
|
|
|
// Collapsed (mini) hover tooltip: the row shows only an icon, so reveal the
|
|
// name (folders also show their playlist count) in a bubble to the right.
|
|
// Same Flickable absolute-position correction as the folder flyout anchor.
|
|
function show-tooltip() {
|
|
if (!ShellState.sidebar-mini) {
|
|
return;
|
|
}
|
|
SidebarTooltipState.id = root.entry.id;
|
|
SidebarTooltipState.text = root.is-folder
|
|
? root.entry.name + " (" + root.entry.count + ")"
|
|
: root.entry.name;
|
|
SidebarTooltipState.anchor-x = root.absolute-position.x - root.x + root.width + 6px;
|
|
SidebarTooltipState.anchor-y = root.absolute-position.y - root.y;
|
|
SidebarTooltipState.open = true;
|
|
}
|
|
// Close only if this row still owns the tooltip (race-safe when the pointer
|
|
// slides directly from one row to the next).
|
|
function hide-tooltip() {
|
|
if (SidebarTooltipState.id == root.entry.id) {
|
|
SidebarTooltipState.open = false;
|
|
}
|
|
}
|
|
|
|
// True when this playlist row should show its micro-collage instead
|
|
// of the generic glyph (opt-out + at least one cover available).
|
|
property <bool> use-collage: !root.is-folder
|
|
&& root.show-collage
|
|
&& root.entry.cover-count > 0;
|
|
|
|
// Collapsed (mini): folders behave as if closed — their nested playlists
|
|
// (indented rows) are hidden; contents open via the right-flyout instead.
|
|
property <bool> hidden-in-mini: ShellState.sidebar-mini && root.entry.indent;
|
|
height: root.hidden-in-mini ? 0px : 32px;
|
|
visible: !root.hidden-in-mini;
|
|
border-radius: 6px;
|
|
background: root.drop-hot
|
|
? #22c55e26
|
|
: (ta.has-hover || root.active
|
|
? Theme.surface-hover : transparent);
|
|
border-width: root.drop-hot ? 1px : 0px;
|
|
border-color: #22c55e66;
|
|
|
|
HorizontalLayout {
|
|
padding-left: ShellState.sidebar-mini ? 0px : (root.entry.indent ? 24px : 8px);
|
|
padding-right: ShellState.sidebar-mini ? 0px : 6px;
|
|
spacing: 8px;
|
|
// mini -> center the lone icon; open -> stretch so the name fills and
|
|
// the chevron is pushed to a fixed x (the row's right edge).
|
|
alignment: ShellState.sidebar-mini ? center : stretch;
|
|
// Leading icon — accent-tinted folder for folders, micro-collage
|
|
// (or list-music fallback) for playlists.
|
|
// Fixed-width icon slot so every row's text starts at the same x,
|
|
// whether the leading glyph is a 15px folder icon or a 20px collage.
|
|
Rectangle {
|
|
width: 24px;
|
|
if root.is-folder: QbzIcon {
|
|
// In the collapsed (mini) state folders always read as closed
|
|
// (their contents open in the right-flyout, not inline).
|
|
source: (root.entry.expanded && !ShellState.sidebar-mini)
|
|
? @image-url("../assets/icons/folder-open.svg")
|
|
: @image-url("../assets/icons/folder.svg");
|
|
width: 15px;
|
|
height: 15px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
// Folder icon tinted accent; the name keeps text color.
|
|
tint: Theme.accent;
|
|
}
|
|
if !root.is-folder && root.use-collage: SidebarPlaylistCollage {
|
|
entry: root.entry;
|
|
size: 20px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
}
|
|
if !root.is-folder && !root.use-collage: QbzIcon {
|
|
source: @image-url("../assets/icons/list-music.svg");
|
|
width: 15px;
|
|
height: 15px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: root.active ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
}
|
|
if !ShellState.sidebar-mini: Text {
|
|
text: root.entry.name;
|
|
color: root.active ? Theme.text-primary : Theme.text-secondary;
|
|
font-size: 13px;
|
|
font-weight: root.is-folder ? 600 : 400;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
overflow: elide;
|
|
}
|
|
// Folder playlist count — only visible on hover.
|
|
if root.is-folder && root.entry.count > 0 && !ShellState.sidebar-mini: Text {
|
|
text: root.entry.count;
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
vertical-alignment: center;
|
|
opacity: ta.has-hover ? 1.0 : 0.0;
|
|
}
|
|
// Folder open/close chevron — the primary affordance. Reflects
|
|
// the open/closed state (down = open, right = closed). Hidden in mini.
|
|
if root.is-folder && !ShellState.sidebar-mini: VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 4px;
|
|
background: chevron-ta.has-hover ? Theme.surface-elevated : transparent;
|
|
QbzIcon {
|
|
source: root.entry.expanded
|
|
? @image-url("../assets/icons/chevron-down.svg")
|
|
: @image-url("../assets/icons/chevron-right.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: chevron-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
chevron-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.toggle-folder(root.entry.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Playlists no longer show a hover "..." button (it cluttered the
|
|
// row, like the folders' removed one). Move-to-folder is reached by
|
|
// right-clicking the row instead (see the `ta` pointer-event below);
|
|
// folder management itself lives in the Playlist Manager.
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
changed has-hover => {
|
|
root.hover-changed(self.has-hover);
|
|
if (self.has-hover) {
|
|
root.show-tooltip();
|
|
} else {
|
|
root.hide-tooltip();
|
|
}
|
|
}
|
|
clicked => {
|
|
// The flyout / navigation takes over from here — drop the tooltip
|
|
// so it doesn't linger behind the opening flyout.
|
|
SidebarTooltipState.open = false;
|
|
if (root.is-folder) {
|
|
// Collapsed (mini) sidebar: a folder opens a flyout to the
|
|
// right instead of expanding downward (Tauri parity).
|
|
if (ShellState.sidebar-mini) {
|
|
SidebarFolderPopupState.folder-id = root.entry.id;
|
|
SidebarFolderPopupState.folder-name = root.entry.name;
|
|
SidebarFolderPopupState.count = root.entry.count;
|
|
// absolute-position double-counts this row's OWN layout
|
|
// offset when the row lives inside a Flickable (here:
|
|
// pl-flick). Measured: abs.y = true_y + root.y for every
|
|
// row (the first row, root.y==0, is the only one that
|
|
// landed right — which is why lower folders drifted down by
|
|
// exactly one row-pitch each). Subtracting root.{x,y} cancels
|
|
// the duplicate and yields the true window position; it is
|
|
// scroll-safe because root.{x,y} is the row's fixed offset
|
|
// within the layout, independent of viewport-y. Rows OUTSIDE
|
|
// a Flickable (the section nav rows) must NOT subtract it.
|
|
SidebarFolderPopupState.anchor-x = root.absolute-position.x - root.x + root.width + 6px;
|
|
SidebarFolderPopupState.anchor-y = root.absolute-position.y - root.y;
|
|
// Fill the popup's playlists from the cache (collapsed
|
|
// folders' children aren't in the flat entries).
|
|
SidebarActions.load-folder-popup(root.entry.id);
|
|
SidebarFolderPopupState.open = true;
|
|
} else {
|
|
root.toggle-folder(root.entry.id);
|
|
}
|
|
} else {
|
|
root.open-playlist(root.entry.id);
|
|
}
|
|
}
|
|
// Right-click any row (folder or playlist) to open its context
|
|
// menu. The old hover "..." button lived at the row's right edge,
|
|
// where the scrollbar overlapped it and ate the click — so it was
|
|
// removed and the menu rehomed here, reachable from anywhere on
|
|
// the row.
|
|
pointer-event(event) => {
|
|
if (event.button == PointerEventButton.right
|
|
&& event.kind == PointerEventKind.up) {
|
|
root.folder-query = "";
|
|
// Reset the filtered folder list to the full set (Rust
|
|
// owns the substring filter; an empty query = everything).
|
|
SidebarActions.search-folders("");
|
|
menu.show();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Right-click context menu — a folder menu (Edit / Hide) or a
|
|
// playlist menu (Edit / Hide / divider / Move-to-folder). Ports the
|
|
// Tauri sidebar row menus 1:1.
|
|
menu := PopupWindow {
|
|
x: root.width - 210px;
|
|
y: 28px;
|
|
width: 210px;
|
|
// Default close-policy closes on ANY click (even inside), which
|
|
// dismissed the menu the instant you clicked the folder-search
|
|
// input. Close only on outside clicks; items still close it
|
|
// explicitly via menu.close().
|
|
close-policy: PopupClosePolicy.close-on-click-outside;
|
|
ContextMenu {
|
|
// ---- Folder menu --------------------------------------------
|
|
if root.is-folder: ContextMenuItem {
|
|
icon: @image-url("../assets/icons/pencil.svg");
|
|
label: @tr("Edit folder");
|
|
clicked => {
|
|
root.edit-folder(root.entry.id);
|
|
menu.close();
|
|
}
|
|
}
|
|
if root.is-folder: ContextMenuItem {
|
|
icon: @image-url("../assets/icons/eye-off.svg");
|
|
label: @tr("Hide from sidebar");
|
|
clicked => {
|
|
root.hide-folder(root.entry.id);
|
|
menu.close();
|
|
}
|
|
}
|
|
|
|
// ---- Playlist menu ------------------------------------------
|
|
if !root.is-folder: ContextMenuItem {
|
|
icon: @image-url("../assets/icons/pencil.svg");
|
|
label: @tr("Edit playlist");
|
|
clicked => {
|
|
root.edit-playlist(root.entry.id);
|
|
menu.close();
|
|
}
|
|
}
|
|
if !root.is-folder: ContextMenuItem {
|
|
icon: @image-url("../assets/icons/eye-off.svg");
|
|
label: @tr("Hide from sidebar");
|
|
clicked => {
|
|
root.hide-playlist(root.entry.id);
|
|
menu.close();
|
|
}
|
|
}
|
|
// Divider before the move-to-folder section.
|
|
if !root.is-folder: Rectangle {
|
|
height: 7px;
|
|
Rectangle {
|
|
height: 1px;
|
|
y: (parent.height - self.height) / 2;
|
|
background: Theme.border-subtle;
|
|
}
|
|
}
|
|
// MOVE TO FOLDER section (wrapped so the `for` is not a direct
|
|
// child of an `if`, which Slint disallows).
|
|
if !root.is-folder: VerticalLayout {
|
|
padding-left: 11px;
|
|
padding-right: 16px;
|
|
padding-bottom: 3px;
|
|
Text {
|
|
text: @tr("Move to folder");
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
horizontal-alignment: left;
|
|
height: 18px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
// Folder search input — only when there are enough folders to
|
|
// warrant it. No auto-focus (calling focus() in init panics
|
|
// with "Recursion detected"). The substring filter runs in
|
|
// Rust (Slint strings have no `contains`); it fills
|
|
// `menu-folders`, which the list below iterates.
|
|
if !root.is-folder && SidebarState.folders.length >= 8: HorizontalLayout {
|
|
padding-left: 11px;
|
|
padding-right: 16px;
|
|
padding-bottom: 4px;
|
|
LineEdit {
|
|
height: 26px;
|
|
placeholder-text: @tr("Search folders");
|
|
text <=> root.folder-query;
|
|
edited => {
|
|
SidebarActions.search-folders(self.text);
|
|
}
|
|
}
|
|
}
|
|
// The folder list — each folder except the playlist's current
|
|
// one. The query filter is already applied to `menu-folders`
|
|
// by Rust; the current-folder exclusion stays in markup.
|
|
if !root.is-folder: ScrollView {
|
|
// Cap the list at ~5 rows (33px each); the rest scroll.
|
|
height: min(SidebarState.menu-folders.length * 33px, 165px);
|
|
vertical-scrollbar-policy: ScrollBarPolicy.as-needed;
|
|
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
|
VerticalLayout {
|
|
for f in SidebarState.menu-folders: ContextMenuItem {
|
|
shown: f.id != root.entry.folder-id;
|
|
icon: @image-url("../assets/icons/folder.svg");
|
|
label: f.name;
|
|
clicked => {
|
|
root.move-playlist(root.entry.id, f.id);
|
|
menu.close();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Move to root — only when the playlist is currently in a folder.
|
|
if !root.is-folder && root.entry.folder-id != "": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/chevron-left.svg");
|
|
label: @tr("Move to root");
|
|
clicked => {
|
|
root.move-playlist(root.entry.id, "");
|
|
menu.close();
|
|
}
|
|
}
|
|
// No folders at all.
|
|
if !root.is-folder && SidebarState.folders.length == 0: HorizontalLayout {
|
|
padding-left: 11px;
|
|
padding-right: 16px;
|
|
padding-bottom: 4px;
|
|
Text {
|
|
text: @tr("No folders yet.");
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
height: 22px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// One section-nav row in the sidebar. Clicking (or hovering) opens the same
|
|
// dropdown the header tab used, but flown out to the RIGHT of the row. In the
|
|
// mini state the label is hidden (icon only). Mirrors NavTabWithMenu.
|
|
component SidebarNavRow inherits Rectangle {
|
|
in property <image> icon;
|
|
in property <string> label;
|
|
in property <int> tab-index;
|
|
in property <[NavMenuEntry]> items;
|
|
in property <int> home-item: -1;
|
|
property <bool> active: HeaderMenuState.open-index == root.tab-index;
|
|
|
|
height: 34px;
|
|
border-radius: 6px;
|
|
background: nav-ta.has-hover ? Theme.surface-hover : transparent;
|
|
|
|
// Publish this row's menu into the shared overlay, anchored to the right
|
|
// of the row at the row's window y.
|
|
function open-menu() {
|
|
HeaderMenuState.open-index = root.tab-index;
|
|
HeaderMenuState.anchor-x = root.absolute-position.x + root.width + 6px;
|
|
HeaderMenuState.anchor-y = root.absolute-position.y;
|
|
HeaderMenuState.items = root.items;
|
|
HeaderMenuState.home-item = root.home-item;
|
|
HeaderMenuState.from-sidebar = true;
|
|
// Head the flyout with the section name + hairline, matching the
|
|
// closed-sidebar CompactNavBtn dropdowns (same @tr labels).
|
|
HeaderMenuState.title = root.label;
|
|
}
|
|
|
|
HorizontalLayout {
|
|
padding-left: ShellState.sidebar-mini ? 0px : 8px;
|
|
padding-right: ShellState.sidebar-mini ? 0px : 8px;
|
|
spacing: 10px;
|
|
alignment: ShellState.sidebar-mini ? center : start;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: 16px;
|
|
height: 16px;
|
|
tint: nav-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
|
}
|
|
}
|
|
if !ShellState.sidebar-mini: Text {
|
|
text: root.label;
|
|
color: nav-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
}
|
|
}
|
|
nav-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.open-menu();
|
|
}
|
|
changed has-hover => {
|
|
if (self.has-hover) {
|
|
HeaderMenuState.trigger-hovered = true;
|
|
root.open-menu();
|
|
} else if (root.active) {
|
|
HeaderMenuState.trigger-hovered = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export component Sidebar inherits Rectangle {
|
|
// Fired when a sidebar section-nav row is clicked — wired in AppShell to
|
|
// the same route handler the header dropdowns use.
|
|
callback navigate(string /* route */);
|
|
// Three-state rendered width: 0 (closed) / 64 (mini) / 240 (open).
|
|
width: ShellState.sidebar-rendered-width;
|
|
background: Theme.surface-card;
|
|
// Square edges — the bezel lives on the inset content panel, not here.
|
|
// Clip keeps content from spilling while the width animates.
|
|
clip: true;
|
|
animate width { duration: 160ms; easing: ease-in-out; }
|
|
|
|
VerticalLayout {
|
|
padding: ShellState.sidebar-mini ? 8px : Spacing.md;
|
|
spacing: Spacing.md;
|
|
|
|
// ---- Section nav — each row opens its dropdown as a right flyout,
|
|
// exactly like the header tabs did (same item sets). ----------
|
|
if ShellState.nav-in-sidebar: VerticalLayout {
|
|
spacing: 2px;
|
|
SidebarNavRow {
|
|
tab-index: 0;
|
|
icon: @image-url("../assets/icons/compass.svg");
|
|
label: @tr("Discover");
|
|
home-item: 0;
|
|
items: [
|
|
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
|
|
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
|
|
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
|
|
];
|
|
}
|
|
SidebarNavRow {
|
|
tab-index: 1;
|
|
icon: @image-url("../assets/icons/music-library-2.svg");
|
|
label: @tr("Library");
|
|
items: [
|
|
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" },
|
|
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
|
|
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" },
|
|
{ label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" },
|
|
{ label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" },
|
|
];
|
|
}
|
|
SidebarNavRow {
|
|
tab-index: 2;
|
|
icon: @image-url("../assets/icons/hard-drive.svg");
|
|
label: @tr("Local Library");
|
|
items: [
|
|
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "local-albums" },
|
|
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "local-artists" },
|
|
{ label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true, route: "local-folders" },
|
|
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "local-tracks" },
|
|
];
|
|
}
|
|
SidebarNavRow {
|
|
tab-index: 3;
|
|
icon: @image-url("../assets/icons/cassette-tape.svg");
|
|
label: @tr("My QBZ");
|
|
items: [
|
|
{ label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true },
|
|
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true },
|
|
];
|
|
}
|
|
}
|
|
if ShellState.nav-in-sidebar: Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
|
|
// ---- Header toolbar (hidden in the mini state) -------------
|
|
if !ShellState.sidebar-mini: HorizontalLayout {
|
|
spacing: 4px;
|
|
height: 22px;
|
|
|
|
// Title (hidden while the search input is open). 1pt smaller
|
|
// + tighter letter-spacing than the rest of the muted caps.
|
|
if !SidebarState.search-open: Text {
|
|
text: @tr("PLAYLISTS");
|
|
color: Theme.text-muted;
|
|
font-size: 10px;
|
|
letter-spacing: 1px;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
}
|
|
|
|
// Inline search input — takes the full header width to the
|
|
// left of the actions when open. Filters playlists (the
|
|
// filter is applied in crate::sidebar, recursively across
|
|
// folders). Wrapped in a Rectangle so the auto-focus can be
|
|
// deferred: calling self.focus() directly from the LineEdit's
|
|
// `init` re-enters property evaluation during the mount/layout
|
|
// pass and panics with "Recursion detected" (i-slint-core
|
|
// properties.rs). A single-shot Timer fires the focus on a
|
|
// later event-loop tick, after the element tree has settled
|
|
// (same end effect as Tauri's focus()).
|
|
if SidebarState.search-open: Rectangle {
|
|
horizontal-stretch: 1;
|
|
search-edit := LineEdit {
|
|
width: 100%;
|
|
height: 22px;
|
|
placeholder-text: @tr("Search playlists");
|
|
text <=> SidebarState.search-query;
|
|
edited => {
|
|
SidebarActions.search-changed(self.text);
|
|
}
|
|
}
|
|
focus-timer := Timer {
|
|
interval: 1ms;
|
|
running: true;
|
|
triggered => {
|
|
search-edit.focus();
|
|
self.running = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Search toggle.
|
|
HeaderIconButton {
|
|
icon: @image-url("../assets/icons/search.svg");
|
|
active: SidebarState.search-open;
|
|
clicked => {
|
|
SidebarState.search-open = !SidebarState.search-open;
|
|
if (!SidebarState.search-open) {
|
|
SidebarState.search-query = "";
|
|
SidebarActions.search-changed("");
|
|
}
|
|
}
|
|
}
|
|
// New playlist (+).
|
|
HeaderIconButton {
|
|
icon: @image-url("../assets/icons/plus.svg");
|
|
icon-size: 16px;
|
|
clicked => {
|
|
SidebarActions.create-playlist();
|
|
}
|
|
}
|
|
// Sort / more (...).
|
|
sort-btn := HeaderIconButton {
|
|
icon: @image-url("../assets/icons/ellipsis.svg");
|
|
clicked => {
|
|
sort-menu.show();
|
|
}
|
|
}
|
|
// Collapse chevron (down = expanded, right = collapsed).
|
|
HeaderIconButton {
|
|
icon: SidebarState.playlists-collapsed
|
|
? @image-url("../assets/icons/chevron-right.svg")
|
|
: @image-url("../assets/icons/chevron-down.svg");
|
|
clicked => {
|
|
SidebarState.playlists-collapsed = !SidebarState.playlists-collapsed;
|
|
}
|
|
}
|
|
|
|
// Sort + actions context menu. Anchored under the "..."
|
|
// button. Sort options mirror Tauri exactly.
|
|
sort-menu := PopupWindow {
|
|
x: parent.width - 210px;
|
|
y: 26px;
|
|
width: 200px;
|
|
Rectangle {
|
|
background: Theme.surface-card;
|
|
border-width: 1px;
|
|
border-color: Theme.border-subtle;
|
|
border-radius: 8px;
|
|
drop-shadow-blur: 20px;
|
|
drop-shadow-color: #00000080;
|
|
VerticalLayout {
|
|
padding: 6px;
|
|
spacing: 1px;
|
|
// "Sort by" section label.
|
|
HorizontalLayout {
|
|
padding-left: 8px;
|
|
padding-top: 4px;
|
|
padding-bottom: 2px;
|
|
spacing: 6px;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/arrow-up-down.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
Text {
|
|
text: @tr("Sort by");
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
}
|
|
}
|
|
MenuItem {
|
|
label: @tr("Name (A-Z)");
|
|
selected: SidebarState.sort-option == "name";
|
|
activated => { SidebarActions.set-sort("name"); }
|
|
}
|
|
MenuItem {
|
|
label: @tr("Recent");
|
|
selected: SidebarState.sort-option == "recent";
|
|
activated => { SidebarActions.set-sort("recent"); }
|
|
}
|
|
MenuItem {
|
|
label: @tr("# of tracks");
|
|
selected: SidebarState.sort-option == "tracks";
|
|
activated => { SidebarActions.set-sort("tracks"); }
|
|
}
|
|
MenuItem {
|
|
label: @tr("Play Count");
|
|
selected: SidebarState.sort-option == "playcount";
|
|
activated => { SidebarActions.set-sort("playcount"); }
|
|
}
|
|
MenuItem {
|
|
label: @tr("Custom");
|
|
selected: SidebarState.sort-option == "custom";
|
|
activated => { SidebarActions.set-sort("custom"); }
|
|
}
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
// New folder.
|
|
MenuItem {
|
|
label: @tr("New folder");
|
|
has-icon: true;
|
|
icon: @image-url("../assets/icons/folder-plus.svg");
|
|
activated => { SidebarActions.create-folder(); }
|
|
}
|
|
// Manage playlists — open the full Playlist Manager.
|
|
MenuItem {
|
|
label: @tr("Manage playlists");
|
|
has-icon: true;
|
|
icon: @image-url("../assets/icons/library-big.svg");
|
|
activated => { SidebarActions.manage-playlists(); }
|
|
}
|
|
// Refresh — re-fetch the playlist list.
|
|
MenuItem {
|
|
label: @tr("Refresh");
|
|
has-icon: true;
|
|
icon: @image-url("../assets/icons/refresh-cw.svg");
|
|
activated => { SidebarActions.refresh-playlists(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Playlist list (hidden when the section is collapsed) ----
|
|
// Wheel-scrolled Flickable (interactive only while NOT dragging, so a
|
|
// playlist drag-and-drop is never hijacked into a scroll) + a thin,
|
|
// auto-hiding scrollbar pinned to the sidebar's right edge.
|
|
if !SidebarState.playlists-collapsed: Rectangle {
|
|
vertical-stretch: 1;
|
|
pl-flick := Flickable {
|
|
width: 100%;
|
|
height: 100%;
|
|
interactive: !DragState.active;
|
|
viewport-width: self.width;
|
|
viewport-height: pl-content.preferred-height;
|
|
pl-content := VerticalLayout {
|
|
width: parent.width;
|
|
spacing: 2px;
|
|
alignment: start;
|
|
for entry in SidebarState.entries: SidebarRow {
|
|
entry: entry;
|
|
active: entry.kind == "playlist" && entry.id == SidebarState.active-id;
|
|
show-collage: SidebarState.playlist-collage;
|
|
open-playlist(id) => {
|
|
SidebarActions.open-playlist(id);
|
|
}
|
|
toggle-folder(id) => {
|
|
SidebarActions.toggle-folder(id);
|
|
}
|
|
move-playlist(pid, fid) => {
|
|
SidebarActions.move-playlist(pid, fid);
|
|
}
|
|
delete-folder(id) => {
|
|
SidebarActions.delete-folder(id);
|
|
}
|
|
edit-playlist(id) => {
|
|
SidebarActions.edit-playlist(id);
|
|
}
|
|
edit-folder(id) => {
|
|
SidebarActions.edit-folder(id);
|
|
}
|
|
hide-playlist(id) => {
|
|
SidebarActions.hide-playlist(id);
|
|
}
|
|
hide-folder(id) => {
|
|
SidebarActions.hide-folder(id);
|
|
}
|
|
hover-changed(h) => {
|
|
pl-sb.hover-rows = h ? pl-sb.hover-rows + 1 : Math.max(0, pl-sb.hover-rows - 1);
|
|
}
|
|
}
|
|
if SidebarState.entries.length == 0 && !SidebarState.loading: Text {
|
|
text: @tr("No playlists yet.");
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
wrap: word-wrap;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Thin scrollbar pinned to the sidebar's RIGHT EDGE. The +Spacing.md
|
|
// offset cancels the list's right padding so the gutter sits flush
|
|
// against the sidebar border. It appears whenever the pointer is
|
|
// over the scrollable list (hover-rows) or the gutter itself.
|
|
pl-sb := Rectangle {
|
|
// Offset by the list's real right padding (8px mini / 16px
|
|
// open) so the gutter sits flush against the sidebar edge in
|
|
// BOTH states. Narrower in the collapsed (mini) state.
|
|
x: parent.width - self.width + (ShellState.sidebar-mini ? 8px : Spacing.md);
|
|
width: ShellState.sidebar-mini ? 8px : 10px;
|
|
height: 100%;
|
|
// Count of list rows currently hovered (>0 = pointer over the
|
|
// scrollable area). Reveals the scrollbar.
|
|
property <int> hover-rows: 0;
|
|
property <length> max-scroll: max(0px, pl-flick.viewport-height - pl-flick.height);
|
|
property <length> thumb-h: pl-flick.viewport-height > 0px
|
|
? max(40px, pl-flick.height / pl-flick.viewport-height * self.height)
|
|
: self.height;
|
|
property <length> travel: max(0px, self.height - self.thumb-h);
|
|
property <bool> show: pl-sb-ta.has-hover || pl-sb-ta.pressed || self.hover-rows > 0;
|
|
visible: self.max-scroll > 0px;
|
|
|
|
thumb := Rectangle {
|
|
x: parent.width - self.width;
|
|
width: pl-sb-ta.has-hover || pl-sb-ta.pressed
|
|
? (ShellState.sidebar-mini ? 4px : 6px)
|
|
: (ShellState.sidebar-mini ? 3px : 4px);
|
|
height: pl-sb.thumb-h;
|
|
y: pl-sb.max-scroll > 0px
|
|
? (-pl-flick.viewport-y / pl-sb.max-scroll) * pl-sb.travel
|
|
: 0px;
|
|
border-radius: 3px;
|
|
background: pl-sb-ta.has-hover || pl-sb-ta.pressed ? #ffffff7a : #ffffff4d;
|
|
opacity: pl-sb.show ? 1.0 : 0.0;
|
|
animate opacity { duration: 160ms; }
|
|
animate width { duration: 100ms; }
|
|
}
|
|
pl-sb-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
moved => {
|
|
if (pl-sb-ta.pressed && pl-sb.travel > 0px) {
|
|
pl-flick.viewport-y = -min(pl-sb.max-scroll, max(0px,
|
|
(pl-sb-ta.mouse-y - pl-sb.thumb-h / 2) / pl-sb.travel * pl-sb.max-scroll));
|
|
}
|
|
}
|
|
pointer-event(e) => {
|
|
if (e.kind == PointerEventKind.down && pl-sb.travel > 0px) {
|
|
pl-flick.viewport-y = -min(pl-sb.max-scroll, max(0px,
|
|
(pl-sb-ta.mouse-y - pl-sb.thumb-h / 2) / pl-sb.travel * pl-sb.max-scroll));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Keep the layout stretching even while collapsed.
|
|
if SidebarState.playlists-collapsed: Rectangle {
|
|
vertical-stretch: 1;
|
|
}
|
|
}
|
|
// The drag-to-resize handle was removed: the sidebar is now a fixed
|
|
// three-state control (open / mini / closed) cycled from the header
|
|
// collapse button.
|
|
}
|