tauri-plugin-widgets 0.8.0

Tauri plugin for App Widgets on Android, iOS, and macOS (WidgetKit); Windows Widgets Board (Adaptive Cards) + desktop webview; Linux desktop webview with X11 DESKTOP pin.
Documentation
package git.s00d.widgets

import android.content.Context
import android.os.Build
import android.os.SystemClock
import android.text.format.DateUtils
import android.util.TypedValue
import android.widget.RemoteViews
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.toArgb
import androidx.glance.GlanceModifier
import androidx.glance.appwidget.AndroidRemoteViews
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.width
import androidx.glance.LocalSize
import androidx.glance.text.FontStyle
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.graphics.Paint
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableString
import android.text.TextPaint
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import android.view.Gravity

@Composable
internal fun RenderText(scope: RenderScope, el: El, modifier: GlanceModifier) {
    val context = scope.context
    val type = el.type
    when (type) {
        "label" -> {
            val txt = el.str("text", "")
            val icon = iconGlyph(el.str("systemName", "*"))
            val iconColor = resolveColorProvider(context, el.opt("iconColor"))
            if (iconColor != null) {
                Row(modifier = modifier.fillMaxWidth()) {
                    Text(icon, style = TextStyle(color = iconColor))
                    renderElementText(context, el, " $txt")
                }
            } else {
                renderElementText(context, el, "$icon $txt", modifier)
            }
        }
        else -> {
            when {
                type == "timer" -> renderLiveTimer(context, el, modifier)
                else -> {
                    val content = when (type) {
                        "date" -> formatDateValue(el.str("date", ""), el.str("dateStyle", "date"))
                        "timer" -> formatTimerValue(
                            el.str("targetDate", ""),
                            el.str("counting", "down")
                        )
                        else -> el.str("content", el.str("text", ""))
                    }
                    renderElementText(context, el, content, modifier, scope.sizeFamily)
                }
            }
        }
    }
}

@Composable
private fun renderLiveTimer(context: Context, el: El, modifier: GlanceModifier) {
    val target = parseIsoDate(el.str("targetDate", ""))
    val countingDown = !el.str("counting", "down").equals("up", ignoreCase = true)
    if (target == null || (countingDown && Build.VERSION.SDK_INT < 24)) {
        renderElementText(
            context,
            el,
            formatTimerValue(el.str("targetDate", ""), el.str("counting", "down")),
            modifier,
        )
        return
    }
    val now = System.currentTimeMillis()
    // completedText: shown when the countdown already ended at render time (a countdown
    // that ends while on screen keeps ticking below zero until the next update).
    val completed = el.str(WireField.COMPLETED_TEXT, "")
    if (countingDown && completed.isNotEmpty() && target.time <= now) {
        renderElementText(context, el, completed, modifier)
        return
    }
    // Range timer: counting up runs from startDate and stops at targetDate.
    val start = parseIsoDate(el.str(WireField.START_DATE, ""))
    if (!countingDown && start != null && target.time <= now) {
        renderElementText(context, el, formatDuration(target.time - start.time), modifier)
        return
    }
    val wallDelta = target.time - now
    val base = if (countingDown) {
        SystemClock.elapsedRealtime() + wallDelta
    } else {
        SystemClock.elapsedRealtime() - (now - (start ?: target).time)
    }
    // showHours: Chronometer adds hours only from 1:00:00; a countdown under an hour
    // never reaches it, so prefix "0:". Count-up timers grow past an hour: left as is.
    val format = if (countingDown && el.bool(WireField.SHOW_HOURS) && wallDelta < 3_600_000L) "0:%s" else null
    val argb = colorProviderArgb(
        resolveColorProvider(context, el.opt("color")),
        context,
        semanticLabelProvider(context).getColor(context).toArgb(),
    )
    val fontSize = el.num(
        "fontSize",
        semanticTextSizeSp(el.str("textStyle", "")).toDouble(),
    ).toFloat().takeIf { it > 0f } ?: 14f
    val rv = RemoteViews(context.packageName, R.layout.tauri_chronometer).apply {
        setChronometer(R.id.tauri_chrono, base, format, true)
        if (Build.VERSION.SDK_INT >= 24) {
            setChronometerCountDown(R.id.tauri_chrono, countingDown)
        }
        setTextColor(R.id.tauri_chrono, argb)
        setTextViewTextSize(R.id.tauri_chrono, TypedValue.COMPLEX_UNIT_SP, fontSize)
    }
    // Glance hosts the RemoteViews in a box that does not report the Chronometer's width to
    // a wrap-content column (the column sizes to its siblings and clips "632314:51:59" to
    // "632314:"): give it the width of the text it shows now.
    val shown = String.format(format ?: "%s", DateUtils.formatElapsedTime(kotlin.math.abs(wallDelta) / 1000))
    // ponytail: a count-up timer grows (9:59 → 10:00 → 1:00:00): two digits of slack.
    val paint = Paint().apply { textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, fontSize, context.resources.displayMetrics) }
    val textPx = paint.measureText(if (countingDown) shown else shown + "00")
    val widthDp = kotlin.math.ceil(textPx / context.resources.displayMetrics.density).toInt() + 2
    AndroidRemoteViews(remoteViews = rv, modifier = modifier.width(widthDp.dp))
}

@Composable
internal fun renderElementText(
    context: Context,
    el: El,
    content: String,
    modifier: GlanceModifier = GlanceModifier,
    sizeFamily: String = "",
) {
    val frame = el.obj("frame")
    val hasFixedBox =
        frame != null &&
            frame.optDouble("width", -1.0) > 0 &&
            frame.optDouble("height", -1.0) > 0
    // Badge boxes (TL/TR/BL/BR): center glyph inside frame+background.
    // Glance Text ignores vertical alignment on the modifier alone.
    val styleEl =
        if (hasFixedBox && el.str("alignment", "").isBlank()) {
            El(org.json.JSONObject(el.raw.toString()).put("alignment", "center"))
        } else {
            el
        }
    var style = textStyleFromElement(context, styleEl.raw)
    val limit = el.int("lineLimit", -1)
    var text = content
    if (el.type == "text") {
        val fit = fitText(context, el, content, style, limit, sizeFamily)
        text = fit.first
        style = fit.second
        if (el.num(WireField.LINE_HEIGHT) != null || el.num(WireField.LETTER_SPACING) != null) {
            renderSpacedText(context, el, text, style, limit, modifier)
            return
        }
    }
    renderGlanceText(text, style, limit, hasFixedBox, modifier)
}

@Composable
private fun renderGlanceText(content: String, style: TextStyle, limit: Int, hasFixedBox: Boolean, modifier: GlanceModifier) {
    if (hasFixedBox) {
        Box(modifier = modifier, contentAlignment = Alignment.Center) {
            if (limit > 0) {
                Text(content, style = style, maxLines = limit)
            } else {
                Text(content, style = style)
            }
        }
    } else if (limit > 0) {
        Text(content, modifier = modifier, style = style, maxLines = limit)
    } else {
        Text(content, modifier = modifier, style = style)
    }
}

/**
 * Width a text may use, in px: its frame width, else the widget width less typical
 * padding. Glance gives no per-node constraints, and with SizeMode.Single LocalSize is the
 * provider's minimum size, so the nominal family width (small 170, else 360 dp) is a floor.
 * Over-estimating only falls back to the TextView's tail ellipsis.
 */
@Composable
private fun availableTextWidthPx(context: Context, el: El, sizeFamily: String): Float {
    val dm = context.resources.displayMetrics
    val frameW = el.obj("frame")?.optDouble("width", -1.0) ?: -1.0
    val dp = if (frameW > 0) frameW.toFloat() else {
        val slot = runCatching { LocalSize.current.width.value }.getOrNull() ?: 0f
        val nominal = when (sizeFamily) {
            "" -> 0f
            "small" -> 170f
            else -> 360f
        }
        (maxOf(slot, nominal) - 24f).takeIf { it > 0f } ?: return Float.MAX_VALUE
    }
    return dp * dm.density
}

/** `truncationMode` head/middle and `minimumScaleFactor`, both by measuring with a TextPaint. */
@Composable
private fun fitText(context: Context, el: El, content: String, style: TextStyle, limit: Int, sizeFamily: String): Pair<String, TextStyle> {
    val mode = el.str(WireField.TRUNCATION_MODE, "tail")
    val minScale = el.num(WireField.MINIMUM_SCALE_FACTOR)?.coerceIn(0.1, 1.0)
    if ((mode == "tail" || limit != 1) && minScale == null) return content to style
    val avail = availableTextWidthPx(context, el, sizeFamily)
    if (avail == Float.MAX_VALUE) return content to style
    val sizeSp = style.fontSize?.value ?: 14f
    val dm = context.resources.displayMetrics
    val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
        textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sizeSp, dm)
        if (style.fontWeight == FontWeight.Bold) typeface = Typeface.DEFAULT_BOLD
    }
    var outStyle = style
    val lines = if (limit > 0) limit else 1
    if (minScale != null) {
        val need = paint.measureText(content)
        if (need > avail * lines) {
            val scale = (avail * lines / need).toDouble().coerceAtLeast(minScale).toFloat()
            outStyle = style.copy(fontSize = (sizeSp * scale).sp)
            paint.textSize *= scale
        }
    }
    if (limit == 1 && (mode == "head" || mode == "middle")) {
        return ellipsizeHeadOrMiddle(content, paint, avail, mode == "head") to outStyle
    }
    return content to outStyle
}

/**
 * `…end` (head) or `sta…end` (middle) fitting [avail] px. Hand-rolled (TextUtils.ellipsize
 * is stubbed under Robolectric). ponytail: char-based cut, may split a surrogate pair.
 */
internal fun ellipsizeHeadOrMiddle(text: String, paint: Paint, avail: Float, head: Boolean): String {
    if (paint.measureText(text) <= avail) return text
    val dots = "\u2026"
    fun build(keep: Int) = if (head) dots + text.takeLast(keep) else text.take((keep + 1) / 2) + dots + text.takeLast(keep / 2)
    var lo = 0
    var hi = text.length - 1
    while (lo < hi) {
        val mid = (lo + hi + 1) / 2
        if (paint.measureText(build(mid)) <= avail) lo = mid else hi = mid - 1
    }
    return build(lo)
}

/**
 * `lineHeight` / `letterSpacing`: Glance Text has neither, so the text goes into a plain
 * RemoteViews TextView (the plugin's chronometer layout, never started) whose
 * `setLetterSpacing` / `setLineHeight` (API 28+) are remotable. Weight, italic and
 * decorations ride along as spans.
 */
@Composable
private fun renderSpacedText(context: Context, el: El, content: String, style: TextStyle, limit: Int, modifier: GlanceModifier) {
    val dm = context.resources.displayMetrics
    val sizeSp = style.fontSize?.value ?: 14f
    val argb = colorProviderArgb(style.color, context, semanticLabelProvider(context).getColor(context).toArgb())
    val spans = SpannableString(content)
    val whole = Spannable.SPAN_INCLUSIVE_EXCLUSIVE
    val bold = style.fontWeight == FontWeight.Bold || style.fontWeight == FontWeight.Medium
    val italic = style.fontStyle == FontStyle.Italic
    val typefaceStyle = when {
        bold && italic -> Typeface.BOLD_ITALIC
        bold -> Typeface.BOLD
        italic -> Typeface.ITALIC
        else -> Typeface.NORMAL
    }
    if (typefaceStyle != Typeface.NORMAL) spans.setSpan(StyleSpan(typefaceStyle), 0, content.length, whole)
    if (el.bool(WireField.UNDERLINE)) spans.setSpan(UnderlineSpan(), 0, content.length, whole)
    if (el.bool(WireField.STRIKETHROUGH)) spans.setSpan(StrikethroughSpan(), 0, content.length, whole)
    val rv = RemoteViews(context.packageName, R.layout.tauri_chronometer).apply {
        setTextViewText(R.id.tauri_chrono, spans)
        setTextColor(R.id.tauri_chrono, argb)
        setTextViewTextSize(R.id.tauri_chrono, TypedValue.COMPLEX_UNIT_SP, sizeSp)
        if (limit > 0) setInt(R.id.tauri_chrono, "setMaxLines", limit)
        el.num(WireField.LETTER_SPACING)?.let { pt ->
            // letterSpacing is in ems on Android; the IR gives points.
            setFloat(R.id.tauri_chrono, "setLetterSpacing", (pt / sizeSp).toFloat())
        }
        el.num(WireField.LINE_HEIGHT)?.takeIf { Build.VERSION.SDK_INT >= 28 }?.let { pt ->
            setInt(R.id.tauri_chrono, "setLineHeight", TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, pt.toFloat(), dm).toInt())
        }
        val gravity = when (style.textAlign) {
            TextAlign.Center -> Gravity.CENTER_HORIZONTAL
            TextAlign.End -> Gravity.END
            else -> Gravity.START
        }
        setInt(R.id.tauri_chrono, "setGravity", gravity or Gravity.CENTER_VERTICAL)
    }
    AndroidRemoteViews(remoteViews = rv, modifier = modifier)
}