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 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) } } } }