475 lines
13 KiB
Python
475 lines
13 KiB
Python
# -*- 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 <http://www.gnu.org/licenses/>.
|
|
#
|
|
'''
|
|
|
|
|
|
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.
|
|
'<Control-Return>': lambda tv: tv._control_enter,
|
|
'<Control-v>': lambda tv: tv._paste,
|
|
'<Control-V>': lambda tv: tv._paste,
|
|
'<F3>': lambda tv: tv.copy_selection_to_stack,
|
|
'<F4>': lambda tv: tv.copyto,
|
|
'<Shift-F3>': lambda tv: tv.cut,
|
|
'<Shift-F4>': lambda tv: tv.pastecut,
|
|
'<Shift-Insert>': 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('<<Modified>>', 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.'''
|
|
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("<<Cut>>")
|
|
|
|
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("<<Paste>>")
|
|
|
|
# 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.set_window_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 set_window_title(self, title):
|
|
self.winfo_toplevel().title(title)
|
|
|
|
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)
|