3. irb for Cocoa buffs
3.1 Interactive Ruby
Now it’s time to attach irb. “irb” stands for Interactive Ruby. If you’re a Ruby programmer, you probably use it all the time. If you’ve ever run script/console in a Rails application, you’ve run irb.
If you’re a RubyCocoa programmer, you may have naively tried to start writing a Cocoa application inside irb (like I did). Unfortunately, you can’t do that. Cocoa applications must be run from a particular directory structure, and if your console is going to run in the same thread as your application, then your application’s event handling must be interleaved with the console input and output.
The way to solve this problem is to attach irb to an NSTextView and write custom input and output handlers that read and write from the text view. That’s a lot of work. Fortunately, some open-source examples exist. Bob Ippolito’s PyInterpreter embeds a Python interpreter in an NSTextView and Martin Demello’s FXIrb embeds irb in a window in the FOX GUI framework. Martin’s example runs the interpreter in a separate thread, which is a tantalizing possibility blocked by two problems in RubyCocoa:
- The RubyCocoa framework currently has serious stability problems for threaded code. But there’s some intense ongoing efforts to fix this, so I don’t think this will be a problem for long.
- All AppKit interactions must be done in a single thread. Since we will be using our console mainly to explore the AppKit, we would want our console to run in the same thread as the UI.
So, like the PyObjC PyInterpreter, our console is going to run in the same thread as the rest of our application. You’ll have to remember that slow-to-execute Ruby calls will lock up your application, but if they generate output, we’ll have a way to break into them. The advantage though, is that we can freely interact with any Cocoa objects without worrying about synchronization.
3.2 A RubyCocoa console
The code for our console is below. To use it, download it from this link, put it in your project directory and restart your application (be sure to also remove the code for the dummy console from the previous chapter). I’ll discuss the console’s implementation in the following section, but if you want to play with it first, just go on to the next chapter.
require 'irb' class ConsoleWindowController < OSX::NSObject attr_accessor :window, :textview, :console def initWithFrame(frame) init styleMask = OSX::NSTitledWindowMask + OSX::NSClosableWindowMask + OSX::NSMiniaturizableWindowMask + OSX::NSResizableWindowMask @window = OSX::NSWindow.alloc.initWithContentRect_styleMask_backing_defer( frame, styleMask, OSX::NSBackingStoreBuffered, false) @textview = OSX::NSTextView.alloc.initWithFrame(frame) @console = RubyConsole.alloc.initWithTextView @textview with @window do |w| w.setContentView scrollableView(@textview) w.setTitle "RubyCocoa Console" w.setDelegate self w.center w.makeKeyAndOrderFront(self) end self end def run @console.performSelector_withObject_afterDelay("run:", self, 0) end def windowWillClose(notification) OSX::NSApplication.sharedApplication.terminate(self) end def windowShouldClose(notification) @alert = OSX::NSAlert.alloc.init with @alert do |a| a.setMessageText( "Do you really want to close this console?\nYour application will exit.") a.setAlertStyle(OSX::NSCriticalAlertStyle) a.addButtonWithTitle("OK") a.addButtonWithTitle("Cancel") a.beginSheetModalForWindow_modalDelegate_didEndSelector_contextInfo( window, self, "alertDidEnd:returnCode:contextInfo:", nil) end false end def alertDidEnd_returnCode_contextInfo(alert, code, contextInfo) window.close if (code == 1000) end end def with(x) yield x if block_given?; x end if not defined? with def scrollableView(content) scrollview = OSX::NSScrollView.alloc.initWithFrame(content.frame) clipview = OSX::NSClipView.alloc.initWithFrame(scrollview.frame) scrollview.setContentView(clipview) scrollview.setDocumentView(content) clipview.setDocumentView(content) content.setFrame(clipview.frame) scrollview.setHasVerticalScroller(1) scrollview.setHasHorizontalScroller(1) scrollview.setAutohidesScrollers(1) resizingMask = OSX::NSViewWidthSizable + OSX::NSViewHeightSizable content.setAutoresizingMask(resizingMask) clipview.setAutoresizingMask(resizingMask) scrollview.setAutoresizingMask(resizingMask) scrollview end class RubyCocoaInputMethod < IRB::StdioInputMethod def initialize(console) super() # superclass method has no arguments @console = console @history_index = 1 @continued_from_line = nil end def gets m = @prompt.match(/(\d+)[>*]/) level = m ? m[1].to_i : 0 if level > 0 @continued_from_line ||= @line_no elsif @continued_from_line mergeLastNLines(@line_no - @continued_from_line + 1) @continued_from_line = nil end @console.write @prompt+" "*level string = @console.readLine @line_no += 1 @history_index = @line_no + 1 @line[@line_no] = string string end def mergeLastNLines(i) return unless i > 1 range = -i..-1 @line[range] = @line[range].map {|l| l.chomp}.join("\n") @line_no -= (i-1) @history_index -= (i-1) end def prevCmd return "" if @line_no == 0 @history_index -= 1 unless @history_index <= 1 @line[@history_index] end def nextCmd return "" if (@line_no == 0) or (@history_index >= @line_no) @history_index += 1 @line[@history_index] end end # this is an output handler for IRB # and a delegate and controller for an NSTextView class RubyConsole < OSX::NSObject attr_accessor :textview, :inputMethod def initWithTextView(textview) init @textview = textview @textview.setDelegate self @textview.setRichText(false) @textview.setContinuousSpellCheckingEnabled(false) @inputMethod = RubyCocoaInputMethod.new(self) @context = Kernel::binding @startOfInput = 0 self end def run(sender = nil) @textview.window.makeKeyAndOrderFront(self) IRB.startInConsole(self) OSX::NSApplication.sharedApplication.terminate(self) end def write(object) string = object.to_s @textview.textStorage.insertAttributedString_atIndex( OSX::NSAttributedString.alloc.initWithString(string), @startOfInput) @startOfInput += string.length @textview.scrollRangeToVisible([lengthOfTextView, 0]) handleEvents if OSX::NSApplication.sharedApplication.isRunning end def moveAndScrollToIndex(index) range = OSX::NSRange.new(index, 0) @textview.scrollRangeToVisible(range) @textview.setSelectedRange(range) end def lengthOfTextView @textview.textStorage.mutableString.length end def currentLine text = @textview.textStorage.mutableString text.substringWithRange( OSX::NSRange.new(@startOfInput, text.length - @startOfInput)).to_s end def readLine app = OSX::NSApplication.sharedApplication @startOfInput = lengthOfTextView loop do event = app.nextEventMatchingMask_untilDate_inMode_dequeue( OSX::NSAnyEventMask, OSX::NSDate.distantFuture(), OSX::NSDefaultRunLoopMode, true) if (event.oc_type == OSX::NSKeyDown) and event.window and (event.window.isEqual? @textview.window) break if event.characters.to_s == "\r" if (event.modifierFlags & OSX::NSControlKeyMask) != 0: case event.keyCode when 0: moveAndScrollToIndex(@startOfInput) # control-a when 14: moveAndScrollToIndex(lengthOfTextView) # control-e end end end app.sendEvent(event) end lineToReturn = currentLine @startOfInput = lengthOfTextView write("\n") return lineToReturn + "\n" end def handleEvents app = OSX::NSApplication.sharedApplication event = app.nextEventMatchingMask_untilDate_inMode_dequeue( OSX::NSAnyEventMask, OSX::NSDate.dateWithTimeIntervalSinceNow(0.01), OSX::NSDefaultRunLoopMode, true) if event if (event.oc_type == OSX::NSKeyDown) and event.window and (event.window.isEqualTo @textview.window) and (event.charactersIgnoringModifiers.to_s == 'c') and (event.modifierFlags & OSX::NSControlKeyMask) raise IRB::Abort, "abort, then interrupt!!" # that's what IRB says... else app.sendEvent(event) end end end def replaceLineWithHistory(s) range = OSX::NSRange.new(@startOfInput, lengthOfTextView - @startOfInput) @textview.textStorage.replaceCharactersInRange_withAttributedString( range, OSX::NSAttributedString.alloc.initWithString(s.chomp)) @textview.scrollRangeToVisible([lengthOfTextView, 0]) true end # delegate methods def textView_shouldChangeTextInRange_replacementString( textview, range, replacement) return false if range.location < @startOfInput replacement = replacement.to_s.gsub("\r","\n") if replacement.length > 0 and replacement[-1].chr == "\n" @textview.textStorage.appendAttributedString( OSX::NSAttributedString.alloc.initWithString(replacement) ) if currentLine != "" @startOfInput = lengthOfTextView false # don't insert replacement text because we've already inserted it else true # caller should insert replacement text end end def textView_willChangeSelectionFromCharacterRange_toCharacterRange( textview, oldRange, newRange) return oldRange if (newRange.length == 0) and (newRange.location < @startOfInput) newRange end def textView_doCommandBySelector(textview, selector) case selector when "moveUp:" replaceLineWithHistory(@inputMethod.prevCmd) when "moveDown:" replaceLineWithHistory(@inputMethod.nextCmd) else false end end end module IRB def IRB.startInConsole(console) IRB.setup(nil) @CONF[:PROMPT_MODE] = :DEFAULT @CONF[:VERBOSE] = false @CONF[:ECHO] = true irb = Irb.new(nil, console.inputMethod) @CONF[:IRB_RC].call(irb.context) if @CONF[:IRB_RC] @CONF[:MAIN_CONTEXT] = irb.context trap("SIGINT") do irb.signal_handle end old_stdout, old_stderr = $stdout, $stderr $stdout = $stderr = console catch(:IRB_EXIT) do loop do begin irb.eval_input rescue Exception puts "Error: #{$!}" end end end $stdout, $stderr = old_stdout, old_stderr end class Context def prompting? true end end end class ApplicationDelegate < OSX::NSObject def applicationDidFinishLaunching(sender) $consoleWindowController = ConsoleWindowController.alloc. initWithFrame([50,50,600,300]) $consoleWindowController.run end end # set up the application delegate $delegate = ApplicationDelegate.alloc.init OSX::NSApplication.sharedApplication.setDelegate($delegate)
3.3 Implementation notes
If you look over the code above, you’ll see a lot that was carried forward from the last chapter. The new code is in three major parts:
RubyConsole
Let’s start with the RubyConsole class. It is the biggest and most complicated addition. According to my comment in the code, it is an output handler for IRB (meaning it acts like an output device) and it is a delegate and controller for the NSTextView. IRB input handling is provided by a different class (RubyCocoaInputMethod).
The object initializer (initWithTextView) attaches the console object to the text view and does some basic configuration.
The run function brings the window to the front and starts the interpreter by calling the IRB.startInConsole method. Unfortunately for us, this function doesn’t return until the user exits the console, so we’re going to have to find some way to keep handling events in our application.
The write method is used by IRB to add output to the view.
The next few methods (moveAndScrollToIndex, lengthOfTextView, and currentLine) do some housekeeping and data access.
The readLine method is where things get crazy. Because IRB has taken over the flow of control in our application, we need a way to run the Cocoa event loop. We do that here, in the function that gets called by IRB to get more input. Here I also added some special handling of control keys to move to the beginning and end of the console’s command line.
The handleEvents method gives us another chance to process events in the application. We call it from the write method (above) every time IRB sends text output to the console window. You can also call it from long-running code in your application to keep your application responsive.
The replaceLineWithHistory method is a helper for the textview:doCommandBySelector: method, which is a delegate method that gets called for certain actions. We use it to implement a command history.
The textView:shouldChangeTextInRange:replacementString: delegate method tracks user input. It gets called when a person tries to type text into the view. The first line of this method rejects insertions that aren’t at the end of the buffer. The rest allows the replacement text to be added.
Finally, textView:willChangeSelectionFromCharacterRange:toCharacterRange: controls user movement in our console.
RubyCocoaInputMethod
More input-handling features are provided by this class, which is a subclass of one defined in IRB. The gets method of this object is called directly by IRB to get new lines. The rest of the methods of this class support the handling of multi-line input and command history.
IRB
Finally, we extend the IRB module to add the startInConsole method to create and run the console. I discovered that Cocoa applications seem to run without a standard output (unless they are run from Xcode), which caused IRB to not want to generate a prompt. Fortunately, Ruby’s open classes made it easy to open the IRB::Context class and force a prompt by redefining the prompt? method.
Other changes
You may have noticed that the ConsoleWindowController’s run method doesn’t call the RubyConsole run method directly. It instead causes it to be called using the performSelector:withObject:afterDelay: method. This isn’t strictly necessary in the current version of our application; I added it after experimenting with using a menu item to launch the console. Because IRB takes over our application without returning, a direct call to @console.run would leave the menu item highlighted after the console would activate. Using this function allowed me to schedule the console startup in the menu handler and then safely allow the menu handler to finish before the console took over.
That was a lot to describe (and to read!). Feel free to email me or post comments if you have questions or suggestions.
Did you find an error? Is something missing? Post your comment or suggestion below!
Comments (6) post
just to say i’ve done a Mac OS X icon for this console app.
i plan do rubify in the near future the AppleScript Sution example called “Archive Maker”, may be extending to other format than tar.gz.
i’ve put the icon here : RubyCocoaConsole (icns)
also you could see a png version here :
RubyCocoaConsole (png)
best,
Yvon
Yvon, thanks, that’s great! I’ll include it in a future update to the tutorial.
On 10.4.8 (and maybe others) the two instances of
OSX.NSDefaultRunLoopModeshould be replaced byOSX::NSDefaultRunLoopMode– otherwise the CPU pegs to 100% and thent the program crashesThis entire article could use a Leo update? It’s all still very (if not more) relevant with RubyCocoa installed natively w/ Leopard dev tools.
Cheers, -JB
Note that Chris Atwood’s fix is applied to the text, but not the linked console.rb!
I’ve implemented this console as a nib object instead of “using up” my App delegate. And used the awakeFromNib method.
Is there any reason this might be a bad thing?