본문 바로가기
C.W.K.
Lesson 01 of 05 · published

모델로 보는 탭과 창

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

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

탭은 작지만 오래 사는 물건이야

Voyager는 화면에 픽셀 하나 그리기 전에 탭이 뭔지부터 모델로 정해뒀어. 셸(Mac은 AppKit, 폰은 SwiftUI)은 그 모델을 그리고, 아빠 손짓을 모델로 넘겨주는 일만 해. 모델은 VoyagerCore에 살아. WebKit도 AppKit도 UIKit도 import하지 않는 부모 타깃이라, 이 레슨에 나오는 규칙은 전부 가짜 엔진을 상대로 테스트 안에서 돌아. 언어 자체는 Swift Quest가 다루니까, 여기선 모양을 볼 거야.

BrowserTab엔 절대 안 바뀌는 게 두 개 있어. id랑 identity. 둘 다 let이야. 탭은 아이덴티티 하나에서 태어나서 거기서 닫혀. 아이덴티티는 쿠키랑 사이트 데이터를 따로 담는 저장소인데, 트랙 5에서 자세히 다뤄. 그 탭에서 연 링크나 팝업도 같은 아이덴티티에 머물러. 나머지는 전부 움직이는 상태야.

  • status: 페이지가 자기에 대해 마지막으로 알려준 것. 주소, 제목, 아이콘, 로딩 중인지. 탭이 자는 동안에도 남아 있어. 자는 탭에 이름이랑 얼굴이 그대로 붙어 있는 이유가 이거야.
  • page: 엔진이 만든 페이지, 아니면 nil. nil이면 자는 중이야.
  • processEnded: 엔진이 이 페이지의 프로세스를 끝냈다는 표시. 크래시일 수도 있고, 페이지 하나에 걸린 시스템 메모리 한도일 수도 있어.
  • contentRevision: 읽은 내용이 다른 문서 것일 수도 있는 순간마다 올라가는 카운터야. 페이지를 읽는 쪽이 낡은 답이랑 지금 답을 가려내라고 있는 거지.

상태 넷, 표 하나

상태탭이 쥐고 있는 것들어오는 길나가는 길
깨어 있음페이지열리거나 선택될 때잠재우기 정책, 닫기
자는 중페이지는 없고, 상태랑 페이지 자리를 담은 바이트잠재우기 정책, 앱 다시 실행선택되거나 새로고침될 때
끝남프로세스가 사라진 페이지엔진이 프로세스가 끝났다고 알릴 때스스로 다시 로드, 1분에 한 번까지 (레슨 4)
네이티브페이지도 없고 잘 일도 없음Home(voyager://home)이나 피파 본체(voyager://pippa)웹 주소로 갈 때

네이티브 줄이 보기보다 중요해. isAsleep은 page == nil && !VoyagerPage.isNative(status.url)이야. 뒤쪽 조건이 없으면 Home이랑 피파 본체는 영원히 자는 탭으로 읽혀. 스트립은 그 제목을 자는 탭의 흐린 색으로 그리고, Home의 열린 탭 목록은 달 아이콘을 붙였겠지. wake에도 같은 가드가 있어서, 네이티브 페이지가 웹 뷰를 받는 일은 없어.

순서랑 맨 앞 자리는 창이 쥐고 있어

BrowserWindowModel은 창 하나의 tabs 배열이랑 selectedID를 들고 있어. @Observable이고 메인 액터에서 돌아서, 탭 스트립이랑 툴바가 여기서 바로 다시 그려. 선택에는 딸린 일이 하나 있어. selectedID가 바뀌면 뒤로 간 탭엔 그 시각(backgroundSince)이 찍히고, 앞으로 나온 탭은 그 값이 지워져. 다음 레슨의 잠재우기 정책은 입력으로 이 타임스탬프 하나만 있으면 돼. 시크릿 창은 모델에 붙은 플래그 하나야. 거기서 여는 탭은 전부 비공개 아이덴티티에 들어가고, 거기서 한 일은 세션에 하나도 안 남아.

고정 그룹은 배열의 앞부분이야

고정 탭은 따로 된 목록이 아니야. 배열 하나의 앞쪽일 뿐이고, pinnedCount는 앞에서부터 고정된 탭이 몇 개인지 세는 값이야. 규칙은 전부 그 앞부분을 통째로 지켜.

  • 링크로 연 탭은 자기를 연 탭 바로 뒤로 가. 그래도 고정 그룹 안으로는 절대 안 들어가. max(index + 1, pinnedCount).
  • 고정하면 고정 그룹 맨 끝으로, 고정을 풀면 나머지의 맨 앞으로 가. 둘 다 같은 한 줄이야. 플래그를 바꾼 다음에 읽은 pinnedCount 자리에 끼워 넣는 거.
  • 드래그는 자기 그룹 안에서만 움직여. 고정 탭을 경계 너머로 끌면 경계에서 멈추고, 고정도 그대로야. Chrome이랑 똑같아.
  • 다른 탭 닫기, 오른쪽 탭 닫기는 고정 탭을 남겨둬.
  • 세션을 되살릴 때 고정 탭을 앞으로 보내는 건 필터 두 번이야. 고정 탭 먼저, 나머지 다음, 그룹마다 저장된 순서 그대로. 그 자리 코드 주석은 정렬하면 순서가 흐트러질지 모른다고 걱정하는데, Swift 표준 라이브러리는 sort가 안정 정렬이라고 보장해. 그러니까 필터는 유일하게 안전한 길이 아니라 의도를 더 또렷하게 적은 길일 뿐이야.

닫기, 그리고 돌아오는 길

탭을 닫으면 스냅숏부터 떠. 이 스냅숏은 원래 인덱스랑 같이, 최근에 닫은 탭 25개를 담는 스택에 올라가. 예외는 창이나 탭이 비공개일 때, 그리고 주소를 한 번도 가진 적 없는 탭일 때야. 맨 앞 자리는 오른쪽 탭이 받고, 오른쪽이 없으면 마지막 탭이 받아. ⇧⌘T는 그 스택에서 하나를 꺼내. 다시 연 탭은 새 id를 받아. 닫힌 탭은 사라졌고, 이건 그 자리를 다시 연 거니까. 그대로 가져오는 건 아이덴티티, 고정 여부, 탭별로 피파 공유를 멈춰둔 설정, 페이지의 저장된 자리야. 들어가는 자리는 원래 인덱스인데, 자기 그룹을 벗어나면 그룹 안으로 맞춰져.

탭 복제는 같은 주소를 같은 아이덴티티로 원래 탭 옆에 열어. 탭을 다른 창으로 옮기는 건 떼어내기랑 붙이기야. 탭은 스냅숏이 돼서 떠나고, 페이지는 놓아줘. 규칙이 진짜 일하는 건 비공개 경계를 넘을 때야. 비공개 탭을 일반 창으로 옮기면 기본 아이덴티티를 받고 저장된 자리를 잃어. 일반 탭을 시크릿 창으로 옮기면 같은 식으로 비공개가 되고. 탭을 옮기는 동작이 한 저장소의 방문 기록이나 로그인을 다른 저장소로 실어 나르면 절대 안 되니까.

⌘1부터 ⌘9까지

Chrome 관례는 이래. ⌘1부터 ⌘8까지는 위치로 탭을 고르고, ⌘9는 언제나 마지막 탭이야. Voyager는 이걸 끝자락 경우까지 그대로 따라 해. 탭이 넷일 때 ⌘7은 마지막 탭으로 점프하지 않고 아무것도 안 해. 테스트도 바로 이 경우를 붙잡아 두고 있어. 범위를 벗어난 번호를 조용히 안으로 맞춰주는 모델은, 아빠 손가락이 느끼기엔 다른 브라우저거든. ⌃Tab이랑 ⌃⇧Tab은 탭을 하나씩 넘기고, 양 끝에 닿으면 반대편으로 돌아가.

원칙: 그리기 전에 브라우저부터 모델로 만들어. 탭 순서는 배열 하나고, 불변식은 고정 탭이 앞부분이라는 것 하나뿐이야. 모든 손짓이 그 배열 위의 함수면 스트립도 메뉴도 세션 파일도 테스트도 같은 모델한테 묻게 돼. 그러면 서로 어긋날 길이 없어.

Code

cwkVoyager, Sources/VoyagerCore/BrowserWindowModel.swift: 새 탭이 들어가는 자리, 그리고 고정 (원문 그대로, 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: 닫기, 그리고 원래 자리로 다시 열기 (원문 그대로, 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부터 ⌘9, 그리고 자기 그룹 안에서만 움직이는 이동 (원문 그대로, 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

직접 만드는 앱(아니면 장난감 앱)에 쓸 탭 모델을 UI 없는 평범한 타입으로 짜봐. 순서 있는 탭 배열, 선택된 id, 탭마다 붙은 고정 플래그. 연 탭 바로 뒤에 열기, 고정, 고정 풀기, 이동, 닫기, 다시 열기를 구현해. 고정 탭은 언제나 배열의 앞부분에 있어야 해. 규칙마다 테스트도 하나씩 써. 끝자락 경우도 빼먹지 마. 마지막 고정 탭에서 연 링크, 인덱스 0으로 끌어다 놓은 일반 탭, 탭 셋일 때 ⌘9, 탭 넷일 때 ⌘7 같은 거.
Hint
불변식은 함수로 만들어둬. 고정 안 된 첫 탭이 모든 고정 탭보다 뒤에 있는지 보는 함수야. 그리고 테스트마다 마지막에 불러. 목적지를 그룹 안으로 맞추는 일은 호출하는 쪽마다 하지 말고, move처럼 한 곳에서만 해. 탭 넷일 때 ⌘7이 뭘 할지도 일부러 정해둬. Chrome은 아무것도 안 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.