Skip to content

Markdown: a transcript owns its text selection, in document order - #1009

Merged
Tryanks merged 6 commits into
mainfrom
transcript-selection
Oct 28, 2026
Merged

Tryanks merged 6 commits into
mainfrom
transcript-selection

Conversation

@Tryanks

@Tryanks Tryanks commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

Drop gpui-base's window-wide text selection for Markdown; a document owns it instead. The chat transcript is one document whose entries are its messages (at their timeline index). A selectable view outside a transcript, such as a PR comment, is a document of its own.

 press (left)
-  any element in the window → gpui-base clears + begins a gesture (capture)
+  only on the document's area (bubble, hitbox hovered) → begin
 drag / release
-  release ends the gesture only if it bubbles to the root
-    (a drop target or stop_propagation leaves it following the pointer)
+  heard in the capture phase while a gesture is held; nothing can swallow it
 ends of the selection
-  window points; each run highlights the band between them (screen geometry)
+  DocPoint { entry, TextPosition { render path, code line, byte offset } }
+  highlight = run ∩ range; copy = text_between(range)  (same range for both)
crates/ui/src/markdown/
├── selection.rs   # new: SelectionDocument, SelectionArea, hit-testing, gestures, touch
├── nodes.rs       # TextPosition + segment walk; text_between replaces painted-state extraction
├── inline*.rs, math*.rs, render.rs   # each run carries its RunOrigin
└── selection_adapter.rs              # deleted (gpui-base bridge)

What changes for a reader:

  • Drag-and-drop no longer leaves a selection. Dragging a thread from the sidebar onto a pane left the gesture active in every chat view (the ui-v2 report). That was the same failure fix(ui): end the text selection before a click stops propagation #595 and the titlebar workaround each patched where they found it.
  • Tables read cell by cell along each row. Cells are joined by \t when copied (previously a space), so a pasted table splits into columns in a spreadsheet.
  • A drag ending off the text (block padding, the gap under a short line) ends at the nearest line.
  • The selection goes when the focus leaves the chat view (the composer is inside it); a press on something outside that takes no focus leaves it, and a press inside the transcript collapses it. A standalone view clears when the focus leaves that view. The chat root now holds a focus handle, so a click on blank chat space takes the focus from the composer, as a click on message text already did. The right-click menu's Copy reads the document.
  • Double-click / long press take the word the platform segments, so Chinese splits into words (gpui-base's rule makes every CJK character a word): Core Foundation on Apple platforms, the ICU Windows ships (loaded on first use), Intl.Segmenter in a browser, Android's Java ICU through the activity, and a bundled ICU4X dictionary on Linux only, which has no system segmenter every install carries. Where none answers, gpui-base's rule. Otherwise double and triple click match the window selection: the character under the pointer, the line within a paragraph, no extension by a later drag.
  • Each pane selects independently. A PR comment selects within itself and no longer across comments.
  • Copy reads messages that were never painted from the timeline, so residency no longer pins selection participants.
  • Touch keeps long press → word, handles, an edit menu with Copy / Select All, the menu stepping aside during a pan, and one touch selection at a time.

Removed because only the window selection needed them: stop_click_propagation (now plain stop_propagation), the titlebar's prevent_default/suppress_text_selection (added in 97034b2 for this), the input's TextSelection::clear on long press, WindowTouchSelectionOverlay, the touch-UI registration plumbing, and SelectAllTouched.

Evidence

  • Before (main): a press on a drag source, a drag across Hello world, a drop on a target, then the free pointer crossing the text: TextSelection::selected_text = "Hello wor".
    After: a_drag_and_drop_across_the_text_leaves_nothing_selected → "".
  • New tests:
    • a_drag_through_a_table_takes_each_cell_whole_before_the_next: a cell wrapped over several rows, dragged to the cell beside its first row → the whole first cell + \t + x.
    • a_drag_ending_off_the_code_lines_ends_at_the_nearest_line.
    • Extraction (nodes.rs): document order, cells, edge-only segments.
    • Word classes for double-click.
  • Real app (macOS, a real Claude reply in a throwaway profile):
    • Dragging from mid-line in a code block past the end and into the padding copies from the press onward.
    • A table drag highlights and copies <whole cell>\tOwn.
    • Right-click on the selection → clicking Copy with the mouse copies it, and the highlight stays.
  • cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, and cargo nextest run --workspace (1351 passed) on macOS. Not run: Windows by hand, phone/iPad touch on a device, the mobile/Web checks (left to CI).

Tests met in this area:

Test What happened Why / where its contract is now
a_scroll_jump_mid_drag_drops_text_left_behind Driver fixed It pressed line 40 at y = 862 in an 852 px window, a press no user can make. Now line 38, still in the second root item.
drag_auto_scrolls_at_viewport_edge_not_participant_edge Ported Now a_drag_scrolls_at_the_viewport_edge_not_the_message_edge. It caught a real bug in the port: the viewport must be the clip, not the view's bounds. Fails with the old choice.
text_wrapping_after_an_inline_image_paints_on_its_own_rows Ported Reads painted runs instead of gpui-base text bounds.
long_press_on_a_message_offers_copy_and_select_all_past_the_viewport, one_touch_selection_at_a_time_and_the_menu_steps_aside_for_a_pan Ported Moved to selection.rs. Pan hiding was missing until this port.
long_press_release_keeps_handles_which_drag_the_selection Rewritten Drives the document's handles and edit menu. Its final tap moved off the start knob.
drag_copies_exactly_the_highlighted_text, a_drag_ending_off_the_code_lines_copies_what_it_highlights Deleted Their oracle was gpui-base's painted projection. Highlight and copy now read one range, so they cannot disagree; literal tests replace them. The midpoint boundary rule has no dedicated test.
extraction_* (nodes.rs) Replaced Covered by the text_between tests.
press_on_titlebar_drag_area_does_not_start_selection, click_that_stops_propagation_does_not_leave_selection_following_pointer Deleted Same contract as the drag-and-drop test: a press outside the document never selects.
text_bounds_cover_one_rect_per_visual_line Deleted Tested a helper that existed only to feed gpui-base.
running_tail_and_selection_pins_are_honored Renamed Now the_running_tail_stays_resident; selection pinning is gone.

Added after the first push: the_selection_goes_when_the_focus_leaves_the_text, a_double_click_on_chinese_takes_the_word_the_platform_segments, and in tcode-client the_system_segmenter_finds_chinese_words plus a UTF-16 offset test. The Android segmenter is checked only by CI (no NDK here); its JNI calls were type-checked against jni 0.22.4 on their own.

Merge Danger

Door: two-way

Self-contained in crates/ui. No wire, persistence or protocol change.

Blast Radius: selection

Every Markdown selection path changes (mouse, keyboard Copy/Select All, the context menu, touch), and so does every click handler that used stop_click_propagation. The likeliest regressions are on platforms not exercised here: Windows mouse input and touch on a real device.

gpui-base's window text selection served Markdown for the whole window: every
left press anywhere cleared it and began a gesture, one gesture spanned every
pane, and a gesture ended only from a release that reached the root in the
bubble phase. A drop target stops that release, so a thread dragged from the
sidebar and dropped on a pane left a selection following the pointer across
every chat view. It also selected by screen geometry, so a table was read row
band by row band instead of cell by cell.

A document now owns its selection. The chat transcript is one document, each
message an entry at its timeline index; a standalone selectable view is its
own. Only a press on the document's area begins a selection, the drag and the
release are heard in the capture phase, and the ends are positions in the
text (render path, code line, byte offset), so highlight and copy read the
same range: a table cell by cell along its rows, cells joined by tabs, and
messages never painted copied from the timeline. Touch keeps the long press,
handles, edit menu and pan behaviour, drawn by the document.

The workarounds that existed only for the window selection go: the gpui-base
participant bridge, painted-selection storage, residency pinning of selection
participants, stop_click_propagation, the titlebar's default prevention and
the input's window-selection clear.
…hanged

A double or triple click takes what gpui-base's multi-click took: the
character under the pointer, words of ASCII, Latin or Cyrillic letters with
every other character (each CJK character) its own word, the line within a
paragraph, and no extension by a later drag. A long press drags its far end
from the word's start by character. Copy trims as the root's Copy did, a
touch handle whose end is clipped is not drawn, and a titlebar press keeps
the focus where it was.
The chat view holds a focus handle over everything it draws, the composer
included, and clears the transcript's selection when the focus leaves it; a
standalone view clears its own selection when the focus leaves the view. A
press on something outside that takes no focus leaves the selection, one that
takes the focus puts it away.
gpui-base's multi-click rule makes every CJK character a word of its own. A
double-click or long press now asks the platform's own segmentation through
the client host: Core Foundation's tokenizer on Apple platforms, the ICU
Windows ships (loaded on first use, so a Windows without it still starts),
the browser's Intl.Segmenter, Android's Java ICU through the activity, and on
Linux, which no system segmenter is certain to be on, a bundled dictionary.
Where none answers, gpui-base's rule stays.
@Tryanks
Tryanks merged commit 737c480 into main Oct 28, 2026
13 checks passed
@Tryanks
Tryanks deleted the transcript-selection branch October 28, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant