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:

  1. Show messages
  2. Scroll to the bottom when new messages arrive, but only if the reader is already there
  3. 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.

A painterly editorial collage for chat window scrolling, showing the concrete objects and system relationships around message stacks, scroll anchors, and old formatting codes.
Message stacks, scroll anchors, and old formatting codes.

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.

Chris Chabot · December 2025