2026-02-10 09:53:49 +01:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from PyQt6.QtWidgets import QMessageBox
|
|
|
|
|
|
|
|
|
|
from warchron.model.model import Model
|
2026-02-13 11:38:59 +01:00
|
|
|
from warchron.model.exception import DomainError, RequiresConfirmation
|
2026-02-10 09:53:49 +01:00
|
|
|
from warchron.view.view import View
|
|
|
|
|
from warchron.constants import ItemType, RefreshScope
|
|
|
|
|
from warchron.controller.navigation_controller import NavigationController
|
|
|
|
|
from warchron.controller.player_controller import PlayerController
|
|
|
|
|
from warchron.controller.war_controller import WarController
|
|
|
|
|
from warchron.controller.campaign_controller import CampaignController
|
|
|
|
|
from warchron.controller.round_controller import RoundController
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AppController:
|
2026-02-11 13:23:04 +01:00
|
|
|
def __init__(self, model: Model, view: View, version: str) -> None:
|
2026-02-10 09:53:49 +01:00
|
|
|
self.model: Model = model
|
|
|
|
|
self.view: View = view
|
2026-02-11 13:23:04 +01:00
|
|
|
self.app_version = version
|
2026-02-10 09:53:49 +01:00
|
|
|
self.navigation = NavigationController(self)
|
|
|
|
|
self.players = PlayerController(self)
|
|
|
|
|
self.wars = WarController(self)
|
|
|
|
|
self.campaigns = CampaignController(self)
|
|
|
|
|
self.rounds = RoundController(self)
|
|
|
|
|
self.current_file: Path | None = None
|
|
|
|
|
self.view.on_close_callback = self.on_app_close
|
|
|
|
|
self.is_dirty: bool = False
|
|
|
|
|
self.__connect()
|
|
|
|
|
self.navigation.refresh_players_view()
|
|
|
|
|
self.navigation.refresh_wars_view()
|
|
|
|
|
self.update_window_title()
|
|
|
|
|
self.view.on_tree_selection_changed = self.navigation.on_tree_selection_changed
|
2026-02-13 15:44:28 +01:00
|
|
|
self.view.on_add_item = self.add_item
|
2026-02-10 09:53:49 +01:00
|
|
|
|
|
|
|
|
def __connect(self) -> None:
|
|
|
|
|
self.view.actionExit.triggered.connect(self.view.close)
|
|
|
|
|
self.view.actionNew.triggered.connect(self.new)
|
|
|
|
|
self.view.actionOpen.triggered.connect(self.open_file)
|
|
|
|
|
self.view.actionSave.triggered.connect(self.save)
|
|
|
|
|
self.view.actionSave_as.triggered.connect(self.save_as)
|
2026-02-11 13:23:04 +01:00
|
|
|
self.view.actionAbout.triggered.connect(self.show_about)
|
2026-02-13 15:44:28 +01:00
|
|
|
self.view.addPlayerBtn.clicked.connect(lambda: self.add_item(ItemType.PLAYER))
|
|
|
|
|
self.view.addWarBtn.clicked.connect(lambda: self.add_item(ItemType.WAR))
|
2026-02-10 16:26:49 +01:00
|
|
|
self.view.majorValue.valueChanged.connect(self.wars.set_major_value)
|
|
|
|
|
self.view.minorValue.valueChanged.connect(self.wars.set_minor_value)
|
|
|
|
|
self.view.influenceToken.toggled.connect(self.wars.set_influence_token)
|
2026-02-13 15:44:28 +01:00
|
|
|
self.view.addObjectiveBtn.clicked.connect(
|
|
|
|
|
lambda: self.add_item(ItemType.OBJECTIVE)
|
|
|
|
|
)
|
|
|
|
|
self.view.addWarParticipantBtn.clicked.connect(
|
|
|
|
|
lambda: self.add_item(ItemType.WAR_PARTICIPANT)
|
|
|
|
|
)
|
2026-02-11 19:22:43 +01:00
|
|
|
self.view.endWarBtn.clicked.connect(self.wars.close_war)
|
2026-02-13 15:44:28 +01:00
|
|
|
self.view.addSectorBtn.clicked.connect(lambda: self.add_item(ItemType.SECTOR))
|
2026-02-10 09:53:49 +01:00
|
|
|
self.view.addCampaignParticipantBtn.clicked.connect(
|
2026-02-13 15:44:28 +01:00
|
|
|
lambda: self.add_item(ItemType.CAMPAIGN_PARTICIPANT)
|
2026-02-10 09:53:49 +01:00
|
|
|
)
|
2026-02-11 19:22:43 +01:00
|
|
|
self.view.endCampaignBtn.clicked.connect(self.campaigns.close_campaign)
|
|
|
|
|
self.view.endRoundBtn.clicked.connect(self.rounds.close_round)
|
2026-03-11 11:44:57 +01:00
|
|
|
self.view.resolvePairingBtn.clicked.connect(self.rounds.resolve_pairing)
|
2026-02-13 15:44:28 +01:00
|
|
|
self.view.on_add_item = self.add_item
|
2026-02-10 09:53:49 +01:00
|
|
|
self.view.on_edit_item = self.edit_item
|
|
|
|
|
self.view.on_delete_item = self.delete_item
|
|
|
|
|
|
|
|
|
|
def on_app_close(self) -> bool:
|
|
|
|
|
if self.is_dirty:
|
|
|
|
|
reply = QMessageBox.question(
|
|
|
|
|
self.view,
|
|
|
|
|
"Unsaved changes",
|
|
|
|
|
"You have unsaved changes. Do you want to save before quitting?",
|
|
|
|
|
QMessageBox.StandardButton.Yes
|
|
|
|
|
| QMessageBox.StandardButton.No
|
|
|
|
|
| QMessageBox.StandardButton.Cancel,
|
|
|
|
|
)
|
|
|
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
|
|
|
self.save()
|
|
|
|
|
elif reply == QMessageBox.StandardButton.Cancel:
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Menu bar methods
|
|
|
|
|
|
|
|
|
|
def new(self) -> None:
|
|
|
|
|
if self.is_dirty:
|
|
|
|
|
reply = QMessageBox.question(
|
|
|
|
|
self.view,
|
|
|
|
|
"Unsaved changes",
|
|
|
|
|
"Discard current campaign?",
|
|
|
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
|
|
|
)
|
|
|
|
|
if reply != QMessageBox.StandardButton.Yes:
|
|
|
|
|
return
|
|
|
|
|
self.model.new()
|
|
|
|
|
self.current_file = None
|
|
|
|
|
self.is_dirty = False
|
|
|
|
|
self.navigation.refresh_players_view()
|
|
|
|
|
self.navigation.refresh_wars_view()
|
|
|
|
|
self.update_window_title()
|
|
|
|
|
|
|
|
|
|
def open_file(self) -> None:
|
|
|
|
|
if self.is_dirty:
|
|
|
|
|
reply = QMessageBox.question(
|
|
|
|
|
self.view,
|
|
|
|
|
"Unsaved changes",
|
|
|
|
|
"Discard current campaign?",
|
|
|
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
|
|
|
)
|
|
|
|
|
if reply != QMessageBox.StandardButton.Yes:
|
|
|
|
|
return
|
|
|
|
|
path = self.view.ask_open_file()
|
|
|
|
|
if not path:
|
|
|
|
|
return
|
2026-03-12 16:28:20 +01:00
|
|
|
try:
|
|
|
|
|
self.model.load(path)
|
|
|
|
|
except RuntimeError as e:
|
|
|
|
|
QMessageBox.warning(
|
|
|
|
|
self.view,
|
|
|
|
|
"Add forbidden",
|
|
|
|
|
str(e),
|
|
|
|
|
)
|
|
|
|
|
return
|
2026-02-10 09:53:49 +01:00
|
|
|
self.current_file = path
|
|
|
|
|
self.is_dirty = False
|
|
|
|
|
self.navigation.refresh_players_view()
|
|
|
|
|
self.navigation.refresh_wars_view()
|
|
|
|
|
self.update_window_title()
|
|
|
|
|
|
|
|
|
|
def save(self) -> None:
|
|
|
|
|
if not self.current_file:
|
|
|
|
|
self.save_as()
|
|
|
|
|
return
|
|
|
|
|
self.model.save(self.current_file)
|
|
|
|
|
self.is_dirty = False
|
|
|
|
|
self.update_window_title()
|
|
|
|
|
|
|
|
|
|
def save_as(self) -> None:
|
|
|
|
|
path = self.view.ask_save_file()
|
|
|
|
|
if not path:
|
|
|
|
|
return
|
|
|
|
|
self.current_file = path
|
|
|
|
|
self.model.save(path)
|
|
|
|
|
self.is_dirty = False
|
|
|
|
|
self.update_window_title()
|
|
|
|
|
|
2026-02-11 13:23:04 +01:00
|
|
|
def show_about(self) -> None:
|
|
|
|
|
QMessageBox.about(
|
|
|
|
|
self.view,
|
|
|
|
|
"About WarChron",
|
|
|
|
|
f"""
|
|
|
|
|
<h2>WarChron</h2>
|
|
|
|
|
<p><b>Version:</b> {self.app_version}</p>
|
|
|
|
|
<p>Campaign & War management tool</p>
|
|
|
|
|
<p>© 2026 Your Name</p>
|
|
|
|
|
<p>Licensed under GNU GPL v3</p>
|
|
|
|
|
<hr>
|
|
|
|
|
<p>Icons from Fugue Icons 3.5.6<br>
|
|
|
|
|
© Yusuke Kamiyamane<br>
|
|
|
|
|
Licensed under Creative Commons Attribution 3.0</p>
|
|
|
|
|
""",
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-10 09:53:49 +01:00
|
|
|
# Display methods
|
|
|
|
|
|
|
|
|
|
def update_window_title(self) -> None:
|
|
|
|
|
base = "WarChron"
|
|
|
|
|
if self.current_file:
|
|
|
|
|
base += f" - {self.current_file.name}"
|
|
|
|
|
else:
|
|
|
|
|
base += " - New file"
|
|
|
|
|
if self.is_dirty:
|
|
|
|
|
base = base + " *"
|
|
|
|
|
self.view.setWindowTitle(base)
|
|
|
|
|
|
|
|
|
|
# Command methods
|
|
|
|
|
|
2026-02-13 15:44:28 +01:00
|
|
|
def add_item(self, item_type: str) -> None:
|
|
|
|
|
try:
|
|
|
|
|
if item_type == ItemType.PLAYER:
|
|
|
|
|
play = self.players.create_player()
|
|
|
|
|
if not play:
|
|
|
|
|
return
|
|
|
|
|
self.navigation.refresh(RefreshScope.PLAYERS_LIST)
|
|
|
|
|
elif item_type == ItemType.WAR:
|
|
|
|
|
war = self.wars.create_war()
|
|
|
|
|
if not war:
|
|
|
|
|
return
|
|
|
|
|
self.navigation.refresh_and_select(
|
|
|
|
|
RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war.id
|
|
|
|
|
)
|
|
|
|
|
elif item_type == ItemType.CAMPAIGN:
|
|
|
|
|
camp = self.campaigns.create_campaign()
|
|
|
|
|
if not camp:
|
|
|
|
|
return
|
|
|
|
|
self.navigation.refresh_and_select(
|
|
|
|
|
RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp.id
|
|
|
|
|
)
|
|
|
|
|
elif item_type == ItemType.OBJECTIVE:
|
|
|
|
|
obj = self.wars.create_objective()
|
|
|
|
|
if not obj:
|
|
|
|
|
return
|
|
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
|
|
|
|
elif item_type == ItemType.WAR_PARTICIPANT:
|
|
|
|
|
war_part = self.wars.create_war_participant()
|
|
|
|
|
if not war_part:
|
|
|
|
|
return
|
|
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
|
|
|
|
elif item_type == ItemType.SECTOR:
|
|
|
|
|
sect = self.campaigns.create_sector()
|
|
|
|
|
if not sect:
|
|
|
|
|
return
|
|
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
|
|
|
|
elif item_type == ItemType.CAMPAIGN_PARTICIPANT:
|
|
|
|
|
camp_part = self.campaigns.create_campaign_participant()
|
|
|
|
|
if not camp_part:
|
|
|
|
|
return
|
|
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
|
|
|
|
elif item_type == ItemType.ROUND:
|
|
|
|
|
rnd = self.rounds.create_round()
|
|
|
|
|
if not rnd:
|
|
|
|
|
return
|
|
|
|
|
self.navigation.refresh_and_select(
|
|
|
|
|
RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=rnd.id
|
|
|
|
|
)
|
|
|
|
|
except DomainError as e:
|
|
|
|
|
QMessageBox.warning(
|
|
|
|
|
self.view,
|
2026-02-24 15:40:24 +01:00
|
|
|
"Add forbidden",
|
2026-02-13 15:44:28 +01:00
|
|
|
str(e),
|
|
|
|
|
)
|
2026-02-24 15:40:24 +01:00
|
|
|
return
|
2026-02-13 15:44:28 +01:00
|
|
|
except RequiresConfirmation as e:
|
|
|
|
|
reply = QMessageBox.question(
|
|
|
|
|
self.view,
|
|
|
|
|
"Confirm update",
|
|
|
|
|
str(e),
|
|
|
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
|
|
|
)
|
|
|
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
|
|
|
e.action()
|
2026-02-24 15:40:24 +01:00
|
|
|
else:
|
|
|
|
|
return
|
|
|
|
|
self.is_dirty = True
|
|
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-13 15:44:28 +01:00
|
|
|
|
2026-02-10 09:53:49 +01:00
|
|
|
def edit_item(self, item_type: str, item_id: str) -> None:
|
|
|
|
|
try:
|
|
|
|
|
if item_type == ItemType.PLAYER:
|
|
|
|
|
self.players.edit_player(item_id)
|
|
|
|
|
self.navigation.refresh(RefreshScope.PLAYERS_LIST)
|
|
|
|
|
elif item_type == ItemType.WAR:
|
|
|
|
|
self.wars.edit_war(item_id)
|
|
|
|
|
self.navigation.refresh_and_select(
|
|
|
|
|
RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=item_id
|
|
|
|
|
)
|
|
|
|
|
elif item_type == ItemType.CAMPAIGN:
|
|
|
|
|
self.campaigns.edit_campaign(item_id)
|
|
|
|
|
self.navigation.refresh_and_select(
|
|
|
|
|
RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=item_id
|
|
|
|
|
)
|
|
|
|
|
elif item_type == ItemType.OBJECTIVE:
|
|
|
|
|
self.wars.edit_objective(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
elif item_type == ItemType.WAR_PARTICIPANT:
|
|
|
|
|
self.wars.edit_war_participant(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
elif item_type == ItemType.SECTOR:
|
|
|
|
|
self.campaigns.edit_sector(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
elif item_type == ItemType.CAMPAIGN_PARTICIPANT:
|
|
|
|
|
self.campaigns.edit_campaign_participant(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
elif item_type == ItemType.CHOICE:
|
|
|
|
|
self.rounds.edit_round_choice(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
elif item_type == ItemType.BATTLE:
|
|
|
|
|
self.rounds.edit_round_battle(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-13 15:44:28 +01:00
|
|
|
except DomainError as e:
|
|
|
|
|
QMessageBox.warning(
|
|
|
|
|
self.view,
|
2026-02-24 15:40:24 +01:00
|
|
|
"Update forbidden",
|
2026-02-13 15:44:28 +01:00
|
|
|
str(e),
|
|
|
|
|
)
|
2026-02-24 15:40:24 +01:00
|
|
|
return
|
2026-02-13 11:38:59 +01:00
|
|
|
except RequiresConfirmation as e:
|
2026-02-10 09:53:49 +01:00
|
|
|
reply = QMessageBox.question(
|
|
|
|
|
self.view,
|
|
|
|
|
"Confirm update",
|
2026-02-13 11:38:59 +01:00
|
|
|
str(e),
|
2026-02-10 09:53:49 +01:00
|
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
|
|
|
)
|
|
|
|
|
if reply == QMessageBox.StandardButton.Yes:
|
2026-02-26 15:42:14 +01:00
|
|
|
try:
|
|
|
|
|
e.action()
|
|
|
|
|
except DomainError as inner:
|
|
|
|
|
QMessageBox.warning(
|
|
|
|
|
self.view,
|
|
|
|
|
"Update forbidden",
|
|
|
|
|
str(inner),
|
|
|
|
|
)
|
|
|
|
|
return
|
2026-02-24 15:40:24 +01:00
|
|
|
else:
|
|
|
|
|
return
|
2026-02-25 14:37:59 +01:00
|
|
|
self.is_dirty = True
|
2026-02-24 15:40:24 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
|
|
|
|
|
def delete_item(self, item_type: str, item_id: str) -> None:
|
|
|
|
|
reply = QMessageBox.question(
|
|
|
|
|
self.view,
|
|
|
|
|
"Confirm deletion",
|
|
|
|
|
"Are you sure you want to delete this item?",
|
|
|
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
|
|
|
)
|
|
|
|
|
if reply != QMessageBox.StandardButton.Yes:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
if item_type == ItemType.PLAYER:
|
|
|
|
|
self.model.remove_player(item_id)
|
|
|
|
|
self.navigation.refresh(RefreshScope.PLAYERS_LIST)
|
|
|
|
|
elif item_type == ItemType.WAR:
|
|
|
|
|
self.model.remove_war(item_id)
|
|
|
|
|
self.navigation.refresh(RefreshScope.WARS_TREE)
|
|
|
|
|
elif item_type == ItemType.CAMPAIGN:
|
|
|
|
|
war = self.model.get_war_by_campaign(item_id)
|
|
|
|
|
war_id = war.id
|
|
|
|
|
self.model.remove_campaign(item_id)
|
|
|
|
|
self.navigation.refresh_and_select(
|
|
|
|
|
RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id
|
|
|
|
|
)
|
|
|
|
|
elif item_type == ItemType.OBJECTIVE:
|
|
|
|
|
self.model.remove_objective(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
elif item_type == ItemType.WAR_PARTICIPANT:
|
|
|
|
|
self.model.remove_war_participant(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
elif item_type == ItemType.SECTOR:
|
|
|
|
|
self.model.remove_sector(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
elif item_type == ItemType.CAMPAIGN_PARTICIPANT:
|
|
|
|
|
self.model.remove_campaign_participant(item_id)
|
2026-02-10 11:09:58 +01:00
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
2026-02-10 09:53:49 +01:00
|
|
|
elif item_type == ItemType.ROUND:
|
|
|
|
|
camp = self.model.get_campaign_by_round(item_id)
|
|
|
|
|
camp_id = camp.id
|
|
|
|
|
self.model.remove_round(item_id)
|
|
|
|
|
self.navigation.refresh_and_select(
|
|
|
|
|
RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp_id
|
|
|
|
|
)
|
2026-02-13 11:38:59 +01:00
|
|
|
except DomainError as e:
|
2026-02-10 09:53:49 +01:00
|
|
|
QMessageBox.warning(
|
|
|
|
|
self.view,
|
|
|
|
|
"Deletion forbidden",
|
2026-02-13 11:38:59 +01:00
|
|
|
str(e),
|
2026-02-10 09:53:49 +01:00
|
|
|
)
|
2026-02-24 15:40:24 +01:00
|
|
|
return
|
2026-02-13 11:38:59 +01:00
|
|
|
except RequiresConfirmation as e:
|
2026-02-10 09:53:49 +01:00
|
|
|
reply = QMessageBox.question(
|
|
|
|
|
self.view,
|
|
|
|
|
"Confirm deletion",
|
2026-02-13 11:38:59 +01:00
|
|
|
str(e),
|
2026-02-10 09:53:49 +01:00
|
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
|
|
|
)
|
|
|
|
|
if reply == QMessageBox.StandardButton.Yes:
|
2026-02-13 11:38:59 +01:00
|
|
|
e.action()
|
2026-02-24 15:40:24 +01:00
|
|
|
else:
|
|
|
|
|
return
|
|
|
|
|
self.is_dirty = True
|
|
|
|
|
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|