Warning, /utilities/filelight/src/qml/MapPage.qml is written in an unsupported language. File is not indexed.
0001 // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0002 // SPDX-FileCopyrightText: 2022 Harald Sitter <sitter@kde.org>
0003
0004 import QtQuick 2.15
0005 import QtQuick.Layouts 1.15
0006 import QtQuick.Controls 2.15 as QQC2
0007 import org.kde.kirigami 2.19 as Kirigami
0008 import org.kde.kirigami.delegates as KD
0009 import org.kde.coreaddons 1.0 as KCoreAddons
0010 import QtQuick.Shapes 1.15
0011
0012 import org.kde.filelight 1.0
0013
0014 Kirigami.Page {
0015 id: page
0016
0017 property url url: RadialMap.rootUrl
0018 property QQC2.Menu contextMenu
0019
0020 enabled: !ContextMenuContext.deleting
0021 topPadding: 0
0022 leftPadding: 0
0023 rightPadding: 0
0024 bottomPadding: 0
0025
0026 Kirigami.Action {
0027 id: goToOverviewAction
0028 enabled: page.state === ""
0029 icon.name: "go-home"
0030 text: i18nc("@action", "Go to Overview")
0031 onTriggered: pageStack.currentIndex = 0
0032 }
0033
0034 Kirigami.Action {
0035 id: goUpAction
0036 enabled: page.state === ""
0037 icon.name: "go-up"
0038 text: i18nc("@action", "Up")
0039 onTriggered: appWindow.slotUp()
0040 shortcut: "Alt+Up"
0041 }
0042
0043 Kirigami.Action {
0044 id: rescanAction
0045 enabled: page.state !== "scanning"
0046 icon.name: "view-refresh"
0047 text: i18nc("@action", "Rescan")
0048 onTriggered: appWindow.rescan()
0049 shortcut: StandardKey.Refresh
0050 }
0051
0052 Kirigami.Action {
0053 id: stopAction
0054 enabled: page.state === "scanning"
0055 icon.name: "process-stop"
0056 text: i18nc("@action", "Stop")
0057 onTriggered: appWindow.closeURL()
0058 }
0059
0060 Kirigami.Action {
0061 id: zoomInAction
0062 enabled: page.state === ""
0063 icon.name: "zoom-in"
0064 text: i18nc("@action", "Zoom In")
0065 displayHint: Kirigami.DisplayHint.AlwaysHide
0066 onTriggered: RadialMap.zoomIn()
0067 shortcut: StandardKey.ZoomIn
0068 }
0069
0070 Kirigami.Action {
0071 id: zoomOutAction
0072 enabled: page.state === ""
0073 icon.name: "zoom-out"
0074 text: i18nc("@action", "Zoom Out")
0075 displayHint: Kirigami.DisplayHint.AlwaysHide
0076 onTriggered: RadialMap.zoomOut()
0077 shortcut: StandardKey.ZoomOut
0078 }
0079
0080 actions: MainContext.historyActions.concat([
0081 goUpAction,
0082 goToOverviewAction,
0083 rescanAction,
0084 stopAction,
0085 zoomInAction,
0086 zoomOutAction,
0087 configureAction,
0088 helpAction,
0089 aboutAction
0090 ])
0091
0092 Component {
0093 id: contextMenuComponent
0094 QQC2.Menu {
0095 id: contextMenu
0096
0097 required property Segment segment
0098
0099 title: segment.displayName()
0100
0101 onAboutToShow: page.contextMenu = this
0102 onAboutToHide: page.contextMenu = null
0103
0104 QQC2.MenuItem {
0105 action: Kirigami.Action {
0106 icon.name: "document-open"
0107 text: i18nc("@action Open file or directory from context menu", "Open")
0108 }
0109 onTriggered: Qt.openUrlExternally(contextMenu.segment.url())
0110 }
0111 QQC2.MenuItem {
0112 visible: contextMenu.segment.isFolder() && contextMenu.segment.url().toString().startsWith("file:")
0113 action: Kirigami.Action {
0114 icon.name: "utilities-terminal"
0115 text: i18nc("@action", "Open Terminal Here")
0116 onTriggered: ContextMenuContext.openTerminal(contextMenu.segment.url())
0117 }
0118 }
0119 QQC2.MenuItem {
0120 visible: contextMenu.segment.isFolder()
0121 action: Kirigami.Action {
0122 icon.name: "zoom-in"
0123 text: i18nc("@action focuses the filelight view on a given map segment", "Center Map Here")
0124 onTriggered: {
0125 MainContext.updateURL(contextMenu.segment.url())
0126 MainContext.openUrl(contextMenu.segment.url())
0127 }
0128 }
0129 }
0130 QQC2.MenuItem {
0131 visible: contextMenu.segment.isFolder()
0132 action: Kirigami.Action {
0133 icon.name: "list-remove"
0134 text: i18nc("@action", "Add to Do Not Scan List")
0135 onTriggered: ContextMenuContext.doNotScan(contextMenu.segment.url())
0136 }
0137 }
0138 QQC2.MenuItem {
0139 visible: contextMenu.segment.isFolder()
0140 action: Kirigami.Action {
0141 icon.name: "view-refresh"
0142 text: i18nc("@action rescan filelight map", "Rescan")
0143 onTriggered: MainContext.rescanSingleDir(contextMenu.segment.url())
0144 }
0145 }
0146 QQC2.MenuItem {
0147 action: Kirigami.Action {
0148 icon.name: "edit-copy"
0149 text: i18nc("@action", "Copy to clipboard")
0150 onTriggered: ContextMenuContext.copyClipboard(contextMenu.segment.displayPath())
0151 }
0152 }
0153 QQC2.MenuSeparator {}
0154 QQC2.MenuItem {
0155 action: Kirigami.Action {
0156 icon.name: "edit-delete"
0157 text: i18nc("@action delete file or folder", "Delete")
0158 onTriggered: ContextMenuContext.deleteFileFromSegment(contextMenu.segment)
0159 }
0160 }
0161 }
0162 }
0163
0164 property real mouseyX: -1
0165 property real mouseyY: -1
0166 property var hoveredSegment: undefined
0167 property bool hoveringListItem: false
0168
0169 RowLayout {
0170 anchors.fill: parent
0171 spacing: 0
0172 visible: page.state === ""
0173
0174 QQC2.ScrollView {
0175 implicitWidth: Kirigami.Units.gridUnit * 10
0176 Layout.maximumWidth: Kirigami.Units.gridUnit * 22
0177 Layout.fillWidth: true
0178 Layout.fillHeight: true
0179 Kirigami.Theme.colorSet: Kirigami.Theme.View
0180 Kirigami.Theme.inherit: false
0181
0182 // flush against both the toolbar and the window edge. without this we get a framed rectangle
0183 background: Rectangle {
0184 color: Kirigami.Theme.backgroundColor
0185 }
0186 Component.onCompleted: background.visible = true
0187
0188 ListView {
0189 id: listview
0190 clip: true
0191 model: visible ? FileModel : undefined
0192 Component.onCompleted: currentIndex = -1
0193 reuseItems: true
0194 focus: true
0195 activeFocusOnTab: true
0196 keyNavigationEnabled: true
0197 keyNavigationWraps: true
0198 delegate: KD.SubtitleDelegate {
0199 width: listview.width
0200 icon.name: ROLE_IsFolder ? "folder" : "file" // TODO mimetype?
0201 text: model.display
0202 subtitle: ROLE_HumanReadableSize
0203 hoverEnabled: true
0204 highlighted: {
0205 if (hoveringListItem) {
0206 return hovered
0207 }
0208 return (hoveredSegment === ROLE_Segment) || (hoveredSegment === "fake" && ROLE_Segment === "")
0209 }
0210 onHoveredChanged: {
0211 if (hovered) {
0212 hoveringListItem = true
0213 hoveredSegment = ROLE_Segment !== "" ? ROLE_Segment : "fake"
0214 }
0215 }
0216 onClicked: {
0217 if (ROLE_IsFolder) {
0218 MainContext.updateURL(ROLE_URL)
0219 MainContext.openUrl(ROLE_URL)
0220 }
0221 }
0222
0223 QQC2.Menu {
0224 id: contextMenu
0225
0226 QQC2.MenuItem {
0227 action: Kirigami.Action {
0228 icon.name: "document-open"
0229 text: i18nc("@action Open file or directory from context menu", "Open")
0230 }
0231 onTriggered: Qt.openUrlExternally(ROLE_URL)
0232 }
0233 QQC2.MenuItem {
0234 visible: ROLE_IsFolder && ROLE_URL.toString().startsWith("file:")
0235 action: Kirigami.Action {
0236 icon.name: "utilities-terminal"
0237 text: i18nc("@action", "Open Terminal Here")
0238 onTriggered: ContextMenuContext.openTerminal(ROLE_URL)
0239 }
0240 }
0241 QQC2.MenuItem {
0242 visible: ROLE_IsFolder
0243 action: Kirigami.Action {
0244 icon.name: "list-remove"
0245 text: i18nc("@action", "Add to Do Not Scan List")
0246 onTriggered: ContextMenuContext.doNotScan(ROLE_URL)
0247 }
0248 }
0249 QQC2.MenuItem {
0250 action: Kirigami.Action {
0251 icon.name: "edit-copy"
0252 text: i18nc("@action", "Copy to clipboard")
0253 onTriggered: ContextMenuContext.copyClipboard(ROLE_URL)
0254 }
0255 }
0256 QQC2.MenuSeparator {}
0257 QQC2.MenuItem {
0258 action: Kirigami.Action {
0259 icon.name: "edit-delete"
0260 text: i18nc("@action delete file or folder", "Delete")
0261 onTriggered: ContextMenuContext.deleteFile(FileModel.file(index))
0262 }
0263 }
0264 }
0265
0266 TapHandler {
0267 acceptedButtons: Qt.RightButton
0268 onTapped: contextMenu.popup()
0269 }
0270 }
0271 }
0272 }
0273
0274 Kirigami.Separator {
0275 Layout.fillHeight: true
0276 }
0277
0278 Item {
0279 id: shapeItem
0280 Layout.fillWidth: true
0281 Layout.fillHeight: true
0282 Layout.minimumHeight: Kirigami.Units.gridUnit * 10
0283 Layout.minimumWidth: Kirigami.Units.gridUnit * 10
0284 Layout.margins: Kirigami.Units.gridUnit
0285 antialiasing: true
0286 layer.enabled: true
0287 layer.samples: 32
0288 layer.smooth: true
0289
0290 property var zOrderedShapes: []
0291 property bool hasShapes: zOrderedShapes.length > 0
0292
0293 function objectToArray(object) {
0294 let newArray = [];
0295 for (let i in object) {
0296 newArray.push(object[i]);
0297 }
0298 return newArray;
0299 }
0300
0301 onChildrenChanged: {
0302 let children = objectToArray(shapeItem.children)
0303 children = children.filter(child => child instanceof Shape);
0304 children.sort((a, b) => b.z - a.z); // sort by level so the higher level object (the visible one) outscores ones it paints over
0305 zOrderedShapes = children
0306 }
0307
0308 function tooltip({ path, size, isFolder, files, totalFiles, isRoot = false }) {
0309 let tips = [path, size]
0310 if (isFolder) {
0311 const percent = Math.floor((100 * files) / totalFiles)
0312 if (percent > 0) {
0313 tips.push(i18ncp("Tooltip of folder, %1 is number of files", "%1 File (%2%)", "%1 Files (%2%)", files, percent))
0314 } else {
0315 tips.push(i18ncp("Tooltip of folder, %1 is number of files", "%1 File", "%1 Files", files))
0316 }
0317 }
0318 if (isRoot) {
0319 tips.push(i18nc("part of tooltip indicating that the item under the mouse is clickable", "Click to go up to parent directory"))
0320 }
0321 return tips.join("\n")
0322 }
0323
0324 Instantiator {
0325 id: instantiator
0326 model: shapeItem.visible ? RadialMap.signature : undefined
0327 active: true
0328 asynchronous: true // arguable
0329
0330 Instantiator {
0331 // FIXME weird
0332 Component.onCompleted: idx = index // lock index by breaking the binding
0333 property int idx: index
0334 readonly property real signatureRadius: {
0335 if (shapeItem.width > shapeItem.height) {
0336 return shapeItem.height / 2 / (instantiator.model.length + 1)
0337 }
0338 return shapeItem.width / 2 / (instantiator.model.length + 1)
0339 }
0340 readonly property real shapeRadius: signatureRadius * (idx + 2)
0341
0342 active: true
0343 model: modelData
0344 onObjectAdded: shapeItem.children.push(object)
0345
0346 delegate: SegmentShape {
0347 // Qt doc: Note: model, index, and modelData roles are not accessible if the delegate contains required properties, unless it has also required properties with matching names.
0348 required property var modelData
0349
0350 readonly property bool segmentHover: hoveredSegment === segment.uuid || (segment.fake && hoveredSegment === "fake")
0351
0352 z: (instantiator.model.length - idx) // reverse order such that more central levels are above. this gives us segment appearance without having to actually paint segments (instead we stack full cicles)
0353 segment: modelData
0354 item: shapeItem
0355 radius: shapeRadius
0356 startAngle: -(modelData.start() / 16)
0357 sweepAngle: -(modelData.length() / 16)
0358 tooltipText: shapeItem.tooltip({
0359 isFolder: segment.isFolder(),
0360 path: segment.displayPath(),
0361 size: segment.humanReadableSize(),
0362 files: segment.files(),
0363 totalFiles: RadialMap.numberOfChildren,
0364 })
0365 showTooltip: !contextMenu && !hoveringListItem && segmentHover && shapeItem.visible
0366 fillColor: segmentHover ? Qt.darker(segment.color) : segment.color
0367 }
0368 }
0369 }
0370
0371 CenterShape {
0372 id: centerShape
0373 z: 500 // on top of everything, arbitrary high number of shapes
0374 visible: shapeItem.hasShapes
0375 segment: RadialMap.rootSegment
0376 segmentUuid: "root"
0377 item: shapeItem
0378 radius: {
0379 if (instantiator.model === undefined) {
0380 return 0
0381 }
0382 if (shapeItem.width > shapeItem.height) {
0383 return shapeItem.height / 2 / (instantiator.model.length + 1)
0384 }
0385 return shapeItem.width / 2 / (instantiator.model.length + 1)
0386 }
0387 startAngle: 0
0388 sweepAngle: 360
0389 tooltipText: shapeItem.tooltip({
0390 isFolder: true,
0391 path: RadialMap.displayPath,
0392 size: RadialMap.overallSize,
0393 files: RadialMap.numberOfChildren,
0394 totalFiles: RadialMap.numberOfChildren,
0395 isRoot: true,
0396 })
0397 showTooltip: !contextMenu && !hoveringListItem && hoveredSegment === segmentUuid && shapeItem.visible
0398 Kirigami.Theme.colorSet: Kirigami.Theme.View
0399 fillColor: Kirigami.Theme.backgroundColor
0400 }
0401
0402 QQC2.Label {
0403 id: centerLabel
0404 z: 501
0405 visible: centerShape.visible
0406 text: RadialMap.overallSize
0407 Kirigami.Theme.colorSet: Kirigami.Theme.View
0408 color: Kirigami.Theme.textColor
0409 horizontalAlignment: Text.AlignHCenter
0410 // The diagonal of the circle is the hypotenuse of the largest square
0411 readonly property var dimension: (2 * centerShape.radius) / Math.sqrt(2)
0412 width: dimension
0413 height: dimension
0414 anchors.centerIn: parent
0415
0416 // Let the text scale way down but lock the maximum at the actual default font height.
0417 // This ensures that the text neatly fits into our dimensions.
0418 fontSizeMode: Text.Fit
0419 minimumPixelSize: 2
0420 Component.onCompleted: font.pixelSize = font.pixelSize
0421 }
0422 }
0423 }
0424
0425 MouseArea {
0426 x: shapeItem.x
0427 y: shapeItem.y
0428 z: 502
0429 width: shapeItem.width
0430 height: shapeItem.height
0431 hoverEnabled: true
0432 acceptedButtons: Qt.LeftButton | Qt.RightButton
0433
0434 function findTarget(mouse) {
0435 let children = shapeItem.zOrderedShapes
0436 for (var i in children) {
0437 const child = children[i]
0438 if (child === centerLabel) {
0439 continue // not part of the shape objects
0440 }
0441 const contains = child.contains(Qt.point(mouse.x, mouse.y))
0442 if (contains) {
0443 return child
0444 }
0445 }
0446 return null
0447 }
0448
0449 onPositionChanged: mouse => {
0450 mouse.accepted = false
0451 const child = findTarget(mouse)
0452 if (child !== null) {
0453 hoveringListItem = false
0454 mouseyX = mouse.x
0455 mouseyY = mouse.y
0456 if (child.segmentUuid === undefined) {
0457 hoveredSegment = "root"
0458 } else {
0459 hoveredSegment = child.segmentUuid
0460 }
0461 } else {
0462 hoveredSegment = undefined
0463 }
0464 }
0465
0466 onClicked: mouse => {
0467 const child = findTarget(mouse)
0468 if (child === null) {
0469 return
0470 }
0471 if (mouse.button === Qt.LeftButton) {
0472 if (child.segmentUuid === "root") {
0473 appWindow.slotUp()
0474 return
0475 }
0476 if (!child.segment.isFolder()) {
0477 Qt.openUrlExternally(child.url)
0478 return
0479 }
0480 MainContext.updateURL(child.url)
0481 MainContext.openUrl(child.url)
0482 } else if (mouse.button === Qt.RightButton) {
0483 console.log("click %1".arg(child))
0484 contextMenuComponent.createObject(child, {segment: child.segment}).popup()
0485 }
0486 }
0487 onPressAndHold: {
0488 if (mouse.source === Qt.MouseEventNotSynthesized) {
0489 const child = findTarget(mouse)
0490 contextMenu.segment = child.segment
0491 contextMenu.popup()
0492 }
0493 }
0494 }
0495
0496 DropperItem {
0497 x: shapeItem.x
0498 y: shapeItem.y
0499 z: 503
0500 width: shapeItem.width
0501 height: shapeItem.height
0502 onUrlsDropped: urls => {
0503 const url = urls[0]
0504 appWindow.updateURL(url)
0505 appWindow.openURL(url)
0506 }
0507 }
0508
0509 Kirigami.LoadingPlaceholder {
0510 id: scanPlaceholder
0511 visible: page.state === "scanning"
0512 anchors.centerIn: parent
0513
0514 Timer {
0515 interval: 16 // = 60 fps because supposedly Qt hardcodes it all over the place (this claim is very old and it's unclear if still true)
0516 // Polish doesn't lend itself to the advanced status text https://bugs.kde.org/show_bug.cgi?id=468395
0517 running: parent.visible && Qt.uiLanguage != 'pl'
0518 repeat: true
0519 onTriggered: {
0520 const files = ScanManager.files();
0521 const size = ScanManager.totalSize()
0522 scanPlaceholder.text = i18ncp("Scanned number of files and size so far", "%1 File, %2", "%1 Files, %2", String(files), KCoreAddons.Format.formatByteSize(size));
0523 }
0524 }
0525 }
0526
0527 Kirigami.PlaceholderMessage {
0528 visible: page.state === "noData"
0529 anchors.centerIn: parent
0530 width: parent.width - (Kirigami.Units.largeSpacing * 4)
0531 text: i18n("No data available")
0532 }
0533
0534 Connections {
0535 target: ScanManager
0536 function onAboutToEmptyCache() {
0537 RadialMap.invalidate()
0538 }
0539 }
0540
0541 Connections {
0542 target: MainContext
0543 function onCanvasIsDirty(filth) {
0544 RadialMap.refresh(filth)
0545 }
0546 }
0547
0548 Component.onCompleted: {
0549 appWindow.mapPage = this
0550 }
0551
0552 states: [
0553 State {
0554 name: "scanning"
0555 when: ScanManager.running
0556 },
0557 State {
0558 name: "noData"
0559 // FIXME this toggles too soon causing incomplete shapes to show wtf - but only when instaniatior is no async
0560 when: !RadialMap.valid && shapeItem.hasShapes
0561 },
0562 State {
0563 name: "" // default state
0564 }
0565 ]
0566 }