The first version of the cIRC chat window was a ScrollView, a LazyVStack, and a confidence level it had absolutely not earned.
Messages appeared. I could scroll. Then a new IRC line arrived, the view politely stayed where it was, and the easy feature stopped being easy.
One new line changed the feature’s status. It was not a message list. It was a reader contract: keep the bottom pinned while the user is following the conversation, leave history alone when they have scrolled up, and render the strange formatting decisions of a protocol that has been alive since 1988.
What the window had to do
For cIRC, my Swift and SwiftUI IRC client, the window seemed to have three jobs:
- Show messages
- Scroll to the bottom when new messages arrive, but only if the reader is already there
- Parse IRC formatting codes from a much older internet
Simple enough to finish before lunch.
Reader, lunch did not survive the work.
Tracking scroll position
SwiftUI’s ScrollView is a marvel of declarative UI design, and opinionated about how much control it gives you over scroll position.
My first attempt was charmingly naive:
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
}
}
}
It worked in the way first attempts often work. Messages appeared. Manual scrolling behaved. Victory was declared too early, which is the traditional moment when software starts telling the truth.
New messages did not move the window when the conversation advanced.
Ah.
Keeping the bottom in view
Chat scrolling is mostly a question of consent. When a new message arrives, the window follows it only if the user was already at the bottom. If they have scrolled up to read history, the interface preserves that act instead of treating new content as permission to steal focus.
SwiftUI does not expose scroll position as a simple fact. From there, the view has to infer it from geometry: content height, container height, current offset, and the distance left between the viewport and the true bottom.
My solution involved the new onScrollGeometryChange modifier:
.onScrollGeometryChange(for: Bool.self) { geometry in
let contentHeight = geometry.contentSize.height
let containerHeight = geometry.containerSize.height
let offsetY = geometry.contentOffset.y
let distanceFromBottom = contentHeight - containerHeight - offsetY
return distanceFromBottom <= bottomThreshold
} action: { _, isAtBottom in
guard !isScrolling else { return }
if isAtBottom != isPinnedToBottom {
isPinnedToBottom = isAtBottom
}
}
Elegant, no. Trustworthy, eventually. Code improved once it stopped pretending scroll position was a single fact.
Avoiding recursive scroll
Programmatic scrolling produced the next failure. When content grew, the window had to follow the bottom, so I added:
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentSize.height
} action: { oldHeight, newHeight in
guard isPinnedToBottom else { return }
guard newHeight > oldHeight else { return }
proxy.scrollTo(bottomID, anchor: .bottom)
}
Scrolling changes geometry. Geometry changes trigger the handler. The handler decides whether to scroll. A chat window can become recursive without ever looking dramatic in the code review.
A small isScrolling guard fixed it. The view sets the flag before programmatic scrolls and clears it after a brief delay:
isScrolling = true
proxy.scrollTo(bottomID, anchor: .bottom)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
isScrolling = false
}
0.05 seconds is not a theorem. Inside this implementation, it is a measured compromise: long enough for the geometry update to settle, short enough not to make manual scrolling feel sticky. I would not put it on a plaque. I would ship it.
The bottom anchor
Messages have to scroll past all content to the true bottom, including padding. My first attempts kept leaving the last line clipped by a few pixels.
An invisible anchor solved it:
LazyVStack {
ForEach(buffer.messages) { message in
MessageRow(message: message)
}
}
.padding()
// Programmatic scroll target
Color.clear
.frame(height: 1)
.id(bottomID)
One invisible pixel became the stable target. Undignified, perhaps. Correct, definitely. The window no longer had to guess where content ended.
Parsing IRC formatting
IRC was born in 1988. Its formatting codes come from a period when control characters still carried presentation state as part of ordinary text.
IRC formatting surface looks like this:
\u{02}(Ctrl+B) - Bold\u{03}- Color codes (followed by numbers, naturally)\u{1D}- Italic\u{1F}- Underline\u{0F}- Reset everything\u{16}- Reverse video (swap foreground/background, because why not)
Color codes deserve special mention. Format: \u{03}FG,BG, where foreground and background are one- or two-digit numbers mapped to a sixteen-color palette. Compact, old, and extremely easy to parse almost correctly, which is the most dangerous kind of almost.
private let ircColors: [Color] = [
.white, // 0
.black, // 1
Color(red: 0, green: 0, blue: 0.5), // 2 - navy
Color(red: 0, green: 0.5, blue: 0), // 3 - green
.red, // 4
Color(red: 0.5, green: 0, blue: 0), // 5 - brown/maroon
.purple, // 6
.orange, // 7
// ... remaining IRC palette entries
]
A parser that kept up
Parsing this required tracking state through the message:
var foreground: Color?
var background: Color?
var isBold = false
var isItalic = false
var isUnderline = false
var isStrikethrough = false
switch char {
case "\u{03}":
flushSegment()
// Parse 1-2 digit foreground
// Optionally parse comma + 1-2 digit background
\u{03} with no following number is the awkward case. It resets colors. \u{03}4 sets the foreground to red. \u{03}4,2 sets foreground red and background navy. The parser has to distinguish absence from intention while walking a plain string.
I briefly considered just stripping all formatting codes. Then I joined an IRC channel where someone’s username was rendered in rainbow colors, and I knew I had to do this properly.
Message buffering
IRC channels can be chatty enough that message storage needs a ceiling. I capped each buffer at 2,000 messages using swift-collections’ Deque:
public var messages: Deque<MessageState>
public func addMessage(_ message: MessageState, incrementUnread: Bool = true) {
messages.append(message)
if messages.count > Self.maxMessages {
let excess = messages.count - Self.maxMessages
let toRemove = max(excess, Self.trimBatchSize)
messages.removeFirst(min(toRemove, messages.count))
}
}
trimBatchSize keeps pruning from becoming work the user can feel. Removing one message at a time is unnecessary churn, so the buffer removes a batch once the limit is exceeded. Nothing heroic, just a small refusal to make old messages expensive.
Tab completion
Tab completion is table stakes in an IRC client. Type @Al<TAB> and the input completes to @Alice: , with the colon at the start of the line because IRC users notice these things.
First version: no repeated-tab cycling.
Second version: cycling worked, then broke when the user typed after a completion.
Third version: behavior worked until SwiftUI state updates erased the completion state:
let savedCandidates = completionCandidates
let savedIndex = completionIndex
appState.inputText = newText // This triggers onChange, which resets state
// Restore the state that was just reset
completionCandidates = savedCandidates
completionIndex = savedIndex
I am not proud of the shape, but I trust the reason. Sometimes the repair is not elegance. Sometimes the repair is preserving the state SwiftUI is about to discard.
Where the window held up
By the end, the window had stopped behaving like a list and started behaving like a chat client. It scrolled smoothly, stayed at the bottom while I was following the channel, left history alone when I was reading backward, rendered decades-old formatting codes, and completed nicks with a single tab.
None of that is exotic. That is what made it exacting.
Every chat app carries this work somewhere: scroll state, reader intent, text formatting, storage ceilings, completion rules, and the small pieces of UI state that vanish at inconvenient times. The product looks simple because the contract is familiar, not because the implementation is small.
Finished, the window is still not perfect. It is a native chat window that respects the reader’s scroll position, preserves enough IRC weirdness to feel honest, and does not pretend old protocols are simple because their messages are short.
Enough dignity for a chat window.
cIRC is a native macOS/iOS IRC client built with Swift 6 and SwiftUI.