-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontroller.py
More file actions
479 lines (406 loc) · 16.6 KB
/
Copy pathcontroller.py
File metadata and controls
479 lines (406 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
# campaign/controller.py
# Copyright (C) 2018 Alex Mair. All rights reserved.
# This file is part of dmclient.
#
# dmclient is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 2 of the License.
#
# dmclient is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with dmclient. If not, see <http://www.gnu.org/licenses/>.
#
import os
from logging import getLogger
from PyQt5.QtCore import QTimer, pyqtSlot, QPoint
from PyQt5.QtGui import QIcon, QStandardItem
from PyQt5.QtWidgets import QMenu
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from campaign import Player
from campaign.note import Note, InternalNote
from core import filters, archive
from core.archive import ArchiveMeta
from core.config import TMP_PATH
from core.controller import QtController
from model import GameBase, CampaignBase
from model.tree import FixedNode, TableNode, TreeModel, BadNode
from ui import get_open_filename, display_error, get_save_filename, \
display_warning
from ui.battlemap.controls import ControlScheme
from ui.battlemap.widgets import RegionalMapView
from ui.campaign import CampaignPropertiesDialog, CampaignWindow
from ui.note import NoteEditorDialog
from ui.search import SearchCompleter
log = getLogger(__name__)
class SearchController(QtController):
"""
.. todo::
Make this class more MVC-ish. It does too much. It'd be nice if it also
followed the general pattern of ``spawn_view()``.
"""
def __init__(self, delphi, completer, interval_msec=250):
super().__init__()
self._delphi = delphi
delphi.responder = self
self._update_delay = interval_msec
self.search_query = ""
self._timer = QTimer()
self._timer.setInterval(interval_msec)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.on_search_requested)
@property
def update_delay(self):
return self._update_delay
@update_delay.setter
def update_delay(self, delay):
self._update_delay = delay
self._timer.setInterval(delay)
@property
def results_visible(self):
return self.results_popup.visible()
@results_visible.setter
def results_visible(self, visible):
if visible:
self.update_results_popup()
self.results_popup.show()
else:
self.results_popup.hide()
def parent_moved(self):
self.update_results_popup()
def parent_resized(self):
self.update_results_popup()
def set_blurb_visible(self, visible=True):
if visible:
self.results_popup.indexing_blurb.show()
else:
self.results_popup.indexing_blurb.hide()
def update_results_popup(self):
geo = self.lineedit.geometry()
parent = self.lineedit.parentWidget()
geo.moveTopLeft(parent.mapToGlobal(geo.topLeft()))
geo.setY(geo.y() + geo.height())
self.results_popup.setGeometry(geo)
self.results_popup.show()
self.results_popup.raise_()
@pyqtSlot(str)
def on_search_text_changed(self, text):
log.debug("search text changed: {}".format(text))
self.search_query = text
if not text:
return
self._timer.start()
@pyqtSlot()
def on_search_requested(self):
if not self._delphi.enabled:
log.error("A search was requested but the oracle is not available.")
return
log.debug("a search was requested: `%s'", self.search_query)
self._timer.stop()
self._delphi.search_query(self.search_query)
@pyqtSlot()
def on_search_results_updated(self, results):
log.debug("SearchController received search results!")
model = self._build_results_model(results)
self.results_popup.setModel(model)
def set_search_results(self, results):
model = self.model
for section in results:
section_item = QStandardItem(section)
section_item.setEditable(False)
section_item.setSelectable(False)
for name, icon in results[section]:
item = QStandardItem(name)
item.setIcon(icon)
item.setEditable(False)
section_item.appendRow(item)
model.appendRow(section_item)
class NoteController(QtController):
def __init__(self, cc, parent=None):
self._cc = cc
view = cc.view
super().__init__(parent, view)
self.tree_node = TableNode(cc.db(), Note,
icon=QIcon(":/icons/books.png"),
text="Documents", delegate=self,
item_action=self.item_doubleclicked)
view.import_document.triggered.connect(self.on_import_document)
view.add_document.triggered.connect(self.on_add_document)
view.remove_document.triggered.connect(self.on_remove_document)
def item_doubleclicked(self, node):
# FIXME this is incrensely hacky.
db = self._cc.db()
try:
item = db.query(InternalNote).filter(Note.id == node.id)[0]
dlg = NoteEditorDialog(item, db, self.view)
dlg.raise_()
dlg.exec()
except IndexError:
# It's an external note.
pass
def context_menu(self):
window = self.view
return [window.import_document, window.add_document]
def item_context_menu(self):
return [self.view.remove_document]
@pyqtSlot()
def on_new_document(self):
base_note = Note(name="Untitled", author=self._cc.campaign.author)
internal_note = InternalNote(note_id=base_note.id,
text="New note...")
db = self._cc.db()
db.add(base_note)
db.add(internal_note)
db.commit()
self.tree_node.update()
@pyqtSlot()
def on_import_document(self):
path = get_open_filename(self.view, "Import text document", filters.txt)
if not path:
return
try:
with open(path) as f:
contents = f.read()
except OSError as e:
log.error("failed to import note: %s", e)
display_error(self.view, "The document could not be imported.",
"Unable to import document")
else:
base_note = Note(name=os.path.basename(path),
author=self._cc.campaign.author)
note = InternalNote(text=contents)
db = self._cc.db()
db.add(base_note)
db.add(note)
db.commit()
self.tree_node.update()
@pyqtSlot()
def on_add_document(self):
path = get_open_filename(self.view, "Add external document",
filters.document)
if not path:
return
try:
with open(path):
pass
# TODO: do this elsewhere.
if not path.startswith("file://"):
path = "file://" + path
note = Note(name=os.path.basename(path), author="", url=path)
db = self._cc.db()
db.add(note)
db.commit()
self.tree_node.update()
except OSError as e:
log.error("could not open note: %s", e)
@pyqtSlot()
def on_remove_document(self):
raise NotImplementedError
class MapController(QtController):
def __init__(self, cc, parent=None):
view = cc.view
super().__init__(parent, view)
self.campaign_window = view
self.tree_node = FixedNode(icon=QIcon(":/icons/maps.png"), text="Maps",
action=self.show_map)
self.toolbar = None
def show_map(self, node):
id = node.id
log.debug("show_map(%s)", id)
try:
map = self.campaign.regional_maps[id]
self.campaign_window.tab_controller.make_tab(map.id, map.name)
except KeyError as e:
log.error("cannot open map %s: %s", id, e)
def spawn_view(self):
# called by tab controller if need be
return RegionalMapView(map, ControlScheme(), self.campaign_window)
class PlayerController(QtController):
def __init__(self, cc, parent=None):
view = cc.view
super().__init__(parent, view)
self.tree_node = TableNode(cc.db(), Player, text="Players",
icon=QIcon(":/icons/party.png"))
class SessionController(QtController):
def __init__(self, cc, parent=None):
view = cc.view
super().__init__(parent, view)
icon = QIcon(":/icons/sessions.png")
self.tree_node = BadNode(text="Campaign sessions")
# self.tree_node = TableNode(CampaignSession,
# cc.db(), icon=icon, text="Sessions")
def show_session(self):
pass
class CampaignController(QtController):
"""
A ``CampaignController`` is the controller for a campaign during the
lifetime of an application. To keep this class small, it is mostly
responsible for managing the highest level logic on a campaign and
delegating operations to sub-controllers.
``CampaignController`` instances operate on already existing campaigns. The
creation of, for example, the working directory for the campaign is expected
to be handled externally, e.g. by the ``AppController`` or test harnesses.
"""
def __init__(self, delphi, campaign, archive_meta=None):
super().__init__(None)
self.delphi = delphi
self.campaign = campaign
self.archive_meta = archive_meta
self.dirty = True
campaign_db_path = self.database_path(campaign)
self._engine = create_engine("sqlite:///{}".format(campaign_db_path), echo=True)
self._Session = sessionmaker(bind=self._engine)
GameBase.metadata.create_all(self._engine)
CampaignBase.metadata.create_all(self._engine)
self.view = CampaignWindow(self.campaign)
self.map_controller = None
self.player_controller = None
self.session_controller = None
self.search_controller = None
self._init_subcontrollers()
asset_tree = self.build_asset_tree()
self.asset_tree_model = TreeModel(asset_tree)
self._init_view()
def db(self):
"""
:return: A session to the database.
"""
self._Session.configure(bind=self._engine)
return self._Session()
@staticmethod
def working_directory(campaign):
"""
Return an absolute path to the working directory for the campaign,
usually ``TEMP_DIR/{campaign.id}/``.
"""
return os.path.join(TMP_PATH, str(campaign.id))
@staticmethod
def extracted_archive_path(campaign):
return os.path.join(CampaignController.working_directory(campaign),
"archive")
@staticmethod
def database_path(campaign):
return os.path.join(CampaignController.extracted_archive_path(campaign),
"campaign.db")
@staticmethod
def build_context_menu(listing, name):
context_menu = QMenu(name)
for action in listing:
context_menu.addAction(action)
return context_menu
@staticmethod
def xapian_database_path(campaign):
return os.path.join(CampaignController.working_directory(campaign),
"oracle")
def _init_subcontrollers(self):
self.map_controller = MapController(self)
self.player_controller = PlayerController(self)
self.note_controller = NoteController(self)
self.session_controller = SessionController(self)
self.search_controller = SearchController(self.delphi,
SearchCompleter())
def _init_view(self):
sc = self.search_controller
v = self.view
v.save_campaign.triggered.connect(self.on_sync_campaign)
v.save_campaign_as.triggered.connect(self.on_save_campaign_as)
v.searchEdit.textChanged.connect(sc.on_search_text_changed)
v.searchEdit.returnPressed.connect(sc.on_search_requested)
v.assetTree.setModel(self.asset_tree_model)
v.assetTree.clicked.connect(lambda idx: log.debug("idx = %s, %s", idx,
self.asset_tree_model.rowCount(idx)))
v.assetTree.doubleClicked.connect(self.asset_tree_doubleclick)
v.assetTree.customContextMenuRequested.connect(self.asset_tree_context_menu_requested)
v.campaign_properties.triggered.connect(self.on_campaign_properties)
def build_asset_tree(self):
root = FixedNode(*[controller.tree_node for controller in
[self.map_controller, self.session_controller,
self.player_controller, self.note_controller]])
return root
def window_moved(self):
"""Called when the main campaign window is moved."""
self.search_controller.update_results_popup()
def asset_tree_doubleclick(self, index):
node = index.internalPointer()
if node.action:
node.action(node)
elif node.parent and node.parent.item_action:
node.parent.item_action(node)
@pyqtSlot(QPoint)
def asset_tree_context_menu_requested(self, point):
index = self.view.assetTree.indexAt(point)
node = index.internalPointer()
if node is None:
return
controller = node.delegate
if not controller:
log.debug("There is no controller on this node.")
return
context_menu = CampaignController.build_context_menu(
controller.context_menu(),
"Asset tree context menu")
context_menu.exec(controller.view.mapToGlobal(point))
@pyqtSlot()
def on_sync_campaign(self):
log.debug("requested to sync campaign")
am = self.archive_meta
if not am:
self._save_campaign_as()
return
path = am.last_seen_path
if am.last_seen_path and not os.path.exists(am.last_seen_path):
# TODO why do we do this? I forget the original use case.
display_warning(self.view, "The campaign archive appears to have "
"been moved or deleted.\n\nPlease "
"select a new location to save this "
"archive.")
path = self._save_campaign_as()
if not path:
return
self._sync_archive_meta(path)
archive.export(self.archive_meta,
CampaignController.extracted_archive_path(self.campaign),
am.last_seen_path)
@pyqtSlot()
def on_save_campaign_as(self):
self._save_campaign_as()
@pyqtSlot()
def on_campaign_properties(self):
dlg = CampaignPropertiesDialog(self.campaign, self.view)
dlg.accepted.connect(lambda: self.on_properties_update(dlg))
dlg.show()
@pyqtSlot()
def on_properties_update(self, propdlg):
options = propdlg.options
for k, v in options.items():
setattr(self.campaign, k, v)
self.view.setWindowTitle(self.campaign.name)
self.properties_dialog = None
def _save_campaign_as(self):
path = get_save_filename(self.view, "Save campaign as",
filter_=filters.campaign)
if not path:
return
try:
self._sync_archive_meta(path)
archive.export(self.archive_meta,
CampaignController.extracted_archive_path(self.campaign),
path)
except OSError as e:
log.error("could not export campaign: %s", e)
else:
return path
def _sync_archive_meta(self, path):
campaign = self.campaign
self.archive_meta = ArchiveMeta(campaign.id,
campaign.game_system.id,
campaign.name,
campaign.description,
campaign.author,
campaign.creation_date,
campaign.revision_date,
last_seen_path=path)