diff --git a/shell/plugins/dock/AppGrid.qml b/shell/plugins/dock/AppGrid.qml index 53c771ddaa3..e85b8b0e4f0 100644 --- a/shell/plugins/dock/AppGrid.qml +++ b/shell/plugins/dock/AppGrid.qml @@ -28,13 +28,26 @@ Item { property string filterText: "" property int selectedIndex: 0 property var apps: [] + // Apps installed or removed while the grid is closed are picked up when it + // next opens. + property bool appsStale: false + + // The search line is a TextInput; keep it showing what the filter holds when + // the grid clears or resets the filter itself. + onFilterTextChanged: if (filterInput.text !== root.filterText) filterInput.text = root.filterText readonly property int iconSize: Math.round(56 * Style.fontScale) + // A name wraps to two lines at most; a cell always has room for both so the + // icons of a row line up whatever their names are. readonly property int labelHeight: Math.ceil(labelMetrics.height) * 2 + readonly property int labelGap: Style.spacing.md + // The gap between neighbouring tiles, and the air above and below a tile's + // content. The selected tile hugs its own icon and name, so it is padded + // evenly on every side whether the name takes one line or two. readonly property int cellGutter: Style.spacing.sm - readonly property int cellPadding: Style.spacing.lg + readonly property int tilePaddingY: Style.spacing.xl readonly property int cellWidth: Math.round(iconSize * 2.2) - readonly property int cellHeight: cellPadding * 2 + iconSize + Style.spacing.md + labelHeight + readonly property int cellHeight: cellGutter * 2 + tilePaddingY * 2 + iconSize + labelGap + labelHeight readonly property int maxColumns: 7 readonly property int visibleRows: 4 readonly property int borderWidth: Math.max(1, Style.space(2)) @@ -49,21 +62,30 @@ Item { readonly property int cardWidth: Math.min(panel.width - screenMargin * 2, maxColumns * cellWidth + inset * 2) readonly property int columns: Math.max(1, Math.floor((cardWidth - inset * 2) / cellWidth)) readonly property int gridWidth: columns * cellWidth - readonly property int cardHeight: Math.min(panel.height - screenMargin * 2, inset * 2 + headerHeight + headerSpacing + visibleRows * cellHeight) + // Like the menu, the grid ends mid-row: the next row peeks in below the last + // whole one, cut through its icons and faded by the scrim, which tells the + // eye there is more below the fold. A screen too short for that just cuts a row where it ends. + readonly property int rowPeek: cellGutter + tilePaddingY + Math.round(iconSize * 0.75) + readonly property int cardHeight: Math.min(panel.height - screenMargin * 2, inset * 2 + headerHeight + headerSpacing + visibleRows * cellHeight + rowPeek) function open() { root.selectedIndex = 0 + pointerGate.reset() if (root.appLibrary) root.appLibrary.refreshIcons() // The cells from last time are reused when nothing changed, so the grid // opens with its icons already drawn. if (root.appsStale || root.filterText !== "" || root.apps.length === 0) { root.filterText = "" - root.rebuild() + root.rebuild(true) } else { grid.positionViewAtBeginning() } root.opened = true - Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + root.focusSearch() + } + + function focusSearch() { + Qt.callLater(function() { filterInput.forceActiveFocus() }) } function close() { @@ -100,7 +122,7 @@ Item { function cancelUninstall() { root.uninstallEntry = null - Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + root.focusSearch() } function confirmUninstall() { @@ -120,26 +142,94 @@ Item { else root.open() } - function rebuild() { + // Fills the grid from the library. A new search starts from the top; an app + // list that changed under an open grid keeps the user where they were. + function rebuild(fromTop) { root.appsStale = false var rows = root.appLibrary ? root.appLibrary.sortedEntries(root.filterText) : [] root.apps = rows.map(function(row) { return row.entry }) root.selectedIndex = Math.max(0, Math.min(root.selectedIndex, root.apps.length - 1)) - grid.positionViewAtBeginning() + if (fromTop) { + grid.positionViewAtBeginning() + } else { + // The view settles on its new size first, then keeps the selection in sight. + Qt.callLater(function() { + grid.returnToBounds() + root.revealIndex(root.selectedIndex) + }) + } + } + + // Contain alone parks the selected row flush with the viewport edge, hiding + // its neighbour entirely and losing the fold. Keep the next hidden row + // peeking past the selection in the direction of travel, as the menu does. + function revealIndex(index) { + if (index < 0 || index >= root.apps.length) return + grid.positionViewAtIndex(index, GridView.Contain) + + var item = grid.itemAtIndex(index) + if (!item) return + + var row = Math.floor(index / root.columns) + var lastRow = Math.floor((root.apps.length - 1) / root.columns) + if (row < lastRow) { + var maxY = Math.max(grid.originY, grid.originY + grid.contentHeight - grid.height) + var overhang = item.y + item.height + root.rowPeek - (grid.contentY + grid.height) + if (overhang > 0) grid.contentY = Math.min(grid.contentY + overhang, maxY) + } + if (row > 0) { + var underhang = grid.contentY - (item.y - root.rowPeek) + if (underhang > 0) grid.contentY = Math.max(grid.contentY - underhang, grid.originY) + } } function setFilter(text) { root.filterText = text root.selectedIndex = 0 - root.rebuild() + root.menuEntry = null + pointerGate.reset() + root.rebuild(true) } - function select(delta) { + // Moves the keyboard selection. The grid scrolls under a pointer that has + // not moved, so the gate is reset to keep that from reselecting a cell. + function selectIndex(index) { if (root.apps.length === 0) return - root.selectedIndex = Math.max(0, Math.min(root.apps.length - 1, root.selectedIndex + delta)) - grid.positionViewAtIndex(root.selectedIndex, GridView.Contain) + root.selectedIndex = Math.max(0, Math.min(root.apps.length - 1, index)) + pointerGate.reset() + root.revealIndex(root.selectedIndex) + } + + function select(delta) { + root.selectIndex(root.selectedIndex + delta) } + // Up and Down stop at the top and bottom rows rather than jumping to the + // first or last app. A move that would leave the grid goes as far as it can: + // below a short last row, to its last app; above, to the first row in the + // same column. + function selectRows(rows) { + var count = root.apps.length + if (count === 0) return + var row = Math.floor(root.selectedIndex / root.columns) + var target = root.selectedIndex + rows * root.columns + if (target < 0) { + if (row === 0) return + target = root.selectedIndex % root.columns + } else if (target > count - 1) { + if (row >= Math.floor((count - 1) / root.columns)) return + target = count - 1 + } + root.selectIndex(target) + } + + function selectFromPointer(index, item, mouse) { + if (root.dragging || !pointerGate.moved(item, mouse)) return + root.selectedIndex = index + } + + readonly property int pageRows: Math.max(1, root.visibleRows - 1) + function launch(index) { var entry = root.apps[index] if (!entry || !root.appLibrary) return @@ -154,14 +244,15 @@ Item { font.pixelSize: Style.font.body } - // Apps installed or removed while the grid is closed are picked up when it - // next opens. - property bool appsStale: false + PointerMoveGate { + id: pointerGate + referenceItem: card + } Connections { target: root.appLibrary function onAppsChanged() { - if (root.opened) root.rebuild() + if (root.opened) root.rebuild(false) else root.appsStale = true } } @@ -234,12 +325,14 @@ Item { Item { id: keyCatcher anchors.fill: parent - focus: true - Keys.priority: Keys.BeforeItem + // Focus is on filterInput, which forwards every key here first. What + // is left unaccepted is typed into the search line. Keys.onPressed: function(event) { if (root.uninstallEntry) { - if (uninstallConfirm.handleKey(event)) event.accepted = true + // Swallow the rest too, or it would type into the search line behind the dialog. + uninstallConfirm.handleKey(event) + event.accepted = true return } if (event.key === Qt.Key_Delete) { @@ -255,13 +348,26 @@ Item { } else if (Util.readingKey(event) === Qt.Key_Right) { root.select(1) } else if (event.key === Qt.Key_Up) { - root.select(-root.columns) + root.selectRows(-1) } else if (event.key === Qt.Key_Down) { - root.select(root.columns) + root.selectRows(1) + } else if (event.key === Qt.Key_PageUp) { + root.selectRows(-root.pageRows) + } else if (event.key === Qt.Key_PageDown) { + root.selectRows(root.pageRows) + } else if (event.key === Qt.Key_Home) { + root.selectIndex(0) + } else if (event.key === Qt.Key_End) { + root.selectIndex(root.apps.length - 1) + } else if (event.key === Qt.Key_Tab) { + root.select(1) + } else if (event.key === Qt.Key_Backtab) { + root.select(-1) } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { root.launch(root.selectedIndex) - } else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) { - root.setFilter(root.filterText + event.text) + } else if (event.text === " " && !root.filterText) { + // A search never starts with a space; it would hide the hint + // and match nothing new. } else { return } @@ -286,15 +392,44 @@ Item { color: Util.alpha(Commons.Color.menu.text, 0.06) Text { + id: searchGlyph anchors.left: parent.left - anchors.right: parent.right anchors.leftMargin: Style.spacing.controlPaddingX + anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText + text: "󰍉" + color: Commons.Color.menu.text + opacity: 0.58 + font.family: Style.font.family + font.pixelSize: Style.font.heading + } + + // A TextInput, not a Text fed by key events: input methods (fcitx5 + // composing Japanese, Chinese or Korean) only reach text-input items. + FilterInput { + id: filterInput + anchors.left: searchGlyph.right + anchors.leftMargin: Style.spacing.md + anchors.right: parent.right anchors.rightMargin: Style.spacing.controlPaddingX anchors.verticalCenter: parent.verticalCenter + keyHandler: keyCatcher + // Input-method commits bypass key handling, so the dialog has to refuse them here. + readOnly: !!root.uninstallEntry + color: Commons.Color.menu.text + font.family: Style.font.menuFamily + font.pixelSize: Style.font.heading + onTextChanged: if (text !== root.filterText) root.setFilter(text) + } + + Text { + anchors.fill: filterInput + verticalAlignment: Text.AlignVCenter + visible: !root.filterText && !filterInput.preeditText textFormat: Text.PlainText - text: root.filterText || Commons.I18n.t("dock.searchApps") + text: Commons.I18n.t("dock.searchApps") color: Commons.Color.menu.text - opacity: root.filterText ? 1 : 0.58 + opacity: 0.58 font.family: Style.font.menuFamily font.pixelSize: Style.font.heading elide: Text.ElideRight @@ -314,6 +449,17 @@ Item { cellHeight: root.cellHeight boundsBehavior: Flickable.StopAtBounds + // The menu's wheel: pixels for a touchpad, a row a notch for a mouse. + WheelHandler { + target: null + onWheel: function(event) { + var delta = event.pixelDelta.y || event.angleDelta.y / 120 * root.cellHeight + var bottom = grid.originY + Math.max(0, grid.contentHeight - grid.height) + grid.contentY = Math.max(grid.originY, Math.min(bottom, grid.contentY - delta)) + event.accepted = true + } + } + delegate: Item { id: cell @@ -327,10 +473,23 @@ Item { width: root.cellWidth height: root.cellHeight + // The selection is a tile around the icon and the name. It ends + // where the name does, so a one-line name is padded the same + // below as the icon is above, not out to the edge of the cell. + Rectangle { + id: tile + x: root.cellGutter + y: root.cellGutter + width: cell.width - root.cellGutter * 2 + height: root.tilePaddingY * 2 + root.iconSize + root.labelGap + Math.ceil(label.contentHeight) + radius: Style.cornerRadius + color: cell.selected ? Commons.Color.menu.selectedBackground : "transparent" + } + Image { id: icon anchors.horizontalCenter: parent.horizontalCenter - y: root.cellPadding + y: tile.y + root.tilePaddingY width: root.iconSize height: root.iconSize sourceSize: Qt.size(root.iconSize * 2, root.iconSize * 2) @@ -341,23 +500,20 @@ Item { asynchronous: false smooth: true mipmap: true - } - - // The selection marks the app's name, not the whole cell. - Rectangle { - anchors.centerIn: label - width: Math.min(cell.width - root.cellGutter * 2, label.contentWidth + Style.spacing.md * 2) - height: label.contentHeight + Style.spacing.xs * 2 - radius: Style.cornerRadius - color: cell.selected ? Commons.Color.menu.selectedBackground : "transparent" + // The icon gives a little under the press, then comes back. + scale: cellMouse.pressed && !cell.moved ? 0.92 : 1 + Behavior on scale { + id: pressScale + NumberAnimation { duration: Style.duration(pressScale.targetValue < 1 ? 80 : 140); easing.type: Easing.OutCubic } + } } Text { id: label anchors.top: icon.bottom - anchors.topMargin: Style.spacing.md + anchors.topMargin: root.labelGap anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - (root.cellGutter + Style.spacing.md) * 2 + width: tile.width - Style.spacing.md * 2 textFormat: Text.PlainText text: root.appLibrary ? root.appLibrary.entryName(cell.modelData) : "" color: cell.selected ? Commons.Color.menu.selectedText : Commons.Color.menu.text @@ -365,25 +521,33 @@ Item { font.pixelSize: Style.font.body horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignTop - wrapMode: Text.Wrap + // Breaks between words when it can, and inside a long one when + // it must, so an unbroken name never runs past its tile. + wrapMode: Text.WrapAtWordBoundaryOrAnywhere maximumLineCount: 2 elide: Text.ElideRight } MouseArea { + id: cellMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton // The grid would otherwise take a dragging press for a scroll. preventStealing: true - onContainsMouseChanged: if (containsMouse && !root.dragging) root.selectedIndex = cell.index onPressed: function(event) { cell.moved = false cell.pressPoint = Qt.point(event.x, event.y) } + // Hover selects only when the pointer itself moves. Cells that + // scroll or rebuild under a resting pointer would otherwise + // pull the selection away from the keyboard. onPositionChanged: function(event) { - if (!(pressedButtons & Qt.LeftButton)) return + if (!(pressedButtons & Qt.LeftButton)) { + root.selectFromPointer(cell.index, cellMouse, event) + return + } if (!cell.moved && Math.hypot(event.x - cell.pressPoint.x, event.y - cell.pressPoint.y) > Style.space(4)) cell.moved = true if (cell.moved) root.dragTo(cell.modelData, cell.mapToItem(panel.contentItem, event.x, event.y)) } @@ -394,6 +558,7 @@ Item { onClicked: function(event) { if (cell.moved) return if (event.button === Qt.RightButton) { + root.selectedIndex = cell.index root.openMenu(cell.modelData, cell.mapToItem(panel.contentItem, event.x, event.y)) return } @@ -404,6 +569,41 @@ Item { } } + // Scroll scrims, as in the menu. The clipped row marks the fold at + // rest; these keep both edges honest once the grid has been scrolled + // and rows hide above as well as below. Strength tracks the distance + // still hidden past each edge rather than a clock, so a jump lands + // with the fade already applied. + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: Math.min(Style.space(28), parent.height / 2) + visible: opacity > 0 + opacity: grid.contentHeight > grid.height + ? Math.max(0, Math.min(1, (grid.contentY - grid.originY) / height)) + : 0 + gradient: Gradient { + GradientStop { position: 0; color: Commons.Color.menu.background } + GradientStop { position: 1; color: Util.alpha(Commons.Color.menu.background, 0) } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: Math.min(Style.space(28), parent.height / 2) + visible: opacity > 0 + opacity: grid.contentHeight > grid.height + ? Math.max(0, Math.min(1, (grid.originY + grid.contentHeight - grid.height - grid.contentY) / height)) + : 0 + gradient: Gradient { + GradientStop { position: 0; color: Util.alpha(Commons.Color.menu.background, 0) } + GradientStop { position: 1; color: Commons.Color.menu.background } + } + } + Text { anchors.centerIn: parent visible: root.apps.length === 0 diff --git a/shell/plugins/dock/DockModel.js b/shell/plugins/dock/DockModel.js index 4f4627f56ae..bbeda496c5d 100644 --- a/shell/plugins/dock/DockModel.js +++ b/shell/plugins/dock/DockModel.js @@ -171,6 +171,63 @@ function pickWindow(windows, workspaceId) { } +// The app grid's arithmetic: which app an arrow key lands on, where the view +// scrolls to keep it in sight, and how tall the selection tile is. + +// The app `rows` rows above (negative) or below (positive) `index`, or `index` +// itself when there is nowhere to go. Moving stops at the first and last rows +// instead of wrapping or jumping to the first or last app. A move that would +// leave the grid goes as far as it can: below a short last row, to its last +// app; above, to the first row in the same column. +function gridStepRows(index, count, columns, rows) { + if (count <= 0) return index + var width = Math.max(1, Math.floor(columns)) + var row = Math.floor(index / width) + var target = index + rows * width + + if (target < 0) { + if (row === 0) return index + return index % width + } + if (target > count - 1) { + if (row >= Math.floor((count - 1) / width)) return index + return count - 1 + } + return target +} + +// Where the view should sit so an app's row, once it is in view, keeps the +// next hidden row peeking past it in the direction of travel. Fitting the app +// into the view alone parks its row flush with the edge, hiding the neighbour +// and losing the fold. `view` is { contentY, originY, height, contentHeight }; +// `item` is { y, height } in content coordinates. +function gridPeekedContentY(view, item, peek, hasNext, hasPrevious) { + var y = view.contentY + + if (hasNext) { + var maxY = Math.max(view.originY, view.originY + view.contentHeight - view.height) + var overhang = item.y + item.height + peek - (y + view.height) + if (overhang > 0) y = Math.min(y + overhang, maxY) + } + if (hasPrevious) { + var underhang = y - (item.y - peek) + if (underhang > 0) y = Math.max(y - underhang, view.originY) + } + return y +} + +// The selection tile hugs its icon and name: the same air above the icon as +// below the name, whether the name takes one line or two. +function gridTileHeight(paddingY, iconSize, labelGap, labelHeight) { + return paddingY * 2 + iconSize + labelGap + Math.ceil(labelHeight) +} + +// A cell holds a tile with a gutter around it, sized for a name of the most +// lines it may take, so the icons of a row line up whatever their names are. +function gridCellHeight(gutter, paddingY, iconSize, labelGap, labelMaxHeight) { + return gutter * 2 + gridTileHeight(paddingY, iconSize, labelGap, labelMaxHeight) +} + if (typeof module !== "undefined") { module.exports = { normalizePosition: normalizePosition, @@ -185,6 +242,10 @@ if (typeof module !== "undefined") { orderWithKeyAt: orderWithKeyAt, dropIndex: dropIndex, pinsInOrder: pinsInOrder, - pickWindow: pickWindow + pickWindow: pickWindow, + gridStepRows: gridStepRows, + gridPeekedContentY: gridPeekedContentY, + gridTileHeight: gridTileHeight, + gridCellHeight: gridCellHeight } } diff --git a/test/shell.d/app-grid-test.sh b/test/shell.d/app-grid-test.sh new file mode 100755 index 00000000000..8e719d44924 --- /dev/null +++ b/test/shell.d/app-grid-test.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +ROOT="$ROOT" run_node_test <<'JS' +const fs = require('fs') + +// AppGrid.qml needs Quickshell, so it cannot be loaded here. Its arrow-key +// logic is plain JavaScript, though, so the function is lifted out of the +// source and run on a stand-in grid: what is tested is the code that ships. +const raw = fs.readFileSync(path.join(root, 'shell/plugins/dock/AppGrid.qml'), 'utf8') + +// Comments are stripped so a note about what the code no longer does cannot +// fail a check on what it does. +const source = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/\s\/\/ .*$/gm, '') + +function functionBody(name) { + const start = source.indexOf(`function ${name}(`) + if (start < 0) fail(`AppGrid.qml defines ${name}`) + const open = source.indexOf('{', source.indexOf(')', start)) + let depth = 0 + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++ + else if (source[i] === '}' && --depth === 0) return source.slice(open + 1, i) + } + return fail(`AppGrid.qml closes ${name}`) +} + +const selectRows = new Function('root', 'rows', functionBody('selectRows')) + +// The app selectRows ends on for a grid of `count` apps in `columns` columns, +// with `index` selected. +function move(index, rows, count = 25, columns = 7) { + const grid = { + selectedIndex: index, + columns, + apps: new Array(count), + selectIndex(next) { grid.selectedIndex = next } + } + selectRows(grid, rows) + return grid.selectedIndex +} + +// --- arrow keys --------------------------------------------------------------- +// 25 apps in 7 columns: rows 0-2 are full, row 3 holds apps 21-24. +assertEqual(move(3, 1), 10, 'Down moves to the app below') +assertEqual(move(10, -1), 3, 'Up moves to the app above') +assertEqual(move(3, -1), 3, 'Up on the first row stays put instead of jumping to the first app') +assertEqual(move(22, 1), 22, 'Down on the last row stays put instead of jumping to the last app') +assertEqual(move(20, 1), 24, 'Down below a short last row lands on its last app') +assertEqual(move(17, 1), 24, 'Down lands on the app directly below when there is one') +assertEqual(move(17, -1), 10, 'Up from the row above a short row is a plain step') +assertEqual(move(17, -3), 3, 'a page up that would leave the grid lands on the first row in the same column') +assertEqual(move(5, 3), 24, 'a page down that would leave the grid lands on the last app') +assertEqual(move(3, 3), 24, 'a page down that fits lands on the app that far below') +assertEqual(move(0, 1, 0), 0, 'an empty grid has nowhere to go') +assertEqual(move(0, 1, 1), 0, 'a single app has nowhere to go') +assertEqual(move(2, 1, 5, 1), 3, 'a single column moves one app at a time') + +// Whatever the size of the grid, a move only goes the way it was asked, never +// wraps, never leaves the grid, and goes no further than it was asked. +let moves = 0 +for (let count = 1; count <= 40; count++) { + for (let columns = 1; columns <= 8; columns++) { + for (let index = 0; index < count; index++) { + for (let rows = -4; rows <= 4; rows++) { + const next = move(index, rows, count, columns) + const from = Math.floor(index / columns) + const to = Math.floor(next / columns) + const ok = next >= 0 && next < count + && (rows === 0 ? next === index : true) + && (rows > 0 ? to >= from && to <= from + rows : true) + && (rows < 0 ? to <= from && to >= from + rows : true) + && (next === index || to !== from) + && (rows > 0 ? next >= index : next <= index) + if (!ok) fail('a move stays inside the grid and goes the way it was asked', + `count ${count}, columns ${columns}, index ${index}, rows ${rows} gave ${next}`) + moves++ + } + } + } +} +pass(`a move stays inside the grid and goes the way it was asked (${moves} moves)`) + +// --- the grid, held to what the change set out to do --------------------------- +// The selection tile is as tall as its icon and name, not as its cell: a cell +// always has room for two lines, so a tile cut to the cell leaves empty space +// below a one-line name. +const tile = /Rectangle\s*\{\s*id:\s*tile\b[^}]*\}/.exec(source) +assert(tile !== null, 'the selection is a tile of its own') +assert(/height:[^\n]*label\.contentHeight/.test(tile[0]), 'the selection tile is as tall as the name it holds') +assert(!/height:[^\n]*cellHeight/.test(tile[0]), 'the selection tile is not cut to the cell, which would leave empty space below a one-line name') + +assert(/FilterInput\s*\{[^}]*keyHandler:\s*keyCatcher/.test(source), 'the search line is a FilterInput the grid handles keys for, so input methods reach it') +assert(!/root\.filterText\s*\+\s*event\.text/.test(source), 'the grid does not type into the search itself, which would leave input methods out') +assert(!/Qt\.callLater\(function\(\) \{ keyCatcher\.forceActiveFocus/.test(source), 'focus goes to the search line, not to the key catcher behind it') + +assert(/PointerMoveGate\s*\{/.test(source) && /pointerGate\.moved\(/.test(source), 'hover selects through the pointer gate, so scrolling cells do not take the selection') +assert(!/onContainsMouseChanged/.test(source), 'hover does not select on cells arriving under a resting pointer') + +assert(/WheelHandler\s*\{/.test(source) && /GradientStop/.test(source) && !/ScrollBar|scrollbar/i.test(source), 'the grid scrolls the way the menu does: its wheel and edge scrims, no scrollbar') + +assert(/event\.text === " " && !root\.filterText/.test(source), 'a space at the start of a search is dropped') +JS diff --git a/test/shell.d/dock-test.sh b/test/shell.d/dock-test.sh index b41452c511e..4c58972b39f 100755 --- a/test/shell.d/dock-test.sh +++ b/test/shell.d/dock-test.sh @@ -109,6 +109,72 @@ assertEqual(dock.pickWindow(foot, 3), 1, 'dock restores a minimized window befor assertEqual(dock.pickWindow(foot.map((w, i) => Object.assign({}, w, { activated: i === 2 })), 4), 1, 'dock cycles from the window here to the minimized one') assertEqual(dock.pickWindow([], 4), -1, 'dock has no window to pick for an app that is not running') +// The app grid: which app an arrow key lands on. 25 apps in 7 columns fill +// rows 0-2 and put apps 21-24 on row 3. +const step = (index, rows, count = 25, columns = 7) => dock.gridStepRows(index, count, columns, rows) +assertEqual(step(3, 1), 10, 'app grid moves Down to the app below') +assertEqual(step(10, -1), 3, 'app grid moves Up to the app above') +assertEqual(step(3, -1), 3, 'app grid stays put on Up in the first row instead of jumping to the first app') +assertEqual(step(22, 1), 22, 'app grid stays put on Down in the last row instead of jumping to the last app') +assertEqual(step(20, 1), 24, 'app grid lands on the last app when Down goes below a short last row') +assertEqual(step(17, 1), 24, 'app grid lands on the app directly below when there is one') +assertEqual(step(17, -3), 3, 'app grid lands a page up that would leave the grid on the first row in the same column') +assertEqual(step(5, 3), 24, 'app grid lands a page down that would leave the grid on the last app') +assertEqual(step(0, 1, 0), 0, 'app grid has nowhere to go when it is empty') +assertEqual(step(2, 1, 5, 1), 3, 'app grid moves one app at a time in a single column') +assertEqual(step(2, 1, 25, 0), 3, 'app grid reads no columns as one column') + +// Whatever its size, a move goes only the way it was asked, never wraps, +// never leaves the grid, and goes no further than it was asked. +for (let count = 1; count <= 40; count++) { + for (let columns = 1; columns <= 8; columns++) { + for (let index = 0; index < count; index++) { + for (let rows = -4; rows <= 4; rows++) { + const next = dock.gridStepRows(index, count, columns, rows) + const from = Math.floor(index / columns) + const to = Math.floor(next / columns) + const ok = next >= 0 && next < count + && (rows === 0 ? next === index : true) + && (rows > 0 ? to >= from && to <= from + rows : true) + && (rows < 0 ? to <= from && to >= from + rows : true) + && (next === index || to !== from) + && (rows > 0 ? next >= index : next <= index) + if (!ok) fail('app grid moves stay inside the grid and go the way they were asked', + `count ${count}, columns ${columns}, index ${index}, rows ${rows} gave ${next}`) + } + } + } +} +pass('app grid moves stay inside the grid and go the way they were asked') + +// A 600px view over 1000px of rows, 150px each, with a 56px peek. +const gridView = (contentY, originY = 0) => ({ contentY, originY, height: 600, contentHeight: 1000 }) +const peeked = (contentY, row, next, previous, originY = 0) => + dock.gridPeekedContentY(gridView(contentY, originY), { y: row * 150, height: 150 }, 56, next, previous) +assertEqual(peeked(0, 1, true, true), 0, 'app grid leaves the view alone for a row with room around it') +assertEqual(peeked(0, 3, true, true), 56, 'app grid scrolls a peek of the next row into view past a row flush with the bottom') +assertEqual(peeked(0, 3, false, true), 0, 'app grid has no next row to peek at after the last row') +assertEqual(peeked(300, 2, true, true), 244, 'app grid scrolls a peek of the previous row into view past a row flush with the top') +assertEqual(peeked(300, 2, true, false), 300, 'app grid has no previous row to peek at before the first row') +assertEqual(peeked(0, 0, true, true), 0, 'app grid never scrolls above its origin') +assertEqual(peeked(380, 6, true, false), 400, 'app grid never scrolls past the end of its content') +assertEqual( + dock.gridPeekedContentY({ contentY: 100, originY: 100, height: 600, contentHeight: 1000 }, { y: 100, height: 150 }, 56, false, true), + 100, + 'app grid never scrolls above a non-zero origin' +) + +// The selected tile is as tall as its icon and name, not its cell, which has +// room for two lines: cut to the cell it leaves empty space under a one-line name. +const [gutter, padding, icon, gap, line] = [4, 10, 56, 6, 16] +const oneLine = dock.gridTileHeight(padding, icon, gap, line) +const twoLines = dock.gridTileHeight(padding, icon, gap, line * 2) +assertEqual(oneLine, padding * 2 + icon + gap + line, 'app grid tile is as tall as its icon, name and padding') +assertEqual(oneLine - (padding + icon + gap + line), padding, 'app grid tile has as much air below a one-line name as above its icon') +assertEqual(twoLines - oneLine, line, 'app grid tile grows by exactly the line a longer name adds') +assertEqual(dock.gridTileHeight(padding, icon, gap, 15.2), padding * 2 + icon + gap + 16, 'app grid rounds a fractional name height up so the name is not clipped') +assertEqual(dock.gridCellHeight(gutter, padding, icon, gap, line * 2), gutter * 2 + twoLines, 'app grid cell holds a two-line tile and a gutter on both sides') + JS home="$TMPDIR/home"