3. irb for Cocoa buffs

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.

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.

console.rb (final version) [ruby]
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)

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
  1. yvon_thoraval@mac.com Thu Oct 19 13:58:12 +0000 2006

    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

  2. Tim Burks Fri Oct 27 21:31:47 +0000 2006

    Yvon, thanks, that’s great! I’ll include it in a future update to the tutorial.

  3. Chris Atwood Tue May 22 20:47:59 +0000 2007

    On 10.4.8 (and maybe others) the two instances of OSX.NSDefaultRunLoopMode should be replaced by OSX::NSDefaultRunLoopMode – otherwise the CPU pegs to 100% and thent the program crashes

  4. JB Mon Mar 31 22:41:22 +0000 2008

    This 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

  5. Note Mon Mar 31 23:18:14 +0000 2008

    Note that Chris Atwood’s fix is applied to the text, but not the linked console.rb!

  6. JB Tue Apr 01 21:30:02 +0000 2008

    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?