import QtQuick import QtQuick.Window import NomadInput 1.0 QtObject { // this is our top screen, for now, though in the future I would like to have it configurable which screen does which. // I'll burn that bridge when I get to it property Window topWindow: Window { width: 960 // this resolution size is for development. I'd like to have a cleaner way to develop for 1080p without getting a dedicated monitor for testing nomad on my dev computer height: 540 visible: true title: "nomad-top" // this is necesarry for Sway, which auto-assigns the windows property string lastInputText: "none" // I've never really integrated NomadInput into this debug section in the top half and now I'm too lazy to. it's just a placeholder anyway. Connections { target: inputManager function onActionPressed(action) { topWindow.lastInputText = inputManager.actionName(action) + " pressed" } function onActionReleased(action) { topWindow.lastInputText = inputManager.actionName(action) + " released" } } Rectangle { // just a placeholder for the top screen until we do something better anchors.fill: parent color: "#181818" Column { anchors.centerIn: parent spacing: 20 Text { text: "nomad top screen" color: "white" font.pixelSize: 50 } Text { text: "last input: " + topWindow.lastInputText color: "white" font.pixelSize: 30 } } } } property Window bottomWindow: Window { width: 620 // again, half-resolution height: 540 visible: true title: "nomad-bottom" // window titling for Sway auto-assign NavigationManager { id: navigationManager rootItem: bottomRoot } Connections { target: inputManager // you don't wanna know what this used to look like :dread: // just checks if our navigation is directional, and, if so, moves the selection in that direction function onActionPressed(action) { switch (action) { case InputManager.Up: case InputManager.Down: case InputManager.Left: case InputManager.Right: navigationManager.move(action) break } } } Rectangle { id: bottomRoot anchors.fill: parent color: "#181818" // top text where a menu dock will probably be eventually Text { anchors.top: parent.top anchors.topMargin: 30 anchors.horizontalCenter: parent.horizontalCenter text: "top placeholder" color: "white" font.pixelSize: 24 } // this is the game carousel. mostly self explanatory, but I wanna point out that the local index for this listView does probably still matter. // I got rid of dedicated menu indexing for the dock, but I think it still matters here, for the sake of auto-scrolling ListView { id: gamesList anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter // these properties are primarily for the gamesList snapping readonly property int cardWidth: 120 readonly property int cardSpacing: 16 readonly property int cardPitch: cardWidth + cardSpacing readonly property int safeLeft: 90 readonly property int safeRight: width - 90 readonly property int edgePeek: 98 readonly property int snapOffset: cardWidth - edgePeek property real scrollTarget: contentX height: 200 orientation: ListView.Horizontal spacing: 16 leftMargin: 16 rightMargin: 16 model: 20 // this is a placeholder for now, I don't know how I really want to handle max game carousel size yet. another time. // provides the smoothing animation for game selection NumberAnimation { id: scrollAnimation target: gamesList property: "contentX" duration: 200 easing.type: Easing.OutCubic } // used to scroll to a new game on the game list function scrollTo(targetX) { if (Math.abs(targetX - gamesList.contentX) < 0.5) { return } scrollAnimation.stop() scrollAnimation.from = gamesList.contentX scrollAnimation.to = targetX scrollAnimation.start() } // provides snapping logic so that the games list is snapped to a card pitch function snappedContentX(snapTargetX) { // set up a relative coord for our snapping logic, so we line up right let relativeTarget = snapTargetX - gamesList.originX // finds the nearest point on the snap grid // it's pretty obtuse, so as an explanation: we subtract snapOffset first so the grid becomes a // simple 0, cardPitch, 2*cardPitch, etc grid. Divide by cardPitch to get the nearest grid index, // round it, then multiply by cardPitch to convert that index back to a position. Finally we // wanna add snapOffset (and originX) back to line it up with our true grid. let snappedTargetX = gamesList.originX + Math.round((relativeTarget - gamesList.snapOffset) / gamesList.cardPitch) * gamesList.cardPitch + gamesList.snapOffset // calculate the furthest possible ends of the carousel // Math.max is for clamping it to make sure even if there are very few games on the board that it // wont return something stupid let minimum = gamesList.originX - gamesList.leftMargin let maximum = gamesList.originX + Math.max(0, gamesList.contentWidth + gamesList.leftMargin + gamesList.rightMargin - gamesList.width) - gamesList.leftMargin // this section of code is here to make sure that when the game carousel is flicked all the way // to the leftmost or rightmost ends, it doesn't snap and cutoff the leftmost or rightmost // card in a way that feels janky. it works by calculating the distance from the current target // to the very edges, and if it's closer to one of the edges, just snap to the edge. let bestTarget = snappedTargetX let bestDistance = Math.abs(snapTargetX - snappedTargetX) let distanceToMinimum = Math.abs(snapTargetX - minimum) if (distanceToMinimum < bestDistance) { bestTarget = minimum bestDistance = distanceToMinimum } let distanceToMaximum = Math.abs(snapTargetX - maximum) if (distanceToMaximum < bestDistance) { bestTarget = maximum } return Math.max(minimum, Math.min(bestTarget, maximum)) } // this is a function to make sure the games list scrolls, but in a predictable, nice-feeling way. // specifically, it scrolls but makes sure the games list is snapped to a specific card pitch. function ensureCurrentInSafeZone() { if (!gamesList.currentItem) { return } let item = gamesList.currentItem let itemLeft = item.x - gamesList.contentX let itemRight = itemLeft + item.width let targetContentX = gamesList.contentX if (itemLeft < gamesList.safeLeft) { targetContentX -= gamesList.safeLeft - itemLeft } else if (itemRight > gamesList.safeRight) { targetContentX += itemRight - gamesList.safeRight } else { return } gamesList.scrollTo(gamesList.snappedContentX(targetContentX)) } Component.onCompleted: { // on startup, this should select the first game in the list. if (currentItem) { navigationManager.focusItem(currentItem) } } // every time the index changes, we wanna re-calculate the game carousel's position onCurrentIndexChanged: { ensureCurrentInSafeZone() } // after flicking, we re-snap the carousel wherever it landed onMovementEnded: { gamesList.scrollTo(gamesList.snappedContentX(gamesList.contentX)) } delegate: GameCard { id: gameTile highlighted: navigationManager.currentItem === gameTile function onNavigationFocused() { gamesList.currentIndex = index } Component.onCompleted: { navigationManager.registerItem(gameTile) } Component.onDestruction: { navigationManager.unregisterItem(gameTile) } MouseArea { anchors.fill: parent onClicked: { navigationManager.focusItem(gameTile) } } } } Row { id: bottomMenu anchors.bottom: parent.bottom anchors.bottomMargin: 30 anchors.horizontalCenter: parent.horizontalCenter spacing: 16 Repeater { id: dockRepeater model: 5 Rectangle { id: dockButton width: 60 height: 60 color: navigationManager.currentItem === dockButton ? "blue" : "white" function onNavigationFocused() { // there used to be code here when my navigation was worse. I bet I'll need it eventually tho // this entire section is a placeholder dock button anyway though. } Component.onCompleted: { navigationManager.registerItem(dockButton) } Component.onDestruction: { navigationManager.unregisterItem(dockButton) } MouseArea { anchors.fill: parent onClicked: { navigationManager.focusItem(dockButton) } } } } } } } }