From 41ca92961abd467f2ba91a256f7edbb0d8f3d333 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:27:53 -0600 Subject: [PATCH] feat(qt): header nav goes compact only when the text tabs no longer fit, and compact items honour the click rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact icon form is now a fit decision, agnostic of the sidebar state: text tabs while every label at the 11 px floor plus the tab chrome and whatever trails the tabs (the playlists button a closed sidebar adds) fits left of the search box, icons only otherwise — or always with "Compact header navigation" on. A closed sidebar used to force the compact form by itself; it now only adds one button to the measure, so a window with room keeps its text tabs. The room is measured from the third sacred button, not from the hidden row (a Row leaves a hidden child's x where it was). The downloads ring and the separator + playlists flyout button move out of the compact row into a trailing group shared by both forms, so a closed sidebar with room keeps its playlists entry; the group hides outright when it has nothing to show. Compact section buttons now call NavFlyout.sectionClicked like the text tabs and the sidebar rows do, so under the click-navigates opt-in a click on an icon lands on the section's first entry instead of only opening the flyout — Purchases was the one compact item that navigated. Settings > Navigation describes the new rule in all eight catalogs (en never carried the old entry). The Purchases navigation gate models headerNavOn and the fit bit: 128 x 3 combinations, still exactly one reachable Purchases entry. cc --- .../translations/de/LC_MESSAGES/qbz-ui.po | 5 +- .../translations/en/LC_MESSAGES/qbz-ui.po | 5 ++ .../translations/es/LC_MESSAGES/qbz-ui.po | 5 +- .../translations/fr/LC_MESSAGES/qbz-ui.po | 5 +- .../translations/ja/LC_MESSAGES/qbz-ui.po | 5 +- .../translations/nl/LC_MESSAGES/qbz-ui.po | 5 +- .../translations/pt/LC_MESSAGES/qbz-ui.po | 5 +- .../translations/ru/LC_MESSAGES/qbz-ui.po | 5 +- .../qml/settings/NavigationSettings.qml | 2 +- crates/qbz-qt/qml/shell/HeaderBar.qml | 89 +++++++++++++++---- scripts/test_qt_purchases_navigation.mjs | 13 +-- 11 files changed, 106 insertions(+), 38 deletions(-) diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 948b158cb..c1976202e 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -4450,8 +4450,9 @@ msgstr "Titelleisten-Modus geändert — QBZ neu starten, um es zu übernehmen" msgid "Compact header navigation" msgstr "Kompakte Navigation in der Kopfzeile" -msgid "Use the icon-only section navigation in the header even while the sidebar is open." -msgstr "Verwendet die Abschnittsnavigation nur mit Symbolen in der Kopfzeile, auch wenn die Seitenleiste geöffnet ist." +#: crates/qbz-qt/qml/settings/NavigationSettings.qml +msgid "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box." +msgstr "Verwendet in der Kopfzeile immer die Abschnittsnavigation nur mit Symbolen. Ausgeschaltet wechseln die Textreiter nur dann zu Symbolen, wenn sie neben dem Suchfeld nicht mehr Platz finden." # DSD support Phase 1 (2026-07-03) msgid "DSD: {}" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index b4a6c5170..508f47229 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -3289,3 +3289,8 @@ msgstr "“{}” will be deleted. This cannot be undone." #: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml msgid "Delete" msgstr "Delete" + + +#: crates/qbz-qt/qml/settings/NavigationSettings.qml +msgid "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box." +msgstr "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box." diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 95d975d43..a972860db 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -4486,8 +4486,9 @@ msgstr "Modo de barra de título cambiado — reinicia QBZ para aplicar" msgid "Compact header navigation" msgstr "Navegación compacta en el encabezado" -msgid "Use the icon-only section navigation in the header even while the sidebar is open." -msgstr "Usa la navegación de secciones de solo iconos en el encabezado aunque la barra lateral esté abierta." +#: crates/qbz-qt/qml/settings/NavigationSettings.qml +msgid "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box." +msgstr "Usa siempre la navegación de secciones de solo iconos en el encabezado. Apagado, las pestañas de texto pasan a iconos solo cuando ya no caben junto al cuadro de búsqueda." # DSD support Phase 1 (2026-07-03) msgid "DSD: {}" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 7b680af39..9cd37d41d 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4450,8 +4450,9 @@ msgstr "Mode de barre de titre modifié — redémarrez QBZ pour appliquer" msgid "Compact header navigation" msgstr "Navigation compacte dans l'en-tête" -msgid "Use the icon-only section navigation in the header even while the sidebar is open." -msgstr "Utilise la navigation des sections en icônes seules dans l'en-tête, même lorsque la barre latérale est ouverte." +#: crates/qbz-qt/qml/settings/NavigationSettings.qml +msgid "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box." +msgstr "Utilise toujours la navigation des sections en icônes seules dans l'en-tête. Désactivé, les onglets texte ne passent en icônes que lorsqu'ils ne tiennent plus à côté du champ de recherche." # DSD support Phase 1 (2026-07-03) msgid "DSD: {}" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 60536dc07..33c3c0140 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -4439,8 +4439,9 @@ msgstr "タイトルバーモードを変更しました — 適用するにはQ msgid "Compact header navigation" msgstr "ヘッダーのコンパクトナビゲーション" -msgid "Use the icon-only section navigation in the header even while the sidebar is open." -msgstr "サイドバーが開いていても、ヘッダーにアイコンのみのセクションナビゲーションを使用します。" +#: crates/qbz-qt/qml/settings/NavigationSettings.qml +msgid "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box." +msgstr "ヘッダーのセクションナビゲーションを常にアイコンのみで表示します。オフの場合、テキストタブは検索ボックスの横に収まらなくなったときだけアイコンに切り替わります。" # DSD support Phase 1 (2026-07-03) msgid "DSD: {}" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 550c6bbe5..8be41d166 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -4465,8 +4465,9 @@ msgstr "Titelbalkmodus gewijzigd — herstart QBZ om toe te passen" msgid "Compact header navigation" msgstr "Compacte headernavigatie" -msgid "Use the icon-only section navigation in the header even while the sidebar is open." -msgstr "Gebruik de sectienavigatie met alleen iconen in de header, ook terwijl de zijbalk open is." +#: crates/qbz-qt/qml/settings/NavigationSettings.qml +msgid "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box." +msgstr "Gebruik altijd de sectienavigatie met alleen iconen in de header. Uit, schakelen de teksttabbladen alleen over op iconen wanneer ze niet meer naast het zoekveld passen." msgid "DSD: {}" msgstr "DSD: {}" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index aac3b12c9..e85bb7b37 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4450,8 +4450,9 @@ msgstr "Modo da barra de título alterado — reinicie o QBZ para aplicar" msgid "Compact header navigation" msgstr "Navegação compacta no cabeçalho" -msgid "Use the icon-only section navigation in the header even while the sidebar is open." -msgstr "Usa a navegação de secções apenas com ícones no cabeçalho mesmo com a barra lateral aberta." +#: crates/qbz-qt/qml/settings/NavigationSettings.qml +msgid "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box." +msgstr "Usa sempre a navegação de secções apenas com ícones no cabeçalho. Desligado, os separadores de texto só passam a ícones quando deixam de caber ao lado da caixa de pesquisa." # DSD support Phase 1 (2026-07-03) msgid "DSD: {}" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index f5496a52d..b8e11a4e5 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -4466,8 +4466,9 @@ msgstr "Режим строки заголовка изменён — перез msgid "Compact header navigation" msgstr "Компактная навигация в заголовке" -msgid "Use the icon-only section navigation in the header even while the sidebar is open." -msgstr "Использовать навигацию по разделам только со значками в заголовке, даже когда боковая панель открыта." +#: crates/qbz-qt/qml/settings/NavigationSettings.qml +msgid "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box." +msgstr "Всегда использовать навигацию по разделам только со значками в заголовке. Если выключено, текстовые вкладки переходят на значки только тогда, когда перестают помещаться рядом с полем поиска." # DSD support Phase 1 (2026-07-03) msgid "DSD: {}" diff --git a/crates/qbz-qt/qml/settings/NavigationSettings.qml b/crates/qbz-qt/qml/settings/NavigationSettings.qml index a16feb538..a28f5f1b0 100644 --- a/crates/qbz-qt/qml/settings/NavigationSettings.qml +++ b/crates/qbz-qt/qml/settings/NavigationSettings.qml @@ -26,7 +26,7 @@ Column { // ADR-010: only mounted when navigation is NOT in the sidebar. visible: root.doc.navInSidebar !== true label: QbzSession.tr("Compact header navigation", QbzSession.trRev) - description: QbzSession.tr("Use the icon-only section navigation in the header even while the sidebar is open.", QbzSession.trRev) + description: QbzSession.tr("Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box.", QbzSession.trRev) QbzToggle { kioskHost: root.kioskHost; checked: root.doc.navHeaderCompact === true onToggled: function (v) { QbzBridge.settingsBool("nav-header-compact", v) } diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index 4ae9b4df3..a3ff4331d 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -5,13 +5,15 @@ // (HeaderBar.slint:858 / :974 — the two blocks are mutually exclusive): // // navInSidebar ON -> the sections live in Sidebar.qml. The header shows -// the COMPACT icon nav only while the sidebar is fully +// the section nav only while the sidebar is fully // closed (state 2), plus the separator + playlists // flyout button, so nothing is unreachable. -// navInSidebar OFF -> the sections live HERE. Full text tabs while the -// sidebar is not fully closed and navHeaderCompact is -// OFF; the compact icon form when navHeaderCompact is -// ON, or whenever the sidebar is fully closed. +// navInSidebar OFF -> the sections live HERE. +// The FORM (2026-09-13) is a fit decision, agnostic of the sidebar state: +// full text tabs while they fit beside the search box, the compact icon +// form when they do not — or always when navHeaderCompact is ON. A closed +// sidebar no longer forces the compact form; it only adds the playlists +// button to the row, and that button is part of the measure. // // The search field gives up 60px whenever the nav is in the header, and // re-centers with a 220ms animation (HeaderBar.slint:569). @@ -228,20 +230,49 @@ Rectangle { // 6 px gap above the 1140 px breakpoint, and the Row's 2 px spacing. readonly property real navTabsChrome: root.navTabCount * ((root.width >= 1140 ? 9 + 14 + 6 : 11) + 11 + 2) + TextMetrics { + id: navMetrics11 + font.pixelSize: 11 + font.weight: theme.weightSemibold + text: root.navLabelsJoined + } + // Where the section nav starts inside leftControls: right after the + // third sacred button. Read off that button, not off the tab row itself, + // because a Row leaves a hidden child's x wherever it last was — a room + // measured from the hidden form would feed the form decision below. + readonly property real navOriginX: fwdBtn.x + fwdBtn.width + leftControls.spacing // Room between the tabs' left edge and the (centred) search box. - readonly property real navTabsRoom: searchBox.x - (leftControls.x + fullTabs.x) - 16 + readonly property real navTabsRoom: searchBox.x - (leftControls.x + root.navOriginX) - 16 + // What trails the tabs in either form (the downloads ring, the playlists + // button of a closed sidebar) plus the Row gap before it — measured off + // the mounted group, which does not depend on the form. + readonly property real navTrailingWidth: + navTrailing.visible ? leftControls.spacing + navTrailing.width : 0 + // The text tabs fit when every label at the 11 px floor, the per-tab + // chrome and the trailing group all sit left of the search box. + readonly property bool navTabsFit: + navMetrics11.advanceWidth + root.navTabsChrome + root.navTrailingWidth <= root.navTabsRoom readonly property int navLabelPx: !root.headerTabsOn ? 11 : navMetrics13.advanceWidth + root.navTabsChrome <= root.navTabsRoom ? 13 : navMetrics12.advanceWidth + root.navTabsChrome <= root.navTabsRoom ? 12 : 11 - // Which of the two header forms is mounted (mutually exclusive, and both - // off while the nav lives in the sidebar and the sidebar is not closed). - readonly property bool headerTabsOn: !QbzShell.navInSidebar - && QbzShell.sidebarState !== 2 && !QbzShell.navHeaderCompact - readonly property bool headerCompactOn: QbzShell.sidebarState === 2 - || (!QbzShell.navInSidebar && QbzShell.navHeaderCompact) + // Whether the section nav is up here at all: the nav lives in the header, + // or it lives in the sidebar and the sidebar is fully closed (so the + // sections stay reachable). + readonly property bool headerNavOn: !QbzShell.navInSidebar || QbzShell.sidebarState === 2 + + // Which of the two header forms is mounted (mutually exclusive). The form + // is a FIT decision (2026-09-13), agnostic of the sidebar state: text tabs + // while they fit beside the search box, icon-only glyphs when they do not + // — or always when "Compact header navigation" is on. A closed sidebar + // used to force the compact form by itself; it now only adds the + // playlists button to the measure, so a window with room keeps its tabs. + readonly property bool headerTabsOn: root.headerNavOn + && !QbzShell.navHeaderCompact && root.navTabsFit + + readonly property bool headerCompactOn: root.headerNavOn && !root.headerTabsOn // Purchases downloading now (see Sidebar.qml's twin block). readonly property var activeDownloads: { @@ -426,7 +457,14 @@ Rectangle { cursorShape: Qt.PointingHandCursor // Icon-only buttons do NOT name their section, so their dropdown // is headed by the section name + hairline (HeaderBar.slint:223). - onClicked: navFlyout.openUnder(cnb, cnb.section, true) + // The click also lands on the section's first entry under the + // same opt-in the text tabs and the sidebar rows honour + // (NavFlyout.sectionClicked) — the compact form used to be the + // one host that ignored it. + onClicked: { + navFlyout.openUnder(cnb, cnb.section, true) + navFlyout.sectionClicked(cnb.section) + } onContainsMouseChanged: { if (containsMouse) { navFlyout.triggerHovered = true @@ -460,14 +498,15 @@ Rectangle { onClicked: QbzShell.navigateBack() } QbzNavButton { + id: fwdBtn name: "chevron-right" anchors.verticalCenter: parent.verticalCenter btnEnabled: QbzShell.canForward onClicked: QbzShell.navigateForward() } - // Full section nav (text tabs) — nav in the header, sidebar not fully - // closed, compact form OFF. Sits AFTER the three sacred buttons so it + // Full section nav (text tabs) — the section nav is up here and the + // tabs fit (headerTabsOn). Sits AFTER the three sacred buttons so it // can never overlap them (HeaderBar.slint:853). Row { id: fullTabs @@ -499,9 +538,9 @@ Rectangle { } } - // Compact section nav — while the sidebar is fully closed (so the - // sections stay reachable), or always when the nav is in the header - // and "Compact header navigation" is ON (HeaderBar.slint:974). + // Compact section nav — the icon-only form: when the text tabs do not + // fit beside the search box, or always when "Compact header + // navigation" is ON (HeaderBar.slint:974). Row { visible: root.headerCompactOn height: parent.height @@ -524,6 +563,20 @@ Rectangle { compact: true anchors.verticalCenter: parent.verticalCenter } + } + + // What trails the section nav in EITHER form (2026-09-13): the + // downloads ring and, while the sidebar is really closed, the + // separator + playlists flyout button. One group, mounted once, so a + // closed sidebar with room for text tabs keeps its playlists entry. + // Hidden outright when it has nothing to show, so the Row gap before + // it (and the fit measure above) only counts when something is there. + Row { + id: navTrailing + visible: root.headerNavOn + && (root.activeDownloads.length > 0 || QbzShell.sidebarState === 2) + height: parent.height + spacing: 2 // Purchases downloading now, as one ring (the sidebar is closed // or the nav lives up here, so there is no row to list them in); // the per-album detail is the hover bubble. Absent while idle. diff --git a/scripts/test_qt_purchases_navigation.mjs b/scripts/test_qt_purchases_navigation.mjs index 2684150a4..098fcafb7 100644 --- a/scripts/test_qt_purchases_navigation.mjs +++ b/scripts/test_qt_purchases_navigation.mjs @@ -23,17 +23,19 @@ assert.equal(slots.length, 2, 'full and compact purchases entries'); assert.match(header, /onClicked: QbzShell.navigateTo\("purchases"\)/); assert.match(sidebar, /id: navColumn[\s\S]*?visible: QbzShell.navInSidebar/); let cases = 0; -for (let bits = 0; bits < 64; bits++) { +for (let bits = 0; bits < 128; bits++) { const [showPurchases, offline, navInSidebar, navHeaderCompact, - systemTitleBar, hideTitleBar] = Array.from({length: 6}, (_, i) => !!(bits & (1 << i))); + systemTitleBar, hideTitleBar, navTabsFit] = Array.from({length: 7}, (_, i) => !!(bits & (1 << i))); for (const sidebarState of [0, 1, 2]) { const ctx = vm.createContext({ - root: {settingsDoc: {showPurchases}}, + // navTabsFit is the measured "text tabs fit beside the search box" + // bit: the form is a fit decision, agnostic of the sidebar state. + root: {settingsDoc: {showPurchases}, navTabsFit}, QbzShell: {navInSidebar, navHeaderCompact, sidebarState, systemTitleBar, hideTitleBar}, QbzSession: {offline}, }); const evaluate = expression => vm.runInContext(expression, ctx); - for (const property of ['headerTabsOn', 'headerCompactOn', 'purchasesInHeader']) + for (const property of ['headerNavOn', 'headerTabsOn', 'headerCompactOn', 'purchasesInHeader']) ctx.root[property] = evaluate(binding(header, property)); const headerCount = slots.reduce((count, expression, index) => count + Number(evaluate(expression) && (index === 0 ? ctx.root.headerTabsOn : ctx.root.headerCompactOn)), 0); @@ -45,7 +47,8 @@ for (let bits = 0; bits < 64; bits++) { cases++; } } -console.log(`Purchases navigation PASS: ${cases} combinations, full/compact/closed sidebar, title bars, opt-in and offline`); +assert.equal(cases, 128 * 3); +console.log(`Purchases navigation PASS: ${cases} combinations, full/compact/closed sidebar, tabs fit or not, title bars, opt-in and offline`); const shell = read('crates/qbz-qt/qml/shell/AppShell.qml'); // The bar's z is mode-aware since the 2.1.2 stabilization: Small's seek thumb