More Scintilla in WxRuby

Getting the Lispy indentation to work was easier said than done but I believe I have prevailed. I haven’t done any massive testing yet but so far I’m getting the behavior I want. A lot has changed in pico.rb, not so much in main.rb though, let’s walk through the new stuff, in main.rb:

evt_stc_modified(@sci.get_id){|evt| onModified(evt)}

The above line was added below the rest of the evt_stc_* lines. It will fire whenever the document has been modified, so far the only thing it does is checking for a TAB press and performing our custom indentation. This is actually an ugly workaround, the original plan was to have a SHIFT+TAB custom event so I turned off that combo with cmd_key_clear with the intention of adding it to an AcceleratorTable instead. However

An accelerator takes precedence over normal processing and can be a convenient way to program some event handling. For example, you can use an accelerator table to enable a dialog with a multi-line text control to accept CTRL-Enter as meaning `OK’ (but not in GTK+ at present).

did not sound good. I want this to work in Linux too. Therefore I added it as a proper menu item with an accelerator and everything but somehow the TAB combo would simply not work anymore, possibly because of some kind of conflict with Scintilla, what do I know. Another key than TAB for this is unacceptable, hence the workaround.

def onUpdateUI(evt)
    chr = @sci.get_char_at(@sci.get_current_pos - 1)
    @cur_lexer.onUpdateUI(@sci, chr)
  end
  
  def onModified(evt)
    chr = @sci.get_char_at(@sci.get_current_pos)
    @cur_lexer.onModified(@sci, chr)
  end
  
  def onCharAdded(evt)
	chr = @sci.get_char_at(@sci.get_current_pos - 1)
    @cur_lexer.onCharAdded(@sci, chr)
  end
  
  def onMarginClick(evt)
    line_num = @sci.line_from_position(evt.get_position)
    margin = evt.get_margin
    if(margin == 1)
      @sci.toggle_fold(line_num)
    end
  end

Here is actually another small workaround evident, for some reason evt.get_key didn’t work with Ubuntu Feisty I’m working on - in the form of a VM - at home, chr = @sci.get_char_at(@sci.get_current_pos - 1) works just fine though.

Most changes are in pico.rb, let’s start with onModified:

def onModified(sci, chr)
    if(chr == 32 && sci.get_selection_start != sci.get_selection_end && sci.get_column(sci.get_current_pos) == 0)
      @back_tab = true
      @start_nbr = sci.line_from_position(sci.get_selection_start)
      @end_nbr = sci.line_from_position(sci.get_selection_end)
    end
  end

Unfortunately we can’t do anything actively when onModified is fired, only record state, therefore we set some variables instead. When TAB is pressed while having selected an s-expression all lines will per default be indented with a predefined number of spaces, in my case 4 I think, after that the cursor will end up at the first position on the first row of the expression. We check for this position, a selection and a space in the current position, if all these conditions are met it can only mean one thing, we have a multi line indent command which instead of indenting everything should “clean up” the whole s-expression a la Cusp. Therefore we set the @back_tab flag to true, and the start + end line we are going to work with.

def onUpdateUI(sci, chr)
    if(@back_tab) then self.backTab(sci) end
    if(chr == 40 || 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

As it happens onUpdateUI will fire immediately after onModified, the user will never notice our ugly after-the-fact hack.

def backTab(sci)
    sci.set_selection(@start_nbr, @start_nbr)
    (@start_nbr..@end_nbr).each do |cur_ln_nbr|
      sci.goto_pos(sci.position_from_line(cur_ln_nbr))
      self.indentLine(sci, cur_ln_nbr, sci.get_current_pos)
    end
    @back_tab = false
  end

We start with getting rid of any possible selection to prevent any unforeseen effects associated with such a state, it might very well be that this line is unnecessary. The main thing is that we will loop through the affected lines and indent them one by one. During every iteration we jump to the first position on every line.

def findParPos(sci, pos, direction)
    lvl       = 1
    dq        = 0
    inc       = direction == "right" ? 1 : -1
    pos_func  = direction == "right" ? "position_after" : "position_before"
    while(lvl != 0 && pos != sci.get_text_length)
      case sci.get_char_at(pos)
      when 34
        dq += 1
      when 41 
        if dq % 2 == 0 then lvl -= inc end 
      when 40
        if dq % 2 == 0 then lvl += inc end  
      end
      if pos == 0 then break end
      pos = eval("sci." + pos_func + " " + pos.to_s)
    end 
    pos
  end

  def parensCount(line)
    line.gsub!(/"[^"]+"/, '')
    line.index(/[\(\)]/) != nil ? line.count("(") - line.count(")") : 0
  end

  def indentLine(sci, line_nbr, cur_pos)
    line_ind = 0
    if(line_nbr > 0)
      above_line = sci.get_line(line_nbr - 1)
      if(above_line.chomp.length > 0)
        par_count = self.parensCount(above_line)
        stack = par_count > 0 ? "(" * (par_count + 1) : false
        if(stack && above_line.index(stack) != nil)
          line_ind = above_line.index(stack) + par_count
        else
          line_ind = sci.get_column(self.findParPos(sci, cur_pos, "left")) + 3
        end
      end
    end
    sci.set_line_indentation(line_nbr, line_ind)
    sci.goto_pos(sci.position_from_line(line_nbr) + line_ind)
  end

This is the “engine” of this whole thing if you will. We will first check if we are on the first line in the editor or if the above line simply is a bunch of white spaces, if that is the case we simply ident with 0 spaces. If not we will use findParPos (which has been remade since the prior tutorial I might add) to get the position of the parens that completes the pairing. In this case we will walk to the left, or upwards if you will. Note the use of position_after etc, using these functions instead of simply iterating should ensure that we handle strange character encodings properly.

Character 34 is “, we don’t count parentheses inside strings. When the modulus returns non zero we know we are inside one.

Note the check for par_count bigger than zero and the following condition and action, this section is for checking if we are working with a quoted list, if that is the case then the indentation logic needs to account for it. This behavior hasn’t been tested thoroughly yet, it works with simpler snippets though.

onCharAdded has been refactored a little bit:

def onCharAdded(sci, chr)
    case chr
    when 40
      self.insertParens(sci)
    when 34
      sci.add_text('"')
      sci.goto_pos(sci.get_current_pos - 1)
    when 10 || 13
      self.indentLine(sci, sci.get_current_line, sci.get_current_pos - 1)
    end
  end
  
  def insertParens(sci)
    cur_pos = sci.get_current_pos
    if(sci.get_char_at(cur_pos) != 40)
      sci.add_text(")")
    else
      sci.insert_text(self.findParPos(sci, cur_pos + 1, "right"), ")") 
    end
    sci.set_selection(cur_pos,cur_pos)
  end

In this case we will walk to the right to enable the automatic parens insertion. Some changes to initialize have been made, most notably the ability to set background colors for various things, I only use it with the bracket matching now to make them stand out more than before:

def initialize
    @keywords = "apply pass maps map mapc maplist..."
    @lex_num = STC_LEX_LISP
    @frg_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, 0, 0], #bracket good light
      35 => [0, 0, 0] #bracket bad light
    }
	@bkg_colors = {
      34 => [125, 165, 225], #bracket good light
      35 => [112, 50, 50] #bracket bad light
    }
    @bkg_color = Wx::Colour.new(230, 230, 230);
    @frg_color = BLACK
    @back_tab = false
  end
  
  def loadLexer(sci)
    super(sci)
    sci.set_tab_width(4)
    sci.set_use_tabs(false)
    sci.set_tab_indents(true)
    sci.set_back_space_un_indents(true)
    sci.set_indent(4)
    sci.set_edge_column(80)
  end

And in lexer.rb, the only addition in loadLexer:

@bkg_colors.each{|num, c| sci.style_set_background(num, Wx::Colour.new(c[0], c[1], c[2]))}

Finally I can say I have the basic editing power of Cusp, at least the parts I’m using at the moment, there might be more cool stuff there that I haven’t discovered yet. However the above makes for a satisfying Lisp editor as far as I’m concerned. At last we can start looking at stuff like auto completion and a project browser.

A completely incoherent s-expression (it never gets this bad but often they get somewhat misaligned during normal editing):

Mangled S-expression

So selecting the whole thing:

Selected s-expression

And hitting Tab, lovely result!

Related Posts

Tags: , ,