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):
So selecting the whole thing:
And hitting Tab, lovely result!