Project browser in WxRuby

This article might bore you, in that case you might want to skip to the end where a pretty useful “trick” for custom sorting the tree items is described. You might also want to check out the image related stuff, it is more complex than in the sample treectrl.rb

In the prior article we went through the code that creates new TreeCtrls, in this tutorial we will take a look at the ProjTree class itself:

class ProjTree < Wx::TreeCtrl
  attr_accessor :pr_info, :lexer_conf, :class_jumper, :config, :icon_files
  def initialize(parent, tpl = false, type = :file_browser)
      :size =>, -1), 
    @aui    = parent
    @type   = type
    @config = tpl ? tpl.config : YAML.load("config.yaml"))
    if tpl
      @class_jumper = tpl.class_jumper
      @lexer_conf   = tpl.lexer_conf
      @pr_info      = tpl.pr_info
      @projects     = YAML.load("projects.yaml"))
      @lexer_conf   =
      @class_jumper =
      @pr_info      =
      self.traverse{|item_id| self.sort_children(item_id)}
    evt_tree_item_activated self, :on_item_activated

First we store the parent (AuiManager) in @aui for later reference. @type is used to control what happens when we have evt_tree_item_activated (more on that later…). If we have a template (tpl, currently it can only be the project browser tree when we create new result trees) we copy info from the template to the new tree, if not we load it. Some YAMLs:


    :keywords: 'apply pass maps map mapc ...'
    - l
    - !ruby/regexp /\((de|dm|class|=:)\s+([^\(\)\s]+)/
    - !ruby/regexp /([^'])(\*[^\(\)\s]+)/
    - !ruby/regexp /^[#\n\r]/
    - !ruby/regexp /^\s+$/
    - !ruby/regexp /^\s+#/
    :class_construct: class
    :comp_limit: 3
    - css
    - html
    - htm
    - js
    - xml
    - tpl
    :file: web.png
    - l
    - php
    - rb
    :file: code.png
  :class:       class.png
  :folder:      folder.png
  :func:        function.png
  :lib_folder:  lib_folder.png
  :line:        line.png 
  :open_folder: open_folder.png
  :project:     project.png
  :text:        text.png

So we’ve moved some regular expression from the class definition in the lexer to config.yaml instead, currently the tree class has no knowledge of the contents in the lexer classes, this is a way of managing the info it needs without cluttering the code. Each category (currently web and code) has extensions that will control which image that will represent them in the tree. I’m lazy so there’s only two categories so far :)


- :path: /opt/picolisp/projects/tpl-test
  :name: Tpl Test
  :open: true
  - /opt/picolisp/lib
- :path: /opt/picolisp/projects/tpl_test
  :name: Tpl Test2
  :open: true
  - /opt/picolisp/lib

Nothing special here, the root path, name, if it’s open or not, if not then we don’t parse it (should probably have some kind of special icon too…). Finally we have an array of additional library paths that should be included (they will however have their own icon, I’m not that lazy).

def create_image_list(tpl = false)
    if tpl
      @icon_files = tpl.icon_files
      @icon_files =
      @config[:icon_files].each{|key, file| @icon_files[key] = {:file => file} }
      @config[:image_map].each{|key, data| @icon_files[key] = {:file => data[:file]} }
    images =, 16, true)
    Wx::BusyCursor.busy do
      @icon_files.each do |key, file_info|
        icon_file = File.join(File.dirname(__FILE__), file_info[:file])
        file_info[:pos] = images.add(, Wx::BITMAP_TYPE_PNG))
        @icon_files[key] = file_info
      self.image_list = images

If we are loading the original project browser we work a little with the @config and add its image info to @icon_files, the image info stored in the config controls which image should be used for each extension (see the above YAML). We create a new ImageList, loop through the @icon_files and add each to the image list, each call to add will generate a unique id which we store in the :pos of each @icon_file sub-hash. This step is extremely important because that id is basically our only chance of retrieving the images later.

def addProjects()
    root_id = add_root("Projects", -1, -1)
    @projects.each do |pr|
      unless pr[:open] then pr[:name] += ", Closed" end
      proj_id = append_item(root_id, pr[:name], @icon_files[:project][:pos], @icon_files[:project][:pos])
      if pr[:open]
        @pr_info[proj_id] = self.createProjTree(proj_id, pr[:path], pr[:libs] ? pr[:libs] : [])
  def createProjTree(parent_id, start_folder, libraries = [])
    rhsh =
    d =
    while name =
      if(name != "." && name != "..")
        cur_path = start_folder + "/" + name
          image = @icon_files[:folder][:pos]
          cur_id = append_item(parent_id, name, image, image)
          set_item_image(cur_id, @icon_files[:open_folder][:pos], Wx::TREE_ITEM_ICON_EXPANDED)
          rhsh.merge!(createProjTree(cur_id, cur_path))
          image = self.getImageWithPath(cur_path)
          cur_id = append_item(parent_id, name, image, image)
          rhsh[cur_id] = cur_path
    libraries.each do |lib_path|
      cur_id = append_item(parent_id, self.getName(lib_path), @icon_files[:lib_folder][:pos], @icon_files[:lib_folder][:pos])
      rhsh.merge!(self.createProjTree(cur_id, lib_path))
    return rhsh

addProjects will recursively loop through the whole project folder structure and parse all files in all folders, we also use the extra library paths and do the same with them, they will end up as folders in the project root folder with their own icons. If we hadn’t needed to keep track of other things than just files and folders the GenericDirCtrl would probably have been enough.

getImageWithPath is where we finally connect icons to file types:

def getImageWithPath(path)
    return @icon_files[:text][:pos] unless path.include?(".")
    ext = self.getExt(path)
    @config[:image_map].each do |type, data|
      return @icon_files[type][:pos] if data[:exts].include?(ext)
    return @icon_files[:text][:pos]
  def getExt(path)

After loading all files and folders it’s time to populate the files with jumpers to their contents in the form of classes and functions for instance:

def setupLexers
    @config[:lexers].each do |lexer, lex_config|
      lex_config[:keyword_arr] = lex_config[:keywords].split(" ").find_all{|str| str.length > lex_config[:comp_limit]}
      @pr_info.each do |proj_id, files|
        files.each do |tree_id, file_name|
          cur_ext = self.getExt(file_name)
          if lex_config[:extensions].include?(cur_ext)
            lex_config[:keyword_arr] += self.getClassInfo(, lex_config[:globals_rgx], lex_config[:class_construct], tree_id, proj_id)
      @lexer_conf[lexer] = lex_config
  def getClassInfo(line_arr, regex_arr, class_construct, file_id, proj_id)
    keyword_arr = []
    cur_class   = nil
    cur_id      = nil
    line_nbr    = 0
    line_arr.each do |line|
      regex_arr.each do |rgx|
        line.scan(rgx){|construct, name|
          unless keyword_arr.include?(name)
            if construct == class_construct
              cur_class = cur_id = append_item(file_id, name, @icon_files[:class][:pos], @icon_files[:class][:pos])
            elsif cur_class != nil
              cur_id = append_item(cur_class, name, @icon_files[:func][:pos], @icon_files[:func][:pos])
              cur_id = append_item(file_id, name, @icon_files[:func][:pos], @icon_files[:func][:pos])
            keyword_arr << name
            @class_jumper[cur_id] = self.getJumper(file_id, line_nbr, name, proj_id)
      line_nbr += 1
    return keyword_arr
  def getJumper(file_id, line_nbr, name, proj_id)
    {:file_id => file_id, :line_nbr => line_nbr, :name => name, :proj_id => proj_id}

Pretty boring stuff, with the help of the regex info we have in the YAML config we can parse each document in the project in the same way as we handle a single document in the Scintilla component, see the prior tutorial on autocompletion for details. In fact @lexer_conf is used by each Scintilla to get the info it needs for autocompletion, there is a reference in them to the project tree.

def buildFindDoc(lines, page_id)
    root_id     = add_root("In Current Document", -1, -1)
    @page_id    = page_id
    @lines      = {}
    lines.each do |nbr, label|
      @lines[ append_item(root_id, label, @icon_files[:line][:pos], @icon_files[:line][:pos]) ] = nbr 
  def buildFindProj(files, proj_id)
    new_files   = {}
    new_jumpers = {}
    root_id     = add_root("Search Result", -1, -1)
    files.each do |file_id, jumpers|
      cur_path                = @pr_info[proj_id][file_id]
      sel_image               = self.getImageWithPath(cur_path)
      new_file_id             = append_item(root_id, self.getName(cur_path), sel_image, sel_image)
      new_files[new_file_id]  = cur_path
      jumpers.each do |jumper|
        jumper_id               = append_item(new_file_id, jumper[:name], @icon_files[:line][:pos], @icon_files[:line][:pos])
        jumper[:file_id]        = new_file_id
        new_jumpers[jumper_id]  = jumper
    @proj_id          = proj_id
    @pr_info[proj_id] = new_files
    @class_jumper     = new_jumpers

These two are kind of similar and if more attention is given to the above code I’m sure it can be slimmed down so that the find project tree can make use of the find doc logic.

We also build a result tree if we try to code jump to the definition and there are two or more definitions:

def buildJumpTree(keepers, proj_id)
    root_id     = add_root("Jump Result", -1, -1)
    file_nodes  = {}
    class_nodes = {}
    keepers.each do |keep_id|
      file_path = self.getParPath(keep_id, proj_id)
      file_name = self.getName(file_path)
      sel_image	= self.getImageWithPath(file_path)
      new_file_id = append_item(root_id, file_name, sel_image, sel_image)
      file_nodes[ new_file_id ] = file_path
      new_class_id = append_item(new_file_id, @class_jumper[keep_id][:name], @icon_files[:class][:pos], @icon_files[:class][:pos])
      class_nodes[new_class_id] = self.getJumper(new_file_id, @class_jumper[keep_id][:line_nbr], @class_jumper[keep_id][:name], proj_id)
    @files        = file_nodes
    @class_jumper = class_nodes

In this case the AUI will send us a list of items to keep, we will simply make use of the project tree info and draw only the objects we tried to jump to instead of the whole tree. The calling code in editor.rb:

def onJumpTo
    wrd     = @sci.getCurWord
    keepers = @project_tree.getIdFromName(wrd, @sci.proj_id, true)
    if keepers.length > 1
      @treebook.add_page(self.create_tree(@project_tree), wrd, true)
      @cur_tree.buildJumpTree(keepers, @sci.proj_id).expand_all
    elsif keepers.length == 1
      info = @project_tree.getJumpInfo(wrd, @sci.proj_id)
      self.openFile(@sci.getLexer, info[:path], @sci.proj_id, info[:line_nbr])

And getIdFromName:

def getIdFromName(wrd, proj_id, multi = false)
    item_ids = []
    @class_jumper.each do |item_id, item_info|
      if item_info[:name] == wrd && item_info[:proj_id] == proj_id
        if multi
          item_ids << item_id
          return item_id
    return item_ids
  def getJumpInfo(wrd, proj_id)
    self.getPathLineNbr(self.getIdFromName(wrd, proj_id), proj_id)
  def getPathLineNbr(item_id, proj_id)
    if @pr_info[proj_id][item_id]
      {:path => @pr_info[proj_id][item_id], :line_nbr => 0}
      {:path => self.getParPath(item_id, proj_id), :line_nbr => @class_jumper[item_id][:line_nbr]}
  def getParPath(item_id, proj_id)
    @pr_info[proj_id][ @class_jumper[item_id][:file_id] ]

Finally, when we click on something:

def getProjId(item_id)
    @pr_info.each do |proj_id, files|
      if files.has_key? item_id then return proj_id end
  def on_item_activated(event)
    case @type
    when :file_browser
      proj_id	= self.getProjId(event.item)
      info		= self.getPathLineNbr(event.item, proj_id)
      @aui.openFile(self.getLexerFromExt(info[:path]), info[:path], proj_id, info[:line_nbr])
    when :file_jumper
      @aui.jumpInPage(@page_id, @lines[event.item])
    when :proj_jumper
      info = self.getPathLineNbr(event.item, @proj_id)
      @aui.openFile(self.getLexerFromExt(info[:path]), info[:path], proj_id, info[:line_nbr])

First we check if the current tree type is :file_browser (default) in that case we get the the current project id and info containing the path to the file to open, the lexer to use and the line number to jump to. If the type is :file_jumper we simply jump to the line in question in a document that we can find with @page_id which we set in buildFindDoc. This enables us to completely disconnect the result trees from whatever document generated them, this will increase their usefulness as the work used to create them won’t have to be repeated as long as they are there. Without this logic they would lose their usefulness as soon as we browsed away from whatever document was used to create them.

openFile is located in the AUI:

def openFile(lexer, path, proj_id, line_nbr = 0)
    if @notebook.get_selection == -1 || @sci == nil || @sci.file_path != path
      @notebook.add_page( create_text_ctrl(path, proj_id, lexer), path.split(/[\/\\]/).last, true, @page_bmp )
      self.toggleSaved(@sci.saved = true)

In order for this to work we first have to check that we are not in fact going to jump to somewhere in the current document we have open in which case we just goto_line. If not we add a new page to the right notebook, note that each Scintilla will keep track of the project id it belongs to, the lexer to use and the path to the file it works with. The fact that we pass true as the second last argument to add_page will make sure that a select event is generated, otherwise the new tab would end up underneath whatever tab was there before.

We proceed with loading the file to work with, scroll to the correct line and toggle saved to true, the reason for that last action is that we are calling the save/not saved logic on modify in the Scintilla, that will signal the document as unsaved when opened even though we actually haven’t done anything to change it. The reason we can’t use on char added is because the document changes when we for instance delete characters and that will not fire on char added. It’s a mess…

One more thing, you might have seen the use of sort_children in the tree control, sort_children uses a method called on_compare_items which basically sorts properly per default in most cases, not in our case though when we have line jumpers looking like this: 100: (car something) (see one of the images in the prior tutorial). In this case 100 would end up above 2 so we need to override this one:

def isLine?(item_nbr)
    self.get_item_image(item_nbr) == @icon_files[:line][:pos]
  def on_compare_items(item1, item2)
    text1 = self.get_item_text(item1)
    text2 = self.get_item_text(item2)
    if self.isLine?(item1) && self.isLine?(item2)
      text1.split(': ').first.to_i <=> text2.split(': ').first.to_i
      text1 <=> text2

First we get the labels of the two rows to compare, then we check if they are both lines. If they are we extract the numbers from the labels and use them to compare with instead. If not we just compare as usual.

I think I got most of the important stuff, anyway there will be zipped source at the end of this series so don’t worry!

Related Posts

Tags: , ,