diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt new file mode 100644 index 000000000..45e6651af --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt @@ -0,0 +1,374 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.view + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF +import android.text.Spanned +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_CANCEL +import android.view.MotionEvent.ACTION_DOWN +import android.view.MotionEvent.ACTION_UP +import android.view.ViewConfiguration +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.doOnLayout +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R +import java.lang.Float.max +import java.lang.Float.min +import kotlin.math.abs + +/** + * Displays text to the user with optional [ClickableSpan]s. Extends the touchable area of the spans + * to ensure they meet the minimum size of 48dp x 48dp for accessibility requirements. + * + * If the touchable area of multiple spans overlap the touch is dispatched to the closest span. + */ +class ClickableSpanTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = android.R.attr.textViewStyle +) : AppCompatTextView(context, attrs, defStyleAttr) { + /** + * Map of [RectF] that enclose the [ClickableSpan] without any additional touchable area. A span + * may extend over more than one line, so multiple entries in this map may point to the same + * span. + */ + private val spanRects = mutableMapOf() + + /** + * Map of [RectF] that enclose the [ClickableSpan] with the additional touchable area. A span + * may extend over more than one line, so multiple entries in this map may point to the same + * span. + */ + private val delegateRects = mutableMapOf() + + /** + * The [ClickableSpan] that is used for the point the user has touched. Null if the user is + * not tapping, or the point they have touched is not associated with a span. + */ + private var clickedSpan: ClickableSpan? = null + + /** The minimum size, in pixels, of a touchable area for accessibility purposes */ + private val minDimenPx = resources.getDimensionPixelSize(R.dimen.minimum_touch_target) + + /** + * Debugging helper. Normally false, set this to true to show a border around spans, and + * shade their touchable area. + */ + private val showSpanBoundaries = false + + /** + * Debugging helper. The paint to use to draw a span. + */ + private lateinit var spanDebugPaint: Paint + + /** + * Debugging helper. The paint to use to shade a span's touchable area. + */ + private lateinit var paddingDebugPaint: Paint + + init { + // Initialise debugging paints, if appropriate. Only ever present in debug builds, and + // is optimised out if showSpanBoundaries is false. + if (BuildConfig.DEBUG && showSpanBoundaries) { + spanDebugPaint = Paint() + spanDebugPaint.color = Color.BLACK + spanDebugPaint.style = Paint.Style.STROKE + + paddingDebugPaint = Paint() + paddingDebugPaint.color = Color.MAGENTA + paddingDebugPaint.alpha = 50 + } + } + + override fun onTextChanged( + text: CharSequence?, + start: Int, + lengthBefore: Int, + lengthAfter: Int + ) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + doOnLayout { measureSpans() } + } + + /** + * Compute [Rect]s for each [ClickableSpan]. + * + * Each span is associated with at least two Rects. One for the span itself, and one for the + * touchable area around the span. + * + * If the span runs over multiple lines there will be two Rects per line. + */ + private fun measureSpans() { + val spannedText = text as? Spanned ?: return + + spanRects.clear() + delegateRects.clear() + + // The goal is to record all the [Rect]s associated with a span with the same fidelity + // that the user sees when they highlight text in the view to select it. + // + // There's no method in [TextView] or [Layout] that does exactly that. [Layout.getSelection] + // would be perfect, but it's not accessible. However, [Layout.getSelectionPath] is. That + // records the Rects between two characters in the string, and handles text that spans + // multiple lines, is bidirectional, etc. + // + // However, it records them in to a [Path], and a Path has no mechanism to extract the + // Rects saved in to it. + // + // So subclass Path with [RectRecordingPath], which records the data from calls to + // [addRect]. Pass that to `getSelectionPath` to extract all the Rects between start and + // end. + val rects = mutableListOf() + val rectRecorder = RectRecordingPath(rects) + + for (span in spannedText.getSpans(0, text.length - 1, ClickableSpan::class.java)) { + rects.clear() + val spanStart = spannedText.getSpanStart(span) + val spanEnd = spannedText.getSpanEnd(span) + + // Collect all the Rects for this span + layout.getSelectionPath(spanStart, spanEnd, rectRecorder) + + // Save them + for (rect in rects) { + // Adjust to account for the view's padding and gravity + rect.offset(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat()) + rect.bottom += extendedPaddingBottom + + // The rect wraps just the span, with no additional touchable area. Save a copy. + spanRects[RectF(rect)] = span + + // Adjust the rect to meet the minimum dimensions + if (rect.height() < minDimenPx) { + val yOffset = (minDimenPx - rect.height()) / 2 + rect.top = max(0f, rect.top - yOffset) + rect.bottom = min(rect.bottom + yOffset, bottom.toFloat()) + } + + if (rect.width() < minDimenPx) { + val xOffset = (minDimenPx - rect.width()) / 2 + rect.left = max(0f, rect.left - xOffset) + rect.right = min(rect.right + xOffset, right.toFloat()) + } + + // Save it + delegateRects[rect] = span + } + } + } + + /** + * Handle some touch events. + * + * - [ACTION_DOWN]: Determine which, if any span, has been clicked, and save in clickedSpan + * - [ACTION_UP]: If a span was saved then dispatch the click to that span + * - [ACTION_CANCEL]: Clear the saved span + * + * Defer to the parent class for other touches. + */ + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + event ?: return super.onTouchEvent(null) + if (delegateRects.isEmpty()) return super.onTouchEvent(event) + + when (event.action) { + ACTION_DOWN -> { + clickedSpan = null + val x = event.x + val y = event.y + + // If the user has clicked directly on a span then use it, ignoring any overlap + for (entry in spanRects) { + if (!entry.key.contains(x, y)) continue + clickedSpan = entry.value + Log.v(TAG, "span click: ${(clickedSpan as URLSpan).url}") + return super.onTouchEvent(event) + } + + // Otherwise, check to see if it's in a touchable area + var activeEntry: MutableMap.MutableEntry? = null + + for (entry in delegateRects) { + if (entry == activeEntry) continue + if (!entry.key.contains(x, y)) continue + + if (activeEntry == null) { + activeEntry = entry + continue + } + Log.v(TAG, "Overlap: ${(entry.value as URLSpan).url} ${(activeEntry.value as URLSpan).url}") + if (isClickOnFirst(entry.key, activeEntry.key, x, y)) { + activeEntry = entry + } + } + clickedSpan = activeEntry?.value + clickedSpan?.let { Log.v(TAG, "padding click: ${(clickedSpan as URLSpan).url}") } + return super.onTouchEvent(event) + } + ACTION_UP -> { + clickedSpan?.let { + clickedSpan = null + val duration = event.eventTime - event.downTime + if (duration <= ViewConfiguration.getLongPressTimeout()) { + it.onClick(this) + return true + } + } + return super.onTouchEvent(event) + } + ACTION_CANCEL -> { + clickedSpan = null + return super.onTouchEvent(event) + } + else -> return super.onTouchEvent(event) + } + } + + /** + * Determine whether a click on overlapping rectangles should be attributed to the first or the + * second rectangle. + * + * When the user clicks on the overlap it has to be attributed to the "best" rectangle. The + * rectangles have equivalent z-order, so their "closeness" to the user in the Z-plane is not + * a consideration. + * + * The chosen rectangle depends on whether they overlap top/bottom (the top of one rect is + * not the same as the top of the other rect), or they overlap left/right (the tops of both + * rects are the same). + * + * In this example the rectangles overlap top/bottom because their top edges are not aligned. + * + * ``` + * +--------------+ + * |1 | + * | +--------------+ + * | |2 | + * | | | + * | | | + * +------| | + * | | + * +--------------+ + * ``` + * + * (Rectangle #1 being partially occluded by rectangle #2 is for clarity in the diagram, it + * does not affect the algorithm) + * + * Take the Y coordinate of the centre of each rectangle. + * + * ``` + * +--------------+ + * |1 | + * | +--------------+ + * |......|2 | <-- Rect #1 centre line + * | | | + * | |..............| <-- Rect #2 centre line + * +------| | + * | | + * +--------------+ + * ``` + * + * Take the Y position of the click, and determine which Y centre coordinate it is closest too. + * Whichever one is closest is the clicked rectangle. + * + * In these examples the left column of numbers is the Y coordinate, `*` marks the point where + * the user clicked. + * + * ``` + * 0 +--------------+ +--------------+ + * 1 |1 | |1 | + * 2 | +--------------+ | +--------------+ + * 3 |......|2 * | |......|2 | + * 4 | | | | | | + * 5 | |..............| | |*.............| + * 6 +------| | +------| | + * 7 | | | | + * 8 +--------------+ +--------------+ + * + * Rect #1 centre Y = 3 + * Rect #2 centre Y = 5 + * Click (*) Y = 3 Click (*) Y = 5 + * Result: Rect #1 is clicked Result: Rect #2 is clicked + * ``` + * + * The approach is the same if the rectangles overlap left/right, but the X coordinate of the + * centre of the rectangle is tested against the X coordinate of the click. + * + * @param first rectangle to test against + * @param second rectangle to test against + * @param x coordinate of user click + * @param y coordinate of user click + * @return true if the click was closer to the first rectangle than the second + */ + private fun isClickOnFirst(first: RectF, second: RectF, x: Float, y: Float): Boolean { + Log.v(TAG, "first: $first second: $second click: $x $y") + val (firstDiff, secondDiff) = if (first.top == second.top) { + Log.v(TAG, "left/right overlap") + Pair(abs(first.centerX() - x), abs(second.centerX() - x)) + } else { + Log.v(TAG, "top/bottom overlap") + Pair(abs(first.centerY() - y), abs(second.centerY() - y)) + } + Log.d(TAG, "firstDiff: $firstDiff secondDiff: $secondDiff") + return firstDiff < secondDiff + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + + // Paint span boundaries. Optimised out on release builds, or debug builds where + // showSpanBoundaries is false. + if (BuildConfig.DEBUG && showSpanBoundaries) { + canvas?.save() + for (entry in delegateRects) { + canvas?.drawRect(entry.key, paddingDebugPaint) + } + + for (entry in spanRects) { + canvas?.drawRect(entry.key, spanDebugPaint) + } + canvas?.restore() + } + } + + companion object { + const val TAG = "LinkTextView" + } +} + +/** + * A [Path] that records the contents of all the [addRect] calls it receives. + * + * @param rects list to record the received [RectF] + */ +private class RectRecordingPath(private val rects: MutableList) : Path() { + override fun addRect(left: Float, top: Float, right: Float, bottom: Float, dir: Direction) { + rects.add(RectF(left, top, right, bottom)) + } +} diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index 43c7f9ae4..91919bc6b 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -55,7 +55,7 @@ android:textStyle="bold" /> - - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index bf011c0b4..021a1e8b6 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -216,7 +216,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" /> - - - - - - - - - - + + + + + @@ -29,4 +34,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index abba099e1..df2bd8a5f 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -62,5 +62,8 @@ 1dp + 48dp + 4dp +