From df6d3814073262788dfbf8075c21650e4c3c6138 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Wed, 2 Sep 2026 04:49:36 +0530 Subject: [PATCH 1/2] feat: make thread text selectable, keeping line breaks on copy Flutter joins the selected text of separate paragraphs with nothing between them, so each paragraph, code body and turn is a selection container that ends its copied text in a line break when the selection runs past it, and the thread's area trims the trailing ones. The selection colours derive from `primary` (the highlight at 30%) behind an explicit host `textSelectionTheme`; the palette gains no token. --- CHANGELOG.md | 13 ++ README.md | 2 +- .../content/docs/components/code-block.mdx | 5 +- docs/src/content/docs/components/markdown.mdx | 7 +- .../docs/components/message-thread.mdx | 26 +++- lib/src/utils/flow_chip_text.dart | 18 ++- lib/src/utils/flow_selection.dart | 145 ++++++++++++++++++ lib/src/widgets/flow_attachment_group.dart | 16 +- lib/src/widgets/flow_code_block.dart | 38 +++-- lib/src/widgets/flow_composer.dart | 72 +++++---- lib/src/widgets/flow_confirmation.dart | 37 +++-- lib/src/widgets/flow_error_state.dart | 31 ++-- lib/src/widgets/flow_markdown.dart | 6 +- lib/src/widgets/flow_message.dart | 7 +- lib/src/widgets/flow_shimmer_text.dart | 28 ++-- lib/src/widgets/flow_streaming_text.dart | 22 ++- lib/src/widgets/flow_thread.dart | 85 ++++++---- playground/lib/src/demos/thread_demo.dart | 3 +- 18 files changed, 435 insertions(+), 126 deletions(-) create mode 100644 lib/src/utils/flow_selection.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 536c855..1254393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## 0.3.0 (unreleased) +- **Selectable text** — text in a `FlowThread` is selectable, the way a + chat in a browser is: drag across turns with a mouse, long-press on + touch, copy with the platform's shortcut or menu. A copy keeps its line + breaks (paragraphs on their own lines, a blank line between turns); + chrome (button labels, the code block's header, the attachment type + pill, the thinking line) and list markers stay out of it. The highlight, + handles and toolbar take `primary`, and the composer's caret and + highlight now match; an explicit `ThemeData.textSelectionTheme` wins + field by field. `FlowThread.selectable` (default true) turns the + thread's own selection area off for a host that installs one around a + larger surface. `FlowCodeBlock` renders its code as one paragraph that + joins a thread's selection, and hosts its own selection area on its + own. Select-all covers the turns the lazy list has built. - **Confirmation** — `FlowConfirmation`, the approval card: an asterisk-marked request with approve and reject buttons that settle into the outcome, every label host-localized. `FlowConfirmationPart` diff --git a/README.md b/README.md index 6b996dc..46ef015 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ | Component | What it does | |---|---| | [`FlowChatView`](https://flowui.stac.dev/components/chat-view) | The full chat surface: bounded thread over a composer, centred at a readable width, with a zero state (greeting, lifted composer, starters), a jump-to-latest button, and drag-and-drop with a washed invitation overlay (web) | -| [`FlowThread`](https://flowui.stac.dev/components/message-thread) | Scrollable conversation — reads from the top, anchoring to the newest message once it outgrows the viewport | +| [`FlowThread`](https://flowui.stac.dev/components/message-thread) | Scrollable conversation: reads from the top, anchoring to the newest message once it outgrows the viewport; text selects across turns and copies with its line breaks | | [`FlowThreadList`](https://flowui.stac.dev/components/thread-list) | Conversation history for a side panel — host-labeled sections, single selection by id, unread and pinned marks | | [`FlowMessage`](https://flowui.stac.dev/components/message-thread) | One turn — ink-wash user bubble, plain assistant, error bubble, typed content parts | | [`FlowStreamingText`](https://flowui.stac.dev/components/streaming-text) | Animated text reveal while a reply arrives | diff --git a/docs/src/content/docs/components/code-block.mdx b/docs/src/content/docs/components/code-block.mdx index 5acdea9..3c91b1b 100644 --- a/docs/src/content/docs/components/code-block.mdx +++ b/docs/src/content/docs/components/code-block.mdx @@ -165,7 +165,10 @@ field. `filename` (header label; falls back to `language`), `onCopy` / `copied` / `copyTooltip` (intent out, host-owned confirmation), `isStreaming`, `wrap`, plus `padding` and `borderRadius` over the - design's 16/12 inset and 12px corner. + design's 16/12 inset and 12px corner. The code is selectable: on its + own the block hosts its own selection, in a thread it joins the + thread's, so a drag from the prose above runs straight through it, and + the header label never selects. - `FlowCodePart` — `code`, `language`, `filename`; rendered by `FlowMessage`, skipped in system messages. - `FlowThread` / `FlowMessage` — `onCodeCopy` (handed the tapped part), diff --git a/docs/src/content/docs/components/markdown.mdx b/docs/src/content/docs/components/markdown.mdx index 568937b..6c9c2b4 100644 --- a/docs/src/content/docs/components/markdown.mdx +++ b/docs/src/content/docs/components/markdown.mdx @@ -110,6 +110,7 @@ Fences restyle through `FlowCodeBlockStyle`. assistant-text rendering; `onLinkTap` threads through with the message-arity shape on the thread. -Prose renders non-selectable `Text.rich`, matching plain assistant text — -wrap the thread in a `SelectionArea` for selection. Fenced code keeps -`FlowCodeBlock`'s own selectable body. +Selection is built into `FlowThread`: prose, tables and fences all join +the thread's selection, and a copy keeps a line break between paragraphs. +A `FlowMarkdown` on its own selects inside a host `SelectionArea`, the way +a plain `Text` does; list markers stay out of it, as in a browser. diff --git a/docs/src/content/docs/components/message-thread.mdx b/docs/src/content/docs/components/message-thread.mdx index 21452f5..7a5c85c 100644 --- a/docs/src/content/docs/components/message-thread.mdx +++ b/docs/src/content/docs/components/message-thread.mdx @@ -111,6 +111,29 @@ extension seam — it carries a `type` your builder switches on and an arbitrary `data` payload, so the host injects its own content (a tool card, a chart) without the library knowing what it is. +## Selecting text + +Text in a thread is selectable, the way a chat in a browser is: drag +across turns with a mouse, long-press a word on touch, then copy with the +platform's shortcut or menu. The highlight, the handles and the toolbar +take the theme's `primary`; an explicit `ThemeData.textSelectionTheme` +wins field by field. + +A copy reads the way the thread does: paragraphs on their own lines, a +blank line between turns, code and table cells included. Chrome stays out +of it (button labels, the code block's header, the type pill on an +attachment, the thinking line), and so do list markers, as in a browser. +Content a host renders (a custom part, a footer, a `leading` slot) is +selectable by default; wrap a widget in `SelectionContainer.disabled` to +keep it out. + +`selectable: false` leaves selection to the host, for a `SelectionArea` +around a larger surface or a long-press the host needs for something +else. A `FlowMessage`, `FlowMarkdown` or `FlowStreamingText` on its own +selects inside a host `SelectionArea`, like a plain `Text`; `FlowCodeBlock` +on its own keeps its own selection. Select-all covers the turns the lazy +list has built. + ## Restyling `FlowMessageStyle` overrides the user bubble without touching the global @@ -133,7 +156,8 @@ Assistant prose is restyled through `FlowMarkdownStyle`, code through (default on-drag) dismisses the keyboard as the thread scrolls; `messageFooter` builds each message's footer slot (an actions row, a timestamp) without replacing the default message the way - `messageBuilder` does. + `messageBuilder` does; `selectable` (default true) hosts the thread's + selection area. - `FlowMessage` — one turn; accepts a `FlowCustomPartBuilder`, an `onAttachmentTap` override, `leading` and `footer` slots (an avatar, the action row), a `textStyle`, the same `markdown` gate and a diff --git a/lib/src/utils/flow_chip_text.dart b/lib/src/utils/flow_chip_text.dart index 10e549d..6ace396 100644 --- a/lib/src/utils/flow_chip_text.dart +++ b/lib/src/utils/flow_chip_text.dart @@ -2,6 +2,7 @@ import 'package:flutter/rendering.dart'; import 'package:material_ui/material_ui.dart'; import '../theme/flow_typography.dart'; +import 'flow_selection.dart'; /// Internal — not exported from the package barrel. /// @@ -56,12 +57,27 @@ class FlowChipSpan extends TextSpan { /// `Text.rich`, chip-aware: renders [span] through a paragraph that /// paints [FlowChipSpan] chips before the glyphs. Mirrors `Text.rich`'s /// ambient wiring — default style, bold text, text scaling, selection -/// registration — so it is a drop-in swap. +/// registration — so it is a drop-in swap. Inside a selection area the +/// paragraph is a [FlowSelectionBlock], so a copy across paragraphs keeps +/// its line breaks. class FlowChipText extends StatelessWidget { const FlowChipText(this.span, {super.key}); final InlineSpan span; + @override + Widget build(BuildContext context) { + // The block is the registrar the paragraph must see, so the ambient + // reads happen a level down. + return FlowSelectionBlock(child: _ChipParagraph(span)); + } +} + +class _ChipParagraph extends StatelessWidget { + const _ChipParagraph(this.span); + + final InlineSpan span; + @override Widget build(BuildContext context) { final defaults = DefaultTextStyle.of(context); diff --git a/lib/src/utils/flow_selection.dart b/lib/src/utils/flow_selection.dart new file mode 100644 index 0000000..af5f90c --- /dev/null +++ b/lib/src/utils/flow_selection.dart @@ -0,0 +1,145 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart' show SelectedContent; +import 'package:material_ui/material_ui.dart'; + +import '../theme/flow_theme.dart'; + +// Internal — not exported from the package barrel. + +/// The highlight's strength over the accent — provisional, pending a +/// design frame for selection. +const double _selectionOpacity = 0.3; + +/// The selection colours for a Flow surface: the host's explicit +/// [TextSelectionTheme] wins field by field, and the tokens fill in the +/// rest — the highlight is `primary` at [_selectionOpacity], the handles +/// and the caret are `primary` itself. +/// +/// A [ThemeData.textSelectionTheme] left at its defaults reads as unset +/// (every field null), so a host that never touched it gets Flow's +/// colours, and one that themed selection app-wide keeps its own. +TextSelectionThemeData flowTextSelectionTheme(BuildContext context) { + final host = TextSelectionTheme.of(context); + final colors = context.flowColors; + return TextSelectionThemeData( + selectionColor: + host.selectionColor ?? + colors.primary.withValues(alpha: _selectionOpacity), + selectionHandleColor: host.selectionHandleColor ?? colors.primary, + cursorColor: host.cursorColor ?? colors.primary, + ); +} + +/// A [SelectionArea] in Flow's colours: the thread's, and a standalone +/// code block's. Installs [flowTextSelectionTheme] above the area so the +/// highlight, the handles and the toolbar all read it — Flutter builds the +/// handles and the menu inside captured inherited themes. +/// +/// The blocks inside end their copied text in a newline whenever the +/// selection runs past them, which a selection drawn to the very end of +/// the content would carry into the clipboard; the area trims those, so a +/// copy never ends in a blank line. +class FlowSelectionArea extends StatefulWidget { + const FlowSelectionArea({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _FlowSelectionAreaState(); +} + +class _FlowSelectionAreaState extends State { + final _FlowSelectionTrimDelegate _delegate = _FlowSelectionTrimDelegate(); + + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextSelectionTheme( + data: flowTextSelectionTheme(context), + child: SelectionArea( + child: SelectionContainer(delegate: _delegate, child: widget.child), + ), + ); + } +} + +/// The area's one container: the copied text without the trailing +/// newlines the last selected blocks appended. +class _FlowSelectionTrimDelegate extends StaticSelectionContainerDelegate { + static final RegExp _trailingNewlines = RegExp(r'\n+$'); + + @override + SelectedContent? getSelectedContent() { + final content = super.getSelectedContent(); + if (content == null) return null; + final text = content.plainText.replaceFirst(_trailingNewlines, ''); + if (text.length == content.plainText.length) return content; + return SelectedContent(plainText: text); + } +} + +/// One block of selectable content — a paragraph, a code body, a whole +/// message — that copies with a line break after it. +/// +/// Flutter joins the selected text of separate paragraphs with nothing +/// between them, so two selected paragraphs copy as one run-on line. A +/// block appends a newline whenever the selection runs past its end, +/// which is exactly when another block follows in the selection. Blocks +/// nest: a message block wraps its paragraphs, so its own newline lands +/// after theirs and turns copy a blank line apart. +/// +/// Inert outside a selection area — and under [SelectionContainer.disabled] +/// — where it renders [child] as is. +class FlowSelectionBlock extends StatefulWidget { + const FlowSelectionBlock({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _FlowSelectionBlockState(); +} + +class _FlowSelectionBlockState extends State { + // SelectionContainer does not dispose the delegate it is handed; the + // block owns it for the container's lifetime. + final _FlowSelectionBlockDelegate _delegate = _FlowSelectionBlockDelegate(); + + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (SelectionContainer.maybeOf(context) == null) return widget.child; + return SelectionContainer(delegate: _delegate, child: widget.child); + } +} + +/// The static delegate — it already tracks the selection's edges and +/// re-synthesizes them for a child that registers late, like a paragraph +/// whose text just grew mid-stream — with one change: the copied text +/// ends in a newline when the selection reaches past the block. +class _FlowSelectionBlockDelegate extends StaticSelectionContainerDelegate { + @override + SelectedContent? getSelectedContent() { + final content = super.getSelectedContent(); + final range = getSelection(); + if (content == null || range == null || content.plainText.isEmpty) { + return content; + } + // The selection touches the block's last character only when it + // continues into the next block — or was drawn to exactly its end, + // where a trailing newline is what a browser gives too. + final end = math.max(range.startOffset, range.endOffset); + if (end < contentLength) return content; + return SelectedContent(plainText: '${content.plainText}\n'); + } +} diff --git a/lib/src/widgets/flow_attachment_group.dart b/lib/src/widgets/flow_attachment_group.dart index ed363f7..29402c3 100644 --- a/lib/src/widgets/flow_attachment_group.dart +++ b/lib/src/widgets/flow_attachment_group.dart @@ -471,12 +471,16 @@ class _TypePill extends StatelessWidget { color: colors.onSurfaceVariant, child: Padding( padding: _pillPadding, - child: Text( - kind, - style: FlowTypography.recut( - context.flowTypography.labelSmall, - fontWeight: FontWeight.w600, - ).copyWith(color: colors.surfaceBright), + // Chrome over the tile, not content: never part of a + // selection sweeping the message. + child: SelectionContainer.disabled( + child: Text( + kind, + style: FlowTypography.recut( + context.flowTypography.labelSmall, + fontWeight: FontWeight.w600, + ).copyWith(color: colors.surfaceBright), + ), ), ), ), diff --git a/lib/src/widgets/flow_code_block.dart b/lib/src/widgets/flow_code_block.dart index 1ddbac7..2e001c7 100644 --- a/lib/src/widgets/flow_code_block.dart +++ b/lib/src/widgets/flow_code_block.dart @@ -3,6 +3,7 @@ import 'package:material_ui/material_ui.dart'; import '../styles/flow_code_block_style.dart'; import '../theme/flow_syntax_colors.dart'; import '../theme/flow_theme.dart'; +import '../utils/flow_selection.dart'; import '../utils/flow_syntax_highlighter.dart'; export '../utils/flow_syntax_highlighter.dart' @@ -33,6 +34,10 @@ export '../utils/flow_syntax_highlighter.dart' /// like a selected message action. The package touches no clipboard and /// ships no strings; [copyTooltip] is the affordance's accessible name. /// +/// The code is selectable: in a thread it joins the thread's selection, +/// so a drag from the prose above runs straight through it; on its own it +/// hosts its own selection area, with the platform's handles and menu. +/// /// Fills the width it's given, so it needs a bounded width — any column, /// list or message slot provides one. class FlowCodeBlock extends StatefulWidget { @@ -216,8 +221,18 @@ class _FlowCodeBlockState extends State { final hasHeader = label != null || widget.onCopy != null; final padding = widget.padding ?? _bodyPadding; - final code = SelectableText.rich(span); - final body = widget.wrap + // In a thread the code joins the thread's selection as one block, so + // a drag runs straight through it; on its own the block hosts its own + // area, which keeps a standalone snippet selectable and gives it a + // toolbar. Under a host's SelectionContainer.disabled it does neither. + // One paragraph either way. + final scope = context + .dependOnInheritedWidgetOfExactType(); + final inArea = scope?.registrar != null; + final code = inArea + ? FlowSelectionBlock(child: Text.rich(span)) + : Text.rich(span); + Widget body = widget.wrap ? Padding(padding: padding, child: code) // Padding inside the scroll view, so the last column of a long // line clears the edge instead of sitting against the clip. @@ -226,6 +241,7 @@ class _FlowCodeBlockState extends State { padding: padding, child: code, ); + if (scope == null) body = FlowSelectionArea(child: body); return MouseRegion( onEnter: (_) => setState(() => _hovered = true), @@ -261,13 +277,17 @@ class _FlowCodeBlockState extends State { Expanded( child: label == null ? const SizedBox.shrink() - : Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: typography.labelMedium - .copyWith(color: colors.onSurfaceMuted) - .merge(style?.headerStyle), + // The header is chrome: a selection sweeping + // the block takes the code, never the label. + : SelectionContainer.disabled( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: typography.labelMedium + .copyWith(color: colors.onSurfaceMuted) + .merge(style?.headerStyle), + ), ), ), if (showCopy) diff --git a/lib/src/widgets/flow_composer.dart b/lib/src/widgets/flow_composer.dart index 10da040..4e4e459 100644 --- a/lib/src/widgets/flow_composer.dart +++ b/lib/src/widgets/flow_composer.dart @@ -13,6 +13,7 @@ import '../utils/flow_circle_button.dart'; import '../utils/flow_clipboard_paste.dart'; import '../utils/flow_file_picker.dart'; import '../utils/flow_gradient_outline.dart'; +import '../utils/flow_selection.dart'; import 'flow_attachment_group.dart'; import 'flow_drop_target.dart'; import 'flow_pill.dart'; @@ -971,38 +972,47 @@ class _FlowComposerState extends State { alignment: AlignmentDirectional.topStart, child: Focus( onKeyEvent: _handleKeyEvent, - child: TextField( - controller: _controller, - focusNode: _focusNode, - enabled: widget.enabled, - minLines: 1, - maxLines: widget.maxLines, - // The design's compressed composer: body face on - // the 1.3 control line, so the empty card stands - // at 116. - style: typography.bodyLarge - .copyWith(height: 1.3, color: colors.onSurface) - .merge(style?.textStyle), - // Android's IME rich-content path, the one media - // input the SDK covers without a plugin. - contentInsertionConfiguration: - widget.onContentInserted == null || - !widget.attachmentsEnabled - ? null - : ContentInsertionConfiguration( - onContentInserted: - widget.onContentInserted!, - ), - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - hintText: widget.placeholder, - hintStyle: typography.bodyLarge.copyWith( - height: 1.3, - color: - style?.hintColor ?? colors.onSurfaceMuted, + // The caret and the highlight in the theme's + // selection colours, matching the thread's; a + // host's own textSelectionTheme still wins. + child: TextSelectionTheme( + data: flowTextSelectionTheme(context), + child: TextField( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + minLines: 1, + maxLines: widget.maxLines, + // The design's compressed composer: body face on + // the 1.3 control line, so the empty card stands + // at 116. + style: typography.bodyLarge + .copyWith( + height: 1.3, + color: colors.onSurface, + ) + .merge(style?.textStyle), + // Android's IME rich-content path, the one media + // input the SDK covers without a plugin. + contentInsertionConfiguration: + widget.onContentInserted == null || + !widget.attachmentsEnabled + ? null + : ContentInsertionConfiguration( + onContentInserted: + widget.onContentInserted!, + ), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: widget.placeholder, + hintStyle: typography.bodyLarge.copyWith( + height: 1.3, + color: + style?.hintColor ?? colors.onSurfaceMuted, + ), + contentPadding: EdgeInsets.zero, ), - contentPadding: EdgeInsets.zero, ), ), ), diff --git a/lib/src/widgets/flow_confirmation.dart b/lib/src/widgets/flow_confirmation.dart index b2906d4..8b03a1b 100644 --- a/lib/src/widgets/flow_confirmation.dart +++ b/lib/src/widgets/flow_confirmation.dart @@ -6,6 +6,7 @@ import '../theme/flow_colors.dart'; import '../theme/flow_theme.dart'; import '../theme/flow_typography.dart'; import '../utils/flow_asterisk_painter.dart'; +import '../utils/flow_selection.dart'; /// The approval card: an asterisk-marked request on a raised card, with /// approve and reject buttons that settle into the outcome. @@ -163,7 +164,7 @@ class FlowConfirmation extends StatelessWidget { Widget? titleLabel; if (title != null) { - titleLabel = Text(title, style: titleStyle); + titleLabel = FlowSelectionBlock(child: Text(title, style: titleStyle)); if (message == null) { // The title is all the card says, and requests arrive unprompted: // announce it. @@ -215,11 +216,13 @@ class FlowConfirmation extends StatelessWidget { padding: const EdgeInsets.only(top: _messageGap), child: Semantics( liveRegion: true, - child: Text( - message, - style: typography.bodyMedium - .copyWith(color: colors.onSurface) - .merge(effective?.messageStyle), + child: FlowSelectionBlock( + child: Text( + message, + style: typography.bodyMedium + .copyWith(color: colors.onSurface) + .merge(effective?.messageStyle), + ), ), ), ), @@ -373,10 +376,16 @@ class _ConfirmationButtonState extends State<_ConfirmationButton> { padding: _padding, child: Center( widthFactor: 1, - child: Text( - widget.label, - style: widget.textStyle.copyWith( - color: _hovered ? widget.hoverForeground : widget.foreground, + // A button's label is chrome, not content: never part of a + // selection sweeping the card. + child: SelectionContainer.disabled( + child: Text( + widget.label, + style: widget.textStyle.copyWith( + color: _hovered + ? widget.hoverForeground + : widget.foreground, + ), ), ), ), @@ -459,7 +468,13 @@ class _SettledRow extends StatelessWidget { // The outcome lands unprompted too — announce the flip. Semantics( liveRegion: true, - child: Text(label, style: textStyle.copyWith(color: foreground)), + // The outcome chip is chrome, like the buttons it replaces. + child: SelectionContainer.disabled( + child: Text( + label, + style: textStyle.copyWith(color: foreground), + ), + ), ), ], ], diff --git a/lib/src/widgets/flow_error_state.dart b/lib/src/widgets/flow_error_state.dart index 1a53d17..26da2cb 100644 --- a/lib/src/widgets/flow_error_state.dart +++ b/lib/src/widgets/flow_error_state.dart @@ -3,6 +3,7 @@ import 'package:material_ui/material_ui.dart'; import '../styles/flow_error_state_style.dart'; import '../theme/flow_theme.dart'; import '../theme/flow_typography.dart'; +import '../utils/flow_selection.dart'; /// A failure surface: an error glyph and a host-written explanation on a /// hairline card, with an optional retry pill. @@ -115,7 +116,7 @@ class FlowErrorState extends StatelessWidget { Widget? rowLabel; if (rowText != null) { - rowLabel = Text(rowText, style: rowStyle); + rowLabel = FlowSelectionBlock(child: Text(rowText, style: rowStyle)); if (below == null) { // The row text is all the card says — a lone title as much as a // lone message — and failures arrive unprompted: announce it. @@ -169,11 +170,13 @@ class FlowErrorState extends StatelessWidget { padding: const EdgeInsets.only(top: _messageGap), child: Semantics( liveRegion: true, - child: Text( - below, - style: typography.bodyMedium - .copyWith(color: colors.onSurfaceVariant) - .merge(effective?.messageStyle), + child: FlowSelectionBlock( + child: Text( + below, + style: typography.bodyMedium + .copyWith(color: colors.onSurfaceVariant) + .merge(effective?.messageStyle), + ), ), ), ), @@ -247,12 +250,16 @@ class _RetryButtonState extends State<_RetryButton> { Icon(Icons.refresh, size: _glyphSize, color: foreground), if (label != null) ...[ const SizedBox(width: _glyphGap), - Text( - label, - style: FlowTypography.recut( - typography.labelMedium, - fontWeight: FontWeight.w600, - ).copyWith(color: foreground), + // A button's label is chrome, not content: never part + // of a selection sweeping the card. + SelectionContainer.disabled( + child: Text( + label, + style: FlowTypography.recut( + typography.labelMedium, + fontWeight: FontWeight.w600, + ).copyWith(color: foreground), + ), ), ], ], diff --git a/lib/src/widgets/flow_markdown.dart b/lib/src/widgets/flow_markdown.dart index 35546a6..9d03756 100644 --- a/lib/src/widgets/flow_markdown.dart +++ b/lib/src/widgets/flow_markdown.dart @@ -568,7 +568,11 @@ class _FlowMarkdownState extends State { children: [ SizedBox( width: _listIndent, - child: Text(marker, style: base), + // Markers stay out of a selection, as a browser's do: a + // copied list is its items, not `•item` run together. + child: SelectionContainer.disabled( + child: Text(marker, style: base), + ), ), Expanded( child: Column( diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart index 1a3b097..628ccdd 100644 --- a/lib/src/widgets/flow_message.dart +++ b/lib/src/widgets/flow_message.dart @@ -6,6 +6,7 @@ import '../models/flow_message_part.dart'; import '../styles/flow_message_style.dart'; import '../theme/flow_theme.dart'; import '../utils/flow_attachment_error.dart'; +import '../utils/flow_selection.dart'; import '../utils/flow_shimmer_sweep.dart'; import 'flow_attachment_group.dart'; import 'flow_attachment_preview.dart'; @@ -371,10 +372,8 @@ class FlowMessage extends StatelessWidget { children: [ for (final part in message.parts) switch (part) { - FlowTextPart(:final text) => Text( - text, - style: style, - textAlign: TextAlign.center, + FlowTextPart(:final text) => FlowSelectionBlock( + child: Text(text, style: style, textAlign: TextAlign.center), ), // System messages are centered notices; attachments, images, // code, failures and confirmations belong to user and diff --git a/lib/src/widgets/flow_shimmer_text.dart b/lib/src/widgets/flow_shimmer_text.dart index 7ccbc83..a864997 100644 --- a/lib/src/widgets/flow_shimmer_text.dart +++ b/lib/src/widgets/flow_shimmer_text.dart @@ -15,6 +15,9 @@ import '../utils/flow_shimmer_sweep.dart'; /// across; with [enabled] false — or reduced motion on — the text renders /// statically in the base ink, so the same widget can stay in place once /// the work settles. +/// +/// Never part of a selection around it: a waiting line is status, not +/// content. class FlowShimmerText extends StatelessWidget { const FlowShimmerText({ super.key, @@ -63,21 +66,28 @@ class FlowShimmerText extends StatelessWidget { // is right for a shape but wrong for a label: it would discard a // caller's [style] colour, and cost a saveLayer, for the readers // least able to spare either. + // Status, not content: the line stays out of any selection around it + // — and the sweep's mask would tint a highlight painted under the + // glyphs, so the static form opts out too, for one behaviour. if (!enabled || MediaQuery.disableAnimationsOf(context)) { - return Text(text, style: resolvedStyle, textAlign: textAlign); + return SelectionContainer.disabled( + child: Text(text, style: resolvedStyle, textAlign: textAlign), + ); } // The sweep masks the glyphs, so they paint opaque: a translucent ink // there would dim the whole line a second time on top of the mask's // own alpha. - return FlowShimmerSweep( - baseColor: resolvedBase, - highlightColor: highlightColor ?? colors.onSurface, - duration: duration, - child: Text( - text, - style: resolvedStyle.copyWith(color: const Color(0xFFFFFFFF)), - textAlign: textAlign, + return SelectionContainer.disabled( + child: FlowShimmerSweep( + baseColor: resolvedBase, + highlightColor: highlightColor ?? colors.onSurface, + duration: duration, + child: Text( + text, + style: resolvedStyle.copyWith(color: const Color(0xFFFFFFFF)), + textAlign: textAlign, + ), ), ); } diff --git a/lib/src/widgets/flow_streaming_text.dart b/lib/src/widgets/flow_streaming_text.dart index 06b7bf5..7e3687b 100644 --- a/lib/src/widgets/flow_streaming_text.dart +++ b/lib/src/widgets/flow_streaming_text.dart @@ -5,6 +5,7 @@ import 'package:material_ui/material_ui.dart'; import '../theme/flow_theme.dart'; import '../utils/flow_reveal_engine.dart'; +import '../utils/flow_selection.dart'; /// Animated reveal for text that arrives incrementally. /// @@ -18,6 +19,9 @@ import '../utils/flow_reveal_engine.dart'; /// whenever it would fall more than a beat behind the incoming text, so fast /// streams never leave the animation lagging. If a new [text] does not extend /// the previous one (a regenerate or branch switch), the reveal restarts. +/// +/// Inside a selection area (a `FlowThread`'s, or a host's) the text is one +/// selectable block, so a copy across paragraphs keeps its line breaks. class FlowStreamingText extends StatefulWidget { const FlowStreamingText({ super.key, @@ -142,7 +146,9 @@ class _FlowStreamingTextState extends State // Settled: history messages and completed streams render statically. if (!_ticker.isActive && _engine.revealed >= widget.text.length) { - return Text(widget.text, style: style, textAlign: widget.textAlign); + return FlowSelectionBlock( + child: Text(widget.text, style: style, textAlign: widget.textAlign), + ); } final text = widget.text; @@ -188,12 +194,14 @@ class _FlowStreamingTextState extends State // Expose the full text once so screen readers aren't re-announced // on every animation frame. - return Semantics( - label: widget.text, - child: ExcludeSemantics( - child: Text.rich( - TextSpan(style: style, children: spans), - textAlign: widget.textAlign, + return FlowSelectionBlock( + child: Semantics( + label: widget.text, + child: ExcludeSemantics( + child: Text.rich( + TextSpan(style: style, children: spans), + textAlign: widget.textAlign, + ), ), ), ); diff --git a/lib/src/widgets/flow_thread.dart b/lib/src/widgets/flow_thread.dart index b33baf1..dd3c6a4 100644 --- a/lib/src/widgets/flow_thread.dart +++ b/lib/src/widgets/flow_thread.dart @@ -5,6 +5,7 @@ import 'package:material_ui/material_ui.dart'; import '../models/flow_message_data.dart'; import '../models/flow_message_part.dart'; +import '../utils/flow_selection.dart'; import 'flow_message.dart'; /// The scrollable conversation, a list of [FlowMessageData]s. @@ -15,6 +16,12 @@ import 'flow_message.dart'; /// naturally sticks to the newest message while a reply streams in, and /// holds position when the user scrolls up to read history. Needs a /// bounded height (an [Expanded] in a column, or a sized parent). +/// +/// Text in the thread is selectable, the way a chat in a browser is: drag +/// across turns with a mouse, long-press on touch, then copy with the +/// platform's shortcut or menu. Paragraphs copy on their own lines and +/// turns a blank line apart; button labels, code headers and the thinking +/// line stay out of it. [selectable] false leaves selection to the host. class FlowThread extends StatefulWidget { const FlowThread({ super.key, @@ -43,6 +50,7 @@ class FlowThread extends StatefulWidget { this.messageFooter, this.charactersPerSecond = 300, this.thinkingLabel, + this.selectable = true, }); /// Oldest → newest; once the conversation outgrows the viewport, the @@ -148,6 +156,15 @@ class FlowThread extends StatefulWidget { /// on a pending message. final String? thinkingLabel; + /// Whether the thread hosts its own selection area — one region across + /// every turn, so a drag or a long-press selects prose, code and tables + /// alike, and a copy joins paragraphs with line breaks and turns with a + /// blank line, in the theme's selection colours. False renders the + /// content selection-ready but unselectable, for a host that installs + /// its own [SelectionArea] around a larger surface or needs long-press + /// for something else. + final bool selectable; + @override State createState() => _FlowThreadState(); } @@ -281,31 +298,27 @@ class _FlowThreadState extends State { final messages = widget.messages; _syncStreaming(messages); - return NotificationListener( - onNotification: (notification) { - // Depth 0 is the thread's own list — scrollers nested inside - // messages (attachment strips, code blocks) report deeper and - // must not steer the fit. - if (notification.depth == 0) _handleMetrics(notification.metrics); - return false; - }, - child: Align( - alignment: AlignmentDirectional.topCenter, - child: ListView.builder( - controller: widget.controller, - reverse: true, - shrinkWrap: _fits, - scrollCacheExtent: _cacheExtent, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - padding: widget.padding ?? _defaultPadding, - itemCount: messages.length, - itemBuilder: (context, index) { - // Reversed list: index 0 is the newest (bottom) message. - final message = messages[messages.length - 1 - index]; - final isOldest = index == messages.length - 1; - return Padding( - key: ValueKey(message.id), - padding: EdgeInsets.only(top: isOldest ? 0 : gap), + final list = Align( + alignment: AlignmentDirectional.topCenter, + child: ListView.builder( + controller: widget.controller, + reverse: true, + shrinkWrap: _fits, + scrollCacheExtent: _cacheExtent, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + padding: widget.padding ?? _defaultPadding, + itemCount: messages.length, + itemBuilder: (context, index) { + // Reversed list: index 0 is the newest (bottom) message. + final message = messages[messages.length - 1 - index]; + final isOldest = index == messages.length - 1; + return Padding( + key: ValueKey(message.id), + padding: EdgeInsets.only(top: isOldest ? 0 : gap), + // A block per turn, so a copy spanning turns puts a blank line + // between them — the paragraphs inside supply their own line + // breaks. Host-built messages get the same. + child: FlowSelectionBlock( child: widget.messageBuilder?.call(context, message) ?? FlowMessage( @@ -338,10 +351,26 @@ class _FlowThreadState extends State { thinkingLabel: widget.thinkingLabel, footer: widget.messageFooter?.call(message), ), - ); - }, - ), + ), + ); + }, ), ); + + return NotificationListener( + onNotification: (notification) { + // Depth 0 is the thread's own list — scrollers nested inside + // messages (attachment strips, code blocks) report deeper and + // must not steer the fit. + if (notification.depth == 0) _handleMetrics(notification.metrics); + return false; + }, + // The area wraps the whole bounded box, not just the list, so a + // drag that starts in the empty space under a short thread still + // selects. The scrollable joins it on its own — edge autoscroll and + // select-all come with it — and the metrics filter above is + // unaffected: the area is not a viewport. + child: widget.selectable ? FlowSelectionArea(child: list) : list, + ); } } diff --git a/playground/lib/src/demos/thread_demo.dart b/playground/lib/src/demos/thread_demo.dart index 0b59ca5..4dab6ca 100644 --- a/playground/lib/src/demos/thread_demo.dart +++ b/playground/lib/src/demos/thread_demo.dart @@ -38,7 +38,8 @@ SizedBox( const String _default = ''' // A scrollable conversation — reads from the top, anchoring to the // newest message once it outgrows the viewport. Give it -// bounded height; inside FlowChatView that comes for free. +// bounded height; inside FlowChatView that comes for free. Text selects +// across turns; selectable: false leaves that to the host. SizedBox( height: 480, child: FlowThread( From b6f6199d701b8aa6d208b5903c13fb8f17c8f683 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Thu, 3 Sep 2026 13:33:25 +0530 Subject: [PATCH 2/2] fix: keep code blocks unselectable in a thread that opted out A code block hosts its own selection area whenever no registrar scope sits above it, which made an opted-out thread with no host area indistinguishable from a standalone block: every fence and code part stayed selectable and took long-press. The thread now becomes a disabled scope in that case, and only in that case, so its content still joins a host's area when one exists above. --- CHANGELOG.md | 4 +-- .../content/docs/components/code-block.mdx | 7 ++-- .../docs/components/message-thread.mdx | 6 ++-- lib/src/widgets/flow_code_block.dart | 7 ++-- lib/src/widgets/flow_thread.dart | 33 +++++++++++++------ 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1254393..16eb3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ handles and toolbar take `primary`, and the composer's caret and highlight now match; an explicit `ThemeData.textSelectionTheme` wins field by field. `FlowThread.selectable` (default true) turns the - thread's own selection area off for a host that installs one around a - larger surface. `FlowCodeBlock` renders its code as one paragraph that + thread's own selection area off: the content joins a host's area above, + or stays unselectable, code blocks included, when there is none. `FlowCodeBlock` renders its code as one paragraph that joins a thread's selection, and hosts its own selection area on its own. Select-all covers the turns the lazy list has built. - **Confirmation** — `FlowConfirmation`, the approval card: an diff --git a/docs/src/content/docs/components/code-block.mdx b/docs/src/content/docs/components/code-block.mdx index 3c91b1b..fd9adfb 100644 --- a/docs/src/content/docs/components/code-block.mdx +++ b/docs/src/content/docs/components/code-block.mdx @@ -166,9 +166,10 @@ field. `copied` / `copyTooltip` (intent out, host-owned confirmation), `isStreaming`, `wrap`, plus `padding` and `borderRadius` over the design's 16/12 inset and 12px corner. The code is selectable: on its - own the block hosts its own selection, in a thread it joins the - thread's, so a drag from the prose above runs straight through it, and - the header label never selects. + own the block hosts its own selection; in a thread it joins the + thread's, so a drag from the prose above runs straight through it, or + stays unselectable when the thread opted out; the header label never + selects. - `FlowCodePart` — `code`, `language`, `filename`; rendered by `FlowMessage`, skipped in system messages. - `FlowThread` / `FlowMessage` — `onCodeCopy` (handed the tapped part), diff --git a/docs/src/content/docs/components/message-thread.mdx b/docs/src/content/docs/components/message-thread.mdx index 7a5c85c..fe5480d 100644 --- a/docs/src/content/docs/components/message-thread.mdx +++ b/docs/src/content/docs/components/message-thread.mdx @@ -127,9 +127,9 @@ Content a host renders (a custom part, a footer, a `leading` slot) is selectable by default; wrap a widget in `SelectionContainer.disabled` to keep it out. -`selectable: false` leaves selection to the host, for a `SelectionArea` -around a larger surface or a long-press the host needs for something -else. A `FlowMessage`, `FlowMarkdown` or `FlowStreamingText` on its own +`selectable: false` leaves selection to the host: inside a host +`SelectionArea` the content joins it, code blocks included; with no area +above, nothing in the thread selects, so a long-press is the host's. A `FlowMessage`, `FlowMarkdown` or `FlowStreamingText` on its own selects inside a host `SelectionArea`, like a plain `Text`; `FlowCodeBlock` on its own keeps its own selection. Select-all covers the turns the lazy list has built. diff --git a/lib/src/widgets/flow_code_block.dart b/lib/src/widgets/flow_code_block.dart index 2e001c7..4b25116 100644 --- a/lib/src/widgets/flow_code_block.dart +++ b/lib/src/widgets/flow_code_block.dart @@ -37,6 +37,8 @@ export '../utils/flow_syntax_highlighter.dart' /// The code is selectable: in a thread it joins the thread's selection, /// so a drag from the prose above runs straight through it; on its own it /// hosts its own selection area, with the platform's handles and menu. +/// Inside a `FlowThread` that opted out of selection, or under a +/// `SelectionContainer.disabled`, it does neither. /// /// Fills the width it's given, so it needs a bounded width — any column, /// list or message slot provides one. @@ -224,8 +226,9 @@ class _FlowCodeBlockState extends State { // In a thread the code joins the thread's selection as one block, so // a drag runs straight through it; on its own the block hosts its own // area, which keeps a standalone snippet selectable and gives it a - // toolbar. Under a host's SelectionContainer.disabled it does neither. - // One paragraph either way. + // toolbar. Under a disabled scope — a host's + // SelectionContainer.disabled, or a thread that opted out of + // selection — it does neither. One paragraph either way. final scope = context .dependOnInheritedWidgetOfExactType(); final inArea = scope?.registrar != null; diff --git a/lib/src/widgets/flow_thread.dart b/lib/src/widgets/flow_thread.dart index dd3c6a4..cdae696 100644 --- a/lib/src/widgets/flow_thread.dart +++ b/lib/src/widgets/flow_thread.dart @@ -159,10 +159,10 @@ class FlowThread extends StatefulWidget { /// Whether the thread hosts its own selection area — one region across /// every turn, so a drag or a long-press selects prose, code and tables /// alike, and a copy joins paragraphs with line breaks and turns with a - /// blank line, in the theme's selection colours. False renders the - /// content selection-ready but unselectable, for a host that installs - /// its own [SelectionArea] around a larger surface or needs long-press - /// for something else. + /// blank line, in the theme's selection colours. False leaves selection + /// to the host: inside a host's [SelectionArea] the content joins it, + /// code blocks included; with no area above, nothing in the thread + /// selects, so long-press stays the host's. final bool selectable; @override @@ -357,6 +357,24 @@ class _FlowThreadState extends State { ), ); + final Widget content; + if (widget.selectable) { + // The area wraps the whole bounded box, not just the list, so a + // drag that starts in the empty space under a short thread still + // selects. The scrollable joins it on its own — edge autoscroll and + // select-all come with it — and the metrics filter below is + // unaffected: the area is not a viewport. + content = FlowSelectionArea(child: list); + } else if (SelectionContainer.maybeOf(context) != null) { + // A host area above: the content joins it, code blocks included. + content = list; + } else { + // No area anywhere: nothing in the thread selects. A code block + // hosts its own area only outside every scope, and a disabled one + // is a scope — so long-press stays the host's, fences included. + content = SelectionContainer.disabled(child: list); + } + return NotificationListener( onNotification: (notification) { // Depth 0 is the thread's own list — scrollers nested inside @@ -365,12 +383,7 @@ class _FlowThreadState extends State { if (notification.depth == 0) _handleMetrics(notification.metrics); return false; }, - // The area wraps the whole bounded box, not just the list, so a - // drag that starts in the empty space under a short thread still - // selects. The scrollable joins it on its own — edge autoscroll and - // select-all come with it — and the metrics filter above is - // unaffected: the area is not a viewport. - child: widget.selectable ? FlowSelectionArea(child: list) : list, + child: content, ); } }