# -*- coding: utf-8 -*- # # Copyright © 2014, 2015, 2018 Simon Forman # # This file is part of joy.py # # joy.py is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # joy.py is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with joy.py. If not see . # ''' A Graphical User Interface for a dialect of Joy in Python. The GUI History Structure Commands Mouse Chords Keyboard Output from Joy ''' from __future__ import print_function from future import standard_library standard_library.install_aliases() from builtins import str, map, object from past.builtins import basestring try: import tkinter as tk from tkinter.font import families, Font except ImportError: import Tkinter as tk from tkFont import families, Font from re import compile as regular_expression from traceback import format_exc import os, sys from joy.utils.stack import stack_to_string from .mousebindings import MouseBindingsMixin from .utils import is_numerical from .world import World def make_gui(dictionary): t = TextViewerWidget(World(dictionary=dictionary)) t['font'] = get_font() t._root().title('Joy') t.pack(expand=True, fill=tk.BOTH) return t def get_font(family='EB Garamond', size=14): if family not in families(): family = 'Times' return Font(family=family, size=size) #: Define mapping between Tkinter events and functions or methods. The #: keys are string Tk "event sequences" and the values are callables that #: get passed the TextViewer instance (so you can bind to methods) and #: must return the actual callable to which to bind the event sequence. TEXT_BINDINGS = { #I want to ensure that these keyboard shortcuts work. '': lambda tv: tv._control_enter, '': lambda tv: tv._paste, '': lambda tv: tv._paste, '': lambda tv: tv.copy_selection_to_stack, '': lambda tv: tv.copyto, '': lambda tv: tv.cut, '': lambda tv: tv.pastecut, '': lambda tv: tv._paste, } class SavingMixin(object): def __init__(self, saver=None, filename=None, save_delay=2000): self.saver = self._saver if saver is None else saver self.filename = filename self._save_delay = save_delay self.tk.call(self._w, 'edit', 'modified', 0) self.bind('<>', self._beenModified) self._resetting_modified_flag = False self._save = None def save(self): ''' Call _saveFunc() after a certain amount of idle time. Called by _beenModified(). ''' self._cancelSave() if self.saver: self._saveAfter(self._save_delay) def _saveAfter(self, delay): ''' Trigger a cancel-able call to _saveFunc() after delay milliseconds. ''' self._save = self.after(delay, self._saveFunc) def _saveFunc(self): self._save = None self.saver(self._get_contents()) def _saver(self, text): if not self.filename: return with open(self.filename, 'wb') as f: os.chmod(self.filename, 0o600) f.write(text.encode('UTF_8')) f.flush() os.fsync(f.fileno()) if hasattr(self, 'repo'): self.repo.stage([self.repo_relative_filename]) self.world.save() def _cancelSave(self): if self._save is not None: self.after_cancel(self._save) self._save = None def _get_contents(self): self['state'] = 'disabled' try: return self.get('0.0', 'end')[:-1] finally: self['state'] = 'normal' def _beenModified(self, event): if self._resetting_modified_flag: return self._clearModifiedFlag() self.save() def _clearModifiedFlag(self): self._resetting_modified_flag = True try: self.tk.call(self._w, 'edit', 'modified', 0) finally: self._resetting_modified_flag = False ## tags = self._saveTags() ## chunks = self.DUMP() ## print chunks class TextViewerWidget(tk.Text, MouseBindingsMixin, SavingMixin): """ This class is a Tkinter Text with special mousebindings to make it act as a Xerblin Text Viewer. """ #This is a regular expression for finding commands in the text. command_re = regular_expression(r'[-a-zA-Z0-9_\\~/.:!@#$%&*?=+<>]+') #These are the config tags for command text when it's highlighted. command_tags = dict( #underline = 1, #bgstipple = "gray50", borderwidth = 2, relief=tk.RIDGE, foreground = "green" ) def __init__(self, world, master=None, **kw): self.world = world if self.world.text_widget is None: self.world.text_widget = self #Turn on undo, but don't override a passed-in setting. kw.setdefault('undo', True) # kw.setdefault('bg', 'white') kw.setdefault('wrap', 'word') kw.setdefault('font', 'arial 12') text_bindings = kw.pop('text_bindings', TEXT_BINDINGS) #Create ourselves as a Tkinter Text tk.Text.__init__(self, master, **kw) #Initialize our mouse mixin. MouseBindingsMixin.__init__(self) #Initialize our saver mixin. SavingMixin.__init__(self) #Add tag config for command highlighting. self.tag_config('command', **self.command_tags) self.tag_config('bzzt', foreground = "orange") self.tag_config('huh', foreground = "grey") self.tag_config('number', foreground = "blue") #Create us a command instance variable self.command = '' #Activate event bindings. Modify text_bindings in your config #file to affect the key bindings and whatnot here. for event_sequence, callback_finder in text_bindings.items(): callback = callback_finder(self) self.bind(event_sequence, callback) ## T.protocol("WM_DELETE_WINDOW", self.on_close) def find_command_in_line(self, line, index): ''' Return the command at index in line and its begin and end indices. find_command_in_line(line, index) => command, begin, end ''' for match in self.command_re.finditer(line): b, e = match.span() if b <= index <= e: return match.group(), b, e def paste_X_selection_to_mouse_pointer(self, event): '''Paste the X selection to the mouse pointer.''' try: text = self.selection_get() except tk.TclError: return 'break' self.insert_it(text) def update_command_word(self, event): '''Highlight the command under the mouse.''' self.unhighlight_command() self.command = '' index = '@%d,%d' % (event.x, event.y) linestart = self.index(index + 'linestart') lineend = self.index(index + 'lineend') line = self.get(linestart, lineend) row, offset = self._get_index(index) if offset >= len(line) or line[offset].isspace(): # The mouse is off the end of the line or on a space so there's no # command, we're done. return cmd = self.find_command_in_line(line, offset) if cmd is None: return cmd, b, e = cmd if is_numerical(cmd): extra_tags = 'number', elif self.world.has(cmd): check = self.world.check(cmd) if check: extra_tags = () elif check is None: extra_tags = 'huh', else: extra_tags = 'bzzt', else: return self.command = cmd self.highlight_command( '%d.%d' % (row, b), '%d.%d' % (row, e), *extra_tags) def highlight_command(self, from_, to, *extra_tags): '''Apply command style from from_ to to.''' cmdstart = self.index(from_) cmdend = self.index(to) self.tag_add('command', cmdstart, cmdend) for tag in extra_tags: self.tag_add(tag, cmdstart, cmdend) def do_command(self, event): '''Do the currently highlighted command.''' self.unhighlight_command() if self.command: self.run_command(self.command) def _control_enter(self, event): select_indices = self.tag_ranges(tk.SEL) if select_indices: command = self.get(select_indices[0], select_indices[1]) else: linestart = self.index(tk.INSERT + ' linestart') lineend = self.index(tk.INSERT + ' lineend') command = self.get(linestart, lineend) if command and not command.isspace(): self.run_command(command) return 'break' def run_command(self, command): '''Given a string run it on the stack, report errors.''' command = command.encode('utf_8') try: self.world.interpret(command) except SystemExit: raise except: self.popupTB(format_exc().rstrip()) def unhighlight_command(self): '''Remove any command highlighting.''' self.tag_remove('number', 1.0, tk.END) self.tag_remove('huh', 1.0, tk.END) self.tag_remove('bzzt', 1.0, tk.END) self.tag_remove('command', 1.0, tk.END) def set_insertion_point(self, event): '''Set the insertion cursor to the current mouse location.''' self.focus() self.mark_set(tk.INSERT, '@%d,%d' % (event.x, event.y)) def copy_selection_to_stack(self, event): '''Copy selection to stack.''' select_indices = self.tag_ranges(tk.SEL) if select_indices: s = self.get(select_indices[0], select_indices[1]) self.world.push(s) def cut(self, event): '''Cut selection to stack.''' self.copy_selection_to_stack(event) # Let the pre-existing machinery take care of cutting the selection. self.event_generate("<>") def copyto(self, event): '''Actually "paste" from TOS''' s = self.world.peek() if s is not None: self.insert_it(s) def insert_it(self, s): if not isinstance(s, basestring): s = stack_to_string(s) # When pasting from the mouse we have to remove the current selection # to prevent destroying it by the paste operation. select_indices = self.tag_ranges(tk.SEL) if select_indices: # Set two marks to remember the selection. self.mark_set('_sel_start', select_indices[0]) self.mark_set('_sel_end', select_indices[1]) self.tag_remove(tk.SEL, 1.0, tk.END) self.insert(tk.INSERT, s) if select_indices: self.tag_add(tk.SEL, '_sel_start', '_sel_end') self.mark_unset('_sel_start') self.mark_unset('_sel_end') def run_selection(self, event): '''Run the current selection if any on the stack.''' select_indices = self.tag_ranges(tk.SEL) if select_indices: selection = self.get(select_indices[0], select_indices[1]) self.tag_remove(tk.SEL, 1.0, tk.END) self.run_command(selection) def pastecut(self, event): '''Cut the TOS item to the mouse.''' self.copyto(event) self.world.pop() def opendoc(self, event): '''OpenDoc the current command.''' if self.command: self.world.do_opendoc(self.command) def lookup(self, event): '''Look up the current command.''' if self.command: self.world.do_lookup(self.command) def cancel(self, event): '''Cancel whatever we're doing.''' self.leave(None) self.tag_remove(tk.SEL, 1.0, tk.END) self._sel_anchor = '0.0' self.mark_unset(tk.INSERT) def leave(self, event): '''Called when mouse leaves the Text window.''' self.unhighlight_command() self.command = '' def _get_index(self, index): '''Get the index in (int, int) form of index.''' return tuple(map(int, self.index(index).split('.'))) def _paste(self, event): '''Paste the system selection to the current selection, replacing it.''' # If we're "key" pasting, we have to move the insertion point # to the selection so the pasted text gets inserted at the # location of the deleted selection. select_indices = self.tag_ranges(tk.SEL) if select_indices: # Mark the location of the current insertion cursor self.mark_set('tmark', tk.INSERT) # Put the insertion cursor at the selection self.mark_set(tk.INSERT, select_indices[1]) # Paste to the current selection, or if none, to the insertion cursor. self.event_generate("<>") # If we mess with the insertion cursor above, fix it now. if select_indices: # Put the insertion cursor back where it was. self.mark_set(tk.INSERT, 'tmark') # And get rid of our unneeded mark. self.mark_unset('tmark') return 'break' def init(self, title, filename, repo_relative_filename, repo, font): self.winfo_toplevel().title(title) if os.path.exists(filename): with open(filename) as f: data = f.read() self.insert(tk.END, data) # Prevent this from triggering a git commit. self.update() self._cancelSave() self.pack(expand=True, fill=tk.BOTH) self.filename = filename self.repo_relative_filename = repo_relative_filename self.repo = repo self['font'] = font # See below. def reset(self): if os.path.exists(self.filename): with open(self.filename) as f: data = f.read() if data: self.delete('0.0', tk.END) self.insert(tk.END, data) def popupTB(self, tb): top = tk.Toplevel() T = TextViewerWidget( self.world, top, width=max(len(s) for s in tb.splitlines()) + 3, ) T['background'] = 'darkgrey' T['foreground'] = 'darkblue' T.tag_config('err', foreground='yellow') T.insert(tk.END, tb) last_line = str(int(T.index(tk.END).split('.')[0]) - 1) + '.0' T.tag_add('err', last_line, tk.END) T['state'] = tk.DISABLED top.title(T.get(last_line, tk.END).strip()) T.pack(expand=1, fill=tk.BOTH) T.see(tk.END)