source: src/mlx/gui/callouts.py@ 1036:bc8c41f33fba

python3
Last change on this file since 1036:bc8c41f33fba was 999:e096a5638b87, checked in by István Váradi <ivaradi@…>, 5 years ago

Removed Gtk 2/3 constant definitions (re #347)

File size: 20.2 KB
Line 
1
2from .common import *
3
4from mlx.i18n import xstr
5import mlx.const as const
6import mlx.config as config
7
8import os
9import re
10
11#------------------------------------------------------------------------------
12
13## @package mlx.gui.callouts
14#
15# Editor dialog for approach callouts.
16#
17# The dialog consists of an aircraft type selector box at the top, and a table
18# with two buttons below it. The table contains the callout files with the
19# corresponding altitudes, and is sorted according to the altitude. When a new
20# file is added, the program finds out a new altitude for it. If the file's
21# name contains numbers that are not used as altitudes yet, the most suitable
22# of those numbers will be used. Otherwise a 'usual' altitude is searched for,
23# in the direction according to the sort order, and if that fails too, the
24# altitudes are tried one-by-one. See the
25# \ref ApproachCalloutsEditor._getNewAltitude function for more details.
26
27#------------------------------------------------------------------------------
28
29class ApproachCalloutsEditor(Gtk.Dialog):
30 """The dialog to edit the approach callouts."""
31 integerRE = re.compile("[0-9]+")
32
33 # A list of "usual" altitudes for callouts
34 _usualAltitudes = [10, 20, 30, 40, 50, 100, 200, 300, 400, 500, 1000,
35 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000]
36
37 @staticmethod
38 def _getNextUsualAltitude(altitude, descending):
39 """Get the next altitude coming after the given one in the
40 given direction."""
41 if descending:
42 previous = None
43 for alt in ApproachCalloutsEditor._usualAltitudes:
44 if alt>=altitude: return previous
45 previous = alt
46 else:
47 for alt in ApproachCalloutsEditor._usualAltitudes:
48 if alt>altitude: return alt
49
50 return None
51
52 def __init__(self, gui):
53 super(ApproachCalloutsEditor, self).__init__(WINDOW_TITLE_BASE + " - " +
54 xstr("callouts_title"),
55 gui.mainWindow,
56 Gtk.DialogFlags.MODAL)
57
58 self.add_button(xstr("button_cancel"), Gtk.ResponseType.REJECT)
59 self.add_button(xstr("button_ok"), Gtk.ResponseType.ACCEPT)
60
61 self._gui = gui
62 self._approachCallouts = {}
63 self._currentAircraftType = const.aircraftTypes[0]
64 self._fileOpenDialog = None
65
66 contentArea = self.get_content_area()
67
68 # FIXME: common code with the checklist editor
69 typeBox = Gtk.HBox()
70
71 label = Gtk.Label(xstr("callouts_aircraftType"))
72 label.set_use_underline(True)
73
74 typeBox.pack_start(label, False, False, 4)
75
76 self._aircraftTypeModel = Gtk.ListStore(str, int)
77 for type in const.aircraftTypes:
78 name = aircraftNames[type] if type in aircraftNames \
79 else "Aircraft type #%d" % (type,)
80 self._aircraftTypeModel.append([name, type])
81 self._aircraftType = Gtk.ComboBox(model = self._aircraftTypeModel)
82 renderer = Gtk.CellRendererText()
83 self._aircraftType.pack_start(renderer, True)
84 self._aircraftType.add_attribute(renderer, "text", 0)
85 self._aircraftType.set_tooltip_text(xstr("callouts_aircraftType_tooltip"))
86 self._aircraftType.set_active(0)
87 self._aircraftType.connect("changed", self._aircraftTypeChanged)
88 label.set_mnemonic_widget(self._aircraftType)
89
90 typeBox.pack_start(self._aircraftType, True, True, 4)
91
92 typeBoxAlignment = Gtk.Alignment(xalign = 0.5, yalign = 0.5,
93 xscale = 0.0, yscale = 0.0)
94 typeBoxAlignment.set_size_request(400, -1)
95 typeBoxAlignment.add(typeBox)
96
97 contentArea.pack_start(typeBoxAlignment, False, False, 12)
98 # FIXME: common code until here, but note that some texts are different
99
100 contentBox = Gtk.HBox()
101
102 controlBox = Gtk.VBox()
103 controlAlignment = Gtk.Alignment(xalign = 0.0, yalign = 0.0,
104 xscale = 0.0, yscale = 0.0)
105 controlAlignment.set_padding(padding_top = 0, padding_bottom = 0,
106 padding_left = 32, padding_right = 32)
107 controlAlignment.add(controlBox)
108 contentBox.pack_start(controlAlignment, False, False, 0)
109
110 self._addButton = Gtk.Button(xstr("callouts_add"))
111 self._addButton.set_use_underline(True)
112 self._addButton.set_tooltip_text(xstr("callouts_add_tooltip"))
113 self._addButton.connect("clicked", self._addButtonClicked)
114 addAlignment = Gtk.Alignment(xalign = 0.5, yalign = 0.0,
115 xscale = 0.0, yscale = 0.0)
116 addAlignment.set_padding(padding_top = 24, padding_bottom = 0,
117 padding_left = 0, padding_right = 0)
118 addAlignment.add(self._addButton)
119 controlBox.pack_start(addAlignment, False, False, 0)
120
121 self._removeButton = Gtk.Button(xstr("callouts_remove"))
122 self._removeButton.set_use_underline(True)
123 self._removeButton.set_tooltip_text(xstr("callouts_remove_tooltip"))
124 self._removeButton.set_sensitive(False)
125 self._removeButton.connect("clicked", self._removeButtonClicked)
126
127 removeAlignment = Gtk.Alignment(xalign = 0.5, yalign = 0.0,
128 xscale = 0.0, yscale = 0.0)
129 removeAlignment.set_padding(padding_top = 24, padding_bottom = 0,
130 padding_left = 0, padding_right = 0)
131 removeAlignment.add(self._removeButton)
132 controlBox.pack_start(removeAlignment, False, False, 0)
133
134 self._fileListModel = Gtk.ListStore(int, str, str)
135 self._fileListModel.set_sort_column_id(0, Gtk.SortType.DESCENDING)
136
137 self._addingFile = False
138 self._fileListModel.connect("row-inserted", self._fileAdded)
139 self._lastAddedAltitude = None
140
141 self._fileList = Gtk.TreeView(model = self._fileListModel)
142
143 renderer = Gtk.CellRendererSpin()
144 renderer.set_property("editable", True)
145
146 adjustment = Gtk.Adjustment(0, 0, 5000, 10, 100)
147 renderer.set_property("adjustment", adjustment);
148 renderer.connect("edited", self._altitudeEdited)
149
150 column = Gtk.TreeViewColumn(xstr("callouts_header_altitude"),
151 renderer, text = 0)
152 self._fileList.append_column(column)
153 column.set_expand(True)
154 column.set_clickable(True)
155 column.set_reorderable(False)
156 column.set_sort_indicator(True)
157 column.set_sort_column_id(0)
158 column.set_sort_order(Gtk.SortType.DESCENDING)
159 column.set_expand(False)
160
161 column = Gtk.TreeViewColumn(xstr("callouts_header_path"),
162 Gtk.CellRendererText(), text = 1)
163 self._fileList.append_column(column)
164 column.set_expand(True)
165 column.set_clickable(False)
166 column.set_reorderable(False)
167 column.set_expand(True)
168
169 self._fileList.set_tooltip_column(2)
170 self._fileList.set_size_request(300, -1)
171 self._fileList.set_reorderable(False)
172 self._fileList.connect("button-press-event",
173 self._fileListButtonPressed)
174 selection = self._fileList.get_selection()
175 selection.set_mode(Gtk.SelectionMode.MULTIPLE)
176 selection.connect("changed", self._fileListSelectionChanged)
177
178 self._buildFileListPopupMenu()
179
180 scrolledWindow = Gtk.ScrolledWindow()
181 scrolledWindow.add(self._fileList)
182 scrolledWindow.set_size_request(300, -1)
183 scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC,
184 Gtk.PolicyType.AUTOMATIC)
185 scrolledWindow.set_shadow_type(Gtk.ShadowType.IN)
186
187 fileListAlignment = Gtk.Alignment(xscale=1.0, yscale=1.0,
188 xalign=0.5, yalign=0.5)
189 fileListAlignment.set_padding(padding_top = 0, padding_bottom = 16,
190 padding_left = 0, padding_right = 8)
191 fileListAlignment.add(scrolledWindow)
192
193 contentBox.pack_start(fileListAlignment, False, False, 4)
194
195 contentArea.pack_start(contentBox, True, True, 4)
196
197 self.set_size_request(-1, 300)
198
199 def run(self):
200 """Run the approach callouts editor dialog."""
201 self._approachCallouts = {}
202 self._displayCurrentApproachCallouts()
203 self.show_all()
204 response = super(ApproachCalloutsEditor, self).run()
205 self.hide()
206
207 if response==Gtk.ResponseType.ACCEPT:
208 self._saveApproachCallouts()
209 config = self._gui.config
210 for (aircraftType, approachCallouts) in \
211 self._approachCallouts.items():
212 config.setApproachCallouts(aircraftType, approachCallouts)
213 config.save()
214
215 def _aircraftTypeChanged(self, comboBox):
216 """Called when the aircraft's type has changed."""
217 self._saveApproachCallouts()
218 self._displayCurrentApproachCallouts()
219
220 def _addButtonClicked(self, button):
221 """Called when the Add button is clicked."""
222 dialog = self._getFileOpenDialog()
223
224 dialog.show_all()
225 result = dialog.run()
226 dialog.hide()
227
228 if result==Gtk.ResponseType.OK:
229 filePath = dialog.get_filename()
230 baseName = os.path.basename(filePath)
231 altitude = self._getNewAltitude(baseName)
232 self._addingFile = True
233 self._lastAddedAltitude = altitude
234 self._fileListModel.append([altitude, baseName, filePath])
235 self._addingFile = False
236
237 def _fileAdded(self, model, path, iter):
238 """Called when a file is added to the list of callouts.
239
240 Makes the treeview to edit the altitude in the given row."""
241 if self._addingFile:
242 GObject.idle_add(self._selectFile)
243 self._fileList.grab_focus()
244 self.grab_focus()
245
246 def _selectFile(self):
247 """Select the file with the last added altitude."""
248 if self._lastAddedAltitude is None: return
249
250 model = self._fileListModel
251 iter = model.get_iter_first()
252 while iter is not None:
253 if model.get_value(iter, 0)==self._lastAddedAltitude: break
254 iter = model.iter_next(iter)
255 if iter is not None:
256 self._fileList.set_cursor(model.get_path(iter),
257 self._fileList.get_column(0), True)
258 self._lastAddedAltitude = None
259
260 def _removeButtonClicked(self, button):
261 """Called when the Remove button is clicked."""
262 self._removeSelected()
263
264 def _removeSelected(self):
265 """Remove the selected files."""
266 selection = self._fileList.get_selection()
267 (model, paths) = selection.get_selected_rows()
268
269 iters = [model.get_iter(path) for path in paths]
270
271 for i in iters:
272 if i is not None:
273 model.remove(i)
274
275 def _fileListSelectionChanged(self, selection):
276 """Called when the selection in the file list changes."""
277 anySelected = selection.count_selected_rows()>0
278 self._removeButton.set_sensitive(anySelected)
279
280 def _getAircraftType(self):
281 """Get the currently selected aircraft type."""
282 # FIXME: the same code as in the checklist editor
283 index = self._aircraftType.get_active()
284 return self._aircraftTypeModel[index][1]
285
286 def _altitudeEdited(self, widget, path, value):
287 """Called when an altitude is edited"""
288 newAltitude = int(value)
289
290 model = self._fileListModel
291 editedIter = model.get_iter_from_string(path)
292 editedPath = model.get_path(editedIter)
293 otherPath = self._hasAltitude(newAltitude, ignorePath = editedPath)
294 if otherPath is not None:
295 dialog = Gtk.MessageDialog(parent = self,
296 type = Gtk.MessageType.QUESTION,
297 message_format =
298 xstr("callouts_altitude_clash"))
299 dialog.format_secondary_markup(xstr("callouts_altitude_clash_sec"))
300 dialog.add_button(xstr("button_no"), Gtk.ResponseType.NO)
301 dialog.add_button(xstr("button_yes"), Gtk.ResponseType.YES)
302 dialog.set_title(WINDOW_TITLE_BASE)
303
304 result = dialog.run()
305 dialog.hide()
306
307 if result!=Gtk.ResponseType.YES:
308 newAltitude = None
309
310 if newAltitude is not None:
311 model[editedPath][0] = newAltitude
312 if otherPath is not None:
313 model.remove(model.get_iter(otherPath))
314
315 def _saveApproachCallouts(self):
316 """Save the currently displayed list of approach callouts for the
317 previously displayed aircraft type."""
318 mapping = {}
319 model = self._fileListModel
320 iter = model.get_iter_first()
321 while iter is not None:
322 altitude = int(model.get(iter, 0)[0])
323 path = model.get(iter, 2)[0]
324 mapping[altitude] = path
325 iter = model.iter_next(iter)
326
327 self._approachCallouts[self._currentAircraftType] = \
328 config.ApproachCallouts(mapping)
329
330 def _displayCurrentApproachCallouts(self):
331 """Display the approach callouts for the currently selected aircraft
332 type."""
333 aircraftType = self._getAircraftType()
334 self._currentAircraftType = aircraftType
335 if aircraftType not in self._approachCallouts:
336 self._approachCallouts[aircraftType] = \
337 self._gui.config.getApproachCallouts(aircraftType).clone()
338 approachCallouts = self._approachCallouts[aircraftType]
339
340 self._fileListModel.clear()
341 for (altitude, path) in approachCallouts:
342 self._fileListModel.append([altitude, os.path.basename(path), path])
343
344 def _getFileOpenDialog(self):
345 """Get the dialog to open a file.
346
347 If it does not exist yet, it will be created."""
348 if self._fileOpenDialog is None:
349 dialog = Gtk.FileChooserDialog(title = WINDOW_TITLE_BASE + " - " +
350 xstr("callouts_open_title"),
351 action = Gtk.FileChooserAction.OPEN,
352 buttons = (Gtk.STOCK_CANCEL,
353 Gtk.ResponseType.CANCEL,
354 Gtk.STOCK_OK, Gtk.ResponseType.OK),
355 parent = self)
356 dialog.set_modal(True)
357 dialog.set_do_overwrite_confirmation(True)
358
359 # FIXME: create the filters in one location and use them
360 # from there
361 filter = Gtk.FileFilter()
362 filter.set_name(xstr("file_filter_audio"))
363 filter.add_pattern("*.wav")
364 filter.add_pattern("*.mp3")
365 dialog.add_filter(filter)
366
367 filter = Gtk.FileFilter()
368 filter.set_name(xstr("file_filter_all"))
369 filter.add_pattern("*.*")
370 dialog.add_filter(filter)
371
372 self._fileOpenDialog = dialog
373
374 return self._fileOpenDialog
375
376 def _getNewAltitude(self, baseName):
377 """Get a new, unique altitude for the audio file with the given
378 base name.
379
380 First the given file name is searched for suitable
381 numbers. Otherwise the smallest altitude in the model is
382 considered, and, depending on the actual ordering of the
383 table, a suitable smaller or greater value is found. It is
384 ensured that the number is unique, unless all numbers are
385 taken.
386
387 If there is no entry in the table yet, 2500 is returned if the
388 table is sorted descending, 10 otherwise."""
389 altitude = self._getNewAltitudeFromFileName(baseName)
390 if altitude is not None: return altitude
391
392 descending = self._fileList.get_column(0).get_sort_order()==Gtk.SortType.DESCENDING
393 model = self._fileListModel
394 numEntries = model.iter_n_children(None)
395 if numEntries==0:
396 return 2500 if descending else 10
397 else:
398 selection = self._fileList.get_selection()
399 (_model, paths) = selection.get_selected_rows()
400
401 if paths:
402 startIter = model.get_iter(max(paths))
403 else:
404 startIter = model.iter_nth_child(None, numEntries-1)
405
406 startValue = model.get_value(startIter, 0)
407
408 altitude = self._getNextValidUsualAltitude(startValue, descending)
409 if altitude is None:
410 altitude = self._getNextValidUsualAltitude(startValue,
411 not descending)
412
413 if altitude is None:
414 for altitude in range(0 if descending else 4999,
415 4999 if descending else 0,
416 1 if descending else -1):
417 if not self._hasAltitude(altitude): break
418
419 return altitude
420
421 def _getNewAltitudeFromFileName(self, baseName):
422 """Get a new altitude value from the given file name.
423
424 The name is traversed for numbers. If a number is less than
425 5000 and there is no such altitude yet in the table, it is
426 checked if it is divisible by 100 or 1000, and if so, it gets
427 a score of 2. If it is divisible by 10, the score will be 1,
428 otherwise 0. The first highest scoring number is returned, if
429 there are any at all, otherwise None."""
430 candidateAltitude = None
431 candidateScore = None
432
433 (baseName, _) = os.path.splitext(baseName)
434 numbers = ApproachCalloutsEditor.integerRE.findall(baseName)
435 for number in numbers:
436 value = int(number)
437 if value<5000 and not self._hasAltitude(value):
438 score = 2 if (value%100)==0 or (value%1000)==0 \
439 else 1 if (value%10)==0 else 0
440 if candidateAltitude is None or score>candidateScore:
441 candidateAltitude = value
442 candidateScore = score
443
444 return candidateAltitude
445
446 def _hasAltitude(self, altitude, ignorePath = None):
447 """Determine if the model already contains the given altitude
448 or not.
449
450 ignorePath is a path in the model to ignore.
451
452 Returns the path of the element found, if any, or None, if the
453 altitude is not found."""
454 model = self._fileListModel
455 iter = model.get_iter_first()
456 while iter is not None:
457 path = model.get_path(iter)
458 if path!=ignorePath and altitude==model[path][0]:
459 return path
460 iter = model.iter_next(iter)
461
462 return None
463
464 def _getNextValidUsualAltitude(self, startValue, descending):
465 """Get the next valid usual altitude."""
466 value = startValue
467 while value is not None and self._hasAltitude(value):
468 value = \
469 ApproachCalloutsEditor._getNextUsualAltitude(value,
470 descending)
471
472 return value
473
474 def _fileListButtonPressed(self, widget, event):
475 """Called when a mouse button is pressed on the file list."""
476 if event.type!=Gdk.EventType.BUTTON_PRESS or event.button!=3:
477 return
478
479 menu = self._fileListPopupMenu
480 menu.popup(None, None, None, None, event.button, event.time)
481
482 def _buildFileListPopupMenu(self):
483 """Build the file list popup menu."""
484 menu = Gtk.Menu()
485
486 menuItem = Gtk.MenuItem()
487 menuItem.set_label(xstr("callouts_remove"))
488 menuItem.set_use_underline(True)
489 menuItem.connect("activate", self._popupRemove)
490 menuItem.show()
491 self._popupRemoveItem = menuItem
492
493 menu.append(menuItem)
494
495 self._fileListPopupMenu = menu
496
497 def _popupRemove(self, menuItem):
498 """Remove the currently selected menu items."""
499 self._removeSelected()
500
501#------------------------------------------------------------------------------
Note: See TracBrowser for help on using the repository browser.