make native text widgets selection-aware (T-135)

Foundation for rendering the Claude conversation natively (T-132) with
the cross-widget select+copy the terminal gives today. Converts the raw
RichText in clide_markdown + clide_code_block to Text.rich, which
registers with a Flutter SelectionArea's selection machinery (raw
RichText does not). Adds a selectionBackground surface token
(globalFocus at ~40% alpha, matching the terminal's selection tint) via
tokens + resolver default; bundled palettes are untouched (D-69).

Text and code blocks now select across each other under a SelectionArea;
tables and tappable link-spans remain non-selectable islands for now.
The selection contrast pair is intentionally not added to the WCAG gate:
the tint is semi-transparent and the gate's neutral-grey compositor would
false-fail it (documented in contrast.dart); deferred to the -hc/-cb pass.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-22 19:36:43 +02:00
co-authored by Claude
parent 04aed1e851
commit 9fed749084
6 changed files with 237 additions and 5 deletions
+14
View File
@@ -185,6 +185,20 @@ List<ContrastPair> extendedPairs(SurfaceTokens s) => [
background: s.globalBackground,
largeText: true,
),
// selection.foreground_on_selection is intentionally omitted here.
//
// The `selectionBackground` token defaults to `globalFocus.withAlpha(0x66)`
// — a semi-transparent tint composited onto the real content background at
// runtime. The WCAG compositor in contrastRatio() blends onto neutral grey
// (0x808080) rather than the actual dark panel background, which
// systematically understates the readable contrast for all current bundled
// themes. Adding the pair here would require retuning palettes, which D-69
// forbids for user-contract themes.
//
// Enforcement is deferred to a follow-up ticket: -hc/-cb variants will
// declare an explicit `surface.selectionBackground` override that is
// opaque enough to clear 3:1 against the grey compositor, at which point
// the pair can be added to extendedPairs.
];
/// Convenience for tests: returns the list of [canonicalPairs] that
+9
View File
@@ -36,6 +36,14 @@ class ThemeResolver {
);
}
// selectionBackground defaults to globalFocus at ~40 % opacity (0x66 alpha)
// when the theme does not declare an explicit surface override for it.
// This matches the terminal's established convention of
// `globalFocus.withAlpha(0x66)` for focus-adjacent highlights.
if (surfaceOverride?[TokenKeys.selectionBackground] == null) {
surface[TokenKeys.selectionBackground] = surface[TokenKeys.globalFocus]!.withAlpha(0x66);
}
final extTokens = <String, Color>{};
if (extensionOverride != null) {
for (final entry in extensionOverride.entries) {
@@ -111,6 +119,7 @@ class ThemeResolver {
syntaxComment: surface[TokenKeys.syntaxComment]!,
syntaxMethod: surface[TokenKeys.syntaxMethod]!,
syntaxPunct: surface[TokenKeys.syntaxPunct]!,
selectionBackground: surface[TokenKeys.selectionBackground]!,
extensionTokens: extTokens,
);
}
+11
View File
@@ -94,6 +94,8 @@ class SurfaceTokens {
required this.syntaxComment,
required this.syntaxMethod,
required this.syntaxPunct,
// selection
required this.selectionBackground,
required this.extensionTokens,
});
@@ -186,6 +188,11 @@ class SurfaceTokens {
final Color syntaxMethod;
final Color syntaxPunct;
/// Background color for text selection highlights. Rendered at ~40 % alpha
/// (like the terminal's `globalFocus.withAlpha(0x66)`) so the selected text
/// remains legible through the tint.
final Color selectionBackground;
/// Extension-declared tokens keyed by their dotted path
/// (e.g. `ext.sqlite.table.background`).
final Map<String, Color> extensionTokens;
@@ -296,6 +303,9 @@ abstract class TokenKeys {
static const syntaxMethod = 'syntax.method';
static const syntaxPunct = 'syntax.punct';
// selection
static const selectionBackground = 'selection.background';
static const all = <String>[
globalForeground,
globalBackground,
@@ -363,5 +373,6 @@ abstract class TokenKeys {
syntaxComment,
syntaxMethod,
syntaxPunct,
selectionBackground,
];
}
+1 -1
View File
@@ -76,7 +76,7 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: RichText(text: textSpan),
child: Text.rich(textSpan),
),
);
}
+4 -4
View File
@@ -65,8 +65,8 @@ class ClideMarkdown extends StatelessWidget {
spans.add(_inlineElementSpan(n, tokens, onRecordTap));
}
}
out.add(RichText(
text: TextSpan(
out.add(Text.rich(
TextSpan(
style: TextStyle(
fontFamily: clideUiFamily,
fontFamilyFallback: clideUiFamilyFallback,
@@ -235,11 +235,11 @@ class ClideMarkdown extends StatelessWidget {
}
static Widget _inlineText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap, fontSize: fontSize, fontWeight: fontWeight));
return Text.rich(_buildInlineSpan(el, tokens, onRecordTap, fontSize: fontSize, fontWeight: fontWeight));
}
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap));
return Text.rich(_buildInlineSpan(el, tokens, onRecordTap));
}
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
+198
View File
@@ -0,0 +1,198 @@
/// Widget tests for SelectableRegion-compatible text rendering in
/// [ClideMarkdown] and [ClideCodeBlock].
///
/// Regression guard for T-135: plain paragraphs and code blocks must
/// register with the ambient [SelectableRegion] (Flutter's widget-layer
/// selection API, wrapped by the app in a SelectionArea) so text can be
/// selected and copied across both widget types. Tables and tappable link
/// spans are known non-selectable islands in v1 and are NOT tested here.
library;
import 'package:clide/widgets/src/clide_code_block.dart';
import 'package:clide/widgets/src/clide_markdown.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
// ---------------------------------------------------------------------------
// Inline mock clipboard — intercepts the platform channel so the test does
// not require a real platform binary.
// ---------------------------------------------------------------------------
class _MockClipboard {
Map<String, dynamic> _data = {'text': null};
Future<Object?> handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'Clipboard.setData':
_data = Map<String, dynamic>.from(call.arguments as Map);
case 'Clipboard.getData':
return _data;
case 'Clipboard.hasStrings':
final text = _data['text'] as String?;
return {'value': text != null && text.isNotEmpty};
}
return null;
}
String? get text => _data['text'] as String?;
}
// ---------------------------------------------------------------------------
// Key-combo helper — mirrors Flutter SDK selectable_region_test approach.
// ---------------------------------------------------------------------------
Future<void> _sendKeys(WidgetTester tester, SingleActivator activator) async {
final mods = <LogicalKeyboardKey>[
if (activator.control) LogicalKeyboardKey.control,
if (activator.shift) LogicalKeyboardKey.shift,
if (activator.alt) LogicalKeyboardKey.alt,
if (activator.meta) LogicalKeyboardKey.meta,
];
for (final m in mods) {
await tester.sendKeyDownEvent(m);
}
await tester.sendKeyDownEvent(activator.trigger);
await tester.sendKeyUpEvent(activator.trigger);
await tester.pump();
for (final m in mods.reversed) {
await tester.sendKeyUpEvent(m);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('SelectableRegion — ClideMarkdown + ClideCodeBlock', () {
late KernelFixture f;
late _MockClipboard clipboard;
setUp(() async {
f = await KernelFixture.create();
clipboard = _MockClipboard();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, clipboard.handleMethodCall);
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null);
await f.dispose();
});
testWidgets('Ctrl+A + Ctrl+C inside SelectableRegion copies paragraph and code text', (tester) async {
// Give the view a physical size so paragraph layout can resolve.
tester.view.physicalSize = const Size(800, 600);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
const paraText = 'Hello world';
const codeText = 'print(42)';
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
harness(
f,
// DefaultTextEditingShortcuts registers Ctrl+A → SelectAllTextIntent
// and Ctrl+C → CopySelectionTextIntent. SelectableRegion provides
// the Actions; WidgetsApp (absent here) normally provides the
// Shortcuts — so we install them explicitly for the test.
DefaultTextEditingShortcuts(
child: SelectableRegion(
focusNode: focusNode,
selectionControls: emptyTextSelectionControls,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: const [
ClideMarkdown(paraText),
ClideCodeBlock(source: codeText),
],
),
),
),
),
);
await tester.pumpAndSettle();
// Give the SelectableRegion focus so it receives keyboard shortcuts.
focusNode.requestFocus();
await tester.pump();
// Ctrl+A selects all content, Ctrl+C copies it.
await _sendKeys(tester, const SingleActivator(LogicalKeyboardKey.keyA, control: true));
await _sendKeys(tester, const SingleActivator(LogicalKeyboardKey.keyC, control: true));
await tester.pump();
// Both the paragraph text and the code text should appear in the
// clipboard. SelectableRegion concatenates selectables with newlines.
final copied = clipboard.text ?? '';
expect(copied, contains(paraText),
reason: 'paragraph text must be selectable via SelectableRegion after '
'converting ClideMarkdown from RichText to Text.rich');
expect(copied, contains(codeText),
reason: 'code block text must be selectable via SelectableRegion after '
'converting ClideCodeBlock from RichText to Text.rich');
});
testWidgets('Text.rich nodes are owned by Text widgets, registering with SelectableRegion', (tester) async {
// Structural guard: every RichText in the ClideMarkdown/ClideCodeBlock
// subtree must be wrapped by a Text ancestor. Text (including Text.rich)
// registers itself with the ambient SelectionRegistrar; a bare
// RichText(...) does not.
tester.view.physicalSize = const Size(800, 600);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
harness(
f,
DefaultTextEditingShortcuts(
child: SelectableRegion(
focusNode: focusNode,
selectionControls: emptyTextSelectionControls,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: const [
ClideMarkdown('A paragraph.\n\nSecond paragraph.'),
ClideCodeBlock(source: 'var x = 1;'),
],
),
),
),
),
);
await tester.pumpAndSettle();
// Every RichText in the subtree should have a Text ancestor — that is
// what Text.rich builds. A bare RichText(...) has no Text parent.
final richTexts = find.byType(RichText);
for (final el in richTexts.evaluate()) {
final textAncestor = find.ancestor(
of: find.byElementPredicate((e) => e == el),
matching: find.byType(Text),
);
expect(textAncestor, findsAtLeastNWidgets(1),
reason: 'RichText for "${(el.widget as RichText).text.toPlainText()}" '
'should be owned by a Text (built via Text.rich, not bare RichText)');
}
});
});
}