FaviconInitials.kt

4.6 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/** Site-icons toggle (Settings > Appearance); off (default) keeps the initials-only look. */36val LocalFaviconsEnabled = staticCompositionLocalOf { false }3738/**39 * Site favicon with initials fallback (offline-first). Shows the cached real40 * favicon (U1, [Favicons]) once fetched; until then the initials chip — the41 * browser's titleInitials + titleBackground port (deterministic HSL 32%/26%).42 * The corner radius tracks the chip size (≈6dp at 26dp, ≈3dp at 16dp) per43 * spec §4.6.44 */45@Composable46fun FaviconInitials(47    source: String,48    modifier: Modifier = Modifier,49    size: Dp = 26.dp,50    cornerRadius: Dp = (size.value * 0.23f).dp,51) {52    val favicons = if (LocalFaviconsEnabled.current) LocalFavicons.current else null53    val domain = remember(source) { extractDomain(source) }54    var revision by remember { mutableIntStateOf(0) }55    LaunchedEffect(favicons) {56        favicons?.revision?.collect { revision = it }57    }58    val sizePx = with(LocalDensity.current) { size.roundToPx() }59    val bitmap = remember(domain, revision, sizePx) {60        if (domain != null && favicons != null) favicons.bitmap(domain, sizePx) else null61    }62    if (bitmap != null) {63        Image(64            bitmap = bitmap.asImageBitmap(),65            contentDescription = null,66            contentScale = ContentScale.Crop,67            modifier = modifier68                .size(size)69                .clip(RoundedCornerShape(cornerRadius)),70        )71        return72    }73    val bg = titleBackground(source)74    Box(75        modifier = modifier76            .size(size)77            .clip(RoundedCornerShape(cornerRadius))78            .drawBehind { drawRect(bg) },79        contentAlignment = Alignment.Center,80    ) {81        Text(82            text = titleInitials(source),83            color = peachColors.T0.copy(alpha = 0.92f),84            fontFamily = PeachType.Mono,85            fontWeight = FontWeight.SemiBold,86            letterSpacing = 0.04.em,87            fontSize = (size.value * 0.34f).sp,88        )89    }90}9192fun titleInitials(source: String): String {93    val cleaned = source.replace(Regex("^https?://"), "").removePrefix("www.").trim()94    if (cleaned.isEmpty()) return "·"95    val words = cleaned.split(Regex("[\\s.\\-_/]+")).filter { it.isNotEmpty() }96    if (words.size >= 2 && words[0].isNotEmpty() && words[1].isNotEmpty()) {97        return (words[0][0].toString() + words[1][0].toString()).uppercase()98    }99    val single = words.firstOrNull() ?: cleaned100    return single.take(2).uppercase()101}102103/** Deterministic muted HSL(hue, 32%, 26%) background, matching the browser. */104fun titleBackground(source: String): Color {105    var hash = 0106    for (ch in source) hash = ((hash shl 5) - hash + ch.code)107    val hue = (((hash % 360) + 360) % 360).toFloat()108    return hsl(hue, 0.32f, 0.26f)109}110111private fun hsl(h: Float, s: Float, l: Float): Color {112    val c = (1f - kotlin.math.abs(2 * l - 1f)) * s113    val x = c * (1f - kotlin.math.abs((h / 60f) % 2f - 1f))114    val m = l - c / 2f115    val (r, g, b) = when {116        h < 60 -> Triple(c, x, 0f)117        h < 120 -> Triple(x, c, 0f)118        h < 180 -> Triple(0f, c, x)119        h < 240 -> Triple(0f, x, c)120        h < 300 -> Triple(x, 0f, c)121        else -> Triple(c, 0f, x)122    }123    return Color(r + m, g + m, b + m)124}