1//
2//  ZoomGlkWindowController.m
3//  ZoomCocoa
4//
5//  Created by Andrew Hunter on 24/11/2005.
6//  Copyright 2005 Andrew Hunter. All rights reserved.
7//
8
9#import "ZoomGlkWindowController.h"
10#import "ZoomPreferences.h"
11#import "ZoomTextToSpeech.h"
12#import "ZoomSkeinController.h"
13#import "ZoomSkein.h"
14#import "ZoomGlkDocument.h"
15#import "ZoomGameInfoController.h"
16#import "ZoomNotesController.h"
17#import "ZoomWindowThatCanBecomeKey.h"
18#import "ZoomGlkSaveRef.h"
19#import "ZoomAppDelegate.h"
20#import "ZoomClearView.h"
21
22#import <GlkView/GlkHub.h>
23#import <GlkView/GlkView.h>
24#import <GlkView/GlkSessionProtocol.h>
25#import <GlkView/GlkFileRef.h>
26
27///
28/// Class to interface to Zoom's skein system
29///
30@interface ZoomGlkSkeinOutputReceiver : NSObject<GlkAutomation>{
31	ZoomSkein* skein;
32}
33
34- (id) initWithSkein: (ZoomSkein*) skein;
35
36@end
37
38@implementation ZoomGlkSkeinOutputReceiver
39
40- (id) initWithSkein: (ZoomSkein*) newSkein {
41	self = [super init];
42
43	if (self) {
44		skein = [newSkein retain];
45	}
46
47	return self;
48}
49
50- (void) dealloc {
51	[skein release];
52	[super dealloc];
53}
54
55- (IBAction) glkTaskHasStarted: (id) sender {
56	[skein zoomInterpreterRestart];
57}
58
59- (void) setGlkInputSource: (id) newSource {
60}
61
62- (void) receivedCharacters: (NSString*) characters
63					 window: (int) windowNumber
64				   fromView: (GlkView*) view {
65	[skein outputText: characters];
66}
67
68- (void) userTyped: (NSString*) userInput
69			window: (int) windowNumber
70		 lineInput: (BOOL) isLineInput
71		  fromView: (GlkView*) view {
72	[skein zoomWaitingForInput];
73	if (isLineInput) {
74		[skein inputCommand: userInput];
75	} else {
76		[skein inputCharacter: userInput];
77	}
78}
79
80- (void) userClickedAtXPos: (int) xpos
81					  ypos: (int) ypos
82					window: (int) windowNumber
83				  fromView: (GlkView*) view {
84}
85
86- (void) viewWaiting: (GlkView*) view {
87	// Do nothing
88}
89
90- (void) viewIsWaitingForInput: (GlkView*) view {
91}
92
93@end
94
95///
96/// The window controller proper
97///
98@interface ZoomGlkWindowController(ZoomPrivate)
99
100- (void) prefsChanged: (NSNotification*) not;
101
102@end
103
104@implementation ZoomGlkWindowController
105
106+ (void) initialize {
107	// Set up the Glk hub
108	[[GlkHub sharedGlkHub] useProcessHubName];
109	[[GlkHub sharedGlkHub] setRandomHubCookie];
110}
111
112// = Preferences =
113
114+ (GlkPreferences*) glkPreferencesFromZoomPreferences {
115	GlkPreferences* prefs = [[GlkPreferences alloc] init];
116	ZoomPreferences* zPrefs = [ZoomPreferences globalPreferences];
117
118	// Set the fonts according to the Zoom preferences object
119	[prefs setProportionalFont: [[zPrefs fonts] objectAtIndex: 0]];
120	[prefs setFixedFont: [[zPrefs fonts] objectAtIndex: 4]];
121
122	// Set the typography options according to the Zoom preferences object
123	[prefs setTextMargin: [zPrefs textMargin]];
124	[prefs setUseScreenFonts: [zPrefs useScreenFonts]];
125	[prefs setUseHyphenation: [zPrefs useHyphenation]];
126	[prefs setUseKerning: [zPrefs useKerning]];
127	[prefs setUseLigatures: [zPrefs useLigatures]];
128
129	[prefs setScrollbackLength: [zPrefs scrollbackLength]];
130
131	// Set the foreground/background colours
132	NSColor* foreground = [[zPrefs colours] objectAtIndex: [zPrefs foregroundColour]];
133	NSColor* background = [[zPrefs colours] objectAtIndex: [zPrefs backgroundColour]];
134
135	NSEnumerator* styleEnum = [[prefs styles] keyEnumerator];
136	NSMutableDictionary* newStyles = [NSMutableDictionary dictionary];
137	NSNumber* styleNum;
138
139	while (styleNum = [styleEnum nextObject]) {
140		GlkStyle* thisStyle = [[prefs styles] objectForKey: styleNum];
141
142		[thisStyle setTextColour: foreground];
143		[thisStyle setBackColour: background];
144
145		[newStyles setObject: thisStyle
146					  forKey: styleNum];
147	}
148
149	[prefs setStyles: newStyles];
150
151	return [prefs autorelease];
152}
153
154// = Initialisation =
155
156- (id) init {
157	self = [super initWithWindowNibPath: [[NSBundle bundleForClass: [ZoomGlkWindowController class]] pathForResource: @"GlkWindow"
158																											  ofType: @"nib"]
159								  owner: self];
160
161	if (self) {
162		[[NSNotificationCenter defaultCenter] addObserver: self
163												selector: @selector(prefsChanged:)
164													name: ZoomPreferencesHaveChangedNotification
165												  object: nil];
166
167		skein = [[ZoomSkein alloc] init];
168	}
169
170	return self;
171}
172
173- (void) dealloc {
174	[[NSNotificationCenter defaultCenter] removeObserver: self];
175
176	[clientPath release];
177	[inputPath release];
178	[savedGamePath release];
179	[logo release];
180	[tts release];
181	[skein release];
182	[normalWindow release];
183	[fullscreenWindow release];
184
185	if (glkView) [glkView setDelegate: nil];
186
187	[super dealloc];
188}
189
190- (void) maybeStartView {
191	// If we're sufficiently configured to start the application, then do so
192	if (glkView && clientPath && inputPath) {
193		[tts release];
194		tts = [[ZoomTextToSpeech alloc] init];
195		[tts setSkein: skein];
196
197		[glkView setDelegate: self];
198		[glkView addOutputReceiver: [[[ZoomGlkSkeinOutputReceiver alloc] initWithSkein: skein] autorelease]];
199		[glkView setPreferences: [ZoomGlkWindowController glkPreferencesFromZoomPreferences]];
200		[glkView setInputFilename: inputPath];
201
202		if (savedGamePath) {
203			if (canOpenSaveGames) {
204				NSString* saveSkeinPath = [savedGamePath stringByAppendingPathComponent: @"Skein.skein"];
205				NSString* saveDataPath = [savedGamePath stringByAppendingPathComponent: @"Save.data"];
206
207				if ([[NSFileManager defaultManager] fileExistsAtPath: saveDataPath]) {
208					[glkView addInputFilename: saveDataPath
209									  withKey: @"savegame"];
210
211					if ([[NSFileManager defaultManager] fileExistsAtPath: saveSkeinPath]) {
212						[skein parseXmlData: [NSData dataWithContentsOfFile: saveSkeinPath]];
213					}
214				}
215			}
216		}
217
218		[glkView launchClientApplication: clientPath
219						   withArguments: [NSArray array]];
220
221		[self prefsChanged: nil];
222	}
223}
224
225- (IBAction)showWindow:(id)sender {
226	[super showWindow: sender];
227
228	if (savedGamePath && !canOpenSaveGames && !shownSaveGameWarning) {
229		shownSaveGameWarning = YES;
230		NSBeginAlertSheet(@"This interpreter is unable to load saved states",
231						  @"Continue", nil, nil,
232						  [self window], nil, nil, nil, nil,
233						  @"Due to a limitation in the design of the interpreter for this story, Zoom is unable to request that it load a saved state file.\n\nYou will need to use the story's own restore function to request that it load the state that you selected.");
234	}
235}
236
237- (void) windowDidLoad {
238	// Configure the view
239	[glkView setRandomViewCookie];
240	[logDrawer setLeadingOffset: 16];
241	[logDrawer setContentSize: NSMakeSize([logDrawer contentSize].width, 120)];
242	[logDrawer setMinContentSize: NSMakeSize(0, 120)];
243
244	// Set the default log message
245	[logText setString: [NSString stringWithFormat: @"Zoom CocoaGlk Plugin\n"]];
246
247	// Set up the window borders
248	if (![[ZoomPreferences globalPreferences] showGlkBorders])
249		[glkView setBorderWidth: 0];
250	else
251		[glkView setBorderWidth: 2];
252
253	// Start it if we've got enough information
254	[self maybeStartView];
255}
256
257- (void) prefsChanged: (NSNotification*) not {
258	// TODO: actually change the preferences (might need some changes to the way Glk styles work here; styles are traditionally fixed after they are set...)
259	if (glkView == nil) return;
260
261	if (!ttsAdded) [glkView addOutputReceiver: tts];
262	ttsAdded = YES;
263	[tts setImmediate: [[ZoomPreferences globalPreferences] speakGameText]];
264
265	// Set up the window borders
266	if (![[ZoomPreferences globalPreferences] showGlkBorders])
267		[glkView setBorderWidth: 0];
268	else
269		[glkView setBorderWidth: 2];
270}
271
272// = Configuring the client =
273
274- (void) setClientPath: (NSString*) newPath {
275	// Set the client path
276	[clientPath release];
277	clientPath = nil;
278	clientPath = [newPath copy];
279
280	// Start it if we've got enough information
281	[self maybeStartView];
282}
283
284- (void) setSaveGame: (NSString*) path {
285	// Set the saved game path
286	[savedGamePath release];
287	savedGamePath = [path copy];
288}
289
290- (void) setCanOpenSaveGame: (BOOL) newCanOpenSaveGame {
291	canOpenSaveGames = newCanOpenSaveGame;
292}
293
294- (void) setInputFilename: (NSString*) newPath {
295	// Set the input path
296	[inputPath release];
297	inputPath = nil;
298	inputPath = [newPath copy];
299
300	// Start it if we've got enough information
301	[self maybeStartView];
302}
303
304- (void) setLogo: (NSImage*) newLogo {
305	[logo release];
306	logo = [newLogo copy];
307}
308
309- (BOOL) disableLogo {
310	return logo == nil || ![[ZoomPreferences globalPreferences] showCoverPicture];
311}
312
313- (NSImage*) logo {
314	return logo;
315}
316
317- (NSString*) preferredSaveDirectory {
318	if (!canOpenSaveGames && savedGamePath) {
319		// If the user has requested a particular save game and the interpreter doesn't know how to load it, then open the directory containing the game that they wanted
320		return [savedGamePath stringByDeletingLastPathComponent];
321	} else {
322		// Otherwise use whatever the document thinks should be used
323		return [[self document] preferredSaveDirectory];
324	}
325}
326
327// = Log messages =
328
329- (void) showLogMessage: (NSString*) message
330			 withStatus: (GlkLogStatus) status {
331	// Choose a style for this message
332	float msgSize = 10;
333	NSColor* msgColour = [NSColor grayColor];
334	BOOL isBold = NO;
335
336	switch (status) {
337		case GlkLogRoutine:
338			break;
339
340		case GlkLogInformation:
341			isBold = YES;
342			break;
343
344		case GlkLogCustom:
345			msgSize = 12;
346			msgColour = [NSColor blackColor];
347			break;
348
349		case GlkLogWarning:
350			msgColour = [NSColor blueColor];
351			msgSize = 12;
352			break;
353
354		case GlkLogError:
355			msgSize = 12;
356			msgColour = [NSColor redColor];
357			isBold = YES;
358			break;
359
360		case GlkLogFatalError:
361			msgSize = 12;
362			msgColour = [NSColor colorWithDeviceRed: 0.8
363											  green: 0
364											   blue: 0
365											  alpha: 1.0];
366			isBold = YES;
367			break;
368	}
369
370	// Create the attributes for this style
371	NSFont* font;
372
373	if (isBold) {
374		font = [NSFont boldSystemFontOfSize: msgSize];
375	} else {
376		font = [NSFont systemFontOfSize: msgSize];
377	}
378
379	NSDictionary* msgAttributes = [NSDictionary dictionaryWithObjectsAndKeys:
380		font, NSFontAttributeName,
381		msgColour, NSForegroundColorAttributeName,
382		nil];
383
384	// Create the attributed string
385	NSAttributedString* newMsg = [[NSAttributedString alloc] initWithString: [message stringByAppendingString: @"\n"]
386																 attributes: msgAttributes];
387
388	// Append this message to the log
389	[[logText textStorage] appendAttributedString: [newMsg autorelease]];
390
391	// Show the log drawer
392	if (status >= GlkLogWarning && (status >= GlkLogFatalError || [[ZoomPreferences globalPreferences] displayWarnings])) {
393		[logDrawer open: self];
394	}
395}
396
397- (void) showLog: (id) sender {
398	[logDrawer open: self];
399}
400
401- (void) windowWillClose: (NSNotification*) not {
402	[glkView terminateClient];
403}
404
405// = The game info window =
406
407- (IBAction) recordGameInfo: (id) sender {
408	ZoomGameInfoController* sgI = [ZoomGameInfoController sharedGameInfoController];
409	ZoomStory* storyInfo = [(ZoomGlkDocument*)[self document] storyData];
410
411	if ([sgI gameInfo] == storyInfo) {
412		NSDictionary* sgIValues = [sgI dictionary];
413
414		[storyInfo setTitle: [sgIValues objectForKey: @"title"]];
415		[storyInfo setHeadline: [sgIValues objectForKey: @"headline"]];
416		[storyInfo setAuthor: [sgIValues objectForKey: @"author"]];
417		[storyInfo setGenre: [sgIValues objectForKey: @"genre"]];
418		[storyInfo setYear: [[sgIValues objectForKey: @"year"] intValue]];
419		[storyInfo setGroup: [sgIValues objectForKey: @"group"]];
420		[storyInfo setComment: [sgIValues objectForKey: @"comments"]];
421		[storyInfo setTeaser: [sgIValues objectForKey: @"teaser"]];
422		[storyInfo setZarfian: [[sgIValues objectForKey: @"zarfRating"] unsignedIntValue]];
423		[storyInfo setRating: [[sgIValues objectForKey: @"rating"] floatValue]];
424
425		[[(id)[NSApp delegate] userMetadata] writeToDefaultFile];
426	}
427}
428
429- (IBAction) updateGameInfo: (id) sender {
430	if ([[ZoomGameInfoController sharedGameInfoController] infoOwner] == self) {
431		[[ZoomGameInfoController sharedGameInfoController] setGameInfo: [(ZoomGlkDocument*)[self document] storyData]];
432	}
433}
434
435// = Gaining/losing focus =
436
437- (void)windowDidBecomeMain:(NSNotification *)aNotification {
438	[[ZoomSkeinController sharedSkeinController] setSkein: skein];
439
440	[[ZoomGameInfoController sharedGameInfoController] setInfoOwner: self];
441	[[ZoomGameInfoController sharedGameInfoController] setGameInfo: [(ZoomGlkDocument*)[self document] storyData]];
442
443	[[ZoomNotesController sharedNotesController] setGameInfo: [(ZoomGlkDocument*)[self document] storyData]];
444	[[ZoomNotesController sharedNotesController] setInfoOwner: self];
445}
446
447- (void)windowDidResignMain:(NSNotification *)aNotification {
448	if ([[ZoomGameInfoController sharedGameInfoController] infoOwner] == self) {
449		[self recordGameInfo: self];
450
451		[[ZoomGameInfoController sharedGameInfoController] setGameInfo: nil];
452		[[ZoomGameInfoController sharedGameInfoController] setInfoOwner: nil];
453	}
454
455	if ([[ZoomNotesController sharedNotesController] infoOwner] == self) {
456		[[ZoomNotesController sharedNotesController] setGameInfo: nil];
457		[[ZoomNotesController sharedNotesController] setInfoOwner: nil];
458	}
459
460	if ([[ZoomSkeinController sharedSkeinController] skein] == skein) {
461		[[ZoomSkeinController sharedSkeinController] setSkein: nil];
462	}
463}
464
465// = Closing the window =
466
467- (void) confirmFinish:(NSWindow *)sheet
468			returnCode:(int)returnCode
469		   contextInfo:(void *)contextInfo {
470	if (returnCode == NSAlertDefaultReturn) {
471		// Close the window
472		closeConfirmed = YES;
473		[[NSRunLoop currentRunLoop] performSelector: @selector(performClose:)
474											 target: [self window]
475										   argument: self
476											  order: 32
477											  modes: [NSArray arrayWithObject: NSDefaultRunLoopMode]];
478	}
479}
480
481- (BOOL) windowShouldClose: (id) sender {
482	// Get confirmation if required
483	if (!closeConfirmed && running && [[ZoomPreferences globalPreferences] confirmGameClose]) {
484		BOOL autosave = [[ZoomPreferences globalPreferences] autosaveGames];
485		NSString* msg;
486
487		msg = @"There is still a story playing in this window. Are you sure you wish to finish it without saving? The current state of the game will be lost.";
488
489		NSBeginAlertSheet(@"Finish the game?",
490						  @"Finish", @"Continue playing", nil,
491						  [self window], self,
492						  @selector(confirmFinish:returnCode:contextInfo:), nil,
493						  nil, msg);
494
495		return NO;
496	}
497
498	return YES;
499}
500
501// = Going fullscreen =
502
503- (IBAction) playInFullScreen: (id) sender {
504	if (isFullscreen) {
505		// Show the menubar
506		[NSMenu setMenuBarVisible: YES];
507
508		// Stop being fullscreen
509		[glkView retain];
510		[glkView removeFromSuperview];
511
512		[glkView setScaleFactor: 1.0];
513		[glkView setFrame: [[normalWindow contentView] bounds]];
514		[[normalWindow contentView] addSubview: glkView];
515		[glkView release];
516
517		// Swap windows back
518		if (normalWindow) {
519			[fullscreenWindow setDelegate: nil];
520			[fullscreenWindow setInitialFirstResponder: nil];
521
522			[normalWindow setDelegate: self];
523			[normalWindow setWindowController: self];
524			[self setWindow: normalWindow];
525			[normalWindow setInitialFirstResponder: glkView];
526			[normalWindow setFrame: oldWindowFrame
527						   display: YES];
528			[normalWindow makeKeyAndOrderFront: self];
529
530			[fullscreenWindow orderOut: self];
531			[fullscreenWindow release]; fullscreenWindow = nil;
532 		}
533
534		//[self setWindowFrameAutosaveName: @"ZoomClientWindow"];
535		isFullscreen = NO;
536	} else {
537		// Do nothing if the game is not running
538		if (!running) return;
539
540		// As of 10.4, we need to create a separate full-screen window (10.4 tries to be 'clever' with the window borders, which messes things up
541		if (!normalWindow) normalWindow = [[self window] retain];
542		if (!fullscreenWindow) {
543			fullscreenWindow = [[ZoomWindowThatCanBecomeKey alloc] initWithContentRect: [[[self window] contentView] bounds]
544																			 styleMask: NSBorderlessWindowMask
545																			   backing: NSBackingStoreBuffered
546																				 defer: YES];
547
548			[fullscreenWindow setLevel: NSFloatingWindowLevel];
549			[fullscreenWindow setHidesOnDeactivate: YES];
550			[fullscreenWindow setReleasedWhenClosed: NO];
551			[fullscreenWindow setOpaque: NO];
552			if ([[NSApp delegate] leopard]) {
553				[fullscreenWindow setBackgroundColor: [NSColor clearColor]];
554			}
555
556			if (![fullscreenWindow canBecomeKeyWindow]) {
557				[NSException raise: @"ZoomProgrammerIsASpoon"
558							format: @"For some reason, the full screen window won't accept key"];
559			}
560		}
561
562		// Swap the displayed windows over
563		[self setWindowFrameAutosaveName: @""];
564		[fullscreenWindow setFrame: [normalWindow frame]
565						   display: NO];
566		[fullscreenWindow makeKeyAndOrderFront: self];
567
568		[glkView retain];
569		[glkView removeFromSuperview];
570		[[fullscreenWindow contentView] addSubview: glkView];
571		[glkView release];
572
573		[normalWindow setInitialFirstResponder: nil];
574		[normalWindow setDelegate: nil];
575
576		[fullscreenWindow setInitialFirstResponder: glkView];
577		[fullscreenWindow makeFirstResponder: glkView];
578		[fullscreenWindow setDelegate: self];
579
580		[fullscreenWindow setWindowController: self];
581		[self setWindow: fullscreenWindow];
582
583		// Start being fullscreen
584		[[self window] makeKeyAndOrderFront: self];
585		oldWindowFrame = [[self window] frame];
586
587		// Finish off glkView
588		NSSize oldGlkViewSize = [glkView frame].size;
589
590		[glkView retain];
591		[glkView removeFromSuperviewWithoutNeedingDisplay];
592
593		// Hide the menubar
594		[NSMenu setMenuBarVisible: NO];
595
596		// Resize the window
597		NSRect frame = [[[self window] screen] frame];
598		if (![[NSApp delegate] leopard]) {
599			[[self window] setShowsResizeIndicator: NO];
600			frame = [NSWindow frameRectForContentRect: frame
601											styleMask: NSBorderlessWindowMask];
602			[[self window] setFrame: frame
603							display: YES
604							animate: YES];
605			[normalWindow orderOut: self];
606		} else {
607			[[self window] setContentView: [[[ZoomClearView alloc] init] autorelease]];
608			[[self window] setFrame: frame
609							display: YES
610							animate: NO];
611		}
612
613		// Resize, reposition the glkView
614		NSRect newGlkViewFrame = [[[self window] contentView] bounds];
615		NSRect newGlkViewBounds;
616
617		newGlkViewBounds.origin = NSMakePoint(0,0);
618		newGlkViewBounds.size   = newGlkViewFrame.size;
619
620		double ratio = newGlkViewFrame.size.width/oldGlkViewSize.width;
621		[glkView setFrame: newGlkViewFrame];
622		[glkView setScaleFactor: ratio];
623
624		// Add it back in again
625		[[[self window] contentView] addSubview: glkView];
626		[glkView release];
627
628		// Perform an animation in Leopard
629		if ([[NSApp delegate] leopard]) {
630			[[[NSApp delegate] leopard] fullScreenView: glkView
631											 fromFrame: oldWindowFrame
632											   toFrame: frame];
633		}
634
635		isFullscreen = YES;
636	}
637}
638
639// = Ending the game =
640
641- (void) taskHasStarted {
642	[[self window] setDocumentEdited: YES];
643
644	running = YES;
645	closeConfirmed = NO;
646}
647
648- (void) taskHasCrashed {
649	[[self window] setTitle: [NSString stringWithFormat: @"%@ (crashed)", [[self document] displayName], nil]];
650}
651
652- (void) taskHasFinished {
653	if (isFullscreen) [self playInFullScreen: self];
654
655	[[self window] setTitle: [NSString stringWithFormat: @"%@ (finished)", [[self document] displayName], nil]];
656
657	[[self window] setDocumentEdited: NO];
658	running = NO;
659}
660
661// = Saving the game =
662
663- (BOOL) promptForFilesForUsage: (NSString*) usage
664					 forWriting: (BOOL) writing
665						handler: (NSObject<GlkFilePrompt>*) handler
666			 preferredDirectory: (NSString*) preferredDirectory {
667	if (![usage isEqualToString: GlkFileUsageSavedGame]) {
668		// We only customise save game generation
669		return NO;
670	}
671
672	// Remember the handler
673	[promptHandler release];
674	promptHandler = [handler retain];
675
676	// Create the prompt window
677	if (writing) {
678		// Create a save dialog
679		NSSavePanel* panel = [NSSavePanel savePanel];
680
681		[panel setRequiredFileType: @"glksave"];
682		if (preferredDirectory != nil) [panel setDirectory: preferredDirectory];
683
684		[panel beginSheetForDirectory: preferredDirectory
685								 file: nil
686					   modalForWindow: [self window]
687						modalDelegate: self
688					   didEndSelector: @selector(panelDidEnd:returnCode:contextInfo:)
689						  contextInfo: nil];
690
691		[lastPanel release]; lastPanel = [panel retain];
692	} else {
693		// Create an open dialog
694		NSOpenPanel* panel = [NSOpenPanel openPanel];
695
696		NSMutableArray* allowedFiletypes = [[[glkView fileTypesForUsage: usage] mutableCopy] autorelease];
697		[allowedFiletypes insertObject: @"glksave"
698							   atIndex: 0];
699
700		[panel setRequiredFileType: [allowedFiletypes objectAtIndex: 0]];
701		if (preferredDirectory != nil) [panel setDirectory: preferredDirectory];
702
703		if ([panel respondsToSelector: @selector(setAllowedFileTypes:)]) {
704			// Only works on 10.3
705			[panel setAllowedFileTypes: allowedFiletypes];
706		}
707
708		[panel beginSheetForDirectory: preferredDirectory
709								 file: nil
710								types: allowedFiletypes
711					   modalForWindow: [self window]
712						modalDelegate: self
713					   didEndSelector: @selector(panelDidEnd:returnCode:contextInfo:)
714						  contextInfo: nil];
715
716		[lastPanel release]; lastPanel = [panel retain];
717	}
718
719	return YES;
720}
721
722- (void) panelDidEnd: (NSSavePanel*) panel
723		  returnCode: (int) returnCode
724		 contextInfo: (void*) willBeNil {
725	if (!promptHandler) return;
726
727	if (returnCode == NSOKButton) {
728		// TODO: preview
729		if ([[[[panel filename] pathExtension] lowercaseString] isEqualToString: @"glksave"]) {
730			ZoomGlkSaveRef* saveRef = [[ZoomGlkSaveRef alloc] initWithPlugIn: [[self document] plugIn]
731																		path: [panel filename]];
732			[saveRef setSkein: skein];
733			[promptHandler promptedFileRef: saveRef];
734			[saveRef autorelease];
735		} else {
736			GlkFileRef* promptRef = [[GlkFileRef alloc] initWithPath: [panel filename]];
737			[promptHandler promptedFileRef: promptRef];
738			[promptRef autorelease];
739		}
740
741		[[NSUserDefaults standardUserDefaults] setObject: [panel directory]
742												  forKey: @"GlkSaveDirectory"];
743		if ([self respondsToSelector: @selector(savePreferredDirectory:)]) {
744			[self savePreferredDirectory: [panel directory]];
745		}
746	} else {
747		[promptHandler promptCancelled];
748	}
749
750	[promptHandler release]; promptHandler = nil;
751	[lastPanel release]; lastPanel = nil;
752}
753
754// = Speech commands =
755
756- (IBAction) stopSpeakingMove: (id) sender {
757	[tts beQuiet];
758}
759
760- (IBAction) speakMostRecent: (id) sender {
761	[tts resetMoves];
762	[tts speakLastText];
763}
764
765- (IBAction) speakNext: (id) sender {
766	[tts speakNextMove];
767}
768
769- (IBAction) speakPrevious: (id) sender {
770	[tts speakPreviousMove];
771}
772
773@end
774