Skip to content

feat(timeline): select message text and copy just the part you picked - #290

Open
tyreseluo wants to merge 2 commits into
mainfrom
feat/message-text-selection
Open

feat(timeline): select message text and copy just the part you picked#290
tyreseluo wants to merge 2 commits into
mainfrom
feat/message-text-selection

Conversation

@tyreseluo

@tyreseluo tyreseluo commented Aug 5, 2026

Copy link
Copy Markdown

Copying a message was all-or-nothing: you could take the whole body from the context menu, but you could not drag across a sentence and copy that.

⚠️ Blocked on ZhangHanDong/makepad#5. Until that merges, selection does not work from a fresh clone or in CI — run ./scripts/patch-makepad-html-selectable.sh locally. See Dependency below.

What you can do now

  • Drag across message text to select it, across message boundaries; drag past the viewport edge and the list auto-scrolls
  • Cmd/Ctrl+C to copy, Cmd+A to select the visible timeline; on mobile the OS clipboard bar appears
  • Agent replies select too
  • Right-click → Copy Text copies the selection when there is one, and the whole message otherwise

Why it did not already work

Makepad ships the machinery — TextFlow.selectable plus PortalList.selectable, which owns a cross-item anchor/cursor, auto-scrolls at the viewport edge and answers Hit::TextCopy. The timeline just never opted in, and two things stood in the way once it did.

The plaintext body was a Label, which has no selection support at all — so the most common kind of message was the one you could not select. Bodies without links used it; bodies with links already went through the HTML renderer, because linkify_get_urls escapes what it rewrites. Both now take the HTML path, which is a TextFlow.

Everything claimed to be a click target. Widget::is_interactive defaults to true, and a selectable PortalList declines to begin a drag over anything interactive, on the grounds that the press belongs to that widget:

let on_interactive = self.point_hits_interactive_item(cx, fe.abs);
if self.selectable && fe.is_primary_hit() && !on_interactive { /* start selection */ }

HtmlOrPlaintext inherited that default, and so does makepad's Html — unlike Markdown and TextFlow, which both return false. A drag on message text therefore became a drag-to-scroll and no selection was ever created: the timeline slid under the pointer, which is exactly what this looked like from the outside.

Instrumenting the hit test is what settled it. The press resolved to the Html widget itself, with a rect that correctly covered the pointer — so this was not a stale-area problem — and the boundary lined up with the card's left edge at x=481:

mouse=(519,508)  hit=…bot_card_body.html_view.html  hit_rect=[x 481..1131, y 441..552]
mouse.x hit result
479 / 481 none has_selection=true
484 / 519 / 568 …html has_selection=false

Markdown-rendered bot cards selected fine; the same content rendered through Html did not.

The invariant this rests on

At most one body per item may report text. View resolves a selection by taking the first child that answers and does not skip hidden children, and TextFlow clears its SelectionTracker only inside begin() — which a hidden widget never reaches. A merely-hidden body keeps answering hit tests, and copy, with whatever it last held, so it would shadow the visible one and copy out text that is not on screen.

So idle bodies are drawn empty rather than hidden (HtmlOrPlaintext::clear_body, guarded because Html::set_text re-parses and redraws unconditionally and these callers run in the draw pass), and the plain body is declared before the bot card so a normal message wins outright.

Dependency

Html's missing is_interactive override is fixed upstream in ZhangHanDong/makepad#5. [patch] still points at the pre-fix rev, so this branch is not self-sufficient yet: a fresh clone, another machine, and CI will all build without the fix and selection will silently not work (it degrades to drag-to-scroll).

scripts/patch-makepad-html-selectable.sh re-applies it to the local cargo checkout in the meantime. Delete the script and let [patch] pick up the new rev once the PR merges.

Also here: the room-filter highlight (80e801c6)

Noticed while verifying the above — the app was logging this on every startup:

[E] src/shared/room_filter_search_results.rs:38:10 - cannot push to frozen vec

The keyboard-selection highlight in the room filter's search results declared its pixel shader with makepad 1.x's fn pixel(self) -> vec4 { … }. The 2.0 DSL wants pixel: fn() { … } and rejects the old form, which fails the whole draw_bg block — taking the selected instance with it, so the Animator had nothing to drive and the row never highlighted. This was the last 1.x shader declaration in the tree.

Shader body unchanged; it was already correctly premultiplied.

Not covered

Image/video captions (TextOrImage) and small state events render through widgets with no selection support, so those stay unselectable.

Testing

Manually exercised on desktop: plain and multi-line messages, agent cards in both HTML and Markdown modes, cross-message drags, edge auto-scroll, copy via keyboard and via the context menu, right-click menu still opening, and the fold/View formatted controls inside agent cards still clickable (a press on a Button correctly declines to start a selection). Startup log is now free of DSL errors.

🤖 Generated with Claude Code

tyreseluo and others added 2 commits August 5, 2026 14:40
Copying a message was all-or-nothing: you could take the whole body from
the context menu, but you could not drag across a sentence and copy that.

Makepad already ships the machinery — `TextFlow.selectable` plus
`PortalList.selectable`, which owns a cross-item anchor/cursor, auto-scrolls
at the viewport edge and answers `Hit::TextCopy`. The timeline just never
opted in, and two things stood in the way once it did.

**The plaintext body was a `Label`.** `Label` has no selection support at
all, so the most common kind of message was the one you could not select.
Bodies without links used it; bodies with links already went through the
HTML renderer, because `linkify_get_urls` escapes what it rewrites. Both
now take the HTML path, which is a `TextFlow` and therefore selectable.

**Everything claimed to be a click target.** `Widget::is_interactive`
defaults to `true`, and a selectable `PortalList` declines to begin a drag
over anything interactive, on the grounds that the press belongs to that
widget. `HtmlOrPlaintext` inherited that default, and so does makepad's
`Html` (unlike `Markdown` and `TextFlow`, which both return `false`). So a
drag on message text became a drag-to-scroll and no selection was ever
created — the timeline slid under the pointer. `HtmlOrPlaintext` now
delegates to its inner `View`; the `Html` half is fixed upstream in
ZhangHanDong/makepad#5, and until that merges
`scripts/patch-makepad-html-selectable.sh` applies it locally.

The invariant this rests on is that at most one body per item reports text.
`View` resolves a selection by taking the first child that answers and does
not skip hidden ones, and `TextFlow` clears its `SelectionTracker` only in
`begin()`, which a hidden widget never reaches — so a merely-hidden body
keeps answering hit tests, and copy, with what it last held. Idle bodies
are therefore drawn EMPTY rather than hidden (`clear_body`), and the plain
body is declared before the bot card so a normal message wins outright.

Also here:
* Agent replies select too — the bot card's three renderers opt in.
* Right-click still opens the context menu. A selectable `PortalList` only
  forwards `MouseDown` to items over an interactive child, which would have
  silently dropped it; the secondary button is re-dispatched to the items,
  where `Message` decides what it means as before.
* "Copy Text" copies the selection when there is one, captured as the menu
  opens (showing it takes key focus, and the list drops its selection on
  focus loss) and matched by event id so a copy aimed at another message
  cannot pick up these words.

Not covered: image/video captions (`TextOrImage`) and small state events,
which render through widgets with no selection support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…syntax

The keyboard-selection highlight in the room filter's search results never
rendered. Its `draw_bg` block declared the pixel shader with makepad 1.x's
`fn pixel(self) -> vec4 { … }`; the 2.0 DSL wants the property form
`pixel: fn() { … }` and rejects the old one with

    [E] src/shared/room_filter_search_results.rs:38:10 - cannot push to frozen vec

which fails the whole `draw_bg` block, taking the `selected` instance with
it — so the Animator had nothing to drive and the row never highlighted.

This was the last 1.x shader declaration in the tree; every other
`draw_bg` already uses the property form.

The body is unchanged. Mixing up from a fully transparent base scales rgb
and alpha together, so the result is already premultiplied and must not be
wrapped in `Pal.premul` on top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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