cocoa_frontend.py
560 lines
| 18.1 KiB
| text/x-python
|
PythonLexer
|
r1263 | # encoding: utf-8 | ||
|
r1291 | # -*- test-case-name: IPython.frontend.cocoa.tests.test_cocoa_frontend -*- | ||
|
r1263 | |||
|
r4872 | """PyObjC classes to provide a Cocoa frontend to the | ||
|
r1292 | IPython.kernel.engineservice.IEngineBase. | ||
|
r1263 | |||
|
r4872 | To add an IPython interpreter to a cocoa app, instantiate an | ||
|
r1291 | IPythonCocoaController in a XIB and connect its textView outlet to an | ||
NSTextView instance in your UI. That's it. | ||||
|
r1263 | |||
|
r1291 | Author: Barry Wark | ||
|
r1263 | """ | ||
__docformat__ = "restructuredtext en" | ||||
|
r1291 | #----------------------------------------------------------------------------- | ||
# Copyright (C) 2008 The IPython Development Team | ||||
|
r1263 | # | ||
# Distributed under the terms of the BSD License. The full license is in | ||||
# the file COPYING, distributed as part of this software. | ||||
|
r1291 | #----------------------------------------------------------------------------- | ||
|
r1263 | |||
|
r1291 | #----------------------------------------------------------------------------- | ||
|
r1263 | # Imports | ||
|
r1291 | #----------------------------------------------------------------------------- | ||
|
r1263 | |||
|
r1304 | import sys | ||
|
r1263 | import objc | ||
|
r1710 | from IPython.external import guid | ||
|
r1263 | |||
from Foundation import NSObject, NSMutableArray, NSMutableDictionary,\ | ||||
NSLog, NSNotificationCenter, NSMakeRange,\ | ||||
|
r1303 | NSLocalizedString, NSIntersectionRange,\ | ||
NSString, NSAutoreleasePool | ||||
|
r4872 | |||
|
r1263 | from AppKit import NSApplicationWillTerminateNotification, NSBeep,\ | ||
NSTextView, NSRulerView, NSVerticalRuler | ||||
from pprint import saferepr | ||||
|
r1278 | import IPython | ||
|
r1292 | from IPython.kernel.engineservice import ThreadedEngineService | ||
|
r1355 | from IPython.frontend.asyncfrontendbase import AsyncFrontEndBase | ||
|
r1263 | |||
from twisted.internet.threads import blockingCallFromThread | ||||
|
r1283 | from twisted.python.failure import Failure | ||
|
r1263 | |||
|
r1321 | #----------------------------------------------------------------------------- | ||
|
r1263 | # Classes to implement the Cocoa frontend | ||
|
r1321 | #----------------------------------------------------------------------------- | ||
|
r1263 | |||
|
r4872 | # TODO: | ||
# 1. use MultiEngineClient and out-of-process engine rather than | ||||
|
r1291 | # ThreadedEngineService? | ||
|
r1263 | # 2. integrate Xgrid launching of engines | ||
|
r4872 | |||
|
r1303 | class AutoreleasePoolWrappedThreadedEngineService(ThreadedEngineService): | ||
"""Wrap all blocks in an NSAutoreleasePool""" | ||||
|
r4872 | |||
|
r1304 | def wrapped_execute(self, msg, lines): | ||
|
r1303 | """wrapped_execute""" | ||
|
r1304 | try: | ||
p = NSAutoreleasePool.alloc().init() | ||||
|
r1321 | result = super(AutoreleasePoolWrappedThreadedEngineService, | ||
self).wrapped_execute(msg, lines) | ||||
|
r1304 | finally: | ||
p.drain() | ||||
|
r4872 | |||
|
r1303 | return result | ||
|
r4872 | |||
|
r1263 | |||
|
r1323 | class Cell(NSObject): | ||
""" | ||||
Representation of the prompts, input and output of a cell in the | ||||
frontend | ||||
""" | ||||
|
r4872 | |||
|
r1323 | blockNumber = objc.ivar().unsigned_long() | ||
blockID = objc.ivar() | ||||
inputBlock = objc.ivar() | ||||
output = objc.ivar() | ||||
|
r4872 | |||
|
r1322 | class CellBlock(object): | ||
""" | ||||
Storage for information about text ranges relating to a single cell | ||||
""" | ||||
|
r4872 | |||
|
r1322 | def __init__(self, inputPromptRange, inputRange=None, outputPromptRange=None, | ||
outputRange=None): | ||||
super(CellBlock, self).__init__() | ||||
self.inputPromptRange = inputPromptRange | ||||
self.inputRange = inputRange | ||||
self.outputPromptRange = outputPromptRange | ||||
self.outputRange = outputRange | ||||
|
r4872 | |||
|
r1322 | def update_ranges_for_insertion(self, text, textRange): | ||
"""Update ranges for text insertion at textRange""" | ||||
|
r4872 | |||
|
r1322 | for r in [self.inputPromptRange,self.inputRange, | ||
self.outputPromptRange, self.outputRange]: | ||||
if(r == None): | ||||
continue | ||||
intersection = NSIntersectionRange(r,textRange) | ||||
if(intersection.length == 0): #ranges don't intersect | ||||
if r.location >= textRange.location: | ||||
r.location += len(text) | ||||
else: #ranges intersect | ||||
if(r.location > textRange.location): | ||||
offset = len(text) - intersection.length | ||||
r.length -= offset | ||||
r.location += offset | ||||
elif(r.location == textRange.location): | ||||
r.length += len(text) - intersection.length | ||||
else: | ||||
r.length -= intersection.length | ||||
|
r4872 | |||
|
r1322 | def update_ranges_for_deletion(self, textRange): | ||
"""Update ranges for text deletion at textRange""" | ||||
|
r4872 | |||
|
r1322 | for r in [self.inputPromptRange,self.inputRange, | ||
self.outputPromptRange, self.outputRange]: | ||||
if(r==None): | ||||
continue | ||||
intersection = NSIntersectionRange(r, textRange) | ||||
if(intersection.length == 0): #ranges don't intersect | ||||
if r.location >= textRange.location: | ||||
r.location -= textRange.length | ||||
else: #ranges intersect | ||||
if(r.location > textRange.location): | ||||
offset = intersection.length | ||||
r.length -= offset | ||||
r.location += offset | ||||
elif(r.location == textRange.location): | ||||
r.length += intersection.length | ||||
else: | ||||
r.length -= intersection.length | ||||
|
r4872 | |||
|
r1322 | def __repr__(self): | ||
return 'CellBlock('+ str((self.inputPromptRange, | ||||
self.inputRange, | ||||
self.outputPromptRange, | ||||
self.outputRange)) + ')' | ||||
|
r1263 | |||
|
r4872 | |||
|
r1315 | class IPythonCocoaController(NSObject, AsyncFrontEndBase): | ||
|
r1263 | userNS = objc.ivar() #mirror of engine.user_ns (key=>str(value)) | ||
waitingForEngine = objc.ivar().bool() | ||||
textView = objc.IBOutlet() | ||||
|
r4872 | |||
|
r1263 | def init(self): | ||
self = super(IPythonCocoaController, self).init() | ||||
|
r1315 | AsyncFrontEndBase.__init__(self, | ||
|
r1303 | engine=AutoreleasePoolWrappedThreadedEngineService()) | ||
|
r1263 | if(self != None): | ||
self._common_init() | ||||
|
r4872 | |||
|
r1263 | return self | ||
|
r4872 | |||
|
r1263 | def _common_init(self): | ||
"""_common_init""" | ||||
|
r4872 | |||
|
r1263 | self.userNS = NSMutableDictionary.dictionary() | ||
self.waitingForEngine = False | ||||
|
r4872 | |||
|
r1263 | self.lines = {} | ||
self.tabSpaces = 4 | ||||
self.tabUsesSpaces = True | ||||
|
r1292 | self.currentBlockID = self.next_block_ID() | ||
|
r1322 | self.blockRanges = {} # blockID=>CellBlock | ||
|
r4872 | |||
|
r1263 | def awakeFromNib(self): | ||
"""awakeFromNib""" | ||||
|
r4872 | |||
|
r1263 | self._common_init() | ||
|
r4872 | |||
|
r1263 | # Start the IPython engine | ||
self.engine.startService() | ||||
NSLog('IPython engine started') | ||||
|
r4872 | |||
|
r1263 | # Register for app termination | ||
|
r1292 | nc = NSNotificationCenter.defaultCenter() | ||
nc.addObserver_selector_name_object_( | ||||
self, | ||||
'appWillTerminate:', | ||||
NSApplicationWillTerminateNotification, | ||||
None) | ||||
|
r4872 | |||
|
r1263 | self.textView.setDelegate_(self) | ||
self.textView.enclosingScrollView().setHasVerticalRuler_(True) | ||||
|
r1292 | r = NSRulerView.alloc().initWithScrollView_orientation_( | ||
self.textView.enclosingScrollView(), | ||||
NSVerticalRuler) | ||||
self.verticalRulerView = r | ||||
|
r1263 | self.verticalRulerView.setClientView_(self.textView) | ||
|
r1292 | self._start_cli_banner() | ||
|
r1322 | self.start_new_block() | ||
|
r4872 | |||
|
r1263 | def appWillTerminate_(self, notification): | ||
"""appWillTerminate""" | ||||
|
r4872 | |||
|
r1263 | self.engine.stopService() | ||
|
r4872 | |||
|
r1263 | def complete(self, token): | ||
"""Complete token in engine's user_ns | ||||
|
r4872 | |||
|
r1263 | Parameters | ||
---------- | ||||
token : string | ||||
|
r4872 | |||
|
r1263 | Result | ||
------ | ||||
|
r4872 | Deferred result of | ||
|
r1292 | IPython.kernel.engineservice.IEngineBase.complete | ||
|
r1263 | """ | ||
|
r4872 | |||
|
r1263 | return self.engine.complete(token) | ||
|
r4872 | |||
|
r1263 | def execute(self, block, blockID=None): | ||
self.waitingForEngine = True | ||||
self.willChangeValueForKey_('commandHistory') | ||||
|
r1303 | d = super(IPythonCocoaController, self).execute(block, | ||
blockID) | ||||
|
r1292 | d.addBoth(self._engine_done) | ||
d.addCallback(self._update_user_ns) | ||||
|
r4872 | |||
|
r1263 | return d | ||
|
r4872 | |||
|
r1303 | def push_(self, namespace): | ||
"""Push dictionary of key=>values to python namespace""" | ||||
|
r4872 | |||
|
r1303 | self.waitingForEngine = True | ||
self.willChangeValueForKey_('commandHistory') | ||||
d = self.engine.push(namespace) | ||||
d.addBoth(self._engine_done) | ||||
d.addCallback(self._update_user_ns) | ||||
|
r4872 | |||
|
r1303 | def pull_(self, keys): | ||
"""Pull keys from python namespace""" | ||||
|
r4872 | |||
|
r1303 | self.waitingForEngine = True | ||
result = blockingCallFromThread(self.engine.pull, keys) | ||||
self.waitingForEngine = False | ||||
|
r4872 | |||
|
r1316 | @objc.signature('v@:@I') | ||
def executeFileAtPath_encoding_(self, path, encoding): | ||||
|
r1303 | """Execute file at path in an empty namespace. Update the engine | ||
user_ns with the resulting locals.""" | ||||
|
r4872 | |||
|
r1303 | lines,err = NSString.stringWithContentsOfFile_encoding_error_( | ||
path, | ||||
|
r1316 | encoding, | ||
|
r1303 | None) | ||
self.engine.execute(lines) | ||||
|
r4872 | |||
|
r1292 | def _engine_done(self, x): | ||
|
r1263 | self.waitingForEngine = False | ||
self.didChangeValueForKey_('commandHistory') | ||||
return x | ||||
|
r4872 | |||
|
r1292 | def _update_user_ns(self, result): | ||
|
r1263 | """Update self.userNS from self.engine's namespace""" | ||
d = self.engine.keys() | ||||
|
r1292 | d.addCallback(self._get_engine_namespace_values_for_keys) | ||
|
r4872 | |||
|
r1263 | return result | ||
|
r4872 | |||
|
r1292 | def _get_engine_namespace_values_for_keys(self, keys): | ||
|
r1263 | d = self.engine.pull(keys) | ||
|
r1292 | d.addCallback(self._store_engine_namespace_values, keys=keys) | ||
|
r4872 | |||
|
r1292 | def _store_engine_namespace_values(self, values, keys=[]): | ||
|
r1263 | assert(len(values) == len(keys)) | ||
self.willChangeValueForKey_('userNS') | ||||
for (k,v) in zip(keys,values): | ||||
self.userNS[k] = saferepr(v) | ||||
self.didChangeValueForKey_('userNS') | ||||
|
r4872 | |||
|
r1303 | def update_cell_prompt(self, result, blockID=None): | ||
|
r1322 | print self.blockRanges | ||
|
r1292 | if(isinstance(result, Failure)): | ||
|
r1322 | prompt = self.input_prompt() | ||
|
r4872 | |||
|
r1292 | else: | ||
|
r1322 | prompt = self.input_prompt(number=result['number']) | ||
|
r4872 | |||
|
r1322 | r = self.blockRanges[blockID].inputPromptRange | ||
self.insert_text(prompt, | ||||
textRange=r, | ||||
|
r1292 | scrollToVisible=False | ||
) | ||||
|
r4872 | |||
|
r1292 | return result | ||
|
r4872 | |||
|
r1292 | def render_result(self, result): | ||
blockID = result['blockID'] | ||||
|
r1322 | inputRange = self.blockRanges[blockID].inputRange | ||
|
r1292 | del self.blockRanges[blockID] | ||
|
r4872 | |||
|
r1292 | #print inputRange,self.current_block_range() | ||
self.insert_text('\n' + | ||||
|
r1304 | self.output_prompt(number=result['number']) + | ||
|
r1292 | result.get('display',{}).get('pprint','') + | ||
'\n\n', | ||||
textRange=NSMakeRange(inputRange.location+inputRange.length, | ||||
0)) | ||||
return result | ||||
|
r4872 | |||
|
r1292 | def render_error(self, failure): | ||
|
r1322 | print failure | ||
blockID = failure.blockID | ||||
inputRange = self.blockRanges[blockID].inputRange | ||||
|
r1304 | self.insert_text('\n' + | ||
self.output_prompt() + | ||||
'\n' + | ||||
failure.getErrorMessage() + | ||||
|
r1322 | '\n\n', | ||
textRange=NSMakeRange(inputRange.location + | ||||
inputRange.length, | ||||
0)) | ||||
|
r1292 | self.start_new_block() | ||
return failure | ||||
|
r4872 | |||
|
r1292 | def _start_cli_banner(self): | ||
|
r1263 | """Print banner""" | ||
|
r4872 | |||
|
r1291 | banner = """IPython1 %s -- An enhanced Interactive Python.""" % \ | ||
IPython.__version__ | ||||
|
r4872 | |||
|
r1263 | self.insert_text(banner + '\n\n') | ||
|
r4872 | |||
|
r1292 | def start_new_block(self): | ||
"""""" | ||||
|
r4872 | |||
|
r1292 | self.currentBlockID = self.next_block_ID() | ||
|
r1322 | self.blockRanges[self.currentBlockID] = self.new_cell_block() | ||
|
r4872 | self.insert_text(self.input_prompt(), | ||
|
r1322 | textRange=self.current_block_range().inputPromptRange) | ||
|
r4872 | |||
|
r1292 | def next_block_ID(self): | ||
|
r4872 | |||
return guid.generate() | ||||
|
r1322 | def new_cell_block(self): | ||
"""A new CellBlock at the end of self.textView.textStorage()""" | ||||
|
r4872 | |||
return CellBlock(NSMakeRange(self.textView.textStorage().length(), | ||||
|
r1322 | 0), #len(self.input_prompt())), | ||
|
r1323 | NSMakeRange(self.textView.textStorage().length(),# + len(self.input_prompt()), | ||
|
r1322 | 0)) | ||
|
r4872 | |||
|
r1292 | def current_block_range(self): | ||
|
r4872 | return self.blockRanges.get(self.currentBlockID, | ||
|
r1322 | self.new_cell_block()) | ||
|
r4872 | |||
|
r1293 | def current_block(self): | ||
|
r1292 | """The current block's text""" | ||
|
r4872 | |||
|
r1322 | return self.text_for_range(self.current_block_range().inputRange) | ||
|
r4872 | |||
|
r1293 | def text_for_range(self, textRange): | ||
"""text_for_range""" | ||||
|
r4872 | |||
|
r1292 | ts = self.textView.textStorage() | ||
return ts.string().substringWithRange_(textRange) | ||||
|
r4872 | |||
|
r1293 | def current_line(self): | ||
|
r1322 | block = self.text_for_range(self.current_block_range().inputRange) | ||
|
r1292 | block = block.split('\n') | ||
return block[-1] | ||||
|
r4872 | |||
|
r1292 | def insert_text(self, string=None, textRange=None, scrollToVisible=True): | ||
|
r4872 | """Insert text into textView at textRange, updating blockRanges | ||
|
r1292 | as necessary | ||
""" | ||||
if(textRange == None): | ||||
#range for end of text | ||||
|
r4872 | textRange = NSMakeRange(self.textView.textStorage().length(), 0) | ||
|
r1292 | self.textView.replaceCharactersInRange_withString_( | ||
textRange, string) | ||||
|
r4872 | |||
|
r1322 | for r in self.blockRanges.itervalues(): | ||
r.update_ranges_for_insertion(string, textRange) | ||||
|
r4872 | |||
|
r1322 | self.textView.setSelectedRange_(textRange) | ||
|
r1292 | if(scrollToVisible): | ||
self.textView.scrollRangeToVisible_(textRange) | ||||
|
r4872 | |||
|
r1292 | def replace_current_block_with_string(self, textView, string): | ||
textView.replaceCharactersInRange_withString_( | ||||
|
r1322 | self.current_block_range().inputRange, | ||
string) | ||||
self.current_block_range().inputRange.length = len(string) | ||||
|
r1292 | r = NSMakeRange(textView.textStorage().length(), 0) | ||
textView.scrollRangeToVisible_(r) | ||||
textView.setSelectedRange_(r) | ||||
|
r4872 | |||
|
r1292 | def current_indent_string(self): | ||
"""returns string for indent or None if no indent""" | ||||
|
r4872 | |||
|
r1301 | return self._indent_for_block(self.current_block()) | ||
|
r4872 | |||
|
r1300 | def _indent_for_block(self, block): | ||
lines = block.split('\n') | ||||
if(len(lines) > 1): | ||||
currentIndent = len(lines[-1]) - len(lines[-1].lstrip()) | ||||
|
r1292 | if(currentIndent == 0): | ||
currentIndent = self.tabSpaces | ||||
|
r4872 | |||
|
r1292 | if(self.tabUsesSpaces): | ||
result = ' ' * currentIndent | ||||
else: | ||||
result = '\t' * (currentIndent/self.tabSpaces) | ||||
else: | ||||
result = None | ||||
|
r4872 | |||
|
r1292 | return result | ||
|
r4872 | |||
|
r1292 | # NSTextView delegate methods... | ||
|
r1263 | def textView_doCommandBySelector_(self, textView, selector): | ||
assert(textView == self.textView) | ||||
NSLog("textView_doCommandBySelector_: "+selector) | ||||
|
r4872 | |||
|
r1263 | if(selector == 'insertNewline:'): | ||
|
r1292 | indent = self.current_indent_string() | ||
|
r1263 | if(indent): | ||
|
r1293 | line = indent + self.current_line() | ||
|
r1263 | else: | ||
|
r1293 | line = self.current_line() | ||
|
r4872 | |||
|
r1293 | if(self.is_complete(self.current_block())): | ||
self.execute(self.current_block(), | ||||
|
r1263 | blockID=self.currentBlockID) | ||
|
r1292 | self.start_new_block() | ||
|
r4872 | |||
|
r1263 | return True | ||
|
r4872 | |||
|
r1263 | return False | ||
|
r4872 | |||
|
r1263 | elif(selector == 'moveUp:'): | ||
|
r1293 | prevBlock = self.get_history_previous(self.current_block()) | ||
|
r1279 | if(prevBlock != None): | ||
|
r1292 | self.replace_current_block_with_string(textView, prevBlock) | ||
|
r1279 | else: | ||
NSBeep() | ||||
|
r1263 | return True | ||
|
r4872 | |||
|
r1263 | elif(selector == 'moveDown:'): | ||
|
r1281 | nextBlock = self.get_history_next() | ||
|
r1279 | if(nextBlock != None): | ||
|
r1292 | self.replace_current_block_with_string(textView, nextBlock) | ||
|
r1279 | else: | ||
NSBeep() | ||||
|
r1263 | return True | ||
|
r4872 | |||
|
r1263 | elif(selector == 'moveToBeginningOfParagraph:'): | ||
|
r1291 | textView.setSelectedRange_(NSMakeRange( | ||
|
r4872 | self.current_block_range().inputRange.location, | ||
|
r1322 | 0)) | ||
|
r1263 | return True | ||
elif(selector == 'moveToEndOfParagraph:'): | ||||
|
r1291 | textView.setSelectedRange_(NSMakeRange( | ||
|
r1322 | self.current_block_range().inputRange.location + \ | ||
self.current_block_range().inputRange.length, 0)) | ||||
|
r1263 | return True | ||
elif(selector == 'deleteToEndOfParagraph:'): | ||||
|
r1291 | if(textView.selectedRange().location <= \ | ||
|
r1292 | self.current_block_range().location): | ||
|
r1322 | raise NotImplemented() | ||
|
r4872 | |||
|
r1263 | return False # don't actually handle the delete | ||
|
r4872 | |||
|
r1263 | elif(selector == 'insertTab:'): | ||
|
r1293 | if(len(self.current_line().strip()) == 0): #only white space | ||
|
r1263 | return False | ||
else: | ||||
self.textView.complete_(self) | ||||
return True | ||||
|
r4872 | |||
|
r1263 | elif(selector == 'deleteBackward:'): | ||
#if we're at the beginning of the current block, ignore | ||||
|
r1291 | if(textView.selectedRange().location == \ | ||
|
r1322 | self.current_block_range().inputRange.location): | ||
|
r1263 | return True | ||
else: | ||||
|
r1322 | for r in self.blockRanges.itervalues(): | ||
deleteRange = textView.selectedRange | ||||
if(deleteRange.length == 0): | ||||
deleteRange.location -= 1 | ||||
deleteRange.length = 1 | ||||
r.update_ranges_for_deletion(deleteRange) | ||||
|
r1263 | return False | ||
return False | ||||
|
r4872 | |||
def textView_shouldChangeTextInRanges_replacementStrings_(self, | ||||
|
r1291 | textView, ranges, replacementStrings): | ||
|
r1263 | """ | ||
Delegate method for NSTextView. | ||||
|
r4872 | |||
Refuse change text in ranges not at end, but make those changes at | ||||
|
r1291 | end. | ||
|
r1263 | """ | ||
|
r4872 | |||
|
r1263 | assert(len(ranges) == len(replacementStrings)) | ||
allow = True | ||||
for r,s in zip(ranges, replacementStrings): | ||||
r = r.rangeValue() | ||||
if(textView.textStorage().length() > 0 and | ||||
|
r1322 | r.location < self.current_block_range().inputRange.location): | ||
|
r1263 | self.insert_text(s) | ||
allow = False | ||||
|
r4872 | |||
|
r1263 | return allow | ||
|
r4872 | |||
def textView_completions_forPartialWordRange_indexOfSelectedItem_(self, | ||||
|
r1291 | textView, words, charRange, index): | ||
|
r1263 | try: | ||
|
r1291 | ts = textView.textStorage() | ||
token = ts.string().substringWithRange_(charRange) | ||||
|
r1263 | completions = blockingCallFromThread(self.complete, token) | ||
except: | ||||
completions = objc.nil | ||||
NSBeep() | ||||
|
r4872 | |||
|
r1263 | return (completions,0) | ||
|
r4872 | |||
|
r1263 | |||