Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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
asterisk-marked request with approve and reject buttons that settle
into the outcome, every label host-localized. `FlowConfirmationPart`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 conversationreads 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 |
Expand Down
6 changes: 5 additions & 1 deletion docs/src/content/docs/components/code-block.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ 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, 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),
Expand Down
7 changes: 4 additions & 3 deletions docs/src/content/docs/components/markdown.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 25 additions & 1 deletion docs/src/content/docs/components/message-thread.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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.

## Restyling

`FlowMessageStyle` overrides the user bubble without touching the global
Expand All @@ -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
Expand Down
18 changes: 17 additions & 1 deletion lib/src/utils/flow_chip_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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);
Expand Down
145 changes: 145 additions & 0 deletions lib/src/utils/flow_selection.dart
Original file line number Diff line number Diff line change
@@ -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<FlowSelectionArea> createState() => _FlowSelectionAreaState();
}

class _FlowSelectionAreaState extends State<FlowSelectionArea> {
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<FlowSelectionBlock> createState() => _FlowSelectionBlockState();
}

class _FlowSelectionBlockState extends State<FlowSelectionBlock> {
// 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');
}
}
16 changes: 10 additions & 6 deletions lib/src/widgets/flow_attachment_group.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
),
),
),
Expand Down
41 changes: 32 additions & 9 deletions lib/src/widgets/flow_code_block.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -33,6 +34,12 @@ 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.
/// 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.
class FlowCodeBlock extends StatefulWidget {
Expand Down Expand Up @@ -216,8 +223,19 @@ class _FlowCodeBlockState extends State<FlowCodeBlock> {
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 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<SelectionRegistrarScope>();
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.
Expand All @@ -226,6 +244,7 @@ class _FlowCodeBlockState extends State<FlowCodeBlock> {
padding: padding,
child: code,
);
if (scope == null) body = FlowSelectionArea(child: body);
Comment thread
cursor[bot] marked this conversation as resolved.

return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
Expand Down Expand Up @@ -261,13 +280,17 @@ class _FlowCodeBlockState extends State<FlowCodeBlock> {
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)
Expand Down
Loading