OSDN Git Service

It's time to bring in the GUI.
authorSimon Forman <sforman@hushmail.com>
Sat, 14 Jul 2018 19:45:52 +0000 (12:45 -0700)
committerSimon Forman <sforman@hushmail.com>
Sat, 14 Jul 2018 19:45:52 +0000 (12:45 -0700)
Minimalist (not to say Brutalist) UI based on text windows and mouse
chords.  Experimental.

joy/gui/__init__.py [new file with mode: 0644]
joy/gui/__main__.py [new file with mode: 0644]
joy/gui/main.py [new file with mode: 0755]
joy/gui/mousebindings.py [new file with mode: 0644]
joy/gui/textwidget.py [new file with mode: 0644]
joy/gui/world.py [new file with mode: 0644]
setup.py

diff --git a/joy/gui/__init__.py b/joy/gui/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/joy/gui/__main__.py b/joy/gui/__main__.py
new file mode 100644 (file)
index 0000000..4ae7742
--- /dev/null
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+#
+#    Copyright © 2018 Simon Forman
+#
+#    This file is part of Joypy.
+#
+#    Joypy 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.
+#
+#    Joypy 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 Joypy.  If not see <http://www.gnu.org/licenses/>.
+#
+import sys
+from joy.gui.main import main
+
+
+sys.exit(main())
diff --git a/joy/gui/main.py b/joy/gui/main.py
new file mode 100755 (executable)
index 0000000..d0fdd30
--- /dev/null
@@ -0,0 +1,244 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+('''\
+Joypy - Copyright © 2018 Simon Forman
+'''
+'This program comes with ABSOLUTELY NO WARRANTY; for details right-click "warranty".'
+' This is free software, and you are welcome to redistribute it under certain conditions;'
+' right-click "sharing" for details.'
+' Right-click on these commands to see docs on UI commands: key_bindings mouse_bindings')
+import os, pickle, sys
+from textwrap import dedent
+
+from dulwich.errors import NotGitRepository
+from dulwich.repo import Repo
+
+from joy.gui.textwidget import TextViewerWidget, tk, get_font, TEXT_BINDINGS
+from joy.gui.world import World
+from joy.library import initialize
+from joy.utils.stack import stack_to_string
+
+
+JOY_HOME = os.environ.get('JOY_HOME')
+if JOY_HOME is None:
+  JOY_HOME = os.path.expanduser('~/.joypy')
+  if not os.path.isabs(JOY_HOME):
+    JOY_HOME = os.path.abspath('./JOY_HOME')
+  #print 'JOY_HOME=' + JOY_HOME
+
+if not os.path.exists(JOY_HOME):
+  #print 'creating...'
+  os.makedirs(JOY_HOME, 0700)
+  #print 'initializing git repository...'
+  repo = Repo.init(JOY_HOME)
+
+else:  # path does exist
+  try:
+    repo = Repo(JOY_HOME)
+  except NotGitRepository:
+    #print 'initializing git repository...'
+    repo = Repo.init(JOY_HOME)
+  #else:
+    #print 'opened git repository.'
+
+
+def repo_relative_path(path):
+  return os.path.relpath(
+    path,
+    os.path.commonprefix((repo.controldir(), path))
+    )
+
+
+STACK_FN = os.path.join(JOY_HOME, 'stack.pickle')
+JOY_FN = os.path.join(JOY_HOME, 'scratch.txt')
+LOG_FN = os.path.join(JOY_HOME, 'log.txt')
+
+
+class StackDisplayWorld(World):
+
+  relative_STACK_FN = repo_relative_path(STACK_FN)
+
+  def interpret(self, command):
+    print '\njoy?', command
+    super(StackDisplayWorld, self).interpret(command)
+
+  def print_stack(self):
+    print '\n%s <-' % stack_to_string(self.stack)
+
+  def save(self):
+    with open(STACK_FN, 'wb') as f:
+      os.chmod(STACK_FN, 0600)
+      pickle.dump(self.stack, f)
+      f.flush()
+      os.fsync(f.fileno())
+    repo.stage([self.relative_STACK_FN])
+    commit_id = repo.do_commit(
+      'message',
+      committer='Simon Forman <forman.simon@gmail.com>',
+      )
+    #print >> sys.stderr, commit_id
+
+
+def init_text(t, title, filename):
+  t.winfo_toplevel().title(title)
+  if os.path.exists(filename):
+    with open(filename) as f:
+      data = f.read()
+    t.insert(tk.END, data)
+    # Prevent this from triggering a git commit.
+    t.update()
+    t._cancelSave()
+  t.pack(expand=True, fill=tk.BOTH)
+  t.filename = filename
+  t.repo_relative_filename = repo_relative_path(filename)
+  t.repo = repo
+  t['font'] = FONT  # See below.
+
+
+def key_bindings(*args):
+  print dedent('''
+    Ctrl-Enter - Run the selection as Joy code.
+    F1 - Reset and show (if hidden) the log.
+    Esc - Like F1 but also clears the stack.
+    F5 - Copy the selection to text on the stack.
+    Shift-F5 - As F5 but cuts the selection.
+    F6 - Paste as text from top of stack.
+    Shift-F6 - As F6 but pops the item.
+    F12 - print a list of all command words, or right-click "words".
+    ''')
+  return args
+
+
+def mouse_bindings(*args):
+  print dedent('''
+    Mouse button chords (to cancel a chord, click the third mouse button.)
+
+    Left - Point, sweep selection
+    Left-Middle - Copy the selection, place text on stack
+    Left-Right - Run the selection as Joy code
+
+    Middle - Paste selection (bypass stack); click and drag to scroll.
+    Middle-Left - Paste from top of stack, preserve
+    Middle-Right - Paste from top of stack, pop
+
+    Right - Execute command word under mouse cursor
+    Right-Left - Print docs of command word under mouse cursor
+    Right-Middle - Lookup word (kinda useless now)
+    ''')
+  return args
+
+
+def reset_log(*args):
+  log.delete('0.0', tk.END)
+  print __doc__
+  return args
+
+
+def show_log(*args):
+  log_window.wm_deiconify()
+  log_window.update()
+  return args
+
+
+def grand_reset(s, e, d):
+  stack = load_stack() or ()
+  reset_text(log, LOG_FN)
+  reset_text(t, JOY_FN)
+  return stack, e, d
+
+
+def reset_text(t, filename):
+  if os.path.exists(filename):
+    with open(filename) as f:
+      data = f.read()
+    if data:
+      t.delete('0.0', tk.END)
+      t.insert(tk.END, data)
+
+
+def load_stack():
+  if os.path.exists(STACK_FN):
+    with open(STACK_FN) as f:
+      return pickle.load(f)
+
+
+tb = TEXT_BINDINGS.copy()
+tb.update({
+  '<Shift-F5>': lambda tv: tv.cut,
+  '<F5>': lambda tv: tv.copy_selection_to_stack,
+  '<Shift-F6>': lambda tv: tv.pastecut,
+  '<F6>': lambda tv: tv.copyto,
+  })
+
+
+defaults = dict(text_bindings=tb, width=80, height=25)
+
+
+D = initialize()
+for func in (
+  reset_log,
+  show_log,
+  grand_reset,
+  key_bindings,
+  mouse_bindings,
+  ):
+  D[func.__name__] = func
+
+
+stack = load_stack()
+
+
+if stack is None:
+  w = StackDisplayWorld(dictionary=D)
+else:
+  w = StackDisplayWorld(stack=stack, dictionary=D)
+
+
+t = TextViewerWidget(w, **defaults)
+
+
+log_window = tk.Toplevel()
+log_window.protocol("WM_DELETE_WINDOW", log_window.withdraw)
+log = TextViewerWidget(w, log_window, **defaults)
+
+
+FONT = get_font('Iosevka', size=14)  # Requires Tk root already set up.
+
+
+init_text(log, 'Log', LOG_FN)
+init_text(t, 'Joy - ' + JOY_HOME, JOY_FN)
+
+
+GLOBAL_COMMANDS = {
+  '<F12>': 'words',
+  '<F1>': 'reset_log show_log',
+  '<Escape>': 'clear reset_log show_log',
+  }
+for event, command in GLOBAL_COMMANDS.items():
+  t.bind_all(event, lambda _, _command=command: w.interpret(_command))
+
+
+class FileFaker(object):
+
+  def __init__(self, T):
+    self.T = T
+
+  def write(self, text):
+    self.T.insert('end', text)
+    self.T.see('end')
+
+  def flush(self):
+    pass
+
+
+def main():
+  sys.stdout, old_stdout = FileFaker(log), sys.stdout
+  try:
+    t.mainloop()
+  finally:
+    sys.stdout = old_stdout
+  return 0
+
+
+if __name__ == '__main__':
+  main()
diff --git a/joy/gui/mousebindings.py b/joy/gui/mousebindings.py
new file mode 100644 (file)
index 0000000..baff8dd
--- /dev/null
@@ -0,0 +1,206 @@
+# -*- coding: utf-8 -*-
+#
+#    Copyright © 2014, 2015 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/>.
+#
+
+
+#Do-nothing event handler.
+nothing = lambda event: None
+
+
+class MouseBindingsMixin:
+  """TextViewerWidget mixin class to provide mouse bindings."""
+
+  def __init__(self):
+
+    #Remember our mouse button state
+    self.B1_DOWN = False
+    self.B2_DOWN = False
+    self.B3_DOWN = False
+
+    #Remember our pending action.
+    self.dothis = nothing
+
+    #We'll need to remember whether or not we've been moving B2.
+    self.beenMovingB2 = False
+
+    #Unbind the events we're interested in.
+    for sequence in (
+      "<Button-1>", "<B1-Motion>", "<ButtonRelease-1>",
+      "<Button-2>", "<B2-Motion>", "<ButtonRelease-2>",
+      "<Button-3>", "<B3-Motion>", "<ButtonRelease-3>",
+      "<B1-Leave>", "<B2-Leave>", "<B3-Leave>", "<Any-Leave>", "<Leave>"
+      ):
+      self.unbind(sequence)
+      self.unbind_all(sequence)
+
+    self.event_delete('<<PasteSelection>>') #I forgot what this was for! :-P  D'oh!
+
+    #Bind our event handlers to their events.
+    self.bind("<Button-1>", self.B1d)
+    self.bind("<B1-Motion>", self.B1m)
+    self.bind("<ButtonRelease-1>", self.B1r)
+
+    self.bind("<Button-2>", self.B2d)
+    self.bind("<B2-Motion>", self.B2m)
+    self.bind("<ButtonRelease-2>", self.B2r)
+
+    self.bind("<Button-3>", self.B3d)
+    self.bind("<B3-Motion>", self.B3m)
+    self.bind("<ButtonRelease-3>", self.B3r)
+
+    self.bind("<Any-Leave>", self.leave)
+
+  def B1d(self, event):
+    '''button one pressed'''
+    self.B1_DOWN = True
+
+    if self.B2_DOWN:
+
+      self.unhighlight_command()
+
+      if self.B3_DOWN :
+        self.dothis = self.cancel
+
+      else:
+        #copy TOS to the mouse (instead of system selection.)
+        self.dothis = self.copyto #middle-left-interclick
+
+    elif self.B3_DOWN :
+      self.unhighlight_command()
+      self.dothis = self.opendoc #right-left-interclick
+
+    else:
+      ##button 1 down, set insertion and begin selection.
+      ##Actually, do nothing. Tk Text widget defaults take care of it.
+      self.dothis = nothing
+      return
+
+    #Prevent further event handling by returning "break".
+    return "break"
+
+  def B2d(self, event):
+    '''button two pressed'''
+    self.B2_DOWN = 1
+
+    if self.B1_DOWN :
+
+      if self.B3_DOWN :
+        self.dothis = self.cancel
+
+      else:
+        #left-middle-interclick - cut selection to stack
+        self.dothis = self.cut
+
+    elif self.B3_DOWN :
+      self.unhighlight_command()
+      self.dothis = self.lookup #right-middle-interclick - lookup
+
+    else:
+      #middle-click - paste X selection to mouse pointer
+      self.set_insertion_point(event)
+      self.dothis = self.paste_X_selection_to_mouse_pointer
+      return
+
+    return "break"
+
+  def B3d(self, event):
+    '''button three pressed'''
+    self.B3_DOWN = 1
+
+    if self.B1_DOWN :
+
+      if self.B2_DOWN :
+        self.dothis = self.cancel
+
+      else:
+        #left-right-interclick - run selection
+        self.dothis = self.run_selection
+
+    elif self.B2_DOWN :
+      #middle-right-interclick - Pop/Cut from TOS to insertion cursor
+      self.unhighlight_command()
+      self.dothis = self.pastecut
+
+    else:
+      #right-click
+      self.CommandFirstDown(event)
+
+    return "break"
+
+  def B1m(self, event):
+    '''button one moved'''
+    if self.B2_DOWN or self.B3_DOWN:
+      return "break"
+
+  def B2m(self, event):
+    '''button two moved'''
+    if self.dothis == self.paste_X_selection_to_mouse_pointer and \
+       not (self.B1_DOWN or self.B3_DOWN):
+
+      self.beenMovingB2 = True
+      return
+
+    return "break"
+
+  def B3m(self, event):
+    '''button three moved'''
+    if self.dothis == self.do_command and \
+       not (self.B1_DOWN or self.B2_DOWN):
+
+      self.update_command_word(event)
+
+    return "break"
+
+  def B1r(self, event):
+    '''button one released'''
+    self.B1_DOWN = False
+
+    if not (self.B2_DOWN or self.B3_DOWN):
+      self.dothis(event)
+
+    return "break"
+
+  def B2r(self, event):
+    '''button two released'''
+    self.B2_DOWN = False
+
+    if not (self.B1_DOWN or self.B3_DOWN or self.beenMovingB2):
+      self.dothis(event)
+
+    self.beenMovingB2 = False
+
+    return "break"
+
+  def B3r(self, event):
+    '''button three released'''
+    self.B3_DOWN = False
+
+    if not (self.B1_DOWN or self.B2_DOWN) :
+      self.dothis(event)
+
+    return "break"
+
+  def InsertFirstDown(self, event):
+    self.focus()
+    self.dothis = nothing
+    self.set_insertion_point(event)
+
+  def CommandFirstDown(self, event):
+    self.dothis = self.do_command
+    self.update_command_word(event)
diff --git a/joy/gui/textwidget.py b/joy/gui/textwidget.py
new file mode 100644 (file)
index 0000000..0633dc0
--- /dev/null
@@ -0,0 +1,422 @@
+# -*- 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
+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 .world import World, is_numerical
+
+
+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-v>': lambda tv: tv._paste,
+  '<Control-V>': lambda tv: tv._paste,
+  '<Shift-Insert>': lambda tv: tv._paste,
+  '<Control-Return>': lambda tv: tv._control_enter,
+  }
+
+
+class SavingMixin:
+
+  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, 'w') as f:
+      os.chmod(self.filename, 0600)
+      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 = "1",
+    foreground = "orange"
+  )
+
+  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)
+
+    #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 self.world.has(cmd) or is_numerical(cmd):
+      self.command = cmd
+      self.highlight_command(
+        '%d.%d' % (row, b),
+        '%d.%d' % (row, e),
+        )
+
+  def highlight_command(self, from_, to):
+    '''Apply command style from from_ to to.'''
+    cmdstart = self.index(from_)
+    cmdend = self.index(to)
+    self.tag_add('command', 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('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 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)
diff --git a/joy/gui/world.py b/joy/gui/world.py
new file mode 100644 (file)
index 0000000..a29875d
--- /dev/null
@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+#
+#    Copyright © 2014, 2015 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/>.
+#
+from inspect import getdoc
+
+from joy.joy import run
+from joy.utils.stack import stack_to_string
+
+
+def is_numerical(s):
+  try:
+    float(s)
+  except ValueError:
+    return False
+  return True
+
+
+class World(object):
+
+  def __init__(self, stack=(), dictionary=None, text_widget=None):
+    self.stack = stack
+    self.dictionary = dictionary or {}
+    self.text_widget = text_widget
+
+  def do_lookup(self, name):
+    word = self.dictionary[name]
+    self.stack = word, self.stack
+    self.print_stack()
+
+  def do_opendoc(self, name):
+    if is_numerical(name):
+      print 'The number', name
+    else:
+      try:
+        word = self.dictionary[name]
+      except KeyError:
+        print repr(name), '???'
+      else:
+        print getdoc(word)
+    self.text_widget.see('end')
+
+  def pop(self):
+    if self.stack:
+      self.stack = self.stack[1]
+    self.print_stack()
+
+  def push(self, it):
+    it = it.encode('utf8')
+    self.stack = it, self.stack
+    self.print_stack()
+
+  def peek(self):
+    if self.stack:
+      return self.stack[0]
+
+  def interpret(self, command):
+    self.stack, _, self.dictionary = run(
+      command,
+      self.stack,
+      self.dictionary,
+      )
+    self.print_stack()
+
+  def has(self, name):
+    return self.dictionary.has_key(name)
+
+  def save(self):
+    pass
+
+  def print_stack(self):
+    stack_out_index = self.text_widget.search('<' 'STACK', 1.0)
+    if stack_out_index:
+      self.text_widget.see(stack_out_index)
+      s = stack_to_string(self.stack) + '\n'
+      self.text_widget.insert(stack_out_index, s)
index 9b9d4d7..4ca08b1 100755 (executable)
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,7 @@ setup(
   author_email='forman.simon@gmail.com',
   url='https://joypy.osdn.io',
   license='GPLv3+',
-  packages=['joy', 'joy.utils'],
+  packages=['joy', 'joy.utils', 'joy.gui'],
   classifiers=[
     'Development Status :: 4 - Beta',
     'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',