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)
    super(parent, 
      :size => Wx::Size.new(150, -1), 
      :style => TR_DEFAULT_STYLE|TR_HAS_BUTTONS|TR_HIDE_ROOT|TR_LINES_AT_ROOT)
    @aui    = parent
    @type   = type
    @config = tpl ? tpl.config : YAML.load(File.open("config.yaml"))
    self.create_image_list(tpl)
    if tpl
      @class_jumper = tpl.class_jumper
      @lexer_conf   = tpl.lexer_conf
      @pr_info      = tpl.pr_info
    else
      @projects     = YAML.load(File.open("projects.yaml"))
      @lexer_conf   = Hash.new
      @class_jumper = Hash.new
      @pr_info      = Hash.new
      self.addProjects
      self.setupLexers
      self.traverse{|item_id| self.sort_children(item_id)}
    end
    evt_tree_item_activated self, :on_item_activated
  end

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:

config.yaml:

--- 
:lexers: 
  :pico: 
    :keywords: 'apply pass maps map mapc ...'
    :extensions: 
    - l
    :globals_rgx: 
    - !ruby/regexp /\((de|dm|class|=:)\s+([^\(\)\s]+)/
    - !ruby/regexp /([^'])(\*[^\(\)\s]+)/
    :tline_rgx: 
    - !ruby/regexp /^[#\n\r]/
    - !ruby/regexp /^\s+$/
    - !ruby/regexp /^\s+#/
    :class_construct: class
    :comp_limit: 3
:image_map:
  :web:
    :exts:
    - css
    - html
    - htm
    - js
    - xml
    - tpl
    :file: web.png
  :code:
    :exts:
    - l
    - php
    - rb
    :file: code.png
:icon_files:
  :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 :)

projects.yaml:

--- 
- :path: /opt/picolisp/projects/tpl-test
  :name: Tpl Test
  :open: true
  :libs:
  - /opt/picolisp/lib
- :path: /opt/picolisp/projects/tpl_test
  :name: Tpl Test2
  :open: true
  :libs:
  - /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
    else
      @icon_files = Hash.new
      @config[:icon_files].each{|key, file| @icon_files[key] = {:file => file} }
      @config[:image_map].each{|key, data| @icon_files[key] = {:file => data[:file]} }
    end
    images = Wx::ImageList.new(16, 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.new(icon_file, Wx::BITMAP_TYPE_PNG))
        @icon_files[key] = file_info
      end
      self.image_list = images
    end
  end

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] : [])
      end
    end
  end
  
  def createProjTree(parent_id, start_folder, libraries = [])
    rhsh = Hash.new
    d = Dir.new(start_folder)
    while name = d.read
      if(name != "." && name != "..")
        cur_path = start_folder + "/" + name
        if File.directory?(cur_path)
          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))
        else
          image = self.getImageWithPath(cur_path)
          cur_id = append_item(parent_id, name, image, image)
          rhsh[cur_id] = cur_path
        end
      end
    end
    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))
    end
    d.close()
    return rhsh
  end

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)
    end
    return @icon_files[:text][:pos]
  end
  
  def getExt(path)
    path.split(/\./).last
  end

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(File.new(file_name).readlines, lex_config[:globals_rgx], lex_config[:class_construct], tree_id, proj_id)
          end
        end
      end
      @lexer_conf[lexer] = lex_config
    end
  end
  
  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])
            else
              cur_id = append_item(file_id, name, @icon_files[:func][:pos], @icon_files[:func][:pos])
            end
            keyword_arr << name
            @class_jumper[cur_id] = self.getJumper(file_id, line_nbr, name, proj_id)
          end
        }
      end
      line_nbr += 1
    end
    return keyword_arr
  end
  
  def getJumper(file_id, line_nbr, name, proj_id)
    {:file_id => file_id, :line_nbr => line_nbr, :name => name, :proj_id => proj_id}
  end

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 
    end
    self.sort_children(root_id)
  end
  
  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
      end
    end
    @proj_id          = proj_id
    @pr_info[proj_id] = new_files
    @class_jumper     = new_jumpers
    self.sort_children(root_id)
  end

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)
    end
    @files        = file_nodes
    @class_jumper = class_nodes
    self
  end

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)
      @mgr.update
      @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])
    end
  end

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
        else
          return item_id
        end
      end 
    end
    return item_ids
  end
  
  def getJumpInfo(wrd, proj_id)
    self.getPathLineNbr(self.getIdFromName(wrd, proj_id), proj_id)
  end
  
  def getPathLineNbr(item_id, proj_id)
    if @pr_info[proj_id][item_id]
      {:path => @pr_info[proj_id][item_id], :line_nbr => 0}
    else
      {:path => self.getParPath(item_id, proj_id), :line_nbr => @class_jumper[item_id][:line_nbr]}
    end
  end
  
  def getParPath(item_id, proj_id)
    @pr_info[proj_id][ @class_jumper[item_id][:file_id] ]
  end

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
    end
    @class_jumper[item_id][: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])
    end
  end

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.setCurSci
      @sci.load_file(path)
      @sci.scroll_to_line(line_nbr)
      self.toggleSaved(@sci.saved = true)
    else
      @sci.goto_line(line_nbr)
    end
  end

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]
  end
  
  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
    else
      text1 <=> text2
    end
  end

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: , ,