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!