Add Glk unit test framework
[projects/chimara/chimara.git] / player / player.py
1 #!/usr/bin/env python
2
3 import sys
4 import os.path
5 import argparse
6 from gi.repository import Gdk
7 Gdk.threads_init()
8 from gi.repository import GObject, GLib, Gdk, Gio, Gtk, Chimara
9 import config
10
11 if config.ENABLE_NLS:
12     import gettext
13     gettext.install(config.GETTEXT_PACKAGE, config.PACKAGE_LOCALE_DIR,
14         unicode=True, codeset='UTF-8')
15 else:
16     _ = lambda x: x
17
18
19 class Player(GObject.GObject):
20     __gtype_name__ = 'ChimaraPlayer'
21
22     def __init__(self, graphics_file=None):
23         super(Player, self).__init__()
24
25         # Initialize settings file; it can be overridden by a "chimara-config"
26         # file in the current directory
27         if os.path.exists('chimara-config'):
28             keyfile = 'chimara-config'
29         else:
30             keyfile = os.path.expanduser('~/.chimara/config')
31         try:
32             # This only works on my custom-built gobject-introspection; opened
33             # bug #682702
34             backend = Gio.keyfile_settings_backend_new(keyfile,
35                 "/org/chimara-if/player/", None)
36         except AttributeError:
37             backend = None
38         self.prefs_settings = Gio.Settings('org.chimara-if.player.preferences',
39             backend=backend)
40         self.state_settings = Gio.Settings('org.chimara-if.player.state',
41             backend=backend)
42
43         builder = Gtk.Builder()
44         try:
45             builder.add_from_file(os.path.join(config.PACKAGE_DATA_DIR,
46                 'chimara.ui'))
47         except GLib.GError:
48             if config.DEBUG:
49                 builder.add_from_file(os.path.join(config.PACKAGE_SRC_DIR,
50                     'chimara.ui'))
51             else:
52                 raise
53         self.window = builder.get_object('chimara')
54         self.aboutwindow = builder.get_object('aboutwindow')
55         self.prefswindow = builder.get_object('prefswindow')
56         actiongroup = builder.get_object('actiongroup')
57
58         # Set the default value of the "View/Toolbar" menu item upon creation
59         # of a new window to the "show-toolbar-default" setting, but bind the
60         # setting one-way only - we don't want toolbars to disappear suddenly
61         toolbar_action = builder.get_object('toolbar')
62         toolbar_action.active = \
63             self.state_settings.get_boolean('show-toolbar-default')
64         self.state_settings.bind('show-toolbar-default', toolbar_action,
65             'active', Gio.SettingsBindFlags.SET)
66
67         filt = Gtk.RecentFilter()
68         # TODO: Use mimetypes and construct the filter dynamically depending on
69         # what plugins are installed
70         for pattern in ['*.z[1-8]', '*.[zg]lb', '*.[zg]blorb', '*.ulx', '*.blb',
71             '*.blorb']:
72             filt.add_pattern(pattern)
73         recent = builder.get_object('recent')
74         recent.add_filter(filt)
75
76         uimanager = Gtk.UIManager()
77         try:
78             uimanager.add_ui_from_file(os.path.join(config.PACKAGE_DATA_DIR,
79                 'chimara.menus'))
80         except GLib.GError:
81             if config.DEBUG:
82                 uimanager.add_ui_from_file(os.path.join(config.PACKAGE_SRC_DIR,
83                     'chimara.menus'))
84             else:
85                 raise
86         uimanager.insert_action_group(actiongroup)
87         menubar = uimanager.get_widget('/menubar')
88         toolbar = uimanager.get_widget('/toolbar')
89         toolbar.no_show_all = True
90         if toolbar_action.active:
91             toolbar.show()
92         else:
93             toolbar.hide()
94
95         # Connect the accelerators
96         accels = uimanager.get_accel_group()
97         self.window.add_accel_group(accels)
98
99         self.glk = Chimara.IF(ignore_errors=True,
100             # interpreter_number=Chimara.IFZmachineVersion.TANDY_COLOR,
101             graphics_file=graphics_file)
102         css_file = _maybe(self.prefs_settings.get_value('css-file'))
103         if css_file is None:
104             css_file = 'style.css'
105         self.glk.set_css_from_file(css_file)
106
107         # DON'T UNCOMMENT THIS your eyes will burn
108         # but it is a good test of programmatically altering just one style
109         # self.glk.set_css_from_string("buffer{font-family: 'Comic Sans MS';}")
110
111         vbox = builder.get_object('vbox')
112         vbox.pack_end(self.glk, True, True, 0)
113         vbox.pack_start(menubar, False, False, 0)
114         vbox.pack_start(toolbar, False, False, 0)
115
116         builder.connect_signals(self)
117         self.glk.connect('notify::program-name', self.change_window_title)
118         self.glk.connect('notify::story-name', self.change_window_title)
119
120         # Create preferences window
121         # TODO
122
123     def change_window_title(self, glk, pspec, data=None):
124         if glk.props.program_name is None:
125             title = "Chimara"
126         elif glk.props.story_name is None:
127             title = "{interp} - Chimara".format(interp=glk.props.program_name)
128         else:
129             title = "{interp} - {story} - Chimara".format(
130                 interp=glk.props.program_name,
131                 story=glk.props.story_name)
132         self.window.props.title = title
133
134     def on_open_activate(self, action, data=None):
135         if not self.confirm_open_new_game():
136             return
137
138         dialog = Gtk.FileChooserDialog(_('Open Game'), self.window,
139             Gtk.FileChooserAction.OPEN,
140             (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
141             Gtk.STOCK_OPEN, Gtk.ResponseType.ACCEPT))
142
143         # Get last opened path
144         path = _maybe(self.state_settings.get_value('last-open-path'))
145         if path is not None:
146             dialog.set_current_folder(path)
147
148         response = dialog.run()
149         dialog.hide()
150         if response != Gtk.ResponseType.ACCEPT:
151             return
152
153         gamefile = dialog.get_file()
154         self.search_for_graphics_file(gamefile.get_path())
155         try:
156             self.glk.run_game_file(gamefile)
157         except GLib.Error as e:
158             error_dialog(self.window, _('Could not open game file {filename}: {errmsg}').format(filename=gamefile.get_path(), errmsg=e.message))
159             return
160
161         path = dialog.get_current_folder()
162         if path is not None:
163             self.state_settings.last_open_path = path
164
165         # Add file to recent files list
166         manager = Gtk.RecentManager.get_default()
167         uri = gamefile.get_uri()
168         manager.add_item(uri)
169
170         dialog.destroy()
171
172     def on_recent_item_activated(self, chooser, data=None):
173         if not self.confirm_open_new_game():
174             return
175
176         uri = chooser.get_current_uri()
177         gamefile = Gio.file_new_for_uri(uri)
178
179         self.search_for_graphics_file(gamefile.get_path())
180         try:
181             self.glk.run_game_file(gamefile)
182         except GLib.Error as e:
183             error_dialog(self.window,
184                 _('Could not open game file {filename}: {errmsg}').format(
185                     filename=gamefile.get_basename(),
186                     errmsg=e.message))
187             return
188
189         # Add file to recent files list again, this updates it to most recently
190         # used
191         manager = Gtk.RecentManager.get_default()
192         manager.add_item(uri)
193
194     def on_stop_activate(self, *args):
195         self.glk.stop()
196
197     def on_quit_chimara_activate(self, *args):
198         Gtk.main_quit()
199
200     def on_copy_activate(self, *args):
201         focus = self.window.get_focus()
202         # Call "copy clipboard" on any widget that defines it
203         if (isinstance(focus, Gtk.Label)
204             or isinstance(focus, Gtk.Entry)
205             or isinstance(focus, Gtk.TextView)):
206             focus.emit('copy-clipboard')
207
208     def on_paste_activate(self, *args):
209         focus = self.window.get_focus()
210         # Call "paste clipboard" on any widget that defines it
211         if isinstance(focus, Gtk.Entry) or isinstance(focus, Gtk.TextView):
212             focus.emit('paste-clipboard')
213
214     def on_preferences_activate(self, *args):
215         self.prefswindow.present()
216
217     def on_toolbar_toggled(self, action, *args):
218         if action.get_active():
219             self.toolbar.show()
220         else:
221             self.toolbar.hide()
222
223     def on_undo_activate(self, *args):
224         self.glk.feed_line_input('undo')
225
226     def on_save_activate(self, *args):
227         self.glk.feed_line_input('save')
228
229     def on_restore_activate(self, *args):
230         self.glk.feed_line_input('restore')
231
232     def on_restart_activate(self, *args):
233         self.glk.feed_line_input('restart')
234
235     def on_quit_activate(self, *args):
236         self.glk.feed_line_input('quit')
237
238     def on_about_activate(self, *args):
239         self.aboutwindow.set_version(config.PACKAGE_VERSION)
240         self.aboutwindow.present()
241
242     def on_window_delete_event(self, *args):
243         Gtk.main_quit()
244         return True
245
246     def confirm_open_new_game(self):
247         """
248         If a game is running in the Glk widget, warn the user that they will
249         quit the currently running game if they open a new one. Returns True if
250         no game was running. Returns False if the user cancelled. Returns True
251         and shuts down the running game if the user wishes to continue.
252         """
253         if not self.glk.props.running:
254             return True
255
256         dialog = Gtk.MessageDialog(self.window,
257             Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
258             Gtk.MessageType.WARNING, Gtk.ButtonsType.CANCEL,
259             _("Are you sure you want to open a new game?"))
260         dialog.format_secondary_text(
261             _("If you open a new game, you will quit the one you are "
262             "currently playing."))
263         dialog.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
264         response = dialog.run()
265         dialog.hide()
266
267         if response != Gtk.ResponseType.OK:
268             return False
269
270         self.glk.stop()
271         self.glk.wait()
272         return True
273
274     def search_for_graphics_file(self, filename):
275         """Internal function: See if there is a corresponding graphics file"""
276
277         # First get the name of the story file
278         base = os.path.basename(filename)
279         base_noext = os.path.splitext(base)[0]
280
281         # Check in the stored resource path, if set
282         resource_path = _maybe(self.prefs_settings.get_value('resource-path'))
283
284         # Otherwise check in the current directory
285         if resource_path is None:
286             resource_path = os.path.dirname(filename)
287
288         blorbfile = os.path.join(resource_path, base_noext + '.blb')
289         if os.path.exists(blorbfile):
290             self.glk.graphics_file = blorbfile
291
292     # Various signal handlers for GtkBuilder file
293     def gtk_widget_hide(self, widget, *args):
294         return Gtk.Widget.hide(widget)
295
296     def gtk_widget_hide_on_delete(self, widget, *args):
297         return Gtk.Widget.hide_on_delete(widget)
298
299     def dummy_handler(self, *args):
300         pass
301
302     on_resource_file_set = dummy_handler
303     on_interpreter_cell_changed = dummy_handler
304     on_toggle_underline = dummy_handler
305     on_toggle_italic = dummy_handler
306     on_toggle_bold = dummy_handler
307     on_toggle_justify = dummy_handler
308     on_toggle_right = dummy_handler
309     on_toggle_center = dummy_handler
310     on_toggle_left = dummy_handler
311     on_background_color_set = dummy_handler
312     on_foreground_color_set = dummy_handler
313     on_font_set = dummy_handler
314     on_css_filechooser_file_set = dummy_handler
315
316
317 def _maybe(variant):
318     """Gets a maybe value from a GVariant - not handled in PyGI"""
319     v = variant.get_maybe()
320     if v is None:
321         return None
322     return v.unpack()
323
324
325 def error_dialog(parent, message):
326     dialog = Gtk.MessageDialog(parent, Gtk.DialogFlags.DESTROY_WITH_PARENT,
327         Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, message)
328     dialog.run()
329     dialog.destroy()
330
331 if __name__ == '__main__':
332     parser = argparse.ArgumentParser()
333     parser.add_argument('game_file', nargs='?', default=None,
334         metavar='GAME FILE', help='the game file to load and start')
335     parser.add_argument('graphics_file', nargs='?', default=None,
336         metavar='GRAPHICS FILE', help='a Blorb resource file to include')
337     args = parser.parse_args()
338
339     # Create configuration dir ~/.chimara
340     try:
341         os.mkdir(os.path.expanduser('~/.chimara'))
342     except OSError:
343         # already exists
344         assert os.path.isdir(os.path.expanduser('~/.chimara'))
345
346     player = Player(graphics_file=args.graphics_file)
347     player.window.show_all()
348
349     if args.game_file is not None:
350         try:
351             player.glk.run_game(args.game_file)
352         except GLib.Error as e:
353             error_dialog(player.window,
354                 _("Error starting Glk library: {errmsg}").format(
355                     errmsg=e.message))
356             sys.exit(1)
357
358     Gdk.threads_enter()
359     Gtk.main()
360     Gdk.threads_leave()
361
362     player.glk.stop()
363     player.glk.wait()
364
365     sys.exit(0)