mirror of
https://github.com/status-im/status-app.git
synced 2026-08-27 15:11:20 +00:00
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:
@@ -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 |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user