diff --git a/lib/kernel/src/theme/contrast.dart b/lib/kernel/src/theme/contrast.dart index 34c6f151..5280ed98 100644 --- a/lib/kernel/src/theme/contrast.dart +++ b/lib/kernel/src/theme/contrast.dart @@ -185,6 +185,20 @@ List 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 diff --git a/lib/kernel/src/theme/resolver.dart b/lib/kernel/src/theme/resolver.dart index caff6103..a9441b53 100644 --- a/lib/kernel/src/theme/resolver.dart +++ b/lib/kernel/src/theme/resolver.dart @@ -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 = {}; 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, ); } diff --git a/lib/kernel/src/theme/tokens.dart b/lib/kernel/src/theme/tokens.dart index 0c15eed3..8f4b8887 100644 --- a/lib/kernel/src/theme/tokens.dart +++ b/lib/kernel/src/theme/tokens.dart @@ -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 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 = [ globalForeground, globalBackground, @@ -363,5 +373,6 @@ abstract class TokenKeys { syntaxComment, syntaxMethod, syntaxPunct, + selectionBackground, ]; } diff --git a/lib/widgets/src/clide_code_block.dart b/lib/widgets/src/clide_code_block.dart index 375692fc..68241e9b 100644 --- a/lib/widgets/src/clide_code_block.dart +++ b/lib/widgets/src/clide_code_block.dart @@ -76,7 +76,7 @@ class _ClideCodeBlockState extends State { ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, - child: RichText(text: textSpan), + child: Text.rich(textSpan), ), ); } diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart index 0fb034d9..f32ae264 100644 --- a/lib/widgets/src/clide_markdown.dart +++ b/lib/widgets/src/clide_markdown.dart @@ -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}) { diff --git a/test/widgets/src/clide_selectable_test.dart b/test/widgets/src/clide_selectable_test.dart new file mode 100644 index 00000000..c2d27e98 --- /dev/null +++ b/test/widgets/src/clide_selectable_test.dart @@ -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 _data = {'text': null}; + + Future handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'Clipboard.setData': + _data = Map.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 _sendKeys(WidgetTester tester, SingleActivator activator) async { + final mods = [ + 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)'); + } + }); + }); +}