File indexing completed on 2024-04-28 07:51:12

0001 # -*- coding: utf-8 -*-
0002 
0003 """
0004 Copyright (C) 2009-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de>
0005 
0006 SPDX-License-Identifier: GPL-2.0
0007 
0008 """
0009 
0010 import datetime
0011 
0012 from qt import Qt, QAbstractTableModel
0013 from qt import QDialog, QDialogButtonBox, QWidget
0014 from qt import QHBoxLayout, QVBoxLayout, QAbstractItemView
0015 from qt import QItemSelectionModel, QGridLayout, QColor, QPalette
0016 
0017 from mi18n import i18n, i18nc, i18nE
0018 from kde import KApplication, KIcon, KDialogButtonBox
0019 
0020 from genericdelegates import RichTextColumnDelegate
0021 
0022 from log import logDebug
0023 from statesaver import StateSaver
0024 from rule import Ruleset
0025 from guiutil import ListComboBox, MJTableView, decorateWindow
0026 from differ import RulesetDiffer
0027 from common import Internal, Debug
0028 from modeltest import ModelTest
0029 from chat import ChatMessage, ChatWindow
0030 
0031 
0032 class TablesModel(QAbstractTableModel):
0033 
0034     """a model for our tables"""
0035 
0036     def __init__(self, tables, parent=None):
0037         super().__init__(parent)
0038         self.tables = tables
0039         assert isinstance(tables, list)
0040 
0041     def headerData(  # pylint: disable=no-self-use
0042             self, section,
0043             orientation, role=Qt.DisplayRole):
0044         """show header"""
0045         if role == Qt.TextAlignmentRole:
0046             if orientation == Qt.Horizontal:
0047                 if section in [3, 4]:
0048                     return int(Qt.AlignLeft)
0049                 return int(Qt.AlignHCenter | Qt.AlignVCenter)
0050         if role != Qt.DisplayRole:
0051             return None
0052         if orientation != Qt.Horizontal:
0053             return int(section + 1)
0054         result = ''
0055         if section < 5:
0056             result = [i18n('Table'),
0057                       '',
0058                       i18n('Players'),
0059                       i18nc('table status',
0060                             'Status'),
0061                       i18n('Ruleset')][section]
0062         return result
0063 
0064     def rowCount(self, parent=None):
0065         """how many tables are in the model?"""
0066         if parent and parent.isValid():
0067             # we have only top level items
0068             return 0
0069         return len(self.tables)
0070 
0071     def columnCount(self, unusedParent=None):  # pylint: disable=no-self-use
0072         """for now we only have id (invisible), id (visible), players,
0073         status, ruleset.name.
0074         id(invisible) always holds the real id, also 1000 for suspended tables.
0075         id(visible) is what should be displayed."""
0076         return 5
0077 
0078     def data(self, index, role=Qt.DisplayRole):
0079         """score table"""
0080         # pylint: disable=too-many-branches,too-many-locals
0081         result = None
0082         if role == Qt.TextAlignmentRole:
0083             if index.column() == 0:
0084                 result = int(Qt.AlignHCenter | Qt.AlignVCenter)
0085             else:
0086                 result = int(Qt.AlignLeft | Qt.AlignVCenter)
0087         if index.isValid() and (0 <= index.row() < len(self.tables)):
0088             table = self.tables[index.row()]
0089             if role == Qt.DisplayRole and index.column() in (0, 1):
0090                 result = table.tableid
0091             elif role == Qt.DisplayRole and index.column() == 2:
0092                 players = []
0093                 zipped = list(zip(table.playerNames, table.playersOnline))
0094                 for idx, pair in enumerate(zipped):
0095                     name, online = pair[0], pair[1]
0096                     if idx < len(zipped) - 1:
0097                         name += ', '
0098                     palette = KApplication.palette()
0099                     if online:
0100                         color = palette.color(
0101                             QPalette.Active,
0102                             QPalette.WindowText).name()
0103                         style = ('font-weight:normal;'
0104                                  'font-style:normal;color:%s'
0105                                  % color)
0106                     else:
0107                         color = palette.color(
0108                             QPalette.Disabled,
0109                             QPalette.WindowText).name()
0110                         style = ('font-weight:100;font-style:italic;color:%s'
0111                                  % color)
0112                     players.append(
0113                         '<nobr style="%s">' %
0114                         style +
0115                         name +
0116                         '</nobr>')
0117                 names = ''.join(players)
0118                 result = names
0119             elif role == Qt.DisplayRole and index.column() == 3:
0120                 status = table.status()
0121                 if table.suspendedAt:
0122                     dateVal = ' ' + datetime.datetime.strptime(
0123                         table.suspendedAt,
0124                         '%Y-%m-%dT%H:%M:%S').strftime('%c')
0125                     status = 'Suspended'
0126                 else:
0127                     dateVal = ''
0128                 result = i18nc('table status', status) + dateVal
0129             elif index.column() == 4:
0130                 if role == Qt.DisplayRole:
0131                     result = i18n((table.myRuleset if table.myRuleset else table.ruleset).name)
0132                 elif role == Qt.ForegroundRole:
0133                     palette = KApplication.palette()
0134                     color = palette.windowText().color() if table.myRuleset else 'red'
0135                     result = QColor(color)
0136         return result
0137 
0138 
0139 class SelectRuleset(QDialog):
0140 
0141     """a dialog for selecting a ruleset"""
0142 
0143     def __init__(self, server=None):
0144         QDialog.__init__(self, None)
0145         decorateWindow(self, i18n('Select a ruleset'))
0146         self.buttonBox = KDialogButtonBox(self)
0147         self.buttonBox.setStandardButtons(
0148             QDialogButtonBox.Cancel | QDialogButtonBox.Ok)
0149         self.buttonBox.accepted.connect(self.accept)
0150         self.buttonBox.rejected.connect(self.reject)
0151         self.cbRuleset = ListComboBox(Ruleset.selectableRulesets(server))
0152         self.grid = QGridLayout()  # our child SelectPlayers needs this
0153         self.grid.setColumnStretch(0, 1)
0154         self.grid.setColumnStretch(1, 6)
0155         vbox = QVBoxLayout(self)
0156         vbox.addLayout(self.grid)
0157         vbox.addWidget(self.cbRuleset)
0158         vbox.addWidget(self.buttonBox)
0159 
0160 
0161 class TableList(QWidget):
0162 
0163     """a widget for viewing, joining, leaving tables"""
0164     # pylint: disable=too-many-instance-attributes
0165 
0166     def __init__(self, client):
0167         super().__init__(None)
0168         self.autoStarted = False
0169         self.client = client
0170         self.setObjectName('TableList')
0171         self.resize(700, 400)
0172         self.view = MJTableView(self)
0173         self.differ = None
0174         self.debugModelTest = None
0175         self.requestedNewTable = False
0176         self.view.setItemDelegateForColumn(
0177             2,
0178             RichTextColumnDelegate(self.view))
0179 
0180         buttonBox = QDialogButtonBox(self)
0181         self.newButton = buttonBox.addButton(
0182             i18nc('allocate a new table',
0183                   "&New"),
0184             QDialogButtonBox.ActionRole)
0185         self.newButton.setIcon(KIcon("document-new"))
0186         self.newButton.setToolTip(i18n("Allocate a new table"))
0187         self.newButton.clicked.connect(self.client.newTable)
0188         self.joinButton = buttonBox.addButton(
0189             i18n("&Join"),
0190             QDialogButtonBox.AcceptRole)
0191         self.joinButton.clicked.connect(client.joinTable)
0192         self.joinButton.setIcon(KIcon("list-add-user"))
0193         self.joinButton.setToolTip(i18n("Join a table"))
0194         self.leaveButton = buttonBox.addButton(
0195             i18n("&Leave"),
0196             QDialogButtonBox.AcceptRole)
0197         self.leaveButton.clicked.connect(self.leaveTable)
0198         self.leaveButton.setIcon(KIcon("list-remove-user"))
0199         self.leaveButton.setToolTip(i18n("Leave a table"))
0200         self.compareButton = buttonBox.addButton(
0201             i18nc('Kajongg-Ruleset',
0202                   'Compare'),
0203             QDialogButtonBox.AcceptRole)
0204         self.compareButton.clicked.connect(self.compareRuleset)
0205         self.compareButton.setIcon(KIcon("preferences-plugin-script"))
0206         self.compareButton.setToolTip(
0207             i18n('Compare the rules of this table with my own rulesets'))
0208         self.chatButton = buttonBox.addButton(
0209             i18n('&Chat'),
0210             QDialogButtonBox.AcceptRole)
0211         self.chatButton.setIcon(KIcon("call-start"))
0212         self.chatButton.clicked.connect(self.chat)
0213         self.startButton = buttonBox.addButton(
0214             i18n('&Start'),
0215             QDialogButtonBox.AcceptRole)
0216         self.startButton.clicked.connect(self.startGame)
0217         self.startButton.setIcon(KIcon("arrow-right"))
0218         self.startButton.setToolTip(
0219             i18n("Start playing on a table. "
0220                  "Empty seats will be taken by robot players."))
0221 
0222         cmdLayout = QHBoxLayout()
0223         cmdLayout.addWidget(buttonBox)
0224 
0225         layout = QVBoxLayout()
0226         layout.addWidget(self.view)
0227         layout.addLayout(cmdLayout)
0228         self.setLayout(layout)
0229 
0230         self.view.doubleClicked.connect(client.joinTable)
0231         StateSaver(self, self.view.horizontalHeader())
0232         self.updateButtonsForTable(None)
0233 
0234     def hideEvent(self, unusedEvent):  # pylint: disable=no-self-use
0235         """table window hides"""
0236         scene = Internal.scene
0237         if scene:
0238             scene.startingGame = False
0239         model = self.view.model()
0240         if model:
0241             for table in model.tables:
0242                 if table.chatWindow:
0243                     table.chatWindow.hide()
0244                     table.chatWindow = None
0245         if scene:
0246             if not scene.game or scene.game.client != self.client:
0247                 # do we still need this connection?
0248                 self.client.logout()
0249 
0250     def chat(self):
0251         """chat. Only generate ChatWindow after the
0252         message has successfully been sent to the server.
0253         Because the server might have gone away."""
0254         def initChat(_):
0255             """now that we were able to send the message to the server
0256             instantiate the chat window"""
0257             table.chatWindow = ChatWindow(table=table)
0258             table.chatWindow.receiveLine(msg)
0259         table = self.selectedTable()
0260         if not table.chatWindow:
0261             line = i18nE('opens a chat window')
0262             msg = ChatMessage(
0263                 table.tableid,
0264                 table.client.name,
0265                 line,
0266                 isStatusMessage=True)
0267             table.client.sendChat(
0268                 msg).addCallback(
0269                     initChat).addErrback(
0270                         self.client.tableError)
0271         elif table.chatWindow.isVisible():
0272             table.chatWindow.hide()
0273         else:
0274             table.chatWindow.show()
0275 
0276     def show(self):
0277         """prepare the view and show it"""
0278         if self.client.hasLocalServer():
0279             title = i18n(
0280                 'Local Games with Ruleset %1',
0281                 self.client.ruleset.name)
0282         else:
0283             title = i18n('Tables at %1', self.client.connection.url)
0284         decorateWindow(self, ' - '.join([self.client.name, title]))
0285         self.view.hideColumn(1)
0286         tableCount = self.view.model().rowCount(
0287             None) if self.view.model() else 0
0288         self.view.showColumn(0)
0289         self.view.showColumn(2)
0290         self.view.showColumn(4)
0291         if tableCount or not self.client.hasLocalServer():
0292             QWidget.show(self)
0293             if self.client.hasLocalServer():
0294                 self.view.hideColumn(0)
0295                 self.view.hideColumn(2)
0296                 self.view.hideColumn(4)
0297 
0298     def selectTable(self, idx):
0299         """select table by idx"""
0300         self.view.selectRow(idx)
0301         self.updateButtonsForTable(self.selectedTable())
0302 
0303     def updateButtonsForTable(self, table):
0304         """update button status for the currently selected table"""
0305         hasTable = bool(table)
0306         suspended = hasTable and bool(table.suspendedAt)
0307         running = hasTable and table.running
0308         suspendedLocalGame = (
0309             suspended and table.gameid
0310             and self.client.hasLocalServer())
0311         self.joinButton.setEnabled(
0312             hasTable and
0313             not running and
0314             not table.isOnline(self.client.name) and
0315             (self.client.name in table.playerNames) == suspended)
0316         self.leaveButton.setVisible(not suspendedLocalGame)
0317         self.compareButton.setVisible(not suspendedLocalGame)
0318         self.startButton.setVisible(not suspended)
0319         if suspendedLocalGame:
0320             self.newButton.setToolTip(i18n("Start a new game"))
0321             self.joinButton.setText(
0322                 i18nc('resuming a local suspended game', '&Resume'))
0323             self.joinButton.setToolTip(
0324                 i18n("Resume the selected suspended game"))
0325         else:
0326             self.newButton.setToolTip(i18n("Allocate a new table"))
0327             self.joinButton.setText(i18n('&Join'))
0328             self.joinButton.setToolTip(i18n("Join a table"))
0329         self.leaveButton.setEnabled(
0330             hasTable and not running and not self.joinButton.isEnabled())
0331         self.startButton.setEnabled(
0332             not running and not suspendedLocalGame and hasTable
0333             and self.client.name == table.playerNames[0])
0334         self.compareButton.setEnabled(hasTable and table.myRuleset is None)
0335         self.chatButton.setVisible(not self.client.hasLocalServer())
0336         self.chatButton.setEnabled(
0337             not running and hasTable
0338             and self.client.name in table.playerNames
0339             and sum(x.startswith('Robot ') for x in table.playerNames) < 3)
0340         if self.chatButton.isEnabled():
0341             self.chatButton.setToolTip(i18n(
0342                 "Chat with others on this table"))
0343         else:
0344             self.chatButton.setToolTip(i18n(
0345                 "For chatting with others on this table, "
0346                 "please first take a seat"))
0347 
0348     def selectionChanged(self, selected, unusedDeselected):
0349         """update button states according to selection"""
0350         if selected.indexes():
0351             self.selectTable(selected.indexes()[0].row())
0352 
0353     def selectedTable(self):
0354         """return the selected table"""
0355         if self.view.selectionModel():
0356             index = self.view.selectionModel().currentIndex()
0357             if index.isValid() and self.view.model():
0358                 return self.view.model().tables[index.row()]
0359         return None
0360 
0361     def compareRuleset(self):
0362         """compare the ruleset of this table against ours"""
0363         table = self.selectedTable()
0364         self.differ = RulesetDiffer(table.ruleset, Ruleset.availableRulesets())
0365         self.differ.show()
0366 
0367     def startGame(self):
0368         """start playing at the selected table"""
0369         table = self.selectedTable()
0370         self.startButton.setEnabled(False)
0371         self.client.callServer(
0372             'startGame',
0373             table.tableid).addErrback(
0374                 self.client.tableError)
0375 
0376     def leaveTable(self):
0377         """leave a table"""
0378         table = self.selectedTable()
0379         self.client.callServer(
0380             'leaveTable',
0381             table.tableid).addErrback(
0382                 self.client.tableError)
0383 
0384     def __keepChatWindows(self, tables):
0385         """copy chatWindows from the old table list which will be
0386         thrown away"""
0387         if self.view.model():
0388             chatWindows = {x.tableid: x.chatWindow for x in self.view.model().tables}
0389             unusedWindows = {x.chatWindow for x in self.view.model().tables}
0390             for table in tables:
0391                 table.chatWindow = chatWindows.get(table.tableid, None)
0392                 unusedWindows -= {table.chatWindow}
0393             for unusedWindow in unusedWindows:
0394                 if unusedWindow:
0395                     unusedWindow.hide()
0396 
0397     def __preselectTableId(self, tables):
0398         """which table should be preselected?
0399         If we just requested a new table:
0400           select first new table.
0401           Only in the rare case that two clients request a new table at
0402           the same moment, this could put the focus on the wrong table.
0403           Ignore that for now.
0404         else if we had one selected:
0405           select that again
0406         else:
0407           select first table"""
0408         if self.requestedNewTable:
0409             self.requestedNewTable = False
0410             model = self.view.model()
0411             if model:
0412                 oldIds = {x.tableid for x in model.tables}
0413                 newIds = sorted({x.tableid for x in tables} - oldIds)
0414                 if newIds:
0415                     return newIds[0]
0416         if self.selectedTable():
0417             return self.selectedTable().tableid
0418         return 0
0419 
0420     def loadTables(self, tables):
0421         """build and use a model around the tables.
0422         Show all new tables (no gameid given yet) and all suspended
0423         tables that also exist locally. In theory all suspended games should
0424         exist locally but there might have been bugs or somebody might
0425         have removed the local database like when reinstalling linux"""
0426         if not Internal.scene:
0427             return
0428         if Debug.table:
0429             for table in tables:
0430                 if table.gameid and not table.gameExistsLocally():
0431                     logDebug('Table %s does not exist locally' % table)
0432         tables = [x for x in tables if not x.gameid or x.gameExistsLocally()]
0433         tables.sort(key=lambda x: x.tableid)
0434         preselectTableId = self.__preselectTableId(tables)
0435         self.__keepChatWindows(tables)
0436         model = TablesModel(tables)
0437         self.view.setModel(model)
0438         if Debug.modelTest:
0439             self.debugModelTest = ModelTest(model, self.view)
0440         selection = QItemSelectionModel(model, self.view)
0441         self.view.initView()
0442         self.view.setSelectionModel(selection)
0443         self.view.setSelectionBehavior(QAbstractItemView.SelectRows)
0444         self.view.setSelectionMode(QAbstractItemView.SingleSelection)
0445         selection.selectionChanged.connect(self.selectionChanged)
0446         if len(tables) == 1:
0447             self.selectTable(0)
0448             self.startButton.setFocus()
0449         elif not tables:
0450             self.newButton.setFocus()
0451         else:
0452             _ = [x for x in tables if x.tableid >= preselectTableId]
0453             self.selectTable(tables.index(_[0]) if _ else 0)
0454         self.updateButtonsForTable(self.selectedTable())
0455         self.view.setFocus()