MacDevCenter    
 Published on MacDevCenter (http://www.macdevcenter.com/)
 See this if you're having trouble printing code examples


Programming With Cocoa
An Introduction to RubyCocoa, Part 2

by Christopher Roach
10/12/2004

Editor's note -- In Part 1 of this two-part series, Christopher Roach provided some background and helped you get started with RubyCocoa programming. In today's conclusion he gets into the actual code ... and if you're following along, you'll end up with a functioning application.

Creating a Skeleton

The first thing we'll need to do before we can begin coding our application is to add a new Ruby file. We'll need to add a class that inherits from the NSObject class. To do so, control-click on the "Classes" directory under "Group & Files" in Xcode. Select "Add->New File..." from the pop-up menu, and then select Ruby NSObject subclass under Ruby in the "New File" dialogue.

Click "Next," and title your new Ruby class Controller.rb, or whatever you titled your controller class in Interface Builder (IB). Then add the new class to the current project and target and click "Finish." Once you've done this you should have a skeleton for a Ruby class that inherits from the NSObject class found in the RubyCocoa OSX module. Next we'll need to add our outlets to the new class.

We add outlets to a Ruby class by calling the ns_outlets method and passing to it a list of symbols representing the names of the outlets we wish to create. This should look very similar to calling the attr_writer method that Ruby provides for creating writer methods for instance variables. You could just as easily substitute attr_writer for the ns_outlets method and the application would still work without incident. You can also use the alias ib_outlets to replace the ns_outlets method as I do in my code.

After we've added all five of our IB outlets to our new Ruby class, we need to add the methods that correspond to the actions we created in IB. We do this by creating a method for each of the actions using the same name as the action we created that takes one parameter for the sender of the message. Once we have added all five actions, we should have a class skeleton similar to the following.

require 'osx/cocoa'

class Controller < OSX::NSObject
	include OSX

	ib_outlets :archiveFile, :fileTypeView,
		:fileTableView, :fileType, :mainWindow

	def addFile(sender)
		puts "addFile method"
	end

	def removeFile(sender)
		puts "removeFile method"
	end

	def browseForArchive(sender)
		puts "browseForArchive method"
	end

	def createArchive(sender)
		puts "createArchive method"
	end

	def extractArchive(sender)
		puts "extractArchive method"
	end
end
O'Reilly Mac OS X Conference

There are two things that you should notice in the code above that are different from what I've already told you to add to your new file. First, I have added a line to each method that prints a string to the console window. This is strictly for testing. If you like, you can go ahead and run your application and try out each of the buttons to make sure that each one works properly. Afterward, make sure you remove each of the puts statements so that our finished application is not trying to print to the console every time we invoke an action.

Next, you'll notice that I've added a line to the beginning of the class definition that includes the OSX module found in the RubyCocoa framework. If you'll remember back at the beginning of this tutorial, I stated that every class in the RubyCocoa framework can be found in the OSX module. What we've done here by including it in our class is to mix-in (Ruby's method for avoiding multiple inheritance) all of these classes, methods, and variables into our class, giving us direct access to everything that the RubyCocoa framework has to offer.

So, you've tried out your new RubyCocoa application and everything seems to be working fine so far, right? And, you've also gone back through the code and scrubbed it by removing all of those unnecessary puts commands, correct? Great, then you've got everything ready for our final section in which we add all of the rest of the class's internals and get our application fully functioning.

Adding the Guts

We'll start by setting up the methods that will be used to manipulate our NSTableView object. The two methods we need to implement for this are the addFile and removeFile methods. However, before we can get these methods working properly we'll need to set up an object to act as our data source for the NSTableView instance.

Our application is simple enough that we will not be creating a separate data structure to act as the data source for our NSTableView. Instead, our Controller will be acting as our data source. To do this, we need to implement two methods in our Controller class as a minimum to allow it to act as a NSTableView data source. These two methods are the numberOfRowsInTableView method that returns the number of files in our table and the tableView_objectValueForTableColumn_row method that returns the value found in the cell currently selected in our table. Below is the implementation for each of these methods.

###
# numberOfRowsInTableView
#  Returns the number of records in the table.
#  This must be implemented by whatever class
#  acts as the data source for the NSTableView
#  class.
###
def numberOfRowsInTableView(afileTable)
	@files.length
end

###
# tableView_objectValueForTableColumn_row
#  Returns the value corresponding to the cell
#  (row and column intersection) the user has
#  currently selected. This must be
#  implemented by whatever class acts as the
#  data source for the NSTableView class.
###
def tableView_objectValueForTableColumn_row(
	afileTable, aTableColumn, rowIndex)
	@files[rowIndex]
end

Both of these methods are pretty simple for our application. The first method simply returns the number of elements in an array called @files (we'll find out more about this array in a second). The second method normally takes the point of intersection between the column and row currently selected and returns a value representing the chosen cell. However, our application only has a single column, so that makes the column number unnecessary in discovering the value of the chosen cell. All we need to do is return the element found at the index correlating to the currently selected row, which is exactly what we do in the code above. Now we just need to create an instance of the Array class and assign it our @files instance variable.

Our @files array that we use to hold the names of the files to be found in our application's table view object needs to be created before our two new methods are called. The best way to do this is to add an initialize method (Ruby's class constructor) and create an instance of the Array class there, just like the code below shows.

###
# initialize
#  This is the Ruby constructor for a class.
#  We use it to create a new
#  instance of the Array class to hold records
#  for our files table.
###
def initialize
	@files = Array.new
end

We now have our necessary methods for acting as a data source for the NSTableView object and we have created the array that will hold the information displayed in that table, but we still haven't implemented the addFile and removeFile methods that will manipulate the table. Lets go ahead and do that now.

First, add the following implementation to our addFile method:

###

# addFile

#  Displays an instance of the NSOpenPanel and

#  gets the name and location of one or more

#  files that the user wishes to add to the

#  archive.

###

def addFile(sender)

	oPanel = NSOpenPanel.openPanel

	oPanel.setAllowsMultipleSelection(true)

	buttonClicked = oPanel.runModal



	if buttonClicked == NSOKButton

		files = oPanel.filenames

		count = files.count



		for i in 0..count - 1

			@files.push(

				files.objectAtIndex(i))

		end



		@fileTableView.reloadData

	end

end

This method displays an NSOpenPanel that allows the user to select multiple files to be placed into the new archive. Thus, the first thing we need to do is create an instance of the NSOpenPanel class. This is exactly what we do on the first line of the method. This line also shows us how the invocation of a Cocoa method looks in Ruby.

In this line we call the openPanel method of the NSOpenPanel class that can be found in the OSX module of the RubyCocoa framework. Since we included the module in our class, we don't have to prefix the NSOpenPanel class with the module name (e.g., OSX::NSOpenPanel). Instead all of the classes and methods of the OSX module are mixed-in to our Controller class.

The next line sets the panel to accept multiple selections, allowing the user to select as many files as he/she pleases. After that, we display our newly created panel to the user and await his or her response. Once the user has disengaged the NSOpenPanel, we check the button that was clicked and make sure the user clicked "OK" before proceeding. If so, we get the names of the files selected and cycle through them adding each file to the @files array. Now that we've added all of the files to the array, we reload the data into the table. This will display all of the elements currently in our @files array.

The removeFile method is just as simple as the addFile method, as you can clearly see from its implementation below:

###

# removeFile

#  Removes the selected file(s) from the list

#  of files for the archive.

###

def removeFile(sender)

	# Get all of the selected indices from the

	# table, and convert it to a Ruby array

	indices = @fileTableView.

		selectedRowEnumerator.allObjects.to_a

	# Get the number of indices in the array

	count = indices.length - 1



	# Reverse the order of the array before

	# removing the elements to avoid changing

	# the index number of the elements we wish

	# to remove. (e.g., if we have an array

	# [a,b,c] and we are removing elements at

	# indexes 1 and 2 we run into a problem.

	# Once element b is removed element c

	# shifts to position 1 and is not removed

	# at the second call to delete_at.)

	indices = indices.reverse



	# Remove all of the selected files

	for i in 0..count

		@files.delete_at(

			Integer(indices[i]))

	end

	@fileTableView.reloadData

end

The removeFile method starts off by getting an array of the indices of the selected elements in the table that the user has chosen for deletion. We then cycle through the list of indices deleting the element in the array at the position described by the current index.

@files array is to reverse the order of the indices, so that we start deleting items from the end of the array and progress towards the beginning. The reason for doing this is to avoid altering the index of an element by deleting another element before it and shifting the latter element forward to a lower index. If we delete from the last index back, we shift only the elements after the ones that we wish to remove, thus we avoid deleting the wrong elements in our array.

Now we have all of the code we need to allow the user to manipulate the files table in our application. If you wish, go ahead and try it out just to make sure the "+" and "-" buttons work properly. However, you will run into one problem — nothing happens. The reason for this is that we haven't told the NSTableView object that our Controller class is its data source.

We can easily do that by switching back over to IB for a second and double-clicking the NSTableView in our application until a thick line appears around it. Then control-click the table and drag a connection line to the instance of our Controller class in the "MainMenu.nib" window. In the "Info" window select "dataSource" in the "Outlets" tab of the "Connections" panel and click the "Connect" button. Now save the nib file in IB and return to Xcode and try running the application again. This time we should be able to add and remove files from our table without any problems.

We have the ability to add and remove files from our files table working properly, and there's only one method left to implement in the "Create" tab. We need to implement the method that will create the actual archive file and perhaps compress it if the user has chosen to do so.

The createArchive method needs to start off by displaying an NSSavePanel that allows the user to choose a name and a location for the new archive. After getting this information, our createArchive method is going to create a temporary directory and copy all of the selected files into it. Then the temporary directory will be tarred and compressed and copied to its chosen location.

Finally, our createArchive method will do a little cleaning up afterwards by removing the temporary folder and all of its contents. If all goes well, the user should receive an NSAlert message letting them know everything executed properly. The following code does all of this and needs to be copied into our application.

###

# createArchive

#  Displays an instance of the NSSavePanel and

#  gets a filename and location for the

#  archive file being created.

###

def createArchive(sender)

	sPanel = NSSavePanel.savePanel

	sPanel.setExtensionHidden true

	sPanel.setAccessoryView @fileTypeView

	filename = NSString.alloc.initWithString(

		"Untitled")

	buttonClicked = sPanel.runModal



	if buttonClicked == NSOKButton

		filetype =

			@fileType.titleOfSelectedItem.to_s

		directory = sPanel.filenames.

			objectAtIndex(0).to_s.split('/');

		filename = directory.pop

		directory = directory.join("/")



		# Call to_s to turn an NSString into a

		# Ruby string before performing a case

		# statement on the value

		case filetype.to_s

			when "tgz"

				tarCommand = "tar -cvzf " +

				"#{filename}.tgz #{filename}"

			when "bz2"

				tarCommand = "tar -cvjf " +

				"#{filename}.bz2 #{filename}"

			when "Z"

				tarCommand = "tar -cvZf " +

				"#{filename}.Z #{filename}"

			when "tar"

				tarCommand = "tar -cvf " +

				"#{filename}.tar #{filename}"

		end



		# Create an alert dialog to display

		# the outcome of the tar command

		alert = NSAlert.alloc.init

		alert.setMessageText(

			"Creation Successful")

		alert.setInformativeText(

			"The tar file was successfully " +

			"created")

		alert.setAlertStyle(

			NSInformationalAlertStyle)

		alert.addButtonWithTitle("Ok")



		begin

			# Create a temporary directory to

			# hold the files for the archive

			system("mkdir #{filename}")



			# Copy all of the selected files

			# to the archive directory

			@files.each do |file|

				system("cp '#{file.to_s}' " +

					"'#{filename}'")

			end



			# Execute the tar command

			system(tarCommand)



			# Copy the tar file to the chosen

			# directory

			system("mv " +

				"'#{filename}.#{filetype}' " +

				"'#{directory}/#{filename}." +

				"#{filetype}'")



			# Remove the temporary directory

			system("rm -rf '#{filename}'")

		rescue

			alert.setMessageText(

				"Creation Unsuccessful")

			alert.setInformativeText(

				"An error occurred while " +

				"creating the archive file." +

				"\nMake sure you have the " +

				"correct permissions and " +

				"try again.")

			alert.setAlertStyle(

				NSCriticalAlertStyle)

		end



		# Display an alert box

		alert.beginSheetModalForWindow(

			@mainWindow,

			:modalDelegate, self,

			:didEndSelector, nil,

			:contextInfo, nil)



	end

end

You'll notice that the first section of this method looks similar to the addFile method we created earlier. That's because we're creating an instance of the NSSavePanel, which is very similar to the NSOpenPanel. One key difference you'll notice here is that we're calling a method called setAccessoryView and we pass into it our custom view we created in IB. This method adds our custom view to the NSSavePanel, which will give the user the ability to choose the type of archive file he or she wants to create when choosing a name and location for the file.

If the users chose the "OK" button from the NSSavePanel, we create the archive for them. We start this process by first gathering all of the information from the NSSavePanel — such as the file type, file name, and directory. Next, we use this information to construct the tar command that will create an archive file and compress it according to the chosen file type.

After that, we create an instance of the NSAlert class and set it to a successful message to be displayed to the user if all goes well. Now we truly begin the process of creating the archive. We use the system method found in the Kernel module to execute the shell commands that will create our temp directory, copy the files into it, tar and compress the temp directory, copy the tarred file to the chosen directory, and finally, remove the temporary directory and its contents from the system. If everything executed properly, the NSAlert we created earlier will be displayed; otherwise, we will manipulate the NSAlert to display an error message to the user.

If everything got copied into your project properly, and you have the correct permissions to do so, you should be able to run the application and create a new archive file. Assuming that works properly, we have half of our application's functionality working. So let's press on and get the other half working. The next portion of this section will add the ability to extract files from already existing archives.

The extractArchive method is very similar to our createArchive method. First, we must create an instance of the NSOpenPanel class that will allow our users to choose the directory in which the archive file will be located. Then, it will create a tar command to decompress and untar the chosen file. Finally, if all goes well, it will display an alert that notifies the users of the success of the extraction, otherwise an error alert will be displayed. The following code should look somewhat familiar and should be copied into our extractArchive method skeleton:

###

# extractArchive

#  Displays an instance of the NSOpenPanel and

#  gets the directory in which the extracted

#  files will reside.  Then, it extracts the

#  archived file to that directory.

###

def extractArchive(sender)

	oPanel = NSOpenPanel.openPanel

	oPanel.setCanChooseFiles(false)

	oPanel.setCanChooseDirectories(true)

	buttonClicked = oPanel.runModal



	if buttonClicked == NSOKButton

		filename =

			@archiveFile.stringValue.to_s

		filetype = filename.split('.')[1]

		directory =

			oPanel.filenames.objectAtIndex(0)



		# Create the tar extraction command

		case filetype

			when "tgz"

				tarCommand = "tar -xvzf " +

				"#{filename} -C #{directory}"

			when "bz2"

				tarCommand = "tar -xvjf " +

				"#{filename} -C #{directory}"

			when "Z"

				tarCommand = "tar -xvZf " +

				"#{filename} -C #{directory}"

			when "tar"

				tarCommand = "tar -xvf " +

				"#{filename} -C #{directory}"

		end



		# Create an alert dialog to display

		# the outcome of the tar command

		alert = NSAlert.alloc.init

		alert.setMessageText(

			"Extraction Successful")

		alert.setInformativeText(

			"The tar file was successfully " +

			"extracted")

		alert.setAlertStyle(

			NSInformationalAlertStyle)

		alert.addButtonWithTitle("Ok")



		# Extract the files from the archive

		begin

			system(tarCommand)

		rescue

			alert.setMessageText(

				"Extraction Unsuccessful")

			alert.setInformativeText(

				"An error occurred while " +

				"extracting the archived " +

				"file.\nMake sure you have " +

				"the correct permissions " +

				"and the chosen file exists.")

			alert.setAlertStyle(

				NSCriticalAlertStyle)

		end



		alert.beginSheetModalForWindow(

			@mainWindow,

			:modalDelegate, self,

			:didEndSelector, nil,

			:contextInfo, nil)

	end

end

In the extractArchive method, the first thing you'll notice is that we create an instance of the NSOpenPanel class; However, this instance is implemented slightly different than the one we created for the addFile method. We set up this panel so that only directories can be chosen — not files — and we went with the default of allowing only a single selection.

After this we see something that looks very much like the createArchive method. We create a tar command string to extract the files from the chosen archive into the directory that was selected in the NSOpenPanel. Next, we create an instance of the NSAlert class and call the system command to execute the tar command string we created in the last step. Finally, we display an alert message to notify the user of the success of the operation. If the system command ran into problems we catch it using the rescue clause and display an error message instead.

There's only one thing still missing from our application, and that is one final piece of convenience. Rather than having the users type in the long and tedious names of the files they wish to extract, we need to implement a method that allows the users to select the file using an NSOpenPanel instead. Our browseForArchive method does just this, and its implementation can be found below.

###

# browseForArchive

#  Pops up an instance of the NSOpenPanel and

#  allows the user to select the archive they

#  wish to open.

###

def browseForArchive(sender)

	# Create an array of the file types

	# allowed in the NSOpenPanel

	filetypes = ["tgz", "bz2", "Z", "tar"]



	# Create the NSOpenPanel and get the

	# archive file to extract

	oPanel = NSOpenPanel.openPanel

	oPanel.setAllowsMultipleSelection(false)

	buttonClicked =

		oPanel.runModalForDirectory(nil,

		:file, nil,

		:types, filetypes)



	if buttonClicked == NSOKButton

		file =

			oPanel.filenames.objectAtIndex(0)

		@archiveFile.setStringValue(file)

	end

end

Again, we find ourselves in familiar territory with just a few differences from what we saw earlier. The first thing you'll notice is that once again we are creating a new NSOpenPanel object. However, this time around we are only letting the user select specific file types. These file types are the four that we have chosen to deal with in our application and can be found in the filetypes array we created just before creating our NSOpenPanel instance. Once, we have displayed the open panel we make sure the user pressed the "OK" button and then we set the archiveFile NSTextField to display the name of the file the user has just chosen. This method allows our application to be a bit speedier and less error-prone than it would be if it counted on the users typing in the full name of the file they wish to decompress and extract.

Final Thoughts

Well, that's it. You should now have a fully functioning RubyCocoa application. Hopefully, the trip was fun and entertaining, and if you're lucky you even got an application that you may put to use in the end. Just in case you had any trouble along the way — or if you just followed along and didn't participate in the tutorial — I have placed a copy of the Xcode project for this article online here. Do with it what you will, and maybe even try adding in some of the suggestions I've listed below.

Obviously, there are still several areas in which this application could be improved. One area this application could definitely be enhanced is in the number of supported file extensions. Right now the application only supports four different file extensions, but there are many more, as well as compression techniques that could be added to this application to make it even more robust. You could also look into changing all of the open and save panels into sheets rather than modal dialogues. Another great feature would be the ability to open an already existing archive and then add and remove files from it.

All of these are great ways to extend this application and make it something that you use on a daily basis. So those of you out there who enjoyed this article, I believe you have some homework to keep you busy for quite awhile. If you make some good changes send them to me and I'll see if I can't find somewhere to post them for others to get them; just remember to send me the full code.

One last note: I just wanted to share with those of you who have followed my articles up till now. I have really enjoyed writing these tutorials, and I am already formulating some ideas for other Ruby articles in the near future. So if you've liked this series on Ruby programming so far, keep on the lookout for a new batch of Ruby articles from me very soon.

Christopher Roach recently graduated with a master's in computer science and currently works in Florida as a software engineer at a government communications corporation.


Return to MacDevCenter.com.

Copyright © 2009 O'Reilly Media, Inc.