From 8d4da7dc920049c9522bcc78bd3d62745f358db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 05:15:48 +0100 Subject: [PATCH] add implementation plan: memory fix, reader UI redesign Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-14-memory-fix-and-reader-ui.md | 1818 +++++++++++++++++ 1 file changed, 1818 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-14-memory-fix-and-reader-ui.md diff --git a/docs/superpowers/plans/2026-03-14-memory-fix-and-reader-ui.md b/docs/superpowers/plans/2026-03-14-memory-fix-and-reader-ui.md new file mode 100644 index 0000000..bc80396 --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-memory-fix-and-reader-ui.md @@ -0,0 +1,1818 @@ +# Memory Fix and Reader UI Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the iOS memory crash during TTS playback and rebuild the reading UI with continuous scroll and paged (Apple Books-style) modes. + +**Architecture:** Surgical memory fix (autoreleasepool + MLX cache clear), then bottom-up UI rebuild: parser gains NSAttributedString output → Book caches sentences → ReaderViewModel extracts shared logic → platform views become thin shells with scroll/paged modes. + +**Tech Stack:** Swift 6.2, SwiftUI, TextKit 2, AVAudioEngine, MLX, SwiftData, XcodeGen + +**Spec:** `docs/superpowers/specs/2026-03-14-memory-fix-and-reader-ui-design.md` + +--- + +## File Structure + +**Modified files:** +- `VorleserKit/Sources/Synthesizer/Synthesizer.swift` — add `clearCache()` method +- `VorleserKit/Sources/AudioEngine/AudioEngine.swift` — autoreleasepool + cache clear in synthesis loop +- `VorleserKit/Sources/BookParser/Book.swift` — cache sentences as stored property +- `VorleserKit/Sources/BookParser/Chapter.swift` — add `attributedText`, mark `@unchecked Sendable` +- `VorleserKit/Sources/BookParser/EPUBParser.swift` — build NSAttributedString from HTML DOM +- `VorleserKit/Sources/BookParser/PlainTextParser.swift` — single chapter, build NSAttributedString +- `VorleserKit/Sources/Storage/StoredBook.swift` — add `readingMode` property +- `VorleserKit/Tests/BookParserTests/PlainTextParserTests.swift` — update for new behavior +- `VorleserKit/Tests/BookParserTests/BookTests.swift` — update for cached sentences + attributedText +- `VorleserKit/Tests/BookParserTests/EPUBParserTests.swift` — test attributedText output +- `Vorleser-iOS/ReaderView.swift` — rewrite as thin shell +- `Vorleser-macOS/MacReaderView.swift` — rewrite as thin shell + +**New files:** +- `VorleserKit/Sources/BookParser/AttributedStringBuilder.swift` — platform-specific NSAttributedString construction +- `VorleserKit/Sources/AudioEngine/ReaderViewModel.swift` — shared reader logic +- `Vorleser-iOS/BookTextView.swift` — UIViewRepresentable scroll mode +- `Vorleser-iOS/PagedBookView.swift` — UIViewControllerRepresentable paged mode +- `Vorleser-macOS/BookTextView.swift` — NSViewRepresentable scroll mode +- `Vorleser-macOS/PagedBookView.swift` — NSViewRepresentable paged mode + +**Removed files:** +- `Vorleser-iOS/ReadingTextView.swift` +- `Vorleser-macOS/MacReadingTextView.swift` + +--- + +## Chunk 1: Memory Fix and Book.sentences Caching + +### Task 1: Add `clearCache()` to Synthesizer + +**Files:** +- Modify: `VorleserKit/Sources/Synthesizer/Synthesizer.swift` + +- [ ] **Step 1: Add clearCache method** + +Add to `Synthesizer.swift` after the `synthesize` method: + +```swift +public func clearCache() { + MLX.Memory.clearCache() +} +``` + +The `MLX` module is already imported. + +- [ ] **Step 2: Build to verify** + +Run: `cd VorleserKit && swift build 2>&1 | tail -5` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add VorleserKit/Sources/Synthesizer/Synthesizer.swift +git commit -m "add Synthesizer.clearCache() wrapping MLX.Memory.clearCache()" +``` + +### Task 2: Add autoreleasepool and cache clearing to AudioEngine + +**Files:** +- Modify: `VorleserKit/Sources/AudioEngine/AudioEngine.swift` + +- [ ] **Step 1: Wrap main synthesis call in autoreleasepool** + +In `playbackLoop()`, replace the synthesis block (lines 132-139): + +```swift +} else { + do { + let samples = try synthesizer.synthesize(text: sentence.text) + buffer = Self.makePCMBuffer(from: samples) + } catch { + currentSentenceIndex += 1 + continue + } +} +``` + +With: + +```swift +} else { + do { + let samples: [Float] = try autoreleasepool { + try synthesizer.synthesize(text: sentence.text) + } + synthesizer.clearCache() + buffer = Self.makePCMBuffer(from: samples) + } catch { + currentSentenceIndex += 1 + continue + } +} +``` + +- [ ] **Step 2: Wrap prefetch synthesis in autoreleasepool** + +Replace the prefetch Task.detached closure (lines 148-151): + +```swift +return Task.detached { [synthesizer] in + guard let samples = try? synthesizer.synthesize(text: nextText) else { return nil } + return SendableBuffer(buffer: Self.makePCMBuffer(from: samples)) +} +``` + +With: + +```swift +return Task.detached { [synthesizer] in + guard let samples: [Float] = try? autoreleasepool(invoking: { + try synthesizer.synthesize(text: nextText) + }) else { return nil } + synthesizer.clearCache() + return SendableBuffer(buffer: Self.makePCMBuffer(from: samples)) +} +``` + +- [ ] **Step 3: Build to verify** + +Run: `cd VorleserKit && swift build 2>&1 | tail -5` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add VorleserKit/Sources/AudioEngine/AudioEngine.swift +git commit -m "fix memory leak: autoreleasepool + cache clear around TTS synthesis" +``` + +### Task 3: Cache Book.sentences as stored property + +**Files:** +- Modify: `VorleserKit/Sources/BookParser/Book.swift` +- Modify: `VorleserKit/Tests/BookParserTests/BookTests.swift` + +- [ ] **Step 1: Write test for cached sentences** + +Add to `BookTests.swift` in the `BookCharacterAddressingTests` suite: + +```swift +@Test func sentencesAreCached() { + let s1 = book.sentences + let s2 = book.sentences + #expect(s1.count == s2.count) + #expect(s1[0].text == s2[0].text) + #expect(s1[0].range == s2[0].range) +} +``` + +- [ ] **Step 2: Run test to verify it passes (it works with computed too)** + +Run: `cd VorleserKit && swift test --filter BookCharacterAddressingTests 2>&1 | tail -10` +Expected: PASS + +- [ ] **Step 3: Convert sentences to stored property** + +Replace the `Book` struct in `Book.swift`: + +```swift +public struct Book: Sendable { + public let id: UUID + public let title: String + public let author: String? + public let chapters: [Chapter] + public let sentences: [Sentence] + + public init(id: UUID = UUID(), title: String, author: String?, chapters: [Chapter]) { + self.id = id + self.title = title + self.author = author + self.chapters = chapters + + var result: [Sentence] = [] + var offset: CharacterOffset = 0 + for chapter in chapters { + let chapterSentences = SentenceSegmenter.segment(chapter.text, globalOffset: offset) + result.append(contentsOf: chapterSentences) + offset += chapter.text.count + } + self.sentences = result + } + + /// Returns the sentence index containing the given global character offset. + public func sentenceIndex(containing offset: CharacterOffset) -> Int? { + sentences.firstIndex { $0.range.contains(offset) } + } + + /// Maps a global character offset to (chapter index, local offset within chapter). + public func chapterAndLocalOffset(for globalOffset: CharacterOffset) -> (chapterIndex: Int, localOffset: Int)? { + var offset = 0 + for chapter in chapters { + let chapterEnd = offset + chapter.text.count + if globalOffset < chapterEnd { + return (chapter.index, globalOffset - offset) + } + offset = chapterEnd + } + return nil + } + + /// Total character count across all chapters. + public var totalCharacters: Int { + chapters.reduce(0) { $0 + $1.text.count } + } +} +``` + +- [ ] **Step 4: Run all BookParser tests** + +Run: `cd VorleserKit && swift test --filter BookParser 2>&1 | tail -10` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add VorleserKit/Sources/BookParser/Book.swift VorleserKit/Tests/BookParserTests/BookTests.swift +git commit -m "cache Book.sentences as stored property computed at init" +``` + +--- + +## Chunk 2: Chapter + AttributedString and Parser Updates + +### Task 4: Add attributedText to Chapter + +**Files:** +- Modify: `VorleserKit/Sources/BookParser/Chapter.swift` + +- [ ] **Step 1: Update Chapter struct** + +Replace `Chapter.swift` with: + +```swift +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// A chapter of a book. Immutable after construction. +/// `@unchecked Sendable` because NSAttributedString is immutable (not NSMutableAttributedString). +public struct Chapter: @unchecked Sendable { + public let index: Int + public let title: String + public let text: String + public let attributedText: NSAttributedString + + public init(index: Int, title: String, text: String, attributedText: NSAttributedString? = nil) { + self.index = index + self.title = title + self.text = text + self.attributedText = attributedText ?? NSAttributedString(string: text) + } +} +``` + +- [ ] **Step 2: Build to verify existing code compiles (old call sites use 3-arg init)** + +Run: `cd VorleserKit && swift build 2>&1 | tail -5` +Expected: Build succeeded (the `attributedText` parameter has a default) + +- [ ] **Step 3: Commit** + +```bash +git add VorleserKit/Sources/BookParser/Chapter.swift +git commit -m "add attributedText to Chapter, mark @unchecked Sendable" +``` + +### Task 5: Create AttributedStringBuilder + +**Files:** +- Create: `VorleserKit/Sources/BookParser/AttributedStringBuilder.swift` + +- [ ] **Step 1: Create the builder** + +Create `VorleserKit/Sources/BookParser/AttributedStringBuilder.swift`: + +```swift +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Builds an NSAttributedString from plain text with formatting ranges. +/// The resulting .string is character-for-character identical to the input text. +enum AttributedStringBuilder { + struct FormattingRange { + enum Style { + case body + case bold + case italic + case heading(level: Int) + case paragraphStart + } + let range: NSRange + let style: Style + } + + /// Build an attributed string from plain text and formatting ranges. + /// The plain text is used as-is — no characters are added or removed. + static func build(text: String, ranges: [FormattingRange]) -> NSAttributedString { + let result = NSMutableAttributedString( + string: text, + attributes: [ + .font: bodyFont(), + .foregroundColor: textColor(), + ] + ) + + for range in ranges { + switch range.style { + case .body: + break + case .bold: + result.addAttribute(.font, value: boldFont(), range: range.range) + case .italic: + result.addAttribute(.font, value: italicFont(), range: range.range) + case .heading(let level): + result.addAttribute(.font, value: headingFont(level: level), range: range.range) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacingBefore = 16 + result.addAttribute(.paragraphStyle, value: paragraphStyle, range: range.range) + case .paragraphStart: + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacingBefore = 8 + result.addAttribute(.paragraphStyle, value: paragraphStyle, range: range.range) + } + } + + return NSAttributedString(attributedString: result) + } + + /// Build a plain attributed string with body font (no formatting ranges). + static func buildPlain(text: String) -> NSAttributedString { + NSAttributedString( + string: text, + attributes: [ + .font: bodyFont(), + .foregroundColor: textColor(), + ] + ) + } + + // MARK: - Platform fonts + + #if canImport(UIKit) + private static func bodyFont() -> UIFont { + .preferredFont(forTextStyle: .body) + } + + private static func boldFont() -> UIFont { + let descriptor = UIFont.preferredFont(forTextStyle: .body).fontDescriptor.withSymbolicTraits(.traitBold)! + return UIFont(descriptor: descriptor, size: 0) + } + + private static func italicFont() -> UIFont { + let descriptor = UIFont.preferredFont(forTextStyle: .body).fontDescriptor.withSymbolicTraits(.traitItalic)! + return UIFont(descriptor: descriptor, size: 0) + } + + private static func headingFont(level: Int) -> UIFont { + let style: UIFont.TextStyle = level <= 2 ? .title1 : .title3 + let descriptor = UIFont.preferredFont(forTextStyle: style).fontDescriptor.withSymbolicTraits(.traitBold)! + return UIFont(descriptor: descriptor, size: 0) + } + + private static func textColor() -> UIColor { + .label + } + + #elseif canImport(AppKit) + private static func bodyFont() -> NSFont { + .preferredFont(forTextStyle: .body) + } + + private static func boldFont() -> NSFont { + let body = NSFont.preferredFont(forTextStyle: .body) + return NSFontManager.shared.convert(body, toHaveTrait: .boldFontMask) + } + + private static func italicFont() -> NSFont { + let body = NSFont.preferredFont(forTextStyle: .body) + return NSFontManager.shared.convert(body, toHaveTrait: .italicFontMask) + } + + private static func headingFont(level: Int) -> NSFont { + let style: NSFont.TextStyle = level <= 2 ? .title1 : .title3 + let heading = NSFont.preferredFont(forTextStyle: style) + return NSFontManager.shared.convert(heading, toHaveTrait: .boldFontMask) + } + + private static func textColor() -> NSColor { + .textColor + } + #endif +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `cd VorleserKit && swift build 2>&1 | tail -5` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add VorleserKit/Sources/BookParser/AttributedStringBuilder.swift +git commit -m "add AttributedStringBuilder for platform-specific NSAttributedString construction" +``` + +### Task 6: Update EPUBParser to produce attributedText + +**Files:** +- Modify: `VorleserKit/Sources/BookParser/EPUBParser.swift` +- Modify: `VorleserKit/Tests/BookParserTests/EPUBParserTests.swift` + +- [ ] **Step 1: Write test for attributedText identity** + +Add to `EPUBParserTests.swift`: + +```swift +@Test func attributedTextStringMatchesPlainText() throws { + let book = try EPUBParser.parse(url: fixtureURL) + for chapter in book.chapters { + #expect(chapter.attributedText.string == chapter.text, + "attributedText.string must be identical to text for chapter '\(chapter.title)'") + } +} + +@Test func attributedTextHasFontAttributes() throws { + let book = try EPUBParser.parse(url: fixtureURL) + let chapter = book.chapters[0] + var hasFont = false + chapter.attributedText.enumerateAttribute(.font, in: NSRange(location: 0, length: chapter.attributedText.length)) { value, _, _ in + if value != nil { hasFont = true } + } + #expect(hasFont, "attributedText should have font attributes") +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd VorleserKit && swift test --filter EPUBParserTests 2>&1 | tail -10` +Expected: `attributedTextStringMatchesPlainText` passes (default NSAttributedString has matching .string), `attributedTextHasFontAttributes` fails (default has no font attribute) + +- [ ] **Step 3: Update EPUBParser to build attributed text** + +Add `#if canImport(UIKit)` / `#if canImport(AppKit)` imports at the top of `EPUBParser.swift`: + +```swift +import Foundation +import ZIPFoundation +import SwiftSoup + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif +``` + +Add a new method to `EPUBParser` and update the chapter construction in `parse(url:)`. Replace the chapter-building loop body (lines 44-58) with: + +```swift +var chapterTitle: String +var chapterText: String +var formattingRanges: [AttributedStringBuilder.FormattingRange] = [] +do { + let htmlData = try extractData(from: archive, path: fullPath) + let html = String(data: htmlData, encoding: .utf8) ?? "" + let doc = try SwiftSoup.parse(html) + chapterTitle = try doc.select("h1, h2, h3, title").first()?.text() ?? "Chapter \(index + 1)" + let body = try doc.body()?.text() ?? "" + chapterText = normalizeWhitespace(body) + formattingRanges = extractFormattingRanges(from: doc, normalizedText: chapterText) +} catch { + chapterTitle = "Chapter \(index + 1) (parse error)" + chapterText = "" +} + +let attributedText = AttributedStringBuilder.build(text: chapterText, ranges: formattingRanges) +chapters.append(Chapter(index: index, title: chapterTitle, text: chapterText, attributedText: attributedText)) +``` + +Add the formatting range extraction method to `EPUBParser`: + +```swift +private static func extractFormattingRanges(from doc: Document, normalizedText: String) -> [AttributedStringBuilder.FormattingRange] { + var ranges: [AttributedStringBuilder.FormattingRange] = [] + + // For now, apply body formatting to the entire text. + // Precise element-to-normalized-text range mapping requires tracking + // which characters in the normalized output came from which HTML elements. + // This is a non-trivial mapping because normalizeWhitespace collapses all + // whitespace. We apply the full body font as a baseline. + // Future: build a proper DOM walker that tracks character positions. + + guard !normalizedText.isEmpty else { return ranges } + + // Apply body font to entire range (AttributedStringBuilder does this by default) + // Attempt to find bold/italic/heading content by searching for element text in normalized text + func findAndAdd(selector: String, style: AttributedStringBuilder.FormattingRange.Style) { + guard let elements = try? doc.select(selector) else { return } + for element in elements { + guard let elementText = try? element.text() else { continue } + let normalizedElement = normalizeWhitespace(elementText) + guard !normalizedElement.isEmpty else { continue } + if let range = normalizedText.range(of: normalizedElement) { + let nsRange = NSRange(range, in: normalizedText) + ranges.append(AttributedStringBuilder.FormattingRange(range: nsRange, style: style)) + } + } + } + + findAndAdd(selector: "b, strong", style: .bold) + findAndAdd(selector: "i, em", style: .italic) + findAndAdd(selector: "h1", style: .heading(level: 1)) + findAndAdd(selector: "h2", style: .heading(level: 2)) + findAndAdd(selector: "h3", style: .heading(level: 3)) + + return ranges +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd VorleserKit && swift test --filter EPUBParserTests 2>&1 | tail -10` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add VorleserKit/Sources/BookParser/EPUBParser.swift VorleserKit/Tests/BookParserTests/EPUBParserTests.swift +git commit -m "EPUBParser produces NSAttributedString with formatting from HTML" +``` + +### Task 7: Restructure PlainTextParser + +**Files:** +- Modify: `VorleserKit/Sources/BookParser/PlainTextParser.swift` +- Modify: `VorleserKit/Tests/BookParserTests/PlainTextParserTests.swift` +- Modify: `VorleserKit/Tests/BookParserTests/BookTests.swift` + +- [ ] **Step 1: Update tests for new behavior** + +Replace `PlainTextParserTests.swift`: + +```swift +import Testing +import Foundation +@testable import BookParser + +@Suite("PlainTextParser") +struct PlainTextParserTests { + @Test func parsesSingleParagraphAsOneChapter() { + let text = "Just a single paragraph with no double newlines." + let book = PlainTextParser.parse(text: text, title: "Test") + #expect(book.chapters.count == 1) + #expect(book.chapters[0].text == text) + } + + @Test func multiParagraphTextBecomesOneChapter() { + let text = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph." + let book = PlainTextParser.parse(text: text, title: "Test Book") + #expect(book.chapters.count == 1) + #expect(book.chapters[0].text == text) + } + + @Test func setsTitle() { + let book = PlainTextParser.parse(text: "Hello", title: "My Book") + #expect(book.title == "My Book") + } + + @Test func parsesFromFile() throws { + let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("test.txt") + try "Line one.\n\nLine two.".write(to: tmpFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmpFile) } + + let book = try PlainTextParser.parse(url: tmpFile) + #expect(book.chapters.count == 1) + #expect(book.title == "test") + } + + @Test func attributedTextStringMatchesPlainText() { + let text = "Hello world.\n\nSecond paragraph." + let book = PlainTextParser.parse(text: text, title: "Test") + #expect(book.chapters[0].attributedText.string == book.chapters[0].text) + } +} +``` + +Also update `BookTests.swift` — the `detectsTextByExtension` test expects 2 chapters, update to 1: + +```swift +@Test func detectsTextByExtension() throws { + let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("test.txt") + try "Paragraph one.\n\nParagraph two.".write(to: tmpFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmpFile) } + + let book = try BookParser.parse(url: tmpFile) + #expect(book.chapters.count == 1) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd VorleserKit && swift test --filter PlainTextParser 2>&1 | tail -10` +Expected: `multiParagraphTextBecomesOneChapter` fails (currently produces 3 chapters) + +- [ ] **Step 3: Rewrite PlainTextParser** + +Replace `PlainTextParser.swift`: + +```swift +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +public struct PlainTextParser { + public static func parse(text: String, title: String, author: String? = nil) -> Book { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return Book(title: title, author: author, chapters: []) + } + + let attributedText = AttributedStringBuilder.buildPlain(text: trimmed) + let chapter = Chapter(index: 0, title: title, text: trimmed, attributedText: attributedText) + return Book(title: title, author: author, chapters: [chapter]) + } + + public static func parse(url: URL) throws -> Book { + let text = try String(contentsOf: url, encoding: .utf8) + let title = url.deletingPathExtension().lastPathComponent + return parse(text: text, title: title) + } +} +``` + +- [ ] **Step 4: Run all BookParser tests** + +Run: `cd VorleserKit && swift test --filter BookParser 2>&1 | tail -10` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add VorleserKit/Sources/BookParser/PlainTextParser.swift VorleserKit/Tests/BookParserTests/PlainTextParserTests.swift VorleserKit/Tests/BookParserTests/BookTests.swift +git commit -m "restructure PlainTextParser to single chapter, add NSAttributedString output" +``` + +### Task 8: Add readingMode to StoredBook + +**Files:** +- Modify: `VorleserKit/Sources/Storage/StoredBook.swift` + +- [ ] **Step 1: Add readingMode property** + +Add after `voiceName`: + +```swift +public var readingMode: String? +``` + +And add to the init parameter list with default: + +```swift +readingMode: String? = "scroll" +``` + +And in the init body: + +```swift +self.readingMode = readingMode +``` + +- [ ] **Step 2: Build to verify** + +Run: `cd VorleserKit && swift build 2>&1 | tail -5` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add VorleserKit/Sources/Storage/StoredBook.swift +git commit -m "add readingMode property to StoredBook" +``` + +--- + +## Chunk 3: ReaderViewModel and Scroll Mode UI + +### Task 9: Create ReaderViewModel + +**Files:** +- Create: `VorleserKit/Sources/AudioEngine/ReaderViewModel.swift` + +- [ ] **Step 1: Create ReaderViewModel** + +Create `VorleserKit/Sources/AudioEngine/ReaderViewModel.swift`: + +```swift +import Foundation +import Observation +import BookParser +import VorleserKit +import class Synthesizer.Synthesizer +import struct Synthesizer.VoicePack + +@Observable +@MainActor +public final class ReaderViewModel { + public private(set) var book: Book? + public private(set) var error: String? + public private(set) var synthesizer: Synthesizer? + public var selectedChapterIndex: Int = 0 + public let engine = AudioEngine() + + /// Character offset in the full book text where each chapter starts. + public private(set) var chapterOffsets: [Int] = [] + + public init() {} + + public func loadBook(fileURL: URL, fileExists: Bool, lastPosition: Int, modelURL: URL?, voicesURL: URL?) async { + guard fileExists else { + self.error = "Book file is missing. Please re-import." + return + } + do { + let parsed = try BookParser.parse(url: fileURL) + self.book = parsed + + // Build chapter offset table + var offsets: [Int] = [] + var offset = 0 + for chapter in parsed.chapters { + offsets.append(offset) + offset += chapter.text.count + } + self.chapterOffsets = offsets + + // Restore chapter position + if lastPosition > 0, + let (chIdx, _) = parsed.chapterAndLocalOffset(for: lastPosition) { + selectedChapterIndex = chIdx + } + + // Init synthesizer + guard let modelURL, let voicesURL else { + error = "TTS model files not found in app bundle." + return + } + let voice = VoicePack.curated.first! + self.synthesizer = try Synthesizer(voice: voice, modelURL: modelURL, voicesURL: voicesURL) + } catch { + self.error = "Failed to load book: \(error)" + } + } + + public func startPlayback(from offset: CharacterOffset) async throws { + guard let book, let synthesizer else { return } + try await engine.play(book: book, from: offset, using: synthesizer) + } + + /// Returns the global character offset for a chapter index. + public func chapterOffset(for chapterIndex: Int) -> CharacterOffset { + guard chapterIndex < chapterOffsets.count else { return 0 } + return chapterOffsets[chapterIndex] + } + + /// Returns the current sentence range in global character offsets, or nil if not playing. + public func currentSentenceRange() -> Range? { + guard engine.state == .playing || engine.state == .synthesizing, + let book else { return nil } + guard let idx = book.sentenceIndex(containing: engine.currentPosition) else { return nil } + return book.sentences[idx].range + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `cd VorleserKit && swift build 2>&1 | tail -5` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add VorleserKit/Sources/AudioEngine/ReaderViewModel.swift +git commit -m "add ReaderViewModel extracting shared reader logic" +``` + +### Task 10: Create iOS BookTextView (scroll mode) + +**Files:** +- Create: `Vorleser-iOS/BookTextView.swift` + +- [ ] **Step 1: Create BookTextView** + +Create `Vorleser-iOS/BookTextView.swift`: + +```swift +import SwiftUI +import UIKit +import VorleserKit + +struct BookTextView: UIViewRepresentable { + let attributedText: NSAttributedString + let highlightRange: Range? + let onTapCharacter: (CharacterOffset) -> Void + var scrollToOffset: CharacterOffset? + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = false + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + + let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) + textView.addGestureRecognizer(tap) + + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + let highlighted = NSMutableAttributedString(attributedString: attributedText) + + if let range = highlightRange, + range.lowerBound >= 0, + range.upperBound <= attributedText.length { + let nsRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound) + highlighted.addAttribute(.backgroundColor, value: UIColor.systemYellow.withAlphaComponent(0.3), range: nsRange) + } + + // Only update if content changed to avoid scroll position reset + if textView.attributedText != highlighted { + textView.attributedText = highlighted + } + + // Auto-scroll to highlighted sentence + if let range = highlightRange, + range.lowerBound >= 0, + range.upperBound <= attributedText.length { + let nsRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound) + textView.scrollRangeToVisible(nsRange) + } + + // Scroll to specific offset (chapter jump) + if let offset = scrollToOffset, + offset >= 0, + offset < attributedText.length { + let nsRange = NSRange(location: offset, length: 1) + textView.scrollRangeToVisible(nsRange) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(onTapCharacter: onTapCharacter) + } + + class Coordinator: NSObject { + let onTapCharacter: (CharacterOffset) -> Void + + init(onTapCharacter: @escaping (CharacterOffset) -> Void) { + self.onTapCharacter = onTapCharacter + } + + @objc func handleTap(_ gesture: UITapGestureRecognizer) { + guard let textView = gesture.view as? UITextView else { return } + let point = gesture.location(in: textView) + let characterIndex = textView.offset( + from: textView.beginningOfDocument, + to: textView.closestPosition(to: point) ?? textView.beginningOfDocument + ) + if characterIndex < textView.attributedText.length { + onTapCharacter(characterIndex) + } + } + } +} +``` + +- [ ] **Step 2: Build iOS target** + +Run: `xcodegen generate && xcodebuild build -project Vorleser.xcodeproj -scheme Vorleser-iOS -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -5` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add Vorleser-iOS/BookTextView.swift +git commit -m "add iOS BookTextView for scroll mode" +``` + +### Task 11: Create macOS BookTextView (scroll mode) + +**Files:** +- Create: `Vorleser-macOS/BookTextView.swift` + +- [ ] **Step 1: Create macOS BookTextView** + +Create `Vorleser-macOS/BookTextView.swift`: + +```swift +import SwiftUI +import AppKit +import VorleserKit + +struct BookTextView: NSViewRepresentable { + let attributedText: NSAttributedString + let highlightRange: Range? + let onClickCharacter: (CharacterOffset) -> Void + var scrollToOffset: CharacterOffset? + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + let textView = scrollView.documentView as! NSTextView + textView.isEditable = false + textView.isSelectable = false + textView.textContainerInset = NSSize(width: 16, height: 16) + + let click = NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleClick(_:))) + textView.addGestureRecognizer(click) + context.coordinator.textView = textView + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + + let highlighted = NSMutableAttributedString(attributedString: attributedText) + + if let range = highlightRange, + range.lowerBound >= 0, + range.upperBound <= attributedText.length { + let nsRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound) + highlighted.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: nsRange) + } + + textView.textStorage?.setAttributedString(highlighted) + + // Auto-scroll to highlighted sentence + if let range = highlightRange, + range.lowerBound >= 0, + range.upperBound <= attributedText.length { + let nsRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound) + textView.scrollRangeToVisible(nsRange) + } + + // Scroll to specific offset (chapter jump) + if let offset = scrollToOffset, + offset >= 0, + offset < attributedText.length { + let nsRange = NSRange(location: offset, length: 1) + textView.scrollRangeToVisible(nsRange) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(onClickCharacter: onClickCharacter) + } + + class Coordinator: NSObject { + weak var textView: NSTextView? + let onClickCharacter: (CharacterOffset) -> Void + + init(onClickCharacter: @escaping (CharacterOffset) -> Void) { + self.onClickCharacter = onClickCharacter + } + + @objc func handleClick(_ gesture: NSClickGestureRecognizer) { + guard let textView else { return } + let point = gesture.location(in: textView) + let characterIndex = textView.characterIndexForInsertion(at: point) + if characterIndex < textView.string.count { + onClickCharacter(characterIndex) + } + } + } +} +``` + +- [ ] **Step 2: Build macOS target** + +Run: `xcodegen generate && xcodebuild build -project Vorleser.xcodeproj -scheme Vorleser-macOS -destination 'platform=macOS' 2>&1 | tail -5` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add Vorleser-macOS/BookTextView.swift +git commit -m "add macOS BookTextView for scroll mode" +``` + +### Task 12: Rewrite iOS ReaderView as thin shell + +**Files:** +- Modify: `Vorleser-iOS/ReaderView.swift` +- Remove: `Vorleser-iOS/ReadingTextView.swift` + +- [ ] **Step 1: Rewrite ReaderView** + +Replace `Vorleser-iOS/ReaderView.swift`: + +```swift +import SwiftUI +import SwiftData +import Storage +import BookParser +import AudioEngine +import VorleserKit + +struct ReaderView: View { + let storedBook: StoredBook + @State private var viewModel = ReaderViewModel() + @State private var scrollToChapterOffset: CharacterOffset? + @State private var fullAttributedText: NSAttributedString? + @State private var readingMode: String = "scroll" + @Environment(\.modelContext) private var modelContext + + private var bookStore: BookStore { + BookStore( + modelContainer: modelContext.container, + documentsDirectory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + ) + } + + var body: some View { + VStack { + if let error = viewModel.error { + ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(error)) + } else if let book = viewModel.book, let attrText = fullAttributedText { + toolbar(book: book) + readingContent(attributedText: attrText) + PlaybackControls(engine: viewModel.engine) + } else { + ProgressView("Loading\u{2026}") + } + } + .navigationTitle(storedBook.title) + .task { await loadBook() } + .onDisappear { + viewModel.engine.stop() + storedBook.readingMode = readingMode + try? bookStore.updatePosition(storedBook, position: viewModel.engine.currentPosition) + } + } + + @ViewBuilder + private func toolbar(book: Book) -> some View { + HStack { + if book.chapters.count > 1 { + Picker("Chapter", selection: Binding( + get: { viewModel.selectedChapterIndex }, + set: { newIndex in + viewModel.selectedChapterIndex = newIndex + scrollToChapterOffset = viewModel.chapterOffset(for: newIndex) + } + )) { + ForEach(book.chapters, id: \.index) { chapter in + Text(chapter.title).tag(chapter.index) + } + } + .pickerStyle(.menu) + } + Picker("Mode", selection: $readingMode) { + Label("Scroll", systemImage: "scroll").tag("scroll") + Label("Book", systemImage: "book").tag("book") + } + .pickerStyle(.segmented) + .frame(maxWidth: 200) + } + .padding(.horizontal) + } + + @ViewBuilder + private func readingContent(attributedText: NSAttributedString) -> some View { + let highlightRange = viewModel.currentSentenceRange() + + if readingMode == "book" { + PagedBookView( + attributedText: attributedText, + highlightRange: highlightRange, + onTapCharacter: { offset in + Task { try await viewModel.startPlayback(from: offset) } + }, + scrollToOffset: scrollToChapterOffset + ) + } else { + BookTextView( + attributedText: attributedText, + highlightRange: highlightRange, + onTapCharacter: { offset in + Task { try await viewModel.startPlayback(from: offset) } + }, + scrollToOffset: scrollToChapterOffset + ) + } + } + + private func loadBook() async { + readingMode = storedBook.readingMode ?? "scroll" + let fileURL = bookStore.fileURL(for: storedBook) + let modelURL = Bundle.main.url(forResource: "kokoro-v1_0", withExtension: "safetensors") + let voicesURL = Bundle.main.url(forResource: "voices", withExtension: "npz") + await viewModel.loadBook( + fileURL: fileURL, + fileExists: bookStore.fileExists(for: storedBook), + lastPosition: storedBook.lastPosition, + modelURL: modelURL, + voicesURL: voicesURL + ) + if let book = viewModel.book { + fullAttributedText = Self.buildFullAttributedText(from: book) + } + } + + private static func buildFullAttributedText(from book: Book) -> NSAttributedString { + let result = NSMutableAttributedString() + for (i, chapter) in book.chapters.enumerated() { + if i > 0 { + let chapterAttr = NSMutableAttributedString(attributedString: chapter.attributedText) + if chapterAttr.length > 0 { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacingBefore = 32 + chapterAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: 1)) + } + result.append(chapterAttr) + } else { + result.append(chapter.attributedText) + } + } + return NSAttributedString(attributedString: result) + } +} +``` + +- [ ] **Step 2: Delete ReadingTextView.swift** + +```bash +rm Vorleser-iOS/ReadingTextView.swift +``` + +- [ ] **Step 3: Regenerate Xcode project and build** + +Run: `xcodegen generate && xcodebuild build -project Vorleser.xcodeproj -scheme Vorleser-iOS -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -10` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add Vorleser-iOS/ReaderView.swift Vorleser-iOS/BookTextView.swift +git rm Vorleser-iOS/ReadingTextView.swift +git commit -m "rewrite iOS ReaderView as thin shell over ReaderViewModel, scroll mode" +``` + +### Task 13: Rewrite macOS MacReaderView as thin shell + +**Files:** +- Modify: `Vorleser-macOS/MacReaderView.swift` +- Remove: `Vorleser-macOS/MacReadingTextView.swift` + +- [ ] **Step 1: Rewrite MacReaderView** + +Replace `Vorleser-macOS/MacReaderView.swift`: + +```swift +import SwiftUI +import SwiftData +import Storage +import BookParser +import AudioEngine +import VorleserKit + +struct MacReaderView: View { + let storedBook: StoredBook + @State private var viewModel = ReaderViewModel() + @State private var scrollToChapterOffset: CharacterOffset? + @State private var fullAttributedText: NSAttributedString? + @State private var readingMode: String = "scroll" + @Environment(\.modelContext) private var modelContext + + private var bookStore: BookStore { + BookStore( + modelContainer: modelContext.container, + documentsDirectory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + ) + } + + var body: some View { + VStack { + if let error = viewModel.error { + ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(error)) + } else if let book = viewModel.book, let attrText = fullAttributedText { + toolbar(book: book) + readingContent(attributedText: attrText) + MacPlaybackControls(engine: viewModel.engine) + } else { + ProgressView("Loading\u{2026}") + } + } + .navigationTitle(storedBook.title) + .task { await loadBook() } + .onDisappear { + viewModel.engine.stop() + storedBook.readingMode = readingMode + try? bookStore.updatePosition(storedBook, position: viewModel.engine.currentPosition) + } + } + + @ViewBuilder + private func toolbar(book: Book) -> some View { + HStack { + if book.chapters.count > 1 { + Picker("Chapter", selection: Binding( + get: { viewModel.selectedChapterIndex }, + set: { newIndex in + viewModel.selectedChapterIndex = newIndex + scrollToChapterOffset = viewModel.chapterOffset(for: newIndex) + } + )) { + ForEach(book.chapters, id: \.index) { chapter in + Text(chapter.title).tag(chapter.index) + } + } + .pickerStyle(.menu) + } + Picker("Mode", selection: $readingMode) { + Label("Scroll", systemImage: "scroll").tag("scroll") + Label("Book", systemImage: "book").tag("book") + } + .pickerStyle(.segmented) + .frame(maxWidth: 200) + } + .padding(.horizontal) + } + + @ViewBuilder + private func readingContent(attributedText: NSAttributedString) -> some View { + let highlightRange = viewModel.currentSentenceRange() + + if readingMode == "book" { + PagedBookView( + attributedText: attributedText, + highlightRange: highlightRange, + onClickCharacter: { offset in + Task { try await viewModel.startPlayback(from: offset) } + }, + scrollToOffset: scrollToChapterOffset + ) + } else { + BookTextView( + attributedText: attributedText, + highlightRange: highlightRange, + onClickCharacter: { offset in + Task { try await viewModel.startPlayback(from: offset) } + }, + scrollToOffset: scrollToChapterOffset + ) + } + } + + private func loadBook() async { + readingMode = storedBook.readingMode ?? "scroll" + let fileURL = bookStore.fileURL(for: storedBook) + let modelURL = Bundle.main.url(forResource: "kokoro-v1_0", withExtension: "safetensors") + let voicesURL = Bundle.main.url(forResource: "voices", withExtension: "npz") + await viewModel.loadBook( + fileURL: fileURL, + fileExists: bookStore.fileExists(for: storedBook), + lastPosition: storedBook.lastPosition, + modelURL: modelURL, + voicesURL: voicesURL + ) + if let book = viewModel.book { + fullAttributedText = Self.buildFullAttributedText(from: book) + } + } + + private static func buildFullAttributedText(from book: Book) -> NSAttributedString { + let result = NSMutableAttributedString() + for (i, chapter) in book.chapters.enumerated() { + if i > 0 { + let chapterAttr = NSMutableAttributedString(attributedString: chapter.attributedText) + if chapterAttr.length > 0 { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacingBefore = 32 + chapterAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: 1)) + } + result.append(chapterAttr) + } else { + result.append(chapter.attributedText) + } + } + return NSAttributedString(attributedString: result) + } +} +``` + +- [ ] **Step 2: Delete MacReadingTextView.swift** + +```bash +rm Vorleser-macOS/MacReadingTextView.swift +``` + +- [ ] **Step 3: Regenerate Xcode project and build** + +Run: `xcodegen generate && xcodebuild build -project Vorleser.xcodeproj -scheme Vorleser-macOS -destination 'platform=macOS' 2>&1 | tail -10` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add Vorleser-macOS/MacReaderView.swift Vorleser-macOS/BookTextView.swift +git rm Vorleser-macOS/MacReadingTextView.swift +git commit -m "rewrite macOS MacReaderView as thin shell over ReaderViewModel, scroll mode" +``` + +--- + +## Chunk 4: Paged (Book) Mode + +### Task 14: Create iOS PagedBookView + +**Files:** +- Create: `Vorleser-iOS/PagedBookView.swift` + +- [ ] **Step 1: Create PagedBookView** + +Create `Vorleser-iOS/PagedBookView.swift`: + +```swift +import SwiftUI +import UIKit +import VorleserKit + +struct PagedBookView: UIViewControllerRepresentable { + let attributedText: NSAttributedString + let highlightRange: Range? + let onTapCharacter: (CharacterOffset) -> Void + var scrollToOffset: CharacterOffset? + + func makeUIViewController(context: Context) -> UIPageViewController { + let pageVC = UIPageViewController( + transitionStyle: .pageCurl, + navigationOrientation: .horizontal + ) + pageVC.dataSource = context.coordinator + pageVC.delegate = context.coordinator + + context.coordinator.parent = self + context.coordinator.recomputePages(for: pageVC.view.bounds.size) + + if let firstPage = context.coordinator.pageViewController(for: 0) { + pageVC.setViewControllers([firstPage], direction: .forward, animated: false) + } + + return pageVC + } + + func updateUIViewController(_ pageVC: UIPageViewController, context: Context) { + context.coordinator.parent = self + context.coordinator.recomputePages(for: pageVC.view.bounds.size) + + // Navigate to page containing scrollToOffset + if let offset = scrollToOffset { + let pageIndex = context.coordinator.pageIndex(containing: offset) + if let vc = context.coordinator.pageViewController(for: pageIndex) { + pageVC.setViewControllers([vc], direction: .forward, animated: false) + } + } + // Auto-follow highlight + else if let range = highlightRange { + let pageIndex = context.coordinator.pageIndex(containing: range.lowerBound) + if pageIndex != context.coordinator.currentPageIndex, + let vc = context.coordinator.pageViewController(for: pageIndex) { + let direction: UIPageViewController.NavigationDirection = pageIndex > context.coordinator.currentPageIndex ? .forward : .reverse + pageVC.setViewControllers([vc], direction: direction, animated: true) + context.coordinator.currentPageIndex = pageIndex + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { + var parent: PagedBookView + var currentPageIndex = 0 + /// Each page boundary is a character offset where the page starts. + var pageBreaks: [Int] = [0] + + init(parent: PagedBookView) { + self.parent = parent + } + + func recomputePages(for size: CGSize) { + guard parent.attributedText.length > 0 else { + pageBreaks = [0] + return + } + + let insets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + let pageWidth = size.width - insets.left - insets.right + let pageHeight = size.height - insets.top - insets.bottom + guard pageWidth > 0, pageHeight > 0 else { + pageBreaks = [0] + return + } + + let textStorage = NSTextStorage(attributedString: parent.attributedText) + let layoutManager = NSLayoutManager() + textStorage.addLayoutManager(layoutManager) + + var breaks: [Int] = [0] + var offset = 0 + + while offset < parent.attributedText.length { + let container = NSTextContainer(size: CGSize(width: pageWidth, height: pageHeight)) + container.lineFragmentPadding = 0 + layoutManager.addTextContainer(container) + + let glyphRange = layoutManager.glyphRange(for: container) + let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + + if charRange.length == 0 { break } + + offset = charRange.location + charRange.length + if offset < parent.attributedText.length { + breaks.append(offset) + } + } + + pageBreaks = breaks + } + + func pageIndex(containing characterOffset: Int) -> Int { + for (i, breakOffset) in pageBreaks.enumerated().reversed() { + if characterOffset >= breakOffset { + return i + } + } + return 0 + } + + func pageViewController(for index: Int) -> UIViewController? { + guard index >= 0, index < pageBreaks.count else { return nil } + let start = pageBreaks[index] + let end = index + 1 < pageBreaks.count ? pageBreaks[index + 1] : parent.attributedText.length + let range = NSRange(location: start, length: end - start) + + let pageText = NSMutableAttributedString(attributedString: parent.attributedText.attributedSubstring(from: range)) + + // Apply highlight if it falls within this page + if let highlightRange = parent.highlightRange { + let hlStart = max(highlightRange.lowerBound - start, 0) + let hlEnd = min(highlightRange.upperBound - start, pageText.length) + if hlStart < hlEnd { + let nsRange = NSRange(location: hlStart, length: hlEnd - hlStart) + pageText.addAttribute(.backgroundColor, value: UIColor.systemYellow.withAlphaComponent(0.3), range: nsRange) + } + } + + let vc = UIViewController() + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = false + textView.isScrollEnabled = false + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + textView.attributedText = pageText + textView.tag = index + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + textView.addGestureRecognizer(tap) + + vc.view = textView + return vc + } + + @objc func handleTap(_ gesture: UITapGestureRecognizer) { + guard let textView = gesture.view as? UITextView else { return } + let pageIndex = textView.tag + let pageStart = pageBreaks[pageIndex] + let point = gesture.location(in: textView) + let localIndex = textView.offset( + from: textView.beginningOfDocument, + to: textView.closestPosition(to: point) ?? textView.beginningOfDocument + ) + let globalIndex = pageStart + localIndex + if globalIndex < parent.attributedText.length { + parent.onTapCharacter(globalIndex) + } + } + + // MARK: - UIPageViewControllerDataSource + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let textView = viewController.view as? UITextView else { return nil } + let index = textView.tag + return self.pageViewController(for: index - 1) + } + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let textView = viewController.view as? UITextView else { return nil } + let index = textView.tag + return self.pageViewController(for: index + 1) + } + + // MARK: - UIPageViewControllerDelegate + + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + guard completed, + let textView = pageViewController.viewControllers?.first?.view as? UITextView else { return } + currentPageIndex = textView.tag + } + } +} +``` + +- [ ] **Step 2: Build iOS target** + +Run: `xcodegen generate && xcodebuild build -project Vorleser.xcodeproj -scheme Vorleser-iOS -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -5` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add Vorleser-iOS/PagedBookView.swift +git commit -m "add iOS PagedBookView with page curl navigation" +``` + +### Task 15: Create macOS PagedBookView + +**Files:** +- Create: `Vorleser-macOS/PagedBookView.swift` + +- [ ] **Step 1: Create macOS PagedBookView** + +Create `Vorleser-macOS/PagedBookView.swift`: + +```swift +import SwiftUI +import AppKit +import VorleserKit + +/// A custom NSView subclass that accepts keyboard events and forwards them to the coordinator. +private class PagedContainerView: NSView { + weak var coordinator: PagedBookView.Coordinator? + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 123: // left arrow + coordinator?.navigatePage(delta: -1) + case 124: // right arrow + coordinator?.navigatePage(delta: 1) + default: + super.keyDown(with: event) + } + } +} + +struct PagedBookView: NSViewRepresentable { + let attributedText: NSAttributedString + let highlightRange: Range? + let onClickCharacter: (CharacterOffset) -> Void + var scrollToOffset: CharacterOffset? + + func makeNSView(context: Context) -> NSView { + let container = PagedContainerView() + container.coordinator = context.coordinator + + let textView = NSTextView() + textView.isEditable = false + textView.isSelectable = false + textView.textContainerInset = NSSize(width: 16, height: 16) + textView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(textView) + NSLayoutConstraint.activate([ + textView.topAnchor.constraint(equalTo: container.topAnchor), + textView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + textView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + ]) + + let click = NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleClick(_:))) + textView.addGestureRecognizer(click) + context.coordinator.textView = textView + + // Request first responder for keyboard events + DispatchQueue.main.async { + container.window?.makeFirstResponder(container) + } + + return container + } + + func updateNSView(_ container: NSView, context: Context) { + guard let textView = context.coordinator.textView else { return } + + context.coordinator.parent = self + context.coordinator.recomputePages(for: container.bounds.size) + + // Navigate to page containing scrollToOffset or highlight + if let offset = scrollToOffset { + context.coordinator.currentPageIndex = context.coordinator.pageIndex(containing: offset) + } else if let range = highlightRange { + let pageIndex = context.coordinator.pageIndex(containing: range.lowerBound) + if pageIndex != context.coordinator.currentPageIndex { + context.coordinator.currentPageIndex = pageIndex + } + } + + context.coordinator.displayCurrentPage() + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject { + var parent: PagedBookView + weak var textView: NSTextView? + var currentPageIndex = 0 + var pageBreaks: [Int] = [0] + + init(parent: PagedBookView) { + self.parent = parent + } + + func recomputePages(for size: CGSize) { + guard parent.attributedText.length > 0 else { + pageBreaks = [0] + return + } + + let insets = NSSize(width: 16, height: 16) + let pageWidth = size.width - insets.width * 2 + let pageHeight = size.height - insets.height * 2 + guard pageWidth > 0, pageHeight > 0 else { + pageBreaks = [0] + return + } + + let textStorage = NSTextStorage(attributedString: parent.attributedText) + let layoutManager = NSLayoutManager() + textStorage.addLayoutManager(layoutManager) + + var breaks: [Int] = [0] + var offset = 0 + + while offset < parent.attributedText.length { + let container = NSTextContainer(size: NSSize(width: pageWidth, height: pageHeight)) + container.lineFragmentPadding = 0 + layoutManager.addTextContainer(container) + + let glyphRange = layoutManager.glyphRange(for: container) + let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + + if charRange.length == 0 { break } + + offset = charRange.location + charRange.length + if offset < parent.attributedText.length { + breaks.append(offset) + } + } + + pageBreaks = breaks + } + + func pageIndex(containing characterOffset: Int) -> Int { + for (i, breakOffset) in pageBreaks.enumerated().reversed() { + if characterOffset >= breakOffset { + return i + } + } + return 0 + } + + func displayCurrentPage() { + guard let textView else { return } + let index = min(currentPageIndex, pageBreaks.count - 1) + let start = pageBreaks[index] + let end = index + 1 < pageBreaks.count ? pageBreaks[index + 1] : parent.attributedText.length + let range = NSRange(location: start, length: end - start) + + let pageText = NSMutableAttributedString(attributedString: parent.attributedText.attributedSubstring(from: range)) + + if let highlightRange = parent.highlightRange { + let hlStart = max(highlightRange.lowerBound - start, 0) + let hlEnd = min(highlightRange.upperBound - start, pageText.length) + if hlStart < hlEnd { + let nsRange = NSRange(location: hlStart, length: hlEnd - hlStart) + pageText.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: nsRange) + } + } + + textView.textStorage?.setAttributedString(pageText) + } + + func navigatePage(delta: Int) { + let newIndex = currentPageIndex + delta + guard newIndex >= 0, newIndex < pageBreaks.count else { return } + currentPageIndex = newIndex + displayCurrentPage() + } + + @objc func handleClick(_ gesture: NSClickGestureRecognizer) { + guard let textView else { return } + let point = gesture.location(in: textView) + let localIndex = textView.characterIndexForInsertion(at: point) + let pageStart = pageBreaks[min(currentPageIndex, pageBreaks.count - 1)] + let globalIndex = pageStart + localIndex + if globalIndex < parent.attributedText.length { + parent.onClickCharacter(globalIndex) + } + } + } +} +``` + +- [ ] **Step 2: Build macOS target** + +Run: `xcodegen generate && xcodebuild build -project Vorleser.xcodeproj -scheme Vorleser-macOS -destination 'platform=macOS' 2>&1 | tail -5` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add Vorleser-macOS/PagedBookView.swift +git commit -m "add macOS PagedBookView with keyboard/click navigation" +``` + +--- + +## Chunk 5: Final Integration and Verification + +### Task 16: Regenerate Xcode project and full build + +- [ ] **Step 1: Regenerate Xcode project** + +Run: `xcodegen generate` + +- [ ] **Step 2: Build both targets** + +Run: `xcodebuild build -project Vorleser.xcodeproj -scheme Vorleser-iOS -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -5` +Run: `xcodebuild build -project Vorleser.xcodeproj -scheme Vorleser-macOS -destination 'platform=macOS' 2>&1 | tail -5` +Expected: Both succeed + +- [ ] **Step 3: Run all tests** + +Run: `cd VorleserKit && swift test 2>&1 | tail -15` +Expected: All tests pass + +- [ ] **Step 4: Commit project file if changed** + +```bash +git add Vorleser.xcodeproj/project.pbxproj project.yml +git commit -m "regenerate Xcode project for new files" +``` + +### Task 17: Manual verification checklist + +- [ ] **Step 1: Run on iOS Simulator** + +Launch the app, open a book, verify: +- Full book text displays in scroll mode +- Tap to play works +- Sentence highlighting appears and auto-scrolls +- Chapter picker jumps to correct position +- Mode toggle switches to paged view +- Page curl navigation works in book mode +- Skip forward/back moves highlight + +- [ ] **Step 2: Run on macOS** + +Launch the app, open a book, verify same functionality as iOS. + +- [ ] **Step 3: Memory profiling (iOS)** + +Profile with Instruments → Allocations while playing 10+ sentences. +Verify memory stays flat (no upward trend). + +- [ ] **Step 4: Commit version bump** + +```bash +# Update MARKETING_VERSION in project.yml to 2026.03.14 +git add project.yml +git commit -m "bump version to 2026.03.14" +```