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