FaviconInitials.kt

4.4 KB · raw

1package com.peachpasswords.ui.components23import androidx.compose.foundation.Image4import androidx.compose.foundation.layout.Box5import androidx.compose.foundation.layout.size6import androidx.compose.foundation.shape.RoundedCornerShape7import androidx.compose.material3.Text8import androidx.compose.runtime.Composable9import androidx.compose.runtime.LaunchedEffect10import androidx.compose.runtime.getValue11import androidx.compose.runtime.mutableIntStateOf12import androidx.compose.runtime.remember13import androidx.compose.runtime.setValue14import androidx.compose.runtime.staticCompositionLocalOf15import androidx.compose.ui.Alignment16import androidx.compose.ui.Modifier17import androidx.compose.ui.draw.clip18import androidx.compose.ui.graphics.Color19import androidx.compose.ui.draw.drawBehind20import androidx.compose.ui.graphics.asImageBitmap21import androidx.compose.ui.layout.ContentScale22import androidx.compose.ui.platform.LocalDensity23import androidx.compose.ui.text.font.FontWeight24import androidx.compose.ui.unit.Dp25import androidx.compose.ui.unit.dp26import androidx.compose.ui.unit.em27import androidx.compose.ui.unit.sp28import com.peachpasswords.shared.extractDomain29import com.peachpasswords.ui.theme.peachColors30import com.peachpasswords.ui.theme.PeachType3132/** Favicons repository for this window; provided by MainActivity and the picker. */33val LocalFavicons = staticCompositionLocalOf<Favicons?> { null }3435/**36 * Site favicon with initials fallback (offline-first). Shows the cached real37 * favicon (U1, [Favicons]) once fetched; until then the initials chip — the38 * browser's titleInitials + titleBackground port (deterministic HSL 32%/26%).39 * The corner radius tracks the chip size (≈6dp at 26dp, ≈3dp at 16dp) per40 * spec §4.6.41 */42@Composable43fun FaviconInitials(44    source: String,45    modifier: Modifier = Modifier,46    size: Dp = 26.dp,47    cornerRadius: Dp = (size.value * 0.23f).dp,48) {49    val favicons = LocalFavicons.current50    val domain = remember(source) { extractDomain(source) }51    var revision by remember { mutableIntStateOf(0) }52    LaunchedEffect(favicons) {53        favicons?.revision?.collect { revision = it }54    }55    val sizePx = with(LocalDensity.current) { size.roundToPx() }56    val bitmap = remember(domain, revision, sizePx) {57        if (domain != null && favicons != null) favicons.bitmap(domain, sizePx) else null58    }59    if (bitmap != null) {60        Image(61            bitmap = bitmap.asImageBitmap(),62            contentDescription = null,63            contentScale = ContentScale.Crop,64            modifier = modifier65                .size(size)66                .clip(RoundedCornerShape(cornerRadius)),67        )68        return69    }70    val bg = titleBackground(source)71    Box(72        modifier = modifier73            .size(size)74            .clip(RoundedCornerShape(cornerRadius))75            .drawBehind { drawRect(bg) },76        contentAlignment = Alignment.Center,77    ) {78        Text(79            text = titleInitials(source),80            color = peachColors.T0.copy(alpha = 0.92f),81            fontFamily = PeachType.Mono,82            fontWeight = FontWeight.SemiBold,83            letterSpacing = 0.04.em,84            fontSize = (size.value * 0.34f).sp,85        )86    }87}8889fun titleInitials(source: String): String {90    val cleaned = source.replace(Regex("^https?://"), "").removePrefix("www.").trim()91    if (cleaned.isEmpty()) return "·"92    val words = cleaned.split(Regex("[\\s.\\-_/]+")).filter { it.isNotEmpty() }93    if (words.size >= 2 && words[0].isNotEmpty() && words[1].isNotEmpty()) {94        return (words[0][0].toString() + words[1][0].toString()).uppercase()95    }96    val single = words.firstOrNull() ?: cleaned97    return single.take(2).uppercase()98}99100/** Deterministic muted HSL(hue, 32%, 26%) background, matching the browser. */101fun titleBackground(source: String): Color {102    var hash = 0103    for (ch in source) hash = ((hash shl 5) - hash + ch.code)104    val hue = (((hash % 360) + 360) % 360).toFloat()105    return hsl(hue, 0.32f, 0.26f)106}107108private fun hsl(h: Float, s: Float, l: Float): Color {109    val c = (1f - kotlin.math.abs(2 * l - 1f)) * s110    val x = c * (1f - kotlin.math.abs((h / 60f) % 2f - 1f))111    val m = l - c / 2f112    val (r, g, b) = when {113        h < 60 -> Triple(c, x, 0f)114        h < 120 -> Triple(x, c, 0f)115        h < 180 -> Triple(0f, c, x)116        h < 240 -> Triple(0f, x, c)117        h < 300 -> Triple(x, 0f, c)118        else -> Triple(c, 0f, x)119    }120    return Color(r + m, g + m, b + m)121}