""" Provides the graphical front-end for Piket. """ import collections import logging import os import sys from math import ceil, sqrt import qdarkstyle # pylint: disable=E0611 from PySide6.QtWidgets import ( QApplication, QGridLayout, QInputDialog, QLineEdit, QMainWindow, QMessageBox, QPushButton, QSizePolicy, QToolBar, QWidget, ) from PySide6.QtGui import ( QAction, QActionGroup, ) from PySide6.QtGui import QIcon from PySide6.QtMultimedia import QSoundEffect from PySide6.QtCore import QObject, QSize, Qt, Signal, Slot, QUrl # pylint: enable=E0611 try: import dbus except ImportError: dbus = None from piket_client.model import ( Person, ConsumptionType, Consumption, ServerStatus, Settlement, ) import piket_client.logger LOG = logging.getLogger(__name__) PLOP_WAVE = QSoundEffect() UNDO_WAVE = QSoundEffect() class NameButton(QPushButton): """ Wraps a QPushButton to provide a counter. """ consumption_created = Signal(Consumption) def __init__(self, person: Person, active_id: str, *args, **kwargs) -> None: self.person = person self.active_id = active_id super().__init__(self.current_label, *args, **kwargs) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.consumption_created.connect(self.window().consumption_added) self.clicked.connect(self.process_click) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.confirm_hide) @Slot(str) def new_active_id(self, new_id: str) -> None: """ Change the active ConsumptionType id, update the label. """ self.active_id = new_id self.setText(self.current_label) @Slot() def rebuild(self) -> None: """ Refresh the Person object and the label. """ self.person = self.person.reload() self.setText(self.current_label) @property def current_count(self) -> int: """ Return the count of the currently active ConsumptionType for this Person. """ return self.person.consumptions.get(self.active_id, 0) @property def current_label(self) -> str: """ Return the label to show on the button. """ return f"{self.person.name}\n{self.current_count}" def process_click(self) -> None: """ Process a click on this button. """ LOG.debug("Button clicked.") result = self.person.add_consumption(self.active_id) if result: PLOP_WAVE.play() self.setText(self.current_label) self.consumption_created.emit(result) else: LOG.error("Failed to add consumption", extra={"person": self.person}) def confirm_hide(self) -> None: LOG.debug("Button right-clicked.") ok = QMessageBox.warning( self.window(), "Persoon verbergen?", f"Wil je {self.person.name} verbergen?", QMessageBox.Yes, QMessageBox.Cancel, ) if ok == QMessageBox.Yes: LOG.warning("Hiding person %s", self.person.name) self.person.set_active(False) self.parent().init_ui() class NameButtons(QWidget): """ Main widget responsible for capturing presses and registering them. """ new_id_set = Signal(str) def __init__(self, consumption_type_id: str, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.layout = None self.layout = QGridLayout() self.setLayout(self.layout) self.active_consumption_type_id = consumption_type_id self.init_ui() @Slot(str) def consumption_type_changed(self, new_id: str): """ Process a change of the consumption type and propagate to the contained buttons. """ LOG.debug("Consumption type updated in NameButtons.", extra={"new_id": new_id}) self.active_consumption_type_id = new_id self.new_id_set.emit(new_id) def init_ui(self) -> None: """ Initialize UI: build GridLayout, retrieve People and build a button for each. """ LOG.debug("Initializing NameButtons.") ps = Person.get_all(True) # num_columns = round(len(ps) / 10) + 1 num_columns = min(5, ceil(sqrt(len(ps)))) if self.layout: LOG.debug("Removing %s widgets for rebuild", self.layout.count()) for index in range(self.layout.count()): item = self.layout.itemAt(0) LOG.debug("Removing item %s: %s", index, item) if item: w = item.widget() LOG.debug("Person %s", w.person) self.layout.removeItem(item) w.deleteLater() for index, person in enumerate(ps): button = NameButton(person, self.active_consumption_type_id, self) self.new_id_set.connect(button.new_active_id) self.layout.addWidget(button, index // num_columns, index % num_columns) class PiketMainWindow(QMainWindow): """ QMainWindow subclass responsible for showing the main application window. """ consumption_type_changed = Signal(str) def __init__(self) -> None: LOG.debug("Initializing PiketMainWindow.") super().__init__() self.main_widget = None self.dark_theme = True self.toolbar = None self.osk = None self.undo_action = None self.undo_queue = collections.deque([], 15) self.init_ui() def init_ui(self) -> None: """ Initialize the UI: construct main widget and toolbar. """ # Connect to dbus, get handle to virtual keyboard if dbus: try: session_bus = dbus.SessionBus() self.osk = session_bus.get_object( "org.onboard.Onboard", "/org/onboard/Onboard/Keyboard" ) except dbus.exceptions.DBusException as exception: # Onboard not present or dbus broken self.osk = None LOG.error("Could not connect to Onboard:") LOG.exception(exception) else: LOG.warning("Onboard disabled due to missing dbus.") # Go full screen self.setWindowState(Qt.WindowActive | Qt.WindowFullScreen) font_metrics = self.fontMetrics() icon_size = font_metrics.height() * 1.45 # Initialize toolbar self.toolbar = QToolBar() self.toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) self.toolbar.setIconSize(QSize(icon_size, icon_size)) # Left self.toolbar.addAction( self.load_icon("add_person.png"), "+ Naam", self.add_person ) self.undo_action = self.toolbar.addAction( self.load_icon("undo.png"), "Oeps", self.do_undo ) self.undo_action.setDisabled(True) self.toolbar.addAction( self.load_icon("quit.png"), "Afsluiten", self.confirm_quit ) self.toolbar.addWidget(self.create_spacer()) # Right self.toolbar.addAction( self.load_icon("add_consumption_type.png"), "Nieuw", self.add_consumption_type, ) self.toolbar.setContextMenuPolicy(Qt.PreventContextMenu) self.toolbar.setFloatable(False) self.toolbar.setMovable(False) self.ct_ag = QActionGroup(self.toolbar) self.ct_ag.setExclusive(True) cts = ConsumptionType.get_all() if not cts: self.show_keyboard() name, ok = QInputDialog.getItem( self, "Consumptietype toevoegen", ( "Dit lijkt de eerste keer te zijn dat Piket start. Wat wil je " "tellen? Je kunt later meer typen toevoegen." ), ["Bier", "Wijn", "Cola"], current=0, editable=True, ) self.hide_keyboard() if ok and name: c_type = ConsumptionType(name=name) c_type = c_type.create() cts.append(c_type) else: QMessageBox.critical( self, "Kan niet doorgaan", ( "Je drukte op 'Annuleren' of voerde geen naam in, dus ik" "sluit af." ), ) sys.exit() for ct in cts: action = QAction( self.load_icon(ct.icon or "beer_bottle.png"), ct.name, self.ct_ag ) action.setCheckable(True) action.setData(str(ct.consumption_type_id)) self.ct_ag.actions()[0].setChecked(True) [self.toolbar.addAction(a) for a in self.ct_ag.actions()] self.ct_ag.triggered.connect(self.consumption_type_change) self.addToolBar(self.toolbar) # Initialize main widget self.main_widget = NameButtons(self.ct_ag.actions()[0].data(), self) self.consumption_type_changed.connect(self.main_widget.consumption_type_changed) self.setCentralWidget(self.main_widget) @Slot(QAction) def consumption_type_change(self, action: QAction): self.consumption_type_changed.emit(action.data()) def show_keyboard(self) -> None: """ Show the virtual keyboard, if possible. """ if self.osk: self.osk.Show() def hide_keyboard(self) -> None: """ Hide the virtual keyboard, if possible. """ if self.osk: self.osk.Hide() def add_person(self) -> None: """ Ask for a new Person and register it, then rebuild the central widget. """ inactive_persons = Person.get_all(False) inactive_persons.sort(key=lambda p: p.name) inactive_names = [p.name for p in inactive_persons] self.show_keyboard() name, ok = QInputDialog.getItem( self, "Persoon toevoegen", "Voer de naam van de nieuwe persoon in, of kies uit de lijst.", inactive_names, 0, True, ) self.hide_keyboard() if ok and name: if name in inactive_names: person = inactive_persons[inactive_names.index(name)] person.set_active(True) else: person = Person(name=name) person = person.create() self.main_widget.init_ui() def add_consumption_type(self) -> None: self.show_keyboard() name, ok = QInputDialog.getItem( self, "Lijst toevoegen", "Wat wil je strepen?", ["Wijn", "Radler"] ) self.hide_keyboard() if ok and name: ct = ConsumptionType(name=name) ct = ct.create() action = QAction( self.load_icon(ct.icon or "beer_bottle.png"), ct.name, self.ct_ag ) action.setCheckable(True) action.setData(str(ct.consumption_type_id)) self.toolbar.addAction(action) def confirm_quit(self) -> None: """ Ask for confirmation that the user wishes to quit, then do so. """ ok = QMessageBox.warning( self, "Wil je echt afsluiten?", "Bevestig dat je wilt afsluiten.", QMessageBox.Yes, QMessageBox.Cancel, ) if ok == QMessageBox.Yes: LOG.warning("Shutdown by user.") QApplication.instance().quit() def do_undo(self) -> None: """ Undo the last marked consumption. """ UNDO_WAVE.play() to_undo = self.undo_queue.pop() LOG.warning("Undoing consumption %s", to_undo) result = to_undo.reverse() if not result or not result.reversed: LOG.error("Reversed consumption %s but was not reversed!", to_undo) self.undo_queue.append(to_undo) elif not self.undo_queue: self.undo_action.setDisabled(True) self.main_widget.init_ui() @Slot(Consumption) def consumption_added(self, consumption): """ Mark an added consumption in the queue. """ self.undo_queue.append(consumption) self.undo_action.setDisabled(False) @staticmethod def create_spacer() -> QWidget: """ Return an empty QWidget that automatically expands. """ spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) return spacer icons_dir = os.path.join(os.path.dirname(__file__), "icons") def load_icon(self, filename: str) -> QIcon: """ Return a QtIcon loaded from the given `filename` in the icons directory. """ if self.dark_theme: filename = "white_" + filename icon = QIcon(os.path.join(self.icons_dir, filename)) return icon def main() -> None: """ Main entry point of GUI client. """ LOG.info("Loading piket_client") app = QApplication(sys.argv) # Set dark theme app.setStyleSheet(qdarkstyle.load_stylesheet_pyside6()) # Load sounds global PLOP_WAVE, UNDO_WAVE sounds_dir = os.path.join(os.path.dirname(__file__), "sounds") PLOP_WAVE.setSource(QUrl.fromLocalFile(os.path.join(sounds_dir, "plop.wav"))) UNDO_WAVE.setSource(QUrl.fromLocalFile(os.path.join(sounds_dir, "undo.wav"))) # Enlarge font size font = app.font() size = font.pointSize() font.setPointSize(size * 1.5) app.setFont(font) # Test connectivity server_running, info = ServerStatus.is_server_running() if not server_running: LOG.critical("Could not connect to server", extra={"info": info}) QMessageBox.critical( None, "Help er is iets kapot", "Kan niet starten omdat de server niet reageert, stuur een foto van " "dit naar Maarten: " + repr(info), ) return 1 # Load main window main_window = PiketMainWindow() # Test unsettled consumptions status = ServerStatus.unsettled_consumptions() unsettled = status["unsettled"]["amount"] if unsettled > 0: first = status["unsettled"]["first"] first_date = first.strftime("%c") ok = QMessageBox.information( None, "Onafgesloten lijst", f"Wil je verdergaan met een lijst met {unsettled} onafgesloten " f"consumpties sinds {first_date}?", QMessageBox.Yes, QMessageBox.No, ) if ok == QMessageBox.No: main_window.show_keyboard() name, ok = QInputDialog.getText( None, "Lijst afsluiten", "Voer een naam in voor de lijst of druk op OK. Laat de datum " "staan.", QLineEdit.Normal, f"{first.strftime('%Y-%m-%d')}", ) main_window.hide_keyboard() if name and ok: settlement = Settlement.create(name) info = [ f'{item["count"]} {item["name"]}' for item in settlement.consumption_summary.values() ] info = ", ".join(info) QMessageBox.information( None, "Lijst afgesloten", f"VO! Op deze lijst stonden: {info}" ) main_window = PiketMainWindow() main_window.show() # Let's go LOG.info("Starting QT event loop.") app.exec_() if __name__ == "__main__": main()