Tech Talk

Adding Lists to Coppice

Tech Talk is a series of posts revealing some of the inner workings of Coppice and some of the technical challenges we've faced while building it. This time we want to cover how we implemented support for lists in Coppice 2021.1.

Our original plan was to quickly follow up our first release of Coppice with an update to improve text editing. Based on what we saw in Apple's APIs, we had expected this to be a relatively simple job and would have allowed us to get a release out before the end of 2020.

For the most part, we were correct. We managed to implement features such as editing line height and paragraph spacing very quickly and with minimal trouble. However, lists ended up requiring a lot more effort on our end to implement, delaying release for a few months. So what makes lists so much more difficult?

Text Editing 101

Rich text in Cocoa is represented by a class called NSAttributedString. This allows you to apply a series of attributes to parts of text. These attributes cover almost everything you can think of, such as the font, the text colour, the paragraph styling, and much more.

In mosts cases when editing an attribute, you just want to add or remove an attribute for exactly what the user has selected. For example, if you select some text and make it bold, you don't care if the selection is part way through a paragraph or even a word, you only make the selected part bold.

However, certain things require changing attributes beyond the selection. For example, if you select a word but then choose to right align the text, you want it to right align the entire paragraph. This means you need to calculate the range of the paragraph (or possibly even multiple paragraphs) that contain the selected text, and apply the new paragraph styles to that bigger range.

Unfortunately, lists add many additional points of complexity:

  1. Each entry of a list is its own paragraph. The means that editing a list requires finding and editing all paragraphs in that list
  2. Lists can be nested. If you edit the root level of a list (e.g. changing from a bulleted list to a numbered list), you don't want to apply those edits to any nested entries.
  3. Lists are not just attributes. Almost every other change you can apply to text or a paragraph simply requires editing the attributes. However, lists also require modifying the text. You need to add, modify, or remove the markers at the start of every list entry.

There are other forms of complexity as well. If you hit return, it should create a new list item. Hitting return while on an empty line should end the list. And inserting a new list item part way through a numbered list should shift all the numbers below that point. Thankfully, these sorts of changes are handled by a Cocoa class called NSTextView which handles the actual text editing.

However, in order for NSTextView to know how to handle these things, we need to mark up our NSAttributedString with information on the location and type of lists. This is done with a class called NSTextList.

NSTextList

NSTextList is a fairly simple class that represents a single list. It only has 3 properties:

Each NSTextList represents one "level" of a list hierarchy. These levels are stored in the textLists property on an NSParagraphStyle. If you have a flat list, this will just hold a single NSTextList. In nested lists, it will hold all the parent lists too. For example, if you are 3 levels deep in a list, you would have 3 NSTextLists, one for the root level, one for the second level, and one for the deepest level.

Example showing a list with 3 levels of nesting, with a second column showing the number of list items in the array for that part of the list. At the first level the array only has the item 'list1', at the second level it has list1 and list2, and at the third level it has list1, list2, and list3

By setting these NSTextLists, you can give NSTextView enough information to let users make changes to the list through regular text editing. However, NSTextView will not actually respond to any changes you make in code (for example, adding a list). That requires a LOT more work…

How to Modify a List

So let's say you have selected some text and want to modify the list attribute in some way (be that making the selection a list, removing a list contained in the selection, or modifying an existing list in the selection). You have the selection and you have a new NSTextList with the changes, but how do we go about correctly modifying the text?

1. Calculate the range and level of the list

In order to make any changes we need to know the full range of the list in text. We also need to know what level of list to change if we have a nested list. The behaviour in most apps seems to be that you should edit the highest level in the selection. So if you have 3 levels in a list and your selection covers entries in levels 3 and 2, then any changes should apply only to level 2.

Thankfully, these calculations aren't too difficult. To find the level, you simply enumerate all the paragraph styles in the selection and find the one with the shortest textLists array.

if let currentLevel = self.level {
    self.level = min(currentLevel, max(paragraphStyle.textLists.count - 1, 0))
} else< {
    self.level = max(paragraphStyle.textLists.count - 1, 0)
}

(Note: to perform the calculation we created a "List Calculator" class so we can store the values in a property while we perform a calculation. We also zero index the levels, because arrays.)

For the range, you again enumerate all the paragraph styles in the selection. This time though, we can use some helper methods to find the range of the list (if there is one) or paragraph (if there isn't). You then combine these ranges together to get the full extent of the list (or the paragraphs to convert to a list).

var newRange = paragraphStyleEffectiveRange //From enumerating the styles

//If we're in a list then we need to get the full range of the list, which may be outside the selection range
if let list = paragraphStyle.textLists.last {
    let listRange = textStorage.range(of: list, at: effectiveRange.location)
    if (listRange.location != NSNotFound) {
        newRange = listRange
    }
} else {
    //If we're outside of a list then we just want the current paragraph
    newRange = (textStorage.string as NSString).paragraphRange(for: effectiveRange)
}

//Merge the ranges
guard let editRange = self.editingRange else {
    self.editingRange = newRange
    return
}
self.editingRange = editRange.union(newRange)

Once we have our range and level, we can move to the next step

2. Update the Attributes

Next, we need to update the attributes. This is actually the simplest step, though first we need to store a copy of the attributed string before we modify it (it will become apparent why in the next step).

We start to enumerate the paragraph styles in the list's range. All we need to do here is take each paragraph style's textLists array and modify it by either adding, removing, or swapping the NSTextList

var textLists = newParagraphStyle.textLists
//If we're setting a list then we want to replace the list at the desired level
if let listType = listType {
    if (textLists.count > level) {
        textLists[level] = listType
    } else {
        textLists = [listType]
    }
} else {
    //If we have no list then we're removing all lists
    textLists = []
}
newParagraphStyle.textLists = textLists

We then update the attributes on the attributed strings.

3. Update the List Markers

Finally, we need to update the list markers. This happens in two stages.

The first happens while we're enumerating the paragraph styles in step 2. We need to enumerate the lines in the range each paragraph style covers in the old copy of the attributed string we made (as a single paragraph style may be shared across multiple paragraphs). The reason we enumerate the old copy of the attributed string, is we want to calculate the ranges for the old markers so we know what range of text to replace.

(oldString.string as NSString).enumerateSubstrings(in: effectiveRange, options: .byLines) { (substring, substringRange, effectiveRange, _) in
    var existingRange = NSRange(location: substringRange.location, length: 0)
    //If we had an old list then we want to calculate the marker so we can get its range for replacement
    if let oldList = oldParagraphStyle.textLists.last {
        var itemNumber = oldString.itemNumber(in: oldList, at: substringRange.location)
        //We need to manually handle the startingItemNumber as itemNumber(in:at:) doesn't (despite being giving the list)
        if (oldList.startingItemNumber > 1) {
            itemNumber = oldList.startingItemNumber + (itemNumber - 1)
        }
        //We just need the length of the marker as the location is always the start of the line
        //We also add 2 as we always have a tab before and after
        existingRange.length = oldList.marker(forItemNumber: itemNumber).count + 2
    }

    //Add the range and text to replace. We don't actually replace here as we don't want to mess up enumerateAttributes()
    if let list = textLists.last {
        replacements.append((existingRange, "\t\(list.marker(forItemNumber: textStorage.itemNumber(in: list, at: substringRange.location)))\t", newParagraphStyle))
    } else {
        replacements.append((existingRange, "", newParagraphStyle))
    }
}

We store the existing marker range, the new marker, and the paragraph style for that marker in a replacements array for use in part 2.

Part 2 is actually replacing the list markers and happens after we've finished enumerating the paragraph styles. The reason for this is that modifying the list markers can change the length of the text, which can mess up range calculations. By doing all the calculations first, we can then do the replacements in reverse order, ensuring we never have to adjust any ranges.

//Going from back to front (so the ranges remain valid) apply all the list replacements
for (range, string, paragraphStyle) in replacements.reversed() {
    textStorage.replaceCharacters(in: range, with: string)
    //If we're adding a list then we need to make absolutely sure what is added has the paragraph style
    //This is especially true for the earliest range we're adding as it may use the attributes of the text before
    if (range.length == 0) {
        let addedRange = NSRange(location: range.location, length: string.count)
        textStorage.removeAttribute(.paragraphStyle, range: addedRange)
        textStorage.addAttribute(.paragraphStyle, value: paragraphStyle, range: addedRange)
    }
    //We also want to update the selectionLocation so the cursor goes back to the start of the location, which may have shifted due to other list items changing above
    if (range.location < selectedLocation) {
        selectedLocation += (string.count - range.length)
    }
}

And with all that done you should now have correctly updated lists in your code.


That is quite a lot of code, but you can see a more complete version in a demo project here (available under an MIT licence). You may be wondering why we are sharing this if it took us so much work to implement. Isn't this just helping potential competitors? Maybe. But we believe that a rising tide lifts all boats.

Proper support for lists is a great feature to have in Coppice, but it doesn't define Coppice. If someone takes this code and uses it to implement list support in their app, then that's great. The Mac platform gets a little bit better. And who knows, maybe somebody has a better way of doing this that we've missed and they'll let us know about it.

If you enjoyed this post, or just want to stay up-to-date on everything to do with Coppice then don't forget to subscribe to The Coppice Blog or follow @mcubedsw on Mastodon.