1 # -*- coding: utf-8 -*-
3 # Copyright © 2014, 2015, 2018 Simon Forman
5 # This file is part of joy.py
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.
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.
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/>.
23 A Graphical User Interface for a dialect of Joy in Python.
37 from __future__ import print_function
40 from tkinter.font import families, Font
43 from tkFont import families, Font
45 from re import compile as regular_expression
46 from traceback import format_exc
49 from joy.utils.stack import stack_to_string
51 from .mousebindings import MouseBindingsMixin
52 from .world import World, is_numerical
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)
63 def get_font(family='EB Garamond', size=14):
64 if family not in families():
66 return Font(family=family, size=size)
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.
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,
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
96 Call _saveFunc() after a certain amount of idle time.
98 Called by _beenModified().
102 self._saveAfter(self._save_delay)
104 def _saveAfter(self, delay):
106 Trigger a cancel-able call to _saveFunc() after delay milliseconds.
108 self._save = self.after(delay, self._saveFunc)
112 self.saver(self._get_contents())
114 def _saver(self, text):
115 if not self.filename:
117 with open(self.filename, 'w') as f:
118 os.chmod(self.filename, 0600)
119 f.write(text.encode('UTF_8'))
122 if hasattr(self, 'repo'):
123 self.repo.stage([self.repo_relative_filename])
126 def _cancelSave(self):
127 if self._save is not None:
128 self.after_cancel(self._save)
131 def _get_contents(self):
132 self['state'] = 'disabled'
134 return self.get('0.0', 'end')[:-1]
136 self['state'] = 'normal'
138 def _beenModified(self, event):
139 if self._resetting_modified_flag:
141 self._clearModifiedFlag()
144 def _clearModifiedFlag(self):
145 self._resetting_modified_flag = True
147 self.tk.call(self._w, 'edit', 'modified', 0)
149 self._resetting_modified_flag = False
151 ## tags = self._saveTags()
152 ## chunks = self.DUMP()
156 class TextViewerWidget(tk.Text, MouseBindingsMixin, SavingMixin):
158 This class is a Tkinter Text with special mousebindings to make
159 it act as a Xerblin Text Viewer.
162 #This is a regular expression for finding commands in the text.
163 command_re = regular_expression(r'[-a-zA-Z0-9_\\~/.:!@#$%&*?=+<>]+')
165 #These are the config tags for command text when it's highlighted.
168 #bgstipple = "gray50",
174 def __init__(self, world, master=None, **kw):
177 if self.world.text_widget is None:
178 self.world.text_widget = self
180 #Turn on undo, but don't override a passed-in setting.
181 kw.setdefault('undo', True)
183 # kw.setdefault('bg', 'white')
184 kw.setdefault('wrap', 'word')
185 kw.setdefault('font', 'arial 12')
187 text_bindings = kw.pop('text_bindings', TEXT_BINDINGS)
189 #Create ourselves as a Tkinter Text
190 tk.Text.__init__(self, master, **kw)
192 #Initialize our mouse mixin.
193 MouseBindingsMixin.__init__(self)
195 #Initialize our saver mixin.
196 SavingMixin.__init__(self)
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")
204 #Create us a command instance variable
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)
213 ## T.protocol("WM_DELETE_WINDOW", self.on_close)
215 def find_command_in_line(self, line, index):
217 Return the command at index in line and its begin and end indices.
218 find_command_in_line(line, index) => command, begin, end
220 for match in self.command_re.finditer(line):
223 return match.group(), b, e
225 def paste_X_selection_to_mouse_pointer(self, event):
226 '''Paste the X selection to the mouse pointer.'''
228 text = self.selection_get()
233 def update_command_word(self, event):
234 '''Highlight the command under the mouse.'''
235 self.unhighlight_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)
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.
248 cmd = self.find_command_in_line(line, offset)
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',
263 self.highlight_command(
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)
276 def do_command(self, event):
277 '''Do the currently highlighted command.'''
278 self.unhighlight_command()
280 self.run_command(self.command)
282 def _control_enter(self, event):
283 select_indices = self.tag_ranges(tk.SEL)
285 command = self.get(select_indices[0], select_indices[1])
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)
294 def run_command(self, command):
295 '''Given a string run it on the stack, report errors.'''
297 self.world.interpret(command)
301 self.popupTB(format_exc().rstrip())
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)
310 def set_insertion_point(self, event):
311 '''Set the insertion cursor to the current mouse location.'''
313 self.mark_set(tk.INSERT, '@%d,%d' % (event.x, event.y))
315 def copy_selection_to_stack(self, event):
316 '''Copy selection to stack.'''
317 select_indices = self.tag_ranges(tk.SEL)
319 s = self.get(select_indices[0], select_indices[1])
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>>")
328 def copyto(self, event):
329 '''Actually "paste" from TOS'''
330 s = self.world.peek()
334 def insert_it(self, s):
335 if not isinstance(s, basestring):
336 s = stack_to_string(s)
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)
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)
347 self.insert(tk.INSERT, s)
350 self.tag_add(tk.SEL, '_sel_start', '_sel_end')
351 self.mark_unset('_sel_start')
352 self.mark_unset('_sel_end')
354 def run_selection(self, event):
355 '''Run the current selection if any on the stack.'''
356 select_indices = self.tag_ranges(tk.SEL)
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)
362 def pastecut(self, event):
363 '''Cut the TOS item to the mouse.'''
367 def opendoc(self, event):
368 '''OpenDoc the current command.'''
370 self.world.do_opendoc(self.command)
372 def lookup(self, event):
373 '''Look up the current command.'''
375 self.world.do_lookup(self.command)
377 def cancel(self, event):
378 '''Cancel whatever we're doing.'''
380 self.tag_remove(tk.SEL, 1.0, tk.END)
381 self._sel_anchor = '0.0'
382 self.mark_unset(tk.INSERT)
384 def leave(self, event):
385 '''Called when mouse leaves the Text window.'''
386 self.unhighlight_command()
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('.')))
393 def _paste(self, event):
394 '''Paste the system selection to the current selection, replacing it.'''
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.
400 select_indices = self.tag_ranges(tk.SEL)
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])
407 # Paste to the current selection, or if none, to the insertion cursor.
408 self.event_generate("<<Paste>>")
410 # If we mess with the insertion cursor above, fix it now.
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')
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:
424 self.insert(tk.END, data)
425 # Prevent this from triggering a git commit.
428 self.pack(expand=True, fill=tk.BOTH)
429 self.filename = filename
430 self.repo_relative_filename = repo_relative_filename
432 self['font'] = font # See below.
435 if os.path.exists(self.filename):
436 with open(self.filename) as f:
439 self.delete('0.0', tk.END)
440 self.insert(tk.END, data)
442 def popupTB(self, tb):
444 T = TextViewerWidget(
447 width=max(len(s) for s in tb.splitlines()) + 3,
450 T['background'] = 'darkgrey'
451 T['foreground'] = 'darkblue'
452 T.tag_config('err', foreground='yellow')
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
459 top.title(T.get(last_line, tk.END).strip())
461 T.pack(expand=1, fill=tk.BOTH)