import QtQuick
import QtQuick.Controls.Basic
// The worksheet. Row 1 is the frozen table header; data starts on row 2. Past the data the
// sheet carries on as empty gridlines, as Excel's does.
FocusScope {
id: g
property var win: Window.window
readonly property real z100: win.zoom / 100
readonly property int rowH: Math.round(20 * z100)
readonly property int headW: Math.max(30, Math.round(rowNumW.advanceWidth + 12))
readonly property int colHeadH: 20
readonly property var cols: library.columns
readonly property int rows: table.rows
readonly property int padCols: 26
property real extraColW: 64 * z100
function colX(c) {
var x = 0
for (var i = 0; i < c && i < cols.length; i++) x += cols[i].width * z100
if (c > cols.length) x += (c - cols.length) * extraColW
return x
}
function colW(c) { return c < cols.length ? cols[c].width * z100 : extraColW }
function colAt(x) {
var acc = 0
for (var i = 0; i < cols.length; i++) {
acc += cols[i].width * z100
if (x < acc) return i
}
return cols.length + Math.floor((x - acc) / extraColW)
}
function ensureVisible(r, c) {
var y = r * rowH
if (y < table.contentY) table.contentY = y
else if (y + rowH > table.contentY + table.height) table.contentY = y + rowH - table.height
var x = colX(c), w = colW(c)
if (x < table.contentX) table.contentX = x
else if (x + w > table.contentX + table.width) table.contentX = Math.min(x + w - table.width, Math.max(0, table.contentWidth - table.width))
}
function moveTo(r, c, extend) {
r = Math.max(0, Math.min(r, Math.max(0, rows - 1)))
c = Math.max(0, Math.min(c, cols.length - 1))
win.curRow = r
win.curCol = c
if (!extend) { win.anchorRow = r; win.anchorCol = c }
ensureVisible(r, c)
}
TextMetrics { id: rowNumW; font.family: uiFont; font.pixelSize: X.fsSmall; text: String(g.rows + 40) }
// ---- column letters ----
Rectangle {
id: colHead
visible: win.headings
x: g.headW
width: parent.width - x
height: visible ? g.colHeadH : 0
color: X.header
clip: true
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Repeater {
model: g.cols.length + g.padCols
delegate: Rectangle {
required property int index
readonly property bool sel: index >= Math.min(win.curCol, win.anchorCol) && index <= Math.max(win.curCol, win.anchorCol)
x: g.colX(index) - table.contentX
width: g.colW(index)
height: g.colHeadH
visible: x + width > 0 && x < colHead.width
color: sel ? X.headerSel : X.header
T {
anchors.centerIn: parent
text: X.colName(index)
font.pixelSize: X.fsSmall
color: parent.sel ? X.greenText : X.ink
}
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
Rectangle { visible: parent.sel; anchors.bottom: parent.bottom; width: parent.width; height: 2; color: X.green }
MouseArea {
anchors.fill: parent
onClicked: if (index < g.cols.length) { win.anchorRow = 0; win.curRow = Math.max(0, g.rows - 1); win.curCol = win.anchorCol = index }
}
}
}
}
// the select-all corner
Rectangle {
visible: win.headings
width: g.headW
height: g.colHeadH
color: X.header
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Canvas {
anchors { right: parent.right; bottom: parent.bottom; margins: 3 }
width: 10
height: 10
onPaint: {
var c = getContext("2d")
c.fillStyle = "#B4B4B4"
c.beginPath(); c.moveTo(10, 0); c.lineTo(10, 10); c.lineTo(0, 10); c.closePath(); c.fill()
}
}
MouseArea { anchors.fill: parent; onClicked: win.selectAll() }
}
// ---- row numbers ----
Rectangle {
id: rowHead
visible: win.headings
y: colHead.height
width: visible ? g.headW : 0
height: parent.height - y
color: X.header
clip: true
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
// row 1: the frozen table header
Rectangle {
width: parent.width
height: g.rowH
color: X.header
T { anchors.centerIn: parent; text: "1"; font.pixelSize: X.fsSmall }
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
}
Item {
y: g.rowH
width: parent.width
height: parent.height - y
clip: true
Repeater {
model: Math.max(0, Math.ceil(parent.height / g.rowH) + 1)
delegate: Rectangle {
required property int index
readonly property int r: Math.floor(table.contentY / g.rowH) + index
readonly property bool sel: r >= win.selTop && r <= win.selBottom
y: r * g.rowH - table.contentY
width: rowHead.width
height: g.rowH
color: sel ? X.headerSel : X.header
T {
anchors.centerIn: parent
text: parent.r + 2
font.pixelSize: X.fsSmall
color: parent.sel ? X.greenText : X.ink
}
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Rectangle { anchors.right: parent.right; width: parent.sel ? 2 : 1; height: parent.height; color: parent.sel ? X.green : X.headerLine }
MouseArea {
anchors.fill: parent
onClicked: (m) => { if (parent.r < g.rows) { g.moveTo(parent.r, 0, m.modifiers & Qt.ShiftModifier); if (!(m.modifiers & Qt.ShiftModifier)) win.anchorCol = 0; win.curCol = g.cols.length - 1 } }
onDoubleClicked: if (parent.r < g.rows) library.activate(parent.r, 0)
}
}
}
}
}
// ---- sheet ----
Item {
id: sheet
x: rowHead.width
y: colHead.height
width: parent.width - x
height: parent.height - y
clip: true
Rectangle { anchors.fill: parent; color: "#FFFFFF" }
// the empty sheet: gridlines everywhere, under the cells
Item {
anchors.fill: parent
visible: win.gridlines
Repeater {
model: Math.max(0, Math.ceil(sheet.height / g.rowH) + 2)
delegate: Rectangle {
required property int index
y: g.rowH + (index + 1) * g.rowH - (table.contentY % g.rowH) - 1
width: sheet.width
height: 1
color: X.grid
}
}
Repeater {
model: g.cols.length + g.padCols
delegate: Rectangle {
required property int index
x: g.colX(index + 1) - table.contentX - 1
width: 1
height: sheet.height
color: X.grid
}
}
}
// row 1, frozen: the table header with its filter buttons
Item {
id: headRow
width: parent.width
height: g.rowH
z: 2
Repeater {
model: g.cols
delegate: Rectangle {
id: hc
required property int index
required property var modelData
x: g.colX(index) - table.contentX
width: g.colW(index)
height: g.rowH
color: X.tableHead
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: "#FFFFFF"; opacity: 0.5 }
T {
x: 4
width: parent.width - 26
height: parent.height
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
font.bold: true
color: "#FFFFFF"
text: hc.modelData.title
horizontalAlignment: hc.modelData.kind === "text" || hc.modelData.kind === "stems" ? Text.AlignLeft : Text.AlignRight
rightPadding: hc.modelData.kind === "text" || hc.modelData.kind === "stems" ? 0 : 2
}
// the AutoFilter button: sorts this column
Rectangle {
anchors { right: parent.right; rightMargin: 3; verticalCenter: parent.verticalCenter }
width: Math.round(15 * g.z100)
height: width
color: fm.containsMouse ? "#F0F0F0" : "#FFFFFF"
border.color: "#A0A0A0"
Ico {
anchors.centerIn: parent
size: Math.round(12 * g.z100)
name: library.sortColumn === hc.index ? (library.sortDescending ? "sort_down" : "sort_up") : "dropdown"
}
MouseArea {
id: fm
anchors.fill: parent
hoverEnabled: true
onClicked: { g.win.curCol = g.win.anchorCol = hc.index; filterMenu.col = hc.index; filterMenu.popup() }
}
}
MouseArea {
anchors { fill: parent; rightMargin: 20 }
onClicked: library.sortBy(hc.index)
}
}
}
}
TableView {
id: table
y: g.rowH
width: parent.width
height: parent.height - y
model: sheetModel
clip: true
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
columnWidthProvider: (c) => g.colW(c)
rowHeightProvider: (r) => g.rowH
interactive: false // the wheel and the scrollbars move it; dragging selects
ScrollBar.vertical: XScrollBar { id: vbar }
ScrollBar.horizontal: XScrollBar { id: hbar }
Connections {
target: g.win
function onZoomChanged() { Qt.callLater(table.forceLayout) }
}
delegate: Rectangle {
id: cell
required property int row
required property int column
required property string display
required property string align
required property string cellStyle
required property bool playing
readonly property bool bar: cellStyle.indexOf("bar:") === 0
color: cellStyle === "good" ? X.good : (row % 2 === 1 ? X.tableBand : "#FFFFFF")
Rectangle { // data bar
visible: cell.bar
x: 2
y: 2
height: parent.height - 4
width: cell.bar ? (parent.width - 4) * parseFloat(cell.cellStyle.slice(4)) : 0
color: X.dataBar
opacity: 0.85
}
Rectangle { visible: g.win.gridlines; anchors.right: parent.right; width: 1; height: parent.height; color: X.grid }
Rectangle { visible: g.win.gridlines; anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.grid }
Text {
anchors.fill: parent
leftPadding: 3
rightPadding: 4
verticalAlignment: Text.AlignBottom
bottomPadding: Math.round(2 * g.z100)
horizontalAlignment: cell.align === "right" ? Text.AlignRight : Text.AlignLeft
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
font.bold: cell.playing
color: cell.cellStyle === "good" ? X.goodText : X.cellText
text: cell.cellStyle === "good" ? cell.display + " stems" : cell.display
elide: cell.align === "right" ? Text.ElideLeft : Text.ElideRight
renderType: Text.NativeRendering
}
// Excel's green error triangle, here marking the song that's playing
Canvas {
visible: cell.playing && cell.column === 0
width: 7
height: 7
onPaint: {
var c = getContext("2d")
c.fillStyle = "#217346"
c.beginPath(); c.moveTo(0, 0); c.lineTo(7, 0); c.lineTo(0, 7); c.closePath(); c.fill()
}
}
}
// selection: range fill, green border, fill handle
Rectangle {
id: selBox
parent: table.contentItem
z: 50
visible: g.rows > 0
readonly property bool range: g.win.selTop !== g.win.selBottom || g.win.curCol !== g.win.anchorCol
x: g.colX(Math.min(g.win.curCol, g.win.anchorCol)) - 1
y: g.win.selTop * g.rowH - 1
width: g.colX(Math.max(g.win.curCol, g.win.anchorCol) + 1) - x + 1
height: (g.win.selBottom - g.win.selTop + 1) * g.rowH + 1
color: range ? X.selFill : "transparent"
border.color: X.green
border.width: 2
Rectangle {
// the active cell stays clear inside a range
visible: selBox.range
x: g.colX(g.win.curCol) - parent.x
y: g.win.curRow * g.rowH - parent.y
width: g.colW(g.win.curCol)
height: g.rowH
color: "#FFFFFF"
opacity: 0.6
}
Rectangle {
x: parent.width - 4
y: parent.height - 4
width: 7
height: 7
color: X.green
border.color: "#FFFFFF"
border.width: 1
}
}
MouseArea {
parent: table.contentItem
z: 60
width: Math.max(table.contentWidth, table.width)
height: Math.max(table.contentHeight, table.height)
acceptedButtons: Qt.LeftButton | Qt.RightButton
property bool dragging: false
function cellAt(m) { return [Math.floor(m.y / g.rowH), g.colAt(m.x)] }
onPressed: (m) => {
g.forceActiveFocus()
var rc = cellAt(m)
if (rc[0] >= g.rows) return
if (m.button === Qt.RightButton) {
if (rc[0] < g.win.selTop || rc[0] > g.win.selBottom) g.moveTo(rc[0], rc[1], false)
ctxMenu.popup()
return
}
g.moveTo(rc[0], rc[1], m.modifiers & Qt.ShiftModifier)
dragging = true
}
onPositionChanged: (m) => {
if (!dragging) return
var rc = cellAt(m)
g.moveTo(rc[0], rc[1], true)
}
onReleased: dragging = false
onDoubleClicked: (m) => { var rc = cellAt(m); if (rc[0] < g.rows) library.activate(rc[0], 0) }
onWheel: (w) => {
if (w.modifiers & Qt.ControlModifier) {
g.win.setZoom(g.win.zoom + (w.angleDelta.y > 0 ? 10 : -10))
return
}
var dy = w.pixelDelta.y !== 0 ? w.pixelDelta.y : w.angleDelta.y / 120 * 3 * g.rowH
var dx = w.pixelDelta.x !== 0 ? w.pixelDelta.x : w.angleDelta.x / 120 * 3 * g.rowH
if (w.modifiers & Qt.ShiftModifier) { dx = dy; dy = 0 }
table.contentY = Math.max(0, Math.min(table.contentY - dy, table.contentHeight - table.height))
table.contentX = Math.max(0, Math.min(table.contentX - dx, Math.max(0, table.contentWidth - table.width)))
}
}
}
// #GETTING_DATA: what Excel shows while a query runs
T {
visible: library.loading
x: 4
y: g.rowH
height: g.rowH
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
color: X.cellText
text: "#GETTING_DATA"
}
// a cell comment when there's nothing to show
Rectangle {
visible: g.rows === 0 && !library.loading
x: g.colX(1) + 30
y: g.rowH * 2
width: 300
height: note.implicitHeight + 16
color: X.note
border.color: "#B2B2B2"
Rectangle { x: -30; y: 6; width: 30; height: 1; color: "#000000" }
Text {
id: note
x: 8
y: 8
width: parent.width - 16
wrapMode: Text.WordWrap
font.family: uiFont
font.pixelSize: X.fsUi
textFormat: Text.StyledText
text: !library.signedIn ? "StemRoller:
This workbook isn't connected yet. Go to Data → Connect to Plex and sign in with the code."
: library.layout === "queue" ? "StemRoller:
The queue is empty. Double-click a song on any sheet to play it."
: library.status !== "" ? "StemRoller:
Loading the library from Plex…"
: "StemRoller:
Nothing on this sheet."
}
}
}
Keys.onPressed: (e) => {
var ext = e.modifiers & Qt.ShiftModifier
var ctrl = e.modifiers & Qt.ControlModifier
var page = Math.max(1, Math.floor(table.height / g.rowH) - 1)
switch (e.key) {
case Qt.Key_Down: g.moveTo(ctrl ? g.rows - 1 : win.curRow + 1, win.curCol, ext); break
case Qt.Key_Up: g.moveTo(ctrl ? 0 : win.curRow - 1, win.curCol, ext); break
case Qt.Key_Right: if (ctrl) { player.next(); break } g.moveTo(win.curRow, win.curCol + 1, ext); break
case Qt.Key_Left: if (ctrl) { player.previous(); break } g.moveTo(win.curRow, win.curCol - 1, ext); break
case Qt.Key_Tab: g.moveTo(win.curRow, win.curCol + 1, false); break
case Qt.Key_PageDown: g.moveTo(win.curRow + page, win.curCol, ext); break
case Qt.Key_PageUp: g.moveTo(win.curRow - page, win.curCol, ext); break
case Qt.Key_Home: g.moveTo(ctrl ? 0 : win.curRow, 0, ext); break
case Qt.Key_End: g.moveTo(ctrl ? g.rows - 1 : win.curRow, g.cols.length - 1, ext); break
case Qt.Key_Return:
case Qt.Key_Enter: library.activate(win.curRow, 0); break
case Qt.Key_Space: player.toggle(); break
case Qt.Key_Delete: library.removeRows(win.selTop, win.selBottom); break
case Qt.Key_Backspace: library.back(); break
case Qt.Key_A: if (ctrl) win.selectAll(); else return; break
case Qt.Key_C: if (ctrl) win.copySelection(); else return; break
case Qt.Key_V: if (!ctrl) player.toggleVocals(); else return; break
default: return
}
e.accepted = true
}
// A new sheet starts at the top; fresh data for the same sheet keeps its scroll position.
property real keptY: 0
property bool switching: false
Connections {
target: library
function onSheetSwitched() { g.switching = true; table.contentY = 0; table.contentX = 0 }
}
Connections {
target: sheetModel
function onModelAboutToBeReset() { g.keptY = table.contentY }
function onModelReset() {
if (g.switching) { g.switching = false; return }
Qt.callLater(() => { table.contentY = Math.min(g.keptY, Math.max(0, table.contentHeight - table.height)) })
}
}
XMenu {
id: ctxMenu
Action { text: "Play"; icon.name: "play"; onTriggered: library.playRows(g.win.selTop, g.win.selBottom) }
Action { text: "Play Next"; icon.name: "play_next"; onTriggered: library.playNextRows(g.win.selTop, g.win.selBottom) }
Action { text: "Add to Queue"; icon.name: "queue_add"; onTriggered: library.queueRows(g.win.selTop, g.win.selBottom) }
MenuSeparator {}
Action { text: "Open"; icon.name: "right"; onTriggered: library.activate(g.win.curRow, 0) }
Action { text: "New Playlist from Selection…"; icon.name: "playlist_new"; onTriggered: g.win.newPlaylist() }
Action { text: "Copy"; onTriggered: g.win.copySelection() }
MenuSeparator {}
Action {
text: "Delete Rows"
icon.name: "delete_rows"
enabled: library.layout === "queue" || g.win.inPlaylist
onTriggered: library.removeRows(g.win.selTop, g.win.selBottom)
}
}
XMenu {
id: filterMenu
property int col: 0
Action { text: "Sort A to Z"; icon.name: "sort_az"; onTriggered: g.win.sortColumn(filterMenu.col, false) }
Action { text: "Sort Z to A"; icon.name: "sort_za"; onTriggered: g.win.sortColumn(filterMenu.col, true) }
}
}