diff --git a/Sources/Playground/Pages/Kit_SelectableText.swift b/Sources/Playground/Pages/Kit_SelectableText.swift new file mode 100644 index 0000000..bcecb8a --- /dev/null +++ b/Sources/Playground/Pages/Kit_SelectableText.swift @@ -0,0 +1,133 @@ +import Shaft + +final class Kit_SelectableText: StatelessWidget { + func build(context: BuildContext) -> Widget { + PageContent { + Text("SelectableText") + .textStyle(.playgroundTitle) + + Text("A widget that displays text the user can select and copy, but not edit.") + .textStyle(.playgroundAbstract) + + HorizontalDivider() + + Text("Basic Usage") + .textStyle(.playgroundHeading) + + Text( + """ + Use SelectableText to display read-only text that users can \ + select with click-and-drag. Useful for displaying content \ + users might want to copy. + """ + ) + .textStyle(.playgroundBody) + + CodeSection( + """ + SelectableText("This text can be selected!") + """ + ) + + SelectableText("This text can be selected! Try clicking and dragging to highlight it.") + + Text("Styled Text") + .textStyle(.playgroundHeading) + + Text( + """ + SelectableText accepts a style parameter, just like Text. \ + It also inherits from DefaultTextStyle. + """ + ) + .textStyle(.playgroundBody) + + CodeSection( + """ + SelectableText( + "Large bold selectable text", + style: TextStyle( + color: .argb(255, 0, 100, 200), + fontSize: 24, + fontWeight: .bold + ) + ) + """ + ) + + SelectableText( + "Large bold selectable text", + style: TextStyle( + color: .argb(255, 0, 100, 200), + fontSize: 24, + fontWeight: .bold + ) + ) + + Text("Multiline") + .textStyle(.playgroundHeading) + + Text( + """ + By default, SelectableText supports multiple lines. \ + Set maxLines to limit the number of visible lines. + """ + ) + .textStyle(.playgroundBody) + + CodeSection( + """ + SelectableText( + \"\"\" + Line 1: Swift is a powerful language. + Line 2: Shaft brings cross-platform UI. + Line 3: SelectableText lets users copy content. + \"\"\", + maxLines: nil + ) + """ + ) + + SelectableText( + """ + Line 1: Swift is a powerful and intuitive programming language. + Line 2: Shaft brings cross-platform UI to Swift developers. + Line 3: SelectableText lets users select and copy read-only content. + Line 4: This is great for displaying code snippets, logs, or any text users might want to copy. + """, + maxLines: nil + ) + + Text("Custom Selection Color") + .textStyle(.playgroundHeading) + + CodeSection( + """ + SelectableText( + "Select me to see a green highlight!", + selectionColor: .argb(100, 0, 200, 100) + ) + """ + ) + + SelectableText( + "Select me to see a green highlight!", + selectionColor: .argb(100, 0, 200, 100) + ) + + Text("vs Text") + .textStyle(.playgroundHeading) + + Text( + """ + For comparison, regular Text cannot be selected. \ + Try selecting the text below — it won't work. + """ + ) + .textStyle(.playgroundBody) + + Text("This is regular Text — you cannot select it.") + .textStyle(.init(color: .argb(255, 150, 150, 150), fontSize: 17)) + } + } +} diff --git a/Sources/Playground/main.swift b/Sources/Playground/main.swift index 72f998c..5a582ea 100644 --- a/Sources/Playground/main.swift +++ b/Sources/Playground/main.swift @@ -34,6 +34,7 @@ final class PlaygroundState: State { "Markdown": Kit_Markdown(), "NavigationSplitView": Kit_NavigationSplitView(), "Resizable": Kit_Resizable(), + "SelectableText": Kit_SelectableText(), "TextField": Kit_TextField(), "Typography": Kit_Typography(), "Hacker News": HackerNewsApp(), @@ -85,6 +86,7 @@ final class PlaygroundState: State { MenuTile("ListView") MenuTile("NavigationSplitView") MenuTile("Resizable") + MenuTile("SelectableText") MenuTile("TextField") MenuTile("Typography") MenuTile("Markdown") diff --git a/Sources/Shaft/ShaftKit/SelectableText.swift b/Sources/Shaft/ShaftKit/SelectableText.swift new file mode 100644 index 0000000..b211378 --- /dev/null +++ b/Sources/Shaft/ShaftKit/SelectableText.swift @@ -0,0 +1,187 @@ +// Copyright 2024 The Shaft Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A widget that displays text that can be selected by the user. +/// +/// This is essentially a read-only [TextField] with no decoration, allowing +/// users to select and copy text but not edit it. +/// +/// Example usage: +/// ```swift +/// SelectableText("Hello, world!") +/// +/// SelectableText( +/// "Styled selectable text", +/// style: TextStyle(fontSize: 20, fontWeight: .bold) +/// ) +/// ``` +public final class SelectableText: StatefulWidget { + public init( + _ data: String, + key: (any Key)? = nil, + style: TextStyle? = nil, + strutStyle: StrutStyle? = nil, + textAlign: TextAlign = .start, + textDirection: TextDirection? = nil, + maxLines: Int? = nil, + focusNode: FocusNode? = nil, + selectionColor: Color? = nil, + dragStartBehavior: DragStartBehavior = .start, + scrollPhysics: ScrollPhysics? = nil, + textHeightBehavior: TextHeightBehavior? = nil, + textWidthBasis: TextWidthBasis = .parent, + onSelectionChanged: SelectionChangedCallback? = nil + ) { + self.data = data + self.key = key + self.style = style + self.strutStyle = strutStyle + self.textAlign = textAlign + self.textDirection = textDirection + self.maxLines = maxLines + self.focusNode = focusNode + self.selectionColor = selectionColor + self.dragStartBehavior = dragStartBehavior + self.scrollPhysics = scrollPhysics + self.textHeightBehavior = textHeightBehavior + self.textWidthBasis = textWidthBasis + self.onSelectionChanged = onSelectionChanged + } + + public let key: (any Key)? + + /// The text to display. + public let data: String + + /// The style to use for the text. + /// + /// If null, defaults to the closest enclosing [DefaultTextStyle]. + public let style: TextStyle? + + public let strutStyle: StrutStyle? + + /// How the text should be aligned horizontally. + public let textAlign: TextAlign + + /// The directionality of the text. + public let textDirection: TextDirection? + + /// An optional maximum number of lines for the text to span, wrapping if + /// necessary. If the text exceeds the given number of lines, it will be + /// truncated according to the text overflow behavior. + /// + /// If this is null (the default), the text may span any number of lines. + public let maxLines: Int? + + /// Controls whether this widget has keyboard focus. + public let focusNode: FocusNode? + + /// The color to use when painting the selection. + public let selectionColor: Color? + + /// Determines the way that drag start behavior is handled. + public let dragStartBehavior: DragStartBehavior + + public let scrollPhysics: ScrollPhysics? + + public let textHeightBehavior: TextHeightBehavior? + + public let textWidthBasis: TextWidthBasis + + /// Called when the user changes the selection. + public let onSelectionChanged: SelectionChangedCallback? + + public func createState() -> some State { + SelectableTextState() + } +} + +public final class SelectableTextState: State, + TextSelectionGestureDetectorBuilder.Delegate +{ + public var editableTextKey = StateGlobalKey() + + public var forcePressEnabled: Bool { false } + + public var selectionEnabled: Bool { true } + + private lazy var controller = TextEditingController() + private lazy var localFocusNode = FocusNode() + + private var effectiveFocusNode: FocusNode { + widget.focusNode ?? localFocusNode + } + + public override func initState() { + super.initState() + controller.text = widget.data + } + + public override func didUpdateWidget(_ oldWidget: SelectableText) { + super.didUpdateWidget(oldWidget) + if widget.data != oldWidget.data { + controller.text = widget.data + } + } + + public override func dispose() { + localFocusNode.dispose() + controller.dispose() + super.dispose() + } + + private func handleSelectionChanged( + selection: TextSelection?, + cause: SelectionChangedCause? + ) { + widget.onSelectionChanged?(selection, cause) + } + + public override func build(context: BuildContext) -> Widget { + let gestureBuilder = TextSelectionGestureDetectorBuilder(delegate: self) + + let defaultTextStyle = DefaultTextStyle.of(context) + var effectiveTextStyle = widget.style + if effectiveTextStyle == nil || effectiveTextStyle!.inherit { + effectiveTextStyle = defaultTextStyle.style.merge(widget.style) + } + + let selectionColor: Color = + widget.selectionColor ?? .argb(100, 0, 122, 255) + let cursorColor: Color = .argb(0, 0, 0, 0) // transparent - no cursor + let backgroundCursorColor: Color = .argb(0, 0, 0, 0) + + let editable: Widget = EditableText( + key: editableTextKey, + autofocus: false, + backgroundCursorColor: backgroundCursorColor, + controller: controller, + cursorColor: cursorColor, + cursorWidth: 0, + dragStartBehavior: widget.dragStartBehavior, + enableInteractiveSelection: true, + expands: false, + focusNode: effectiveFocusNode, + maxLines: widget.maxLines, + mouseCursor: .system(.text), + onSelectionChanged: handleSelectionChanged, + readOnly: true, + rendererIgnoresPointer: true, + scrollPhysics: widget.scrollPhysics, + selectionColor: selectionColor, + showCursor: false, + showSelectionHandles: false, + strutStyle: widget.strutStyle, + style: effectiveTextStyle ?? TextStyle(), + textAlign: widget.textAlign, + textDirection: widget.textDirection, + textHeightBehavior: widget.textHeightBehavior, + textWidthBasis: widget.textWidthBasis + ) + + return gestureBuilder.buildGestureDetector(behavior: .translucent) { + editable + } + } +}