Skip to content
C.W.K.
Lesson 01 of 05 · published

Tabs and Windows as a Model

~14 min · tabs, state-model, pinned-tabs, swift

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A tab is a small thing with a long life

Before Voyager drew a single pixel, it had a model of what a tab is. The shell (AppKit on the Mac, SwiftUI on the phone) only draws that model and sends Dad's gestures into it. The model lives in VoyagerCore, the parent target that imports no WebKit, AppKit or UIKit, so every rule in this lesson runs under test against a fake engine. Swift Quest covers the language; this lesson is about the shape.

BrowserTab carries two things that never change: its id and its identity. Both are declared let. A tab is born in one identity (one isolated store of cookies and site data, track 5) and closes in it. Links and pop-ups opened from it stay there. Everything else is state that moves:

  • status: the last thing the page said about itself (address, title, icon, loading). It is kept while the tab sleeps, which is why a sleeping tab still has a name and a face.
  • page: the engine's page, or nil. Nil means asleep.
  • processEnded: the engine ended this page's process (a crash, or the system's memory limit for one page).
  • contentRevision: a counter that moves whenever a read might belong to a different document, so anything reading the page can tell a stale answer from a current one.

Four states, one table

StateWhat the tab holdsHow it gets thereHow it leaves
Awakea pageopened, or chosenthe sleep policy, a close
Asleepno page; its status and the page's place as bytesthe sleep policy, or a relaunchchosen, or reloaded
Endeda page whose process is gonethe engine reports the process endedloads again by itself, once a minute at most (lesson 4)
Nativeno page, and never asleepHome (voyager://home) or Pippa herself (voyager://pippa)going to a web address

The native row matters more than it looks. isAsleep is page == nil && !VoyagerPage.isNative(status.url). Without the second half, Home and Pippa herself would read as sleeping tabs forever: the strip would draw their titles in the dimmed colour of a sleeping tab, and Home's list of open tabs would give them the moon icon. wake carries the same guard, so a native page is never handed a web view.

The window owns order and the front

BrowserWindowModel holds one window's tabs array and its selectedID. It is @Observable and runs on the main actor, so the strip and the toolbar redraw from it directly. Selection has a side job. When selectedID changes, the tab that went behind is stamped with the time (backgroundSince) and the one that came forward is cleared. That timestamp is the whole input the sleep policy needs in the next lesson. A private window is a flag on the model: every tab it opens is in the private identity, and nothing it does goes into the session.

The pinned group is a prefix

Pinned tabs are not a separate list. They are the front of the one array, and pinnedCount is how many tabs at the front are pinned. Every rule keeps that prefix whole:

  • A tab opened from a link goes right behind its opener, but never inside the pinned group: max(index + 1, pinnedCount).
  • Pinning moves a tab to the end of the pinned group; unpinning moves it to the front of the rest. Both are the same line, an insert at pinnedCount, read after the flag has changed.
  • A drag moves a tab only within its own group. A pinned tab dragged past the edge stops there and stays pinned, as in Chrome.
  • Close Other Tabs and Close Tabs to the Right spare pinned tabs.
  • A restored session puts pinned tabs first with two filters, pinned then unpinned, each group in its saved order. The code comment there doubts that a sort would keep that order, but Swift's standard library does guarantee sort is stable, so the filters are the plainer statement of intent, not the only safe one.

Close, and the way back

Closing a tab takes a snapshot first. Unless the window or the tab is private, or the tab never had an address, the snapshot goes onto a stack of the last 25 closed tabs with its old index. The tab to the right takes the front; if there is none, the last tab does. ⇧⌘T pops the stack. The reopened tab gets a new id, because the closed tab is gone and this is its place reopened. It keeps the identity, the pinned flag, the per-tab pause on sharing with Pippa, and the page's saved place, and it goes back to its old index clamped into its own group.

Duplicate Tab opens the same address in the same identity beside the original. Moving a tab to another window is a detach and an attach: the tab leaves as a snapshot and its page is let go. Attaching across privacy is where a rule bites. A private tab moved into a normal window takes the default identity and loses its saved place, and a normal tab moved into a private window becomes private the same way, because a move must never carry one store's history or sign-in into another.

⌘1 to ⌘9

Chrome's convention is that ⌘1 through ⌘8 pick a tab by position and ⌘9 is always the last tab. Voyager copies it, edge included: with four tabs open, ⌘7 does nothing rather than jumping to the last one. A test pins that edge, because a model that quietly clamps would be a different browser under Dad's fingers. ⌃Tab and ⌃⇧Tab step through the tabs and wrap at both ends.

Principle: Model the browser before you draw it. When the tab order is one array with one invariant (pinned is a prefix) and every gesture is a function on that array, the strip, the menu, the session file and the tests all ask the same model, and none of them can disagree.

Code

cwkVoyager, Sources/VoyagerCore/BrowserWindowModel.swift: where a new tab goes, and pinning (verbatim, lines 341-361)·swift
    private func insert(_ tab: BrowserTab, after opener: BrowserTab?) {
        tab.window = self
        if let opener, let index = index(of: opener.id) {
            // A tab opened from a pinned tab lands after the pinned group.
            tabs.insert(tab, at: max(index + 1, pinnedCount))
        } else {
            tabs.append(tab)
        }
    }

    // MARK: Pinning and the tab commands

    /// A pinned tab joins the end of the pinned group; an unpinned one goes
    /// to the front of the rest.
    public func setPinned(_ id: TabID, _ pinned: Bool) {
        guard let index = index(of: id), tabs[index].isPinned != pinned else { return }
        let tab = tabs.remove(at: index)
        tab.isPinned = pinned
        tabs.insert(tab, at: pinnedCount)
        sessionChanged()
    }
cwkVoyager, Sources/VoyagerCore/BrowserWindowModel.swift: close, and reopen at the old place (verbatim, lines 415-450)·swift
    public func close(_ id: TabID) {
        guard let index = index(of: id) else { return }
        let tab = tabs[index]
        let snapshot = tab.snapshot()
        if !isPrivate, !tab.isPrivate, snapshot.url != nil {
            closedTabs.append((snapshot, index))
            if closedTabs.count > Self.closedTabLimit { closedTabs.removeFirst() }
        }
        tab.discardPage()
        tab.window = nil
        tabs.remove(at: index)
        if selectedID == id {
            // The tab to the right takes the place, or the last one.
            let next = tabs.indices.contains(index) ? tabs[index] : tabs.last
            selectedID = next?.id
            next?.wake(using: services.engine)
        }
        sessionChanged()
    }

    @discardableResult
    public func reopenClosedTab() -> BrowserTab? {
        guard let (snapshot, index) = closedTabs.popLast() else { return nil }
        // A new tab id: the closed tab is gone, this is its place reopened.
        let tab = BrowserTab(
            identity: snapshot.identity,
            status: PageStatus(url: snapshot.url, title: snapshot.title, favicon: snapshot.favicon),
            restorable: snapshot.state, pinned: snapshot.pinned)
        tab.sharesContextWithPippa = snapshot.sharesContextWithPippa
        tab.window = self
        let place = snapshot.pinned ? min(index, pinnedCount) : min(max(index, pinnedCount), tabs.count)
        tabs.insert(tab, at: place)
        select(tab.id)
        sessionChanged()
        return tab
    }
cwkVoyager, Sources/VoyagerCore/BrowserWindowModel.swift: ⌘1 to ⌘9, and a move that stays in its group (verbatim, lines 479-496)·swift
    /// Chrome's ⌘1…⌘8 pick a tab by position; ⌘9 is always the last one.
    public func select(position: Int) {
        guard !tabs.isEmpty else { return }
        let index = position >= 9 ? tabs.count - 1 : position - 1
        guard tabs.indices.contains(index) else { return }
        select(tabs[index].id)
    }

    /// A tab moves within its own group: pinned tabs among the pinned, the
    /// rest among the rest.
    public func move(_ id: TabID, to destination: Int) {
        guard let index = index(of: id) else { return }
        let tab = tabs.remove(at: index)
        let pinned = tabs.prefix { $0.isPinned }.count
        let place = tab.isPinned ? min(max(destination, 0), pinned) : min(max(destination, pinned), tabs.count)
        tabs.insert(tab, at: place)
        sessionChanged()
    }

External links

Exercise

Write a tab model for an app of your own (or a toy one) as a plain type with no UI: an ordered array of tabs, a selected id, and a pinned flag on each tab. Implement open-after-opener, pin, unpin, move, close and reopen so that pinned tabs are always a prefix of the array. Then write one test per rule, including the edges: a link opened from the last pinned tab, an unpinned tab dragged to index 0, ⌘9 with three tabs, and ⌘7 with four.
Hint
Make the invariant a function you can check after every operation (the first unpinned index is after every pinned index) and call it at the end of each test. Clamp a destination inside its group in one place, the way move does, instead of in every caller. Decide on purpose what ⌘7 does with four tabs; Chrome's answer is nothing.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.