🏡 index : quine-global-labs/mirror-kde-plasma-desktop.git

/*
    SPDX-FileCopyrightText: 2013-2015 Eike Hein <hein@kde.org>

    SPDX-License-Identifier: GPL-2.0-or-later
*/
pragma ComponentBehavior: Bound

import QtQuick
import QtQuick.Layouts

import org.kde.kirigami as Kirigami
import org.kde.ksvg as KSvg
import org.kde.plasma.components as PlasmaComponents3
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.extras as PlasmaExtras
import org.kde.plasma.plasmoid
import org.kde.plasma.private.kicker as Kicker

PlasmaComponents3.ScrollView {
    id: root

    required property Kicker.KAStatsFavoritesModel globalFavorites
    required property Kicker.SimpleFavoritesModel systemFavorites
    required property Kicker.RootModel rootModel
    required property Kicker.RunnerModel runnerModel

    signal interactionConcluded

    focus: true

    // we never want a vertical scrollbar, the components handle those.
    PlasmaComponents3.ScrollBar.vertical.policy: PlasmaComponents3.ScrollBar.AlwaysOff
    // needs to be set explicitly as the default can cause loops
    PlasmaComponents3.ScrollBar.horizontal.visible: Plasmoid.containment.screenGeometry.width > 0 && contentWidth > (Plasmoid.containment.screenGeometry.width - Kirigami.Units.largeSpacing * 4)

    // ScrollBar's padding is only to make space for the scrollbar, so we don't need it here
    Layout.minimumWidth: Math.min(Math.max(mainRow.Layout.minimumWidth, mainRow.implicitWidth), Plasmoid.containment.screenGeometry.width - Kirigami.Units.largeSpacing * 4)
    Layout.maximumWidth: Layout.minimumWidth

    contentWidth: mainRow.implicitWidth

    Layout.minimumHeight: Math.min(Math.max(sideBar.implicitHeight, rootList.implicitHeight + rootList.Layout.bottomMargin), Math.round(Plasmoid.containment.screenGeometry.height * 0.9)) + topPadding + bottomPadding
    Layout.maximumHeight: Layout.minimumHeight

    function ensureVisible(item: Item) : void {
        const actualItemX = item.x
        const viewXPosition = (item.width <= contentItem.width)
            ? Math.round(actualItemX + item.width / 2 - contentItem.width / 2)
            : actualItemX
        const flickable = contentItem as Flickable
        if (actualItemX < flickable.contentX) {
            flickable.contentX = Math.max(0, viewXPosition)
        } else if ((actualItemX + item.width) > (flickable.contentX + flickable.width)) {
            flickable.contentX = Math.min(flickable.contentWidth - flickable.width, viewXPosition)
        }
        flickable.returnToBounds()
    }

    function focusRunnerColumn(column, focusTopElement = true) : void {
        if (runnerColumns.visibleChildren.length <= column + 1) { // visibleChildren includes repeater
            return
        }
        const targetList = runnerColumns.visibleChildren[column]
        targetList.currentIndex = focusTopElement ? 0 : (targetList.count - 1)
        targetList.giveFocus(Qt.TabFocusReason)
        root.ensureVisible(targetList)
    }

    function focusRootList(focusTopElement = true) : void {
        rootList.currentIndex = focusTopElement ? 0 : (rootList.model.count - 1)
        rootList.forceActiveFocus(Qt.TabFocusReason)
    }

    function focusSideBar() : void {
        sideBar.forceActiveFocus(Qt.TabFocusReason)
        root.ensureVisible(sideBar)
    }


    function reset() {
        rootList.currentIndex = -1;
        hoverBlock.reset();

        searchField.text = "";
        searchField.focus = true;
    }

    DropArea {
        id: unfavoriteDropArea
        anchors.fill: parent
        keys: ["favoritedrag"]
        onDropped: drop => {
            let draggedItem = drag.source as SideBarItem
            if (draggedItem && draggedItem.favoritesModel.isFavorite(draggedItem.favoriteId)) {
                draggedItem.showUnfavoritePlaceholder = true
                draggedItem.favoritesModel.removeFavorite(draggedItem.favoriteId)
                drop.accept(Qt.MoveAction)
            }
        }
    }

    RowLayout {
        id: mainRow

        anchors.fill: parent

        spacing: 0

        readonly property int minimumMainWidth: Math.max(searchField.defaultWidth, root.runnerModel.resultsPresent ? 0 : Math.min(rootList.implicitWidth, rootList.Layout.maximumWidth))
        Layout.minimumWidth: (sideBar.visible ? sideBar.implicitWidth + sideBar.Layout.rightMargin : 0) + minimumMainWidth
        LayoutMirroring.enabled: ((Plasmoid.location === PlasmaCore.Types.RightEdge)
            || (Application.layoutDirection === Qt.RightToLeft && Plasmoid.location !== PlasmaCore.Types.LeftEdge))
        LayoutMirroring.childrenInherit: true

        KSvg.FrameSvgItem {
            id: sideBar

            readonly property real innerMargin: 2
            readonly property bool onTopPanel: Plasmoid.location === PlasmaCore.Types.TopEdge

            visible: (root.globalFavorites.count + root.systemFavorites.count) > 0

            Layout.fillHeight: true
            Layout.rightMargin: Kirigami.Units.smallSpacing

            implicitWidth: Math.max(favoriteApps.implicitWidth, favoriteSystemActions.implicitWidth) + margins.left + margins.right + sideBarScrollView.leftPadding + sideBarScrollView.rightPadding
            implicitHeight: sideBarLayout.implicitHeight + margins.top + margins.bottom

            imagePath: "widgets/frame"
            prefix: "plain"

            function handleLeftRightArrow(event: KeyEvent) : void {
                let backArrowKey = (event.key === Qt.Key_Left && !mainRow.LayoutMirroring.enabled) ||
                    (event.key === Qt.Key_Right && mainRow.LayoutMirroring.enabled)
                let forwardArrowKey = (event.key === Qt.Key_Right && !mainRow.LayoutMirroring.enabled) ||
                    (event.key === Qt.Key_Left && mainRow.LayoutMirroring.enabled)
                if (backArrowKey & runnerColumns.visibleChildren.length > 1) {
                    root.focusRunnerColumn(runnerColumns.visibleChildren.length - 2, true)
                } else if (forwardArrowKey) {
                    if (runnerColumns.visibleChildren.length > 1) {
                        root.focusRunnerColumn(0, true)
                    } else if (rootList.visible) {
                        root.focusRootList(true)
                    } else {
                        searchField.forceActiveFocus(Qt.TabFocusReason)
                    }
                } else {
                    event.accepted = false
                }
            }

            activeFocusOnTab: true
            onActiveFocusChanged: if (activeFocus) {
                let target = (onTopPanel && favoriteSystemActions.model.count) ? favoriteSystemActions : favoriteApps
                target.forceActiveFocus(Qt.TabFocusReason)
            }
            KeyNavigation.right: rootList

            PlasmaComponents3.ScrollView {
                id: sideBarScrollView

                anchors {
                    fill: parent
                    topMargin: sideBar.innerMargin
                    bottomMargin: sideBar.innerMargin
                }

                PlasmaComponents3.ScrollBar.horizontal.policy: PlasmaComponents3.ScrollBar.AlwaysOff
                PlasmaComponents3.ScrollBar.vertical.visible: Plasmoid.containment.screenGeometry.height > 0 && sideBarScrollView.contentHeight > Math.round(Plasmoid.containment.screenGeometry.height * 0.9)
                contentWidth: availableWidth
                contentHeight: Math.max(sideBarLayout.implicitHeight + sideBarLayout.anchors.topMargin + sideBarLayout.anchors.bottomMargin, sideBar.height - 2 * sideBar.innerMargin)

                function ensureVisible(item: Item) {
                    let flickable = (contentItem as Flickable)
                    let actualItemY = flickable.mapFromItem(item, 0, 0).y
                    let viewYPosition = (item.height <= contentItem.height)
                        ? Math.round(actualItemY + item.height / 2 - contentItem.height / 2)
                        : actualItemY
                    if (actualItemY < flickable.contentY) {
                        flickable.contentY = Math.max(0, viewYPosition)
                    } else if ((actualItemY + item.height) > (flickable.contentY + flickable.height)) {
                        flickable.contentY = Math.min(flickable.contentHeight - flickable.height, viewYPosition)
                    }
                    flickable.returnToBounds()
                }

                ColumnLayout {
                    id: sideBarLayout
                    anchors {
                        fill: parent
                        topMargin: sideBar.margins.top - sideBar.innerMargin
                        leftMargin: sideBar.margins.left
                        rightMargin: sideBar.margins.right
                        bottomMargin: sideBar.margins.bottom - sideBar.innerMargin
                    }

                    Accessible.role: Accessible.List
                    Accessible.name: i18nc("@title:group accessible name for favorite group in sidebar", "Favorites")

                    Keys.onLeftPressed: event => sideBar.handleLeftRightArrow(event)
                    Keys.onRightPressed: event => sideBar.handleLeftRightArrow(event)

                    LayoutItemProxy {
                        target: sideBar.onTopPanel ? favoriteSystemActions : favoriteApps
                    }
                    KSvg.SvgItem {
                        id: sidebarSeparator

                        Layout.fillWidth: true
                        Layout.leftMargin: Kirigami.Units.smallSpacing
                        Layout.rightMargin: Layout.leftMargin
                        Layout.alignment: Qt.AlignHCenter

                        visible: (favoriteApps.model && favoriteApps.model.count
                            && favoriteSystemActions.model && favoriteSystemActions.model.count)

                        imagePath: "widgets/line"
                        elementId: "horizontal-line"
                    }
                    LayoutItemProxy {
                        target: sideBar.onTopPanel ? favoriteApps : favoriteSystemActions
                    }

                    SideBarSection {
                        id: favoriteApps

                        Layout.fillHeight: true
                        Layout.alignment: Qt.AlignHCenter

                        KeyNavigation.up: favoriteSystemActions.bottomSideBarItem
                        KeyNavigation.down: favoriteSystemActions

                        model: root.globalFavorites
                        favoriteType: SideBarSection.FavoriteType.KAStats
                        onItemFocused: item => sideBarScrollView.ensureVisible(item)
                        onInteractionConcluded: root.interactionConcluded()

                        Binding {
                            target: root.globalFavorites
                            property: "iconSize"
                            value: Kirigami.Units.iconSizes.medium
                            restoreMode: Binding.RestoreBinding
                        }
                    }

                    SideBarSection {
                        id: favoriteSystemActions

                        Layout.alignment: Qt.AlignHCenter

                        model: root.systemFavorites
                        favoriteType: SideBarSection.FavoriteType.Simple
                        onItemFocused: item => sideBarScrollView.ensureVisible(item)
                        onInteractionConcluded: root.interactionConcluded()
                        KeyNavigation.up: favoriteApps.bottomSideBarItem
                        KeyNavigation.down: favoriteApps
                    }
                }
            }
        }

        ItemListView {
            id: rootList

            Layout.alignment: Qt.AlignTop
            Layout.bottomMargin: searchField.implicitHeight + Kirigami.Units.smallSpacing
            Layout.fillHeight: true
            Layout.fillWidth: true
            Layout.maximumHeight: implicitHeight


            visible: searchField.text === ""

            iconsEnabled: Plasmoid.configuration.showIconsRootLevel

            hoverEnabled: !hoverBlock.enabled

            mainSearchField: searchField

            model: root.rootModel

            onExited: rootList.currentIndex = -1
            onInteractionConcluded: root.interactionConcluded()
            onKeyNavigationAtListEnd: {
                searchField.focus = true;
            }

            onNavigateLeftRequested: {
                if (!sideBar.visible) {
                    return
                }
                currentIndex = -1
                root.focusSideBar()
            }
        }

        RowLayout {
            id: runnerColumns

            Layout.fillHeight: true

            visible: searchField.text !== "" && root.runnerModel.count > 0 && !initialDelayTimer.active && root.runnerModel.resultsPresent

            spacing: 0

            Timer {
                property bool active: false
                id: initialDelayTimer
                interval: 250 // match KRunner's delay for multi-runner queries
                onRunningChanged: if (running && !root.runnerModel.resultsPresent) { active = true }
                onTriggered: active = false
            }

            Repeater {
                id: runnerColumnsRepeater

                model: root.runnerModel

                delegate: RunnerResultsList {
                    id: runnerMatches

                    Layout.fillHeight: true
                    Layout.fillWidth: true

                    visible: model.count > 0 || (model.querying && visible)
                    hoverEnabled: !hoverBlock.enabled

                    model: root.runnerModel.modelForRow(index)
                    mainSearchField: searchField

                    function navigateToAdjacentColumn(forward = true) {
                        let currentColumnIndex = runnerColumns.visibleChildren.indexOf(runnerMatches)
                        let targetColumnIndex = currentColumnIndex + (forward ? 1 : -1)
                        if (currentColumnIndex < 0 || runnerColumns.visibleChildren.length <= 2) {
                            return
                        }
                        currentIndex = -1
                        if (targetColumnIndex >= 0 && targetColumnIndex < (runnerColumns.visibleChildren.length - 1)) {
                            root.focusRunnerColumn(targetColumnIndex)
                        } else if (sideBar.visible) {
                            root.focusSideBar()
                        } else {
                            root.focusRunnerColumn(forward ? 0 : runnerColumns.visibleChildren.length - 2)
                        }
                    }

                    onListActiveFocusChanged: {
                        if (!listActiveFocus) {
                            currentIndex = -1;
                        }
                    }

                    onInteractionConcluded: root.interactionConcluded()
                    onNavigateLeftRequested: navigateToAdjacentColumn(false)
                    onNavigateRightRequested: navigateToAdjacentColumn(true)
                }
            }

        }
        PlasmaExtras.PlaceholderMessage {
            id: noMatchesPlaceholder

            Layout.minimumWidth: mainRow.minimumMainWidth
            Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter

            visible: root.runnerModel.query !== "" && !root.runnerModel.resultsPresent && ((!root.runnerModel.querying && !initialDelayTimer.active) || visible)
            iconName: "edit-none"
            text: i18nc("@info:status", "No matches")

            Binding {
                searchField.width: noMatchesPlaceholder.width
                when: noMatchesPlaceholder.visible
            }
        }
    }

    PlasmaExtras.SearchField {
        id: searchField

        readonly property int spacing: sideBar.width ? sideBar.Layout.rightMargin : 0

        anchors.bottom: mainRow.bottom
        anchors.left: parent.left
        anchors.leftMargin: sideBar.visible ? sideBar.width + spacing : 0

        readonly property real defaultWidth: Kirigami.Units.gridUnit * 14

        width: runnerColumns.visible && root.runnerModel.resultsPresent
            ? runnerColumns.visibleChildren[0].width - (runnerColumns.visibleChildren.length > 2 ? Kirigami.Units.smallSpacing : 0)
            : (rootList.visible ? rootList.width : mainRow.minimumMainWidth)

        focus: !Kirigami.InputMethod.willShowOnActive

        onTextChanged: {
            initialDelayTimer.restart()
            root.runnerModel.query = text;
        }

        onFocusChanged: {
            if (focus) {
                // FIXME: Cleanup arbitration between rootList/runnerCols here and in Keys.
                if (rootList.visible) {
                    rootList.currentIndex = -1;
                }

                if (runnerColumns.visible) {
                    runnerColumnsRepeater.itemAt(0).currentIndex = -1;
                }
            }
        }

        states: [
            State {
                name: "right"
                when: (Plasmoid.location === PlasmaCore.Types.RightEdge && Application.layoutDirection === Qt.LeftToRight)
                    || (Plasmoid.location === PlasmaCore.Types.LeftEdge && Application.layoutDirection === Qt.RightToLeft)

                AnchorChanges {
                    target: searchField
                    anchors.top: undefined
                    anchors.bottom: mainRow.bottom
                    anchors.left: undefined
                    anchors.right: parent.right
                }

                PropertyChanges {
                    searchField.anchors.leftMargin: undefined
                    searchField.anchors.rightMargin: sideBar.visible ? sideBar.width + spacing : 0
                }
            }
        ]

        function handleUpDownArrow(event: KeyEvent) : void {
            if (rootList.visible) {
                root.focusRootList(event.key === Qt.Key_Down);
            } else if (runnerColumns.visible) {
                let index = runnerColumns.visibleChildren[0].currentIndex
                root.focusRunnerColumn(0, event.key === Qt.Key_Down)
                // First column, first entry is initially selected even when focus is on the search
                // field, as Return will activate it. Down should immediately go to the second entry.
                if (index === 0 && event.key === Qt.Key_Down) {
                    runnerColumns.visibleChildren[0].currentIndex = Math.min(index + 1, runnerColumns.visibleChildren[0].count - 1)
                }
            } else {
                event.accepted = false
            }
        }

        function handleLeftRightArrow(event: KeyEvent) : void {
            let backArrowKey = (event.key === Qt.Key_Left && !mainRow.LayoutMirroring.enabled) ||
                (event.key === Qt.Key_Right && mainRow.LayoutMirroring.enabled)
            let forwardArrowKey = (event.key === Qt.Key_Right && !mainRow.LayoutMirroring.enabled) ||
                (event.key === Qt.Key_Left && mainRow.LayoutMirroring.enabled)

            if (backArrowKey) {
                if (!sideBar.visible && !runnerColumns.visible) {
                    return;
                }
                if (runnerColumns.visibleChildren[0]?.length > 1) {
                    runnerColumns.visibleChildren[0].currentIndex = -1;
                }
                if (sideBar.visible) {
                    root.focusSideBar(true)
                } else {
                    root.focusRunnerColumn(runnerColumns.visibleChildren.length - 2, true)
                }
            } else if (forwardArrowKey && runnerColumns.visibleChildren.length > 2) {
                runnerColumns.visibleChildren[0].currentIndex = -1;
                root.focusRunnerColumn(1, true)
            } else {
                event.accepted = false
            }
        }

        function launchBestMatch() : void  {
            if (launchMatchTimer.running) {
                launchMatchTimer.stop()
                launchMatchTimer.triggered()
                return
            }
            if (!root.runnerModel.querying || runnerColumns.visibleChildren[0]?.currentItem?.text.toLowerCase().includes(root.runnerModel.query.toLowerCase())) {
                launchMatchTimer.triggered()
                return
            }
            launchMatchTimer.start()
        }

        Timer {
            id: launchMatchTimer
            interval: 750
            onTriggered: runnerColumns.visibleChildren[0]?.currentItem?.action.trigger()
        }

        Connections {
            target: runnerColumns.visibleChildren[0] instanceof RunnerResultsList ? runnerColumns.visibleChildren[0] : null
            enabled: launchMatchTimer.running
            function onCurrentItemChanged() : void {
                if (runnerColumns.visibleChildren[0]?.currentItem?.text.toLowerCase().includes(root.runnerModel.query.toLowerCase())) {
                    launchMatchTimer.stop()
                    Qt.callLater(launchMatchTimer.triggered) // callLater to resolve spurious binding loop
                }
            }
        }

        Connections {
            target: root.runnerModel
            enabled: launchMatchTimer.running || initialDelayTimer.active
            function onQueryFinished() : void {
                // queryFinish may be emitted for intermediate queries, ignore those
                if (root.runnerModel.querying) { return }
                initialDelayTimer.triggered()
                if (!launchMatchTimer.running) { return }
                launchMatchTimer.stop()
                Qt.callLater(launchMatchTimer.triggered) // callLater to give bindings time to update
            }
        }

        Keys.priority: Keys.AfterItem // arrow keys should move cursor first
        Keys.onUpPressed: event => handleUpDownArrow(event)
        Keys.onDownPressed: event => handleUpDownArrow(event)
        Keys.onLeftPressed: event => handleLeftRightArrow(event)
        Keys.onRightPressed: event => handleLeftRightArrow(event)
        Keys.onEnterPressed: launchBestMatch()
        Keys.onReturnPressed: launchBestMatch()
        Keys.onEscapePressed: root.interactionConcluded()

        function appendText(newText) {
            focus = true;
            text = text + newText;
        }
    }

    Component.onCompleted: {
        kicker.modelRefreshed.connect(() => {
            const searchTerm = searchField.text
            root.reset()
            runnerModel.clear() // runnerModel is on a timer, clearing and setting query is too fast to work
            searchField.text = searchTerm
        });
        kicker.reset.connect(reset)

        rootModel.refresh();
    }

    HoverBlocker {
        id: hoverBlock

        anchors.fill: parent
        onWidthChanged: hoverBlock.reset()

        Connections {
            target: root.runnerModel

            function onQueryChanged() : void {
                hoverBlock.reset()
            }
        }
    }
}