From: Niki Roo Date: Tue, 25 Mar 2025 23:19:31 +0000 (+0100) Subject: Qt client, first step X-Git-Url: http://git.nikiroo.be/?a=commitdiff_plain;h=a46e774054fd8898c0fbaaf033a5b9b90d1868b8;p=gamiki.git Qt client, first step --- diff --git a/gamiki-qt.py b/gamiki-qt.py new file mode 100755 index 0000000..3107704 --- /dev/null +++ b/gamiki-qt.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +from sys import argv, exit + +try: + from PyQt6.QtGui import QIcon + from PyQt6.QtCore import * + from PyQt6.QtWidgets import * +except: + try: + # apt-get install python3-pyqt5 + from PyQt5.QtGui import QIcon + from PyQt5.QtCore import * + from PyQt5.QtWidgets import * + except: + from PyQt4.QtGui import QIcon + from PyQt4.QtCore import * + from PyQt4.QtWidgets import * + +from gamiki import Builder, Library +from gamiki.qt.tiles import Grid, List, startGame + +class MainWindow(QMainWindow): + def __init__(self, centre: QWidget): + super().__init__() + self.initUi(centre) + + def initUi(self, centre: QWidget): + # Title/Icon + self.setWindowTitle("Testy window") + self.setWindowIcon(QIcon('gamiki.png')) + + # Menu + self.menu = self.menuBar() + + ## Menu: File (Exit) + self.file_menu = self.menu.addMenu("File") + exit_action = self.file_menu.addAction("Exit", self.close) + + # Central widget: Centre + self.setCentralWidget(centre) + + # Size and visibility + self.resize(800, 600) + +if __name__ == '__main__': + + #QMessageBox.warning(window, "Testy", "Mess") + + app = QApplication(argv) + + #centre = List(Builder().games) + centre = Grid(Builder().games) + centre.onClick.connect(startGame) + window = MainWindow(centre) + window.show() + + exit(app.exec_()) + diff --git a/gamiki/qt/flow_layout.py b/gamiki/qt/flow_layout.py new file mode 100755 index 0000000..90ddd80 --- /dev/null +++ b/gamiki/qt/flow_layout.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 + +############################################################################# +## +## Copyright (C) 2013 Riverbank Computing Limited. +## Copyright (C) 2010 Nokia Corporation and/or its subsidiary(-ies). +## All rights reserved. +## +## This file is part of the examples of PyQt. +## +## $QT_BEGIN_LICENSE:BSD$ +## You may use this file under the terms of the BSD license as follows: +## +## "Redistribution and use in source and binary forms, with or without +## modification, are permitted provided that the following conditions are +## met: +## * Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## * Redistributions in binary form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. +## * Neither the name of Nokia Corporation and its Subsidiary(-ies) nor +## the names of its contributors may be used to endorse or promote +## products derived from this software without specific prior written +## permission. +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +## $QT_END_LICENSE$ +## +############################################################################# + +try: + from PyQt6.QtCore import * + from PyQt6.QtWidgets import * +except: + try: + from PyQt5.QtCore import * + from PyQt5.QtWidgets import * + except: + from PyQt4.QtCore import * + from PyQt4.QtWidgets import * + +class FlowLayout(QLayout): + + widthChanged = pyqtSignal(int) + + def __init__(self, parent=None, margin=0, spacing=-1, orientation=Qt.Orientation.Horizontal): + super(FlowLayout, self).__init__(parent) + + if parent is not None: + self.setContentsMargins(margin, margin, margin, margin) + + self.setSpacing(spacing) + self.itemList = [] + self.orientation = orientation + + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) + + def addItem(self, item): + self.itemList.append(item) + + def count(self): + return len(self.itemList) + + def itemAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList[index] + + return None + + def takeAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList.pop(index) + + return None + + def expandingDirections(self): + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + if (self.orientation == Qt.Orientation.Horizontal): + return self.doLayoutHorizontal(QRect(0, 0, width, 0), True) + elif (self.orientation == Qt.Orientation.Vertical): + return self.doLayoutVertical(QRect(0, 0, width, 0), True) + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + if (self.orientation == Qt.Orientation.Horizontal): + self.doLayoutHorizontal(rect, False) + elif (self.orientation == Qt.Orientation.Vertical): + self.doLayoutVertical(rect, False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + + for item in self.itemList: + size = size.expandedTo(item.minimumSize()) + + margin, _, _, _ = self.getContentsMargins() + + size += QSize(2 * margin, 2 * margin) + return size + + def doLayoutHorizontal(self, rect, testOnly): + # Get initial coordinates of the drawing region (should be 0, 0) + x = rect.x() + y = rect.y() + lineHeight = 0 + i = 0 + for item in self.itemList: + wid = item.widget() + # Space X and Y is item spacing horizontally and vertically + spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.ControlType.PushButton, QSizePolicy.ControlType.PushButton, Qt.Orientation.Horizontal) + spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.ControlType.PushButton, QSizePolicy.ControlType.PushButton, Qt.Orientation.Vertical) + # Determine the coordinate we want to place the item at + # It should be placed at : initial coordinate of the rect + width of the item + spacing + nextX = x + item.sizeHint().width() + spaceX + # If the calculated nextX is greater than the outer bound... + if nextX - spaceX > rect.right() and lineHeight > 0: + x = rect.x() # Reset X coordinate to origin of drawing region + y = y + lineHeight + spaceY # Move Y coordinate to the next line + nextX = x + item.sizeHint().width() + spaceX # Recalculate nextX based on the new X coordinate + lineHeight = 0 + + if not testOnly: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = nextX # Store the next starting X coordinate for next item + lineHeight = max(lineHeight, item.sizeHint().height()) + i = i + 1 + + return y + lineHeight - rect.y() + + def doLayoutVertical(self, rect, testOnly): + # Get initial coordinates of the drawing region (should be 0, 0) + x = rect.x() + y = rect.y() + # Initalize column width and line height + columnWidth = 0 + lineHeight = 0 + + # Space between items + spaceX = 0 + spaceY = 0 + + # Variables that will represent the position of the widgets in a 2D Array + i = 0 + j = 0 + for item in self.itemList: + wid = item.widget() + # Space X and Y is item spacing horizontally and vertically + spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.ControlType.PushButton, QSizePolicy.ControlType.PushButton, Qt.Orientation.Horizontal) + spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.ControlType.PushButton, QSizePolicy.ControlType.PushButton, Qt.Orientation.Vertical) + # Determine the coordinate we want to place the item at + # It should be placed at : initial coordinate of the rect + width of the item + spacing + nextY = y + item.sizeHint().height() + spaceY + # If the calculated nextY is greater than the outer bound, move to the next column + if nextY - spaceY > rect.bottom() and columnWidth > 0: + y = rect.y() # Reset y coordinate to origin of drawing region + x = x + columnWidth + spaceX # Move X coordinate to the next column + nextY = y + item.sizeHint().height() + spaceY # Recalculate nextX based on the new X coordinate + # Reset the column width + columnWidth = 0 + + # Set indexes of the item for the 2D array + j += 1 + i = 0 + + # Assign 2D array indexes + item.x_index = i + item.y_index = j + + # Only call setGeometry (which place the actual widget using coordinates) if testOnly is false + # For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??) + if not testOnly: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + y = nextY # Store the next starting Y coordinate for next item + columnWidth = max(columnWidth, item.sizeHint().width()) # Update the width of the column + lineHeight = max(lineHeight, item.sizeHint().height()) # Update the height of the line + + i += 1 # Increment i + + # Only call setGeometry (which place the actual widget using coordinates) if testOnly is false + # For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??) + if not testOnly: + self.calculateMaxWidth(i) + #print("Total width : " + str(totalWidth)) + self.widthChanged.emit(self.totalMaxWidth + spaceX * self.itemsOnWidestRow) + #self.widthChanged.emit(self.totalMaxWidth) + return lineHeight + + # Method to calculate the maximum width among each "row" of the flow layout + # This will be useful to let the UI know the total width of the flow layout + def calculateMaxWidth(self, numberOfRows): + # Init variables + self.totalMaxWidth = 0 + self.itemsOnWidestRow = 0 + + # For each "row", calculate the total width by adding the width of each item + # and then update the totalMaxWidth if the calculated width is greater than the current value + # Also update the number of items on the widest row + for i in range(numberOfRows): + rowWidth = 0 + itemsOnWidestRow = 0 + for item in self.itemList: + # Only compare items from the same row + if (item.x_index == i): + rowWidth += item.sizeHint().width() + itemsOnWidestRow += 1 + if (rowWidth > self.totalMaxWidth): + self.totalMaxWidth = rowWidth + self.itemsOnWidestRow = itemsOnWidestRow + +class Window(QWidget): + def __init__(self): + super(Window, self).__init__() + + flowLayout = FlowLayout( + # Vertical 1/2 + #orientation=Qt.Vertical + ) + + flowLayout.addWidget(QPushButton("Short")) + flowLayout.addWidget(QPushButton("Longer")) + flowLayout.addWidget(QPushButton("Different text")) + flowLayout.addWidget(QPushButton("More text")) + flowLayout.addWidget(QPushButton("Even longer button text")) + self.setLayout(flowLayout) + + # Vertical 2/2 + if (flowLayout.orientation == Qt.Vertical): + flowLayout.widthChanged.connect(self.setMinimumWidth) + + self.setWindowTitle("Flow Layout") + +if __name__ == '__main__': + + import sys + + app = QApplication(sys.argv) + mainWin = Window() + mainWin.show() + sys.exit(app.exec_()) + diff --git a/gamiki/qt/tiles.py b/gamiki/qt/tiles.py new file mode 100755 index 0000000..68f4dae --- /dev/null +++ b/gamiki/qt/tiles.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 + +# TODO: add QScrollArea + +from sys import argv, exit +from enum import IntFlag +from pathlib import PurePath + +try: + from PyQt6.QtCore import * + from PyQt6.QtWidgets import * + from PyQt6.QtGui import QPixmap +except: + try: + from PyQt5.QtCore import * + from PyQt5.QtWidgets import * + from PyQt5.QtGui import QPixmap + except: + from PyQt4.QtCore import * + from PyQt4.QtWidgets import * + from PyQt4.QtGui import QPixmap + +from gamiki import Game +from gamiki.qt.flow_layout import FlowLayout + +pool = QThreadPool() +def startGame(game): + class Worker(QRunnable): + def run(self): game.start() + worker = Worker() + pool.start(worker) + +def setBg(widget: QWidget, color: str): + if (";" in color): + raise RuntimeException("No colour can contain a `;'") + + if (color): + bg = ("background-color: " + color + ";") + elif hasattr(widget, "bgColor"): + bg = ("background-color: " + widget.bgColor + ";") + else: + bg = "" + + changed = False + attribs = widget.styleSheet().split(";") + for i, attr in enumerate(attribs): + if (attr.strip().startswith("background-color:")): + attribs[i] = bg if color else "" + changed = True + if (not changed) and (color): + attribs.append(bg) + + widget.setStyleSheet(";".join(attribs) + ";") + +def addOverlay(self: QWidget, target: QWidget): + if (not hasattr(self, "overlay" )): self.overlay = [] + if (not hasattr(self, "clicked" )): self.clicked = False + if (not hasattr(self, "hovered" )): self.hovered = False + if (not hasattr(self, "selected")): self.selected = False + + overlay = QWidget() + overlay.setAttribute(Qt.WA_TransparentForMouseEvents) + overlay.setParent(target) + overlay.move(0, 0) + overlay.bgColor = "rgba(0, 0, 0, 0)" + + self.overlay.append(overlay) + +def fixColor(widget: QWidget): + if (widget.clicked): + bg = "rgba(200, 200, 250, 60)" + elif (widget.hovered and widget.selected): + bg = "rgba(0, 0, 100, 60)" + elif (widget.hovered): + bg = "rgba(150, 150, 200, 60)" + elif (widget.selected): + bg = "rgba(0, 0, 100, 60)" + else: + bg = "" + + for overlay in widget.overlay: + setBg(overlay, bg) + +class CoverMode(IntFlag): + LIST = 1, + COVER = 2, + FIXED = 4 + +class Cover(QWidget): + onClick = pyqtSignal(Game) + clicked = False + selected = False + hovered = False + + def __init__(self, game: Game, mode: CoverMode = None, size: int = 0): + super().__init__() + + self.game = game + self.cover_mode = mode if (mode) else (CoverMode.LIST|CoverMode.FIXED) + + if (size > 0): + self.cover_size = size + else: + self.cover_size = 48 if (CoverMode.LIST in self.cover_mode) else 200 + + self.initUI() + + def _resetMinMax(self): + if (CoverMode.LIST in self.cover_mode): + self.line1.setMinimumWidth(self.cover_size) + else: + self.line1.setMaximumWidth(self.cover_size) + + if (CoverMode.LIST in self.cover_mode): + self.line2.setMinimumWidth(self.cover_size) + else: + self.line2.setMaximumWidth(self.cover_size) + + def _initUI_text(self): + self.line1 = QLabel(self.game.name) + self.line1.setStyleSheet("font-size: 20px; font-weight: bold;") + + self.line2 = QLabel(self.game.tags[0] if (self.game.tags) else "") + self.line2.setStyleSheet("") + self.line2.setAlignment(Qt.AlignRight) + + self._resetMinMax() + + def initUI(self): + self._initUI_text() + + pixmap = QPixmap(self.game["Icon"] if ("Icon" in self.game) else "") + + self.cover = QLabel() + self.cover.setPixmap(pixmap) + self.cover.setScaledContents(True) + self.cover.setMinimumSize(self.cover_size, self.cover_size) + self.cover.setMaximumSize(self.cover_size, self.cover_size) + + line1sub = QWidget() + hlay = QHBoxLayout() + hlay.setContentsMargins(0, 0, 0, 0) + hlay.addWidget(self.line1) + hlay.addStretch() + line1sub.setLayout(hlay) + + sub = QWidget() + hlay = QVBoxLayout() + hlay.setContentsMargins(0, 0, 0, 0) + hlay.addWidget(line1sub) + hlay.addWidget(self.line2) + sub.setLayout(hlay) + + if CoverMode.LIST in self.cover_mode: + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.cover) + layout.addWidget(sub) + else: + layout = QVBoxLayout() + layout.addWidget(self.cover) + layout.addWidget(sub) + layout.addStretch() + + addOverlay(self, self) + addOverlay(self, self.cover) + + self.setLayout(layout) + + def resizeEvent(self, event): + self.overlay[0].setFixedSize(event.size()) + + if (CoverMode.FIXED in self.cover_mode): + return super().resizeEvent(event) + + m = self.contentsMargins() + diff_w = (m.left() + m.right()) + diff_h = (m.top() + m.bottom()) + + s = event.size() + petit = min(s.width() - diff_w, s.height() - diff_h) + self.cover.setMaximumSize(petit, petit) + self.overlay[1].setFixedSize(self.cover.size()) + + self._resetMinMax() + + super().resizeEvent(event) + + def enterEvent(self, event): + self.hovered = True + fixColor(self) + super().enterEvent(event) + + def leaveEvent(self, event): + self.hovered = False + fixColor(self) + super().leaveEvent(event) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.clicked = True + fixColor(self) + + def mouseReleaseEvent(self, event): + if event.button() == Qt.LeftButton: + self.clicked = False + self.onClick.emit(self.game) + fixColor(self) + +class List(QWidget): + onClick = pyqtSignal(Game) + + def __init__(self, games: list[Game], icon_size: int = 0): + super().__init__() + + self.games = games + self.icon_size = icon_size + + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + #layout.setSpacing(0) + + for cover in self._gen_covers(CoverMode.LIST|CoverMode.FIXED): + layout.addWidget(cover) + layout.addStretch() + + self.setLayout(layout) + + def _gen_covers(self, mode: CoverMode) -> list[Cover]: + covers = [] + for game in self.games: + cover = Cover(game, mode, self.icon_size) + cover.onClick.connect(lambda game: self.onClick.emit(game)) + covers.append(cover) + return covers + + def _on_click(self, game): + print(repr(game)) + +class Grid(List): + def __init__(self, games: list[Game], icon_size: int = 0): + super().__init__(games, icon_size) + + def initUI(self): + layout = FlowLayout() + + for cover in self._gen_covers(CoverMode.COVER|CoverMode.FIXED): + layout.addWidget(cover) + + self.setLayout(layout) + +if __name__ == '__main__': + app = QApplication(argv) + + game1 = Game(None, PurePath("")) + game1["Name"] = "Super Buba" + game1["Icon"] = "./buba.jpg" + game1._init() + game2 = Game(None, PurePath("")) + game2["Name"] = "Super Buba II: le retour de la nouvelle bête" + game2["Icon"] = "./buba.jpg" + game2._init() + game3 = Game(None, PurePath("")) + game3["Name"] = "Fluffy" + game3["Icon"] = "./fluffy.jpg" + game3._init() + + #game_widget1 = Cover(game1) ; game_widget1.show() + #game_widget2 = Cover(game3, CoverMode.COVER) ; game_widget2.show() + list1 = List([ game1, game2, game3 ]) ; list1.show() + grid = Grid([ game1, game2, game3 ]) ; grid.show() + + list1.onClick.connect(lambda game: print("LIST:", game)) + grid.onClick.connect(lambda game: print("GRID:", game)) + + exit(app.exec_()) +