Files
nomad-shell/shell/qml/Main.qml
T

297 lines
12 KiB
QML

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)
}
}
}
}
}
}
}
}