feat(browser): downloads UI — pill, strip, list, and Record menus

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.
This commit is contained in:
Andrey Bocharnikov
2026-08-13 09:42:17 +04:00
parent def06d98c3
commit 69cb6f9bdb
17 changed files with 1787 additions and 7 deletions
@@ -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"])
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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")
}
}
}
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="#000">
<path d="M20.2764 19.6035C20.6626 19.6039 20.9756 19.9173 20.9756 20.3037C20.9756 20.6901 20.6626 21.0035 20.2764 21.0039H3.72461C3.33801 21.0039 3.02441 20.6903 3.02441 20.3037C3.02441 19.9171 3.33801 19.6035 3.72461 19.6035H20.2764ZM12 3C12.3866 3 12.7002 3.31457 12.7002 3.70117V15.5684L17.8242 11.1514C18.117 10.8993 18.5592 10.932 18.8115 11.2246C19.0636 11.5173 19.0308 11.9595 18.7383 12.2119L12.457 17.627C12.1944 17.8534 11.8056 17.8533 11.543 17.627L5.2627 12.2119C4.96995 11.9595 4.93711 11.5174 5.18945 11.2246C5.4419 10.932 5.88401 10.8991 6.17676 11.1514L11.2998 15.5684V3.70117C11.2998 3.31457 11.6134 3 12 3Z"/>
<path d="M5.06066 4.00001C4.76777 4.2929 4.76777 4.76778 5.06066 5.06067L18.9393 18.9394C19.2322 19.2322 19.7071 19.2322 20 18.9394C20.2929 18.6465 20.2929 18.1716 20 17.8787L6.12132 4.00001C5.82843 3.70712 5.35355 3.70712 5.06066 4.00001Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 995 B

@@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g fill="#000"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></g></svg>

After

Width:  |  Height:  |  Size: 213 B

+2
View File
@@ -136,6 +136,7 @@
<file>icons/dots-icon.svg</file>
<file>icons/double-checkmark.svg</file>
<file>icons/download.svg</file>
<file>icons/downloads-cancel.svg</file>
<file>icons/downloads.svg</file>
<file>icons/edit.svg</file>
<file>icons/edit_pencil.svg</file>
@@ -194,6 +195,7 @@
<file>icons/mobile-sync-off.svg</file>
<file>icons/mobile-sync.svg</file>
<file>icons/mobile.svg</file>
<file>icons/more-v.svg</file>
<file>icons/more.svg</file>
<file>icons/muted.svg</file>
<file>icons/network.svg</file>
@@ -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)
}
}
}
}
@@ -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
@@ -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()
}
}
}
@@ -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
}
}
+2
View File
@@ -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
@@ -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)
}
}
@@ -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()
}
}
@@ -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()
+2
View File
@@ -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
@@ -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.