# The MIT License # # Copyright (c) 2009-2010 the bpython authors. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # # This module is called "bpython.gtk_" to avoid name clashes with the # "original" gtk module. I first had used an absolute_import import from # the future to avoid that, but people are stupid and add the package path # to sys.path. from __future__ import with_statement import inspect import optparse import os import sys from locale import LC_ALL, getpreferredencoding, setlocale from xmlrpclib import ServerProxy, Error as XMLRPCError from string import Template from urllib import quote as urlquote import gobject import gtk import pango from bpython import importcompletion, repl from bpython.formatter import theme_map import bpython.args py3 = sys.version_info[0] == 3 _COLORS = dict(b='blue', c='cyan', g='green', m='magenta', r='red', w='white', y='yellow', k='black', d='black') class ArgspecFormatter(object): """ Format an argspec using Pango markup language. """ def format(self, args, varargs, varkw, defaults, in_arg): self.args_seen = 0 self.in_arg = in_arg return inspect.formatargspec(args, varargs, varkw, defaults, self.formatarg, formatvalue=self.formatvalue) def formatarg(self, name): if name == self.in_arg or self.args_seen == self.in_arg: string = '%s' % (name, ) else: string = name self.args_seen += 1 return string def formatvalue(self, value): return '=%s' % (value, ) class ExceptionDialog(gtk.MessageDialog): def __init__(self, exc_type, exc_value, tb, text='An error occurred.'): gtk.MessageDialog.__init__(self, buttons=gtk.BUTTONS_CLOSE, type=gtk.MESSAGE_ERROR, message_format=text) self.set_resizable(True) import cgitb text = cgitb.text((exc_type, exc_value, tb), 5) expander = gtk.Expander('Exception details') self.vbox.pack_start(expander) textview = gtk.TextView() textview.get_buffer().set_text(text) scrolled_window = gtk.ScrolledWindow() scrolled_window.add(textview) expander.add(scrolled_window) self.show_all() class ExceptionManager(object): """ A context manager which runs the dialog `DialogType` on error, with the exception's type, value, a traceback and a text to display as arguments. """ def __init__(self, DialogType, text='An error occurred.'): self.DialogType = DialogType self.text = text def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): if not (exc_value is None or issubclass(exc_type, (KeyboardInterrupt, SystemExit))): dialog = self.DialogType(exc_type, exc_value, traceback, self.text) dialog.run() dialog.destroy() class MenuBar(gtk.MenuBar): def __init__(self): gtk.MenuBar.__init__(self) class Nested(object): """ A helper class, inspired by a semaphore. """ def __init__(self): self.counter = 0 def __enter__(self): self.counter += 1 return self def __exit__(self, exc_type, exc_value, exc_tb): self.counter -= 1 def __nonzero__(self): return bool(self.counter) class Statusbar(gtk.Statusbar): """Contains feedback messages""" def __init__(self): gtk.Statusbar.__init__(self) self.context_id = self.get_context_id('Statusbar') def message(self, s, n=3): self.clear() self.push(self.context_id, s) gobject.timeout_add(n*1000, self.clear) def clear(self): self.pop(self.context_id) # To stop the timeout from firing again return False class SuggestionWindow(gtk.Window): """ The window where suggestions are displayed. """ __gsignals__ = dict(expose_event=None, selection_changed=(gobject.SIGNAL_RUN_LAST, None, (str, ))) def __init__(self): gtk.Window.__init__(self, gtk.WINDOW_POPUP) self.set_app_paintable(True) self.set_border_width(4) self.set_decorated(False) self.set_name('gtk-tooltips') self.argspec_formatter = ArgspecFormatter() vbox = gtk.VBox(homogeneous=False) vbox.set_style(self.get_style()) self.argspec_label = gtk.Label() self.argspec_label.set_alignment(0, 0) self.argspec_label.set_line_wrap(True) self.argspec_label.set_use_markup(True) vbox.pack_start(self.argspec_label, expand=False) self.docstring_label = gtk.Label() self.docstring_label.set_alignment(0, 0) style = self.docstring_label.get_style() #color = _COLORS[self.config.color_scheme['comment'].lower()] #color = gtk.gdk.color_parse(color) #style.fg[gtk.STATE_NORMAL] = color self.docstring_label.set_style(style) vbox.pack_start(self.docstring_label, expand=False) self.model = gtk.ListStore(str, str) self.view = gtk.TreeView(self.model) self.view.set_headers_visible(False) self.view.set_style(self.get_style()) column = gtk.TreeViewColumn(None, gtk.CellRendererText(), text=0, background=1) self.view.append_column(column) self.view.get_selection().connect('changed', self.on_selection_changed) sw = gtk.ScrolledWindow() sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) sw.add(self.view) vbox.pack_start(sw) self.add(vbox) self.resize(300, 150) self.show_all() def back(self): self.select(-1) def do_expose_event(self, event): """ Draw a flat box around the popup window on expose event. """ width, height = self.get_size() self.style.paint_flat_box(self.window, gtk.STATE_NORMAL, gtk.SHADOW_OUT, None, self, 'tooltip', 0, 0, width, height) gtk.Window.do_expose_event(self, event) def forward(self): self.select(1) def on_selection_changed(self, selection): model, iter_ = selection.get_selected() if iter_ is not None: value = model.get_value(iter_, 0) self.emit('selection-changed', value) def select(self, offset): """ Select the suggestions at offset `offset`. """ selection = self.view.get_selection() model, iter_ = selection.get_selected() if iter_ is not None: row = model.get_path(iter_)[0] row += offset else: row = 0 if row < 0: row = len(model) - 1 elif row >= len(model): row = 0 iter_ = model.get_iter(row) selection.select_iter(iter_) self.view.scroll_to_cell(row) def update_argspec(self, argspec): if argspec: func_name, args, is_bound_method, in_arg = argspec[:4] args, varargs, varkw, defaults = args[:4] if is_bound_method and isinstance(in_arg, int): in_arg += 1 argspec = self.argspec_formatter.format(args, varargs, varkw, defaults, in_arg) markup = '%s%s' % (func_name, argspec) self.argspec_label.set_markup(markup) self.argspec_label.set_property('visible', bool(argspec)) def update_docstring(self, docstring): self.docstring_label.set_text(docstring) self.docstring_label.set_property('visible', bool(docstring)) def update_matches(self, matches): self.model.clear() bg = self.get_style().bg[gtk.STATE_NORMAL] for match in matches: self.model.append([match, bg.to_string()]) self.view.set_property('visible', bool(matches)) class GTKInteraction(repl.Interaction): def __init__(self, config, statusbar): repl.Interaction.__init__(self, config, statusbar) def confirm(self, q): dialog = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, gtk.BUTTONS_YES_NO, q) response = dialog.run() dialog.destroy() return response == gtk.RESPONSE_YES def file_prompt(self, s): chooser = gtk.FileChooserDialog(title="File to save to", action=gtk.FILE_CHOOSER_ACTION_SAVE, buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_OPEN, gtk.RESPONSE_OK)) chooser.set_default_response(gtk.RESPONSE_OK) chooser.set_current_name('test.py') chooser.set_current_folder(os.path.expanduser('~')) pyfilter = gtk.FileFilter() pyfilter.set_name("Python files") pyfilter.add_pattern("*.py") chooser.add_filter(pyfilter) allfilter = gtk.FileFilter() allfilter.set_name("All files") allfilter.add_pattern("*") chooser.add_filter(allfilter) response = chooser.run() if response == gtk.RESPONSE_OK: fn = chooser.get_filename() else: fn = False chooser.destroy() return fn def notify(self, s, n=10): self.statusbar.message(s) class ReplWidget(gtk.TextView, repl.Repl): __gsignals__ = dict(button_press_event=None, focus_in_event=None, focus_out_event=None, realize=None, exit_event=(gobject.SIGNAL_RUN_LAST, None, ())) def __init__(self, interpreter, config): gtk.TextView.__init__(self) repl.Repl.__init__(self, interpreter, config) self.interp.writetb = self.writetb self.editing = Nested() self.reset_indent = False self.modify_font(pango.FontDescription(self.config.gtk_font)) self.set_wrap_mode(gtk.WRAP_CHAR) self.list_win = SuggestionWindow() self.list_win.connect('selection-changed', self.on_suggestion_selection_changed) self.list_win.hide() self.modify_base('normal', gtk.gdk.color_parse(_COLORS[self.config.color_gtk_scheme['background']])) self.text_buffer = self.get_buffer() self.interact = GTKInteraction(self.config, Statusbar()) tags = dict() for (name, value) in self.config.color_gtk_scheme.iteritems(): tag = tags[name] = self.text_buffer.create_tag(name) for (char, prop) in zip(value, ['foreground', 'background']): if char.lower() == 'd': continue tag.set_property(prop, _COLORS[char.lower()]) if char.isupper(): tag.set_property('weight', pango.WEIGHT_BOLD) tags['prompt'].set_property('editable', False) self.text_buffer.connect('delete-range', self.on_buf_delete_range) self.text_buffer.connect('insert-text', self.on_buf_insert_text) self.text_buffer.connect('mark-set', self.on_buf_mark_set) def change_line(self, line): """ Replace the current input line with `line`. """ with self.editing: self.text_buffer.delete(self.get_line_start_iter(), self.get_line_end_iter()) if self.config.syntax: self.insert_highlighted(self.get_line_start_iter(), line) else: self.text_buffer.insert(self.get_line_start_iter(), line) def clear_current_line(self): """ Called when a SyntaxError occurs. """ repl.Repl.clear_current_line(self) self.reset_indent = True def complete(self): if self.config.auto_display_list: self.list_win_visible = repl.Repl.complete(self) if self.list_win_visible: self.list_win.update_argspec(self.argspec) self.list_win.update_docstring(self.docstring or '') self.list_win.update_matches(self.matches) iter_rect = self.get_iter_location(self.get_cursor_iter()) x, y = self.window.get_origin() _, height = self.get_line_yrange(self.get_cursor_iter()) self.list_win.move(x + iter_rect.x, y + iter_rect.y + height) self.list_win.show() else: self.list_win.hide() @property def cpos(self): cpos = (self.get_line_end_iter().get_offset() - self.get_cursor_iter().get_offset()) if cpos and not self.get_overwrite(): cpos += 1 return cpos def cw(self): """ Return the current word. """ return self.text_buffer.get_text(self.get_word_start_iter(), self.get_cursor_iter()) def current_line(self): """ Return the current input line. """ return self.text_buffer.get_slice(self.get_line_start_iter(), self.get_line_end_iter()) def echo(self, string): with self.editing: self.text_buffer.insert_with_tags_by_name(self.get_cursor_iter(), string, 'output') self.move_cursor(len(string)) def get_cursor_iter(self): """ Return an iter where the cursor currently is. """ cursor_marker = self.text_buffer.get_insert() return self.text_buffer.get_iter_at_mark(cursor_marker) def get_line_start_iter(self): """ Return an iter where the current line starts. """ line_start_marker = self.text_buffer.get_mark('line_start') if line_start_marker is None: return self.text_buffer.get_start_iter() return self.text_buffer.get_iter_at_mark(line_start_marker) def get_line_end_iter(self): """ Return an iter where the current line ends. """ iter_ = self.get_line_start_iter() if not iter_.ends_line() and not iter_.forward_to_line_end(): iter_ = self.text_buffer.get_end_iter() return iter_ def get_word_start_iter(self): iter_ = self.get_cursor_iter() pred = lambda char, _: not (char.isalnum() or char in '_.') if iter_.backward_find_char(pred, None, self.get_line_start_iter()): iter_.forward_char() return iter_ def do_button_press_event(self, event): if self.list_win_visible: self.list_win.hide() return gtk.TextView.do_button_press_event(self, event) def do_focus_in_event(self, event): if self.list_win_visible: self.list_win.show() return gtk.TextView.do_focus_in_event(self, event) def do_focus_out_event(self, event): if self.list_win_visible: self.list_win.hide() return gtk.TextView.do_focus_out_event(self, event) def do_key_press_event(self, event): state = event.state & (gtk.gdk.CONTROL_MASK | gtk.gdk.MOD1_MASK | gtk.gdk.MOD4_MASK | gtk.gdk.SHIFT_MASK) if not state: if event.keyval == gtk.keysyms.Return: if self.list_win_visible: self.list_win_visible = False self.list_win.hide() self.rl_history.reset() line = self.current_line() more = self.push_line() self.prompt(more) if self.reset_indent: self.reset_indent = False else: indentation = self.next_indentation() if indentation: with self.editing: self.text_buffer.insert(self.get_cursor_iter(), '\t' * indentation) self.move_cursor(indentation) return True elif event.keyval == gtk.keysyms.Tab and self.list_win_visible: self.list_win.forward() return True elif event.keyval == gtk.keysyms.Up: if self.list_win_visible: self.list_win.back() else: if not self.rl_history.is_at_end(): self.rl_history.enter(self.current_line()) self.change_line(self.rl_history.back()) self.text_buffer.place_cursor(self.get_line_end_iter()) return True elif event.keyval == gtk.keysyms.Down: if self.list_win_visible: self.list_win.forward() else: if not self.rl_history.is_at_start(): self.rl_history.enter(self.current_line()) self.change_line(self.rl_history.forward()) self.text_buffer.place_cursor(self.get_line_end_iter()) return True elif state & gtk.gdk.SHIFT_MASK: if (event.keyval == gtk.keysyms.ISO_Left_Tab and self.list_win_visible): self.list_win.back() return True elif state & gtk.gdk.CONTROL_MASK: if event.string == chr(4) and not self.current_line(): self.emit('exit-event') return True return gtk.TextView.do_key_press_event(self, event) def do_realize(self): gtk.TextView.do_realize(self) self.prompt(False) def highlight(self, start_iter, tokens): """ Highlight the text starting at `start_iter` using `tokens`. """ token_start_iter = start_iter.copy() token_end_iter = start_iter.copy() for (token, value) in tokens: while token not in theme_map: token = token.parent token_end_iter.forward_chars(len(value)) self.text_buffer.apply_tag_by_name(theme_map[token], token_start_iter, token_end_iter) token_start_iter.forward_chars(len(value)) def highlight_current_line(self): """ Highlight the current line. """ if self.config.syntax: if self.highlighted_paren is not None: self.reprint_line(*self.highlighted_paren) self.highlighted_paren = None start = self.get_line_start_iter() self.text_buffer.remove_all_tags(start, self.get_line_end_iter()) self.highlight(start, self.tokenize(self.current_line())) def insert_highlighted(self, iter_, string): offset = iter_.get_offset() newline = iter_.forward_to_line_end() # self.tokenize() may call self.reprint_line(), which will # invalidate iters. tokens = self.tokenize(string, newline) iter_ = self.text_buffer.get_iter_at_offset(offset) self.insert_highlighted_tokens(iter_, tokens) def insert_highlighted_tokens(self, iter_, tokens): offset = iter_.get_offset() buffer = self.text_buffer for (token, value) in tokens: while token not in theme_map: token = token.parent iter_ = buffer.get_iter_at_offset(offset) with self.editing: buffer.insert_with_tags_by_name(iter_, value, theme_map[token]) offset += len(value) def move_cursor(self, offset): """ Move the cursor to a given offset. """ iter_ = self.get_cursor_iter() iter_.forward_chars(offset) self.text_buffer.place_cursor(iter_) return iter_ def on_buf_delete_range(self, buffer, start, end): if self.editing: return buffer.emit_stop_by_name('delete-range') # Only allow editing of the current line and not of previous ones line_start = self.get_line_start_iter() if end.compare(line_start) < 0: return elif start.compare(line_start) < 0: start = line_start with self.editing: buffer.delete(start, end) self.highlight_current_line() self.complete() def on_buf_insert_text(self, buffer, iter_, text, length): if self.editing: return self.set_cursor_to_valid_insert_position() buffer.emit_stop_by_name('insert-text') for (i, line) in enumerate(text.splitlines()): if i: self.prompt(self.push_line()) with self.editing: buffer.insert_at_cursor(line) self.highlight_current_line() self.complete() def on_buf_mark_set(self, buffer, iter_, textmark): if (textmark.get_name() == 'insert' and self.get_line_start_iter().compare(iter_) < 0): self.highlight_current_line() def on_suggestion_selection_changed(self, selection, word): with self.editing: self.text_buffer.delete(self.get_word_start_iter(), self.get_cursor_iter()) self.text_buffer.insert_at_cursor(word) def do_paste(self, widget): clipboard = gtk.clipboard_get() paste_url = self.pastebin() if paste_url: clipboard.set_text(paste_url) clipboard.store() def do_write2file(self, widget): self.write2file() def do_partial_paste(self, widget): bounds = self.text_buffer.get_selection_bounds() if bounds == (): # FIXME show a nice status bar message pass else: self.pastebin(self.text_buffer.get_text(bounds[0], bounds[1])) def write(self, s): """For overriding stdout defaults""" if '\x04' in s: for block in s.split('\x04'): self.write(block) return if s.rstrip() and '\x03' in s: t = s.split('\x03')[1] else: t = s if not py3 and isinstance(t, unicode): t = t.encode(getpreferredencoding()) self.echo(s) self.s_hist.append(s.rstrip()) def prompt(self, more): """ Show the appropriate Python prompt. """ if more: text = '... ' else: text = '>>> ' with self.editing: iter_ = self.get_cursor_iter() self.text_buffer.insert_with_tags_by_name(iter_, text, 'prompt') iter_.forward_chars(4) mark = self.text_buffer.create_mark('line_start', iter_, True) self.text_buffer.place_cursor(iter_) self.scroll_to_mark(mark, 0.2) def push_line(self): line = self.current_line() # Save mark for easy referencing later self.text_buffer.create_mark('line%i_start' % (len(self.buffer), ), self.get_line_start_iter(), True) self.rl_history.append(line) iter_ = self.get_line_end_iter() self.text_buffer.place_cursor(iter_) with self.editing: self.text_buffer.insert(iter_, '\n') self.move_cursor(1) self.highlight_current_line() try: return self.push(line + '\n') except SystemExit: self.emit('exit-event') return False def reprint_line(self, lineno, tokens): """ Helper function for paren highlighting: Reprint line at offset `lineno` in current input buffer. """ if not self.buffer or lineno == len(self.buffer): return mark = self.text_buffer.get_mark('line%i_start' % (lineno, )) start = self.text_buffer.get_iter_at_mark(mark) end = start.copy() end.forward_to_line_end() self.text_buffer.remove_all_tags(start, end) self.highlight(start, tokens) def set_cursor_to_valid_insert_position(self): cursor_iter = self.get_cursor_iter() line_start_iter = self.get_line_start_iter() if line_start_iter.compare(cursor_iter) > 0: self.text_buffer.place_cursor(line_start_iter) def getstdout(self): bounds = self.text_buffer.get_bounds() text = self.text_buffer.get_text(bounds[0], bounds[1]) return text def writetb(self, lines): with ExceptionManager(ExceptionDialog, 'An error occured while trying to display ' 'an error. Please contact the bpython ' 'developers.'): string = ''.join(lines) with self.editing: self.text_buffer.insert_with_tags_by_name( self.get_cursor_iter(), string, 'error' ) self.move_cursor(len(string)) def init_import_completion(): try: importcompletion.find_iterator.next() except StopIteration: return False else: return True def main(args=None): setlocale(LC_ALL, '') gtk_options = ('gtk-specific options', "Options specific to bpython's Gtk+ front end", [optparse.Option('--socket-id', dest='socket_id', type='int', help='Embed bpython')]) config, options, exec_args = bpython.args.parse(args, gtk_options, True) interpreter = repl.Interpreter(None, getpreferredencoding()) repl_widget = ReplWidget(interpreter, config) repl_widget.connect('exit-event', gtk.main_quit) gobject.idle_add(init_import_completion) if not exec_args: sys.path.insert(0, '') gobject.idle_add(repl_widget.startup) else: if options.interactive: gobject.idle_add(bpython.args.exec_code, interpreter, exec_args) else: bpython.args.exec_code(interpreter, exec_args) return 0 # sys.stderr = repl_widget sys.stdout = repl_widget if not options.socket_id: parent = gtk.Window() parent.connect('delete-event', lambda widget, event: gtk.main_quit()) # branding # fix icon to be distributed and loaded from the correct path icon = gtk.gdk.pixbuf_new_from_file(os.path.join(os.path.dirname(__file__), 'logo.png')) parent.set_title('bpython') parent.set_icon(icon) parent.resize(600, 300) else: parent = gtk.Plug(options.socket_id) parent.connect('destroy', gtk.main_quit) container = gtk.VBox() parent.add(container) mb = gtk.MenuBar() filemenu = gtk.Menu() filem = gtk.MenuItem("File") filem.set_submenu(filemenu) save = gtk.ImageMenuItem(gtk.STOCK_SAVE) save.connect("activate", repl_widget.do_write2file) filemenu.append(save) pastebin = gtk.MenuItem("Pastebin") pastebin.connect("activate", repl_widget.do_paste) filemenu.append(pastebin) pastebin_partial = gtk.MenuItem("Pastebin selection") pastebin_partial.connect("activate", repl_widget.do_partial_paste) filemenu.append(pastebin_partial) exit = gtk.ImageMenuItem(gtk.STOCK_QUIT) exit.connect("activate", gtk.main_quit) filemenu.append(exit) mb.append(filem) vbox = gtk.VBox(False, 2) vbox.pack_start(mb, False, False, 0) container.pack_start(vbox, expand=False) # read from config sw = gtk.ScrolledWindow() sw.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) sw.add(repl_widget) container.add(sw) sb = repl_widget.interact.statusbar container.pack_end(sb, expand=False) parent.show_all() parent.connect('delete-event', lambda widget, event: gtk.main_quit()) try: gtk.main() except KeyboardInterrupt: pass finally: if config.hist_length: histfilename = os.path.expanduser(config.hist_file) repl_widget.rl_history.save(histfilename, getpreferredencoding()) return 0 if __name__ == '__main__': from bpython.gtk_ import main main()