OSDN Git Service

More stack effects, and modify the key bindings.
[joypy/Thun.git] / joy / gui / textwidget.py
1 # -*- coding: utf-8 -*-
2 #
3 #    Copyright © 2014, 2015, 2018 Simon Forman
4 #
5 #    This file is part of joy.py
6 #
7 #    joy.py is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU General Public License as published by
9 #    the Free Software Foundation, either version 3 of the License, or
10 #    (at your option) any later version.
11 #
12 #    joy.py is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU General Public License for more details.
16 #
17 #    You should have received a copy of the GNU General Public License
18 #    along with joy.py.  If not see <http://www.gnu.org/licenses/>.
19 #
20 '''
21
22
23 A Graphical User Interface for a dialect of Joy in Python.
24
25
26 The GUI
27
28   History
29   Structure
30   Commands
31     Mouse Chords
32     Keyboard
33   Output from Joy
34
35
36 '''
37 from __future__ import print_function
38 try:
39   import tkinter as tk
40   from tkinter.font import families, Font
41 except ImportError:
42   import Tkinter as tk
43   from tkFont import families, Font
44
45 from re import compile as regular_expression
46 from traceback import format_exc
47 import os, sys
48
49 from joy.utils.stack import stack_to_string
50
51 from .mousebindings import MouseBindingsMixin
52 from .world import World, is_numerical
53
54
55 def make_gui(dictionary):
56   t = TextViewerWidget(World(dictionary=dictionary))
57   t['font'] = get_font()
58   t._root().title('Joy')
59   t.pack(expand=True, fill=tk.BOTH)
60   return t
61
62
63 def get_font(family='EB Garamond', size=14):
64   if family not in families():
65     family = 'Times'
66   return Font(family=family, size=size)
67
68
69 #: Define mapping between Tkinter events and functions or methods. The
70 #: keys are string Tk "event sequences" and the values are callables that
71 #: get passed the TextViewer instance (so you can bind to methods) and
72 #: must return the actual callable to which to bind the event sequence.
73 TEXT_BINDINGS = {
74
75   #I want to ensure that these keyboard shortcuts work.
76   '<Control-v>': lambda tv: tv._paste,
77   '<Control-V>': lambda tv: tv._paste,
78   '<Shift-Insert>': lambda tv: tv._paste,
79   '<Control-Return>': lambda tv: tv._control_enter,
80   }
81
82
83 class SavingMixin:
84
85   def __init__(self, saver=None, filename=None, save_delay=2000):
86     self.saver = self._saver if saver is None else saver
87     self.filename = filename
88     self._save_delay = save_delay
89     self.tk.call(self._w, 'edit', 'modified', 0)
90     self.bind('<<Modified>>', self._beenModified)
91     self._resetting_modified_flag = False
92     self._save = None
93
94   def save(self):
95     '''
96     Call _saveFunc() after a certain amount of idle time.
97
98     Called by _beenModified().
99     '''
100     self._cancelSave()
101     if self.saver:
102       self._saveAfter(self._save_delay)
103
104   def _saveAfter(self, delay):
105     '''
106     Trigger a cancel-able call to _saveFunc() after delay milliseconds.
107     '''
108     self._save = self.after(delay, self._saveFunc)
109
110   def _saveFunc(self):
111     self._save = None
112     self.saver(self._get_contents())
113
114   def _saver(self, text):
115     if not self.filename:
116       return
117     with open(self.filename, 'w') as f:
118       os.chmod(self.filename, 0600)
119       f.write(text.encode('UTF_8'))
120       f.flush()
121       os.fsync(f.fileno())
122     if hasattr(self, 'repo'):
123       self.repo.stage([self.repo_relative_filename])
124     self.world.save()
125
126   def _cancelSave(self):
127     if self._save is not None:
128       self.after_cancel(self._save)
129       self._save = None
130
131   def _get_contents(self):
132     self['state'] = 'disabled'
133     try:
134       return self.get('0.0', 'end')[:-1]
135     finally:
136       self['state'] = 'normal'
137
138   def _beenModified(self, event):
139     if self._resetting_modified_flag:
140       return
141     self._clearModifiedFlag()
142     self.save()
143
144   def _clearModifiedFlag(self):
145     self._resetting_modified_flag = True
146     try:
147       self.tk.call(self._w, 'edit', 'modified', 0)
148     finally:
149       self._resetting_modified_flag = False
150
151 ##        tags = self._saveTags()
152 ##        chunks = self.DUMP()
153 ##        print chunks
154
155
156 class TextViewerWidget(tk.Text, MouseBindingsMixin, SavingMixin):
157   """
158   This class is a Tkinter Text with special mousebindings to make
159   it act as a Xerblin Text Viewer.
160   """
161
162   #This is a regular expression for finding commands in the text.
163   command_re = regular_expression(r'[-a-zA-Z0-9_\\~/.:!@#$%&*?=+<>]+')
164
165   #These are the config tags for command text when it's highlighted.
166   command_tags = dict(
167     #underline = 1,
168     #bgstipple = "gray50",
169     borderwidth = 2,
170     relief=tk.RIDGE,
171     foreground = "green"
172   )
173
174   def __init__(self, world, master=None, **kw):
175
176     self.world = world
177     if self.world.text_widget is None:
178       self.world.text_widget = self
179
180     #Turn on undo, but don't override a passed-in setting.
181     kw.setdefault('undo', True)
182
183 #        kw.setdefault('bg', 'white')
184     kw.setdefault('wrap', 'word')
185     kw.setdefault('font', 'arial 12')
186
187     text_bindings = kw.pop('text_bindings', TEXT_BINDINGS)
188
189     #Create ourselves as a Tkinter Text
190     tk.Text.__init__(self, master, **kw)
191
192     #Initialize our mouse mixin.
193     MouseBindingsMixin.__init__(self)
194
195     #Initialize our saver mixin.
196     SavingMixin.__init__(self)
197
198     #Add tag config for command highlighting.
199     self.tag_config('command', **self.command_tags)
200     self.tag_config('bzzt', foreground = "orange")
201     self.tag_config('huh', foreground = "grey")
202     self.tag_config('number', foreground = "blue")
203
204     #Create us a command instance variable
205     self.command = ''
206
207     #Activate event bindings. Modify text_bindings in your config
208     #file to affect the key bindings and whatnot here.
209     for event_sequence, callback_finder in text_bindings.items():
210       callback = callback_finder(self)
211       self.bind(event_sequence, callback)
212
213 ##        T.protocol("WM_DELETE_WINDOW", self.on_close)
214
215   def find_command_in_line(self, line, index):
216     '''
217     Return the command at index in line and its begin and end indices.
218     find_command_in_line(line, index) => command, begin, end
219     '''
220     for match in self.command_re.finditer(line):
221       b, e = match.span()
222       if b <= index <= e:
223         return match.group(), b, e
224
225   def paste_X_selection_to_mouse_pointer(self, event):
226     '''Paste the X selection to the mouse pointer.'''
227     try:
228       text = self.selection_get()
229     except tk.TclError:
230       return 'break'
231     self.insert_it(text)
232
233   def update_command_word(self, event):
234     '''Highlight the command under the mouse.'''
235     self.unhighlight_command()
236     self.command = ''
237     index = '@%d,%d' % (event.x, event.y)
238     linestart = self.index(index + 'linestart')
239     lineend = self.index(index + 'lineend')
240     line = self.get(linestart, lineend)
241     row, offset = self._get_index(index)
242
243     if offset >= len(line) or line[offset].isspace():
244       # The mouse is off the end of the line or on a space so there's no
245       # command, we're done.
246       return
247
248     cmd = self.find_command_in_line(line, offset)
249     if cmd is None:
250       return
251
252     cmd, b, e = cmd
253     if is_numerical(cmd):
254       extra_tags = 'number',
255     elif self.world.has(cmd):
256       check = self.world.check(cmd)
257       if check: extra_tags = ()
258       elif check is None: extra_tags = 'huh',
259       else: extra_tags = 'bzzt',
260     else:
261       return
262     self.command = cmd
263     self.highlight_command(
264       '%d.%d' % (row, b),
265       '%d.%d' % (row, e),
266       *extra_tags)
267
268   def highlight_command(self, from_, to, *extra_tags):
269     '''Apply command style from from_ to to.'''
270     cmdstart = self.index(from_)
271     cmdend = self.index(to)
272     self.tag_add('command', cmdstart, cmdend)
273     for tag in extra_tags:
274       self.tag_add(tag, cmdstart, cmdend)
275
276   def do_command(self, event):
277     '''Do the currently highlighted command.'''
278     self.unhighlight_command()
279     if self.command:
280       self.run_command(self.command)
281
282   def _control_enter(self, event):
283     select_indices = self.tag_ranges(tk.SEL)
284     if select_indices:
285       command = self.get(select_indices[0], select_indices[1])
286     else:
287       linestart = self.index(tk.INSERT + ' linestart')
288       lineend = self.index(tk.INSERT + ' lineend')
289       command = self.get(linestart, lineend)
290     if command and not command.isspace():
291       self.run_command(command)
292     return 'break'
293
294   def run_command(self, command):
295     '''Given a string run it on the stack, report errors.'''
296     try:
297       self.world.interpret(command)
298     except SystemExit:
299       raise
300     except:
301       self.popupTB(format_exc().rstrip())
302
303   def unhighlight_command(self):
304     '''Remove any command highlighting.'''
305     self.tag_remove('number', 1.0, tk.END)
306     self.tag_remove('huh', 1.0, tk.END)
307     self.tag_remove('bzzt', 1.0, tk.END)
308     self.tag_remove('command', 1.0, tk.END)
309
310   def set_insertion_point(self, event):
311     '''Set the insertion cursor to the current mouse location.'''
312     self.focus()
313     self.mark_set(tk.INSERT, '@%d,%d' % (event.x, event.y))
314
315   def copy_selection_to_stack(self, event):
316     '''Copy selection to stack.'''
317     select_indices = self.tag_ranges(tk.SEL)
318     if select_indices:
319       s = self.get(select_indices[0], select_indices[1])
320       self.world.push(s)
321
322   def cut(self, event):
323     '''Cut selection to stack.'''
324     self.copy_selection_to_stack(event)
325     # Let the pre-existing machinery take care of cutting the selection.
326     self.event_generate("<<Cut>>")
327
328   def copyto(self, event):
329     '''Actually "paste" from TOS'''
330     s = self.world.peek()
331     if s is not None:
332       self.insert_it(s)
333
334   def insert_it(self, s):
335     if not isinstance(s, basestring):
336       s = stack_to_string(s)
337
338     # When pasting from the mouse we have to remove the current selection
339     # to prevent destroying it by the paste operation.
340     select_indices = self.tag_ranges(tk.SEL)
341     if select_indices:
342       # Set two marks to remember the selection.
343       self.mark_set('_sel_start', select_indices[0])
344       self.mark_set('_sel_end', select_indices[1])
345       self.tag_remove(tk.SEL, 1.0, tk.END)
346
347     self.insert(tk.INSERT, s)
348
349     if select_indices:
350       self.tag_add(tk.SEL, '_sel_start', '_sel_end')
351       self.mark_unset('_sel_start')
352       self.mark_unset('_sel_end')
353
354   def run_selection(self, event):
355     '''Run the current selection if any on the stack.'''
356     select_indices = self.tag_ranges(tk.SEL)
357     if select_indices:
358       selection = self.get(select_indices[0], select_indices[1])
359       self.tag_remove(tk.SEL, 1.0, tk.END)
360       self.run_command(selection)
361
362   def pastecut(self, event):
363     '''Cut the TOS item to the mouse.'''
364     self.copyto(event)
365     self.world.pop()
366
367   def opendoc(self, event):
368     '''OpenDoc the current command.'''
369     if self.command:
370       self.world.do_opendoc(self.command)
371
372   def lookup(self, event):
373     '''Look up the current command.'''
374     if self.command:
375       self.world.do_lookup(self.command)
376
377   def cancel(self, event):
378     '''Cancel whatever we're doing.'''
379     self.leave(None)
380     self.tag_remove(tk.SEL, 1.0, tk.END)
381     self._sel_anchor = '0.0'
382     self.mark_unset(tk.INSERT)
383
384   def leave(self, event):
385     '''Called when mouse leaves the Text window.'''
386     self.unhighlight_command()
387     self.command = ''
388
389   def _get_index(self, index):
390     '''Get the index in (int, int) form of index.'''
391     return tuple(map(int, self.index(index).split('.')))
392
393   def _paste(self, event):
394     '''Paste the system selection to the current selection, replacing it.'''
395
396     # If we're "key" pasting, we have to move the insertion point
397     # to the selection so the pasted text gets inserted at the
398     # location of the deleted selection.
399
400     select_indices = self.tag_ranges(tk.SEL)
401     if select_indices:
402       # Mark the location of the current insertion cursor 
403       self.mark_set('tmark', tk.INSERT)
404       # Put the insertion cursor at the selection
405       self.mark_set(tk.INSERT, select_indices[1])
406
407     # Paste to the current selection, or if none, to the insertion cursor.
408     self.event_generate("<<Paste>>")
409
410     # If we mess with the insertion cursor above, fix it now.
411     if select_indices:
412       # Put the insertion cursor back where it was.
413       self.mark_set(tk.INSERT, 'tmark')
414       # And get rid of our unneeded mark.
415       self.mark_unset('tmark')
416
417     return 'break'
418
419   def init(self, title, filename, repo_relative_filename, repo, font):
420     self.winfo_toplevel().title(title)
421     if os.path.exists(filename):
422       with open(filename) as f:
423         data = f.read()
424       self.insert(tk.END, data)
425       # Prevent this from triggering a git commit.
426       self.update()
427       self._cancelSave()
428     self.pack(expand=True, fill=tk.BOTH)
429     self.filename = filename
430     self.repo_relative_filename = repo_relative_filename
431     self.repo = repo
432     self['font'] = font  # See below.
433
434   def reset(self):
435     if os.path.exists(self.filename):
436       with open(self.filename) as f:
437         data = f.read()
438       if data:
439         self.delete('0.0', tk.END)
440         self.insert(tk.END, data)
441
442   def popupTB(self, tb):
443     top = tk.Toplevel()
444     T = TextViewerWidget(
445       self.world,
446       top,
447       width=max(len(s) for s in tb.splitlines()) + 3,
448       )
449
450     T['background'] = 'darkgrey'
451     T['foreground'] = 'darkblue'
452     T.tag_config('err', foreground='yellow')
453
454     T.insert(tk.END, tb)
455     last_line = str(int(T.index(tk.END).split('.')[0]) - 1) + '.0'
456     T.tag_add('err', last_line, tk.END)
457     T['state'] = tk.DISABLED
458
459     top.title(T.get(last_line, tk.END).strip())
460
461     T.pack(expand=1, fill=tk.BOTH)
462     T.see(tk.END)