Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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,6 +14,8 @@
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 }
Expand All @@ -25,8 +27,10 @@
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 +222,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)
}
}
Comment thread
PSchmiedmayer marked this conversation as resolved.

return contextMenu
}
Expand Down Expand Up @@ -274,6 +290,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,16 @@
UITextInteractionView(
model: model,
exclusionRects: overflowFrames,
openURL: context.environment.openURL
openURL: context.environment.openURL,
selectionActions: context.environment.textSelectionActions
)
}

func updateUIView(_ uiView: UITextInteractionView, context: Context) {
uiView.model = model
uiView.exclusionRects = overflowFrames
uiView.openURL = context.environment.openURL
uiView.selectionActions = context.environment.textSelectionActions
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
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]

weak var inputDelegate: (any UITextInputDelegate)?

Expand All @@ -31,8 +33,10 @@
init(
model: TextSelectionModel,
exclusionRects: [CGRect],
openURL: OpenURLAction
openURL: OpenURLAction,
selectionActions: [TextSelectionAction] = []
) {
self.selectionActions = selectionActions
self.model = model
self.exclusionRects = exclusionRects
self.openURL = openURL
Expand All @@ -58,14 +62,41 @@
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
let hasSelection = !(model.selectedRange?.isCollapsed ?? true)
switch action {
case #selector(copy(_:)), #selector(share(_:)):
return !(model.selectedRange?.isCollapsed ?? true)
return hasSelection
default:
return false
// Look up, translate, search and the rest are the platform's to offer for selected text; refusing
// every unknown selector was what kept them off the menu.
return hasSelection && super.canPerformAction(action, withSender: sender)
}
}

// The edit menu for a text interaction is assembled through the context menu system, so this is where
// the app's own actions join the platform's without replacing how the selection itself works.
override func buildMenu(with builder: any UIMenuBuilder) {
super.buildMenu(with: builder)
guard builder.system == .context, !selectionActions.isEmpty,
let selectedRange = model.selectedRange, !selectedRange.isCollapsed
else {
return
}
let selectedText = Formatter(model.attributedText(in: selectedRange)).plainText()
let actions = selectionActions.map { action in
UIAction(
title: action.title,
image: action.systemImage.map { UIImage(systemName: $0) } ?? nil
) { _ in
action.handler(selectedText)
}
}
Comment thread
PSchmiedmayer marked this conversation as resolved.
Outdated
builder.insertChild(
UIMenu(options: .displayInline, children: actions),
atEndOfMenu: .standardEdit
)
}

override func copy(_ sender: Any?) {
guard let selectedRange = model.selectedRange else {
return
Expand Down
32 changes: 32 additions & 0 deletions Sources/Textual/TextSelectionAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import SwiftUI

/// An action offered on the menu for a text selection, alongside the platform's own.
///
/// The system already provides copying, sharing, look-up and translation for selected text; a selection
/// action adds what the app can do with that text (quote it, search it, save it) without replacing the
/// platform's selection handling.
public struct TextSelectionAction: Identifiable, Sendable {
public let id: String
/// The menu item's title.
public let title: String
/// The name of an SF Symbol shown with the title, where the platform's menu shows images.
public let systemImage: String?
/// Runs with the plain text of the selection when the item is chosen.
public let handler: @MainActor @Sendable (String) -> Void

public init(
id: String? = nil,
title: String,
systemImage: String? = nil,
handler: @escaping @MainActor @Sendable (String) -> Void
) {
self.id = id ?? title
self.title = title
self.systemImage = systemImage
self.handler = handler
}
}

extension EnvironmentValues {
@Entry var textSelectionActions: [TextSelectionAction] = []
}
Loading