[macruby-changes] [2220] MacRuby/branches/experimental/sample-macruby/HotCocoa

source_changes at macosforge.org source_changes at macosforge.org
Wed Aug 5 04:42:46 PDT 2009


Revision: 2220
          http://trac.macosforge.org/projects/ruby/changeset/2220
Author:   vincent.isambart at gmail.com
Date:     2009-08-05 04:42:46 -0700 (Wed, 05 Aug 2009)
Log Message:
-----------
Imported HotConsole

Modified Paths:
--------------
    MacRuby/branches/experimental/sample-macruby/HotCocoa/README

Added Paths:
-----------
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/README
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/Rakefile
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/config/
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/config/build.yml
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/application.rb
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/eval_thread.rb
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/helpers.rb
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/menu.rb
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/resources/
    MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/resources/HotConsole.icns

Modified: MacRuby/branches/experimental/sample-macruby/HotCocoa/README
===================================================================
--- MacRuby/branches/experimental/sample-macruby/HotCocoa/README	2009-08-05 09:11:23 UTC (rev 2219)
+++ MacRuby/branches/experimental/sample-macruby/HotCocoa/README	2009-08-05 11:42:46 UTC (rev 2220)
@@ -43,6 +43,8 @@
 download_and_progress_indicator: demo of downloading data, progress indicator
 and scroll view containing a text view.
 
+hotconsole: an IRB-like console using WebKit.
+
 Building the examples:
 
 $ cd <example-directory>

Added: MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/README
===================================================================
--- MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/README	                        (rev 0)
+++ MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/README	2009-08-05 11:42:46 UTC (rev 2220)
@@ -0,0 +1,9 @@
+HotConsole is an graphical IRB-like console written using HotCocoa.
+It does not use the IRB code at all
+Its advantages compared to the command line macirb:
+- you can easily enter non-ASCII characters
+- multiple windows, with code running in multiple threads
+
+A few shortcuts:
+- Alt+Up/Down to navigate in the history
+- Alt+Enter to type text on multiple lines

Added: MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/Rakefile
===================================================================
--- MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/Rakefile	                        (rev 0)
+++ MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/Rakefile	2009-08-05 11:42:46 UTC (rev 2220)
@@ -0,0 +1,4 @@
+require 'hotcocoa/application_builder'
+require 'hotcocoa/standard_rake_tasks'
+
+task :default => [:run]
\ No newline at end of file

Added: MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/config/build.yml
===================================================================
--- MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/config/build.yml	                        (rev 0)
+++ MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/config/build.yml	2009-08-05 11:42:46 UTC (rev 2220)
@@ -0,0 +1,8 @@
+name: HotConsole
+load: lib/application.rb
+version: "1.0"
+icon: resources/HotConsole.icns
+resources:
+  - resources/**/*.*
+sources: 
+  - lib/**/*.rb

Added: MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/application.rb
===================================================================
--- MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/application.rb	                        (rev 0)
+++ MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/application.rb	2009-08-05 11:42:46 UTC (rev 2220)
@@ -0,0 +1,322 @@
+require 'lib/eval_thread' # for EvalThread and standard output redirection
+require 'hotcocoa'
+framework 'WebKit'
+include HotCocoa
+
+# TODO:
+# - stdin (should not be very hard but it's used only very rarely so very low priority)
+# - do not perform_action if the code typed is not finished (needs a basic lexer)
+# - maybe always displays puts/writes before the prompt?
+$terminals = []
+
+class Terminal
+  def base_html
+    return <<-HTML
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+    <html>
+    <head>
+      <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+      <style type="text/css"><!--
+        body, body * {
+          font-family: Monaco;
+          white-space: pre-wrap; /* in normal mode, WebKit sometimes adds nbsp when pressing space */
+        }
+      --></style>
+    </head>
+    <body></body>
+    </html>
+    HTML
+  end
+  
+  def alertDidEnd alert, returnCode: return_code, contextInfo: context_info
+    return if return_code == NSAlertSecondButtonReturn # do nothing if the use presses cancel
+    
+    # in all cases, first ask the thread to end nicely if possible
+    @eval_thread.end_thread
+    
+    @window.close
+    
+    @eval_thread.kill_running_threads if return_code == NSAlertFirstButtonReturn # kill the running code if asked
+  end
+  
+  def should_close?
+    # we can always close directly is nothing is running
+    return true if command_line and not @eval_thread.children_threads_running?
+    
+    alert = NSAlert.alloc.init
+    alert.messageText = "Some code is still running in this console.\nDo you really want to close it?"
+    alert.alertStyle = NSCriticalAlertStyle
+    alert.addButtonWithTitle("Close and kill")
+    alert.addButtonWithTitle("Cancel")
+    alert.addButtonWithTitle("Close and let run")
+    alert.beginSheetModalForWindow @window, modalDelegate: self, didEndSelector: "alertDidEnd:returnCode:contextInfo:", contextInfo: nil
+    false
+  end
+  
+  def start
+    @line_num = 1
+    @history = [ ]
+    @pos_in_history = 0
+    
+    @eval_thread = EvalThread.new(self)
+    
+    frame = [300, 300, 600, 400]
+    w = NSApp.mainWindow
+    if w
+      frame[0] = w.frame.origin.x + 20
+      frame[1] = w.frame.origin.y - 20
+    end
+    @window = window(:frame => frame, :title => "HotConsole") do |win|
+      win.should_close? { self.should_close? }
+      win.will_close {
+        $terminals.delete(self)
+        @window_closed = true
+      }
+      win.did_become_main {
+        # we want order in $terminals to be
+        # last used terminal to most recent used
+        $terminals.delete(self)
+        $terminals << self
+      }
+      win.contentView.margin = 0
+      @web_view = web_view(:layout => {:expand => [:width, :height]})
+      @web_view.editingDelegate = self # for webView:doCommandBySelector:
+      @web_view.frameLoadDelegate = self # for webView:didFinishLoadForFrame:
+      @web_view.mainFrame.loadHTMLString base_html, baseURL: nil
+      win << @web_view
+    end
+    class << @window
+      attr_accessor :terminal
+    end
+    @window.terminal = self
+    @window_closed = false
+    $terminals << self
+  end
+  
+  def display_intro_message
+    write <<-END_MESSAGE
+Welcome to HotConsole!
+Shortcuts:
+- Alt+Up/Down to navigate in the history
+- Alt+Enter to type text on multiple lines
+- Command+K to clean up the terminal
+    END_MESSAGE
+  end
+  
+  def empty_body
+    document.body.innerHTML = ''
+  end
+  
+  def clear
+    if command_line
+      @next_prompt_command = command_line.innerText
+      empty_body
+      write_prompt
+    else
+      @next_prompt_command = nil
+      empty_body
+    end
+  end
+
+  def webView view, didFinishLoadForFrame: frame
+    # we must be sure the body is really empty because of the preservation of white spaces
+    # we can easily have a carriage return left in the HTML
+    empty_body
+    display_intro_message
+    write_prompt
+  end
+  
+  # return the HTML document of the main frame
+  def document
+    @web_view.mainFrame.DOMDocument
+  end
+  
+  # returns the DOM node for the command line
+  # (or nil if there is no command line currently)
+  def command_line
+    document.getElementById('command_line')
+  end
+  
+  # move the carret of the command line to its first character
+  def command_line_carret_to_beginning
+    cl = command_line
+    range = document.createRange
+    range.setStart cl, offset: 0
+    range.setEnd cl, offset: 0
+    @web_view.setSelectedDOMRange range, affinity: NSSelectionAffinityUpstream
+  end
+  
+  def display_history
+    command_line.innerText = @history[@pos_in_history] || ''
+    # if we do not move the caret to the beginning,
+    # we lose the focus if the command line is emptied
+    command_line_carret_to_beginning
+  end
+  
+  # callback called when a command by selector is run on the WebView
+  def webView webView, doCommandBySelector: command
+    command = command.to_s
+    if command == 'insertNewline:' # Return
+      perform_action
+    elsif command == 'moveBackward:' # Alt+Up Arrow
+      if @pos_in_history > 0
+        @pos_in_history -= 1
+        display_history
+      end
+    elsif command == 'moveForward:' # Alt+Down Arrow
+      if @pos_in_history < @history.length
+        @pos_in_history += 1
+        display_history
+      end
+    # moveToBeginningOfParagraph and moveToEndOfParagraph are also sent by Alt+Up/Down
+    # but we must ignore them because they move the cursor
+    elsif command != 'moveToBeginningOfParagraph:' and command != 'moveToEndOfParagraph:'
+      return false
+    end
+    true
+  end
+
+  # simply writes (appends) a DOM element to the WebView
+  def write_element(element)
+    document.body.appendChild(element)
+  end
+  
+  def write(obj)
+    if obj.respond_to?(:to_str)
+      text = obj
+    else
+      text = obj.to_s
+    end
+
+    if @window_closed
+      # if the window was closed while code that printed text was still running,
+      # the text is displayed on the most recently used terminal if there is one,
+      # or the standard error output if no terminal was found
+      target = $terminals.last || STDERR
+      target.write(text)
+    else
+      # if the window is still opened, just put the text
+      # in a DOM span element and writes it on the WebView
+      span = document.createElement('span')
+      span.innerText = text
+      write_element(span)
+    end
+  end
+  
+  # puts is just a write of the text followed by a carriage return and that returns nil
+  def puts(obj)
+    if obj.respond_to?(:to_ary)
+      obj.each { |elem| puts elem }
+    else
+      # we do not call just write because of encoding/string problems
+      # and because Ruby itself does it in two calls to write
+      write obj
+      write "\n"
+    end
+  end
+
+  def scroll_to_bottom
+    body = document.body
+    body.scrollTop = body.scrollHeight
+    @web_view.setNeedsDisplay true
+  end
+  
+  def end_edition
+    if command_line
+      command_line.setAttribute('contentEditable', value: nil)
+      command_line.setAttribute('id', value: nil)
+    end
+  end
+
+  def write_prompt
+    end_edition
+
+    table = document.createElement('table')
+    row = table.insertRow(0)
+    prompt = row.insertCell(-1)
+    prompt.setAttribute('style', value: 'vertical-align: top;')
+    prompt.innerText = '>>'
+    typed_text = row.insertCell(-1)
+    typed_text.setAttribute('contentEditable', value: 'true')
+    typed_text.setAttribute('id', value: 'command_line')
+    typed_text.setAttribute('style', value: 'width: 100%;')
+    if @next_prompt_command
+      typed_text.innerText = @next_prompt_command
+      @next_prompt_command = nil
+    end
+    write_element(table)
+
+    command_line.focus
+    scroll_to_bottom
+  end
+  
+  # executes the code written on the prompt when the user validates it with return
+  def perform_action
+    current_line_number = @line_num
+    command = command_line.innerText
+    @line_num += command.count("\n")+1
+    if command.strip.empty?
+      write_prompt
+      return
+    end
+    
+    @history.push(command)
+    @pos_in_history = @history.length
+
+    # the code is sent to an other thread that will do the evaluation
+    @eval_thread.send_command(current_line_number, command)
+    # the user must not be able to modify the prompt until the command ends
+    # (when back_from_eval is called)
+    end_edition
+  end
+
+  # back_from_eval is called when the evaluation thread has finished its evaluation of the given code
+  # text is either the representation of the value returned by the code executed, or the backtrace of an exception
+  def back_from_eval(text)
+    # if the window was closed while code was still executing,
+    # we can just ignore the call because there is no need
+    # to print the result and a new prompt
+    return if @window_closed
+    write text
+    write_prompt
+  end
+end
+
+class Application
+  def start
+    application :name => "HotConsole" do |app|
+      app.delegate = self
+      start_terminal
+    end
+  end
+
+  def on_new(sender)
+    start_terminal
+  end
+
+  def on_close(sender)
+    w = NSApp.mainWindow
+    if w
+      w.performClose self
+    else
+      NSBeep()
+    end
+  end
+  
+  def on_clear(sender)
+    w = NSApp.mainWindow
+    if w and w.respond_to?(:terminal)
+      w.terminal.clear
+    else
+      NSBeep()
+    end
+  end
+
+  private
+
+  def start_terminal
+    Terminal.new.start
+  end
+end
+
+Application.new.start

Added: MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/eval_thread.rb
===================================================================
--- MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/eval_thread.rb	                        (rev 0)
+++ MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/eval_thread.rb	2009-08-05 11:42:46 UTC (rev 2220)
@@ -0,0 +1,162 @@
+require 'thread' # for Queue
+require 'lib/helpers' # for Object#send_on_main_thread
+
+# EvalThread is the class that does all the code evaluation.
+# The code is evaluated in a new thread mainly for 2 reasons:
+# - so the application is not frozen if a commands takes too much time
+# - to be able to find where to write the text for the standard output (using thread variables)
+class EvalThread
+  # Writer is the class to manage the standard output.
+  # The difficult part is to find the good target to print to,
+  # especially if the user starts new threads
+  # warning: It won't work if the current thread is a NSThread or pure C thread,
+  #          but anyway for the moment NSThreads do no seem to work well in MacRuby
+  class Writer
+    # sets the target where to write when something is written on the standard output
+    def self.set_stdout_target_for_thread(target)
+      Thread.current[:_irb_stdout_target] = target
+    end
+    
+    # a standard output object has only one mandatory method: write.
+    # it generally returns the number of characters written
+    def write(obj)
+      if obj.respond_to?(:to_str)
+        str = obj
+      else
+        str = obj.to_s
+      end
+      find_target_and_call :write, str
+      str.length
+    end
+    
+    # if puts is not there, Ruby will automatically use the write
+    # method when calling Kernel#puts, but defining it has 2 advantages:
+    # - if puts is not defined, you cannot of course use $stdout.puts directly
+    # - when Ruby emulates puts, it calls write twice
+    #   (once for the string and once for the carriage return)
+    #   but here we send the calls to another thread so it's nice
+    #   to be able to save up one (slow) interthread call
+    def puts(*args)
+      find_target_and_call :puts, args
+      nil
+    end
+    
+    private
+    
+    # the core of write/puts: tries to find the target where to
+    # write the text and calls the indicated function on it.
+    # it returns the number of characters in the given string
+    def find_target_and_call(function_name, obj)
+      current_thread = Thread.current
+      target = current_thread[:_irb_stdout_target]
+      
+      # first, if we have a target in the thread, use it
+      return target.send_on_main_thread(function_name, obj) if target
+      
+      # if we do not have any target, search for a target in every thread in the ThreadGroup
+      if group = current_thread.group
+        group.list.each do |thread|
+          return target.send_on_main_thread(function_name, obj) if target = thread[:_irb_stdout_target]
+        end
+      end
+      
+      # if we still do not have any target, try to get the most recently used and opened terminal
+      target = $terminals.last
+      return target.send_on_main_thread(function_name, obj) if target
+      
+      # if we do not find any target, just write it on STDERR
+      STDERR.send(function_name, obj)
+    end
+  end
+  # replace Ruby's standard output
+  $stdout = Writer.new
+  
+  # sends a command to evaluate.
+  # the line_number is not computed internally because the empty lines
+  # are not sent to the eval thread but still increase the line number
+  def send_command(line_number, command)
+    @queue_for_commands.push([line_number, command])
+  end
+  
+  # asks the thread to end.
+  # this operation is not immediate because if an operation is
+  # still working in the thread, we want to let it finish
+  def end_thread
+    @queue_for_commands.push([nil, :END])
+  end
+  
+  # kill the evaluation thread and its children
+  def kill_running_threads
+    @thread_group.list.each do |thread|
+      thread.kill if thread != @thread
+    end
+    @thread.kill if @thread.alive? # kill the main thread last
+  end
+  
+  def children_threads_running?
+    if @thread.alive?
+      @thread_group.list.count > 1
+    else
+      @thread_group.list.count > 0
+    end
+  end
+  
+  # starts a new EvalThread in a separate thread
+  # the target is the terminal where the standard output and prompt are written.
+  # target must have 3 methods: write, puts and back_from_eval, all taking a string argument
+  def initialize(target)
+    @target = target
+    @queue_for_commands = Queue.new
+    @binding = TOPLEVEL_BINDING.dup
+    @underscore_assigner = eval("_ = nil; proc { |val| _ = val }", @binding)
+    @thread = Thread.new { run }
+  end
+
+  private
+  
+  # tells the target eval has finished and it can show a new prompt
+  def back_from_eval(text)
+    @target.send_on_main_thread :back_from_eval, text
+  end
+
+  def run
+    # create a new ThreadGroup and sets it as the group for the current thread.
+    # the ThreadGroup allows us to find the parent thread when the standard output is used
+    # from a thread created by the user and not by us as new threads are automatically added
+    # to the ThreadGroup of their parent thread
+    @thread_group = ThreadGroup.new
+    @thread_group.add(Thread.current)
+    
+    # when some code in the thread uses the standard output, we want the text
+    # to be printed to the current target (the current terminal)
+    Writer.set_stdout_target_for_thread(@target)
+    
+    # evaluation loop
+    loop do
+      # waits for the next command to evaluate
+      line_num, command = @queue_for_commands.pop
+      break if command == :END
+
+      # eval_file and eval_line are used to clean up the backtrace of catched exceptions
+      eval_file = __FILE__
+      eval_line = -1
+      begin
+        # eval_line must have exactly the line number where the eval call occurs
+        eval_line = __LINE__; value = eval(command, @binding, 'hotconsole', line_num)
+        @underscore_assigner.call(value)
+        back_from_eval "=> #{value.inspect}\n"
+      rescue Exception => e
+        backtrace = e.backtrace
+        # we try to remove the backtrace of the call to eval itself
+        # because it only confuses the user
+        i = backtrace.index { |l| l.index("#{eval_file}:#{eval_line}") }
+        if i == 0
+          backtrace = []
+        elsif i
+          backtrace = backtrace[0..i-1]
+        end
+        back_from_eval "#{e.class.name}: #{e.message}\n" + (backtrace.empty? ? '' : "#{backtrace.join("\n")}\n")
+      end
+    end
+  end
+end

Added: MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/helpers.rb
===================================================================
--- MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/helpers.rb	                        (rev 0)
+++ MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/helpers.rb	2009-08-05 11:42:46 UTC (rev 2220)
@@ -0,0 +1,9 @@
+class Object
+  # calls a method on the object on the main thread
+  def send_on_main_thread(function_name, parameter = nil, asynchronous = true)
+    function_name = function_name.to_s
+    # if the target method has a parameter, we have to be sure the method name ends with a ':'
+    function_name << ':' if parameter and not /:$/.match(function_name)
+    performSelectorOnMainThread function_name, withObject: parameter, waitUntilDone: (not asynchronous)
+  end
+end

Added: MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/menu.rb
===================================================================
--- MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/menu.rb	                        (rev 0)
+++ MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/lib/menu.rb	2009-08-05 11:42:46 UTC (rev 2220)
@@ -0,0 +1,46 @@
+module HotCocoa
+  def application_menu
+    menu do |main|
+      main.submenu :apple do |apple|
+        apple.item :about, :title => "About #{NSApp.name}"
+        apple.separator
+        apple.item :preferences, :key => ","
+        apple.separator
+        apple.submenu :services
+        apple.separator
+        apple.item :hide, :title => "Hide #{NSApp.name}", :key => "h"
+        apple.item :hide_others, :title => "Hide Others", :key => "h", :modifiers => [:command, :alt]
+        apple.item :show_all, :title => "Show All"
+        apple.separator
+        apple.item :quit, :title => "Quit #{NSApp.name}", :key => "q"
+      end
+      main.submenu :file do |file|
+        file.item :new, :key => "n"
+        file.item :close, :key => "w", :modifiers => [:command]
+      end
+      main.submenu :edit do |edit|
+        edit.item :undo, :key => "z", :modifiers => [:command], :action => "undo:"
+        edit.item :redo, :key => "z", :modifiers => [:command, :shift], :action => "redo:"
+        edit.separator
+        edit.item :cut, :key => "x", :action => "cut:"
+        edit.item :copy, :key => "c", :action => "copy:"
+        edit.item :paste, :key => "v", :action => "paste:"
+      end
+      main.submenu :view do |view|
+        view.item :bigger, :key => "+", :modifiers => [:command], :action => "makeTextLarger:"
+        view.item :smaller, :key => "-", :modifiers => [:command], :action => "makeTextSmaller:"
+        view.separator
+        view.item :clear, :key => "k", :modifiers => [:command]
+      end
+      main.submenu :window do |win|
+        win.item :minimize, :key => "m"
+        win.item :zoom
+        win.separator
+        win.item :bring_all_to_front, :title => "Bring All to Front", :key => "o"
+      end
+      main.submenu :help do |help|
+        help.item :help, :title => "#{NSApp.name} Help"
+      end
+    end
+  end
+end

Added: MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/resources/HotConsole.icns
===================================================================
(Binary files differ)


Property changes on: MacRuby/branches/experimental/sample-macruby/HotCocoa/hotconsole/resources/HotConsole.icns
___________________________________________________________________
Added: svn:executable
   + *
Added: svn:mime-type
   + application/octet-stream
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/macruby-changes/attachments/20090805/cfcae315/attachment-0001.html>


More information about the macruby-changes mailing list