From a054aaa03eeb56e2a5f6b3bb06559eebce8fdcbe Mon Sep 17 00:00:00 2001 From: Chethan616 Date: Sun, 6 Sep 2026 19:18:34 +0530 Subject: [PATCH] Fix text-selection handle drag/visuals and onboarding page 1 vibrancy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text selection: - The handle-drag gesture's pointerInput was keyed on selectedOcrRanges — the exact value the gesture itself mutates on every move (onSelectOcrRange inside updateRange()). The instant a drag moved past touch slop, that update fired, Compose saw the key change, and cancelled + restarted the gesture coroutine mid-drag. The restarted awaitEachGesture then waited on a fresh touch-down that never came (the same finger was still down), so a handle drag died after the first movement regardless of direction. Fixed by reading the selection via rememberUpdatedState instead of keying on it. - Replaced the symmetric "balloon on a flat neck" handle shape with the actual Android/Chrome/Google Docs construction: a circle with one sharp corner notched out exactly at the anchor point, asymmetric per side (each handle's bulge leans into the selection it bounds). Verified geometrically (numeric assertions + rendered PNG previews) before implementing in Kotlin. - The highlight band between the handles is now a fully-rounded capsule (radius = half its own height) instead of a flat rectangle with a 2-5px corner clamp, so it reads as one selection with the handles rather than two balloons stuck onto a separate bar. Onboarding page 1: - The fanned stack of format-sheets behind the assembled book sat at a permanent 0.20/0.34 fill alpha once landed (only the last-landing sheet ever fades, as the book takes over) — pale enough on the light background to read as dull/grey. Raised to 0.75 card / full-opacity lines, keeping a real contrast gap between the two so the "document lines" still read against the card instead of both washing into one flat block. --- .../clearpdf/ui/components/OnboardingDemos.kt | 19 +++++-- .../clearpdf/ui/screen/PdfContinuousPage.kt | 29 ++++++++--- .../clearpdf/ui/screen/PdfViewerInternals.kt | 49 +++++++++++++++---- 3 files changed, 75 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt index e863bde..423412c 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt @@ -318,9 +318,18 @@ fun DemoDocumentOpen(isActive: Boolean, backdrop: Backdrop, glass: Color, ink: C * rectangle read as a document once it is down at a third of its size. * * [tint] is the sheet's file kind, from [DemoKinds]. Card and bars share it so a sheet reads as one - * coloured object rather than a coloured card with grey lines on it. Both alphas sit a little above - * the neutral ones they replace (0.16 / 0.24): a hue needs more weight than grey to register at the - * 0.32 flight scale, under a 64° fold. + * coloured object rather than a coloured card with grey lines on it. + * + * Fill alphas are high (0.75 card / 1.0 lines), not the washed-out 0.20 / 0.34 this used to carry — + * and deliberately still a real gap between the two (not both pushed equally to ~0.9), since the + * bars are what read as "document lines" against the card and need contrast against it, not just + * high opacity in isolation. Every sheet + * stays at this same opacity for its entire life — through the flight, and after it lands in the + * fanned stack behind the assembled book (only the last-landing sheet ever fades, as the book takes + * over; the rest sit there indefinitely, per `DemoDocumentOpen`). At 0.20 that permanent settled fan + * read as pale grey ghosts on the light onboarding background — exactly what a real user flagged as + * "backside pages are dull and grey." Near-opaque keeps every sheet in the fan a saturated, distinct + * colour instead. */ @Composable private fun OrbitSheet(tint: Color, modifier: Modifier) { @@ -328,7 +337,7 @@ private fun OrbitSheet(tint: Color, modifier: Modifier) { modifier .size(width = SheetW, height = SheetH) .clip(RoundedCornerShape(14.dp)) - .background(tint.copy(0.20f)) + .background(tint.copy(0.75f)) .padding(horizontal = 14.dp, vertical = 16.dp) ) { Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { @@ -338,7 +347,7 @@ private fun OrbitSheet(tint: Color, modifier: Modifier) { .fillMaxWidth(w) .height(5.dp) .clip(RoundedCornerShape(3.dp)) - .background(tint.copy(0.34f)) + .background(tint) ) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt index b614df9..ab89eab 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment @@ -154,6 +155,16 @@ internal fun PdfContinuousPage( var magnifierFocus by remember(page, activeTool) { mutableStateOf(Offset.Unspecified) } val selectionHandleDiameterPx = with(LocalDensity.current) { 32.dp.toPx() } val selectionHandleHitRadiusPx = with(LocalDensity.current) { 30.dp.toPx() } + // Read inside the handle-drag gesture below instead of the raw `selectedOcrRanges` parameter. + // That gesture used to key its `pointerInput` on `selectedOcrRanges` itself — but the gesture + // is also what MUTATES it on every move (`onSelectOcrRange` inside `updateRange()`), so the + // instant a drag moved past touch slop, Compose saw the key change and cancelled + restarted + // the gesture coroutine mid-drag. The restarted `awaitEachGesture` then waited on a fresh + // touch-down that never came (the same finger was already down), which is why a handle drag + // died after exactly one movement in any direction. `selectedOcrRanges` is no longer a key — + // `rememberUpdatedState` is what lets the gesture still read the *current* selection at the + // start of each new touch (to know which handle was grabbed) without needing a key at all. + val latestSelectedOcrRanges by rememberUpdatedState(selectedOcrRanges) // Page layout (rebuilt on the Pdf_Tools model): the image is drawn at its TRUE // aspect via ContentScale.FillWidth, so the box height follows the bitmap. There @@ -223,9 +234,13 @@ internal fun PdfContinuousPage( selectedOcrRanges.forEach { range -> ocrBlocks.firstOrNull { it.id == range.blockId }?.let { b -> val r = expandedTextHighlightRect(ocrTextRangeToRect(b, range, frame), verticalScale = 1.35f) - // Stock-Android text-selection look: a subtle translucent band in the same - // blue as the selection handles (#4285F4), not an opaque saturated cyan block. - val radius = (r.height * 0.14f).coerceIn(2f, 5f) + // Fully rounded — half the band's own height, a real capsule — not the old 2-5px + // clamp, which read as a barely-softened rectangle. The handles hanging off each + // end are a rounded teardrop; a flat bar between them was what made the whole + // selection look like two balloons stuck onto a straight rod instead of one + // shape. Same blue family as the handles (#4285F4) so the band and the handles + // it connects to read as one selection, not two unrelated pieces. + val radius = r.height * 0.5f drawRoundRect(Color(0xFF4285F4).copy(0.28f), r.topLeft, r.size, CornerRadius(radius, radius)) } } @@ -373,8 +388,8 @@ internal fun PdfContinuousPage( // DrawScope is itself a Density, so dp -> px works directly here. val minPx = 14.dp.toPx() val maxPx = 30.dp.toPx() - drawTextSelectionHandle(start, (startLineH * 1.1f).coerceIn(minPx, maxPx)) - drawTextSelectionHandle(end, (endLineH * 1.1f).coerceIn(minPx, maxPx)) + drawTextSelectionHandle(start, (startLineH * 1.1f).coerceIn(minPx, maxPx), isLeftHandle = true) + drawTextSelectionHandle(end, (endLineH * 1.1f).coerceIn(minPx, maxPx), isLeftHandle = false) } } @@ -641,7 +656,7 @@ internal fun PdfContinuousPage( // Direct drag (no long-press wait): sweep a contiguous run of words in reading order // and update the range live. The page stays in text-selection mode, while the parent // viewer still receives two-finger pinch events for zoom. - .pointerInput(page, ocrBlocks, selectedOcrRanges, selectionHandleHitRadiusPx) { + .pointerInput(page, ocrBlocks, selectionHandleHitRadiusPx) { fun updateRange() { val s = selDragStart; val e = selDragEnd if (s == null || e == null || ocrBlocks.isEmpty()) return @@ -660,7 +675,7 @@ internal fun PdfContinuousPage( awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) val frame = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()) - val handles = ocrSelectionHandleAnchors(ocrBlocks, selectedOcrRanges, frame) + val handles = ocrSelectionHandleAnchors(ocrBlocks, latestSelectedOcrRanges, frame) val handleMode = when { handles == null -> 0 (down.position - handles.first).getDistance() <= selectionHandleHitRadiusPx -> 1 diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt index 3d062a3..0fefd18 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt @@ -454,22 +454,51 @@ internal fun ocrSelectionHandleLineHeights( return (firstBlock.bottom - firstBlock.top) * frame.height to (lastBlock.bottom - lastBlock.top) * frame.height } -/** Custom organic handle used by the PDF selection layer; deliberately not a platform handle. */ +/** + * Stock-Android-style selection handle: a circle with one sharp corner notched out exactly at + * [anchor] — the actual look of the system text-selection handles (and Chrome's, and Google Docs'), + * not a free-floating teardrop. The bulge always leans *toward* the selection it bounds: the left + * handle's sharp corner sits at its top-left with the circle hanging down-right (into the selected + * text), the right handle's sharp corner sits at top-right with the circle hanging down-left. + * + * The construction is the special case where a circle of radius `r` is centered exactly `(r, r)` + * diagonally from the external anchor point: the tangent lines from the anchor to that circle then + * land exactly on the horizontal and vertical through the anchor (no trig needed to find them), and + * the visible arc is the far 270° of the circle — the 90° nearest the anchor is what's replaced by + * the two straight tangent segments that meet at the sharp corner. + * + * The previous shape was a symmetric, free-floating teardrop with a flat rectangular "neck" — it + * didn't sit flush against anything, so next to the highlight band it read as two balloons stuck + * onto a separate bar rather than the band's own corner drawn out into a handle. + */ internal fun DrawScope.drawTextSelectionHandle( anchor: Offset, diameter: Float, + isLeftHandle: Boolean, color: Color = Color(0xFF4285F4) ) { - val neckHalfWidth = diameter * 0.22f - val bodyRadius = diameter * 0.47f - val top = anchor.y - 1f - val bottom = top + bodyRadius * 2f + val r = diameter * 0.5f + // +1 for the left handle (circle bulges right, into the selection), -1 for the right handle + // (circle bulges left, into the selection). + val sign = if (isLeftHandle) 1f else -1f + val center = Offset(anchor.x + sign * r, anchor.y + r) + val bottomTangent = Offset(anchor.x, anchor.y + r) // straight down from the anchor + // Sweeping the long way (270°, away from the anchor-facing quadrant) from `bottomTangent` back + // to the anchor's other tangent point (straight sideways from it) — direction flips with + // handedness since the circle sits on the opposite side. + val startAngleDeg = if (isLeftHandle) 180f else 0f + val sweepDeg = if (isLeftHandle) -270f else 270f + val path = Path().apply { - moveTo(anchor.x - neckHalfWidth, top) - lineTo(anchor.x + neckHalfWidth, top) - lineTo(anchor.x + neckHalfWidth, top + 6f) - cubicTo(anchor.x + bodyRadius, top + 9f, anchor.x + bodyRadius, bottom - 2f, anchor.x, bottom) - cubicTo(anchor.x - bodyRadius, bottom - 2f, anchor.x - bodyRadius, top + 9f, anchor.x - neckHalfWidth, top + 6f) + moveTo(anchor.x, anchor.y) + lineTo(bottomTangent.x, bottomTangent.y) + arcTo( + rect = Rect(center.x - r, center.y - r, center.x + r, center.y + r), + startAngleDegrees = startAngleDeg, + sweepAngleDegrees = sweepDeg, + forceMoveTo = false + ) + lineTo(anchor.x, anchor.y) close() } drawPath(path, color)