Qt client, first step
authorNiki Roo <niki@nikiroo.be>
Tue, 25 Mar 2025 23:19:31 +0000 (00:19 +0100)
committerNiki Roo <niki@nikiroo.be>
Tue, 25 Mar 2025 23:19:31 +0000 (00:19 +0100)
gamiki-qt.py [new file with mode: 0755]
gamiki/qt/flow_layout.py [new file with mode: 0755]
gamiki/qt/tiles.py [new file with mode: 0755]

diff --git a/gamiki-qt.py b/gamiki-qt.py
new file mode 100755 (executable)
index 0000000..3107704
--- /dev/null
@@ -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 (executable)
index 0000000..90ddd80
--- /dev/null
@@ -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 (executable)
index 0000000..68f4dae
--- /dev/null
@@ -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_())
+