12.5 KiB · text History 49b9ecf
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.plasma.components as PlasmaComponents3
import org.kde.kirigami as Kirigami

import "Status.js" as Status

// The live CI wall: every job running right now, a card each, tiled to
// fill the space — GitGud's /users/ci/live, as a window of its own or as
// the widget itself on the desktop (wall mode).
//
// Up to six cards side by side (fewer when they'd get too narrow to read
// a log line in); past that the columns fill up from the right in pairs,
// as on the web and in the Android app. The order is the operator's:
// oldest-started first, or newest (the header's buttons, or R). So is
// what it shows: your own jobs, or one organization's.
//
// All state lives in `store` (main.qml): it polls /ci/live while any
// wall is on screen, so a window and a desktop wall share one feed.
Item {
    id: wall

    required property var store
    // Leave the header's window-only buttons out on the desktop.
    property bool inWindow: false
    property bool fullScreen: false
    property bool translucent: false
    signal toggleFullScreen()

    readonly property var cards: store.wallCards
    readonly property var ordered: cards ? (store.wallNewestFirst ? cards.slice().reverse() : cards) : null
    // One column per 13 grid units of width, at most six; more (up to six)
    // when a column would stack more cards than fit at a readable height
    // — a small desktop widget would otherwise draw slivers.
    readonly property int widthCols: Math.max(1, Math.min(6, Math.floor(grid.width / (Kirigami.Units.gridUnit * 13))))
    readonly property int rowsFit: Math.max(1, Math.floor(grid.height / (Kirigami.Units.gridUnit * 3.5)))
    readonly property int maxCols: cards ? Math.min(6, Math.max(widthCols, Math.ceil(cards.length / rowsFit))) : widthCols
    readonly property var columns: ordered ? Status.wallSplit(ordered, maxCols) : []
    // A small desktop widget: the header keeps only what it must.
    readonly property bool narrow: width < Kirigami.Units.gridUnit * 34

    // Tint: every bit of text — and the clock, borders and shimmer that
    // share its colours — in one hue (store.wallTintHue, green unless
    // set), a terminal look that suits the translucent window. Done by
    // overriding the theme's text colours on the layout below, which
    // every Label, icon and card under it inherits. Status colours —
    // a failed step, "offline" — keep their meaning and aren't touched.
    //
    // Lightness comes from the theme's background, not its text colour:
    // reading the text colour the layout itself overrides would be a
    // loop. A dark theme gets a bright tint, a light one a deep one.
    readonly property bool tinted: store.wallTint
    readonly property real tintHue: store.wallTintHue / 360
    readonly property bool darkTheme: Kirigami.Theme.backgroundColor.hslLightness < 0.5
    // The accent (clock, live dot, borders) is the most saturated, so it
    // still stands out from the body text the way the neutral colour does.
    readonly property color tintText: Qt.hsla(tintHue, 0.70, darkTheme ? 0.62 : 0.30, 1)
    readonly property color tintDim: Qt.hsla(tintHue, 0.40, darkTheme ? 0.45 : 0.50, 1)
    readonly property color tintAccent: Qt.hsla(tintHue, 1.0, darkTheme ? 0.55 : 0.28, 1)

    Component.onCompleted: store.wallViewers += 1
    Component.onDestruction: store.wallViewers -= 1

    ColumnLayout {
        Kirigami.Theme.textColor: wall.tinted ? wall.tintText : wall.Kirigami.Theme.textColor
        Kirigami.Theme.disabledTextColor: wall.tinted ? wall.tintDim : wall.Kirigami.Theme.disabledTextColor
        Kirigami.Theme.neutralTextColor: wall.tinted ? wall.tintAccent : wall.Kirigami.Theme.neutralTextColor

        anchors.fill: parent
        anchors.margins: Kirigami.Units.smallSpacing
        spacing: Kirigami.Units.smallSpacing

        // ── header ──────────────────────────────────────────────────────
        RowLayout {
            Layout.fillWidth: true
            spacing: Kirigami.Units.smallSpacing

            Rectangle {
                id: liveDot
                Layout.preferredWidth: Kirigami.Units.smallSpacing * 2.5
                Layout.preferredHeight: width
                radius: width / 2
                color: wall.store.wallError === "" ? Kirigami.Theme.neutralTextColor
                                                   : Kirigami.Theme.negativeTextColor
                SequentialAnimation on opacity {
                    loops: Animation.Infinite
                    NumberAnimation { from: 1; to: 0.3; duration: 900 }
                    NumberAnimation { from: 0.3; to: 1; duration: 900 }
                }
            }

            Kirigami.Heading {
                level: 3
                text: i18n("Live CI")
            }

            PlasmaComponents3.ComboBox {
                id: scopeBox
                // "" is the token owner's own wall; otherwise an org handle.
                readonly property var scopes: [""].concat(wall.store.wallOrgs.map(function(o) { return o.handle }))
                model: scopes.map(function(s) { return s === "" ? i18n("Mine") : "@" + s })
                currentIndex: Math.max(0, scopes.indexOf(wall.store.wallScope))
                onActivated: function(index) { wall.store.setWallScope(scopes[index]) }
                PlasmaComponents3.ToolTip { text: i18n("Whose running jobs to show") }
            }

            PlasmaComponents3.Label {
                visible: wall.cards !== null && !wall.narrow
                opacity: 0.7
                text: wall.cards ? i18np("1 job", "%1 jobs", wall.cards.length) : ""
            }

            // The tiling at a glance: a bar per column, split by its cards.
            Row {
                visible: wall.columns.length > 0 && !wall.narrow
                spacing: 2
                Repeater {
                    model: wall.columns
                    Column {
                        id: glyphColumn
                        required property var modelData
                        readonly property int cards: modelData.length
                        spacing: 1
                        Repeater {
                            model: glyphColumn.cards
                            Rectangle {
                                width: 5
                                height: (14 - (glyphColumn.cards - 1)) / glyphColumn.cards
                                radius: 1
                                color: Kirigami.Theme.disabledTextColor
                            }
                        }
                    }
                }
            }

            PlasmaComponents3.Label {
                visible: wall.store.wallError !== "" && wall.cards !== null
                color: Kirigami.Theme.negativeTextColor
                font: Kirigami.Theme.smallFont
                text: i18n("offline · retrying")
                PlasmaComponents3.ToolTip { text: wall.store.wallError }
            }

            Item { Layout.fillWidth: true }

            QQC2.ButtonGroup { id: orderGroup }

            PlasmaComponents3.ToolButton {
                checkable: true
                checked: !wall.store.wallNewestFirst
                QQC2.ButtonGroup.group: orderGroup
                icon.name: "view-sort-ascending"
                text: i18n("Oldest first")
                display: wall.narrow ? QQC2.AbstractButton.IconOnly : QQC2.AbstractButton.TextOnly
                onClicked: wall.store.setWallNewestFirst(false)
                PlasmaComponents3.ToolTip { text: i18n("Oldest first (R flips the order)") }
            }

            PlasmaComponents3.ToolButton {
                checkable: true
                checked: wall.store.wallNewestFirst
                QQC2.ButtonGroup.group: orderGroup
                icon.name: "view-sort-descending"
                text: i18n("Newest first")
                display: wall.narrow ? QQC2.AbstractButton.IconOnly : QQC2.AbstractButton.TextOnly
                onClicked: wall.store.setWallNewestFirst(true)
                PlasmaComponents3.ToolTip { text: i18n("Newest first (R flips the order)") }
            }

            PlasmaComponents3.ToolButton {
                visible: wall.inWindow
                checkable: true
                checked: wall.translucent
                icon.name: "transform-opacity"
                text: i18n("Translucent")
                display: QQC2.AbstractButton.IconOnly
                onClicked: wall.store.setWallTranslucent(!wall.translucent)
                PlasmaComponents3.ToolTip { text: i18n("See-through background (T)") }
            }

            PlasmaComponents3.ToolButton {
                checkable: true
                checked: wall.tinted
                icon.name: "fill-color"
                text: i18n("Tint")
                display: QQC2.AbstractButton.IconOnly
                onClicked: wall.store.setWallTint(!wall.tinted)
                PlasmaComponents3.ToolTip {
                    text: wall.inWindow ? i18n("All text in one hue (H) — pick the hue in the widget's settings")
                                        : i18n("All text in one hue — pick the hue in the widget's settings")
                }
            }

            PlasmaComponents3.ToolButton {
                visible: wall.inWindow
                icon.name: wall.fullScreen ? "view-restore" : "view-fullscreen"
                text: wall.fullScreen ? i18n("Exit Full Screen") : i18n("Full Screen")
                display: QQC2.AbstractButton.IconOnly
                onClicked: wall.toggleFullScreen()
                PlasmaComponents3.ToolTip { text: wall.fullScreen ? i18n("Exit Full Screen (Esc)") : i18n("Full Screen (F11)") }
            }

            PlasmaComponents3.ToolButton {
                icon.name: "internet-web-browser"
                text: i18n("Open in Browser")
                display: QQC2.AbstractButton.IconOnly
                onClicked: Qt.openUrlExternally(wall.store.wallWebUrl())
                PlasmaComponents3.ToolTip { text: i18n("Open in Browser") }
            }
        }

        // ── cards ───────────────────────────────────────────────────────
        Item {
            id: grid
            Layout.fillWidth: true
            Layout.fillHeight: true

            PlasmaComponents3.Label {
                anchors.centerIn: parent
                width: parent.width - Kirigami.Units.gridUnit * 2
                visible: wall.ordered === null || wall.ordered.length === 0
                horizontalAlignment: Text.AlignHCenter
                wrapMode: Text.WordWrap
                opacity: 0.7
                text: !wall.store.wallConfigured
                      ? i18n("Configure the widget to connect to your GitGud instance")
                      : wall.ordered === null
                        ? (wall.store.wallError !== "" ? wall.store.wallError : i18n("Loading…"))
                        : i18n("Nothing running. Jobs appear here the moment a runner picks them up.")
            }

            RowLayout {
                anchors.fill: parent
                spacing: Kirigami.Units.smallSpacing

                Repeater {
                    model: wall.columns

                    ColumnLayout {
                        id: column
                        required property var modelData
                        Layout.fillWidth: true
                        Layout.fillHeight: true
                        Layout.preferredWidth: 1   // equal shares
                        spacing: Kirigami.Units.smallSpacing

                        Repeater {
                            model: column.modelData

                            WallCard {
                                required property var modelData
                                Layout.fillWidth: true
                                Layout.fillHeight: true
                                Layout.preferredHeight: 1   // equal shares
                                card: modelData
                                translucent: wall.translucent
                                // The theme's own, from above the tint:
                                // a running step stays told apart from
                                // a passed one whatever the hue.
                                runningColor: wall.Kirigami.Theme.neutralTextColor
                                now: wall.store.now
                            }
                        }
                    }
                }
            }
        }
    }
}