Text can be selected
One SelectionArea around the whole tree rather than a SelectableText per label. Per-widget selection reaches only inside one widget, so a nick, the time beside it and the two lines under it cannot be dragged across — which is most of what anyone wants to copy out of a chat. The risk of wrapping everything is a selection gesture that swallows the taps underneath it, so there is a test that opens a room by pressing the button that opens it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ec74784 parent: 6214c7e modified
flutter/lib/nim_renderer.dart +13 -1 | @@ -115,7 +115,19 @@ class _NimAppState extends State<NimApp> { | ||
| 115 | 115 | error: t.destructive, |
| 116 | 116 | ), |
| 117 | 117 | ), |
| 118 | - home: Scaffold(backgroundColor: t.bg, body: SafeArea(child: _build(_tree))), | |
| 118 | + // Everything inside one SelectionArea, so a message can be selected | |
| 119 | + // and copied — and so can a nick, a timestamp, or a line of an error. | |
| 120 | + // Per-widget `SelectableText` was the alternative and is worse: it | |
| 121 | + // selects within one widget only, so a two-line answer and the name | |
| 122 | + // above it cannot be dragged across, which is most of what anyone | |
| 123 | + // wants to copy out of a chat. | |
| 124 | + // | |
| 125 | + // Taps still arrive: a selection starts on a drag, and the buttons, | |
| 126 | + // faces and reaction pills under here keep their gestures. | |
| 127 | + home: Scaffold( | |
| 128 | + backgroundColor: t.bg, | |
| 129 | + body: SafeArea(child: SelectionArea(child: _build(_tree))), | |
| 130 | + ), | |
| 119 | 131 | ); |
| 120 | 132 | |
| 121 | 133 | // ---------------------------------------------------------------- helpers |
| @@ -115,7 +115,19 @@ class _NimAppState extends State<NimApp> { | |||
| 115 | error: t.destructive, | 115 | error: t.destructive, |
| 116 | ), | 116 | ), |
| 117 | ), | 117 | ), |
| 118 | - home: Scaffold(backgroundColor: t.bg, body: SafeArea(child: _build(_tree))), | 118 | + // Everything inside one SelectionArea, so a message can be selected |
| 119 | + // and copied — and so can a nick, a timestamp, or a line of an error. | ||
| 120 | + // Per-widget `SelectableText` was the alternative and is worse: it | ||
| 121 | + // selects within one widget only, so a two-line answer and the name | ||
| 122 | + // above it cannot be dragged across, which is most of what anyone | ||
| 123 | + // wants to copy out of a chat. | ||
| 124 | + // | ||
| 125 | + // Taps still arrive: a selection starts on a drag, and the buttons, | ||
| 126 | + // faces and reaction pills under here keep their gestures. | ||
| 127 | + home: Scaffold( | ||
| 128 | + backgroundColor: t.bg, | ||
| 129 | + body: SafeArea(child: SelectionArea(child: _build(_tree))), | ||
| 130 | + ), | ||
| 119 | ); | 131 | ); |
| 120 | 132 | ||
| 121 | // ---------------------------------------------------------------- helpers | 133 | // ---------------------------------------------------------------- helpers |
modified
flutter/test/nim_layout_test.dart +26 -0 | @@ -215,6 +215,32 @@ void main() { | ||
| 215 | 215 | } |
| 216 | 216 | }); |
| 217 | 217 | |
| 218 | + group('selection', () { | |
| 219 | + testWidgets('the whole tree sits in one SelectionArea', (tester) async { | |
| 220 | + // One, not one per Text: a selection has to be draggable across the | |
| 221 | + // nick, the time and the message, which is most of what anyone wants | |
| 222 | + // to copy out of a chat. | |
| 223 | + core.demoUi(); | |
| 224 | + await layOut(tester, sizes['desktop']!); | |
| 225 | + expect(find.byType(SelectionArea), findsOneWidget); | |
| 226 | + expect(find.text('hello there'), findsOneWidget); | |
| 227 | + }); | |
| 228 | + | |
| 229 | + testWidgets('and taps still reach what is under it', (tester) async { | |
| 230 | + // The risk with wrapping everything: a selection gesture that eats the | |
| 231 | + // taps underneath. Opening a room from the list is the plainest one. | |
| 232 | + core.demoUi(); | |
| 233 | + core.dispatch('screen.chats'); | |
| 234 | + await layOut(tester, sizes['desktop']!); | |
| 235 | + // The row's "Open", not its name: the name is a label in this list. | |
| 236 | + await tester.tap(find.text('Open').first); | |
| 237 | + await tester.pump(); | |
| 238 | + expect(find.text('hello there'), findsWidgets, | |
| 239 | + reason: 'tapping the room did not open it'); | |
| 240 | + expectLaidOut(tester, 'the room the tap opened'); | |
| 241 | + }); | |
| 242 | + }); | |
| 243 | + | |
| 218 | 244 | group('emoji', () { |
| 219 | 245 | // ✏️ is U+270F plus a variation selector asking for emoji presentation, |
| 220 | 246 | // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome |
| @@ -215,6 +215,32 @@ void main() { | |||
| 215 | } | 215 | } |
| 216 | }); | 216 | }); |
| 217 | 217 | ||
| 218 | + group('selection', () { | ||
| 219 | + testWidgets('the whole tree sits in one SelectionArea', (tester) async { | ||
| 220 | + // One, not one per Text: a selection has to be draggable across the | ||
| 221 | + // nick, the time and the message, which is most of what anyone wants | ||
| 222 | + // to copy out of a chat. | ||
| 223 | + core.demoUi(); | ||
| 224 | + await layOut(tester, sizes['desktop']!); | ||
| 225 | + expect(find.byType(SelectionArea), findsOneWidget); | ||
| 226 | + expect(find.text('hello there'), findsOneWidget); | ||
| 227 | + }); | ||
| 228 | + | ||
| 229 | + testWidgets('and taps still reach what is under it', (tester) async { | ||
| 230 | + // The risk with wrapping everything: a selection gesture that eats the | ||
| 231 | + // taps underneath. Opening a room from the list is the plainest one. | ||
| 232 | + core.demoUi(); | ||
| 233 | + core.dispatch('screen.chats'); | ||
| 234 | + await layOut(tester, sizes['desktop']!); | ||
| 235 | + // The row's "Open", not its name: the name is a label in this list. | ||
| 236 | + await tester.tap(find.text('Open').first); | ||
| 237 | + await tester.pump(); | ||
| 238 | + expect(find.text('hello there'), findsWidgets, | ||
| 239 | + reason: 'tapping the room did not open it'); | ||
| 240 | + expectLaidOut(tester, 'the room the tap opened'); | ||
| 241 | + }); | ||
| 242 | + }); | ||
| 243 | + | ||
| 218 | group('emoji', () { | 244 | group('emoji', () { |
| 219 | // ✏️ is U+270F plus a variation selector asking for emoji presentation, | 245 | // ✏️ is U+270F plus a variation selector asking for emoji presentation, |
| 220 | // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome | 246 | // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome |