Writing a third-generation log browser using SwiftUI: 2 Displaying log entries
In the first article explaining how I’m developing my third-generation log browser, I considered how it obtains its log entries using the OSLog API in macOS. Those end up in a structure containing the individual contents of each of the fields in a log entry. Here I explain how those entries are displayed to the user, with SwiftUI.
There are several options for displaying log entries. Console adopts a column view that really doesn’t work well even on a 27-inch display.
Experience developing Ulbow has shown that use of semantic colour to distinguish fields in log entries is superior.
Rather than confine fields to columns of even width, and leave several of them empty according to the fields used in that type of log entry, I have therefore opted for a display based on that in Ulbow.
This is most effective in Dark mode.
Styled text
It’s common for log browsers like Ulbow to be called on to display thousands of log entries, even when predicates are used by experts. Ulbow accomplishes that using the building block for Rich Text, NSAttributedString. Each field has a colour attribute applied to it, and those are appended to one another to build the line representing each log entry. Those lines are then appended to one another to assemble the full NSAttributedString to be displayed in the text scroller forming the lower section of Ulbow’s document window, using AppKit.
The equivalent using SwiftUI uses the almost identically named AttributedString, then displayed as a Text view in a SwiftUI Scroller.
My first experiment was therefore to repeat the same process using AttributedStrings instead of NSAttributedStrings. In code:let theProcess = self.setAttrStr(string: (log.process + " "), color: NSColor.green)
theLineStr.append(theProcess)
becomesvar theProcess = AttributedString(log.process + " ")
theProcess[AttributeScopes.AppKitAttributes.ForegroundColorAttribute.self] = .green
theLineStr.append(theProcess)
to colour the Process name in green and append it to the log entry.
Working entirely using the newer AttributedString, this proved unusable. Even displaying just a couple of hundred log entries in a single AttributedString took several seconds, and when there were 1,000 or more the whole window was unusable after taking 15 seconds to open.
When assessed using Instruments, a small test log excerpt took over 1.6 seconds to open, little of that taken by the view itself, but much of that period apparently being used by the CPU, presumably handling the AttributedString.
The code was refactored to compare the following variations:
- creating the AttributedString one field at a time, appending those in a single entry into one Attributed String, then appending that to the whole AttributedString to be displayed;
- creating an NSAttributedString for each entry, converting that to an AttributedString, then appending that to the whole AttributedString to be displayed;
- creating each entry as an NSAttributedString, appending those into a single NSAttributedString, and converting that into an AttributedString for display.
There was no significant difference in their performance, so I abandoned further attempts to display log entries as styled text in a SwiftUI Text Scroller.
Styled list
The other obvious choice for display of log entries is SwiftUI’s List, as I had already used in less demanding lists such as that in Unhidden. This approach iterates through each log entry when building the view, styling each field as it goes, for example in:if !line.process.isEmpty {
if #available(macOS 14.0, *) {
Text(line.process)
.foregroundStyle(.green)
} else {
Text(line.process)
.foregroundColor(.green)
}}
This has to contain two alternative calls to set the foreground colour of the text, as foregroundStyle()
is required for macOS 14 and later, while foregroundColor()
is required in older versions of macOS. Unfortunately, because of its rapid change, SwiftUI is riddled with such version conflicts.
To my surprise, this List approach performed much better than using AttributedString, and windows typically open in less than a second even when there are more than 1,000 log entries to be displayed.
This is demonstrated again by Instruments, here opening a log window of the same size as shown above for the Text Scroller implementation. The whole process took 0.44 seconds, most of which was in handling the view. Memory use was also substantially less than for AttributedString. This scales well, with 10,000 log entries remaining brisk, and even 20,000 being perfectly usable.
This is how log entries are displayed in LogUI (to rhyme with doggy), available as a notarized app from here: logui01
Source code for the List view is given in the Appendix at the end of this article.
Copy and save
Unlike similar views in AppKit, List views have little or no intrinsic support for common features, such as selection and copying of content, or saving in Rich Text format, a feature that also appears to have been omitted from AttributedString, although there’s helpful support provided for NSAttributedString.
Having proved the concept of obtaining log entries using OSLog rather than the log show
command, and displaying those entries in a SwiftUI List view, I’m now refactoring this into a document-based app, which should at least facilitate saving log extracts in Rich Text format.
Reference
SwiftUI List view in Apple Developer Documentation
Appendix: Source code
struct ContentView: View {
@State private var messageInfo = Logger()
var body: some View {
let _ = messageInfo.getMessages()
if (self.messageInfo.logList.count > 0) {
VStack {
List(self.messageInfo.logList) { line in
MessageRow(line: line)
}
}
.frame(minWidth: 900, minHeight: 200, alignment: .center)
} else {
Text("No results returned from the log for your query.")
.font(.largeTitle)
}
}
}
struct MessageRow: View {
let line: LogEntry
var body: some View {
HStack {
Text(line.date + " ")
if #available(macOS 14.0, *) {
Text("\(line.type)")
.foregroundStyle(.red)
} else {
Text("\(line.type)")
.foregroundColor(.red)
}
if !line.activityIdentifier.isEmpty {
Text(line.activityIdentifier)
}
// etc.
Text(line.composedMessage)
}
}
}