Digitale bierlijst

gui.py 10KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. """
  2. Provides the graphical front-end for Piket.
  3. """
  4. import collections
  5. import logging
  6. import os
  7. import sys
  8. # pylint: disable=E0611
  9. from PySide2.QtWidgets import (
  10. QAction,
  11. QActionGroup,
  12. QApplication,
  13. QGridLayout,
  14. QInputDialog,
  15. QMainWindow,
  16. QMessageBox,
  17. QPushButton,
  18. QSizePolicy,
  19. QToolBar,
  20. QWidget,
  21. )
  22. from PySide2.QtGui import QIcon
  23. from PySide2.QtCore import QObject, QSize, Qt, Signal, Slot
  24. # pylint: enable=E0611
  25. try:
  26. import dbus
  27. except ImportError:
  28. dbus = None
  29. from piket_client.sound import PLOP_WAVE, UNDO_WAVE
  30. from piket_client.model import Person, ConsumptionType, Consumption
  31. import piket_client.logger
  32. LOG = logging.getLogger(__name__)
  33. def plop() -> None:
  34. """ Asynchronously play the plop sound. """
  35. PLOP_WAVE.play()
  36. class NameButton(QPushButton):
  37. """ Wraps a QPushButton to provide a counter. """
  38. consumption_created = Signal(Consumption)
  39. def __init__(self, person: Person, active_id: str, *args, **kwargs) -> None:
  40. self.person = person
  41. self.active_id = active_id
  42. super().__init__(self.current_label, *args, **kwargs)
  43. self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
  44. self.consumption_created.connect(self.window().consumption_added)
  45. self.clicked.connect(self.process_click)
  46. @Slot(str)
  47. def new_active_id(self, new_id: str) -> None:
  48. """ Change the active ConsumptionType id, update the label. """
  49. self.active_id = new_id
  50. self.setText(self.current_label)
  51. @Slot()
  52. def rebuild(self) -> None:
  53. """ Refresh the Person object and the label. """
  54. self.person = self.person.reload()
  55. self.setText(self.current_label)
  56. @property
  57. def current_count(self) -> int:
  58. """ Return the count of the currently active ConsumptionType for this
  59. Person. """
  60. return self.person.consumptions.get(self.active_id, 0)
  61. @property
  62. def current_label(self) -> str:
  63. """ Return the label to show on the button. """
  64. return f"{self.person.name} ({self.current_count})"
  65. def process_click(self) -> None:
  66. """ Process a click on this button. """
  67. result = self.person.add_consumption(self.active_id)
  68. if result:
  69. plop()
  70. self.setText(self.current_label)
  71. self.consumption_created.emit(result)
  72. else:
  73. print("Jantoeternuitje, kapot")
  74. class NameButtons(QWidget):
  75. """ Main widget responsible for capturing presses and registering them.
  76. """
  77. new_id_set = Signal(str)
  78. def __init__(self, consumption_type_id: str, *args, **kwargs) -> None:
  79. super().__init__(*args, **kwargs)
  80. self.layout = None
  81. self.layout = QGridLayout()
  82. self.setLayout(self.layout)
  83. self.active_consumption_type_id = consumption_type_id
  84. self.init_ui()
  85. @Slot(str)
  86. def consumption_type_changed(self, new_id: str):
  87. """ Process a change of the consumption type and propagate to the
  88. contained buttons. """
  89. self.active_consumption_type_id = new_id
  90. self.new_id_set.emit(new_id)
  91. def init_ui(self) -> None:
  92. """ Initialize UI: build GridLayout, retrieve People and build a button
  93. for each. """
  94. ps = Person.get_all()
  95. num_columns = round(len(ps) / 10) + 1
  96. if self.layout:
  97. LOG.debug("Removing %s widgets for rebuild", self.layout.count())
  98. for index in range(self.layout.count()):
  99. item = self.layout.itemAt(0)
  100. LOG.debug("Removing item %s: %s", index, item)
  101. if item:
  102. w = item.widget()
  103. LOG.debug("Person %s", w.person)
  104. self.layout.removeItem(item)
  105. w.deleteLater()
  106. for index, person in enumerate(ps):
  107. button = NameButton(person, self.active_consumption_type_id, self)
  108. self.new_id_set.connect(button.new_active_id)
  109. self.layout.addWidget(button, index // num_columns, index % num_columns)
  110. class PiketMainWindow(QMainWindow):
  111. """ QMainWindow subclass responsible for showing the main application
  112. window. """
  113. consumption_type_changed = Signal(str)
  114. def __init__(self) -> None:
  115. super().__init__()
  116. self.main_widget = None
  117. self.toolbar = None
  118. self.osk = None
  119. self.undo_action = None
  120. self.undo_queue = collections.deque([], 20)
  121. self.init_ui()
  122. def init_ui(self) -> None:
  123. """ Initialize the UI: construct main widget and toolbar. """
  124. # Connect to dbus, get handle to virtual keyboard
  125. if dbus:
  126. try:
  127. session_bus = dbus.SessionBus()
  128. self.osk = session_bus.get_object(
  129. "org.onboard.Onboard", "/org/onboard/Onboard/Keyboard"
  130. )
  131. except dbus.exceptions.DBusException:
  132. # Onboard not present or dbus broken
  133. self.osk = None
  134. # Go full screen
  135. self.setWindowState(Qt.WindowActive | Qt.WindowFullScreen)
  136. font_metrics = self.fontMetrics()
  137. icon_size = font_metrics.height() * 2
  138. # Initialize toolbar
  139. self.toolbar = QToolBar()
  140. self.toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
  141. self.toolbar.setIconSize(QSize(icon_size, icon_size))
  142. # Left
  143. self.toolbar.addAction(
  144. self.load_icon("add_person.svg"), "Nieuw persoon", self.add_person
  145. )
  146. self.undo_action = self.toolbar.addAction(
  147. self.load_icon("undo.svg"), "Oeps", self.do_undo
  148. )
  149. self.undo_action.setDisabled(True)
  150. self.toolbar.addAction(
  151. self.load_icon("quit.svg"), "Afsluiten", self.confirm_quit
  152. )
  153. self.toolbar.addWidget(self.create_spacer())
  154. # Right
  155. ag = QActionGroup(self.toolbar)
  156. ag.setExclusive(True)
  157. cts = ConsumptionType.get_all()
  158. if not cts:
  159. self.show_keyboard()
  160. name, ok = QInputDialog.getItem(
  161. self,
  162. "Consumptietype toevoegen",
  163. (
  164. "Dit lijkt de eerste keer te zijn dat Piket start. Wat wil je "
  165. "tellen? Je kunt later meer typen toevoegen."
  166. ),
  167. ["Bier", "Wijn", "Cola"],
  168. current=0,
  169. editable=True,
  170. )
  171. self.hide_keyboard()
  172. if ok and name:
  173. c_type = ConsumptionType(name=name)
  174. c_type = c_type.create()
  175. cts.append(c_type)
  176. else:
  177. QMessageBox.critical(
  178. self,
  179. "Kan niet doorgaan",
  180. (
  181. "Je drukte op 'Annuleren' of voerde geen naam in, dus ik"
  182. "sluit af."
  183. ),
  184. )
  185. sys.exit()
  186. for ct in cts:
  187. action = QAction(self.load_icon(ct.icon or "beer_bottle.svg"), ct.name, ag)
  188. action.setCheckable(True)
  189. action.setData(str(ct.consumption_type_id))
  190. ag.actions()[0].setChecked(True)
  191. [self.toolbar.addAction(a) for a in ag.actions()]
  192. ag.triggered.connect(self.consumption_type_change)
  193. self.addToolBar(self.toolbar)
  194. # Initialize main widget
  195. self.main_widget = NameButtons(ag.actions()[0].data(), self)
  196. self.consumption_type_changed.connect(self.main_widget.consumption_type_changed)
  197. self.setCentralWidget(self.main_widget)
  198. @Slot(QAction)
  199. def consumption_type_change(self, action: QAction):
  200. self.consumption_type_changed.emit(action.data())
  201. def show_keyboard(self) -> None:
  202. """ Show the virtual keyboard, if possible. """
  203. if self.osk:
  204. self.osk.Show()
  205. def hide_keyboard(self) -> None:
  206. """ Hide the virtual keyboard, if possible. """
  207. if self.osk:
  208. self.osk.Hide()
  209. def add_person(self) -> None:
  210. """ Ask for a new Person and register it, then rebuild the central
  211. widget. """
  212. self.show_keyboard()
  213. name, ok = QInputDialog.getItem(
  214. self,
  215. "Persoon toevoegen",
  216. "Voer de naam van de nieuwe persoon in, of kies uit de lijst.",
  217. ["Cas", "Frenk"],
  218. 0,
  219. True,
  220. )
  221. self.hide_keyboard()
  222. if ok and name:
  223. person = Person(name=name)
  224. person = person.create()
  225. self.main_widget.init_ui()
  226. def confirm_quit(self) -> None:
  227. """ Ask for confirmation that the user wishes to quit, then do so. """
  228. ok = QMessageBox.warning(
  229. self,
  230. "Wil je echt afsluiten?",
  231. "Bevestig dat je wilt afsluiten.",
  232. QMessageBox.Yes,
  233. QMessageBox.Cancel,
  234. )
  235. if ok == QMessageBox.Yes:
  236. LOG.warning("Shutdown by user.")
  237. QApplication.instance().quit()
  238. def do_undo(self) -> None:
  239. """ Undo the last marked consumption. """
  240. UNDO_WAVE.play()
  241. to_undo = self.undo_queue.pop()
  242. LOG.warning("Undoing consumption %s", to_undo)
  243. result = to_undo.reverse()
  244. if not result or not result.reversed:
  245. LOG.error("Reversed consumption %s but was not reversed!", to_undo)
  246. self.undo_queue.append(to_undo)
  247. elif not self.undo_queue:
  248. self.undo_action.setDisabled(True)
  249. self.main_widget.init_ui()
  250. @Slot(Consumption)
  251. def consumption_added(self, consumption):
  252. """ Mark an added consumption in the queue. """
  253. self.undo_queue.append(consumption)
  254. self.undo_action.setDisabled(False)
  255. @staticmethod
  256. def create_spacer() -> QWidget:
  257. """ Return an empty QWidget that automatically expands. """
  258. spacer = QWidget()
  259. spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
  260. return spacer
  261. icons_dir = os.path.join(os.path.dirname(__file__), "icons")
  262. @classmethod
  263. def load_icon(cls, filename: str) -> QIcon:
  264. """ Return a QtIcon loaded from the given `filename` in the icons
  265. directory. """
  266. return QIcon(os.path.join(cls.icons_dir, filename))
  267. def main() -> None:
  268. """ Main entry point of GUI client. """
  269. app = QApplication(sys.argv)
  270. font = app.font()
  271. size = font.pointSize()
  272. font.setPointSize(size * 1.75)
  273. app.setFont(font)
  274. main_window = PiketMainWindow()
  275. main_window.show()
  276. app.exec_()
  277. if __name__ == "__main__":
  278. main()