Scintilla basics in WxRuby

Update: The project is now on bitbucket.

As you might know I’ve recently started dabbling in web development with Pico Lisp and currently I’m using Cusp as an editor. It’s an annoying situation because Cusp is made for Common Lisp so today I started looking into the possibility of creating an editor with WxRuby using the Scintilla component. If you have looked at my old WxRuby project you might have noticed that I used WxSugar, apparently most of the functionality of WxSugar is already built into WxRuby nowadays so I’m gonna try without it this time.


The results are encouraging, in just one day I’ve managed to replicate a lot of what Cusp is doing for me at the moment; automatic parens matching and warning if there are unmatched parenses. The added bonus is proper syntax highlighting. Still to do is for example a project browser and syntax hints.

I started out with the scintilla.rb in the WxRuby samples folder, all the code in this tutorial is based on that example. One of the first things you note is the use of set_lexer with STC_LEX_RUBY, so Googling for that gives me some part of Boa Constructor as a result. In that code listing is a list of the available constants, and lo and behold, STC_LEX_LISP is one of them, perfect! First I toyed with the idea of creating my own lexer but after trying out STC_LEX_LISP with a few modifications (see below) I don’t really see the point, I’ve got everything I want at the moment except comment coloring, but I’m sure I can override that somehow too.

Update:
I’ve launched my own syntax highlighter since I wrote the above, see the syntax highlighting article for more info.

The most important documents so far have been the WxRuby Wx::StyledTextCtrl class reference and the Yellowbrain guide to wxPython. Let’s move on to the modified scintilla.rb:

begin
  require 'rubygems' 
rescue LoadError
end
require 'wx'
include Wx

require 'lexer.rb'

Minimal_Quit = 1
Minimal_About = ID_ABOUT
Toggle_Whitespace = 5000
Toggle_EOL = 5001

class MyFrame < Frame
  def initialize(title,pos,size,style=DEFAULT_FRAME_STYLE)
.
. #no changes from the original here
.

    self.loadLexer('pico')
    
. #the style_set_foreground and background lines have been moved to lexer.rb
. #otherwise same same
.

    evt_stc_updateui(@sci.get_id){|evt| onUpdateUI(evt)}
    
  end

  def loadLexer(lex_name)
    require lex_name + ".rb"
    @cur_lexer = eval(lex_name.capitalize + ".new")
    @cur_lexer.loadLexer(@sci)
  end
  
. . .

  def onUpdateUI(evt)
    chr = @sci.get_char_at(@sci.get_current_pos - 1)
    @cur_lexer.onUpdateUI(@sci, chr)
  end
  
  def onCharadded(evt)
    chr =  evt.get_key
    curr_line = @sci.get_current_line
    @cur_lexer.onCharadded(@sci, chr)
    if(chr == 13 || chr == 10)
        if curr_line > 0
          line_ind = @sci.get_line_indentation(curr_line - 1)
          if line_ind > 0
            @sci.set_line_indentation(curr_line, line_ind)
            @sci.goto_pos(@sci.position_from_line(curr_line)+line_ind)
          end
        end
    end
  end

. . .

end

. . .

As you see I’ve made some kind of attempt here to separate lexer behavior from more general behavior by putting a lot of the logic in lexer.rb and pico.rb where Pico inherits from Lexer. Let’s take a look at lexer.rb first:

class Lexer
  def loadLexer(sci)
    sci.style_set_foreground(STC_STYLE_DEFAULT, @frg_color);
    sci.style_set_background(STC_STYLE_DEFAULT, @bkg_color);
    sci.style_set_foreground(STC_STYLE_LINENUMBER, LIGHT_GREY);
    sci.style_set_background(STC_STYLE_LINENUMBER, WHITE);
    sci.style_set_foreground(STC_STYLE_INDENTGUIDE, LIGHT_GREY);
    sci.set_lexer(@lex_num)
    sci.style_clear_all
    @colors.each{|num, c| sci.style_set_foreground(num, Wx::Colour.new(c[0], c[1], c[2]))}
    sci.set_key_words(0, @keywords)
  end
  
  def onCharadded(sci, chr)
  end
  
  def onUpdateUI(sci, chr)
  end
end

So @frg_color, @bkg_color, @lex_num, @keywords and @colors need to be set in the child which in this case is pico.rb:

class Pico < Lexer
  def initialize
    @keywords = "apply pass maps map mapc maplist mapcar... and many more but not important for this tutorial"
    @lex_num = STC_LEX_LISP
    @colors = {
      2 => [255, 0, 0], #numbers
      3 => [0, 0, 150], #keywords
      5 => [150, 0, 150], #'quoted
      6 => [200, 150, 0], #strings "hello"
      7 => [0, 255, 0],
      34 => [0, 255, 0], #bracket good light
      35 => [255, 0, 0] #bracket bad light
    }
    @bkg_color = Wx::Colour.new(230, 230, 230);
    @frg_color = BLACK
  end
  
  def loadLexer(sci)
    super(sci)
  end

I haven’t been able to figure out what style #7 is doing in STC_LEX_LISP, possibly nothing. The loadLexer function in Lexer should make more sense now anyway. Let’s take a look the most important stuff:

def findParPos(sci, cur_pos)
    remaining_txt = sci.get_text_range(cur_pos + 1, sci.get_text_length)
	lvl = 1
    pos = 1
    remaining_txt.each_byte{|chr|
	  if(lvl != 0)
        if(chr == 41)
          lvl -= 1
        elsif(chr == 40)
          lvl += 1
        end
      else
        break
      end
      pos += 1
    }
    pos
  end
  
  def onCharadded(sci, chr)
    cur_pos = sci.get_current_pos
    if(chr == 40)
      nxt_chr = sci.get_char_at(cur_pos)
      if(nxt_chr != 40)
        sci.add_text(")")
      else
        sci.insert_text(self.findParPos(sci, cur_pos) + cur_pos, ")")
      end
      sci.set_selection(cur_pos,cur_pos)
    end
  end
  
  def onUpdateUI(sci, chr)
    if(chr == 40 or chr == 41)
      cur_pos = sci.get_current_pos - 1
      other_pos = sci.brace_match(cur_pos)
      if(other_pos != -1)
        sci.brace_highlight(cur_pos, other_pos)
      else
        sci.brace_bad_light(cur_pos)
      end
    end
  end
end

The two bottom functions are called from the main loop whenever a character is added and whenever the UI is redrawn respectively. In this case we need onUpdateUI to constantly display feedback through brace matching, here being done only on ‘(’ and ‘)’ since these are the only characters we need to match in Lisp. When I move the cursor through the text this code will display matching parens in green and a red parens if no match can be found, this is a very important behavior when coding in Lisp.


Whenever a character is added we check if it is ‘(’, in which case we check if the next character also is ‘(’. If it is not we simply add a ‘)’ directly after so we don’t need to do this trivial matching ourselves. However, if the next character is yet another ‘(’ we need to start counting with the goal of adding a matching parens at the end of the “line”. Example: We start with (cdr (car List)), if a parens is added like this: ((cdr (car List)) we automatically need to match to create the following: ((cdr (car List))). This is a behavior that I’ve noticed is speeding things up considerably when I work in Cusp.

To do this we need to know the place where we have to insert the last ‘)’, that is done with findParPos which will find the offset from the current position by looping through all remaining characters - after the first matched ‘(’ - in the document and take note of each parens pair. We will insert our new ‘)’ after the position that marks the ‘)’ that finally creates an equal number of ‘(’ and ‘)’.

That’s it for now, we’ve got the basic editing of Cusp, now we need a project browser and syntax completion/jumping, possibly also a class browser integrated in the project browser but I’ll settle for just a project browser :)

I almost forgot, we need proper Lispy indenting with the ability to select a whole function and do tab to “clean” the whole thing up indentation-wise. That will be the immediate priority.

Related Posts

Tags: , ,