Programmish
NSTextView Highlighting with Python

Get the sample code here: PyObjC-Highlight.zip

Just about every program that displays, manages, or edits structured or parsable data has some form of syntax highlighting. This sample application presents a naive syntax highlighter for a YAML like markup language, using PyObjC and Cocoa.

Our highlighter covers three basic elements:

  • Comments, which start with a pound (#) symbol
  • Key – value pairs, which are separated by a colon (:)
  • List items, which start with a dash (-)

Comment lines are formatted with green text and a normal font. The Key of the key – value pair is colored blue and presented in a bold font. The dash of all list items is colored red, using a normal font. The remainder of the text should be represented in a black system default font.

In this example the text highlighting should be applied whenever the text changes, which although convenient for a simple example does cause issues for large documents that take a long time to parse and highlight, as the UI will freeze during the highlight process. There are a few solutions to this problem:

  • Run the highlighter in a background thread, triggered whenever typing has stopped
  • Use an incremental parser/highlighter that only updates the current and immediately surrounding lines

We’ll bypass these solutions in favor of a straightforward example. To implement a highlighter that updates whenever the text changes we’ll use the textDidChange delegate method of the NSText class.

def textDidChange_(self, notification):
	"""
	Delegate method called by the NSTextView whenever the contents of the
	text view have changed. This is called after the text has changed and
	been committed to the view. See the Cocoa reference documents:

http://developer.apple.com/documentation/Cocoa/Reference/ApplicationKit/Classes/NSText_Class/Reference/Reference.html

http://developer.apple.com/documentation/Cocoa/Reference/ApplicationKit/Classes/NSTextView_Class/Reference/Reference.html

	Specifically the sections on Delegate Methods for information on additional
	delegate methods relating to text control is NSTextView objects.
	"""

	# Retrieve the current contents of the document and start highlighting
	content = self.highlightedText.string()
	self.highlightText(content)

In this method we retrieve the contents of the NSTextView in our UI, and pass that content to our highlightText method:

def highlightText(self, content):
	"""
	Apply our customized highlighting to the provided content. It is assumed that
	this content was extracted from the NSTextView.
	"""

	# Calling the setAttributesForRange with no values creates
	# a default that "resets" the formatting on all of the content
	self.setAttributesForRange(None, None, None, None)

	# We'll highlight the content by breaking it down into lines, and
	# processing each line one by one. By storing how many characters
	# have been processed we can maintain an "offset" into the overall
	# content that we use to specify the range of text that is currently
	# being highlighted.
	contentLines = content.split("n")
	highlightOffset = 0

	for line in contentLines:

		if line.strip().startswith("#"):
			# Comment - we want to highlight the whole comment line
			self.setAttributesForRange(NSColor.greenColor(), None, highlightOffset, len(line))

		elif line.find(":") > -1:
			# Tag - we only want to highlight the tag, not the colon or the remainder of the line
			startOfLine = line[0: line.find(":")]
			yamlTag = startOfLine.strip("t ")
			yamlTagStart = line.find(yamlTag)
			self.setAttributesForRange(NSColor.blueColor(), "bold", highlightOffset + yamlTagStart, len(yamlTag))

		elif line.strip().startswith("-"):
			# List item - we only want to highlight the dash
			listIndex = line.find("-")
			self.setAttributesForRange(NSColor.redColor(), None, highlightOffset + listIndex, 1)

		# Add the processed line to our offset, as well as the newline that terminated the line
		highlightOffset += len(line) + 1

The technique we use for parsing our content is primitive (and error prone) but suffices for a simple example. After breaking the content into lines, each line is checked against a basic format for our three highlighting targets. If the line matches, we determine the range of the text from that line to which highlighting will be applied. We call our setAttributesForRange method with a color (an NSColor object), a font strength (“normal” or “bold”), and the location and length of the text in the original document to highlight.

Throughout the parsing we need to maintain a tally of how many characters have been processed, as the highlighting is being applied to the original content, and we need an index into that content. Normally in Python we would use two indices into a List to denote a slice or range; unfortunately Objective-C uses an index and length to create a range (specifically with the NSRange class). This conversion doesn’t add a lot of code to our example, but it can be a point in the code for bugs to pop up.

The actual highlighting takes place in the setAttributesForRange method:

def setAttributesForRange(self, color, font, rangeStart, rangeLength):
	"""
	Set the visual attributes for a range of characters in the NSTextView. If
	values for the color and font are None, defaults will be used.

	The rangeStart is an index into the contents of the NSTextView, and
	rangeLength is used in combination with this index to create an NSRange
	structure, which is passed to the NSTextView methods for setting
	text attributes. If either of these values are None, defaults will
	be provided.

	The "font" parameter is used as an key for the "fontMap", which contains
	the associated NSFont objects for each font style.
	"""
	fontMap = {
				"normal" : NSFont.systemFontOfSize_(self.fontSize),
				"bold" : NSFont.boldSystemFontOfSize_(self.fontSize)
				}

	# Setup sane defaults for the color, font and range if no values
	# are provided
	if color is None:
		color = NSColor.blackColor()
	if font is None:
		font = "normal"        

	if font not in fontMap:
		font = "normal"

	displayFont = fontMap[font]

	if rangeStart is None:
		rangeStart = 0

	if rangeLength is None:
		rangeLength = len(self.highlightedText.string()) - rangeStart

	# Set the attributes for the specified character range
	range = NSRange(rangeStart, rangeLength)
	self.highlightedText.setTextColor_range_(color, range)
	self.highlightedText.setFont_range_(displayFont, range)

This method takes a color (NSColor object), a font type (“normal” or “bold”), and the location and length of the text to highlight in the NSTextView. If any of the parameters is None a sensible default value is used. When called with all None parameters the default is to highlight the entire range of the document with a normal weight black font (in fact the method is called like this at the start of the highlightText method to reset the highlights on the document). The actual methods used to apply formatting are the setTextColor_range_ and setFont_range_ methods of the NSTextView.

Leave a Reply