Keep a selection in a bubble; typing or deselecting gives the caret back #11

Closed
asxpi wants to merge 0 commits from asxpi/anotherim-js:fix/bubble-selection-caret into develop
First-time contributor

On the Linux desktop app (WebKitGTK), words selected in a message disappeared as soon as the mouse was released, and the caret jumped back to the composer.

Cause. #refocus (a click in the feed gives the caret back) checked for a selection with document.getSelection(). A message's words live in its row's shadow root, and WebKit reports a selection there to document.getSelection() as collapsed, empty and with no range. Measured in WebKitGTK on the mock: isCollapsed: true, "", rangeCount: 0. So the click that ends a drag saw nothing selected and focused the composer. Blink reports it differently, which is likely why it wasn't noticed.

1. Keep the selection (5612014)

  • New hasTextSelection() in ui/src/views/text-selection.ts, which also asks Selection.getComposedRanges().
  • It must be given the shadow roots. Asked with none, WebKit rescopes the range to one around the outermost host, so even the caret a plain click leaves in a bubble reads as not collapsed. A plain click would then never refocus. The cheap call without roots runs first, and the open roots are gathered only when it says there may be something.
  • empty-space.ts (keeping the native menu over selected text) had the same check and now uses the helper.

2. Getting back to the composer (06c259f). With the selection kept, the focus stays off the composer, so:

  • A character typed with the focus off any field puts the caret in the composer and is let through, so it is typed there, as in Telegram Desktop or Slack. Ctrl, Alt and Meta shortcuts are left alone, so Ctrl+C still copies the selection. Dead keys and an input method's first key name no character and are left alone too.
  • When selected text goes away, #takeCaret runs. Not mid-press, where a new sweep starts: the press's release decides, and only if nothing is selected by then.
  • The stray-key test in composer-focus.test.ts asserted that other letters were left alone. It's updated to the new rule.

Tests. ui/test/text-selection.test.ts has 14 cases: the helper, a feed click with a caret and with a selection, typing, and deselecting. The stand-in Selection behaves like WebKit, rescoping to the host when no roots are given. The plain-click case and the shadow-selection case fail on the old code. tsc passes. make test-ui: 2162 passed, 2 failed, both the thread-footer cases, which fail under load and pass alone.

Checked by hand in a local Flatpak build of 0.5.2:

  • a plain click puts the caret in the field
  • a selection survives the release and Ctrl+C
  • typing goes to the field
  • deselecting by a click gives the caret back

🤖 Generated with Claude Code

On the Linux desktop app (WebKitGTK), words selected in a message disappeared as soon as the mouse was released, and the caret jumped back to the composer. **Cause.** `#refocus` (a click in the feed gives the caret back) checked for a selection with `document.getSelection()`. A message's words live in its row's shadow root, and WebKit reports a selection there to `document.getSelection()` as collapsed, empty and with no range. Measured in WebKitGTK on the mock: `isCollapsed: true`, `""`, `rangeCount: 0`. So the click that ends a drag saw nothing selected and focused the composer. Blink reports it differently, which is likely why it wasn't noticed. **1. Keep the selection** (`5612014`) - New `hasTextSelection()` in `ui/src/views/text-selection.ts`, which also asks `Selection.getComposedRanges()`. - It must be given the shadow roots. Asked with none, WebKit rescopes the range to one around the outermost host, so even the caret a plain click leaves in a bubble reads as not collapsed. A plain click would then never refocus. The cheap call without roots runs first, and the open roots are gathered only when it says there may be something. - `empty-space.ts` (keeping the native menu over selected text) had the same check and now uses the helper. **2. Getting back to the composer** (`06c259f`). With the selection kept, the focus stays off the composer, so: - A character typed with the focus off any field puts the caret in the composer and is let through, so it is typed there, as in Telegram Desktop or Slack. Ctrl, Alt and Meta shortcuts are left alone, so Ctrl+C still copies the selection. Dead keys and an input method's first key name no character and are left alone too. - When selected text goes away, `#takeCaret` runs. Not mid-press, where a new sweep starts: the press's release decides, and only if nothing is selected by then. - The stray-key test in `composer-focus.test.ts` asserted that other letters were left alone. It's updated to the new rule. **Tests.** `ui/test/text-selection.test.ts` has 14 cases: the helper, a feed click with a caret and with a selection, typing, and deselecting. The stand-in Selection behaves like WebKit, rescoping to the host when no roots are given. The plain-click case and the shadow-selection case fail on the old code. `tsc` passes. `make test-ui`: 2162 passed, 2 failed, both the thread-footer cases, which fail under load and pass alone. Checked by hand in a local Flatpak build of 0.5.2: - a plain click puts the caret in the field - a selection survives the release and Ctrl+C - typing goes to the field - deselecting by a click gives the caret back 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A click in the feed puts the caret back in the composer unless text
is selected, and that was judged with document.getSelection(). A
message's words live in its row's shadow root, and WebKitGTK reports
a selection there to document.getSelection() as collapsed, empty and
with no range. So the click that ends a drag saw nothing selected,
focused the composer and the selection was gone.

Add hasTextSelection() (views/text-selection.ts), which also asks
Selection.getComposedRanges(). It has to be given the shadow roots:
asked with none, WebKit rescopes a range to one around the outermost
host, and even the caret a plain click leaves in a bubble reads as
not collapsed. The cheap call without roots goes first and the open
roots are gathered only when it says there may be something.

empty-space.ts had the same check for keeping the native menu over
selected text and uses the helper too.

Assisted-by: Claude:opus-5
Words now stay selected after a drag, so the focus stays off the
composer and the next key went nowhere: only Backspace, Space and
Enter brought the caret back, and a click on the picked words
themselves clears them after its own click, which #refocus saw still
selected.

A character typed with the focus off any field now puts the caret in
the composer and is let through, so the engine types it there, as in
Telegram Desktop or Slack. Shortcuts (Ctrl, Alt, Meta) are left
alone, so Ctrl+C still copies the selection; a dead key or an input
method's first key names no character and is left alone too.

And when selected text goes away, #takeCaret runs. Not mid-press,
where a new sweep starts and moving the focus would end it: the
release decides, and only if nothing is selected by then.

The stray-key test that kept other letters out is updated to the new
rule.

Assisted-by: Claude:opus-5
Tab moved the focus from the message field on to the mic or Send, so
it could not be typed. It now inserts a tab where the caret is, over
any selected words, through insertText so Ctrl+Z takes it back.
Shift+Tab still leaves the field, so the keyboard is never trapped
in it, and Ctrl+Tab still goes to the next chat.

With the interface scale at 125 % and up (CSS zoom), WebKitGTK lays
out an empty field's placeholder wider than the box -- 531px of
scroll in a 430px field at 125 % -- even for the empty placeholder
the chat's field sets, and drew a horizontal scrollbar under it
until the first key. The placeholder attribute is now left off when
it has no words, and the field clips sideways overflow, which a
field that wraps never needs, for the composers that do have one.

Assisted-by: Claude:opus-5-5
Author
First-time contributor

Added a479fa1 (composer: Tab types a tab; no scrollbar under the empty field). Both came up while testing this branch.

Tab in the message field. Tab moved the focus on to the mic or Send. It now inserts a tab at the caret, replacing any selected words, through insertText, so Ctrl+Z removes it. Shift+Tab still leaves the field, so the keyboard is never trapped there, and Ctrl+Tab still goes to the next chat.

Horizontal scrollbar under the empty field (WebKitGTK). At an interface scale of 125% and up (CSS zoom), WebKitGTK lays out a textarea's placeholder wider than the box. That happens even for the empty placeholder="" the chat's field sets: 531px of scroll in a 430px field at 125%. The bar went away at the first key. The attribute is now left off when there are no words, and the textarea gets overflow-x: hidden for the composers that do have a placeholder (the thread's). Measured afterwards in WebKitGTK: 430/430 at 125%. At 150% only a 1px rounding is left, which overflow-x: hidden keeps from drawing a bar.

Checked:

  • composer-focus.test.ts: 5 new cases, 3 for Tab and 2 for the placeholder attribute. Where the caret lands after the tab isn't asserted there: happy-dom has no execCommand, and its setRangeText(..., 'end') puts the caret at the end of the whole value.
  • Headless WebKitGTK and Chromium: the tab lands at the caret (offset 6 in if x:|done), the focus stays in the field, and undo takes the tab back.
  • tsc passes. make test-ui: 2166 passed. The 3 failures are the thread-footer cases and chat-view's "names a room reply"; each has failed in some full runs and passed alone.
  • By hand in the Flatpak and in Chromium on the web build.
Added `a479fa1` (composer: Tab types a tab; no scrollbar under the empty field). Both came up while testing this branch. **Tab in the message field.** Tab moved the focus on to the mic or Send. It now inserts a tab at the caret, replacing any selected words, through `insertText`, so Ctrl+Z removes it. Shift+Tab still leaves the field, so the keyboard is never trapped there, and Ctrl+Tab still goes to the next chat. **Horizontal scrollbar under the empty field (WebKitGTK).** At an interface scale of 125% and up (CSS `zoom`), WebKitGTK lays out a textarea's placeholder wider than the box. That happens even for the empty `placeholder=""` the chat's field sets: 531px of scroll in a 430px field at 125%. The bar went away at the first key. The attribute is now left off when there are no words, and the textarea gets `overflow-x: hidden` for the composers that do have a placeholder (the thread's). Measured afterwards in WebKitGTK: 430/430 at 125%. At 150% only a 1px rounding is left, which `overflow-x: hidden` keeps from drawing a bar. Checked: - `composer-focus.test.ts`: 5 new cases, 3 for Tab and 2 for the placeholder attribute. Where the caret lands after the tab isn't asserted there: happy-dom has no `execCommand`, and its `setRangeText(..., 'end')` puts the caret at the end of the whole value. - Headless WebKitGTK and Chromium: the tab lands at the caret (offset 6 in `if x:|done`), the focus stays in the field, and `undo` takes the tab back. - `tsc` passes. `make test-ui`: 2166 passed. The 3 failures are the thread-footer cases and `chat-view`'s "names a room reply"; each has failed in some full runs and passed alone. - By hand in the Flatpak and in Chromium on the web build.
Owner

Merged into develop by hand with a merge commit and released in 0.5.3 (v0.5.3 = 87ebd52b, https://dev.another.im/another-im/anotherim-js/releases/tag/v0.5.3). The forge does not mark a hand merge as merged, so this PR is closed here. One follow-up on top: the chat reads the selection once per press rather than per selectionchange event (d231e462).

Merged into develop by hand with a merge commit and released in 0.5.3 (v0.5.3 = 87ebd52b, https://dev.another.im/another-im/anotherim-js/releases/tag/v0.5.3). The forge does not mark a hand merge as merged, so this PR is closed here. One follow-up on top: the chat reads the selection once per press rather than per selectionchange event (d231e462).
ann closed this pull request 2026-10-16 06:19:15 +00:00
ann referenced this pull request from a commit 2026-10-27 11:52:18 +00:00
Android lock fixes from the final audit of 2026-10-27 (findings 1, 3, 4, 7, 8, 9, 10, 11): every lock now runs on DeviceLock's one worker and looks at the store itself (storage info) when its turn comes, so a screen-off heard while the silent reopen is still opening the tier closes it again instead of being dropped (H1), and a reopen or a fingerprint open that ends with the phone already off or locked is closed again at once (DeviceLock.opened, called from MainActivity.presence after Relock.keep); behind a secure lock an open split store closes at every lock whether or not a copy of K2 is held, the copy deciding only whether the unlock is silent, and with no copy the page is told __anotherDeviceLocked('fingerprint') and the gate says why it asks (M3, new string in en ru uk et es); a close libtacky holds back for a call or a transfer (close_main closed 0) sets DeviceLock.closeHeld, the bridge's deviceClosePending(status) makes the page take that unlocked store as call (shellStatus in store.begin and recheck), so a fresh window after a swipe during a call gets the fingerprint, and open-main over the held close releases K2 through the prompt and calls the close off, or opens the tier with it where the close went meanwhile (M4); the page publishes its drafts and answers deviceLockFlushed after a request round trip before close_main goes (at most a second, #11); the gate offers the fingerprint after RELOCK_WAIT_MS (15 s) of a reopen that never comes (#10); Relock.take sleeps for its retry outside the object's lock (#9); EnrolmentProbe counts an unexpected Keystore exception on a made probe as a change, fail closed (#8); the headers of DeviceLock, Relock and docs/push.md now say K2 passes through JVM strings that are released, not zeroed (#7, K2 over JNI as bytes left for later). Ringing still counts as busy in libtacky's MainTierBusy (libtacky's, not changed here). Tests: RelockTest 8 new (screen off during the reopen with the keyguard locked and within the lock delay, both failing on the old code; an open ending behind the lock; a held close keeping a fresh window at the fingerprint and called off by the prompt, or opened by it where it went; the close waiting for the page's drafts; a take's retry not holding drop), DeviceLockTest and WatchesTest updated for the close without a copy, KeystoreSealersTest for the fail-closed probe; device-lock.test.ts and storage-gate.test.ts 6 new; JVM free 485, gms 488, all passing; make check STRICT=1 clean; make test-ui 380 files, 4168 passed, 8 skipped; make test-shell ok.
ann referenced this pull request from a commit 2026-10-27 15:08:57 +00:00

Pull request closed

Sign in to join this conversation.
No description provided.