Files
vorleser/Vorleser-macOS/PagedBookView.swift
2026-03-14 07:46:50 +01:00

178 lines
5.5 KiB
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<Int>?
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
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)
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)
}
}
}
}