From 31d5e3151b74c2a8ae7297528d7fa50989f5ce20 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 9 Oct 2020 14:14:32 -0700 Subject: [PATCH 1/3] Refactor display rules. --- qt_ui/displayoptions.py | 43 ++++++++++++++++++++++++ qt_ui/widgets/map/QLiberationMap.py | 41 ++++++----------------- qt_ui/widgets/map/QMapControlPoint.py | 3 +- qt_ui/widgets/map/QMapGroundObject.py | 5 +-- qt_ui/windows/QLiberationWindow.py | 48 ++++++--------------------- 5 files changed, 70 insertions(+), 70 deletions(-) create mode 100644 qt_ui/displayoptions.py diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py new file mode 100644 index 00000000..a9b6320c --- /dev/null +++ b/qt_ui/displayoptions.py @@ -0,0 +1,43 @@ +"""Visibility options for the game map.""" +from dataclasses import dataclass +from typing import Iterator + + +@dataclass +class DisplayRule: + name: str + _value: bool + + @property + def menu_text(self) -> str: + return self.name + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool) -> None: + from qt_ui.widgets.map.QLiberationMap import QLiberationMap + self._value = value + QLiberationMap.instance.reload_scene() + QLiberationMap.instance.update() + + def __bool__(self) -> bool: + return self.value + + +class DisplayOptions: + ground_objects = DisplayRule("Ground Objects", True) + control_points = DisplayRule("Control Points", True) + lines = DisplayRule("Lines", True) + events = DisplayRule("Events", True) + sam_ranges = DisplayRule("SAM Ranges", True) + flight_paths = DisplayRule("Flight Paths", False) + + @classmethod + def menu_items(cls) -> Iterator[DisplayRule]: + # Python 3.6 enforces that __dict__ is order preserving by default. + for value in cls.__dict__.values(): + if isinstance(value, DisplayRule): + yield value diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 3d775367..1f3fdd57 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import logging -from typing import Dict, List, Optional, Tuple +from typing import List, Optional, Tuple from PySide2.QtCore import Qt from PySide2.QtGui import QBrush, QColor, QPen, QPixmap, QWheelEvent @@ -18,11 +20,12 @@ from game import Game, db from game.data.radar_db import UNITS_WITH_RADAR from gen import Conflict from gen.flights.flight import Flight +from qt_ui.displayoptions import DisplayOptions from qt_ui.models import GameModel +from qt_ui.widgets.map.QFrontLine import QFrontLine from qt_ui.widgets.map.QLiberationScene import QLiberationScene from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject -from qt_ui.widgets.map.QFrontLine import QFrontLine from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from theater import ControlPoint, FrontLine @@ -30,15 +33,7 @@ from theater import ControlPoint, FrontLine class QLiberationMap(QGraphicsView): WAYPOINT_SIZE = 4 - instance = None - display_rules: Dict[str, bool] = { - "cp": True, - "go": True, - "lines": True, - "events": True, - "sam": True, - "flight_paths": False - } + instance: Optional[QLiberationMap] = None def __init__(self, game_model: GameModel): super(QLiberationMap, self).__init__() @@ -161,7 +156,8 @@ class QLiberationMap(QGraphicsView): buildings = self.game.theater.find_ground_objects_by_obj_name(ground_object.obj_name) scene.addItem(QMapGroundObject(self, go_pos[0], go_pos[1], 14, 12, cp, ground_object, self.game, buildings)) - if ground_object.category == "aa" and self.get_display_rule("sam"): + is_aa = ground_object.category == "aa" + if is_aa and DisplayOptions.sam_ranges: max_range = 0 has_radar = False if ground_object.groups: @@ -177,11 +173,11 @@ class QLiberationMap(QGraphicsView): added_objects.append(ground_object.obj_name) for cp in self.game.theater.enemy_points(): - if self.get_display_rule("lines"): + if DisplayOptions.lines: self.scene_create_lines_for_cp(cp, playerColor, enemyColor) for cp in self.game.theater.player_points(): - if self.get_display_rule("lines"): + if DisplayOptions.lines: self.scene_create_lines_for_cp(cp, playerColor, enemyColor) self.draw_flight_plans(scene) @@ -202,7 +198,7 @@ class QLiberationMap(QGraphicsView): # Something may have caused those items to already be removed. pass self.flight_path_items.clear() - if not self.get_display_rule("flight_paths"): + if not DisplayOptions.flight_paths: return for package in self.game_model.ato_model.packages: for flight in package.flights: @@ -367,18 +363,3 @@ class QLiberationMap(QGraphicsView): effect = QGraphicsOpacityEffect() effect.setOpacity(0.3) overlay.setGraphicsEffect(effect) - - - @staticmethod - def set_display_rule(rule: str, value: bool): - QLiberationMap.display_rules[rule] = value - QLiberationMap.instance.reload_scene() - QLiberationMap.instance.update() - - @staticmethod - def get_display_rules() -> Dict[str, bool]: - return QLiberationMap.display_rules - - @staticmethod - def get_display_rule(rule) -> bool: - return QLiberationMap.display_rules[rule] diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py index f5b2e1c4..ef9bf5c9 100644 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ b/qt_ui/widgets/map/QMapControlPoint.py @@ -7,6 +7,7 @@ from qt_ui.models import GameModel from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 from theater import ControlPoint from .QMapObject import QMapObject +from ...displayoptions import DisplayOptions class QMapControlPoint(QMapObject): @@ -21,7 +22,7 @@ class QMapControlPoint(QMapObject): self.base_details_dialog: Optional[QBaseMenu2] = None def paint(self, painter, option, widget=None) -> None: - if self.parent.get_display_rule("cp"): + if DisplayOptions.control_points: painter.save() painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(self.brush_color) diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py index 1ed9f3d2..af0789a8 100644 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ b/qt_ui/widgets/map/QMapGroundObject.py @@ -8,8 +8,9 @@ import qt_ui.uiconstants as const from game import Game from game.data.building_data import FORTIFICATION_BUILDINGS from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu -from theater import TheaterGroundObject, ControlPoint +from theater import ControlPoint, TheaterGroundObject from .QMapObject import QMapObject +from ...displayoptions import DisplayOptions class QMapGroundObject(QMapObject): @@ -50,7 +51,7 @@ class QMapGroundObject(QMapObject): player_icons = "_blue" enemy_icons = "" - if self.parent.get_display_rule("go"): + if DisplayOptions.ground_objects: painter.save() cat = self.ground_object.category diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index 3933083c..acda1bb0 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -19,6 +19,7 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game, persistency from qt_ui.dialogs import Dialog +from qt_ui.displayoptions import DisplayOptions from qt_ui.models import GameModel from qt_ui.uiconstants import URLS from qt_ui.widgets.QTopPanel import QTopPanel @@ -134,48 +135,21 @@ class QLiberationWindow(QMainWindow): file_menu.addSeparator() file_menu.addAction(self.showLiberationPrefDialogAction) file_menu.addSeparator() - #file_menu.addAction("Close Current Game", lambda: self.closeGame()) # Not working file_menu.addAction("E&xit" , lambda: self.exit()) displayMenu = self.menu.addMenu("&Display") - tg_cp_visibility = QAction('&Control Point', displayMenu) - tg_cp_visibility.setCheckable(True) - tg_cp_visibility.setChecked(True) - tg_cp_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("cp", tg_cp_visibility.isChecked())) + for display_rule in DisplayOptions.menu_items(): + def make_check_closure(): + def closure(): + display_rule.value = action.isChecked() + return closure - tg_go_visibility = QAction('&Ground Objects', displayMenu) - tg_go_visibility.setCheckable(True) - tg_go_visibility.setChecked(True) - tg_go_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("go", tg_go_visibility.isChecked())) - - tg_line_visibility = QAction('&Lines', displayMenu) - tg_line_visibility.setCheckable(True) - tg_line_visibility.setChecked(True) - tg_line_visibility.toggled.connect( - lambda: QLiberationMap.set_display_rule("lines", tg_line_visibility.isChecked())) - - tg_event_visibility = QAction('&Events', displayMenu) - tg_event_visibility.setCheckable(True) - tg_event_visibility.setChecked(True) - tg_event_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("events", tg_event_visibility.isChecked())) - - tg_sam_visibility = QAction('&SAM Range', displayMenu) - tg_sam_visibility.setCheckable(True) - tg_sam_visibility.setChecked(True) - tg_sam_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("sam", tg_sam_visibility.isChecked())) - - tg_flight_path_visibility = QAction('&Flight Paths', displayMenu) - tg_flight_path_visibility.setCheckable(True) - tg_flight_path_visibility.setChecked(False) - tg_flight_path_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("flight_paths", tg_flight_path_visibility.isChecked())) - - displayMenu.addAction(tg_go_visibility) - displayMenu.addAction(tg_cp_visibility) - displayMenu.addAction(tg_line_visibility) - displayMenu.addAction(tg_event_visibility) - displayMenu.addAction(tg_sam_visibility) - displayMenu.addAction(tg_flight_path_visibility) + action = QAction(f"&{display_rule.menu_text}", displayMenu) + action.setCheckable(True) + action.setChecked(display_rule.value) + action.toggled.connect(make_check_closure()) + displayMenu.addAction(action) help_menu = self.menu.addMenu("&Help") help_menu.addAction("&Discord Server", lambda: webbrowser.open_new_tab("https://" + "discord.gg" + "/" + "bKrt" + "rkJ")) From 2d8c8c63c91d7e66da5e08312ce8f7f90d5c1718 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 9 Oct 2020 16:18:08 -0700 Subject: [PATCH 2/3] Improve flight path display options. Adds an option show only selected flight, and also changes the show all option to highlight the selected flight. --- qt_ui/displayoptions.py | 27 +++++++++-- qt_ui/widgets/ato.py | 22 +++++++++ qt_ui/widgets/map/QLiberationMap.py | 74 ++++++++++++++++++++++------- qt_ui/windows/GameUpdateSignal.py | 12 ++++- qt_ui/windows/QLiberationWindow.py | 47 ++++++++++++------ 5 files changed, 145 insertions(+), 37 deletions(-) diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py index a9b6320c..f6f2e6c6 100644 --- a/qt_ui/displayoptions.py +++ b/qt_ui/displayoptions.py @@ -1,6 +1,6 @@ """Visibility options for the game map.""" from dataclasses import dataclass -from typing import Iterator +from typing import Iterator, Optional, Union @dataclass @@ -27,17 +27,38 @@ class DisplayRule: return self.value +class DisplayGroup: + def __init__(self, name: Optional[str]) -> None: + self.name = name + + def __iter__(self) -> Iterator[DisplayRule]: + # Python 3.6 enforces that __dict__ is order preserving by default. + for value in self.__dict__.values(): + if isinstance(value, DisplayRule): + yield value + + +class FlightPathOptions(DisplayGroup): + def __init__(self) -> None: + super().__init__("Flight Paths") + self.hide = DisplayRule("Hide Flight Paths", True) + self.only_selected = DisplayRule("Show Selected Flight Path", False) + self.all = DisplayRule("Show All Flight Paths", False) + + class DisplayOptions: ground_objects = DisplayRule("Ground Objects", True) control_points = DisplayRule("Control Points", True) lines = DisplayRule("Lines", True) events = DisplayRule("Events", True) sam_ranges = DisplayRule("SAM Ranges", True) - flight_paths = DisplayRule("Flight Paths", False) + flight_paths = FlightPathOptions() @classmethod - def menu_items(cls) -> Iterator[DisplayRule]: + def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]: # Python 3.6 enforces that __dict__ is order preserving by default. for value in cls.__dict__.values(): if isinstance(value, DisplayRule): yield value + elif isinstance(value, DisplayGroup): + yield value diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index b39f3063..8703f7e9 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -109,6 +109,7 @@ class QFlightPanel(QGroupBox): """Sets the package model to display.""" self.package_model = model self.flight_list.set_package(model) + self.selection_changed.connect(self.on_selection_changed) self.on_selection_changed() @property @@ -122,6 +123,15 @@ class QFlightPanel(QGroupBox): enabled = index.isValid() self.edit_button.setEnabled(enabled) self.delete_button.setEnabled(enabled) + self.change_map_flight_selection(index) + + @staticmethod + def change_map_flight_selection(index: QModelIndex) -> None: + if not index.isValid(): + GameUpdateSignal.get_instance().select_flight(None) + return + + GameUpdateSignal.get_instance().select_flight(index.row()) def on_edit(self) -> None: """Opens the flight edit dialog.""" @@ -270,6 +280,18 @@ class QPackagePanel(QGroupBox): enabled = index.isValid() self.edit_button.setEnabled(enabled) self.delete_button.setEnabled(enabled) + self.change_map_package_selection(index) + + def change_map_package_selection(self, index: QModelIndex) -> None: + if not index.isValid(): + GameUpdateSignal.get_instance().select_package(None) + return + + package = self.ato_model.get_package_model(index) + if package.rowCount() == 0: + GameUpdateSignal.get_instance().select_package(None) + else: + GameUpdateSignal.get_instance().select_package(index.row()) def on_edit(self) -> None: """Opens the package edit dialog.""" diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 1f3fdd57..227e6c5a 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -42,6 +42,8 @@ class QLiberationMap(QGraphicsView): self.game: Optional[Game] = game_model.game self.flight_path_items: List[QGraphicsItem] = [] + # A tuple of (package index, flight index), or none. + self.selected_flight: Optional[Tuple[int, int]] = None self.setMinimumSize(800,600) self.setMaximumHeight(2160) @@ -56,6 +58,25 @@ class QLiberationMap(QGraphicsView): lambda: self.draw_flight_plans(self.scene()) ) + def update_package_selection(index: Optional[int]) -> None: + self.selected_flight = index, 0 + self.draw_flight_plans(self.scene()) + + GameUpdateSignal.get_instance().package_selection_changed.connect( + update_package_selection + ) + + def update_flight_selection(index: Optional[int]) -> None: + if self.selected_flight is None: + logging.error("Flight was selected with no package selected") + return + self.selected_flight = self.selected_flight[0], index + self.draw_flight_plans(self.scene()) + + GameUpdateSignal.get_instance().flight_selection_changed.connect( + update_flight_selection + ) + def init_scene(self): scene = QLiberationScene(self) self.setScene(scene) @@ -198,37 +219,44 @@ class QLiberationMap(QGraphicsView): # Something may have caused those items to already be removed. pass self.flight_path_items.clear() - if not DisplayOptions.flight_paths: + if DisplayOptions.flight_paths.hide: return - for package in self.game_model.ato_model.packages: - for flight in package.flights: - self.draw_flight_plan(scene, flight) + for p_idx, package in enumerate(self.game_model.ato_model.packages): + for f_idx, flight in enumerate(package.flights): + selected = (p_idx, f_idx) == self.selected_flight + if DisplayOptions.flight_paths.only_selected and not selected: + continue + highlight = selected and DisplayOptions.flight_paths.all + self.draw_flight_plan(scene, flight, highlight) - def draw_flight_plan(self, scene: QGraphicsScene, flight: Flight) -> None: + def draw_flight_plan(self, scene: QGraphicsScene, flight: Flight, + highlight: bool) -> None: is_player = flight.from_cp.captured pos = self._transform_point(flight.from_cp.position) - self.draw_waypoint(scene, pos, is_player) + self.draw_waypoint(scene, pos, is_player, highlight) prev_pos = tuple(pos) for point in flight.points: new_pos = self._transform_point(Point(point.x, point.y)) - self.draw_flight_path(scene, prev_pos, new_pos, is_player) - self.draw_waypoint(scene, new_pos, is_player) + self.draw_flight_path(scene, prev_pos, new_pos, is_player, + highlight) + self.draw_waypoint(scene, new_pos, is_player, highlight) prev_pos = tuple(new_pos) - self.draw_flight_path(scene, prev_pos, pos, is_player) + self.draw_flight_path(scene, prev_pos, pos, is_player, highlight) def draw_waypoint(self, scene: QGraphicsScene, position: Tuple[int, int], - player: bool) -> None: - waypoint_pen = self.waypoint_pen(player) - waypoint_brush = self.waypoint_brush(player) + player: bool, highlight: bool) -> None: + waypoint_pen = self.waypoint_pen(player, highlight) + waypoint_brush = self.waypoint_brush(player, highlight) self.flight_path_items.append(scene.addEllipse( position[0], position[1], self.WAYPOINT_SIZE, self.WAYPOINT_SIZE, waypoint_pen, waypoint_brush )) def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[int, int], - pos1: Tuple[int, int], player: bool): - flight_path_pen = self.flight_path_pen(player) + pos1: Tuple[int, int], player: bool, + highlight: bool) -> None: + flight_path_pen = self.flight_path_pen(player, highlight) # Draw the line to the *middle* of the waypoint. offset = self.WAYPOINT_SIZE // 2 self.flight_path_items.append(scene.addLine( @@ -317,21 +345,31 @@ class QLiberationMap(QGraphicsView): return X > treshold and X or treshold, Y > treshold and Y or treshold + def highlight_color(self, transparent: Optional[bool] = False) -> QColor: + return QColor(255, 255, 0, 20 if transparent else 255) + def base_faction_color_name(self, player: bool) -> str: if player: return self.game.get_player_color() else: return self.game.get_enemy_color() - def waypoint_pen(self, player: bool) -> QPen: + def waypoint_pen(self, player: bool, highlight: bool) -> QColor: + if highlight: + return self.highlight_color() name = self.base_faction_color_name(player) - return QPen(brush=CONST.COLORS[name]) + return CONST.COLORS[name] - def waypoint_brush(self, player: bool) -> QColor: + def waypoint_brush(self, player: bool, highlight: bool) -> QColor: + if highlight: + return self.highlight_color(transparent=True) name = self.base_faction_color_name(player) return CONST.COLORS[f"{name}_transparent"] - def flight_path_pen(self, player: bool) -> QPen: + def flight_path_pen(self, player: bool, highlight: bool) -> QPen: + if highlight: + return self.highlight_color() + name = self.base_faction_color_name(player) color = CONST.COLORS[name] pen = QPen(brush=color) diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index 8a52d555..3c112952 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Tuple from PySide2.QtCore import QObject, Signal @@ -24,11 +24,21 @@ class GameUpdateSignal(QObject): debriefingReceived = Signal(DebriefingSignal) flight_paths_changed = Signal() + package_selection_changed = Signal(int) # Optional[int] + flight_selection_changed = Signal(int) # Optional[int] def __init__(self): super(GameUpdateSignal, self).__init__() GameUpdateSignal.instance = self + def select_package(self, index: Optional[int]) -> None: + # noinspection PyUnresolvedReferences + self.package_selection_changed.emit(index) + + def select_flight(self, index: Optional[int]) -> None: + # noinspection PyUnresolvedReferences + self.flight_selection_changed.emit(index) + def redraw_flight_paths(self) -> None: # noinspection PyUnresolvedReferences self.flight_paths_changed.emit() diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index acda1bb0..7e203b98 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -1,16 +1,16 @@ import logging import sys import webbrowser -from typing import Optional +from typing import Optional, Union from PySide2.QtCore import Qt from PySide2.QtGui import QIcon from PySide2.QtWidgets import ( QAction, - QDesktopWidget, + QActionGroup, QDesktopWidget, QFileDialog, QMainWindow, - QMessageBox, + QMenu, QMessageBox, QSplitter, QVBoxLayout, QWidget, @@ -19,7 +19,7 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game, persistency from qt_ui.dialogs import Dialog -from qt_ui.displayoptions import DisplayOptions +from qt_ui.displayoptions import DisplayGroup, DisplayOptions, DisplayRule from qt_ui.models import GameModel from qt_ui.uiconstants import URLS from qt_ui.widgets.QTopPanel import QTopPanel @@ -139,17 +139,19 @@ class QLiberationWindow(QMainWindow): displayMenu = self.menu.addMenu("&Display") - for display_rule in DisplayOptions.menu_items(): - def make_check_closure(): - def closure(): - display_rule.value = action.isChecked() - return closure - - action = QAction(f"&{display_rule.menu_text}", displayMenu) - action.setCheckable(True) - action.setChecked(display_rule.value) - action.toggled.connect(make_check_closure()) - displayMenu.addAction(action) + last_was_group = True + for item in DisplayOptions.menu_items(): + if isinstance(item, DisplayRule): + displayMenu.addAction(self.make_display_rule_action(item)) + last_was_group = False + elif isinstance(item, DisplayGroup): + if not last_was_group: + displayMenu.addSeparator() + group = QActionGroup(displayMenu) + for display_rule in item: + displayMenu.addAction( + self.make_display_rule_action(display_rule, group)) + last_was_group = True help_menu = self.menu.addMenu("&Help") help_menu.addAction("&Discord Server", lambda: webbrowser.open_new_tab("https://" + "discord.gg" + "/" + "bKrt" + "rkJ")) @@ -162,6 +164,21 @@ class QLiberationWindow(QMainWindow): help_menu.addSeparator() help_menu.addAction(self.showAboutDialogAction) + @staticmethod + def make_display_rule_action( + display_rule, group: Optional[QActionGroup] = None) -> QAction: + def make_check_closure(): + def closure(): + display_rule.value = action.isChecked() + + return closure + + action = QAction(f"&{display_rule.menu_text}", group) + action.setCheckable(True) + action.setChecked(display_rule.value) + action.toggled.connect(make_check_closure()) + return action + def newGame(self): wizard = NewGameWizard(self) wizard.show() From 1fa18447e17c3aa128b9a27a9f156faa91cdf513 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 9 Oct 2020 18:23:43 -0700 Subject: [PATCH 3/3] Show player slots in the overview. --- qt_ui/dialogs.py | 2 +- qt_ui/models.py | 6 ++ qt_ui/widgets/QLabeledWidget.py | 11 ++- qt_ui/widgets/QTopPanel.py | 31 +++--- qt_ui/widgets/ato.py | 108 ++++++++++++++++++++- qt_ui/widgets/clientslots.py | 28 ++++++ qt_ui/windows/QLiberationWindow.py | 2 +- qt_ui/windows/mission/QEditFlightDialog.py | 12 +-- 8 files changed, 177 insertions(+), 23 deletions(-) create mode 100644 qt_ui/widgets/clientslots.py diff --git a/qt_ui/dialogs.py b/qt_ui/dialogs.py index e09dd92a..f88aa4b0 100644 --- a/qt_ui/dialogs.py +++ b/qt_ui/dialogs.py @@ -58,7 +58,7 @@ class Dialog: flight: Flight) -> None: """Opens the dialog to edit the given flight.""" cls.edit_flight_dialog = QEditFlightDialog( - cls.game_model.game, + cls.game_model, package_model.package, flight ) diff --git a/qt_ui/models.py b/qt_ui/models.py index 98515eab..428b4598 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -95,6 +95,8 @@ class NullListModel(QAbstractListModel): class PackageModel(QAbstractListModel): """The model for an ATO package.""" + FlightRole = Qt.UserRole + #: Emitted when this package is being deleted from the ATO. deleted = Signal() @@ -113,6 +115,8 @@ class PackageModel(QAbstractListModel): return self.text_for_flight(flight) if role == Qt.DecorationRole: return self.icon_for_flight(flight) + elif role == PackageModel.FlightRole: + return flight return None @staticmethod @@ -185,6 +189,8 @@ class AtoModel(QAbstractListModel): PackageRole = Qt.UserRole + client_slots_changed = Signal() + def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None: super().__init__() self.game = game diff --git a/qt_ui/widgets/QLabeledWidget.py b/qt_ui/widgets/QLabeledWidget.py index 88459896..f258f458 100644 --- a/qt_ui/widgets/QLabeledWidget.py +++ b/qt_ui/widgets/QLabeledWidget.py @@ -1,4 +1,6 @@ """A layout containing a widget with an associated label.""" +from typing import Optional + from PySide2.QtCore import Qt from PySide2.QtWidgets import QHBoxLayout, QLabel, QWidget @@ -10,8 +12,13 @@ class QLabeledWidget(QHBoxLayout): label is used to name the input. """ - def __init__(self, text: str, widget: QWidget) -> None: + def __init__(self, text: str, widget: QWidget, + tooltip: Optional[str]) -> None: super().__init__() - self.addWidget(QLabel(text)) + label = QLabel(text) + self.addWidget(label) self.addStretch() self.addWidget(widget, alignment=Qt.AlignRight) + if tooltip is not None: + label.setToolTip(tooltip) + widget.setToolTip(tooltip) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index f75826ad..dabaecd7 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -11,9 +11,11 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game from game.event import CAP, CAS, FrontlineAttackEvent +from qt_ui.models import GameModel from qt_ui.widgets.QBudgetBox import QBudgetBox from qt_ui.widgets.QFactionsInfos import QFactionsInfos from qt_ui.widgets.QTurnCounter import QTurnCounter +from qt_ui.widgets.clientslots import MaxPlayerCount from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QWaitingForMissionResultWindow import \ QWaitingForMissionResultWindow @@ -23,14 +25,18 @@ from qt_ui.windows.stats.QStatsWindow import QStatsWindow class QTopPanel(QFrame): - def __init__(self, game: Game): + def __init__(self, game_model: GameModel): super(QTopPanel, self).__init__() - self.game = game + self.game_model = game_model self.setMaximumHeight(70) self.init_ui() GameUpdateSignal.get_instance().gameupdated.connect(self.setGame) GameUpdateSignal.get_instance().budgetupdated.connect(self.budget_update) + @property + def game(self) -> Optional[Game]: + return self.game_model.game + def init_ui(self): self.turnCounter = QTurnCounter() @@ -68,6 +74,8 @@ class QTopPanel(QFrame): self.proceedBox = QGroupBox("Proceed") self.proceedBoxLayout = QHBoxLayout() + self.proceedBoxLayout.addLayout( + MaxPlayerCount(self.game_model.ato_model)) self.proceedBoxLayout.addWidget(self.passTurnButton) self.proceedBoxLayout.addWidget(self.proceedButton) self.proceedBox.setLayout(self.proceedBoxLayout) @@ -84,16 +92,17 @@ class QTopPanel(QFrame): self.setLayout(self.layout) def setGame(self, game: Optional[Game]): - self.game = game - if game is not None: - self.turnCounter.setCurrentTurn(self.game.turn, self.game.current_day) - self.budgetBox.setGame(self.game) - self.factionsInfos.setGame(self.game) + if game is None: + return - if self.game and self.game.turn == 0: - self.proceedButton.setEnabled(False) - else: - self.proceedButton.setEnabled(True) + self.turnCounter.setCurrentTurn(self.game.turn, self.game.current_day) + self.budgetBox.setGame(self.game) + self.factionsInfos.setGame(self.game) + + if self.game and self.game.turn == 0: + self.proceedButton.setEnabled(False) + else: + self.proceedButton.setEnabled(True) def openSettings(self): self.subwindow = QSettingsWindow(self.game) diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index 8703f7e9..c978c1bc 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -10,7 +10,7 @@ from PySide2.QtCore import ( QSize, Qt, ) -from PySide2.QtGui import QFont, QFontMetrics, QPainter +from PySide2.QtGui import QFont, QFontMetrics, QIcon, QPainter from PySide2.QtWidgets import ( QAbstractItemView, QGroupBox, @@ -18,15 +18,109 @@ from PySide2.QtWidgets import ( QListView, QPushButton, QSplitter, - QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout, + QStyle, QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout, ) +from game import db from gen.ato import Package from gen.flights.flight import Flight from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from ..models import AtoModel, GameModel, NullListModel, PackageModel +class FlightDelegate(QStyledItemDelegate): + FONT_SIZE = 10 + HMARGIN = 4 + VMARGIN = 4 + + def get_font(self, option: QStyleOptionViewItem) -> QFont: + font = QFont(option.font) + font.setPointSize(self.FONT_SIZE) + return font + + @staticmethod + def flight(index: QModelIndex) -> Flight: + return index.data(PackageModel.FlightRole) + + def first_row_text(self, index: QModelIndex) -> str: + flight = self.flight(index) + task = flight.flight_type.name + count = flight.count + name = db.unit_type_name(flight.unit_type) + delay = flight.scheduled_in + return f"[{task}] {count} x {name} in {delay} minutes" + + def second_row_text(self, index: QModelIndex) -> str: + flight = self.flight(index) + origin = flight.from_cp.name + return f"From {origin}" + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, + index: QModelIndex) -> None: + # Draw the list item with all the default selection styling, but with an + # invalid index so text formatting is left to us. + super().paint(painter, option, QModelIndex()) + + rect = option.rect.adjusted(self.HMARGIN, self.VMARGIN, -self.HMARGIN, + -self.VMARGIN) + + with painter_context(painter): + painter.setFont(self.get_font(option)) + + icon: Optional[QIcon] = index.data(Qt.DecorationRole) + if icon is not None: + icon.paint(painter, rect, Qt.AlignLeft | Qt.AlignVCenter, + self.icon_mode(option), + self.icon_state(option)) + + rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, + 0, 0, 0) + painter.drawText(rect, Qt.AlignLeft, self.first_row_text(index)) + line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2) + painter.drawText(line2, Qt.AlignLeft, self.second_row_text(index)) + + clients = self.num_clients(index) + if clients: + painter.drawText(rect, Qt.AlignRight, + f"Player Slots: {clients}") + + def num_clients(self, index: QModelIndex) -> int: + flight = self.flight(index) + return flight.client_count + + @staticmethod + def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode: + if not (option.state & QStyle.State_Enabled): + return QIcon.Disabled + elif option.state & QStyle.State_Selected: + return QIcon.Selected + elif option.state & QStyle.State_Active: + return QIcon.Active + return QIcon.Normal + + @staticmethod + def icon_state(option: QStyleOptionViewItem) -> QIcon.State: + return QIcon.On if option.state & QStyle.State_Open else QIcon.Off + + @staticmethod + def icon_size(option: QStyleOptionViewItem) -> QSize: + icon_size: Optional[QSize] = option.decorationSize + if icon_size is None: + return QSize(0, 0) + else: + return icon_size + + def sizeHint(self, option: QStyleOptionViewItem, + index: QModelIndex) -> QSize: + left = self.icon_size(option).width() + self.HMARGIN + metrics = QFontMetrics(self.get_font(option)) + first = metrics.size(0, self.first_row_text(index)) + second = metrics.size(0, self.second_row_text(index)) + text_width = max(first.width(), second.width()) + return QSize(left + text_width + 2 * self.HMARGIN, + first.height() + second.height() + 2 * self.VMARGIN) + + class QFlightList(QListView): """List view for displaying the flights of a package.""" @@ -34,6 +128,7 @@ class QFlightList(QListView): super().__init__() self.package_model = model self.set_package(model) + self.setItemDelegate(FlightDelegate()) self.setIconSize(QSize(91, 24)) self.setSelectionBehavior(QAbstractItemView.SelectItems) @@ -206,6 +301,15 @@ class PackageDelegate(QStyledItemDelegate): line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2) painter.drawText(line2, Qt.AlignLeft, self.right_text(index)) + clients = self.num_clients(index) + if clients: + painter.drawText(rect, Qt.AlignRight, + f"Player Slots: {clients}") + + def num_clients(self, index: QModelIndex) -> int: + package = self.package(index) + return sum(f.client_count for f in package.flights) + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: metrics = QFontMetrics(self.get_font(option)) diff --git a/qt_ui/widgets/clientslots.py b/qt_ui/widgets/clientslots.py new file mode 100644 index 00000000..1c9fff9b --- /dev/null +++ b/qt_ui/widgets/clientslots.py @@ -0,0 +1,28 @@ +"""Widgets for displaying client slots.""" +from PySide2.QtWidgets import QLabel + +from qt_ui.models import AtoModel +from qt_ui.widgets.QLabeledWidget import QLabeledWidget + + +class MaxPlayerCount(QLabeledWidget): + def __init__(self, ato_model: AtoModel) -> None: + self.ato_model = ato_model + self.slots_label = QLabel(str(self.count_client_slots)) + self.ato_model.client_slots_changed.connect(self.update_count) + super().__init__( + "Max Players:", self.slots_label, + ("Total number of client slots. To add client slots, edit a flight " + "using the panel on the left.") + ) + + @property + def count_client_slots(self) -> int: + slots = 0 + for package in self.ato_model.packages: + for flight in package.flights: + slots += flight.client_count + return slots + + def update_count(self) -> None: + self.slots_label.setText(str(self.count_client_slots)) diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index 7e203b98..db5caa10 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -77,7 +77,7 @@ class QLiberationWindow(QMainWindow): vbox = QVBoxLayout() vbox.setMargin(0) - vbox.addWidget(QTopPanel(self.game)) + vbox.addWidget(QTopPanel(self.game_model)) vbox.addWidget(hbox) central_widget = QWidget() diff --git a/qt_ui/windows/mission/QEditFlightDialog.py b/qt_ui/windows/mission/QEditFlightDialog.py index 9f795b79..bfbcc5cb 100644 --- a/qt_ui/windows/mission/QEditFlightDialog.py +++ b/qt_ui/windows/mission/QEditFlightDialog.py @@ -4,9 +4,9 @@ from PySide2.QtWidgets import ( QVBoxLayout, ) -from game import Game from gen.ato import Package from gen.flights.flight import Flight +from qt_ui.models import GameModel from qt_ui.uiconstants import EVENT_ICONS from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner @@ -15,22 +15,22 @@ from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner class QEditFlightDialog(QDialog): """Dialog window for editing flight plans and loadouts.""" - def __init__(self, game: Game, package: Package, flight: Flight) -> None: + def __init__(self, game_model: GameModel, package: Package, flight: Flight) -> None: super().__init__() - self.game = game + self.game_model = game_model self.setWindowTitle("Create flight") self.setWindowIcon(EVENT_ICONS["strike"]) layout = QVBoxLayout() - self.flight_planner = QFlightPlanner(package, flight, game) + self.flight_planner = QFlightPlanner(package, flight, game_model.game) layout.addWidget(self.flight_planner) self.setLayout(layout) self.finished.connect(self.on_close) - @staticmethod - def on_close(_result) -> None: + def on_close(self, _result) -> None: GameUpdateSignal.get_instance().redraw_flight_paths() + self.game_model.ato_model.client_slots_changed.emit()