From 91d4d768f269d564f35a60969aa8bdd8504dbd5b Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Sat, 5 Sep 2026 15:21:03 -0400 Subject: [PATCH 1/7] Apply rounded corners to IME chrome and status bar Refactor corner radius handling to apply rounded corners to the entire IME chrome layout instead of just padding the bottom row. Move corner radius logic from padding-based insets to ViewOutlineProvider-based clipping. Increase fallback corner radius from 24dp to 50dp for better visual consistency. --- .../inputmethod/StatusBarController.kt | 248 ++++++++++++++++-- .../Titan2EliteBottomRowSafeArea.kt | 28 -- .../statusbar/StatusBarButtonHost.kt | 14 + .../suggestions/ui/FullSuggestionsBar.kt | 21 +- .../pastiera/inputmethod/ui/LedStatusView.kt | 97 ++++++- .../pastiera/update/UpdateChecker.kt | 2 +- .../pastiera/update/UpdateReleaseResolver.kt | 49 ++++ app/src/main/res/values/strings.xml | 2 +- .../NestedStatusBarGeometryTest.kt | 76 ++++++ .../Titan2EliteBottomRowSafeAreaTest.kt | 45 ---- .../inputmethod/ui/LedStatusViewTest.kt | 36 +++ .../update/UpdateCheckerFlavorLogicTest.kt | 44 ++++ .../unihertz-titan2elite/README.md | 4 +- 13 files changed, 548 insertions(+), 118 deletions(-) delete mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/Titan2EliteBottomRowSafeArea.kt create mode 100644 app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt delete mode 100644 app/src/test/java/it/palsoftware/pastiera/inputmethod/Titan2EliteBottomRowSafeAreaTest.kt diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt index aaa9eb196..aa4fe4ac7 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt @@ -6,6 +6,10 @@ import android.animation.ValueAnimator import android.content.Context import android.content.Intent import android.graphics.Color +import android.graphics.Outline +import android.graphics.Matrix +import android.graphics.Path +import android.graphics.RectF import androidx.core.content.ContextCompat import android.graphics.drawable.ColorDrawable import android.graphics.drawable.GradientDrawable @@ -15,6 +19,7 @@ import android.view.MotionEvent import android.view.RoundedCorner import android.view.View import android.view.ViewGroup +import android.view.ViewOutlineProvider import android.view.animation.AccelerateDecelerateInterpolator import android.widget.ImageView import android.widget.FrameLayout @@ -257,7 +262,7 @@ class StatusBarController( companion object { private const val TAG = "StatusBarController" private val DEFAULT_BACKGROUND = Color.parseColor("#000000") - private const val TITAN_2_ELITE_CORNER_FALLBACK_RADIUS_DP = 24f + private const val TITAN_2_ELITE_CORNER_FALLBACK_RADIUS_DP = 50f } data class StatusSnapshot( @@ -350,8 +355,6 @@ class StatusBarController( private var baseLeftPadding: Int = 0 private var baseRightPadding: Int = 0 private var baseBottomPadding: Int = 0 - private var bottomRowBaseLeftPadding: Int = 0 - private var bottomRowBaseRightPadding: Int = 0 private var lastHamburgerInputConnection: android.view.inputmethod.InputConnection? = null private var lastInsetsLogSignature: String? = null private var softwareKeyboardShown: Boolean = false @@ -630,35 +633,23 @@ class StatusBarController( } else { 0 } - val cornerInsets = Titan2EliteBottomRowSafeArea.resolveInsetsPx( - enabled = useTitan2EliteRoundedCornerInsets, - leftCornerRadiusPx = bottomLeftRadius, - rightCornerRadiusPx = bottomRightRadius, - fallbackCornerRadiusPx = fallbackCornerRadius - ) - val appliedBottomRowLeftPadding = bottomRowBaseLeftPadding + - if (useTitan2EliteRoundedCornerInsets) { - max(cutout.left, cornerInsets.left) - } else { - 0 - } - val appliedBottomRowRightPadding = bottomRowBaseRightPadding + + val bottomInset = max(navAndGestures.bottom, cutout.bottom) + val appliedBottomPadding = baseBottomPadding + bottomInset + (view as? ImeChromeLayout)?.bottomCornerRadiiPx = if (useTitan2EliteRoundedCornerInsets) { - max(cutout.right, cornerInsets.right) + Pair( + bottomLeftRadius.takeIf { it > 0 } ?: fallbackCornerRadius, + bottomRightRadius.takeIf { it > 0 } ?: fallbackCornerRadius + ) } else { - 0 + null } - val bottomInset = max(navAndGestures.bottom, cutout.bottom) - val appliedBottomPadding = baseBottomPadding + bottomInset + ledStatusView.bottomCornerRadiiPx = (view as? ImeChromeLayout)?.bottomCornerRadiiPx view.updatePadding( left = baseLeftPadding, right = baseRightPadding, bottom = appliedBottomPadding ) - variationsWrapper?.updatePadding( - left = appliedBottomRowLeftPadding, - right = appliedBottomRowRightPadding - ) logImeOverlayInsetsIfEnabled( navBottom = navAndGestures.bottom, imeBottom = 0, @@ -730,10 +721,6 @@ class StatusBarController( } variationsWrapper = variationBarView?.ensureView() - variationsWrapper?.let { bottomRow -> - bottomRowBaseLeftPadding = bottomRow.paddingLeft - bottomRowBaseRightPadding = bottomRow.paddingRight - } attachHamburgerMenu(variationsWrapper) ledStatusView.layout = modifierLedLayout() val ledStrip = ledStatusView.ensureView() @@ -781,6 +768,9 @@ class StatusBarController( } (statusBarLayout as? ImeChromeLayout)?.apply { surfaceView = symSurfaceContainer + indicatorView = ledStrip + expandedSurfaceView = emojiKeyboardContainer + compactStatusRow = fullSuggestionsBar?.ensureView() } applyChromeZOrder() applyAccessibilitySecondRowReadPreference() @@ -3465,8 +3455,174 @@ class StatusBarController( ).toInt() } - private class ImeChromeLayout(context: Context) : LinearLayout(context) { + internal class ImeChromeLayout(context: Context) : LinearLayout(context) { private val screenAwakeController = ImeTouchScreenAwakeController(context) + var indicatorView: View? = null + var expandedSurfaceView: View? = null + var compactStatusRow: View? = null + private var nestedRow: View? = null + private var originalRowMargins = intArrayOf(0, 0, 0) + private var originalRowOutline: ViewOutlineProvider? = null + private var originalRowClip = false + private var originalRowMinHeight = 0 + private val originalIconTransforms = mutableMapOf>() + + private fun updateNestedStatusRow() { + nestedRow?.let { row -> + (row.layoutParams as LayoutParams).apply { + leftMargin = originalRowMargins[0] + rightMargin = originalRowMargins[1] + bottomMargin = originalRowMargins[2] + } + row.outlineProvider = originalRowOutline + row.clipToOutline = originalRowClip + row.minimumHeight = originalRowMinHeight + } + nestedRow = null + val radii = bottomCornerRadiiPx ?: return + if (softwareKeyboardModeActive || expandedSurfaceView?.visibility != View.GONE || + indicatorView?.visibility != View.VISIBLE || surfaceView?.visibility != View.VISIBLE + ) return + val surfaceIndex = indexOfChild(surfaceView) + val row = (surfaceIndex - 1 downTo 0) + .map(::getChildAt).firstOrNull { it.visibility == View.VISIBLE } ?: return + val params = row.layoutParams as LayoutParams + nestedRow = row + originalRowMargins = intArrayOf(params.leftMargin, params.rightMargin, params.bottomMargin) + originalRowOutline = row.outlineProvider + originalRowClip = row.clipToOutline + originalRowMinHeight = row.minimumHeight + // Both LED rows occupy 5.5 dp, with 1 dp of edge spacing. + val inset = (6.5f * resources.displayMetrics.density).toInt() + val radius = maxOf(radii.first, radii.second) + val stripTop = (resources.displayMetrics.density).toInt() + params.leftMargin += inset + params.rightMargin += inset + // The LED surface draws first; overlap its empty center with the row. + val overlap = (radius + stripTop - inset).coerceAtLeast(0) + params.bottomMargin -= overlap + row.minimumHeight = maxOf(originalRowMinHeight, overlap) + row.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + val left = (radii.first - inset).coerceAtLeast(0).toFloat() + val right = (radii.second - inset).coerceAtLeast(0).toFloat() + val bottomExtension = inset - (3.1f * resources.displayMetrics.density).toInt() + val path = Path().apply { + addRoundRect( + RectF(0f, -2f * radius, view.width.toFloat(), view.height.toFloat()), + floatArrayOf(0f, 0f, 0f, 0f, right, right + bottomExtension, left, left + bottomExtension), + Path.Direction.CW + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) outline.setPath(path) + else { + @Suppress("DEPRECATION") + outline.setConvexPath(path) + } + } + } + row.clipToOutline = true + row.invalidateOutline() + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + originalIconTransforms.forEach { (icon, original) -> + icon.scaleType = original.first + icon.imageMatrix = original.second + } + originalIconTransforms.clear() + val row = nestedRow as? ViewGroup ?: return + val originalContentHeight = row.height + // A fixed-height row can be shorter than the requested overlap. + // Anchor its actual bottom to the inner LED contour after layout. + // The straight lower indicators occupy only the lower LED row; + // their top edge is closer to the bottom than the two-row side arcs. + val bottomInset = (3.1f * resources.displayMetrics.density).toInt() + surfaceView?.let { surface -> + val targetBottom = surface.bottom - bottomInset + val extraHeight = (targetBottom - row.bottom).coerceAtLeast(0) + // Fill the space up to the row's original top instead of translating + // a short row downward and exposing an empty band above it. + fun extendContent(view: View) { + val oldTop = if (view === row) view.top else 0 + val oldLeft = view.left + val oldWidth = view.width + val oldHeight = view.height + val targetHeight = if (view === row) oldHeight + extraHeight + else (view.parent as View).height + view.measure( + MeasureSpec.makeMeasureSpec(oldWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(targetHeight, MeasureSpec.EXACTLY) + ) + view.layout(oldLeft, oldTop, oldLeft + oldWidth, oldTop + targetHeight) + if (view is ViewGroup) { + for (index in 0 until view.childCount) { + val child = view.getChildAt(index) + if (child.visibility == View.VISIBLE && + child.height >= originalContentHeight - 4f * resources.displayMetrics.density && + child.height < view.height + ) extendContent(child) + } + } + } + if (extraHeight > 0) extendContent(row) + } + // Fixed-height button containers otherwise leave unused space beneath + // their contents when the corner geometry makes the row taller. + for (index in 0 until row.childCount) { + val child = row.getChildAt(index) + if (child.visibility == View.VISIBLE && child.height < row.height) { + child.offsetTopAndBottom(row.height - row.paddingBottom - child.bottom) + } + } + val radii = bottomCornerRadiiPx ?: return + fun fitIcons(view: View, offsetX: Int) { + if (view is ImageView && view.visibility == View.VISIBLE) { + val onLeft = offsetX < radii.first + val onRight = offsetX + view.width > row.width - radii.second + val drawable = view.drawable + if ((onLeft || onRight) && drawable != null && + drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0 + ) { + originalIconTransforms[view] = view.scaleType to Matrix(view.imageMatrix) + val iconFraction = if (row === compactStatusRow) 0.64f else 0.48f + val iconHeight = if (row === compactStatusRow) { + (minOf(view.height, originalContentHeight) - 4f * resources.displayMetrics.density).coerceAtLeast(1f) + } else view.height.toFloat() + val size = minOf(view.width.toFloat(), iconHeight) * iconFraction + val requestedScale = size / maxOf(drawable.intrinsicWidth, drawable.intrinsicHeight) + val scale = if (row === compactStatusRow) requestedScale else minOf(1f, requestedScale) + val inward = 4f * resources.displayMetrics.density * if (onLeft) 1f else -1f + view.scaleType = ImageView.ScaleType.MATRIX + view.imageMatrix = Matrix().apply { + setScale(scale, scale) + postTranslate( + (view.width - drawable.intrinsicWidth * scale) / 2f - view.paddingLeft + inward, + (view.height - drawable.intrinsicHeight * scale) / 2f - view.paddingTop - + 2f * resources.displayMetrics.density + ) + } + } + } + if (view is ViewGroup) { + for (index in 0 until view.childCount) { + val child = view.getChildAt(index) + fitIcons(child, offsetX + child.left) + } + } + } + fitIcons(row, 0) + } + + // (left, right) display corner radii in px; null disables the outline clip. + var bottomCornerRadiiPx: Pair? = null + set(value) { + if (field == value) return + field = value + applyBottomCornerClip() + requestLayout() + } var surfaceView: View? = null set(value) { @@ -3489,12 +3645,46 @@ class StatusBarController( return super.dispatchTouchEvent(event) } + // Extend the shape above the view so even a bar shorter than the display + // radius follows the original arc, rather than shrinking it to fit the bar. + private fun applyBottomCornerClip() { + val radii = bottomCornerRadiiPx + val radius = radii?.let { maxOf(it.first, it.second) } ?: 0 + if (radius <= 0) { + clipToOutline = false + outlineProvider = ViewOutlineProvider.BACKGROUND + return + } + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + val left = radii!!.first.coerceIn(0, view.width / 2).toFloat() + val right = radii.second.coerceIn(0, view.width / 2).toFloat() + val path = Path().apply { + addRoundRect( + RectF(0f, -2f * radius, view.width.toFloat(), view.height.toFloat()), + floatArrayOf(0f, 0f, 0f, 0f, right, right, left, left), + Path.Direction.CW + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + outline.setPath(path) + } else { + @Suppress("DEPRECATION") + outline.setConvexPath(path) + } + } + } + clipToOutline = true + invalidateOutline() + } + override fun onDetachedFromWindow() { screenAwakeController.release() super.onDetachedFromWindow() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + updateNestedStatusRow() val surface = surfaceView if (!softwareKeyboardModeActive || surface == null || surface.visibility == View.GONE) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/Titan2EliteBottomRowSafeArea.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/Titan2EliteBottomRowSafeArea.kt deleted file mode 100644 index 10dc5fca6..000000000 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/Titan2EliteBottomRowSafeArea.kt +++ /dev/null @@ -1,28 +0,0 @@ -package it.palsoftware.pastiera.inputmethod - -import kotlin.math.roundToInt - -internal object Titan2EliteBottomRowSafeArea { - private const val BOTTOM_ROW_RADIUS_FRACTION = 1f / 3f - - data class Insets(val left: Int, val right: Int) - - fun resolveInsetsPx( - enabled: Boolean, - leftCornerRadiusPx: Int, - rightCornerRadiusPx: Int, - fallbackCornerRadiusPx: Int - ): Insets { - if (!enabled) return Insets(left = 0, right = 0) - - return Insets( - left = resolveInsetPx(leftCornerRadiusPx, fallbackCornerRadiusPx), - right = resolveInsetPx(rightCornerRadiusPx, fallbackCornerRadiusPx) - ) - } - - private fun resolveInsetPx(reportedRadiusPx: Int, fallbackRadiusPx: Int): Int { - val radiusPx = reportedRadiusPx.takeIf { it > 0 } ?: fallbackRadiusPx - return (radiusPx.coerceAtLeast(0) * BOTTOM_ROW_RADIUS_FRACTION).roundToInt() - } -} diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/StatusBarButtonHost.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/StatusBarButtonHost.kt index e934fb33f..d2517a27d 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/StatusBarButtonHost.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/StatusBarButtonHost.kt @@ -184,6 +184,20 @@ class StatusBarButtonHost( } private fun applyTheme(view: View, fallbackHeight: Int? = null, state: ButtonState? = null) { + (view.getTag(it.palsoftware.pastiera.R.id.tag_badge_view) as? TextView)?.let { badge -> + val params = view.layoutParams + val enlargedPastierinaButton = params != null && params.height > 0 && + params.width > params.height * 1.2f && + it.palsoftware.pastiera.SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) + badge.setTextSize(TypedValue.COMPLEX_UNIT_SP, if (enlargedPastierinaButton) 14f else 10f) + (badge.layoutParams as? FrameLayout.LayoutParams)?.let { badgeParams -> + val rightMargin = dpToPx(if (enlargedPastierinaButton) 12f else 2f) + if (badgeParams.rightMargin != rightMargin) { + badgeParams.rightMargin = rightMargin + badge.layoutParams = badgeParams + } + } + } val theme = themeOverride ?: return val height = view.layoutParams?.height?.takeIf { it > 0 } ?: view.height.takeIf { it > 0 } diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt index b40405788..bf5dcf16d 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt @@ -474,6 +474,13 @@ class FullSuggestionsBar( } } + private fun minimalButtonWidthPx(): Int { + val size = (targetHeightPx - dpToPx(4f)).coerceAtLeast(dpToPx(24f)) + return if (it.palsoftware.pastiera.SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) { + maxOf(dpToPx(56f), (size * 1.6f).toInt()) + } else size + } + private fun renderMinimalUiButtons() { val leftContainer = minimalLeftButtonsContainer ?: return val rightContainer = minimalRightButtonsContainer ?: return @@ -490,6 +497,10 @@ class FullSuggestionsBar( } val buttonSize = (targetHeightPx - dpToPx(4f)).coerceAtLeast(dpToPx(24f)) + val buttonWidth = minimalButtonWidthPx() + val buttonHeight = if (it.palsoftware.pastiera.SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) { + targetHeightPx + } else buttonSize val spacing = dpToPx(3f) val callbacks = (callbacksProvider?.invoke() ?: StatusBarCallbacks()) .copy(onHamburgerMenuRequested = { toggleHamburgerMenu() }) @@ -499,10 +510,10 @@ class FullSuggestionsBar( id = buttonId, size = buttonSize, callbacks = callbacks, - width = buttonSize, - height = buttonSize + width = buttonWidth, + height = buttonHeight ) ?: return - hosted.container.layoutParams = LinearLayout.LayoutParams(buttonSize, buttonSize).apply { + hosted.container.layoutParams = LinearLayout.LayoutParams(buttonWidth, buttonHeight).apply { marginEnd = if (isLast) 0 else spacing } target.addView(hosted.container) @@ -538,7 +549,7 @@ class FullSuggestionsBar( } ?: 0 val minimalLeftInset = if (showMinimalUiButtons) { minimalLeftButtonsContainer?.takeIf { it.visibility == View.VISIBLE }?.let { - it.childCount * (targetHeightPx - dpToPx(4f)).coerceAtLeast(dpToPx(24f)) + + it.childCount * minimalButtonWidthPx() + (it.childCount - 1).coerceAtLeast(0) * spacing + spacing } ?: 0 @@ -558,7 +569,7 @@ class FullSuggestionsBar( val leftInset = indicatorInset + minimalLeftInset val rightInset = if (showMinimalUiButtons) { minimalRightButtonsContainer?.takeIf { it.visibility == View.VISIBLE }?.let { - it.childCount * (targetHeightPx - dpToPx(4f)).coerceAtLeast(dpToPx(24f)) + + it.childCount * minimalButtonWidthPx() + (it.childCount - 1).coerceAtLeast(0) * spacing + spacing } ?: 0 diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt index adab2a7ad..748694138 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt @@ -3,6 +3,10 @@ package it.palsoftware.pastiera.inputmethod.ui import android.animation.ValueAnimator import android.content.Context import android.graphics.Color +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PathMeasure import android.graphics.drawable.GradientDrawable import android.util.TypedValue import android.view.View @@ -48,6 +52,17 @@ class LedStatusView( private var container: ModifierLedCanvas? = null private val ledsByState = mutableMapOf>() + private val segmentsByView = mutableMapOf() + + var bottomCornerRadiiPx: Pair? = null + set(value) { + if (field == value) return + field = value + container?.cornerRadiiPx = value + container?.let { canvas -> + for (index in 0 until canvas.childCount) canvas.getChildAt(index).invalidate() + } + } internal var layout: ModifierLedLayout = ModifierLedLayouts.DEFAULT set(value) { @@ -63,6 +78,7 @@ class LedStatusView( container?.let { return it } container = ModifierLedCanvas(context, ledHeight).apply { + cornerRadiiPx = bottomCornerRadiiPx setPadding(0, topPadding, 0, 0) layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -100,22 +116,70 @@ class LedStatusView( private fun rebuildSegments() { val canvas = container ?: return ledsByState.clear() + segmentsByView.clear() canvas.replaceSegments(layout.segments) { segment -> - createLedView(LED_COLOR_GRAY_OFF).also { led -> + createLedView(LED_COLOR_GRAY_OFF, segment).also { led -> ledsByState.getOrPut(segment.state) { mutableListOf() }.add(led) } } } - private fun createLedView(initialColor: Int): View { + private fun createLedView(initialColor: Int, segment: ModifierLedSegment): View { return View(context).apply { - background = createDrawable(initialColor) + segmentsByView[this] = segment + background = createDrawable(initialColor, segment) setTag(R.id.led_previous_color, initialColor) } } - private fun createDrawable(color: Int): GradientDrawable { - return GradientDrawable().apply { + private fun createDrawable(color: Int, segment: ModifierLedSegment): GradientDrawable { + return object : GradientDrawable() { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + this.color = color + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + + override fun draw(canvas: Canvas) { + val radii = bottomCornerRadiiPx + if (radii == null) { + super.draw(canvas) + return + } + val width = bounds.width().toFloat() + val height = bounds.height().toFloat() + // Each row follows a concentric contour, so the indicator retains + // its thickness through the bend instead of being cut off by it. + val inset = (1f - segment.y - segment.height / 2f) * ledHeight + topPadding + val leftRadius = radii.first.toFloat().coerceIn(inset, maxOf(inset, width / 2f)) + val rightRadius = radii.second.toFloat().coerceIn(inset, maxOf(inset, width / 2f)) + val leftArc = leftRadius - inset + val rightArc = rightRadius - inset + val contour = Path().apply { + moveTo(inset, height - leftRadius) + if (leftArc > 0f) { + arcTo(inset, height - leftRadius - leftArc, + leftRadius + leftArc, height - inset, 180f, -90f, false) + } + lineTo(width - rightRadius, height - inset) + if (rightArc > 0f) { + arcTo(width - rightRadius - rightArc, height - rightRadius - rightArc, + width - inset, height - inset, 90f, -90f, false) + } + } + val measure = PathMeasure(contour, false) + paint.strokeWidth = segment.height * ledHeight + // Leave room for the round caps at both ends of each segment. + val cap = paint.strokeWidth / 2f + val start = measure.length * segment.x + cap + val end = measure.length * (segment.x + segment.width) - cap + if (end > start) { + val stroke = Path() + measure.getSegment(start, end, stroke, true) + canvas.drawPath(stroke, paint) + } + } + }.apply { shape = GradientDrawable.RECTANGLE setColor(color) cornerRadius = this@LedStatusView.cornerRadius @@ -150,7 +214,7 @@ class LedStatusView( led.setTag(R.id.led_previous_color, targetColor) if (previousColor == targetColor) { - led.background = createDrawable(targetColor) + led.background = createDrawable(targetColor, segmentsByView.getValue(led)) return } @@ -159,7 +223,7 @@ class LedStatusView( interpolator = AccelerateDecelerateInterpolator() addUpdateListener { animator -> val color = animator.animatedValue as Int - led.background = createDrawable(color) + segmentsByView[led]?.let { led.background = createDrawable(color, it) } } }.start() } @@ -169,6 +233,11 @@ class LedStatusView( private val contentHeightPx: Int ) : ViewGroup(context) { private val segments = mutableListOf() + var cornerRadiiPx: Pair? = null + set(value) { + field = value + requestLayout() + } fun replaceSegments( newSegments: List, @@ -185,7 +254,8 @@ class LedStatusView( override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val measuredWidth = resolveSize(suggestedMinimumWidth, widthMeasureSpec) - val desiredHeight = paddingTop + contentHeightPx + paddingBottom + val curveHeight = cornerRadiiPx?.let { maxOf(it.first, it.second) } ?: 0 + val desiredHeight = paddingTop + maxOf(contentHeightPx, curveHeight) + paddingBottom val measuredHeight = resolveSize(desiredHeight, heightMeasureSpec) val contentWidth = (measuredWidth - paddingLeft - paddingRight).coerceAtLeast(0) val availableHeight = (measuredHeight - paddingTop - paddingBottom).coerceAtLeast(0) @@ -193,6 +263,13 @@ class LedStatusView( for (index in 0 until childCount) { val child = getChildAt(index) val segment = segments[index] + if (cornerRadiiPx != null) { + child.measure( + MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY) + ) + continue + } child.measure( MeasureSpec.makeMeasureSpec( (contentWidth * segment.width).roundToInt().coerceAtLeast(1), @@ -213,6 +290,10 @@ class LedStatusView( for (index in 0 until childCount) { val child = getChildAt(index) val segment = segments[index] + if (cornerRadiiPx != null) { + child.layout(0, 0, right - left, bottom - top) + continue + } val childLeft = paddingLeft + (contentWidth * segment.x).roundToInt() val childTop = paddingTop + (availableHeight * segment.y).roundToInt() child.layout( diff --git a/app/src/main/java/it/palsoftware/pastiera/update/UpdateChecker.kt b/app/src/main/java/it/palsoftware/pastiera/update/UpdateChecker.kt index 8b7719524..a4c4d86a1 100644 --- a/app/src/main/java/it/palsoftware/pastiera/update/UpdateChecker.kt +++ b/app/src/main/java/it/palsoftware/pastiera/update/UpdateChecker.kt @@ -70,7 +70,7 @@ fun checkForUpdate( val normalizedLatest = normalizeReleaseVersion(latestVersion) val normalizedCurrent = normalizeReleaseVersion(currentVersion) - val hasUpdate = normalizedLatest != normalizedCurrent + val hasUpdate = isReleaseVersionNewer(normalizedLatest, normalizedCurrent) // If ignoring dismissed releases, check if this release was dismissed if (hasUpdate && ignoreDismissedReleases) { diff --git a/app/src/main/java/it/palsoftware/pastiera/update/UpdateReleaseResolver.kt b/app/src/main/java/it/palsoftware/pastiera/update/UpdateReleaseResolver.kt index 7ac35d030..bdc68baa7 100644 --- a/app/src/main/java/it/palsoftware/pastiera/update/UpdateReleaseResolver.kt +++ b/app/src/main/java/it/palsoftware/pastiera/update/UpdateReleaseResolver.kt @@ -49,3 +49,52 @@ internal fun findApkDownloadUrl(assets: List): String? = internal fun normalizeReleaseVersion(version: String): String = version.removePrefix("nightly/").removePrefix("v").removePrefix("V") + +internal fun isReleaseVersionNewer(latestVersion: String, currentVersion: String): Boolean { + val latest = parseVersionParts(latestVersion) + val current = parseVersionParts(currentVersion) + // Builds running ahead of the newest published release must not be offered + // a "new" update, so only unparseable versions fall back to inequality. + if (latest == null || current == null) return latestVersion != currentVersion + + val coreComparison = compareNumberLists(latest.core, current.core) + if (coreComparison != 0) return coreComparison > 0 + return compareSuffixes(latest.suffix, current.suffix) > 0 +} + +private data class VersionParts(val core: List, val suffix: List) + +private fun parseVersionParts(version: String): VersionParts? { + val core = version.substringBefore('-') + val suffix = version.substringAfter('-', missingDelimiterValue = "") + val numbers = core.split('.').map { it.toIntOrNull() ?: return null } + return VersionParts(numbers, if (suffix.isEmpty()) emptyList() else suffix.split('.')) +} + +private fun compareNumberLists(left: List, right: List): Int { + for (index in 0 until maxOf(left.size, right.size)) { + val diff = left.getOrElse(index) { 0 }.compareTo(right.getOrElse(index) { 0 }) + if (diff != 0) return diff + } + return 0 +} + +private fun compareSuffixes(left: List, right: List): Int { + if (left.isEmpty() && right.isEmpty()) return 0 + // A release without a suffix is newer than a pre-release with the same core version. + if (left.isEmpty()) return 1 + if (right.isEmpty()) return -1 + for (index in 0 until maxOf(left.size, right.size)) { + val leftSegment = left.getOrNull(index) ?: return -1 + val rightSegment = right.getOrNull(index) ?: return 1 + val leftNumber = leftSegment.toIntOrNull() + val rightNumber = rightSegment.toIntOrNull() + val diff = if (leftNumber != null && rightNumber != null) { + leftNumber.compareTo(rightNumber) + } else { + leftSegment.compareTo(rightSegment) + } + if (diff != 0) return diff + } + return 0 +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b56b1e716..68ad50ecb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1558,7 +1558,7 @@ Each key can be configured as Keycode, Action, Native Ctrl, or None. Use overrid Suggestions Device-specific interface Titan 2 Elite rounded corners - Keep Pastierina and the extended status bar controls inside the Elite’s rounded display corners. + Round the bottom corners of the status bar to match the Elite’s rounded display corners. Left Slot Right Slot 1 Right Slot 2 diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt new file mode 100644 index 000000000..29b6b8afe --- /dev/null +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt @@ -0,0 +1,76 @@ +package it.palsoftware.pastiera.inputmethod + +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class NestedStatusBarGeometryTest { + @Test + fun statusRowNestsInsideIndicatorsAndRestoresWhenDisabledOrExpanded() { + val context = RuntimeEnvironment.getApplication() + val chrome = StatusBarController.ImeChromeLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + val row = FrameLayout(context) + val side = LinearLayout(context).apply { + gravity = android.view.Gravity.CENTER_VERTICAL + } + val buttonWrapper = FrameLayout(context) + val button = android.widget.ImageView(context) + buttonWrapper.addView(button, FrameLayout.LayoutParams(112, 74)) + side.addView(buttonWrapper, LinearLayout.LayoutParams(112, 74)) + row.addView(side, FrameLayout.LayoutParams(112, 74, android.view.Gravity.END or android.view.Gravity.CENTER_VERTICAL)) + val surface = View(context) + val expanded = View(context).apply { visibility = View.GONE } + chrome.addView(row, LinearLayout.LayoutParams(-1, 74)) + row.minimumHeight = 74 + chrome.addView(surface, LinearLayout.LayoutParams(-1, 101)) + chrome.surfaceView = surface + chrome.indicatorView = surface + chrome.expandedSurfaceView = expanded + chrome.bottomCornerRadiiPx = 100 to 100 + fun measure() { + chrome.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + chrome.layout(0, 0, chrome.measuredWidth, chrome.measuredHeight) + } + measure() + assertTrue(row.clipToOutline) + assertTrue(row.left > 0) + assertEquals("The status content must start at the top of the bar", 0, row.top) + assertEquals(0, side.top) + assertEquals(row.height, side.height) + assertEquals(0, buttonWrapper.top) + assertEquals(row.height, button.height) + assertTrue(row.bottom > surface.top) + assertEquals((3.1f * context.resources.displayMetrics.density).toInt(), surface.bottom - row.bottom) + val nestedHeight = chrome.measuredHeight + measure() + assertEquals(nestedHeight, chrome.measuredHeight) + + expanded.visibility = View.VISIBLE + chrome.requestLayout() + measure() + assertEquals(0, row.left) + assertEquals(row.bottom, surface.top) + assertTrue(!row.clipToOutline) + assertEquals(74, row.minimumHeight) + + expanded.visibility = View.GONE + chrome.bottomCornerRadiiPx = null + measure() + assertEquals(0, row.left) + assertEquals(row.bottom, surface.top) + } +} diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/Titan2EliteBottomRowSafeAreaTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/Titan2EliteBottomRowSafeAreaTest.kt deleted file mode 100644 index 204e9e14c..000000000 --- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/Titan2EliteBottomRowSafeAreaTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package it.palsoftware.pastiera.inputmethod - -import org.junit.Assert.assertEquals -import org.junit.Test - -class Titan2EliteBottomRowSafeAreaTest { - @Test - fun disabled_hasNoHorizontalInsets() { - assertEquals( - Titan2EliteBottomRowSafeArea.Insets(0, 0), - Titan2EliteBottomRowSafeArea.resolveInsetsPx( - enabled = false, - leftCornerRadiusPx = 90, - rightCornerRadiusPx = 72, - fallbackCornerRadiusPx = 60 - ) - ) - } - - @Test - fun enabled_usesOneThirdOfReportedCornerRadiiForBottomRow() { - assertEquals( - Titan2EliteBottomRowSafeArea.Insets(30, 24), - Titan2EliteBottomRowSafeArea.resolveInsetsPx( - enabled = true, - leftCornerRadiusPx = 90, - rightCornerRadiusPx = 72, - fallbackCornerRadiusPx = 60 - ) - ) - } - - @Test - fun enabled_fallsBackPerMissingSide() { - assertEquals( - Titan2EliteBottomRowSafeArea.Insets(20, 24), - Titan2EliteBottomRowSafeArea.resolveInsetsPx( - enabled = true, - leftCornerRadiusPx = 0, - rightCornerRadiusPx = 72, - fallbackCornerRadiusPx = 60 - ) - ) - } -} diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusViewTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusViewTest.kt index 097319f5e..2df8e400f 100644 --- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusViewTest.kt +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusViewTest.kt @@ -1,6 +1,8 @@ package it.palsoftware.pastiera.inputmethod.ui import android.graphics.Color +import android.graphics.Bitmap +import android.graphics.Canvas import android.view.View import it.palsoftware.pastiera.R import it.palsoftware.pastiera.inputmethod.StatusBarController @@ -11,10 +13,44 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) class LedStatusViewTest { + @Test + @GraphicsMode(GraphicsMode.Mode.NATIVE) + fun roundedIndicatorsBendUpBothSidesAndToggleBackToFlat() { + val leds = LedStatusView(RuntimeEnvironment.getApplication()).apply { + layout = ModifierLedLayouts.TITAN_2_ELITE + bottomCornerRadiiPx = 100 to 100 + } + val view = leds.ensureView() + fun measureAndLayout() { + view.measure( + View.MeasureSpec.makeMeasureSpec(1_000, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(200, View.MeasureSpec.AT_MOST) + ) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + } + measureAndLayout() + assertTrue(view.height >= 100) + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + fun hasPaint(left: Int, right: Int, top: Int, bottom: Int): Boolean = + (left until right).any { x -> + (top until bottom).any { y -> Color.alpha(bitmap.getPixel(x, y)) > 0 } + } + assertTrue("Left indicators must rise along the corner", hasPaint(25, 40, 60, 80)) + assertTrue("Right indicators must rise along the corner", hasPaint(960, 975, 60, 80)) + assertTrue("The glass corner must stay clear", !hasPaint(0, 10, 90, 100)) + + leds.bottomCornerRadiiPx = null + measureAndLayout() + assertTrue(view.height < 100) + assertEquals(164, view.getChildAt(0).width) + } + @Test fun titan2EliteLayoutProjectsFiveSegmentsOntoTwoRows() { val ledStatusView = LedStatusView(RuntimeEnvironment.getApplication()).apply { diff --git a/app/src/test/java/it/palsoftware/pastiera/update/UpdateCheckerFlavorLogicTest.kt b/app/src/test/java/it/palsoftware/pastiera/update/UpdateCheckerFlavorLogicTest.kt index d80c992c9..e9301ff66 100644 --- a/app/src/test/java/it/palsoftware/pastiera/update/UpdateCheckerFlavorLogicTest.kt +++ b/app/src/test/java/it/palsoftware/pastiera/update/UpdateCheckerFlavorLogicTest.kt @@ -1,7 +1,9 @@ package it.palsoftware.pastiera.update import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class UpdateCheckerFlavorLogicTest { @@ -48,6 +50,48 @@ class UpdateCheckerFlavorLogicTest { assertEquals("0.85-nightly.20260306.214144", normalizeReleaseVersion("nightly/v0.85-nightly.20260306.214144")) } + @Test + fun newerReleaseTriggersUpdate() { + assertTrue(isReleaseVersionNewer("0.86", "0.85")) + assertTrue(isReleaseVersionNewer("1.0", "0.99")) + assertTrue( + isReleaseVersionNewer( + "0.86-nightly.20260820.222455", + "0.86-nightly.20260811.214801" + ) + ) + } + + @Test + fun sameOrOlderReleaseDoesNotTriggerUpdate() { + assertFalse(isReleaseVersionNewer("0.85", "0.85")) + assertFalse(isReleaseVersionNewer("0.85", "0.86")) + assertFalse( + isReleaseVersionNewer( + "0.86-nightly.20260811.214801", + "0.86-nightly.20260820.222455" + ) + ) + assertFalse( + isReleaseVersionNewer( + "0.86-nightly.20260811.214801", + "0.86-nightly.20260811.214801" + ) + ) + } + + @Test + fun releaseWithoutSuffixIsNewerThanPrereleaseOfSameCore() { + assertTrue(isReleaseVersionNewer("0.86", "0.86-nightly.20260820.222455")) + assertFalse(isReleaseVersionNewer("0.86-nightly.20260820.222455", "0.86")) + } + + @Test + fun unparseableVersionsFallBackToInequality() { + assertTrue(isReleaseVersionNewer("abc", "def")) + assertFalse(isReleaseVersionNewer("abc", "abc")) + } + private fun sampleReleases(): List = listOf( GitHubRelease( diff --git a/docs/device-archives/unihertz-titan2elite/README.md b/docs/device-archives/unihertz-titan2elite/README.md index a1e4edf86..fbc1d6275 100644 --- a/docs/device-archives/unihertz-titan2elite/README.md +++ b/docs/device-archives/unihertz-titan2elite/README.md @@ -6,7 +6,7 @@ Dieses Verzeichnis sammelt Referenzmaterial zum Unihertz Titan 2 Elite. Solange Laut Hersteller verwendet das Gerät ein 4,03-Zoll-AMOLED mit 1080 × 1200 Pixeln und 401 ppi. Die Referenzfotos zeigen stark gerundete untere Displayecken. Der sichtbare Bogen entspricht in der normalisierten 1080 × 1200-Schablone näherungsweise einem Radius von 96 px. Die [SVG-Schablone](titan2elite-display-overlay.svg) markiert diesen Bogen und die im Emulator bei 400 dpi ermittelten 16-px-Führungslinien für die untere Pastiera-Zeile. -Pastiera rückt nur die untere Status-/Variationszeile ein. Die Prediction-Zeile bleibt über die volle Displaybreite sichtbar. Der horizontale Abstand beträgt ein Drittel des von Android gemeldeten unteren Eckenradius; fehlt dieser Wert, wird ein Drittel des vorläufigen 24-dp-Radius verwendet, also 8 dp. Seitliche System-Gestenbereiche zählen nicht als Displayausschnitt und vergrößern diesen Abstand nicht. +Ein final ausgeliefertes Gerät (Titan 2 Elite_V02.00.04) meldet über `WindowInsets.getRoundedCorner` an allen vier Ecken einen Radius von 100 px. Ist die Option aktiv, rundet Pastiera die unteren Ecken der gesamten IME-Chrome per Outline-Clip mit dem gemeldeten Radius ab, sodass der Hintergrund der Statusleiste dem Displaybogen folgt. Die Zeileninhalte bleiben bewusst über die volle Displaybreite und werden nicht eingerückt. Fehlt der gemeldete Radius, wird ein am Gerät abgeleiteter 50-dp-Fallback verwendet. ## Emulator-Abnahme @@ -29,6 +29,8 @@ Debug-only-Pfad zum Schreiben von Einstellungen einzuführen. ## Modifier-LEDs +Bei aktivierter Rundungsoption folgen die LED-Segmente konzentrischen Bögen entlang der unteren Displayecken. Die beiden Zeilen behalten ihre Farben und Zustände; ihre Segmentbreiten werden entlang der Kontur verteilt. Die LED-Fläche reserviert mindestens die Höhe des größeren Eckenradius, damit die äußeren Segmente sichtbar nach oben laufen können. Ohne Rundungsoption bleibt die bisherige flache Darstellung erhalten. + Die LED-Geometrie verwendet normalisierte X-/Y-Koordinaten sowie Breite und Höhe pro Segment. Das bisherige einzeilige Layout bleibt das Defaultprofil. Das Titan-2-Elite-Profil bildet dagegen zwei physische Tastenzeilen ab: Alt links und Sym rechts oben, Shift gekoppelt an beiden äußeren unteren Positionen sowie Ctrl unten rechts über From d6ed112d71d441a040b867f51ecf07502c8ae1fa Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Sat, 5 Sep 2026 15:25:39 -0400 Subject: [PATCH 2/7] Add conditional padding for Titan2 Elite rounded corners Conditionally disable vertical padding when Titan2 Elite rounded corner insets are enabled. Also optimize by only calling setPadding when values actually change, setting geometryChanged flag accordingly. --- .../inputmethod/ui/VariationBarView.kt | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt index 5e7d8f4ce..43aeffff2 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt @@ -131,6 +131,8 @@ class VariationBarView( private var lastIsStaticContent: Boolean? = null private var lastVariationAreaVisible: Boolean? = null private var lastThemeSignature: Int? = null + private var lastGeometrySignature: List? = null + private var lastSnapshot: StatusBarController.StatusSnapshot? = null private var pressedView: View? = null private var longPressHandler: Handler? = null private var longPressRunnable: Runnable? = null @@ -193,6 +195,15 @@ class VariationBarView( ) visibility = View.GONE setBackgroundColor(themeOverride?.background ?: Color.TRANSPARENT) + addOnLayoutChangeListener { view, left, _, right, _, oldLeft, _, oldRight, _ -> + if (right - left != oldRight - oldLeft && view.visibility == View.VISIBLE) { + view.post { + lastSnapshot?.let { snapshot -> + if (view.visibility == View.VISIBLE) showVariations(snapshot, currentInputConnection) + } + } + } + } } // Container for left fixed buttons (clipboard) @@ -256,7 +267,7 @@ class VariationBarView( ).toInt() private fun verticalPaddingPx(): Int = - TypedValue.applyDimension( + if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) 0 else TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, BASE_VERTICAL_PADDING_DP * min( (themeOverride?.variationsHeightScale ?: 1f).coerceIn(0.65f, 1.6f), @@ -278,7 +289,10 @@ class VariationBarView( view.layoutParams = params geometryChanged = true } - view.setPadding(view.paddingLeft, verticalPadding, view.paddingRight, verticalPadding) + if (view.paddingTop != verticalPadding || view.paddingBottom != verticalPadding) { + view.setPadding(view.paddingLeft, verticalPadding, view.paddingRight, verticalPadding) + geometryChanged = true + } } wrapper?.let { view -> val params = (view.layoutParams as? LinearLayout.LayoutParams) @@ -375,6 +389,8 @@ class VariationBarView( } fun showVariations(snapshot: StatusBarController.StatusSnapshot, inputConnection: android.view.inputmethod.InputConnection?) { + lastSnapshot = snapshot + applyHeight() isTitan2Layout = SettingsManager.isTitan2LayoutEnabled(context) val containerView = container ?: return val wrapperView = wrapper ?: return @@ -472,6 +488,16 @@ class VariationBarView( val variationAreaVisibilityChanged = lastVariationAreaVisible != variationAreaVisible val themeSignature = themeOverride.signature() val themeChanged = lastThemeSignature != themeSignature + val roundedCorners = SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) + val fallbackWidth = context.resources.displayMetrics.widthPixels - + if (roundedCorners) 2 * dpToPx(6.5f) else 0 + val availableWidth = ((containerView.width.takeIf { it > 0 } ?: fallbackWidth) - + containerView.paddingLeft - containerView.paddingRight).coerceAtLeast(1) + val geometrySignature = listOf( + availableWidth, verticalPaddingPx(), roundedCorners, + SettingsManager.getDynamicVariationBarSlotCount(context), + SettingsManager.getDynamicVariationBarResizeToContent(context) + ) val hasExistingRow = currentVariationsRow != null && currentVariationsRow?.parent == containerView && currentVariationsRow?.visibility == View.VISIBLE @@ -481,6 +507,7 @@ class VariationBarView( !contentModeChanged && !variationAreaVisibilityChanged && !themeChanged && + lastGeometrySignature == geometrySignature && (hasExistingRow || !variationAreaVisible) ) { currentVariationsRow?.let { row -> @@ -503,11 +530,6 @@ class VariationBarView( } currentVariationsRow = null - val screenWidth = context.resources.displayMetrics.widthPixels - val leftPadding = containerView.paddingLeft - val rightPadding = containerView.paddingRight - val availableWidth = screenWidth - leftPadding - rightPadding - val spacingBetweenButtons = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 3f, @@ -610,6 +632,7 @@ class VariationBarView( lastIsStaticContent = isStaticContent lastVariationAreaVisible = variationAreaVisible lastThemeSignature = themeSignature + lastGeometrySignature = geometrySignature // Create a single callbacks object with all available callbacks. // Each button factory will extract only the callbacks it needs. From 74ec498fe2cc35f477e34e394de37aa13bd5d874 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Sat, 5 Sep 2026 15:39:37 -0400 Subject: [PATCH 3/7] Add gapless chrome mode to remove UI spacing and borders Conditionally removes corner radii, borders, padding, and spacing from status bar buttons, suggestion bar, and variation bar when the Titan2Elite rounded corner insets setting is enabled. This creates a seamless, gapless appearance across UI chrome elements. --- .../inputmethod/StatusBarController.kt | 46 +++++++++++++++++-- .../statusbar/StatusBarButtonHost.kt | 6 ++- .../suggestions/ui/FullSuggestionsBar.kt | 26 +++++++---- .../pastiera/inputmethod/ui/LedStatusView.kt | 24 ++++++++-- .../inputmethod/ui/VariationBarView.kt | 15 +++--- .../inputmethod/CornerButtonThemeTest.kt | 36 +++++++++++++++ .../NestedStatusBarGeometryTest.kt | 2 +- 7 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 app/src/test/java/it/palsoftware/pastiera/inputmethod/CornerButtonThemeTest.kt diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt index aa4fe4ac7..f4c714325 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt @@ -6,6 +6,8 @@ import android.animation.ValueAnimator import android.content.Context import android.content.Intent import android.graphics.Color +import android.graphics.Canvas +import android.graphics.Paint import android.graphics.Outline import android.graphics.Matrix import android.graphics.Path @@ -50,6 +52,7 @@ import it.palsoftware.pastiera.inputmethod.ui.VariationBarView import it.palsoftware.pastiera.inputmethod.ui.KeyboardThemeColors import it.palsoftware.pastiera.inputmethod.suggestions.ui.FullSuggestionsBar import it.palsoftware.pastiera.inputmethod.statusbar.StatusBarButtonRegistry +import it.palsoftware.pastiera.inputmethod.statusbar.StatusBarButtonPosition import it.palsoftware.pastiera.inputmethod.statusbar.StatusBarCallbacks import it.palsoftware.pastiera.inputmethod.subtype.AdditionalSubtypeUtils import it.palsoftware.pastiera.inputmethod.subtype.AdditionalSubtypeUtils.languageCode @@ -440,8 +443,24 @@ class StatusBarController( private fun applyKeyboardThemeOverrides(activeColors: KeyboardThemeColors) { statusBarLayout?.setBackgroundColor(activeColors.background) - symSurfaceStack?.setBackgroundColor(activeColors.background) - symSurfaceContainer?.setBackgroundColor(activeColors.background) + val roundedCorners = SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) + val surfaceBackground = if (roundedCorners) Color.TRANSPARENT else activeColors.background + symSurfaceStack?.setBackgroundColor(surfaceBackground) + symSurfaceContainer?.setBackgroundColor(surfaceBackground) + (statusBarLayout as? ImeChromeLayout)?.let { chrome -> + val regularButtons = buttonRegistry.getEnabledButtons(context) + val compactButtons = buttonRegistry.getEnabledPastierinaButtons(context) + chrome.regularCornerColors = Pair( + if (regularButtons.any { it.position == StatusBarButtonPosition.LEFT }) activeColors.statusBarButton else activeColors.normalKey, + if (regularButtons.any { it.position == StatusBarButtonPosition.RIGHT }) activeColors.statusBarButton else activeColors.normalKey + ) + chrome.compactCornerColors = Pair( + if (compactButtons.any { it.position == StatusBarButtonPosition.LEFT }) activeColors.statusBarButton else activeColors.suggestion, + if (compactButtons.any { it.position == StatusBarButtonPosition.RIGHT }) activeColors.statusBarButton else activeColors.suggestion + ) + chrome.bottomFillColors = activeColors.normalKey to activeColors.suggestion + chrome.invalidate() + } emojiKeyboardContainer?.setBackgroundColor(activeColors.background) variationBarView?.themeOverride = activeColors ledStatusView.themeOverride = activeColors @@ -3457,6 +3476,27 @@ class StatusBarController( internal class ImeChromeLayout(context: Context) : LinearLayout(context) { private val screenAwakeController = ImeTouchScreenAwakeController(context) + var regularCornerColors: Pair = Color.BLACK to Color.BLACK + var compactCornerColors: Pair = Color.BLACK to Color.BLACK + var bottomFillColors: Pair = Color.BLACK to Color.BLACK + private val cornerFillPaint = Paint() + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val row = nestedRow ?: return + val radii = bottomCornerRadiiPx ?: return + val colors = if (row === compactStatusRow) compactCornerColors else regularCornerColors + // Fill only the corner cutouts beside this row. Never paint across + // the suggestion/variation boundary or extend a button below its row. + cornerFillPaint.color = colors.first + canvas.drawRect(0f, row.top.toFloat(), radii.first.toFloat(), row.bottom.toFloat(), cornerFillPaint) + cornerFillPaint.color = colors.second + canvas.drawRect((width - radii.second).toFloat(), row.top.toFloat(), width.toFloat(), row.bottom.toFloat(), cornerFillPaint) + // Continue the row's themed surface to the bottom of the display. + // Children draw afterward, keeping the modifier lights above the fill. + cornerFillPaint.color = if (row === compactStatusRow) bottomFillColors.second else bottomFillColors.first + canvas.drawRect(0f, row.bottom.toFloat(), width.toFloat(), height.toFloat(), cornerFillPaint) + } var indicatorView: View? = null var expandedSurfaceView: View? = null var compactStatusRow: View? = null @@ -3561,7 +3601,7 @@ class StatusBarController( val child = view.getChildAt(index) if (child.visibility == View.VISIBLE && child.height >= originalContentHeight - 4f * resources.displayMetrics.density && - child.height < view.height + child.height <= view.height ) extendContent(child) } } diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/StatusBarButtonHost.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/StatusBarButtonHost.kt index d2517a27d..816713fce 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/StatusBarButtonHost.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/StatusBarButtonHost.kt @@ -199,18 +199,20 @@ class StatusBarButtonHost( } } val theme = themeOverride ?: return + (view.getTag(it.palsoftware.pastiera.R.id.tag_badge_view) as? TextView)?.setTextColor(theme.iconColor) val height = view.layoutParams?.height?.takeIf { it > 0 } ?: view.height.takeIf { it > 0 } ?: fallbackHeight?.takeIf { it > 0 } if (height != null) { val active = state is ButtonState.MinimalUiState && state.isActive + val gapless = it.palsoftware.pastiera.SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) view.background = StatusBarButtonStyles.createButtonDrawable( heightPx = height, normalColor = if (active) theme.pressedColor else theme.normalColor, pressedColor = theme.pressedColor, - cornerRadiusRatio = theme.cornerRadiusRatio, + cornerRadiusRatio = if (gapless) 0f else theme.cornerRadiusRatio, borderColor = theme.borderColor, - borderWidthPx = theme.borderWidthPx + borderWidthPx = if (gapless) 0 else theme.borderWidthPx ) } when (view) { diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt index bf5dcf16d..0a366c931 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt @@ -474,6 +474,11 @@ class FullSuggestionsBar( } } + private fun gaplessChrome(): Boolean = + it.palsoftware.pastiera.SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) + + private fun chromeSpacingPx(): Int = if (gaplessChrome()) 0 else dpToPx(3f) + private fun minimalButtonWidthPx(): Int { val size = (targetHeightPx - dpToPx(4f)).coerceAtLeast(dpToPx(24f)) return if (it.palsoftware.pastiera.SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) { @@ -501,7 +506,7 @@ class FullSuggestionsBar( val buttonHeight = if (it.palsoftware.pastiera.SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) { targetHeightPx } else buttonSize - val spacing = dpToPx(3f) + val spacing = chromeSpacingPx() val callbacks = (callbacksProvider?.invoke() ?: StatusBarCallbacks()) .copy(onHamburgerMenuRequested = { toggleHamburgerMenu() }) @@ -541,7 +546,7 @@ class FullSuggestionsBar( private fun applyContainerInsetsForMinimalButtons() { val bar = container ?: return - val spacing = dpToPx(3f) + val spacing = chromeSpacingPx() val indicatorInset = modifierIndicatorsContainer?.takeIf { showModifierMenuIndicators && it.visibility == View.VISIBLE }?.let { @@ -668,7 +673,7 @@ class FullSuggestionsBar( ).apply { // Apply margin only if not the last suggestion box if (index < slotOrder.size - 1) { - marginEnd = dpToPx(3f) + marginEnd = chromeSpacingPx() } } if (suggestion != null && actionCandidate?.equals(suggestion, ignoreCase = true) == true) { @@ -804,7 +809,8 @@ class FullSuggestionsBar( gravity = Gravity.CENTER layoutParams = weightLayoutParams background = buildSuggestionBackground() - setPadding(dpToPx(4f), dpToPx(4f), dpToPx(4f), dpToPx(4f)) + val padding = if (gaplessChrome()) 0 else dpToPx(4f) + setPadding(padding, padding, padding, padding) actions.forEachIndexed { index, action -> val button = ImageView(context).apply { @@ -819,7 +825,7 @@ class FullSuggestionsBar( ViewGroup.LayoutParams.MATCH_PARENT, 1f ).apply { - if (index < actions.lastIndex) marginEnd = dpToPx(3f) + if (index < actions.lastIndex) marginEnd = chromeSpacingPx() } isClickable = true isFocusable = true @@ -840,12 +846,12 @@ class FullSuggestionsBar( private fun buildActionBackground(color: Int): StateListDrawable { val normal = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE - cornerRadius = dpToPx(7f).toFloat() + cornerRadius = if (gaplessChrome()) 0f else dpToPx(7f).toFloat() setColor(color) } val pressed = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE - cornerRadius = dpToPx(7f).toFloat() + cornerRadius = if (gaplessChrome()) 0f else dpToPx(7f).toFloat() setColor(themeOverride?.accent ?: PRESSED_BLUE) } return StateListDrawable().apply { @@ -949,18 +955,18 @@ class FullSuggestionsBar( } private fun buildSuggestionBackground(): StateListDrawable { - val radiusRatio = themeOverride?.chromeCornerRadiusRatio ?: 0f + val radiusRatio = if (gaplessChrome()) 0f else themeOverride?.chromeCornerRadiusRatio ?: 0f val radius = (targetHeightPx * radiusRatio).coerceAtLeast(0f) val normalDrawable = GradientDrawable().apply { setColor(themeOverride?.suggestion ?: DEFAULT_SUGGESTION_COLOR) cornerRadius = radius alpha = 255 // placeholders look identical; they stay non-clickable - themeOverride?.let { setStroke(dpToPx(1f), it.divider) } + if (!gaplessChrome()) themeOverride?.let { setStroke(dpToPx(1f), it.divider) } } val pressedDrawable = GradientDrawable().apply { setColor(themeOverride?.accent ?: PRESSED_BLUE) cornerRadius = radius - themeOverride?.let { setStroke(dpToPx(1f), it.divider) } + if (!gaplessChrome()) themeOverride?.let { setStroke(dpToPx(1f), it.divider) } } return StateListDrawable().apply { addState(intArrayOf(android.R.attr.state_pressed), pressedDrawable) diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt index 748694138..46203bd38 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt @@ -53,6 +53,7 @@ class LedStatusView( private var container: ModifierLedCanvas? = null private val ledsByState = mutableMapOf>() private val segmentsByView = mutableMapOf() + private val inactiveUpperStates = mutableSetOf(ModifierLedState.ALT, ModifierLedState.SYM) var bottomCornerRadiiPx: Pair? = null set(value) { @@ -118,7 +119,7 @@ class LedStatusView( ledsByState.clear() segmentsByView.clear() canvas.replaceSegments(layout.segments) { segment -> - createLedView(LED_COLOR_GRAY_OFF, segment).also { led -> + createLedView(themeOverride?.ledInactive ?: LED_COLOR_GRAY_OFF, segment).also { led -> ledsByState.getOrPut(segment.state) { mutableListOf() }.add(led) } } @@ -146,6 +147,11 @@ class LedStatusView( super.draw(canvas) return } + // Alt/Sym share the outer positions with Shift. Show the upper + // contour only when it carries an active modifier state. + if (layout == ModifierLedLayouts.TITAN_2_ELITE && segment.y == 0f && + segment.state in inactiveUpperStates + ) return val width = bounds.width().toFloat() val height = bounds.height().toFloat() // Each row follows a concentric contour, so the indicator retains @@ -169,10 +175,17 @@ class LedStatusView( } val measure = PathMeasure(contour, false) paint.strokeWidth = segment.height * ledHeight + val joinRightIndicators = layout == ModifierLedLayouts.TITAN_2_ELITE && + (segment.state == ModifierLedState.CTRL || + (segment.state == ModifierLedState.SHIFT && segment.x > 0.5f)) + paint.strokeCap = if (joinRightIndicators) Paint.Cap.BUTT else Paint.Cap.ROUND // Leave room for the round caps at both ends of each segment. - val cap = paint.strokeWidth / 2f + val cap = if (joinRightIndicators) 0f else paint.strokeWidth / 2f val start = measure.length * segment.x + cap - val end = measure.length * (segment.x + segment.width) - cap + val endFraction = if (joinRightIndicators && segment.state == ModifierLedState.CTRL) { + layout.segments.first { it.state == ModifierLedState.SHIFT && it.x > segment.x }.x + } else segment.x + segment.width + val end = measure.length * endFraction - cap if (end > start) { val stroke = Path() measure.getSegment(start, end, stroke, true) @@ -187,6 +200,9 @@ class LedStatusView( } private fun updateLeds(state: ModifierLedState, isLocked: Boolean, isActive: Boolean = false) { + if (state == ModifierLedState.ALT) { + if (isLocked || isActive) inactiveUpperStates.remove(state) else inactiveUpperStates.add(state) + } val theme = themeOverride val targetColor = when { isLocked -> theme?.ledLocked ?: LED_COLOR_RED_LOCKED @@ -197,6 +213,8 @@ class LedStatusView( } private fun updateSymLeds(symPage: Int) { + if (symPage > 0) inactiveUpperStates.remove(ModifierLedState.SYM) + else inactiveUpperStates.add(ModifierLedState.SYM) val theme = themeOverride val targetColor = when (symPage) { 1 -> theme?.ledActive ?: LED_COLOR_BLUE_ACTIVE diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt index 43aeffff2..6812a710e 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt @@ -530,7 +530,7 @@ class VariationBarView( } currentVariationsRow = null - val spacingBetweenButtons = TypedValue.applyDimension( + val spacingBetweenButtons = if (roundedCorners) 0 else TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 3f, context.resources.displayMetrics @@ -1129,11 +1129,14 @@ class VariationBarView( heightPx = buttonHeight, normalColor = it.normalKey, pressedColor = it.accent, - cornerRadiusRatio = it.chromeCornerRadiusRatio, + cornerRadiusRatio = if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) 0f else it.chromeCornerRadiusRatio, borderColor = it.divider, - borderWidthPx = dpToPx(1f) + borderWidthPx = if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) 0 else dpToPx(1f) ) - } ?: VariationButtonStyles.createButtonDrawable(buttonHeight) + } ?: VariationButtonStyles.createButtonDrawable( + buttonHeight, + cornerRadiusRatio = if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) 0f else VariationButtonStyles.BUTTON_CORNER_RADIUS_RATIO + ) return TextView(context).apply { text = variation @@ -1197,14 +1200,14 @@ class VariationBarView( } private fun createPlaceholderButton(buttonWidth: Int, buttonHeight: Int): View { - val dp3 = TypedValue.applyDimension( + val dp3 = if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) 0 else TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 3f, context.resources.displayMetrics ).toInt() val drawable = GradientDrawable().apply { setColor(Color.TRANSPARENT) - cornerRadius = VariationButtonStyles.cornerRadiusForSize( + cornerRadius = if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) 0f else VariationButtonStyles.cornerRadiusForSize( buttonHeight, themeOverride?.chromeCornerRadiusRatio ) diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/CornerButtonThemeTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/CornerButtonThemeTest.kt new file mode 100644 index 000000000..67dc23693 --- /dev/null +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/CornerButtonThemeTest.kt @@ -0,0 +1,36 @@ +package it.palsoftware.pastiera.inputmethod + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.widget.TextView +import it.palsoftware.pastiera.R +import it.palsoftware.pastiera.SettingsManager +import it.palsoftware.pastiera.inputmethod.statusbar.* +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class CornerButtonThemeTest { + @Test + fun gaplessClipboardUsesAndRefreshesThemeSurfaceAndBadgeColors() { + val context = RuntimeEnvironment.getApplication() + SettingsManager.setTitan2EliteRoundedCornerInsetsEnabled(context, true) + val host = StatusBarButtonHost(context, StatusBarButtonRegistry()) + host.themeOverride = StatusBarButtonStyles.ThemeOverride(Color.YELLOW, Color.MAGENTA, Color.BLACK) + val hosted = requireNotNull(host.getOrCreateButton(StatusBarButtonId.Clipboard, 40, StatusBarCallbacks(), 80, 40)) + val badge = hosted.button.getTag(R.id.tag_badge_view) as TextView + assertEquals(Color.BLACK, badge.currentTextColor) + val background = hosted.button.background.current as GradientDrawable + assertEquals(Color.YELLOW, background.color!!.defaultColor) + assertEquals(0f, background.cornerRadius) + + host.themeOverride = StatusBarButtonStyles.ThemeOverride(Color.BLUE, Color.CYAN, Color.WHITE) + assertEquals(Color.WHITE, badge.currentTextColor) + assertEquals(Color.BLUE, (hosted.button.background.current as GradientDrawable).color!!.defaultColor) + } +} diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt index 29b6b8afe..daa5504d0 100644 --- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt @@ -28,7 +28,7 @@ class NestedStatusBarGeometryTest { val button = android.widget.ImageView(context) buttonWrapper.addView(button, FrameLayout.LayoutParams(112, 74)) side.addView(buttonWrapper, LinearLayout.LayoutParams(112, 74)) - row.addView(side, FrameLayout.LayoutParams(112, 74, android.view.Gravity.END or android.view.Gravity.CENTER_VERTICAL)) + row.addView(side, FrameLayout.LayoutParams(112, -1, android.view.Gravity.END or android.view.Gravity.CENTER_VERTICAL)) val surface = View(context) val expanded = View(context).apply { visibility = View.GONE } chrome.addView(row, LinearLayout.LayoutParams(-1, 74)) From 747992c1c4d4c681b253e0884355ca6bd61c9c7c Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Sat, 5 Sep 2026 15:41:36 -0400 Subject: [PATCH 4/7] Fix status bar indicator layout for tall rows Adjust indicator view height calculation to account for requested row height and add bottom inset. Extend side light paths to start from the top of the row when the row is taller than the minimum required height. --- .../palsoftware/pastiera/inputmethod/StatusBarController.kt | 6 +++++- .../it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt | 4 +++- .../pastiera/inputmethod/NestedStatusBarGeometryTest.kt | 4 ++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt index f4c714325..943a100c6 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt @@ -3508,6 +3508,7 @@ class StatusBarController( private val originalIconTransforms = mutableMapOf>() private fun updateNestedStatusRow() { + indicatorView?.layoutParams?.height = ViewGroup.LayoutParams.WRAP_CONTENT nestedRow?.let { row -> (row.layoutParams as LayoutParams).apply { leftMargin = originalRowMargins[0] @@ -3539,7 +3540,10 @@ class StatusBarController( params.leftMargin += inset params.rightMargin += inset // The LED surface draws first; overlap its empty center with the row. - val overlap = (radius + stripTop - inset).coerceAtLeast(0) + val requestedRowHeight = params.height.coerceAtLeast(0) + val bottomInset = (3.1f * resources.displayMetrics.density).toInt() + indicatorView?.layoutParams?.height = maxOf(radius + stripTop, requestedRowHeight + bottomInset) + val overlap = maxOf((radius + stripTop - inset).coerceAtLeast(0), requestedRowHeight) params.bottomMargin -= overlap row.minimumHeight = maxOf(originalRowMinHeight, overlap) row.outlineProvider = object : ViewOutlineProvider() { diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt index 46203bd38..a63bef0ce 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt @@ -162,7 +162,8 @@ class LedStatusView( val leftArc = leftRadius - inset val rightArc = rightRadius - inset val contour = Path().apply { - moveTo(inset, height - leftRadius) + moveTo(inset, 0f) + lineTo(inset, height - leftRadius) if (leftArc > 0f) { arcTo(inset, height - leftRadius - leftArc, leftRadius + leftArc, height - inset, 180f, -90f, false) @@ -172,6 +173,7 @@ class LedStatusView( arcTo(width - rightRadius - rightArc, height - rightRadius - rightArc, width - inset, height - inset, 90f, -90f, false) } + lineTo(width - inset, 0f) } val measure = PathMeasure(contour, false) paint.strokeWidth = segment.height * ledHeight diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt index daa5504d0..3cbec2d0b 100644 --- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt @@ -59,6 +59,10 @@ class NestedStatusBarGeometryTest { measure() assertEquals(nestedHeight, chrome.measuredHeight) + row.layoutParams = (row.layoutParams as LinearLayout.LayoutParams).apply { height = 140 } + measure() + assertEquals("Side lights must begin at the top of a tall row", row.top, surface.top) + expanded.visibility = View.VISIBLE chrome.requestLayout() measure() From 7604665eb4ec89294167b199e7f397705406c86f Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Sat, 5 Sep 2026 16:25:12 -0400 Subject: [PATCH 5/7] Add rounded corner insets support to status bar Conditionally adjust padding, spacing, margins, and styling of status bar elements based on rounded corner insets setting. Remove padding and borders when rounded corners are enabled, and adjust close button dimensions and positioning accordingly. --- .../inputmethod/StatusBarController.kt | 206 ++++++++++++++++-- .../inputmethod/ui/ClipboardHistoryView.kt | 5 + .../inputmethod/ui/EmojiPickerView.kt | 68 +++++- .../pastiera/inputmethod/ui/LedStatusView.kt | 41 +++- .../inputmethod/ClipboardRoundedLayoutTest.kt | 44 ++++ .../NestedStatusBarGeometryTest.kt | 28 +++ .../ui/EmojiPickerOnScreenSearchTest.kt | 39 ++++ .../inputmethod/ui/LedStatusViewTest.kt | 22 ++ 8 files changed, 426 insertions(+), 27 deletions(-) create mode 100644 app/src/test/java/it/palsoftware/pastiera/inputmethod/ClipboardRoundedLayoutTest.kt diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt index 943a100c6..b619e1df0 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt @@ -266,6 +266,7 @@ class StatusBarController( private const val TAG = "StatusBarController" private val DEFAULT_BACKGROUND = Color.parseColor("#000000") private const val TITAN_2_ELITE_CORNER_FALLBACK_RADIUS_DP = 50f + private const val HARDWARE_SYM_KEY_HEIGHT_DP = 56f } data class StatusSnapshot( @@ -459,6 +460,8 @@ class StatusBarController( if (compactButtons.any { it.position == StatusBarButtonPosition.RIGHT }) activeColors.statusBarButton else activeColors.suggestion ) chrome.bottomFillColors = activeColors.normalKey to activeColors.suggestion + chrome.expandedCloseColor = activeColors.statusBarButton + chrome.expandedKeyHeightPx = hardwareSymKeyHeightPx(activeColors) chrome.invalidate() } emojiKeyboardContainer?.setBackgroundColor(activeColors.background) @@ -790,6 +793,7 @@ class StatusBarController( indicatorView = ledStrip expandedSurfaceView = emojiKeyboardContainer compactStatusRow = fullSuggestionsBar?.ensureView() + expandedCloseButton = symSurfaceCloseButton } applyChromeZOrder() applyAccessibilitySecondRowReadPreference() @@ -1157,6 +1161,9 @@ class StatusBarController( } } view.configureSoftwareKeyboardMode(softwareKeyboardHeight) + view.configureRoundedLayout( + SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) + ) view.setInputConnection(inputConnection) // Refresh only when needed (data changed), otherwise keep the list stable. @@ -1235,6 +1242,13 @@ class StatusBarController( onKeyboardLayoutRequested = null ) } + val roundedControls = SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) && !pickerShownAboveSoftwareKeyboard + val colors = activeThemeColors() + val iconSize = if (pastierinaModeActive) { + (dpToPx(36f * colors.suggestionsHeightScale.coerceIn(0.65f, 1.6f)) - dpToPx(4f)) * 0.64f + } else minOf(dpToPx(24f).toFloat(), hardwareSymKeyHeightPx(colors) * 0.48f) + view.configureRoundedControls(roundedControls, hardwareSymKeyHeightPx(colors), iconSize) + (statusBarLayout as? ImeChromeLayout)?.expandedPickerButtons = if (roundedControls) view.edgeControls else null view.setInputConnection(inputConnection) // Only scroll to top when view is just added (first open or switching pages) @@ -1334,7 +1348,9 @@ class StatusBarController( private fun updateEmojiKeyboard(symMappings: Map, page: Int, inputConnection: android.view.inputmethod.InputConnection? = null) { val container = emojiKeyboardContainer ?: return // Restore default padding for emoji/symbols pages. - container.setPadding(emojiKeyboardHorizontalPaddingPx, 0, emojiKeyboardHorizontalPaddingPx, 0) + val roundedCorners = SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) + val sidePadding = if (roundedCorners) 0 else emojiKeyboardHorizontalPaddingPx + container.setPadding(sidePadding, 0, sidePadding, 0) val inputConnectionChanged = lastInputConnectionUsed != inputConnection val inputConnectionBecameAvailable = lastInputConnectionUsed == null && inputConnection != null if (lastSymPageRendered == page && lastSymMappingsRendered == symMappings && !inputConnectionChanged && !inputConnectionBecameAvailable) { @@ -1371,7 +1387,7 @@ class StatusBarController( android.view.KeyEvent.KEYCODE_N to "N", android.view.KeyEvent.KEYCODE_M to "M" ) - val keySpacing = TypedValue.applyDimension( + val keySpacing = if (roundedCorners) 0 else TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 4f, context.resources.displayMetrics @@ -1380,7 +1396,7 @@ class StatusBarController( // Calcola la larghezza fissa dei tasti basata sulla prima riga (10 caselle) val maxKeysInRow = 10 // Prima riga ha 10 caselle val screenWidth = context.resources.displayMetrics.widthPixels - val horizontalPadding = TypedValue.applyDimension( + val horizontalPadding = if (roundedCorners) 0 else TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 8f * 2, // padding sinistro + destro context.resources.displayMetrics @@ -1389,11 +1405,7 @@ class StatusBarController( val totalSpacing = keySpacing * (maxKeysInRow - 1) val fixedKeyWidth = (availableWidth - totalSpacing) / maxKeysInRow - val keyHeight = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - 56f, - context.resources.displayMetrics - ).toInt() + val keyHeight = hardwareSymKeyHeightPx() // Crea ogni riga della tastiera for ((rowIndex, row) in keyboardRows.withIndex()) { @@ -2337,15 +2349,17 @@ class StatusBarController( ) val drawable = GradientDrawable().apply { setColor(theme.normalKey) - setCornerRadius(cornerRadius) - setStroke(dpToPx(1f), theme.divider) + val roundedCorners = SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) + setCornerRadius(if (roundedCorners) 0f else cornerRadius) + if (!roundedCorners) setStroke(dpToPx(1f), theme.divider) } keyLayout.background = drawable // Emoji/carattere deve occupare tutto il tasto, centrata // Calcola textSize in base all'altezza disponibile (convertendo da pixel a sp) val heightInDp = height / context.resources.displayMetrics.density - val contentTextSize = if (page == 2) { + val roundedCorners = SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) + val contentTextSize = if (page == 2 || roundedCorners) { // Per caratteri unicode, usa una dimensione più piccola (heightInDp * 0.5f) } else { @@ -2357,6 +2371,7 @@ class StatusBarController( text = content textSize = contentTextSize // textSize è in sp gravity = Gravity.CENTER + if (roundedCorners) setTextColor(theme.textAndIcons) // Per pagina 2 (caratteri), rendi bianco e in grassetto if (page == 2) { setTextColor(theme.textAndIcons) @@ -2532,6 +2547,65 @@ class StatusBarController( (symSurfaceCloseButton as? ImageView)?.apply { setColorFilter(theme.textAndIcons) background = createCloseButtonBackground(theme) + val rounded = SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context) + (layoutParams as? FrameLayout.LayoutParams)?.let { params -> + val screenWidth = statusBarLayout?.width?.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels + val rightInset = if (rounded) dpToPx(3.1f) else 0 + val targetWidth = if (rounded) screenWidth - (screenWidth / 10) * 9 - rightInset else dpToPx(36f) + val targetHeight = if (rounded) hardwareSymKeyHeightPx(theme) else dpToPx(32f) + if (params.width != targetWidth || params.height != targetHeight || params.rightMargin != rightInset) { + params.width = targetWidth + params.height = targetHeight + params.rightMargin = rightInset + layoutParams = params + } + } + setPadding(dpToPx(4f), dpToPx(4f), dpToPx(if (rounded) 12f else 4f), dpToPx(if (rounded) 12f else 4f)) + if (rounded) { + background = ColorDrawable(theme.statusBarButton) + drawable?.let { icon -> + val clipboardSize = if (pastierinaModeActive) { + (dpToPx(36f * theme.suggestionsHeightScale.coerceIn(0.65f, 1.6f)) - dpToPx(4f)) * 0.64f + } else { + minOf(dpToPx(24f).toFloat(), dpToPx(55f * theme.variationsHeightScale.coerceIn(0.65f, 1.6f)) * 0.48f) + } + val scale = clipboardSize / icon.intrinsicHeight.coerceAtLeast(1) + scaleType = ImageView.ScaleType.MATRIX + imageMatrix = Matrix().apply { + setScale(scale, scale) + postTranslate( + (layoutParams.width - icon.intrinsicWidth * scale) / 2f - paddingLeft - dpToPx(4f), + (layoutParams.height - icon.intrinsicHeight * scale) / 2f - paddingTop - dpToPx(2f) + ) + } + } + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + val displayRadius = (statusBarLayout as? ImeChromeLayout)?.bottomCornerRadiiPx?.second + ?: dpToPx(TITAN_2_ELITE_CORNER_FALLBACK_RADIUS_DP) + val radius = (displayRadius - reservedExpandedLedHeight()).coerceAtLeast(0).toFloat() + val horizontalRadius = (displayRadius - dpToPx(3.1f)).coerceAtLeast(0).toFloat() + val path = Path().apply { + addRoundRect( + RectF(0f, -2f * radius, view.width.toFloat(), view.height.toFloat()), + floatArrayOf(0f, 0f, 0f, 0f, horizontalRadius, radius, 0f, 0f), + Path.Direction.CW + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) outline.setPath(path) + else { + @Suppress("DEPRECATION") + outline.setConvexPath(path) + } + } + } + clipToOutline = true + invalidateOutline() + } else { + scaleType = ImageView.ScaleType.CENTER_INSIDE + clipToOutline = false + outlineProvider = ViewOutlineProvider.BACKGROUND + } } } @@ -2855,6 +2929,13 @@ class StatusBarController( * @param backgroundView Il view dello sfondo da impostare a opaco immediatamente */ private fun animateEmojiKeyboardIn(view: View, backgroundView: View? = null) { + if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) { + view.alpha = 1f + view.translationY = 0f + view.visibility = View.VISIBLE + backgroundView?.setBackgroundColor(activeThemeColors().background) + return + } val height = view.height if (height == 0) { view.measure( @@ -2899,6 +2980,13 @@ class StatusBarController( * @param onAnimationEnd Callback chiamato quando l'animazione è completata */ private fun animateEmojiKeyboardOut(view: View, backgroundView: View? = null, onAnimationEnd: (() -> Unit)? = null) { + if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) { + view.visibility = View.GONE + view.translationY = 0f + view.alpha = 1f + onAnimationEnd?.invoke() + return + } val height = view.height if (height == 0) { view.visibility = View.GONE @@ -3056,6 +3144,7 @@ class StatusBarController( val shouldShowSoftwareKeyboard = isFullSoftwareKeyboardMode && !snapshot.clipboardOverlay + (layout as? ImeChromeLayout)?.expandedPickerButtons = null (layout as? ImeChromeLayout)?.softwareKeyboardModeActive = shouldShowSoftwareKeyboard if (snapshot.clipboardOverlay) { // Show clipboard as dedicated overlay (not part of SYM pages) @@ -3082,7 +3171,7 @@ class StatusBarController( emojiKeyboardView.setBackgroundColor(activeColors.background) emojiKeyboardView.visibility = View.VISIBLE val surfaceHeight = resolveSurfaceHeightWithOptionalLed(animationHeight, showLedStrip) - setSurfaceCloseVisible(false) + setSurfaceCloseVisible(SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) applySymSurfaceLayout(symSurfaceView, symSurfaceStackView, emojiKeyboardView, surfaceHeight, reserveLedSpace = showLedStrip) if (!symShown && !wasSymActive) { emojiKeyboardView.alpha = 1f @@ -3195,7 +3284,9 @@ class StatusBarController( emojiKeyboardView.setBackgroundColor(activeColors.background) emojiKeyboardView.visibility = View.VISIBLE applySymSurfaceLayout(symSurfaceView, symSurfaceStackView, emojiKeyboardView, surfaceHeight, reserveLedSpace = showLedStrip) - setSurfaceCloseVisible(snapshot.symPage in listOf(1, 2, 5)) + setSurfaceCloseVisible(snapshot.symPage in listOf(1, 2, 5) || + (snapshot.symPage == 3 && SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) || + (snapshot.symPage == 4 && (layout as? ImeChromeLayout)?.expandedPickerButtons != null)) if (!symShown && !wasSymActive) { emojiKeyboardView.alpha = 1f // keep black visible immediately emojiKeyboardView.translationY = surfaceHeight.toFloat() @@ -3382,7 +3473,7 @@ class StatusBarController( stackParams.height = ViewGroup.LayoutParams.MATCH_PARENT stack.layoutParams = stackParams } - updateSurfaceCloseBottomMargin(if (reserveLedSpace) measureLedStripHeight() else 0) + updateSurfaceCloseBottomMargin(if (reserveLedSpace) reservedExpandedLedHeight() else 0) val contentParams = content.layoutParams as? LinearLayout.LayoutParams val targetContentHeight = 0 @@ -3423,10 +3514,14 @@ class StatusBarController( if (!reserveLedSpace) { return contentHeight } - val ledHeight = measureLedStripHeight() + val ledHeight = reservedExpandedLedHeight() return contentHeight + ledHeight } + private fun reservedExpandedLedHeight(): Int = + if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) dpToPx(3.1f) + else measureLedStripHeight() + private fun measureLedStripHeight(): Int { val ledStrip = ledStatusView.getView() ?: return 0 if (ledStrip.measuredHeight > 0) { @@ -3457,6 +3552,12 @@ class StatusBarController( measuredHeight: Int, isFullSoftwareKeyboardMode: Boolean ): Int { + if (!isFullSoftwareKeyboardMode && snapshot.symPage in listOf(1, 2, 5)) { + // All hardware SYM pages use the same three key rows. Do not let + // measurement under the previous page's weighted layout resize them. + val gap = if (SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) 0 else dpToPx(4f) + return 3 * hardwareSymKeyHeightPx() + 2 * gap + } if (snapshot.symPage == 4 && measuredHeight > 0) { return measuredHeight } @@ -3474,15 +3575,53 @@ class StatusBarController( ).toInt() } + private fun hardwareSymKeyHeightPx(theme: KeyboardThemeColors = activeThemeColors()): Int { + if (!SettingsManager.getTitan2EliteRoundedCornerInsetsEnabled(context)) return dpToPx(HARDWARE_SYM_KEY_HEIGHT_DP) + return if (pastierinaModeActive) dpToPx(36f * theme.suggestionsHeightScale.coerceIn(0.65f, 1.6f)) + else dpToPx(55f * theme.variationsHeightScale.coerceIn(0.65f, 1.6f)) + } + internal class ImeChromeLayout(context: Context) : LinearLayout(context) { private val screenAwakeController = ImeTouchScreenAwakeController(context) var regularCornerColors: Pair = Color.BLACK to Color.BLACK var compactCornerColors: Pair = Color.BLACK to Color.BLACK var bottomFillColors: Pair = Color.BLACK to Color.BLACK + var expandedCloseColor: Int = Color.BLACK + var expandedCloseButton: View? = null + var expandedPickerButtons: Pair? = null + var expandedKeyHeightPx: Int = (HARDWARE_SYM_KEY_HEIGHT_DP * resources.displayMetrics.density).toInt() private val cornerFillPaint = Paint() override fun onDraw(canvas: Canvas) { super.onDraw(canvas) + if (bottomCornerRadiiPx != null && expandedSurfaceView?.visibility == View.VISIBLE) { + val surface = surfaceView + val content = expandedSurfaceView + if (surface != null && content != null) { + cornerFillPaint.color = bottomFillColors.first + canvas.drawRect(0f, (surface.top + content.bottom).toFloat(), width.toFloat(), height.toFloat(), cornerFillPaint) + expandedCloseButton?.takeIf { it.visibility == View.VISIBLE }?.let { button -> + cornerFillPaint.color = expandedCloseColor + canvas.drawRect( + (surface.left + button.left).toFloat(), (surface.top + button.top).toFloat(), + width.toFloat(), height.toFloat(), cornerFillPaint + ) + } + expandedPickerButtons?.let { (leftButton, rightButton) -> + listOf(leftButton, rightButton).forEach { button -> + if (button.visibility != View.VISIBLE) return@forEach + val bounds = android.graphics.Rect(0, 0, button.width, button.height) + offsetDescendantRectToMyCoords(button, bounds) + cornerFillPaint.color = expandedCloseColor + canvas.drawRect( + if (button === leftButton) 0f else bounds.left.toFloat(), bounds.top.toFloat(), + if (button === rightButton) width.toFloat() else bounds.right.toFloat(), + height.toFloat(), cornerFillPaint + ) + } + } + } + } val row = nestedRow ?: return val radii = bottomCornerRadiiPx ?: return val colors = if (row === compactStatusRow) compactCornerColors else regularCornerColors @@ -3496,6 +3635,8 @@ class StatusBarController( // Children draw afterward, keeping the modifier lights above the fill. cornerFillPaint.color = if (row === compactStatusRow) bottomFillColors.second else bottomFillColors.first canvas.drawRect(0f, row.bottom.toFloat(), width.toFloat(), height.toFloat(), cornerFillPaint) + cornerFillPaint.color = colors.second + canvas.drawRect((width - radii.second).toFloat(), row.bottom.toFloat(), width.toFloat(), height.toFloat(), cornerFillPaint) } var indicatorView: View? = null var expandedSurfaceView: View? = null @@ -3509,6 +3650,8 @@ class StatusBarController( private fun updateNestedStatusRow() { indicatorView?.layoutParams?.height = ViewGroup.LayoutParams.WRAP_CONTENT + (indicatorView?.layoutParams as? LayoutParams)?.topMargin = 0 + expandedSurfaceView?.clipToOutline = false nestedRow?.let { row -> (row.layoutParams as LayoutParams).apply { leftMargin = originalRowMargins[0] @@ -3521,6 +3664,39 @@ class StatusBarController( } nestedRow = null val radii = bottomCornerRadiiPx ?: return + if (expandedSurfaceView?.visibility == View.VISIBLE && indicatorView?.visibility == View.VISIBLE) { + val density = resources.displayMetrics.density + val radius = maxOf(radii.first, radii.second) + val stripHeight = (3.1f * density).toInt() + val ledHeight = maxOf(radius + density.toInt(), expandedKeyHeightPx + stripHeight) + (indicatorView?.layoutParams as? LayoutParams)?.apply { + height = ledHeight + topMargin = -(ledHeight - stripHeight).coerceAtLeast(0) + } + expandedSurfaceView?.apply { + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + val left = (radii.first - stripHeight).coerceAtLeast(0).toFloat() + val right = (radii.second - stripHeight).coerceAtLeast(0).toFloat() + val path = Path().apply { + addRoundRect( + RectF(stripHeight.toFloat(), -2f * radius, view.width.toFloat() - stripHeight, view.height.toFloat()), + floatArrayOf(0f, 0f, 0f, 0f, right, right, left, left), + Path.Direction.CW + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) outline.setPath(path) + else { + @Suppress("DEPRECATION") + outline.setConvexPath(path) + } + } + } + clipToOutline = true + invalidateOutline() + } + return + } if (softwareKeyboardModeActive || expandedSurfaceView?.visibility != View.GONE || indicatorView?.visibility != View.VISIBLE || surfaceView?.visibility != View.VISIBLE ) return diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/ClipboardHistoryView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/ClipboardHistoryView.kt index 371c0d2c6..131041e40 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/ClipboardHistoryView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/ClipboardHistoryView.kt @@ -187,6 +187,11 @@ class ClipboardHistoryView( currentInputConnection = connection } + /** The chrome owns the shared, screen-contoured close button in rounded mode. */ + fun configureRoundedLayout(enabled: Boolean) { + closeButton?.visibility = if (enabled) View.GONE else View.VISIBLE + } + fun configureSoftwareKeyboardMode(heightPx: Int?) { val targetHeight = heightPx?.takeIf { it > 0 } ?: dpToPx(177f) updateHeight(targetHeight) diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerView.kt index fb8dd8a05..f0b17c89f 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerView.kt @@ -2,6 +2,7 @@ package it.palsoftware.pastiera.inputmethod.ui import android.content.Context import android.graphics.Color +import android.graphics.Matrix import android.graphics.Rect import android.graphics.drawable.GradientDrawable import android.graphics.drawable.ColorDrawable @@ -69,6 +70,9 @@ class EmojiPickerView( private val searchPanel: FrameLayout private val searchToggleButton: ImageView private val closeButton: ImageView + private var roundedControls = false + private var roundedIconSize = 0f + val edgeControls: Pair get() = searchToggleButton to closeButton private var coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var loadingJob: Job? = null @@ -375,6 +379,66 @@ class EmojiPickerView( currentInputConnection = connection } + fun configureRoundedControls(enabled: Boolean, rowHeight: Int, iconSize: Float) { + roundedControls = enabled + roundedIconSize = iconSize + // Reserve the slot; rounded mode uses the chrome's shared SYM close control. + closeButton.visibility = if (enabled) View.INVISIBLE else View.VISIBLE + val height = if (enabled) rowHeight else dpToPx(32f) + val bar = closeButton.parent as LinearLayout + bar.layoutParams = bar.layoutParams.apply { this.height = height } + tabScrollView.layoutParams = tabScrollView.layoutParams.apply { this.height = height } + tabRow.layoutParams = tabRow.layoutParams.apply { this.height = height } + tabRow.setPadding(if (enabled) 0 else smallPadding / 2, 0, if (enabled) 0 else smallPadding / 2, 0) + for (index in 0 until tabRow.childCount) { + val category = tabRow.getChildAt(index) + category.layoutParams = category.layoutParams.apply { + this.height = if (enabled) ViewGroup.LayoutParams.MATCH_PARENT else dpToPx(32f) + } + (category as? ImageView)?.scaleType = ImageView.ScaleType.CENTER_INSIDE + } + fun sizeControls(width: Int) { + listOf(searchToggleButton, closeButton).forEach { button -> + button.layoutParams = (button.layoutParams as LinearLayout.LayoutParams).apply { + this.width = if (enabled) { + if (button === closeButton) width - (width / 10) * 9 else width / 10 + } else dpToPx(if (button === closeButton) 36f else 32f) + this.height = height + marginEnd = if (!enabled && button === searchToggleButton) spacing else 0 + } + } + applyEdgeControlAppearance() + } + sizeControls(width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels) + requestLayout() + } + + private fun applyEdgeControlAppearance() { + listOf(searchToggleButton, closeButton).forEach { button -> + if (roundedControls) { + button.background = ColorDrawable(themeOverride?.statusBarButton ?: Color.TRANSPARENT) + button.setPadding(0, 0, 0, 0) + button.scaleType = ImageView.ScaleType.MATRIX + button.drawable?.let { icon -> + val scale = roundedIconSize / icon.intrinsicHeight.coerceAtLeast(1) + button.imageMatrix = Matrix().apply { + setScale(scale, scale) + postTranslate( + (button.layoutParams.width - icon.intrinsicWidth * scale) / 2f + + dpToPx(4f) * if (button === searchToggleButton) 1 else -1, + (button.layoutParams.height - icon.intrinsicHeight * scale) / 2f - dpToPx(2f) + ) + } + } + } else { + val pad = dpToPx(4f) + button.setPadding(pad, pad, pad, pad) + button.scaleType = ImageView.ScaleType.CENTER_INSIDE + button.background = if (button === closeButton) createCloseButtonBackground() else createTabBackground(isSearchPanelVisible) + } + } + } + fun configureSoftwareKeyboardMode(heightPx: Int?, onKeyboardLayoutRequested: (() -> Unit)?) { val configuredHeight = configuredHeightPx(context) val targetHeight = heightPx?.takeIf { it > 0 } ?: configuredHeight @@ -807,6 +871,7 @@ class EmojiPickerView( isSearchPanelVisible = visible searchPanel.visibility = if (visible) View.VISIBLE else View.GONE searchToggleButton.background = createTabBackground(visible) + applyEdgeControlAppearance() setSearchInputCaptureEnabled(visible) if (visible) { searchField.requestFocus() @@ -920,7 +985,7 @@ class EmojiPickerView( isFocusable = true layoutParams = LinearLayout.LayoutParams( 0, // Use weight - tabHeight, + if (roundedControls) ViewGroup.LayoutParams.MATCH_PARENT else tabHeight, 1f // Equal weight for all tabs ) setOnClickListener { @@ -1159,6 +1224,7 @@ class EmojiPickerView( searchToggleButton.background = createTabBackground(isSearchPanelVisible) keyboardSwitcherButton.setColorFilter(theme?.textAndIcons ?: Color.WHITE) keyboardSwitcherButton.background = createTabBackground(false) + applyEdgeControlAppearance() emptyView.setTextColor(colorWithAlpha(theme?.textAndIcons ?: Color.WHITE, 128)) updateTabsSelection() sectionAdapter.notifyDataSetChanged() diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt index a63bef0ce..aff911185 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusView.kt @@ -10,6 +10,7 @@ import android.graphics.PathMeasure import android.graphics.drawable.GradientDrawable import android.util.TypedValue import android.view.View +import android.view.MotionEvent import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import it.palsoftware.pastiera.R @@ -53,7 +54,7 @@ class LedStatusView( private var container: ModifierLedCanvas? = null private val ledsByState = mutableMapOf>() private val segmentsByView = mutableMapOf() - private val inactiveUpperStates = mutableSetOf(ModifierLedState.ALT, ModifierLedState.SYM) + private val statePriority = mutableMapOf() var bottomCornerRadiiPx: Pair? = null set(value) { @@ -147,11 +148,18 @@ class LedStatusView( super.draw(canvas) return } - // Alt/Sym share the outer positions with Shift. Show the upper - // contour only when it carries an active modifier state. - if (layout == ModifierLedLayouts.TITAN_2_ELITE && segment.y == 0f && - segment.state in inactiveUpperStates - ) return + // One physical contour per side in rounded mode. Alt/Sym and + // Shift share it; a locked modifier wins over an active one. + if (layout == ModifierLedLayouts.TITAN_2_ELITE && segment.y == 0f) return + if (layout == ModifierLedLayouts.TITAN_2_ELITE && segment.state == ModifierLedState.SHIFT) { + val otherState = if (segment.x < 0.5f) ModifierLedState.ALT else ModifierLedState.SYM + val priority = maxOf(statePriority[ModifierLedState.SHIFT] ?: 0, statePriority[otherState] ?: 0) + paint.color = when (priority) { + 2 -> themeOverride?.ledLocked ?: LED_COLOR_RED_LOCKED + 1 -> themeOverride?.ledActive ?: LED_COLOR_BLUE_ACTIVE + else -> themeOverride?.ledInactive ?: LED_COLOR_GRAY_OFF + } + } val width = bounds.width().toFloat() val height = bounds.height().toFloat() // Each row follows a concentric contour, so the indicator retains @@ -202,9 +210,7 @@ class LedStatusView( } private fun updateLeds(state: ModifierLedState, isLocked: Boolean, isActive: Boolean = false) { - if (state == ModifierLedState.ALT) { - if (isLocked || isActive) inactiveUpperStates.remove(state) else inactiveUpperStates.add(state) - } + statePriority[state] = if (isLocked) 2 else if (isActive) 1 else 0 val theme = themeOverride val targetColor = when { isLocked -> theme?.ledLocked ?: LED_COLOR_RED_LOCKED @@ -215,8 +221,7 @@ class LedStatusView( } private fun updateSymLeds(symPage: Int) { - if (symPage > 0) inactiveUpperStates.remove(ModifierLedState.SYM) - else inactiveUpperStates.add(ModifierLedState.SYM) + statePriority[ModifierLedState.SYM] = if (symPage == 2) 2 else if (symPage > 0) 1 else 0 val theme = themeOverride val targetColor = when (symPage) { 1 -> theme?.ledActive ?: LED_COLOR_BLUE_ACTIVE @@ -226,6 +231,8 @@ class LedStatusView( else -> theme?.ledInactive ?: LED_COLOR_GRAY_OFF } ledsByState[ModifierLedState.SYM].orEmpty().forEach { led -> animateLedColor(led, targetColor) } + // The visible idle contour depends on both the upper and lower states. + ledsByState[ModifierLedState.SHIFT].orEmpty().forEach { it.invalidate() } } private fun animateLedColor(led: View?, targetColor: Int) { @@ -253,6 +260,18 @@ class LedStatusView( private val contentHeightPx: Int ) : ViewGroup(context) { private val segments = mutableListOf() + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + // Rounded indicators overlap the controls. Their transparent center + // must not become a full-row long-press target above those controls. + if (cornerRadiiPx != null && event.actionMasked == MotionEvent.ACTION_DOWN) { + val edge = 3.1f * resources.displayMetrics.density + if (event.x > edge && event.x < width - edge && event.y < height - edge) { + return false + } + } + return super.dispatchTouchEvent(event) + } + var cornerRadiiPx: Pair? = null set(value) { field = value diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/ClipboardRoundedLayoutTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/ClipboardRoundedLayoutTest.kt new file mode 100644 index 000000000..9dc6593d1 --- /dev/null +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/ClipboardRoundedLayoutTest.kt @@ -0,0 +1,44 @@ +package it.palsoftware.pastiera.inputmethod + +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import it.palsoftware.pastiera.clipboard.ClipboardHistoryManager +import it.palsoftware.pastiera.inputmethod.ui.ClipboardHistoryView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class ClipboardRoundedLayoutTest { + @Test + fun roundedModeUsesSharedCloseButPreservesCardSpacing() { + val context = RuntimeEnvironment.getApplication() + val view = ClipboardHistoryView(context, ClipboardHistoryManager(context)) + fun descendants(group: ViewGroup): List = (0 until group.childCount).flatMap { + val child = group.getChildAt(it) + listOf(child) + if (child is ViewGroup) descendants(child) else emptyList() + } + val children = descendants(view) + val recycler = children.filterIsInstance().single() + val close = children.filterIsInstance().single() + val originalPadding = recycler.paddingBottom + val originalSidePadding = recycler.paddingLeft + view.configureRoundedLayout(true) + assertEquals(View.GONE, close.visibility) + assertEquals(originalSidePadding, recycler.paddingLeft) + assertEquals(originalSidePadding, recycler.paddingRight) + assertEquals(originalSidePadding, recycler.paddingTop) + assertEquals(originalPadding, recycler.paddingBottom) + view.configureRoundedLayout(false) + assertEquals(View.VISIBLE, close.visibility) + assertTrue(recycler.paddingLeft > 0) + assertEquals(originalPadding, recycler.paddingBottom) + } +} diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt index 3cbec2d0b..139d22e6d 100644 --- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt @@ -64,6 +64,7 @@ class NestedStatusBarGeometryTest { assertEquals("Side lights must begin at the top of a tall row", row.top, surface.top) expanded.visibility = View.VISIBLE + chrome.indicatorView = null chrome.requestLayout() measure() assertEquals(0, row.left) @@ -77,4 +78,31 @@ class NestedStatusBarGeometryTest { assertEquals(0, row.left) assertEquals(row.bottom, surface.top) } + + @Test + fun expandedKeyboardOverlaysCornerLightsInsteadOfReservingCornerHeight() { + val context = RuntimeEnvironment.getApplication() + val chrome = StatusBarController.ImeChromeLayout(context).apply { orientation = LinearLayout.VERTICAL } + val surface = FrameLayout(context) + val stack = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + val content = FrameLayout(context) + val lights = View(context) + stack.addView(content, LinearLayout.LayoutParams(-1, 0, 1f)) + stack.addView(lights, LinearLayout.LayoutParams(-1, -2)) + surface.addView(stack, FrameLayout.LayoutParams(-1, -1)) + chrome.addView(surface, LinearLayout.LayoutParams(-1, 400)) + chrome.surfaceView = surface + chrome.expandedSurfaceView = content + chrome.indicatorView = lights + chrome.bottomCornerRadiiPx = 100 to 100 + chrome.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + chrome.layout(0, 0, chrome.measuredWidth, chrome.measuredHeight) + assertTrue(content.clipToOutline) + assertEquals((3.1f * context.resources.displayMetrics.density).toInt(), surface.height - content.height) + assertTrue(lights.top < content.bottom) + assertEquals(surface.height, lights.bottom) + } } diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerOnScreenSearchTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerOnScreenSearchTest.kt index f71f1634c..bfcb62acc 100644 --- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerOnScreenSearchTest.kt +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerOnScreenSearchTest.kt @@ -33,6 +33,45 @@ import org.robolectric.annotation.Config @Config(sdk = [33]) class EmojiPickerOnScreenSearchTest { + @Test + fun roundedEdgeControlsShareBarHeightAndRestoreOriginalSizing() { + var closed = false + val view = EmojiPickerView(RuntimeEnvironment.getApplication()) { closed = true } + val (left, right) = view.edgeControls + val originalLeftWidth = left.layoutParams.width + val originalRightWidth = right.layoutParams.width + val originalHeight = right.layoutParams.height + val tabs = view.privateField("tabRow").get(view) as android.widget.LinearLayout + val category = android.widget.ImageView(view.context).apply { + layoutParams = android.widget.LinearLayout.LayoutParams(0, originalHeight, 1f) + } + tabs.addView(category) + view.configureRoundedControls(true, 110, 40f) + assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, category.layoutParams.height) + assertEquals(android.widget.ImageView.ScaleType.CENTER_INSIDE, category.scaleType) + tabs.measure( + View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(110, View.MeasureSpec.EXACTLY) + ) + assertEquals(110, category.measuredHeight) + assertEquals(110, left.layoutParams.height) + assertEquals(110, right.layoutParams.height) + assertEquals(110, (right.parent as View).layoutParams.height) + val width = view.resources.displayMetrics.widthPixels + assertEquals(width - (width / 10) * 9, right.layoutParams.width) + assertEquals(View.INVISIBLE, right.visibility) + assertTrue(right.background is android.graphics.drawable.ColorDrawable) + right.performClick() + assertTrue(closed) + view.configureRoundedControls(false, 110, 40f) + assertEquals(originalHeight, category.layoutParams.height) + assertEquals(View.VISIBLE, right.visibility) + assertEquals(originalLeftWidth, left.layoutParams.width) + assertEquals(originalRightWidth, right.layoutParams.width) + assertEquals(originalHeight, right.layoutParams.height) + assertTrue(right.background is android.graphics.drawable.GradientDrawable) + } + @Test fun emojiCommitRunsSynchronouslyBeforeAutoClose() { val events = mutableListOf() diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusViewTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusViewTest.kt index 2df8e400f..9affaa865 100644 --- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusViewTest.kt +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/ui/LedStatusViewTest.kt @@ -18,6 +18,28 @@ import org.robolectric.annotation.GraphicsMode @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) class LedStatusViewTest { + @Test + fun roundedOverlayPassesCenterTouchesThroughButRetainsBottomStripGestures() { + val leds = LedStatusView(RuntimeEnvironment.getApplication()).apply { + bottomCornerRadiiPx = 100 to 100 + } + val view = leds.ensureView() + view.measure( + View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(110, View.MeasureSpec.EXACTLY) + ) + view.layout(0, 0, 1000, 110) + fun touch(x: Float, y: Float): Boolean { + val event = android.view.MotionEvent.obtain(0, 0, android.view.MotionEvent.ACTION_DOWN, x, y, 0) + return try { view.dispatchTouchEvent(event) } finally { event.recycle() } + } + org.junit.Assert.assertFalse(touch(50f, 50f)) + org.junit.Assert.assertFalse(touch(500f, 50f)) + assertTrue(touch(500f, 109f)) + leds.bottomCornerRadiiPx = null + assertTrue(touch(500f, 50f)) + } + @Test @GraphicsMode(GraphicsMode.Mode.NATIVE) fun roundedIndicatorsBendUpBothSidesAndToggleBackToFlat() { From 0cdd62f33bd49df9c3b8d0a5b2b2c7b84b3eaff5 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Sat, 5 Sep 2026 16:33:04 -0400 Subject: [PATCH 6/7] Add assertion for status bar chrome height coverage Ensure that the chrome height fully covers the surface bottom with no unused band remaining below the indicators in the nested status bar geometry test. --- .../pastiera/inputmethod/StatusBarController.kt | 7 ++++++- .../pastiera/inputmethod/NestedStatusBarGeometryTest.kt | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt index b619e1df0..754361a8f 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt @@ -3719,7 +3719,12 @@ class StatusBarController( val requestedRowHeight = params.height.coerceAtLeast(0) val bottomInset = (3.1f * resources.displayMetrics.density).toInt() indicatorView?.layoutParams?.height = maxOf(radius + stripTop, requestedRowHeight + bottomInset) - val overlap = maxOf((radius + stripTop - inset).coerceAtLeast(0), requestedRowHeight) + // A fixed-height row does not honor minimumHeight during measurement. + // Overlapping more than that height puts the LED surface above the + // row's top, while LinearLayout still reserves its full height below. + // onLayout expands the row to meet the LEDs after measurement. + val overlap = if (params.height >= 0) requestedRowHeight + else (radius + stripTop - inset).coerceAtLeast(0) params.bottomMargin -= overlap row.minimumHeight = maxOf(originalRowMinHeight, overlap) row.outlineProvider = object : ViewOutlineProvider() { diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt index 139d22e6d..3f8ea13d0 100644 --- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt +++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/NestedStatusBarGeometryTest.kt @@ -54,6 +54,7 @@ class NestedStatusBarGeometryTest { assertEquals(0, buttonWrapper.top) assertEquals(row.height, button.height) assertTrue(row.bottom > surface.top) + assertEquals("No unused band may remain below the indicators", chrome.height, surface.bottom) assertEquals((3.1f * context.resources.displayMetrics.density).toInt(), surface.bottom - row.bottom) val nestedHeight = chrome.measuredHeight measure() From dd6e42243c5a8e768ce648810efbdbf6cd1ef58e Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:54:35 +0200 Subject: [PATCH 7/7] Keep rounded-corner PR focused on keyboard UI --- .../pastiera/update/UpdateChecker.kt | 2 +- .../pastiera/update/UpdateReleaseResolver.kt | 49 ------------------- .../update/UpdateCheckerFlavorLogicTest.kt | 44 ----------------- 3 files changed, 1 insertion(+), 94 deletions(-) diff --git a/app/src/main/java/it/palsoftware/pastiera/update/UpdateChecker.kt b/app/src/main/java/it/palsoftware/pastiera/update/UpdateChecker.kt index a4c4d86a1..8b7719524 100644 --- a/app/src/main/java/it/palsoftware/pastiera/update/UpdateChecker.kt +++ b/app/src/main/java/it/palsoftware/pastiera/update/UpdateChecker.kt @@ -70,7 +70,7 @@ fun checkForUpdate( val normalizedLatest = normalizeReleaseVersion(latestVersion) val normalizedCurrent = normalizeReleaseVersion(currentVersion) - val hasUpdate = isReleaseVersionNewer(normalizedLatest, normalizedCurrent) + val hasUpdate = normalizedLatest != normalizedCurrent // If ignoring dismissed releases, check if this release was dismissed if (hasUpdate && ignoreDismissedReleases) { diff --git a/app/src/main/java/it/palsoftware/pastiera/update/UpdateReleaseResolver.kt b/app/src/main/java/it/palsoftware/pastiera/update/UpdateReleaseResolver.kt index bdc68baa7..7ac35d030 100644 --- a/app/src/main/java/it/palsoftware/pastiera/update/UpdateReleaseResolver.kt +++ b/app/src/main/java/it/palsoftware/pastiera/update/UpdateReleaseResolver.kt @@ -49,52 +49,3 @@ internal fun findApkDownloadUrl(assets: List): String? = internal fun normalizeReleaseVersion(version: String): String = version.removePrefix("nightly/").removePrefix("v").removePrefix("V") - -internal fun isReleaseVersionNewer(latestVersion: String, currentVersion: String): Boolean { - val latest = parseVersionParts(latestVersion) - val current = parseVersionParts(currentVersion) - // Builds running ahead of the newest published release must not be offered - // a "new" update, so only unparseable versions fall back to inequality. - if (latest == null || current == null) return latestVersion != currentVersion - - val coreComparison = compareNumberLists(latest.core, current.core) - if (coreComparison != 0) return coreComparison > 0 - return compareSuffixes(latest.suffix, current.suffix) > 0 -} - -private data class VersionParts(val core: List, val suffix: List) - -private fun parseVersionParts(version: String): VersionParts? { - val core = version.substringBefore('-') - val suffix = version.substringAfter('-', missingDelimiterValue = "") - val numbers = core.split('.').map { it.toIntOrNull() ?: return null } - return VersionParts(numbers, if (suffix.isEmpty()) emptyList() else suffix.split('.')) -} - -private fun compareNumberLists(left: List, right: List): Int { - for (index in 0 until maxOf(left.size, right.size)) { - val diff = left.getOrElse(index) { 0 }.compareTo(right.getOrElse(index) { 0 }) - if (diff != 0) return diff - } - return 0 -} - -private fun compareSuffixes(left: List, right: List): Int { - if (left.isEmpty() && right.isEmpty()) return 0 - // A release without a suffix is newer than a pre-release with the same core version. - if (left.isEmpty()) return 1 - if (right.isEmpty()) return -1 - for (index in 0 until maxOf(left.size, right.size)) { - val leftSegment = left.getOrNull(index) ?: return -1 - val rightSegment = right.getOrNull(index) ?: return 1 - val leftNumber = leftSegment.toIntOrNull() - val rightNumber = rightSegment.toIntOrNull() - val diff = if (leftNumber != null && rightNumber != null) { - leftNumber.compareTo(rightNumber) - } else { - leftSegment.compareTo(rightSegment) - } - if (diff != 0) return diff - } - return 0 -} diff --git a/app/src/test/java/it/palsoftware/pastiera/update/UpdateCheckerFlavorLogicTest.kt b/app/src/test/java/it/palsoftware/pastiera/update/UpdateCheckerFlavorLogicTest.kt index e9301ff66..d80c992c9 100644 --- a/app/src/test/java/it/palsoftware/pastiera/update/UpdateCheckerFlavorLogicTest.kt +++ b/app/src/test/java/it/palsoftware/pastiera/update/UpdateCheckerFlavorLogicTest.kt @@ -1,9 +1,7 @@ package it.palsoftware.pastiera.update import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test class UpdateCheckerFlavorLogicTest { @@ -50,48 +48,6 @@ class UpdateCheckerFlavorLogicTest { assertEquals("0.85-nightly.20260306.214144", normalizeReleaseVersion("nightly/v0.85-nightly.20260306.214144")) } - @Test - fun newerReleaseTriggersUpdate() { - assertTrue(isReleaseVersionNewer("0.86", "0.85")) - assertTrue(isReleaseVersionNewer("1.0", "0.99")) - assertTrue( - isReleaseVersionNewer( - "0.86-nightly.20260820.222455", - "0.86-nightly.20260811.214801" - ) - ) - } - - @Test - fun sameOrOlderReleaseDoesNotTriggerUpdate() { - assertFalse(isReleaseVersionNewer("0.85", "0.85")) - assertFalse(isReleaseVersionNewer("0.85", "0.86")) - assertFalse( - isReleaseVersionNewer( - "0.86-nightly.20260811.214801", - "0.86-nightly.20260820.222455" - ) - ) - assertFalse( - isReleaseVersionNewer( - "0.86-nightly.20260811.214801", - "0.86-nightly.20260811.214801" - ) - ) - } - - @Test - fun releaseWithoutSuffixIsNewerThanPrereleaseOfSameCore() { - assertTrue(isReleaseVersionNewer("0.86", "0.86-nightly.20260820.222455")) - assertFalse(isReleaseVersionNewer("0.86-nightly.20260820.222455", "0.86")) - } - - @Test - fun unparseableVersionsFallBackToInequality() { - assertTrue(isReleaseVersionNewer("abc", "def")) - assertFalse(isReleaseVersionNewer("abc", "abc")) - } - private fun sampleReleases(): List = listOf( GitHubRelease(