// 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 label; in property danger: false; in property selected: false; in property icon; in property 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 icon; in property active: false; in property 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 entry; in property active: false; in property 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 is-folder: root.entry.kind == "folder"; // Live filter for the move-to-folder list (per-row search input). property 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 drag-px: DragState.pointer-x; property drag-py: DragState.pointer-y; property drag-on: DragState.active; property 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 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 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 icon; in property label; in property tab-index; in property <[NavMenuEntry]> items; in property home-item: -1; property 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 hover-rows: 0; property max-scroll: max(0px, pl-flick.viewport-height - pl-flick.height); property thumb-h: pl-flick.viewport-height > 0px ? max(40px, pl-flick.height / pl-flick.viewport-height * self.height) : self.height; property travel: max(0px, self.height - self.thumb-h); property 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. }