Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@
NSTextInteractionView(
model: model,
exclusionRects: overflowFrames,
openURL: context.environment.openURL
openURL: context.environment.openURL,
selectionActions: context.environment.textSelectionActions
)
}

func updateNSView(_ nsView: NSTextInteractionView, context: Context) {
nsView.model = model
nsView.exclusionRects = overflowFrames
nsView.openURL = context.environment.openURL
nsView.selectionActions = context.environment.textSelectionActions
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,24 @@
var model: TextSelectionModel
var exclusionRects: [CGRect]
var openURL: OpenURLAction
/// Actions the app adds to the selection menu, after the platform's own.
var selectionActions: [TextSelectionAction]

override var acceptsFirstResponder: Bool { true }
override var isFlipped: Bool { true }
override var mouseDownCanMoveWindow: Bool { false }

private var dragStart: TextPosition?
private var selectionAnchor: TextPosition?
private var outsideClickMonitor: Any?

init(
model: TextSelectionModel,
exclusionRects: [CGRect],
openURL: OpenURLAction
openURL: OpenURLAction,
selectionActions: [TextSelectionAction] = []
) {
self.selectionActions = selectionActions
self.model = model
self.exclusionRects = exclusionRects
self.openURL = openURL
Expand Down Expand Up @@ -218,6 +223,18 @@
keyEquivalent: ""
)
)
if !selectionActions.isEmpty {
contextMenu.addItem(.separator())
for action in selectionActions {
let item = NSMenuItem(title: action.title, action: #selector(performSelectionAction(_:)), keyEquivalent: "")
item.target = self
item.representedObject = action
if let systemImage = action.systemImage {
item.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: nil)
}
contextMenu.addItem(item)
}
}

return contextMenu
}
Expand Down Expand Up @@ -257,6 +274,40 @@
selectionAnchor = nil
}

/// A selection lives as long as its view is first responder, as it does in a text view.
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned {
resetSelection()
}
return resigned
}

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let outsideClickMonitor {
NSEvent.removeMonitor(outsideClickMonitor)
self.outsideClickMonitor = nil
}
guard window != nil else {
return
}
outsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
self?.dismissSelection(forClickIn: event)
return event
}
}

/// Ends the selection for a click anywhere in the window but on this view; clicks on the view are its own business.
func dismissSelection(forClickIn event: NSEvent) {
guard model.selectedRange != nil, event.window === window,
!bounds.contains(convert(event.locationInWindow, from: nil))
else {
return
}
resetSelection()
}

@objc private func share(_ sender: Any?) {
guard let selectedRange = model.selectedRange else {
return
Expand All @@ -274,6 +325,16 @@
sharingPicker.show(relativeTo: rect, of: self, preferredEdge: .maxY)
}

@objc private func performSelectionAction(_ sender: NSMenuItem) {
guard let selectedRange = model.selectedRange, !selectedRange.isCollapsed,
let action = sender.representedObject as? TextSelectionAction
else {
return
}
action.handler(Formatter(model.attributedText(in: selectedRange)).plainText())
}


@objc private func copy(_ sender: Any?) {
guard let selectedRange = model.selectedRange else {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
}

func isEqual(to other: any TextLayoutCollection) -> Bool {
base == (other as? LiveTextLayoutCollection)?.base
guard let other = other as? LiveTextLayoutCollection else {
return false
}
// Lazy containers measure their content in passes that have no width yet; the layouts published from such a
// pass have no lines, and only the size tells them apart from the ones of the pass that follows.
return base == other.base && geometry.size == other.geometry.size
}

func needsPositionReconciliation(with other: any TextLayoutCollection) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,32 @@

extension TextLayoutCollection {
var startPosition: TextPosition {
TextPosition(
indexPath: .init(runSlice: 0, run: 0, line: 0, layout: 0),
affinity: layouts.count > 0 ? .downstream : .upstream
)
// The first layout can be empty (a blank paragraph, say); the document starts at the first slice there is.
for (layoutIndex, layout) in layouts.enumerated() {
for (lineIndex, line) in layout.lines.enumerated() {
for (runIndex, run) in line.runs.enumerated() where !run.slices.isEmpty {
return TextPosition(
indexPath: .init(runSlice: 0, run: runIndex, line: lineIndex, layout: layoutIndex),
affinity: .downstream
)
}
}
}
return TextPosition(indexPath: .init(runSlice: 0, run: 0, line: 0, layout: 0), affinity: .upstream)
}

var endPosition: TextPosition {
guard
let layout = layouts.last,
let line = layout.lines.last,
let run = line.runs.last
else {
return startPosition
for (layoutIndex, layout) in layouts.enumerated().reversed() {
for (lineIndex, line) in layout.lines.enumerated().reversed() {
for (runIndex, run) in line.runs.enumerated().reversed() where !run.slices.isEmpty {
return TextPosition(
indexPath: .init(runSlice: run.slices.endIndex - 1, run: runIndex, line: lineIndex, layout: layoutIndex),
affinity: .upstream
)
}
}
}
return TextPosition(
indexPath: .init(
runSlice: run.slices.endIndex - 1,
run: line.runs.endIndex - 1,
line: layout.lines.endIndex - 1,
layout: layouts.endIndex - 1
),
affinity: .upstream
)
return startPosition
}

func position(from position: TextPosition, offset: Int) -> TextPosition? {
Expand Down Expand Up @@ -75,15 +78,25 @@
}

func localCharacterRange(at indexPath: IndexPath) -> Range<Int> {
let line = layouts[indexPath.layout].lines[indexPath.line]
return line.runs[indexPath.run]
.slices[indexPath.runSlice]
.characterRange
// A layout without lines (an empty paragraph) has no slice to name; the position is its start.
guard let slice = runSlice(at: indexPath) else {
return 0..<0
}
return slice.characterRange
}

func layoutDirection(at indexPath: IndexPath) -> LayoutDirection {
let line = layouts[indexPath.layout].lines[indexPath.line]
return line.runs[indexPath.run].layoutDirection
guard let line = layouts[safe: indexPath.layout]?.lines[safe: indexPath.line],
let run = line.runs[safe: indexPath.run]
else {
return .leftToRight
}
return run.layoutDirection
}

private func runSlice(at indexPath: IndexPath) -> (any TextRunSlice)? {
layouts[safe: indexPath.layout]?.lines[safe: indexPath.line]?.runs[safe: indexPath.run]?
.slices[safe: indexPath.runSlice]
}

func position(at layoutIndex: Int, localCharacterIndex: Int) -> TextPosition? {
Expand Down Expand Up @@ -307,4 +320,9 @@
)
}
}
extension Array {
fileprivate subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@
// testing, position mapping, and selection rectangle computation.

extension View {
/// Overlays the text layouts published below this view, rebuilt whenever `size` changes.
///
/// The preference alone is not enough to follow the text: while a message streams into a lazy container the
/// layouts are first published from a pass without width, and later passes that only change the size do not
/// re-evaluate the overlay. Reading the size here makes them.
func overlayTextLayoutCollection(
size: CGSize,
@ViewBuilder content: @escaping (any TextLayoutCollection) -> some View
) -> some View {
overlayPreferenceValue(Text.LayoutKey.self) { value in
GeometryReader { geometry in
content(LiveTextLayoutCollection(base: value, geometry: geometry))
.id(size)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,15 @@ import SwiftUI

struct TextSelectionCoordination: ViewModifier {
#if TEXTUAL_ENABLE_TEXT_SELECTION
@Environment(TextSelectionCoordinator.self) private var inherited: TextSelectionCoordinator?
@State private var coordinator = TextSelectionCoordinator()
#endif

func body(content: Content) -> some View {
#if TEXTUAL_ENABLE_TEXT_SELECTION
content.environment(coordinator)
// A coordinator set up further out keeps the whole subtree to one selection; only without one does the
// view coordinate its own paragraphs.
content.environment(inherited ?? coordinator)
#else
content
#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ struct TextSelectionInteraction: ViewModifier {
@Environment(TextSelectionCoordinator.self) private var coordinator: TextSelectionCoordinator?

@State private var model = TextSelectionModel()
@State private var contentSize = CGSize.zero
#endif

func body(content: Content) -> some View {
#if TEXTUAL_ENABLE_TEXT_SELECTION
if textSelection.allowsSelection {
content
.overlayTextLayoutCollection { layoutCollection in
.onGeometryChange(for: CGSize.self, of: \.size) { contentSize = $0 }
.overlayTextLayoutCollection(size: contentSize) { layoutCollection in
Color.clear
.onChange(of: AnyTextLayoutCollection(layoutCollection), initial: true) {
model.setCoordinator(coordinator)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,18 @@
UITextInteractionView(
model: model,
exclusionRects: overflowFrames,
openURL: context.environment.openURL
openURL: context.environment.openURL,
selectionActions: context.environment.textSelectionActions,
selectionActionProminence: context.environment.textSelectionActionProminence
)
}

func updateUIView(_ uiView: UITextInteractionView, context: Context) {
uiView.model = model
uiView.exclusionRects = overflowFrames
uiView.openURL = context.environment.openURL
uiView.selectionActions = context.environment.textSelectionActions
uiView.selectionActionProminence = context.environment.textSelectionActionProminence
}
}
#endif
Loading