From 69cb6f9bdbc00a9f4b5a6f9e5e538545c8259dd2 Mon Sep 17 00:00:00 2001 From: Andrey Bocharnikov Date: Thu, 6 Aug 2026 19:56:46 +0400 Subject: [PATCH] =?UTF-8?q?feat(browser):=20downloads=20UI=20=E2=80=94=20p?= =?UTF-8?q?ill,=20strip,=20list,=20and=20Record=20menus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads UI per the Figma pill design, all rendered from Download Records (ADR 0006): - DownloadPill is the one shared delegate for both the session strip and the Downloads list; eliding comes from DownloadFormatUtils, while the status wording (Missing file / Cancelled / Interrupted) lives on the pill itself — qsTr in QML, where lupdate can see it. - DownloadPillStrip shows session-only pills; DownloadsListView renders Download History rows through the same DownloadPill. - DownloadRecordMenu takes a Record plus a 'capabilities' object bound at the call site (BrowserDownloadsContext.capabilitiesFor); the menu derives state booleans internally and switches Share vs Copy labels via capabilities.useShareLabels. - BrowserLinkContextMenu is the long-press link/image Download menu for mobile Backends (WebEngine keeps its native menu). - TabsBookmarksOverviewModal gains the Downloads mode, listing Records through DownloadsListView next to Open Tabs and Bookmarks. - StatusQ icons downloads-cancel / more-v back the pill actions. Tested in tst_DownloadPill, tst_DownloadsListView, tst_DownloadRecordMenu and tst_BrowserLinkContextMenu. Module registration for the new components lands with the BrowserLayout wiring. --- .../tests/tst_BrowserLinkContextMenu.qml | 96 +++++ storybook/qmlTests/tests/tst_DownloadPill.qml | 376 ++++++++++++++++++ .../qmlTests/tests/tst_DownloadRecordMenu.qml | 269 +++++++++++++ .../qmlTests/tests/tst_DownloadsListView.qml | 182 +++++++++ .../src/assets/img/icons/downloads-cancel.svg | 6 + ui/StatusQ/src/assets/img/icons/more-v.svg | 1 + ui/StatusQ/src/assets/img/img.qrc | 2 + .../Browser/controls/DownloadPill.qml | 306 ++++++++++++++ ui/app/AppLayouts/Browser/controls/qmldir | 1 + .../Browser/panels/DownloadPillStrip.qml | 190 +++++++++ .../Browser/panels/DownloadsListView.qml | 89 +++++ ui/app/AppLayouts/Browser/panels/qmldir | 2 + .../Browser/popups/BrowserLinkContextMenu.qml | 73 ++++ .../Browser/popups/DownloadRecordMenu.qml | 153 +++++++ .../popups/TabsBookmarksOverviewModal.qml | 42 +- ui/app/AppLayouts/Browser/popups/qmldir | 2 + .../Browser/stores/DownloadsStore.qml | 4 +- 17 files changed, 1787 insertions(+), 7 deletions(-) create mode 100644 storybook/qmlTests/tests/tst_BrowserLinkContextMenu.qml create mode 100644 storybook/qmlTests/tests/tst_DownloadPill.qml create mode 100644 storybook/qmlTests/tests/tst_DownloadRecordMenu.qml create mode 100644 storybook/qmlTests/tests/tst_DownloadsListView.qml create mode 100644 ui/StatusQ/src/assets/img/icons/downloads-cancel.svg create mode 100644 ui/StatusQ/src/assets/img/icons/more-v.svg create mode 100644 ui/app/AppLayouts/Browser/controls/DownloadPill.qml create mode 100644 ui/app/AppLayouts/Browser/panels/DownloadPillStrip.qml create mode 100644 ui/app/AppLayouts/Browser/panels/DownloadsListView.qml create mode 100644 ui/app/AppLayouts/Browser/popups/BrowserLinkContextMenu.qml create mode 100644 ui/app/AppLayouts/Browser/popups/DownloadRecordMenu.qml diff --git a/storybook/qmlTests/tests/tst_BrowserLinkContextMenu.qml b/storybook/qmlTests/tests/tst_BrowserLinkContextMenu.qml new file mode 100644 index 0000000000..ff8fb370ca --- /dev/null +++ b/storybook/qmlTests/tests/tst_BrowserLinkContextMenu.qml @@ -0,0 +1,96 @@ +import QtQuick +import QtTest + +import StatusQ.Popups + +import AppLayouts.Browser.popups + +/** + * Link/image long-press menu (ADR 0005 "save link"): item sets per URL shape + * and signal payloads. + */ +Item { + id: root + width: 400 + height: 400 + + Component { + id: menuComponent + BrowserLinkContextMenu {} + } + + TestCase { + name: "BrowserLinkContextMenu" + when: windowShown + + function enabledTexts(menu) { + const texts = [] + for (let i = 0; i < menu.count; ++i) { + const item = menu.itemAt(i) + if (!item || item instanceof StatusMenuSeparator) + continue + if (item.enabled && item.text) + texts.push(item.text) + } + return texts + } + + function test_linkOnly_showsLinkActions_noImage() { + const menu = createTemporaryObject(menuComponent, root, { + linkUrl: "https://example.com/file.zip", + imageUrl: "" + }) + const texts = enabledTexts(menu) + verify(texts.indexOf(qsTr("Open in new tab")) >= 0) + verify(texts.indexOf(qsTr("Download link")) >= 0) + verify(texts.indexOf(qsTr("Download image")) < 0) + } + + function test_imageOnly_showsImageDownload_noLinkActions() { + const menu = createTemporaryObject(menuComponent, root, { + linkUrl: "", + imageUrl: "https://example.com/photo.png" + }) + const texts = enabledTexts(menu) + verify(texts.indexOf(qsTr("Download image")) >= 0) + verify(texts.indexOf(qsTr("Open in new tab")) < 0) + verify(texts.indexOf(qsTr("Download link")) < 0) + } + + function test_linkedImage_showsBothSets() { + const menu = createTemporaryObject(menuComponent, root, { + linkUrl: "https://example.com/page", + imageUrl: "https://example.com/photo.png" + }) + const texts = enabledTexts(menu) + verify(texts.indexOf(qsTr("Open in new tab")) >= 0) + verify(texts.indexOf(qsTr("Download link")) >= 0) + verify(texts.indexOf(qsTr("Download image")) >= 0) + } + + function test_signals_carryTheMatchingUrl() { + const menu = createTemporaryObject(menuComponent, root, { + linkUrl: "https://example.com/file.zip", + imageUrl: "https://example.com/photo.png" + }) + + let downloaded = [] + let opened = [] + menu.downloadRequested.connect(u => downloaded.push(String(u))) + menu.openInNewTabRequested.connect(u => opened.push(String(u))) + + for (let i = 0; i < menu.count; ++i) { + const action = menu.actionAt(i) + if (!action || !action.enabled) + continue + if (action.text === qsTr("Download link") + || action.text === qsTr("Download image") + || action.text === qsTr("Open in new tab")) + action.trigger() + } + + compare(opened, ["https://example.com/file.zip"]) + compare(downloaded, ["https://example.com/file.zip", "https://example.com/photo.png"]) + } + } +} diff --git a/storybook/qmlTests/tests/tst_DownloadPill.qml b/storybook/qmlTests/tests/tst_DownloadPill.qml new file mode 100644 index 0000000000..ac1854c5fb --- /dev/null +++ b/storybook/qmlTests/tests/tst_DownloadPill.qml @@ -0,0 +1,376 @@ +import QtQuick +import QtTest + +import AppLayouts.Browser.adapters +import AppLayouts.Browser.controls +import AppLayouts.Browser.panels + +import "../../../ui/app/AppLayouts/Browser/webview/DownloadFormatUtils.js" as DownloadFormatUtils + +/** + * Download Pill UI: + * Figma control matrix, status wording, fixed strip width. + */ +Item { + id: root + width: 360 + height: 200 + + Component { + id: recordComponent + + QtObject { + property string fileName: "report.pdf" + property url url: "https://example.com/report.pdf" + property int state: AbstractWebView.DownloadState.DownloadInProgress + property double receivedBytes: 400 + property double totalBytes: 1000 + property bool isPaused: false + property bool isTerminal: false + property bool missingFile: false + property var liveDownload: null + + function pause() { isPaused = true; state = AbstractWebView.DownloadState.DownloadPaused } + function resume() { isPaused = false; state = AbstractWebView.DownloadState.DownloadInProgress } + function cancel() { isPaused = false; state = AbstractWebView.DownloadState.DownloadCancelled; isTerminal = true } + } + } + + Component { + id: pillComponent + DownloadPill {} + } + + Component { + id: stripComponent + DownloadPillStrip { + width: 360 + } + } + + TestCase { + name: "DownloadPill" + when: windowShown + + function makePill(record, extras) { + const props = Object.assign({ + download: record, + width: 227 + }, extras || {}) + return createTemporaryObject(pillComponent, root, props) + } + + function test_inProgress_statusText_showsReceivedTotal() { + const record = createTemporaryObject(recordComponent, root, { + receivedBytes: 400, + totalBytes: 1000 + }) + const pill = makePill(record) + verify(pill.statusText.indexOf("/") >= 0) + } + + function test_paused_statusText_showsReceivedTotal_notPausedWord() { + const record = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadPaused, + isPaused: true, + receivedBytes: 400, + totalBytes: 1000 + }) + const pill = makePill(record) + verify(pill.statusText.indexOf("/") >= 0) + verify(pill.statusText.indexOf(qsTr("Paused")) < 0) + } + + // Regression: a wrapped 32-bit byte count rendered negative sizes. + function test_statusText_survivesFilesOver2GiB() { + const record = createTemporaryObject(recordComponent, root, { + totalBytes: 3 * 1024 * 1024 * 1024, // 3 GiB > 2^31 + receivedBytes: 2.5 * 1024 * 1024 * 1024 + }) + const pill = makePill(record) + + const text = pill.statusText + verify(text.indexOf("/") >= 0) + verify(text.indexOf("-") < 0, "overflowed to negative: " + text) + verify(text.indexOf("GB") >= 0, "expected GB sizes, got: " + text) + } + + // Missing File outranks the Record state in the subtitle. + function test_missingFile_statusText_overridesState() { + const record = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadCompleted, + isTerminal: true, + missingFile: true + }) + const pill = makePill(record) + compare(pill.statusText, qsTr("Missing file")) + } + + function test_inProgress_pauseLeft_cancelRight_noOptions() { + const record = createTemporaryObject(recordComponent, root) + const pill = makePill(record) + + compare(pill.primaryAction, DownloadPill.PrimaryAction.Pause) + verify(pill.pauseButtonVisible) + verify(!pill.resumeButtonVisible) + verify(pill.cancelButtonVisible) + verify(!pill.optionsButtonVisible) + } + + function test_paused_resumeLeft_cancelRight_noOptions() { + const record = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadPaused, + isPaused: true + }) + const pill = makePill(record) + + compare(pill.primaryAction, DownloadPill.PrimaryAction.Resume) + verify(pill.resumeButtonVisible) + verify(!pill.pauseButtonVisible) + verify(pill.cancelButtonVisible) + verify(!pill.optionsButtonVisible) + + // The rendered controls follow the flags. + const primary = findChild(pill, "downloadPillPrimaryButton") + verify(!!primary) + verify(primary.visible) + compare(primary.icon.name, "play") + + const cancel = findChild(pill, "downloadPillCancelButton") + verify(!!cancel) + verify(cancel.visible) + + const options = findChild(pill, "downloadPillOptionsButton") + verify(!!options) + verify(!options.visible) + } + + function test_completed_fileLeft_optionsRight_noCancel_emptyStatus() { + const done = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadCompleted, + isTerminal: true, + receivedBytes: 1000 + }) + const pill = makePill(done) + + compare(pill.primaryAction, DownloadPill.PrimaryAction.File) + verify(pill.optionsButtonVisible) + verify(!pill.cancelButtonVisible) + compare(pill.statusText, "") + } + + function test_cancelled_iconLeft_cancelledStatus_hasOptionsMenu() { + // Cancelled keeps its ⋮ so Retry/Dismiss stay reachable from the strip. + const cancelled = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadCancelled, + isTerminal: true + }) + const pill = makePill(cancelled) + + compare(pill.primaryAction, DownloadPill.PrimaryAction.Cancelled) + compare(pill.statusText, qsTr("Cancelled")) + verify(pill.optionsButtonVisible) + verify(!pill.cancelButtonVisible) + } + + function test_interrupted_optionsRight_shortStatus_noInlineCancel() { + const interrupted = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadInterrupted, + isTerminal: true + }) + const pill = makePill(interrupted) + + compare(pill.primaryAction, DownloadPill.PrimaryAction.None) + verify(pill.optionsButtonVisible) + verify(!pill.cancelButtonVisible) + compare(pill.statusText, qsTr("Interrupted")) + } + + function test_cancelButton_forwardsToRecord() { + const record = createTemporaryObject(recordComponent, root) + const pill = makePill(record) + + pill.triggerCancel() + compare(record.state, AbstractWebView.DownloadState.DownloadCancelled) + compare(pill.primaryAction, DownloadPill.PrimaryAction.Cancelled) + verify(!pill.cancelButtonVisible) + verify(pill.optionsButtonVisible) // Cancelled keeps its ⋮ + } + + // Missing File follows the Record, not the surface — the strikeout + // lives in the pill, so both strip and list rows get it. + function test_missingFile_strikesThroughFileName() { + const record = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadCompleted, + isTerminal: true, + missingFile: true + }) + const pill = makePill(record) + + const label = findChild(pill, "downloadPillFileNameLabel") + verify(!!label) + verify(label.font.strikeout, "Missing File strikes through the file name") + } + + function test_elideFileName_middleElidesBase_keepsExtension() { + const longName = "very-long-download-report-name.pdf" + const elided = DownloadFormatUtils.elideFileName(longName, 18) + + verify(elided.endsWith(".pdf")) + verify(elided.indexOf("…") >= 0 || elided.indexOf("...") >= 0) + verify(elided.length <= 18) + compare(DownloadFormatUtils.elideFileName("short.pdf", 40), "short.pdf") + compare(DownloadFormatUtils.elideFileName("noext", 4).length <= 4, true) + } + + function test_strip_singlePill_keepsFixedWidth_leftAligned() { + const record = createTemporaryObject(recordComponent, root) + const strip = createTemporaryObject(stripComponent, root) + strip.downloadsModel = [record] + strip.width = 360 + strip.height = 44 + waitForRendering(strip) + + compare(strip.pillWidth, 227) + + const listView = findChild(strip, "downloadPillListView") + verify(!!listView) + compare(listView.count, 1) + const pill = listView.itemAtIndex(0) + verify(!!pill) + compare(pill.width, 227) + // Lone pill must not stretch to the strip content width. + verify(pill.width < listView.width) + } + + function test_strip_multiPill_keepsFixedWidth_noShrink() { + const a = createTemporaryObject(recordComponent, root, { fileName: "a.pdf" }) + const b = createTemporaryObject(recordComponent, root, { fileName: "b.pdf" }) + const c = createTemporaryObject(recordComponent, root, { fileName: "c.pdf" }) + const strip = createTemporaryObject(stripComponent, root) + strip.downloadsModel = [a, b, c] + strip.width = 360 + strip.height = 44 + waitForRendering(strip) + + const listView = findChild(strip, "downloadPillListView") + verify(!!listView) + compare(listView.count, 3) + compare(strip.pillWidth, 227) + + // Newest-first + positionViewAtBeginning → first pill is on-screen. + listView.forceLayout() + waitForRendering(strip) + const first = listView.itemAtIndex(0) + verify(!!first) + compare(first.width, 227) + + // Three fixed 227px pills exceed the strip content area. + verify(listView.contentWidth > listView.width) + verify(listView.contentWidth >= strip.pillWidth * 3) + } + + function test_strip_newDownload_insertsAtLeft() { + const older = createTemporaryObject(recordComponent, root, { fileName: "older.pdf" }) + const newer = createTemporaryObject(recordComponent, root, { fileName: "newer.pdf" }) + const strip = createTemporaryObject(stripComponent, root) + strip.width = 600 + strip.height = 44 + strip.downloadsModel = [older] + waitForRendering(strip) + + strip.downloadsModel = [newer, older] + waitForRendering(strip) + + const listView = findChild(strip, "downloadPillListView") + verify(!!listView) + compare(listView.count, 2) + listView.forceLayout() + waitForRendering(strip) + + const left = listView.itemAtIndex(0) + verify(!!left) + compare(left.download, newer) + } + + function test_strip_figmaChrome_flushPills_onlyActiveCardIsWhite() { + const active = createTemporaryObject(recordComponent, root, { fileName: "a.pdf" }) + const done = createTemporaryObject(recordComponent, root, { + fileName: "b.pdf", + state: AbstractWebView.DownloadState.DownloadCompleted, + isTerminal: true + }) + const strip = createTemporaryObject(stripComponent, root) + strip.downloadsModel = [active, done] + strip.width = 600 + waitForRendering(strip) + + compare(strip.implicitHeight, 44) + + const listView = findChild(strip, "downloadPillListView") + verify(!!listView) + listView.forceLayout() + waitForRendering(strip) + + const first = listView.itemAtIndex(0) + const second = listView.itemAtIndex(1) + verify(!!first && !!second) + verify(first.highlighted) + verify(!second.highlighted) + compare(first.height, strip.implicitHeight) + + // Pills sit flush — the strip tint separates them, not a gap. + compare(second.x - first.x, strip.pillWidth) + compare(second.color, strip.color) + verify(first.color !== second.color) + } + + // Strip signals carry the Download Record, not a strip index. + function test_strip_click_and_options_emitTheRecord() { + const done = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadCompleted, + isTerminal: true + }) + const strip = createTemporaryObject(stripComponent, root) + strip.downloadsModel = [done] + strip.width = 360 + strip.height = 44 + waitForRendering(strip) + + const listView = findChild(strip, "downloadPillListView") + verify(!!listView) + const pill = listView.itemAtIndex(0) + verify(!!pill) + + let clicked = null + strip.openDownloadClicked.connect(function (r) { clicked = r }) + pill.itemClicked() + compare(clicked, done) + + let gotRecord = null + let gotAnchor = null + strip.optionsClicked.connect(function (r, anchor) { + gotRecord = r + gotAnchor = anchor + }) + const options = findChild(pill, "downloadPillOptionsButton") + verify(!!options) + verify(options.visible) + mouseClick(options) + compare(gotRecord, done) + verify(!!gotAnchor, "anchor Item for menu alignment") + } + + function test_primaryPause_forwardsToRecord() { + const record = createTemporaryObject(recordComponent, root) + const pill = makePill(record) + + pill.triggerPrimaryAction() + compare(record.state, AbstractWebView.DownloadState.DownloadPaused) + compare(pill.primaryAction, DownloadPill.PrimaryAction.Resume) + + pill.triggerPrimaryAction() + compare(record.state, AbstractWebView.DownloadState.DownloadInProgress) + } + } +} diff --git a/storybook/qmlTests/tests/tst_DownloadRecordMenu.qml b/storybook/qmlTests/tests/tst_DownloadRecordMenu.qml new file mode 100644 index 0000000000..7f301ce44b --- /dev/null +++ b/storybook/qmlTests/tests/tst_DownloadRecordMenu.qml @@ -0,0 +1,269 @@ +import QtQuick +import QtTest + +import StatusQ.Popups + +import AppLayouts.Browser.adapters +import AppLayouts.Browser.popups + +/** + * Shared DownloadRecordMenu: + * item sets + Share/Copy labels from one bound `capabilities` object. + */ +Item { + id: root + width: 400 + height: 400 + + Component { + id: recordComponent + + QtObject { + property string fileName: "report.pdf" + property url url: "https://example.com/report.pdf" + property string targetPath: "/tmp/downloads/report.pdf" + property string mimeType: "application/pdf" + property int state: AbstractWebView.DownloadState.DownloadCompleted + property double receivedBytes: 1000 + property double totalBytes: 1000 + property bool isPaused: false + property bool isTerminal: true + property bool missingFile: false + property bool isInline: false + property var liveDownload: null + + function pause() { isPaused = true; state = AbstractWebView.DownloadState.DownloadPaused } + function resume() { isPaused = false; state = AbstractWebView.DownloadState.DownloadInProgress } + function cancel() { isPaused = false; state = AbstractWebView.DownloadState.DownloadCancelled; isTerminal = true } + } + } + + Component { + id: menuComponent + DownloadRecordMenu {} + } + + TestCase { + name: "DownloadRecordMenu" + when: windowShown + + /// One capabilities object per case. + function caps(overrides) { + return Object.assign({ + openInBrowser: false, + shareFile: false, + shareUrl: false, + showInFolder: false, + retry: false, + dismiss: false, + downloadsEntry: false, + useShareLabels: false + }, overrides || {}) + } + + function actionTexts(menu) { + const texts = [] + for (let i = 0; i < menu.count; ++i) { + const item = menu.itemAt(i) + if (!item || item instanceof StatusMenuSeparator) + continue + if (item.enabled && item.text) + texts.push(item.text) + } + return texts + } + + function test_completed_desktop_copyLabels_andShowInFolder() { + const record = createTemporaryObject(recordComponent, root) + const menu = createTemporaryObject(menuComponent, root, { + record: record, + capabilities: caps({ + openInBrowser: true, + shareFile: true, + shareUrl: true, + showInFolder: true + }) + }) + + const texts = actionTexts(menu) + verify(texts.indexOf(qsTr("Copy file path")) >= 0) + verify(texts.indexOf(qsTr("Copy file")) < 0) + verify(texts.indexOf(qsTr("Copy URL")) >= 0) + verify(texts.indexOf(qsTr("Open in Browser")) >= 0) + verify(texts.indexOf(qsTr("Show in folder")) >= 0) + verify(texts.indexOf(qsTr("Share file")) < 0) + verify(texts.indexOf(qsTr("Pause")) < 0) + verify(texts.indexOf(qsTr("Cancel")) < 0) + } + + function test_completed_mobile_shareLabels_hideShowInFolderOnIos() { + const record = createTemporaryObject(recordComponent, root) + const menu = createTemporaryObject(menuComponent, root, { + record: record, + capabilities: caps({ + shareFile: true, + shareUrl: true, + useShareLabels: true + }) + }) + + const texts = actionTexts(menu) + verify(texts.indexOf(qsTr("Share file")) >= 0) + verify(texts.indexOf(qsTr("Share URL")) >= 0) + verify(texts.indexOf(qsTr("Show in folder")) < 0) + } + + function test_active_list_exposesPauseResumeCancel() { + const record = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadInProgress, + isTerminal: false, + receivedBytes: 100 + }) + const menu = createTemporaryObject(menuComponent, root, { + record: record, + capabilities: caps({ useShareLabels: true }) + }) + + const texts = actionTexts(menu) + verify(texts.indexOf(qsTr("Pause")) >= 0) + verify(texts.indexOf(qsTr("Cancel")) >= 0) + verify(texts.indexOf(qsTr("Share file")) < 0) + + record.pause() + const paused = actionTexts(menu) + verify(paused.indexOf(qsTr("Resume")) >= 0) + verify(paused.indexOf(qsTr("Cancel")) >= 0) + } + + function test_interrupted_retryAndUrlActions() { + const record = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadInterrupted, + isTerminal: true + }) + const menu = createTemporaryObject(menuComponent, root, { + record: record, + capabilities: caps({ shareUrl: true, retry: true }) + }) + + const texts = actionTexts(menu) + verify(texts.indexOf(qsTr("Retry")) >= 0) + verify(texts.indexOf(qsTr("Copy URL")) >= 0) + verify(texts.indexOf(qsTr("Cancel")) < 0) + } + + function test_pill_completed_canShowDismiss() { + const record = createTemporaryObject(recordComponent, root) + const menu = createTemporaryObject(menuComponent, root, { + record: record, + capabilities: caps({ + shareFile: true, + shareUrl: true, + showInFolder: true, + dismiss: true, + useShareLabels: true + }) + }) + + const texts = actionTexts(menu) + verify(texts.indexOf(qsTr("Dismiss")) >= 0) + } + + function test_actionSignals_arePlain_recordIsTheMenus() { + const record = createTemporaryObject(recordComponent, root) + const menu = createTemporaryObject(menuComponent, root, { + record: record, + capabilities: caps({ retry: true, shareUrl: true }) + }) + + let retried = 0 + menu.retryRequested.connect(function() { retried += 1 }) + menu.retryRequested() + compare(retried, 1) + // The caller reads the Record straight off the menu. + compare(menu.record, record) + } + + /// The pill strip menu leads with "Downloads" (opens the + /// Downloads List section of the Open tabs overview), above a divider. + function test_downloadsEntry_firstInStripMenu_emitsSignal() { + const record = createTemporaryObject(recordComponent, root) + const menu = createTemporaryObject(menuComponent, root, { + record: record, + capabilities: caps({ + shareFile: true, + shareUrl: true, + showInFolder: true, + dismiss: true, + downloadsEntry: true + }) + }) + + const texts = actionTexts(menu) + verify(texts.indexOf(qsTr("Downloads")) >= 0) + compare(texts[0], qsTr("Downloads"), "Downloads leads the strip menu") + + let opened = 0 + menu.downloadsRequested.connect(function() { opened += 1 }) + for (let i = 0; i < menu.count; ++i) { + const action = menu.actionAt(i) + if (action && action.enabled && action.text === qsTr("Downloads")) + action.trigger() + } + compare(opened, 1, "activating the entry requests the Downloads List") + } + + /// List-row menus never show the entry — the user is already + /// in the Downloads List. + function test_downloadsEntry_absentFromListMenus() { + const record = createTemporaryObject(recordComponent, root) + const menu = createTemporaryObject(menuComponent, root, { + record: record, + capabilities: caps({ + openInBrowser: true, + shareFile: true, + shareUrl: true, + showInFolder: true + }) + }) + + const texts = actionTexts(menu) + verify(texts.indexOf(qsTr("Downloads")) < 0) + } + + /// `capabilities` is a BINDING at the call site, so the menu + /// can never show stale capabilities — flipping the record's state + /// re-derives the object with no populate step in between. + function test_capabilitiesBinding_followsRecordState_noStaleMenu() { + const record = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadInProgress, + isTerminal: false + }) + const menu = createTemporaryObject(menuComponent, root, { + record: record + }) + const self = this + menu.capabilities = Qt.binding(function() { + const complete = !!menu.record + && menu.record.state === AbstractWebView.DownloadState.DownloadCompleted + return self.caps({ + openInBrowser: complete, + shareFile: complete, + shareUrl: true + }) + }) + + let texts = actionTexts(menu) + verify(texts.indexOf(qsTr("Open in Browser")) < 0) + verify(texts.indexOf(qsTr("Pause")) >= 0) + + record.state = AbstractWebView.DownloadState.DownloadCompleted + record.isTerminal = true + + texts = actionTexts(menu) + verify(texts.indexOf(qsTr("Open in Browser")) >= 0, + "capabilities re-derive from the record with no populate call") + verify(texts.indexOf(qsTr("Copy file path")) >= 0) + verify(texts.indexOf(qsTr("Pause")) < 0) + } + } +} diff --git a/storybook/qmlTests/tests/tst_DownloadsListView.qml b/storybook/qmlTests/tests/tst_DownloadsListView.qml new file mode 100644 index 0000000000..dae6e35d74 --- /dev/null +++ b/storybook/qmlTests/tests/tst_DownloadsListView.qml @@ -0,0 +1,182 @@ +import QtQuick +import QtTest + +import AppLayouts.Browser.adapters +import AppLayouts.Browser.controls +import AppLayouts.Browser.panels + +/** + * DownloadsListView: empty line only at zero Records. + * Model may arrive via object construction (BrowserLayout → createObject), where a + * JS array is converted and no longer reports as Array — count must use .length. + * Rows render through the shared DownloadPill delegate; its per-state controls + * matrix is asserted in tst_DownloadPill. + */ +Item { + id: root + width: 360 + height: 400 + + Component { + id: recordComponent + + QtObject { + property string fileName: "report.pdf" + property url url: "https://example.com/report.pdf" + property int state: AbstractWebView.DownloadState.DownloadCompleted + property bool isPaused: false + property bool missingFile: false + + function pause() { + isPaused = true + state = AbstractWebView.DownloadState.DownloadPaused + } + function resume() { + isPaused = false + state = AbstractWebView.DownloadState.DownloadInProgress + } + function cancel() { + isPaused = false + state = AbstractWebView.DownloadState.DownloadCancelled + } + } + } + + Component { + id: listViewComponent + + DownloadsListView { + width: 360 + height: 400 + } + } + + TestCase { + name: "DownloadsListView" + when: windowShown + + function emptyLabel(view) { + return findChild(view, "downloadsListEmptyLabel") + } + + function listRow(view, index) { + const list = findChild(view, "downloadsListView") + verify(!!list) + list.forceLayout() + waitForRendering(view) + const row = list.itemAtIndex(index) + verify(!!row, "list row " + index) + return row + } + + // Rows render through the shared DownloadPill delegate. + function rowPill(view, index) { + const pill = findChild(listRow(view, index), "downloadPill") + verify(!!pill, "row " + index + " renders the shared DownloadPill") + return pill + } + + function test_empty_placeholder_visible_when_noRecords() { + const view = createTemporaryObject(listViewComponent, root, { + downloadsModel: [] + }) + waitForRendering(view) + + const label = emptyLabel(view) + verify(!!label) + verify(label.visible) + compare(label.text, qsTr("Downloaded files will appear here.")) + } + + function test_empty_placeholder_hidden_when_modelAssignedDirectly() { + const record = createTemporaryObject(recordComponent, root) + const view = createTemporaryObject(listViewComponent, root) + view.downloadsModel = [record] + waitForRendering(view) + + const label = emptyLabel(view) + verify(!!label) + verify(!label.visible) + } + + function test_empty_placeholder_hidden_when_modelViaCreateObject() { + // Mirrors BrowserLayout → TabsBookmarksOverviewModal createObject path. + const record = createTemporaryObject(recordComponent, root) + const view = listViewComponent.createObject(root, { + downloadsModel: [record], + width: 360, + height: 400 + }) + verify(!!view) + waitForRendering(view) + + // The bug: Array.isArray fails after createObject conversion. + // Length must still report non-empty. + verify(view._count >= 1) + + const label = emptyLabel(view) + verify(!!label) + verify(!label.visible) + + view.destroy() + } + + // The per-state controls matrix lives in the shared DownloadPill and is + // asserted once, in tst_DownloadPill. The list keeps one shared-delegate + // case (below) plus its own concerns: empty state and record signals. + function test_cancelled_showsOptionsMenu_inList() { + const record = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadCancelled + }) + const view = createTemporaryObject(listViewComponent, root, { + downloadsModel: [record] + }) + waitForRendering(view) + + const pill = rowPill(view, 0) + compare(pill.primaryAction, DownloadPill.PrimaryAction.Cancelled) + verify(!pill.cancelButtonVisible) + verify(pill.optionsButtonVisible, "Cancelled keeps its ⋮ (Retry/Dismiss reachable)") + + const options = findChild(pill, "downloadPillOptionsButton") + verify(!!options) + verify(options.visible) + } + + // View signals carry the Download Record, not a list index. + function test_rowClick_emitsTheRecord() { + const record = createTemporaryObject(recordComponent, root) + const view = createTemporaryObject(listViewComponent, root, { + downloadsModel: [record] + }) + waitForRendering(view) + + let got = null + view.openDownloadClicked.connect(function (r) { got = r }) + mouseClick(listRow(view, 0)) + compare(got, record) + } + + function test_optionsClick_emitsRecordAndAnchor() { + const record = createTemporaryObject(recordComponent, root, { + state: AbstractWebView.DownloadState.DownloadCompleted + }) + const view = createTemporaryObject(listViewComponent, root, { + downloadsModel: [record] + }) + waitForRendering(view) + + let gotRecord = null + let gotAnchor = null + view.optionsClicked.connect(function (r, anchor) { + gotRecord = r + gotAnchor = anchor + }) + const options = findChild(rowPill(view, 0), "downloadPillOptionsButton") + verify(!!options) + mouseClick(options) + compare(gotRecord, record) + verify(!!gotAnchor, "anchor Item for menu alignment") + } + } +} diff --git a/ui/StatusQ/src/assets/img/icons/downloads-cancel.svg b/ui/StatusQ/src/assets/img/icons/downloads-cancel.svg new file mode 100644 index 0000000000..56fe86ff9c --- /dev/null +++ b/ui/StatusQ/src/assets/img/icons/downloads-cancel.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/StatusQ/src/assets/img/icons/more-v.svg b/ui/StatusQ/src/assets/img/icons/more-v.svg new file mode 100644 index 0000000000..b820d85eaa --- /dev/null +++ b/ui/StatusQ/src/assets/img/icons/more-v.svg @@ -0,0 +1 @@ + diff --git a/ui/StatusQ/src/assets/img/img.qrc b/ui/StatusQ/src/assets/img/img.qrc index 3fd4475378..8398f1e419 100644 --- a/ui/StatusQ/src/assets/img/img.qrc +++ b/ui/StatusQ/src/assets/img/img.qrc @@ -136,6 +136,7 @@ icons/dots-icon.svg icons/double-checkmark.svg icons/download.svg + icons/downloads-cancel.svg icons/downloads.svg icons/edit.svg icons/edit_pencil.svg @@ -194,6 +195,7 @@ icons/mobile-sync-off.svg icons/mobile-sync.svg icons/mobile.svg + icons/more-v.svg icons/more.svg icons/muted.svg icons/network.svg diff --git a/ui/app/AppLayouts/Browser/controls/DownloadPill.qml b/ui/app/AppLayouts/Browser/controls/DownloadPill.qml new file mode 100644 index 0000000000..03abb719a9 --- /dev/null +++ b/ui/app/AppLayouts/Browser/controls/DownloadPill.qml @@ -0,0 +1,306 @@ +import QtQuick +import QtQuick.Layouts + +import StatusQ.Core +import StatusQ.Core.Theme +import StatusQ.Controls + +import AppLayouts.Browser.adapters + +import "../webview/DownloadFormatUtils.js" as DownloadFormatUtils + +/** + * The one delegate for a Download Record (Browser CONTEXT / Figma File + * download): the strip renders it as a capsule, the Downloads + * List as a flat row. The state-to-controls matrix lives here and only here. + * Left: Pause | Play | file | downloads. Right: downloads-cancel | more-v. + */ +Rectangle { + id: root + + enum PrimaryAction { + None, + Pause, + Resume, + File, + Cancelled + } + + property var download: null + + // Eliding comes from DownloadFormatUtils; the wording lives here because + // lupdate only scans QML. + + // Presentation knobs: the surfaces differ in + // chrome, not in behaviour. Defaults draw the strip capsule; the Downloads + // List overrides them to render a flat row inside its own hover highlight. + property color pillColor: root.highlighted ? Theme.palette.background : Theme.palette.baseColor2 + property int nameFontSize: Theme.additionalTextSize + property int statusFontSize: Theme.tertiaryTextFontSize + property int leadingSlotSize: 24 + property int contentSpacing: Theme.halfPadding + property int leadingMargin: 12 + property int trailingMargin: Theme.halfPadding + // False when a surrounding delegate (Downloads List row) owns the click. + property bool interactive: true + + readonly property int primaryAction: { + if (!download) + return DownloadPill.PrimaryAction.None + const state = download.state + if (state === AbstractWebView.DownloadState.DownloadCompleted) + return DownloadPill.PrimaryAction.File + if (state === AbstractWebView.DownloadState.DownloadCancelled) + return DownloadPill.PrimaryAction.Cancelled + if (state === AbstractWebView.DownloadState.DownloadPaused || download.isPaused) + return DownloadPill.PrimaryAction.Resume + if (state === AbstractWebView.DownloadState.DownloadInProgress + || state === AbstractWebView.DownloadState.DownloadRequested) + return DownloadPill.PrimaryAction.Pause + // Interrupted: tap retries via BrowserDownloadsContext; no inline control. + return DownloadPill.PrimaryAction.None + } + + readonly property bool pauseButtonVisible: primaryAction === DownloadPill.PrimaryAction.Pause + readonly property bool resumeButtonVisible: primaryAction === DownloadPill.PrimaryAction.Resume + readonly property bool cancelButtonVisible: primaryAction === DownloadPill.PrimaryAction.Pause + || primaryAction === DownloadPill.PrimaryAction.Resume + // Cancelled keeps its ⋮ so Retry/Dismiss stay reachable. + readonly property bool optionsButtonVisible: primaryAction === DownloadPill.PrimaryAction.File + || primaryAction === DownloadPill.PrimaryAction.None + || primaryAction === DownloadPill.PrimaryAction.Cancelled + + readonly property bool missingFile: !!(download && download.missingFile) + + /// Figma File download: only non-terminal Records get the white card; finished + /// ones blend into the strip background. + readonly property bool highlighted: primaryAction === DownloadPill.PrimaryAction.Pause + || primaryAction === DownloadPill.PrimaryAction.Resume + + readonly property string fileNameText: { + const name = download?.fileName ?? "" + if (!name) + return "" + if (fileNameLabel.width <= 0) + return name + // Fit by measured width (char-budget from "x" under-elides and paints into Cancel). + if (fileNameMetrics.advanceWidth(name) <= fileNameLabel.width) + return name + let lo = 4 + let hi = name.length + let best = DownloadFormatUtils.elideFileName(name, lo) + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2) + const candidate = DownloadFormatUtils.elideFileName(name, mid) + if (fileNameMetrics.advanceWidth(candidate) <= fileNameLabel.width) { + best = candidate + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return best + } + + /// Subtitle wording, one per state. InProgress, Requested and Paused all + /// show received/total — the Resume control already says "paused". + readonly property string statusText: { + if (!download) + return "" + if (root.missingFile) + return qsTr("Missing file") + const state = download.state + if (state === AbstractWebView.DownloadState.DownloadCompleted) + return "" + if (state === AbstractWebView.DownloadState.DownloadCancelled) + return qsTr("Cancelled") + if (state === AbstractWebView.DownloadState.DownloadInterrupted) + return qsTr("Interrupted") + if (state === AbstractWebView.DownloadState.DownloadInProgress + || state === AbstractWebView.DownloadState.DownloadRequested + || state === AbstractWebView.DownloadState.DownloadPaused + || download.isPaused) { + const sizeFormat = Locale.DataSizeTraditionalFormat + const received = download.receivedBytes ?? 0 + const total = download.totalBytes ?? 0 + if (total > 0) { + return "%1 / %2" + .arg(Qt.locale().formattedDataSize(received, 2, sizeFormat)) + .arg(Qt.locale().formattedDataSize(total, 2, sizeFormat)) + } + return Qt.locale().formattedDataSize(received, 2, sizeFormat) + } + return "" + } + + /// anchor is the ⋮ button itself — the menu right-aligns under (or over) it. + signal optionsButtonClicked(Item anchor) + signal primaryActionTriggered() + signal cancelTriggered() + signal itemClicked() + + objectName: "downloadPill" + + implicitHeight: 44 + implicitWidth: 227 + height: implicitHeight + color: root.pillColor + clip: true + + // FontMetrics.advanceWidth(text) is a method; TextMetrics.advanceWidth is a property. + FontMetrics { + id: fileNameMetrics + font: fileNameLabel.font + } + + function triggerPrimaryAction() { + if (!download) + return + if (primaryAction === DownloadPill.PrimaryAction.Pause && download.pause) + download.pause() + else if (primaryAction === DownloadPill.PrimaryAction.Resume && download.resume) + download.resume() + primaryActionTriggered() + } + + function triggerCancel() { + if (!download || !cancelButtonVisible) + return + if (download.cancel) + download.cancel() + cancelTriggered() + } + + MouseArea { + anchors.fill: parent + enabled: root.interactive + acceptedButtons: Qt.LeftButton + onClicked: root.itemClicked() + } + + RowLayout { + anchors.fill: parent + // Figma File download: 12px inset, 8px between Play/text and text/Cancel. + anchors.leftMargin: root.leadingMargin + anchors.rightMargin: root.trailingMargin + spacing: root.contentSpacing + + // Fixed leading slot — without a shared width, filenames jog left/right by state. + Item { + Layout.preferredWidth: root.leadingSlotSize + Layout.preferredHeight: root.leadingSlotSize + Layout.alignment: Qt.AlignVCenter + + StatusFlatRoundButton { + id: primaryBtn + objectName: "downloadPillPrimaryButton" + anchors.centerIn: parent + width: root.leadingSlotSize + height: root.leadingSlotSize + visible: root.pauseButtonVisible || root.resumeButtonVisible + icon.name: root.pauseButtonVisible ? "pause" : "play" + type: StatusFlatRoundButton.Type.Tertiary + onClicked: root.triggerPrimaryAction() + } + + StatusIcon { + anchors.centerIn: parent + width: 24 + height: 24 + visible: !primaryBtn.visible + icon: root.primaryAction === DownloadPill.PrimaryAction.Cancelled ? "downloads" : "file" + color: root.missingFile + || root.primaryAction === DownloadPill.PrimaryAction.Cancelled + ? Theme.palette.baseColor1 + : Theme.palette.directColor1 + opacity: root.primaryAction === DownloadPill.PrimaryAction.None ? 0.5 : 1 + } + } + + Item { + Layout.fillWidth: true + Layout.minimumWidth: 0 + Layout.preferredHeight: textColumn.implicitHeight + Layout.alignment: Qt.AlignVCenter + clip: true + + ColumnLayout { + id: textColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + StatusBaseText { + id: fileNameLabel + objectName: "downloadPillFileNameLabel" + Layout.fillWidth: true + text: root.fileNameText + elide: Text.ElideNone + maximumLineCount: 1 + font.pixelSize: root.nameFontSize + // Missing File follows the Record, not the surface. + font.strikeout: root.missingFile + color: root.missingFile ? Theme.palette.baseColor1 : Theme.palette.directColor1 + } + + StatusBaseText { + Layout.fillWidth: true + visible: root.statusText.length > 0 + text: root.statusText + elide: Text.ElideRight + maximumLineCount: 1 + font.pixelSize: root.statusFontSize + color: root.primaryAction === DownloadPill.PrimaryAction.Cancelled + ? Theme.palette.dangerColor1 + : Theme.palette.baseColor1 + } + } + + // Figma "Fade": soft edge so filename never meets Cancel. + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + width: 24 + visible: fileNameLabel.contentWidth > fileNameLabel.width - width + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: root.color } + } + } + } + + // Fixed trailing slot so Cancel vs ⋮ does not shift the text column. + Item { + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + Layout.alignment: Qt.AlignVCenter + + StatusFlatRoundButton { + id: cancelBtn + objectName: "downloadPillCancelButton" + anchors.centerIn: parent + width: 32 + height: 32 + visible: root.cancelButtonVisible + icon.name: "downloads-cancel" + type: StatusFlatRoundButton.Type.Tertiary + onClicked: root.triggerCancel() + } + + StatusFlatRoundButton { + id: optionsBtn + objectName: "downloadPillOptionsButton" + anchors.centerIn: parent + width: 32 + height: 32 + visible: root.optionsButtonVisible + icon.name: "more-v" + type: StatusFlatRoundButton.Type.Tertiary + onClicked: root.optionsButtonClicked(optionsBtn) + } + } + } +} diff --git a/ui/app/AppLayouts/Browser/controls/qmldir b/ui/app/AppLayouts/Browser/controls/qmldir index 3b4d378b55..2442619c43 100644 --- a/ui/app/AppLayouts/Browser/controls/qmldir +++ b/ui/app/AppLayouts/Browser/controls/qmldir @@ -1,4 +1,5 @@ BrowserAddressField 1.0 BrowserAddressField.qml DownloadElement 1.0 DownloadElement.qml +DownloadPill 1.0 DownloadPill.qml BrowserHeaderButton 1.0 BrowserHeaderButton.qml StatusBubble 1.0 StatusBubble.qml diff --git a/ui/app/AppLayouts/Browser/panels/DownloadPillStrip.qml b/ui/app/AppLayouts/Browser/panels/DownloadPillStrip.qml new file mode 100644 index 0000000000..e1ff00ff75 --- /dev/null +++ b/ui/app/AppLayouts/Browser/panels/DownloadPillStrip.qml @@ -0,0 +1,190 @@ +import QtQuick +import QtQuick.Layouts + +import StatusQ.Core +import StatusQ.Core.Theme +import StatusQ.Controls + +import AppLayouts.Browser.controls + +/** + * Download Pill strip (session-only — never fed from Download History). + * Mobile: under the address bar. Desktop: window footer. + * Newest pills insert at the left; existing pills animate right. Fixed width; + * overflow scrolls horizontally. + */ +Rectangle { + id: root + + property var downloadsModel: [] + + readonly property int pillWidth: 227 + + /// Both signals carry the Download Record — the one identity vocabulary + /// for a download; no strip-index space to translate. + signal openDownloadClicked(var record) + /// anchor is the pill's ⋮ button — the menu right-aligns under (or over) it. + signal optionsClicked(var record, Item anchor) + signal close() + + // Figma File download: pills sit flush on a tinted strip, no card gaps, no border. + color: Theme.palette.baseColor2 + implicitHeight: 44 + + onDownloadsModelChanged: d.syncFromDownloadsModel() + Component.onCompleted: d.syncFromDownloadsModel() + + QtObject { + id: d + + readonly property int shiftDurationMs: 220 + + /// Mirrors DownloadPill.highlighted for a neighbour the delegate can't reach. + /// Index-based and bounds-checked — delegates outlive removals from the model. + function isHighlightedAt(index) { + if (index < 0 || index >= stripListModel.count) + return false + const record = stripListModel.get(index).record + return !!record && !record.isTerminal + } + + /// Mirror the JS-array store model into a ListModel so insert(0) emits + /// rowsInserted + layout change — ListView can then run displaced animation. + /// Full array reassignment alone would reset the view with no shift. + function syncFromDownloadsModel() { + const next = root.downloadsModel || [] + const nextLen = next.length + + if (nextLen === 0) { + stripListModel.clear() + return + } + + // Prepend of one Record: new item at [0], previous strip is the tail. + if (stripListModel.count > 0 && nextLen === stripListModel.count + 1) { + let matches = true + for (let i = 0; i < stripListModel.count; ++i) { + if (stripListModel.get(i).record !== next[i + 1]) { + matches = false + break + } + } + if (matches) { + stripListModel.insert(0, { record: next[0] }) + listView.positionViewAtBeginning() + return + } + } + + // Single removal (dismiss / clear one pill). + if (nextLen === stripListModel.count - 1 && stripListModel.count > 0) { + for (let i = 0; i < stripListModel.count; ++i) { + const rec = stripListModel.get(i).record + let found = false + for (let j = 0; j < nextLen; ++j) { + if (next[j] === rec) { + found = true + break + } + } + if (!found) { + stripListModel.remove(i) + return + } + } + } + + // Fallback: rebuild without animation (initial bind / unexpected reorder). + stripListModel.clear() + for (let i = 0; i < nextLen; ++i) + stripListModel.append({ record: next[i] }) + listView.positionViewAtBeginning() + } + } + + ListModel { + id: stripListModel + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + ListView { + id: listView + objectName: "downloadPillListView" + + Layout.fillWidth: true + Layout.fillHeight: true + orientation: ListView.Horizontal + clip: true + spacing: 0 + boundsBehavior: Flickable.StopAtBounds + model: stripListModel + + add: Transition { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: d.shiftDurationMs + easing.type: Easing.OutCubic + } + } + + displaced: Transition { + NumberAnimation { + property: "x" + duration: d.shiftDurationMs + easing.type: Easing.OutCubic + } + } + + removeDisplaced: Transition { + NumberAnimation { + property: "x" + duration: d.shiftDurationMs + easing.type: Easing.OutCubic + } + } + + delegate: DownloadPill { + id: pill + + required property var record + required property int index + + download: record + width: root.pillWidth + height: ListView.view.height + + onItemClicked: root.openDownloadClicked(pill.record) + onOptionsButtonClicked: function (anchor) { + root.optionsClicked(pill.record, anchor) + } + + // Figma divider: only between two blended (terminal) pills — a + // highlighted neighbour already separates them with its own card. + Rectangle { + width: 1 + height: 16 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + color: Theme.palette.baseColor1 + visible: pill.index > 0 && !pill.highlighted + && !d.isHighlightedAt(pill.index - 1) + } + } + } + + StatusFlatRoundButton { + id: closeBtn + objectName: "downloadPillStripClose" + Layout.preferredWidth: 48 + Layout.fillHeight: true + icon.name: "close" + type: StatusFlatRoundButton.Type.Quaternary + onClicked: root.close() + } + } +} diff --git a/ui/app/AppLayouts/Browser/panels/DownloadsListView.qml b/ui/app/AppLayouts/Browser/panels/DownloadsListView.qml new file mode 100644 index 0000000000..c761b5578d --- /dev/null +++ b/ui/app/AppLayouts/Browser/panels/DownloadsListView.qml @@ -0,0 +1,89 @@ +import QtQuick +import QtQuick.Controls + +import StatusQ +import StatusQ.Core +import StatusQ.Core.Theme + +import AppLayouts.Browser.controls + +/** + * Downloads List for the tabs/bookmarks overview. + * Newest Records first. Each row is the shared DownloadPill delegate rendered + * flat — the state-to-controls matrix, wording, eliding and Missing File + * presentation all live in the pill. + */ +Item { + id: root + + property var downloadsModel: [] + + /// Both signals carry the Download Record — the one identity vocabulary + /// for a download; no index space to translate. + signal openDownloadClicked(var record) + /// anchor is the row's ⋮ button — the menu right-aligns under it. + signal optionsClicked(var record, Item anchor) + + // Length, not Array.isArray: createObject converts JS arrays so isArray fails + // while .length still reports Records. + readonly property int _count: downloadsModel && downloadsModel.length !== undefined + ? downloadsModel.length : 0 + + StatusListView { + id: listView + objectName: "downloadsListView" + anchors.fill: parent + model: root.downloadsModel + spacing: Theme.halfPadding + clip: true + + delegate: ItemDelegate { + id: row + required property var modelData + required property int index + + readonly property var record: modelData + + width: ListView.view.width + height: 56 + padding: Theme.halfPadding + + background: Rectangle { + radius: Theme.radius + color: row.hovered ? Theme.palette.primaryColor3 : StatusColors.transparent + } + + contentItem: DownloadPill { + download: row.record + + // Flat-row chrome: the surrounding delegate owns hover and click. + interactive: false + pillColor: StatusColors.transparent + leadingSlotSize: 32 + contentSpacing: Theme.padding + leadingMargin: 0 + trailingMargin: 0 + nameFontSize: Theme.fontSize(14) + statusFontSize: Theme.fontSize(12) + + onOptionsButtonClicked: function (anchor) { + root.optionsClicked(row.record, anchor) + } + } + + onClicked: root.openDownloadClicked(row.record) + + HoverHandler { + cursorShape: hovered ? Qt.PointingHandCursor : undefined + } + } + } + + StatusBaseText { + objectName: "downloadsListEmptyLabel" + visible: root._count === 0 + anchors.centerIn: parent + text: qsTr("Downloaded files will appear here.") + color: Theme.palette.secondaryText + } +} diff --git a/ui/app/AppLayouts/Browser/panels/qmldir b/ui/app/AppLayouts/Browser/panels/qmldir index 92081575b4..021c3a4dbf 100644 --- a/ui/app/AppLayouts/Browser/panels/qmldir +++ b/ui/app/AppLayouts/Browser/panels/qmldir @@ -4,6 +4,8 @@ FavoritesBar 1.0 FavoritesBar.qml BrowserLandscapeToolbar 1.0 BrowserLandscapeToolbar.qml BrowserPortraitToolbar 1.0 BrowserPortraitToolbar.qml DownloadBar 1.0 DownloadBar.qml +DownloadPillStrip 1.0 DownloadPillStrip.qml +DownloadsListView 1.0 DownloadsListView.qml BrowserTabView 1.0 BrowserTabView.qml FindBar 1.0 FindBar.qml MobileAddressBar 1.0 MobileAddressBar.qml diff --git a/ui/app/AppLayouts/Browser/popups/BrowserLinkContextMenu.qml b/ui/app/AppLayouts/Browser/popups/BrowserLinkContextMenu.qml new file mode 100644 index 0000000000..f6cd75c7d2 --- /dev/null +++ b/ui/app/AppLayouts/Browser/popups/BrowserLinkContextMenu.qml @@ -0,0 +1,73 @@ +import QtQuick + +import StatusQ.Popups +import StatusQ.Core.Utils as SQUtils + +/** + * Long-press menu for a link and/or image in the mobile browser (ADR 0005 + * "save link"). Fed by AbstractWebView.linkLongPressed; either URL may be + * empty, never both. Desktop WebEngine keeps its own context menu. + */ +StatusMenu { + id: root + + property url linkUrl + property url imageUrl + + /// Host Web View (Backend) that raised the long-press; the host routes + /// Download link through it (ADR 0005). Set by openAt. + property var hostView: null + + /// Open at a view-local touch point: `position` is relative to + /// `parentItem` (the host view fills it). + function openAt(linkUrl, imageUrl, position, parentItem, hostView) { + root.linkUrl = linkUrl + root.imageUrl = imageUrl + root.hostView = hostView + root.parent = parentItem + root.x = position.x + root.y = position.y + root.open() + } + + readonly property bool hasLink: linkUrl.toString() !== "" + readonly property bool hasImage: imageUrl.toString() !== "" + + // Mobile: system share sheet. Desktop (unused today): copy. + readonly property string shareLabel: SQUtils.Utils.isMobile ? qsTr("Share link") : qsTr("Copy link") + readonly property string shareIcon: SQUtils.Utils.isMobile + ? (SQUtils.Utils.isIOS ? "share-ios" : "share-android") + : "copy" + + signal openInNewTabRequested(url targetUrl) + signal shareUrlRequested(url targetUrl) + signal downloadRequested(url targetUrl) + + StatusAction { + enabled: root.hasLink + icon.name: "browser" + text: qsTr("Open in new tab") + onTriggered: root.openInNewTabRequested(root.linkUrl) + } + StatusAction { + enabled: root.hasLink + icon.name: root.shareIcon + text: root.shareLabel + onTriggered: root.shareUrlRequested(root.linkUrl) + } + StatusAction { + enabled: root.hasLink + icon.name: "download" + text: qsTr("Download link") + onTriggered: root.downloadRequested(root.linkUrl) + } + StatusMenuSeparator { + visible: root.hasLink && root.hasImage + } + StatusAction { + enabled: root.hasImage + icon.name: "image" + text: qsTr("Download image") + onTriggered: root.downloadRequested(root.imageUrl) + } +} diff --git a/ui/app/AppLayouts/Browser/popups/DownloadRecordMenu.qml b/ui/app/AppLayouts/Browser/popups/DownloadRecordMenu.qml new file mode 100644 index 0000000000..c5f7a6cecb --- /dev/null +++ b/ui/app/AppLayouts/Browser/popups/DownloadRecordMenu.qml @@ -0,0 +1,153 @@ +import QtQuick + +import StatusQ.Popups +import StatusQ.Core.Utils as SQUtils + +import AppLayouts.Browser.adapters + +/** + * One download menu for Download Pill and Downloads List. + * Identity is the Download Record; enablement comes from one `capabilities` + * object (BrowserDownloadsContext.capabilitiesFor) BOUND at the call site, so + * the menu can never show stale capabilities. State-derived + * booleans stay internal. Share vs Copy labels via capabilities.useShareLabels. + * StatusAction has no visible; StatusMenu hides disabled items by default. + */ +StatusMenu { + id: root + + property var record: null + + /// Pill strip opens grant session Dismiss; list opens do not. + /// Set by openAnchored options; read by the host's capabilities binding. + property bool forStrip: false + + /// Open right-aligned with the ⋮ `anchor` it was invoked from; + /// options.above opens upward, options.forStrip marks a pill-strip open. + /// x/y stay bound: the menu's width is 0 until content is first laid out. + function openAnchored(record, anchor, options) { + root.forStrip = !!(options && options.forStrip) + root.record = record + root.parent = anchor + root.x = Qt.binding(() => anchor.width - root.width) + root.y = Qt.binding(() => (options && options.above) ? -root.height : anchor.height) + root.open() + } + + /// { openInBrowser, shareFile, shareUrl, showInFolder, retry, dismiss, + /// downloadsEntry, useShareLabels } — bind it: + /// capabilities: ctx.capabilitiesFor(record, …) + property var capabilities: null + + readonly property var _caps: root.capabilities ?? ({}) + + // Mobile: Share file / Share URL. Desktop: Copy file path / Copy URL. + readonly property bool _useShareLabels: !!_caps.useShareLabels + + readonly property bool isCancelled: record?.state === AbstractWebView.DownloadState.DownloadCancelled ?? false + readonly property bool isComplete: record?.state === AbstractWebView.DownloadState.DownloadCompleted ?? false + readonly property bool isInterrupted: record?.state === AbstractWebView.DownloadState.DownloadInterrupted ?? false + readonly property bool isMissing: !!(record && record.missingFile) + readonly property bool isPaused: { + if (!record) + return false + return record.isPaused + || record.state === AbstractWebView.DownloadState.DownloadPaused + } + readonly property bool isActiveTransfer: { + if (!record || isComplete || isCancelled || isInterrupted) + return false + return record.state === AbstractWebView.DownloadState.DownloadInProgress + || record.state === AbstractWebView.DownloadState.DownloadRequested + || isPaused + } + + readonly property string shareFileLabel: _useShareLabels ? qsTr("Share file") : qsTr("Copy file path") + readonly property string shareUrlLabel: _useShareLabels ? qsTr("Share URL") : qsTr("Copy URL") + readonly property string shareFileIcon: _useShareLabels + ? (SQUtils.Utils.isIOS ? "share-ios" : "share-android") + : "copy" + readonly property string shareUrlIcon: _useShareLabels + ? (SQUtils.Utils.isIOS ? "share-ios" : "share-android") + : "copy" + + // Plain signals — callers already hold the menu's Record. + signal downloadsRequested() + signal showInFolderRequested() + signal shareFileRequested() + signal shareUrlRequested() + signal openInBrowserRequested() + signal retryRequested() + signal dismissRequested() + + StatusAction { + // Pill strip only (Figma pill menu): opens the Downloads List section + // of the Open tabs overview. Absent in list menus — you are already there. + enabled: !!root._caps.downloadsEntry + icon.name: "download" + text: qsTr("Downloads") + onTriggered: root.downloadsRequested() + } + StatusMenuSeparator { + visible: !!root._caps.downloadsEntry + } + StatusAction { + enabled: isActiveTransfer && !isPaused + icon.name: "pause" + text: qsTr("Pause") + onTriggered: root.record.pause() + } + StatusAction { + enabled: isActiveTransfer && isPaused + icon.name: "play" + text: qsTr("Resume") + onTriggered: root.record.resume() + } + StatusAction { + enabled: isComplete && !!root._caps.openInBrowser + icon.name: "browser" + text: qsTr("Open in Browser") + onTriggered: root.openInBrowserRequested() + } + StatusAction { + enabled: isComplete && !!root._caps.shareFile + icon.name: root.shareFileIcon + text: root.shareFileLabel + onTriggered: root.shareFileRequested() + } + StatusAction { + enabled: (isComplete || isInterrupted || isCancelled) && !!root._caps.shareUrl + icon.name: root.shareUrlIcon + text: root.shareUrlLabel + onTriggered: root.shareUrlRequested() + } + StatusAction { + enabled: isComplete && !!root._caps.showInFolder + icon.name: "show" + text: qsTr("Show in folder") + onTriggered: root.showInFolderRequested() + } + StatusAction { + enabled: !!root._caps.retry + icon.name: "refresh" + text: qsTr("Retry") + onTriggered: root.retryRequested() + } + StatusMenuSeparator { + visible: isActiveTransfer || (!!root._caps.dismiss && (isComplete || isCancelled)) + } + StatusAction { + enabled: isActiveTransfer + type: StatusAction.Type.Danger + icon.name: "downloads-cancel" + text: qsTr("Cancel") + onTriggered: root.record.cancel() + } + StatusAction { + // Pill strip only: remove Completed/Cancelled from the session strip. + enabled: !!root._caps.dismiss && (isComplete || isCancelled) + icon.name: "close" + text: qsTr("Dismiss") + onTriggered: root.dismissRequested() + } +} diff --git a/ui/app/AppLayouts/Browser/popups/TabsBookmarksOverviewModal.qml b/ui/app/AppLayouts/Browser/popups/TabsBookmarksOverviewModal.qml index bd668e753b..a4b5f64e0c 100644 --- a/ui/app/AppLayouts/Browser/popups/TabsBookmarksOverviewModal.qml +++ b/ui/app/AppLayouts/Browser/popups/TabsBookmarksOverviewModal.qml @@ -16,12 +16,15 @@ import shared.controls import SortFilterProxyModel +import AppLayouts.Browser.panels + StatusDialog { id: root enum Mode { OpenTabs, - Bookmarks + Bookmarks, + Downloads } property int initialMode: TabsBookmarksOverviewModal.Mode.OpenTabs @@ -42,7 +45,14 @@ StatusDialog { signal deleteBookmarkRequested(string url) signal bookmarkClicked(string url) - title: mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.OpenTabs ? qsTr("Open tabs") : qsTr("Bookmarks") + // downloads + property var downloadsModel: [] + + // Carries the Download Record — the one identity vocabulary. + signal downloadClicked(var record) + signal downloadOptionsClicked(var record, Item anchor) + + title: d.titleText destroyOnClose: true fillHeightOnBottomSheet: true width: 560 @@ -54,6 +64,14 @@ StatusDialog { QtObject { id: d + readonly property string titleText: { + if (mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.Bookmarks) + return qsTr("Bookmarks") + if (mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.Downloads) + return qsTr("Downloads") + return qsTr("Open tabs") + } + // Tabs Overview readonly property int cardWidth: 162 readonly property int cardHeight: 200 @@ -78,7 +96,7 @@ StatusDialog { filters: SQUtils.SearchFilter { roleName: "title" searchPhrase: searchField.text - enabled: searchField.visible + enabled: searchField.visible && mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.OpenTabs } } @@ -89,7 +107,7 @@ StatusDialog { SQUtils.SearchFilter { roleName: "name" searchPhrase: searchField.text - enabled: searchField.visible + enabled: searchField.visible && mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.Bookmarks }, ValueFilter { roleName: "url" @@ -107,7 +125,7 @@ StatusDialog { id: searchField Layout.fillWidth: true - visible: searchButton.checked + visible: searchButton.checked && mainTabBar.currentIndex !== TabsBookmarksOverviewModal.Mode.Downloads onVisibleChanged: clear() placeholderText: mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.OpenTabs ? qsTr("Search in open tabs") @@ -280,6 +298,15 @@ StatusDialog { } } } + + DownloadsListView { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(root.availableHeight, 400) + Layout.fillHeight: true + downloadsModel: root.downloadsModel + onOpenDownloadClicked: record => root.downloadClicked(record) + onOptionsClicked: (record, anchor) => root.downloadOptionsClicked(record, anchor) + } } } @@ -304,6 +331,9 @@ StatusDialog { CustomSwitchButton { icon.name: "bookmark" } + CustomSwitchButton { + icon.name: "download" + } } } rightButtons: ObjectModel { @@ -313,6 +343,7 @@ StatusDialog { icon.width: d.iconSize icon.height: d.iconSize checkable: true + visible: mainTabBar.currentIndex !== TabsBookmarksOverviewModal.Mode.Downloads tooltip.text: qsTr("Search") onToggled: searchField.focus = checked } @@ -320,6 +351,7 @@ StatusDialog { icon.name: "add" icon.width: d.iconSize icon.height: d.iconSize + visible: mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.OpenTabs tooltip.text: qsTr("Add") onClicked: { root.addTabRequested() diff --git a/ui/app/AppLayouts/Browser/popups/qmldir b/ui/app/AppLayouts/Browser/popups/qmldir index 4ec02f907e..2217997d23 100644 --- a/ui/app/AppLayouts/Browser/popups/qmldir +++ b/ui/app/AppLayouts/Browser/popups/qmldir @@ -4,5 +4,7 @@ AddFavoriteModal 1.0 AddFavoriteModal.qml FavoriteMenu 1.0 FavoriteMenu.qml BrowserWalletMenu 1.0 BrowserWalletMenu.qml DownloadMenu 1.0 DownloadMenu.qml +DownloadRecordMenu 1.0 DownloadRecordMenu.qml +BrowserLinkContextMenu 1.0 BrowserLinkContextMenu.qml MobileSettingsMenu 1.0 MobileSettingsMenu.qml TabsBookmarksOverviewModal 1.0 TabsBookmarksOverviewModal.qml diff --git a/ui/app/AppLayouts/Browser/stores/DownloadsStore.qml b/ui/app/AppLayouts/Browser/stores/DownloadsStore.qml index 35d9e20b16..5d1b5952bd 100644 --- a/ui/app/AppLayouts/Browser/stores/DownloadsStore.qml +++ b/ui/app/AppLayouts/Browser/stores/DownloadsStore.qml @@ -310,8 +310,8 @@ QtObject { return true } - // Formatting (elideFileName) lives in - // webview/DownloadFormatUtils.js — pure functions, imported by the pill. + // Formatting lives with the pill: elideFileName in + // webview/DownloadFormatUtils.js, the status wording in DownloadPill.qml. /// Sanitize a suggested file name and resolve a free Download Target under downloadsDirectory. /// Collisions with existing files or in-session Records get "(1)", "(2)", … suffixes.