From 6572a891ac3ba5f4de7b9ee5dd7eaf6be1fb67d6 Mon Sep 17 00:00:00 2001 From: Mysh Date: Tue, 3 Mar 2026 21:33:22 +0100 Subject: [PATCH 01/12] feat: gemini capsule based themes, lagrange-like By copy-and-pasting from Lagrange codebase I was somewhat able to reproduce similar styling of gemini pages. A lot of TODOs still. --- .../dev/gemcap/data/ClientCertRepository.kt | 109 ++- .../gemcap/domain/CapsuleIdentityGenerator.kt | 118 +++ .../mysh/dev/gemcap/domain/GeminiModels.kt | 46 +- .../mysh/dev/gemcap/domain/GeminiParser.kt | 263 ++++++- .../dev/gemcap/domain/LinkIconResolver.kt | 60 ++ .../java/mysh/dev/gemcap/ui/BrowserScreen.kt | 19 +- .../mysh/dev/gemcap/ui/BrowserViewModel.kt | 72 +- .../dev/gemcap/ui/components/ControlBar.kt | 4 +- .../mysh/dev/gemcap/ui/components/TabItem.kt | 20 +- .../dev/gemcap/ui/components/cards/TabCard.kt | 37 +- .../controlBarComponents/AddressBar.kt | 2 - .../dev/gemcap/ui/content/CachedTextStyles.kt | 54 +- .../mysh/dev/gemcap/ui/content/ContentItem.kt | 6 +- .../gemcap/ui/content/EmbeddedMediaContent.kt | 12 +- .../dev/gemcap/ui/content/HeadingContent.kt | 12 +- .../mysh/dev/gemcap/ui/content/LinkContent.kt | 52 +- .../dev/gemcap/ui/content/ListItemContent.kt | 13 +- .../gemcap/ui/content/PreformattedContent.kt | 10 +- .../dev/gemcap/ui/content/QuoteContent.kt | 9 +- .../mysh/dev/gemcap/ui/content/TextContent.kt | 14 +- .../java/mysh/dev/gemcap/ui/model/TabState.kt | 111 +-- .../gemcap/ui/theme/CapsuleStyleGenerator.kt | 703 ++++++++++++++++++ .../java/mysh/dev/gemcap/ui/theme/Theme.kt | 6 +- app/src/main/res/values/strings.xml | 9 + 24 files changed, 1561 insertions(+), 200 deletions(-) create mode 100644 app/src/main/java/mysh/dev/gemcap/domain/CapsuleIdentityGenerator.kt create mode 100644 app/src/main/java/mysh/dev/gemcap/domain/LinkIconResolver.kt create mode 100644 app/src/main/java/mysh/dev/gemcap/ui/theme/CapsuleStyleGenerator.kt diff --git a/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt b/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt index 3f27313..bda5c22 100644 --- a/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt +++ b/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt @@ -10,8 +10,6 @@ import mysh.dev.gemcap.util.PemUtils import org.json.JSONArray import org.json.JSONObject import java.security.KeyStore -import javax.naming.InvalidNameException -import javax.naming.ldap.LdapName private const val TAG = "ClientCertRepository" @@ -24,6 +22,7 @@ class ClientCertRepository(context: Context) { private val prefs = context.getSharedPreferences("client_certs", Context.MODE_PRIVATE) private val identityStorage = EncryptedIdentityStorage(context) + private var cachedCertificates: List? = null companion object { private const val KEY_CERTIFICATES = "certificates" @@ -40,6 +39,7 @@ class ClientCertRepository(context: Context) { * Retrieves all stored identities. */ fun getCertificates(): List { + cachedCertificates?.let { return it } val json = prefs.getString(KEY_CERTIFICATES, null) ?: return emptyList() return try { val array = JSONArray(json) @@ -50,6 +50,7 @@ class ClientCertRepository(context: Context) { if (available.size != parsed.size) { saveCertificates(available) } + cachedCertificates = available available } catch (e: Exception) { Log.e(TAG, "Failed to parse certificates JSON", e) @@ -251,25 +252,10 @@ class ClientCertRepository(context: Context) { previous: ClientCertificate? ): ClientCertificate { val subjectDn = certificate.subjectX500Principal.name - var commonName: String? = null - var email: String? = null - var organization: String? = null - - try { - val ldapName = LdapName(subjectDn) - ldapName.rdns.forEach { rdn -> - val value = rdn.value?.toString() ?: return@forEach - when { - rdn.type.equals("CN", ignoreCase = true) && commonName == null -> commonName = value - rdn.type.equals("EMAILADDRESS", ignoreCase = true) && email == null -> email = value - rdn.type.equals("O", ignoreCase = true) && organization == null -> organization = value - } - } - } catch (_: IllegalArgumentException) { - // Fall back to the raw subject DN values below. - } catch (_: InvalidNameException) { - // Fall back to the raw subject DN values below. - } + val attrs = parseX500Dn(subjectDn) + val commonName = attrs["CN"] + val email = attrs["EMAILADDRESS"] ?: attrs["1.2.840.113549.1.9.1"] + val organization = attrs["O"] return ClientCertificate( alias = alias, @@ -284,6 +270,78 @@ class ClientCertRepository(context: Context) { ) } + /** + * Parses an RFC 2253 X.500 Distinguished Name into a map of attribute types to values. + * Returns the first value for each attribute type (uppercased keys). + * Supports both comma and semicolon separators per RFC 2253. + */ + private fun parseX500Dn(dn: String): Map { + val normalizedDn = dn.replace(';', ',') // Replace ; with , per RFC 2253 [page:0] + val result = mutableMapOf() + for (rdn in splitDnComponents(normalizedDn)) { + val eqIndex = rdn.indexOf('=') + if (eqIndex > 0) { + val type = rdn.substring(0, eqIndex).trim().uppercase() + val value = unescapeDnValue(rdn.substring(eqIndex + 1).trim()) + if (value.isNotEmpty()) { + result.putIfAbsent(type, value) + } + } + } + return result + } + + /** Splits an RFC 2253 DN string on unescaped commas. */ + private fun splitDnComponents(dn: String): List { + val parts = mutableListOf() + val current = StringBuilder() + var i = 0 + var inQuote = false + while (i < dn.length) { + val c = dn[i] + when { + c == '\\' && i + 1 < dn.length -> { + current.append(c).append(dn[i + 1]) + i += 2 + } + c == '"' -> { + inQuote = !inQuote + i++ + } + c == ',' && !inQuote -> { + parts.add(current.toString()) + current.clear() + i++ + } + else -> { + current.append(c) + i++ + } + } + } + if (current.isNotEmpty()) { + parts.add(current.toString()) + } + return parts + } + + /** Unescapes an RFC 2253 DN attribute value. */ + private fun unescapeDnValue(value: String): String { + val trimmed = value.removeSurrounding("\"") + val sb = StringBuilder() + var i = 0 + while (i < trimmed.length) { + if (trimmed[i] == '\\' && i + 1 < trimmed.length) { + sb.append(trimmed[i + 1]) + i += 2 + } else { + sb.append(trimmed[i]) + i++ + } + } + return sb.toString().trim() + } + private fun runBetaMigrationIfNeeded() { if (prefs.getBoolean(KEY_BETA_MIGRATION_DONE, false)) { return @@ -342,6 +400,7 @@ class ClientCertRepository(context: Context) { } private fun saveCertificates(certificates: List) { + cachedCertificates = null val array = JSONArray() certificates.forEach { cert -> val obj = JSONObject().apply { @@ -373,8 +432,8 @@ class ClientCertRepository(context: Context) { } } -sealed class IdentityImportStoreResult { - data class Success(val certificate: ClientCertificate) : IdentityImportStoreResult() - data class Error(val message: String) : IdentityImportStoreResult() - object NeedsPassphrase : IdentityImportStoreResult() +sealed interface IdentityImportStoreResult { + data class Success(val certificate: ClientCertificate) : IdentityImportStoreResult + data class Error(val message: String) : IdentityImportStoreResult + data object NeedsPassphrase : IdentityImportStoreResult } diff --git a/app/src/main/java/mysh/dev/gemcap/domain/CapsuleIdentityGenerator.kt b/app/src/main/java/mysh/dev/gemcap/domain/CapsuleIdentityGenerator.kt new file mode 100644 index 0000000..b5d02a5 --- /dev/null +++ b/app/src/main/java/mysh/dev/gemcap/domain/CapsuleIdentityGenerator.kt @@ -0,0 +1,118 @@ +package mysh.dev.gemcap.domain + +import android.annotation.SuppressLint +import android.net.Uri +import java.net.URI +import java.util.Locale + +// Identity generation ported from Lagrange: +// - src/gmdocument.c:setThemeSeed_GmDocument() +// - src/gmutil.c:urlThemeSeed_String() / urlPaletteSeed_String() +object CapsuleIdentityGenerator { + + // Lagrange: src/gmutil.c:282-287 (urlUser_String() regex patterns) + private val userPathPattern = Regex("^/~([^/?#]+)") + private val usersPathPattern = Regex("^/users/([^/?#]+)", RegexOption.IGNORE_CASE) + + // Lagrange: src/gmdocument.c:1549-1556 (siteIcons) + // TODO: not all of them are being rendered correctly out of the box lmao, + // I guess I have to now figure out how fontpacks work? + private val siteIcons = intArrayOf( + 0x203B, 0x2042, 0x205C, 0x2182, 0x25ED, 0x2600, 0x2601, 0x2604, 0x2605, 0x2606, + 0x265C, 0x265E, 0x2690, 0x2691, 0x2693, 0x2698, 0x2699, 0x26F0, 0x270E, 0x2728, + 0x272A, 0x272F, 0x2731, 0x2738, 0x273A, 0x273E, 0x2740, 0x2742, 0x2744, 0x2748, + 0x274A, 0x2318, 0x2756, 0x2766, 0x27BD, 0x27C1, 0x27D0, 0x2B19, 0x1F300, 0x1F303, + 0x1F306, 0x1F308, 0x1F30A, 0x1F319, 0x1F31F, 0x1F320, 0x1F340, 0x1F4CD, 0x1F4E1, + 0x1F531, 0x1F533, 0x1F657, 0x1F659, 0x1F665, 0x1F668, 0x1F66B, 0x1F78B, 0x1F796, + 0x1F79C + ) + + fun fromUrl(url: String): CapsuleIdentity { + val normalizedUrl = url.trim() + // Lagrange seed-source flow: + // setThemeSeed_GmDocument(..., urlPaletteSeed_String(url), urlThemeSeed_String(url)) + // See src/gmdocument.c:2348 and src/gmutil.c:324-345. + val themeSeedSource = themeSeedSource(normalizedUrl) + val paletteSeedSource = paletteSeedSource(normalizedUrl, themeSeedSource) + val themeSeed = themeHash(themeSeedSource) + val paletteSeed = themeHash(paletteSeedSource) + + var iconCodePoint = 0 + if (paletteSeedSource.isNotEmpty()) { + // Lagrange: src/gmdocument.c:1561-1563 + val index = (paletteSeed.toInt() ushr 7) % siteIcons.size + iconCodePoint = siteIcons[index] + } + // Lagrange special cases (no explanation though upon why): + // src/gmdocument.c:2124-2129 + when (paletteSeedSource.lowercase(Locale.US)) { + "geminiprotocol.net" -> iconCodePoint = 0x264A + "spartan.mozz.us" -> iconCodePoint = 0x1F4AA + } + + val icon = if (iconCodePoint != 0) String(Character.toChars(iconCodePoint)) else null + + return CapsuleIdentity( + icon = icon, + themeSeed = themeSeed, + paletteSeed = paletteSeed, + themeSeedSource = themeSeedSource, + paletteSeedSource = paletteSeedSource + ) + } + + private fun themeSeedSource(url: String): String { + @SuppressLint("UseKtx") + val uri = Uri.parse(url) ?: return "" + if (uri.scheme?.equals("file", ignoreCase = true) == true) return "" + val path = uri.encodedPath.orEmpty() + val userMatch = userPathPattern.find(path)?.groupValues?.getOrNull(1) + ?: usersPathPattern.find(path)?.groupValues?.getOrNull(1) + return userMatch?.takeIf { it.isNotBlank() } ?: uri.host.orEmpty().lowercase(Locale.US) + } + + + // Partial port of src/gmutil.c:urlPaletteSeed_String() (335-345). + private fun paletteSeedSource(url: String, defaultSource: String): String { + // Lagrange also checks a site-specific override (valueString_SiteSpec + paletteSeed key), + // which is not implemented in Gemcap. + // + // The file-scheme check is redundant with themeSeedSource but kept for parity with + // Lagrange's urlPaletteSeed_String() and to support future site-specific overrides + // that might never arrive. + @SuppressLint("UseKtx") + val uri = Uri.parse(url) ?: return defaultSource + if (uri.scheme.equals("file", ignoreCase = true)) { + return "" + } + return defaultSource + } + + // Lagrange: src/gmdocument.c:88-152 (themeHash_ CRC-32 table) + @OptIn(ExperimentalUnsignedTypes::class) + private val crc32Table: UIntArray by lazy { + UIntArray(256) { i -> + var crc = i.toUInt() + repeat(8) { + crc = if ((crc and 1u) != 0u) { + 0xEDB88320u xor (crc shr 1) + } else { + crc shr 1 + } + } + crc + } + } + + // Lagrange: src/gmdocument.c:88 (themeHash_) + @OptIn(ExperimentalUnsignedTypes::class) + private fun themeHash(seedSource: String): UInt { + if (seedSource.isEmpty()) return 0u + var crc = 0u + for (b in seedSource.toByteArray(Charsets.UTF_8)) { + val idx = ((crc xor (b.toInt() and 0xFF).toUInt()) and 0xFFu).toInt() + crc = crc32Table[idx] xor (crc shr 8) + } + return crc + } +} diff --git a/app/src/main/java/mysh/dev/gemcap/domain/GeminiModels.kt b/app/src/main/java/mysh/dev/gemcap/domain/GeminiModels.kt index ea4ef74..cf5c9ed 100644 --- a/app/src/main/java/mysh/dev/gemcap/domain/GeminiModels.kt +++ b/app/src/main/java/mysh/dev/gemcap/domain/GeminiModels.kt @@ -42,11 +42,55 @@ data class StableByteArray(val bytes: ByteArray) { } } +enum class LinkScheme { + GEMINI, + TITAN, + GOPHER, + FINGER, + HTTP, + FILE, + DATA, + ABOUT, + MAILTO, + SPARTAN, + NEX, + MISFIN, + GUPPY, + UNKNOWN +} + +enum class LinkFlag { + REMOTE, + HUMAN_READABLE, + IMAGE_EXT, + AUDIO_EXT, + FONTPACK_EXT, + QUERY, + ICON_FROM_LABEL, + VISITED // TODO: add visited links styles and state +} + +data class CapsuleIdentity( + val icon: String?, + val themeSeed: UInt, + val paletteSeed: UInt, + val themeSeedSource: String, + val paletteSeedSource: String +) + sealed class GeminiContent { abstract val id: Int data class Text(override val id: Int, val text: String) : GeminiContent() - data class Link(override val id: Int, val url: String, val text: String) : GeminiContent() + data class Link( + override val id: Int, + val url: String, + val text: String, + val resolvedUrl: String? = null, + val scheme: LinkScheme = LinkScheme.UNKNOWN, + val flags: Set = emptySet(), + val labelIcon: String? = null + ) : GeminiContent() data class Heading(override val id: Int, val level: Int, val text: String) : GeminiContent() data class ListItem(override val id: Int, val text: String) : GeminiContent() data class Quote(override val id: Int, val text: String) : GeminiContent() diff --git a/app/src/main/java/mysh/dev/gemcap/domain/GeminiParser.kt b/app/src/main/java/mysh/dev/gemcap/domain/GeminiParser.kt index 665349e..4e35246 100644 --- a/app/src/main/java/mysh/dev/gemcap/domain/GeminiParser.kt +++ b/app/src/main/java/mysh/dev/gemcap/domain/GeminiParser.kt @@ -1,6 +1,7 @@ package mysh.dev.gemcap.domain import java.net.URI +import java.util.Locale object GeminiParser { @@ -31,7 +32,13 @@ object GeminiParser { "wmv" to "video/x-ms-wmv" ) - fun parse(text: String): List { + private data class LabelIconResult( + val displayText: String, + val icon: String?, + val fromLabel: Boolean + ) + + fun parse(text: String, baseUrl: String? = null): List { val lines = text.lines() val content = mutableListOf() var idCounter = 0 @@ -70,10 +77,38 @@ object GeminiParser { line.startsWith("=>") -> { val parts = line.removePrefix("=>").trim().split(Regex("\\s+"), 2) - val url = parts.getOrNull(0) ?: "" - val linkText = parts.getOrNull(1) ?: url + val url = parts.getOrNull(0)?.trim().orEmpty() + val rawLinkText = parts.getOrNull(1)?.trim().orEmpty().ifEmpty { url } val id = idCounter++ - val mediaMimeType = detectMediaMimeType(url) + val resolvedUrl = resolveUrl(url, baseUrl) + val scheme = detectLinkScheme(url, resolvedUrl, baseUrl) + val flags = mutableSetOf() + + val isRemote = isRemoteLink(baseUrl, resolvedUrl) + if (isRemote) { + flags += LinkFlag.REMOTE + } + if (parts.size > 1 && parts[1].isNotBlank()) { + flags += LinkFlag.HUMAN_READABLE + } + + val extensionFlags = extensionFlags(url, resolvedUrl) + flags += extensionFlags + + if (isQueryLink(scheme, url, resolvedUrl)) { + flags += LinkFlag.QUERY + } + + val iconResult = extractLabelIcon( + label = rawLinkText, + scheme = scheme, + isRemote = isRemote + ) + if (iconResult.fromLabel) { + flags += LinkFlag.ICON_FROM_LABEL + } + + val mediaMimeType = detectMediaMimeType(url, resolvedUrl) content.add( if (mediaMimeType != null) { @@ -81,11 +116,19 @@ object GeminiParser { id = id, url = url, mimeType = mediaMimeType, - linkText = linkText, + linkText = iconResult.displayText, state = GeminiContent.EmbeddedMediaState.COLLAPSED ) } else { - GeminiContent.Link(id = id, url = url, text = linkText) + GeminiContent.Link( + id = id, + url = url, + text = iconResult.displayText, + resolvedUrl = resolvedUrl, + scheme = scheme, + flags = flags.toSet(), + labelIcon = iconResult.icon + ) } ) } @@ -121,10 +164,10 @@ object GeminiParser { ) ) - line.startsWith("*") && line.length > 1 -> content.add( + line.length > 1 && line[0] == '*' && line[1].isWhitespace() -> content.add( GeminiContent.ListItem( id = idCounter++, - text = line.removePrefix("*").trimStart() + text = line.substring(1).trimStart() ) ) @@ -153,21 +196,215 @@ object GeminiParser { return content } - fun detectMediaMimeType(url: String): String? { - if (!isGeminiOrRelativeUrl(url)) { + fun detectMediaMimeType(url: String, resolvedUrl: String? = null): String? { + if (!isGeminiOrRelativeUrl(url, resolvedUrl)) { return null } - val pathWithoutQuery = url.substringBefore("?").substringBefore("#") + val pathWithoutQuery = (resolvedUrl ?: url).substringBefore("?").substringBefore("#") val extension = pathWithoutQuery.substringAfterLast(".", "").lowercase() return mediaExtensionToMime[extension] } - private fun isGeminiOrRelativeUrl(url: String): Boolean { + private fun isGeminiOrRelativeUrl(url: String, resolvedUrl: String?): Boolean { return try { - val scheme = URI(url).scheme?.lowercase() + val scheme = URI(resolvedUrl ?: url).scheme?.lowercase(Locale.US) scheme == null || scheme == "gemini" } catch (_: Exception) { !url.contains("://") } } + + private fun resolveUrl(url: String, baseUrl: String?): String? { + if (url.isBlank()) return null + return try { + if (baseUrl.isNullOrBlank()) { + if (url.contains("://") || url.startsWith("about:", ignoreCase = true)) { + URI(url).toString() + } else { + null + } + } else { + URI(baseUrl).resolve(url).toString() + } + } catch (_: Exception) { + if (url.contains("://") || url.startsWith("about:", ignoreCase = true)) url else null + } + } + + private fun detectLinkScheme(url: String, resolvedUrl: String?, baseUrl: String?): LinkScheme { + val scheme = parseScheme(resolvedUrl ?: url) ?: parseScheme(baseUrl) + + return when (scheme) { + "gemini", null -> LinkScheme.GEMINI + "titan" -> LinkScheme.TITAN + "gopher" -> LinkScheme.GOPHER + "finger" -> LinkScheme.FINGER + "http", "https" -> LinkScheme.HTTP + "file" -> LinkScheme.FILE + "data" -> LinkScheme.DATA + "about" -> LinkScheme.ABOUT + "mailto" -> LinkScheme.MAILTO + "spartan" -> LinkScheme.SPARTAN + "nex" -> LinkScheme.NEX + "misfin" -> LinkScheme.MISFIN + "guppy" -> LinkScheme.GUPPY + else -> LinkScheme.UNKNOWN + } + } + + private fun parseScheme(url: String?): String? { + if (url.isNullOrBlank()) return null + return try { + URI(url).scheme?.lowercase(Locale.US) + } catch (_: Exception) { + null + } + } + + private fun isRemoteLink(baseUrl: String?, resolvedUrl: String?): Boolean { + if (baseUrl.isNullOrBlank() || resolvedUrl.isNullOrBlank()) return false + return try { + val baseHost = URI(baseUrl).host?.lowercase(Locale.US).orEmpty() + val targetHost = URI(resolvedUrl).host?.lowercase(Locale.US).orEmpty() + baseHost.isNotEmpty() && targetHost.isNotEmpty() && baseHost != targetHost + } catch (_: Exception) { + false + } + } + + // Lagrange-compatible extension sets (includes formats not in mediaExtensionToMime) + // Though I have to make my own decoders for at least psd and hdr I think? + // TODO: actually check this + private val imageExtensions = setOf( + "gif", "jpg", "jpeg", "png", "tga", "psd", "hdr", "jxl", "webp", "pic" + ) + private val audioExtensions = setOf("mp3", "wav", "ogg", "opus", "mid") // also midi will NOT work + + private fun extensionFlags(url: String, resolvedUrl: String?): Set { + val path = (resolvedUrl ?: url).substringBefore("?").substringBefore("#").lowercase(Locale.US) + if (path.isBlank()) return emptySet() + val ext = path.substringAfterLast('.', "") + if (ext.isEmpty()) return emptySet() + + val flags = mutableSetOf() + if (ext in imageExtensions) flags += LinkFlag.IMAGE_EXT + if (ext in audioExtensions) flags += LinkFlag.AUDIO_EXT + if (ext == "fontpack") flags += LinkFlag.FONTPACK_EXT + return flags + } + + private fun isQueryLink(scheme: LinkScheme, url: String, resolvedUrl: String?): Boolean { + if (url.endsWith("?") || url.contains("%s")) return true + if (scheme == LinkScheme.GOPHER) { + return try { + URI(resolvedUrl ?: url).path?.startsWith("/7") == true + } catch (_: Exception) { + false + } + } + return false + } + + private fun extractLabelIcon( + label: String, + scheme: LinkScheme, + isRemote: Boolean + ): LabelIconResult { + val trimmed = label.trim() + if (trimmed.isEmpty()) { + return LabelIconResult(displayText = label, icon = null, fromLabel = false) + } + + if (!customIconAllowedForScheme(scheme, isRemote)) { + return LabelIconResult(displayText = trimmed, icon = null, fromLabel = false) + } + + val firstIcon = readFirstIconToken(trimmed) ?: return LabelIconResult( + displayText = trimmed, + icon = null, + fromLabel = false + ) + + val firstCodePoint = trimmed.codePointAt(0) + val firstIsAllowed = when { + scheme == LinkScheme.MAILTO || scheme == LinkScheme.MISFIN -> firstCodePoint == 0x1F4E7 + else -> isAllowedLinkIcon(firstCodePoint) + } + if (!firstIsAllowed) { + return LabelIconResult(displayText = trimmed, icon = null, fromLabel = false) + } + + val remaining = trimmed.substring(firstIcon.endIndex).trimStart() + if (remaining.isEmpty()) { + return LabelIconResult(displayText = trimmed, icon = null, fromLabel = false) + } + + val nextCodePoint = remaining.codePointAt(0) + if (isAllowedLinkIcon(nextCodePoint)) { + return LabelIconResult(displayText = trimmed, icon = null, fromLabel = false) + } + + return LabelIconResult(displayText = remaining, icon = firstIcon.icon, fromLabel = true) + } + + private fun customIconAllowedForScheme(scheme: LinkScheme, isRemote: Boolean): Boolean { + return (scheme == LinkScheme.GEMINI && !isRemote) || + scheme == LinkScheme.ABOUT || + scheme == LinkScheme.FILE || + scheme == LinkScheme.MAILTO || + scheme == LinkScheme.MISFIN || + scheme == LinkScheme.UNKNOWN + } + + private data class IconToken( + val icon: String, + val endIndex: Int + ) + + private fun readFirstIconToken(text: String): IconToken? { + if (text.isEmpty()) return null + + val first = text.codePointAt(0) + var end = Character.charCount(first) + + if (isRegionalIndicatorLetter(first) && text.length > end) { + val second = text.codePointAt(end) + if (isRegionalIndicatorLetter(second)) { + end += Character.charCount(second) + } + } + + if (text.length > end) { + val variationSelector = text.codePointAt(end) + if (variationSelector == 0xFE0F) { + end += Character.charCount(variationSelector) + } + } + + return IconToken( + icon = text.substring(0, end), + endIndex = end + ) + } + + private fun isRegionalIndicatorLetter(codePoint: Int): Boolean { + return codePoint in 0x1F1E6..0x1F1FF + } + + private fun isAllowedLinkIcon(codePoint: Int): Boolean { + if (codePoint in 0x1F3FB..0x1F3FF) { + return false + } + return codePoint in 0x1F300..0x1FAFF || + codePoint in 0x2600..0x27BF || + isRegionalIndicatorLetter(codePoint) || + codePoint == 0x2022 || + codePoint == 0x2139 || + (codePoint in 0x2190..0x21FF) || + codePoint == 0x29BF || + codePoint == 0x2A2F || + (codePoint in 0x2B00..0x2BFF) || + codePoint == 0x20BF || + (codePoint in 0x1F191..0x1F19A) + } } diff --git a/app/src/main/java/mysh/dev/gemcap/domain/LinkIconResolver.kt b/app/src/main/java/mysh/dev/gemcap/domain/LinkIconResolver.kt new file mode 100644 index 0000000..7ede5d8 --- /dev/null +++ b/app/src/main/java/mysh/dev/gemcap/domain/LinkIconResolver.kt @@ -0,0 +1,60 @@ +package mysh.dev.gemcap.domain + +object LinkIconResolver { + private const val ARROW = "\u27A4" // ➤ + private const val ENVELOPE = "\uD83D\uDCE7" // 📧 + private const val MAGNIFYING_GLASS = "\uD83D\uDD0D" // 🔍 + private const val POINTING_FINGER = "\uD83D\uDC49" // 👉 + private const val UPLOAD_ARROW = "\u2BA5" // ⮥ + private const val IMAGE = "\uD83D\uDDBC" // 🖼 + private const val FILE = "\uD83D\uDCCE" // 📎 + private const val GLOBE = "\uD83C\uDF10" // 🌐 + private const val NEX = "\uD83D\uDE87" // 🚇 + private const val GUPPY = "\uD83D\uDC1F" // 🐟 + private const val SPARTAN = "\uD83D\uDCAA" // 💪 + private const val FONTPACK = "\uD83D\uDD20" // 🔠 + + fun iconFor(link: GeminiContent.Link): String { + if (LinkFlag.ICON_FROM_LABEL in link.flags && !link.labelIcon.isNullOrBlank()) { + return link.labelIcon + } + if (LinkFlag.QUERY in link.flags) { + return if (link.scheme == LinkScheme.SPARTAN) UPLOAD_ARROW else MAGNIFYING_GLASS + } + return when (link.scheme) { + LinkScheme.TITAN -> UPLOAD_ARROW + LinkScheme.FINGER -> POINTING_FINGER + LinkScheme.NEX -> NEX + LinkScheme.GUPPY -> GUPPY + LinkScheme.SPARTAN -> SPARTAN + LinkScheme.MAILTO, LinkScheme.MISFIN -> ENVELOPE + LinkScheme.DATA -> FILE + LinkScheme.FILE -> FILE + else -> when { + LinkFlag.REMOTE in link.flags -> GLOBE + LinkFlag.IMAGE_EXT in link.flags -> IMAGE + LinkFlag.FONTPACK_EXT in link.flags -> FONTPACK + else -> ARROW + } + } + } + + fun descriptionFor(link: GeminiContent.Link): String { + return when (link.scheme) { + LinkScheme.HTTP -> "HTTP link" + LinkScheme.GEMINI -> "Gemini link" + LinkScheme.GOPHER -> "Gopher link" + LinkScheme.FINGER -> "Finger link" + LinkScheme.TITAN -> "Titan link" + LinkScheme.SPARTAN -> "Spartan link" + LinkScheme.NEX -> "Nex link" + LinkScheme.GUPPY -> "Guppy link" + LinkScheme.MAILTO -> "Email link" + LinkScheme.MISFIN -> "Misfin link" + LinkScheme.FILE -> "File link" + LinkScheme.DATA -> "Data link" + LinkScheme.ABOUT -> "Internal link" + LinkScheme.UNKNOWN -> "Link" + } + } +} diff --git a/app/src/main/java/mysh/dev/gemcap/ui/BrowserScreen.kt b/app/src/main/java/mysh/dev/gemcap/ui/BrowserScreen.kt index 804b123..d31ede4 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/BrowserScreen.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/BrowserScreen.kt @@ -2,6 +2,7 @@ package mysh.dev.gemcap.ui import android.util.Log import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -57,6 +58,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -74,6 +76,7 @@ import mysh.dev.gemcap.data.FontSize import mysh.dev.gemcap.data.SearchEngine import mysh.dev.gemcap.data.ThemeMode import mysh.dev.gemcap.domain.GeminiContent +import mysh.dev.gemcap.R import mysh.dev.gemcap.domain.GeminiError import mysh.dev.gemcap.ui.callbacks.BrowserCallbacks import mysh.dev.gemcap.ui.callbacks.BrowserCallbacksImpl @@ -91,6 +94,8 @@ import mysh.dev.gemcap.ui.model.HOME_URL import mysh.dev.gemcap.ui.model.SearchState import mysh.dev.gemcap.ui.model.TabState import mysh.dev.gemcap.ui.model.TabsUiState +import mysh.dev.gemcap.ui.theme.CapsuleStyleGenerator +import mysh.dev.gemcap.ui.theme.isDarkMode import mysh.dev.gemcap.util.ScreenshotUtils private const val TAG = "Recomposition" @@ -437,7 +442,7 @@ private fun BrowserContent( when { activeTab == null -> { - Text("No Tabs Open", modifier = Modifier.align(Alignment.Center)) + Text(stringResource(R.string.no_tabs_open), modifier = Modifier.align(Alignment.Center)) } activeTab.isLoading && activeTab.content.isEmpty() -> { @@ -530,7 +535,13 @@ private fun GeminiContentList( ) { logRecomposition { ">>> GeminiContentList (${content.size} items)" } - val cachedStyles = rememberCachedTextStyles() + val isDarkMode = isDarkMode() + val capsuleStyle = remember(tab.capsuleIdentity, isDarkMode) { + CapsuleStyleGenerator.fromIdentity(tab.capsuleIdentity, isDarkMode) + } + val cachedStyles = rememberCachedTextStyles(capsuleStyle) + val contentBackground = capsuleStyle?.backgroundColor + ?: MaterialTheme.colorScheme.background val currentPageUrl = tab.displayedUrl val listState = remember(tab.id, currentPageUrl) { val scrollPosition = tab.getScrollPosition(currentPageUrl) @@ -609,7 +620,9 @@ private fun GeminiContentList( ) { SelectionContainer { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .background(contentBackground), contentAlignment = Alignment.TopCenter ) { LazyColumn( diff --git a/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt b/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt index 258d7c3..e2d9fac 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt @@ -28,6 +28,9 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import mysh.dev.gemcap.data.BrowserRepository import mysh.dev.gemcap.data.ClientCertRepository +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import mysh.dev.gemcap.data.FontSize import mysh.dev.gemcap.data.IdentityImportStoreResult import mysh.dev.gemcap.data.ImportResult @@ -38,6 +41,7 @@ import mysh.dev.gemcap.data.TabSession import mysh.dev.gemcap.data.TabSessionState import mysh.dev.gemcap.data.ThemeMode import mysh.dev.gemcap.domain.Bookmark +import mysh.dev.gemcap.domain.CapsuleIdentityGenerator import mysh.dev.gemcap.domain.ClientCertificate import mysh.dev.gemcap.domain.DownloadPromptState import mysh.dev.gemcap.domain.GeminiContent @@ -248,13 +252,9 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) } } private fun reloadRestoredTabs() { - val originalActiveTabId = activeTabId ?: return - val tabIds = tabs.map { it.id } - tabIds.forEach { tabId -> - tabManager.selectTab(tabId) - loadPage(addToHistory = false) - } - tabManager.selectTab(originalActiveTabId) + // Only load the active tab immediately; other tabs load on first selection + activeTabId ?: return + loadPage(addToHistory = false) } // Search delegation @@ -279,6 +279,11 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) fun selectTab(tabId: String) { tabManager.selectTab(tabId) bookmarkManager.updateBookmarkStatus(activeTab?.url) + // Lazy-load tabs that haven't been loaded yet (e.g., restored from session) + val tab = activeTab ?: return + if (!tab.isLoading && tab.content.isEmpty() && tab.error == null && tab.url != HOME_URL) { + loadPage(addToHistory = false) + } } fun onUrlChange(newUrl: String) { @@ -351,10 +356,11 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) mimeType = state.mimeType ) + val app = getApplication() dialogManager.setDownloadMessage( result.fold( - onSuccess = { "Saved to $it" }, - onFailure = { "Download failed: ${it.message}" } + onSuccess = { app.getString(R.string.download_saved, it) }, + onFailure = { app.getString(R.string.download_failed, it.message) } ) ) } @@ -613,6 +619,7 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) tab.addToHistory(finalUrl) } } + tab.capsuleIdentity = CapsuleIdentityGenerator.fromUrl(finalUrl) if (response.status !in 20..29) { tab.error = GeminiError( @@ -627,13 +634,14 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) val mimeType = response.meta.lowercase().split(";").first().trim() when { mimeType.startsWith("image/") -> handleImageResponse(response, tab, finalUrl, mimeType) - mimeType == "text/gemini" || mimeType.isEmpty() -> handleGemtextResponse(response, tab) + mimeType == "text/gemini" || mimeType.isEmpty() -> + handleGemtextResponse(response, tab, finalUrl) mimeType.startsWith("text/") -> handleTextResponse(response, tab, finalUrl, mimeType) else -> handleDownloadResponse(response, tab, finalUrl, mimeType) } tab.updateDisplayedUrl(finalUrl) - tab.cachePage(finalUrl, tab.content, tab.rawBody, tab.title) + tab.cachePage(finalUrl, tab.content, tab.rawBody, tab.title, tab.capsuleIdentity) if (addToHistory) { historyManager.record(finalUrl, tab.title) } @@ -659,10 +667,10 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) tab.title = finalUrl.substringAfterLast("/").ifEmpty { "Image" } } - private fun handleGemtextResponse(response: GeminiResponse, tab: TabState) { + private fun handleGemtextResponse(response: GeminiResponse, tab: TabState, finalUrl: String) { val bodyString = response.body?.toString(Charsets.UTF_8) ?: "" tab.rawBody = bodyString - val parsedContent = GeminiParser.parse(bodyString).toImmutableList() + val parsedContent = GeminiParser.parse(bodyString, baseUrl = finalUrl).toImmutableList() tab.content = parsedContent parsedContent.filterIsInstance() .firstOrNull { it.level == 1 } @@ -700,7 +708,7 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) ) ) tab.content = listOf( - GeminiContent.Text(id = 0, text = "Download available: $fileName") + GeminiContent.Text(id = 0, text = getApplication().getString(R.string.download_available, fileName)) ).toImmutableList() tab.title = fileName } @@ -716,9 +724,7 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) } private fun handleTofuExpired(result: FetchResult.TofuExpired, tab: TabState) { - val expiredDate = - java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault()) - .format(java.util.Date(result.expiredAt)) + val expiredDate = formatTimestamp(result.expiredAt) tab.error = GeminiError( statusCode = 0, message = "Server certificate expired on $expiredDate", @@ -728,9 +734,7 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) } private fun handleTofuNotYetValid(result: FetchResult.TofuNotYetValid, tab: TabState) { - val validFrom = - java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault()) - .format(java.util.Date(result.notBefore)) + val validFrom = formatTimestamp(result.notBefore) tab.error = GeminiError( statusCode = 0, message = "Server certificate is not yet valid (valid from $validFrom)", @@ -780,9 +784,13 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) yield() } val localContent = loadLocalPage(targetUrl) + currentTab.capsuleIdentity = CapsuleIdentityGenerator.fromUrl(targetUrl) if (localContent != null) { currentTab.rawBody = localContent - val parsedContent = GeminiParser.parse(localContent).toImmutableList() + val parsedContent = GeminiParser.parse( + localContent, + baseUrl = targetUrl + ).toImmutableList() currentTab.content = parsedContent parsedContent.filterIsInstance() .firstOrNull { it.level == 1 } @@ -793,7 +801,8 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) targetUrl, currentTab.content, currentTab.rawBody, - currentTab.title + currentTab.title, + currentTab.capsuleIdentity ) } else { currentTab.error = GeminiError( @@ -1165,7 +1174,7 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) val clip = ClipData.newPlainText("Gemini Link", url) clipboardManager.setPrimaryClip(clip) viewModelScope.launch { - snackbarHostState.showSnackbar("Link copied to clipboard") + snackbarHostState.showSnackbar(getApplication().getString(R.string.link_copied_to_clipboard)) } } @@ -1556,19 +1565,11 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) app.getString(R.string.embedded_media_error_tofu_warning, result.host) is GeminiFetchResult.TofuExpired -> { - val expiredDate = java.text.SimpleDateFormat( - "yyyy-MM-dd HH:mm", - java.util.Locale.getDefault() - ).format(java.util.Date(result.expiredAt)) - app.getString(R.string.embedded_media_error_tofu_expired, expiredDate) + app.getString(R.string.embedded_media_error_tofu_expired, formatTimestamp(result.expiredAt)) } is GeminiFetchResult.TofuNotYetValid -> { - val validFrom = java.text.SimpleDateFormat( - "yyyy-MM-dd HH:mm", - java.util.Locale.getDefault() - ).format(java.util.Date(result.notBefore)) - app.getString(R.string.embedded_media_error_tofu_not_yet_valid, validFrom) + app.getString(R.string.embedded_media_error_tofu_not_yet_valid, formatTimestamp(result.notBefore)) } is GeminiFetchResult.CertificateRequired -> { @@ -1589,4 +1590,9 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) is GeminiFetchResult.Success -> app.getString(R.string.embedded_media_error_failed_to_load_media) } } + + // TODO: finally add utils.kt or something + private fun formatTimestamp(millis: Long): String { + return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(millis)) + } } diff --git a/app/src/main/java/mysh/dev/gemcap/ui/components/ControlBar.kt b/app/src/main/java/mysh/dev/gemcap/ui/components/ControlBar.kt index 1aa36ff..4aa9f61 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/components/ControlBar.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/components/ControlBar.kt @@ -100,9 +100,7 @@ fun ControlBar( IconButton(onClick = { callbacks.onNewTab() }) { Icon(Icons.Default.Add, contentDescription = "New Tab") } - } - - if (!toolbarState.isCompactMode) { + } else { IconButton(onClick = { callbacks.onIdentityClick() }) { Icon( Icons.Default.Person, diff --git a/app/src/main/java/mysh/dev/gemcap/ui/components/TabItem.kt b/app/src/main/java/mysh/dev/gemcap/ui/components/TabItem.kt index bf1f20b..9cf16db 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/components/TabItem.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/components/TabItem.kt @@ -16,12 +16,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import mysh.dev.gemcap.ui.theme.CapsuleStyleGenerator +import mysh.dev.gemcap.ui.theme.isDarkMode import mysh.dev.gemcap.ui.model.TabState @Composable @@ -33,6 +36,12 @@ fun TabItem( onClosed: () -> Unit ) { val shape = SimpleChromeTabShape() + val isDarkMode = isDarkMode() + val capsuleStyle = remember(tab.capsuleIdentity, isDarkMode) { + CapsuleStyleGenerator.fromIdentity(tab.capsuleIdentity, isDarkMode) + } + val activeColor = capsuleStyle?.chromeAccentColor?.copy(alpha = 0.12f) ?: MaterialTheme.colorScheme.surface + val titleColor = capsuleStyle?.chromeTextColor ?: MaterialTheme.colorScheme.onSurface Box( modifier = Modifier @@ -40,7 +49,7 @@ fun TabItem( .fillMaxHeight() ) { Surface( - color = if (isActive) MaterialTheme.colorScheme.surface else Color.Transparent, + color = if (isActive) activeColor else Color.Transparent, shape = shape, modifier = Modifier .fillMaxSize() @@ -50,9 +59,18 @@ fun TabItem( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 32.dp) ) { + if (!tab.capsuleIdentity?.icon.isNullOrBlank()) { + Text( + text = tab.capsuleIdentity?.icon.orEmpty(), + style = MaterialTheme.typography.labelLarge, + color = capsuleStyle?.chromeAccentColor ?: titleColor, + modifier = Modifier.padding(end = 6.dp) + ) + } Text( text = tab.title.ifEmpty { "New Tab" }, style = MaterialTheme.typography.labelLarge, + color = titleColor, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) diff --git a/app/src/main/java/mysh/dev/gemcap/ui/components/cards/TabCard.kt b/app/src/main/java/mysh/dev/gemcap/ui/components/cards/TabCard.kt index a36c751..eec4d8b 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/components/cards/TabCard.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/components/cards/TabCard.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -22,12 +23,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import mysh.dev.gemcap.ui.theme.CapsuleStyleGenerator +import mysh.dev.gemcap.ui.theme.isDarkMode import mysh.dev.gemcap.ui.model.TabState @Composable @@ -38,11 +42,18 @@ fun TabCard( onClosed: () -> Unit, aspectRatio: Float ) { - val cardColor = if (isActive) { + val isDarkMode = isDarkMode() + val capsuleStyle = remember(tab.capsuleIdentity, isDarkMode) { + CapsuleStyleGenerator.fromIdentity(tab.capsuleIdentity, isDarkMode) + } + val cardColor = if (isActive && capsuleStyle != null) { + capsuleStyle.chromeAccentColor.copy(alpha = 0.15f) + } else if (isActive) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceVariant } + val titleColor = capsuleStyle?.chromeTextColor ?: MaterialTheme.colorScheme.onSurface Card( modifier = Modifier @@ -60,12 +71,24 @@ fun TabCard( .padding(12.dp) ) { // Title area - Text( - text = tab.title.ifEmpty { "New Tab" }, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (!tab.capsuleIdentity?.icon.isNullOrBlank()) { + Text( + text = tab.capsuleIdentity?.icon.orEmpty(), + style = MaterialTheme.typography.labelLarge, + color = capsuleStyle?.chromeAccentColor ?: titleColor, + modifier = Modifier.padding(end = 6.dp) + ) + } + Text( + text = tab.title.ifEmpty { "New Tab" }, + style = MaterialTheme.typography.labelLarge, + color = titleColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + } Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/mysh/dev/gemcap/ui/components/controlBarComponents/AddressBar.kt b/app/src/main/java/mysh/dev/gemcap/ui/components/controlBarComponents/AddressBar.kt index 3742192..60fd302 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/components/controlBarComponents/AddressBar.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/components/controlBarComponents/AddressBar.kt @@ -53,8 +53,6 @@ import mysh.dev.gemcap.domain.HistoryEntry private object AddressBarDefaults { val Height = 40.dp val CornerRadius = 20.dp - val IconSize = 32.dp - val IconPadding = 6.dp val BorderThickness = 1.dp } diff --git a/app/src/main/java/mysh/dev/gemcap/ui/content/CachedTextStyles.kt b/app/src/main/java/mysh/dev/gemcap/ui/content/CachedTextStyles.kt index 6ef9662..f78851a 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/content/CachedTextStyles.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/content/CachedTextStyles.kt @@ -5,10 +5,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import mysh.dev.gemcap.ui.theme.CapsuleStyle // TODO: it was used to fix recomposition upon scrolling, is it still an issue? @Stable @@ -23,18 +28,34 @@ data class CachedTextStyles( val linkStyle: TextStyle, val quoteStyle: TextStyle, val monoStyle: TextStyle, - val primaryColor: Color, - val tertiaryColor: Color, - val surfaceVariantColor: Color, - val onSurfaceVariantColor: Color + val highlightColor: Color, + val bodyColor: Color, + val heading1Color: Color, + val heading2Color: Color, + val heading3Color: Color, + val linkTextColor: Color, + val linkIconColor: Color, + val quoteTextColor: Color, + val quoteIndicatorColor: Color, + val bulletColor: Color, + val preformattedTextColor: Color, + val preformattedAltColor: Color, + val preformattedBackgroundColor: Color, + val chromeAccentColor: Color, + val chromeTextColor: Color, + val linkIconIndent: Dp ) @Composable -fun rememberCachedTextStyles(): CachedTextStyles { +fun rememberCachedTextStyles(capsuleStyle: CapsuleStyle?): CachedTextStyles { val typography = MaterialTheme.typography val colorScheme = MaterialTheme.colorScheme + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current - return remember(typography, colorScheme) { + return remember(typography, colorScheme, capsuleStyle, density) { + val iconWidth = textMeasurer.measure("\u27A4", typography.bodyLarge).size.width + val indent = with(density) { iconWidth.toDp() } + 6.dp CachedTextStyles( headlineLarge = typography.headlineLarge, headlineMedium = typography.headlineMedium, @@ -46,10 +67,23 @@ fun rememberCachedTextStyles(): CachedTextStyles { linkStyle = typography.bodyLarge.copy(fontWeight = FontWeight.Bold), quoteStyle = typography.bodyMedium.copy(fontStyle = FontStyle.Italic), monoStyle = typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - primaryColor = colorScheme.primary, - tertiaryColor = colorScheme.tertiary, - surfaceVariantColor = colorScheme.surfaceVariant, - onSurfaceVariantColor = colorScheme.onSurfaceVariant + highlightColor = colorScheme.primaryContainer, + bodyColor = capsuleStyle?.bodyColor ?: colorScheme.onSurface, + heading1Color = capsuleStyle?.heading1Color ?: colorScheme.onSurface, + heading2Color = capsuleStyle?.heading2Color ?: colorScheme.onSurface, + heading3Color = capsuleStyle?.heading3Color ?: colorScheme.onSurface, + linkTextColor = capsuleStyle?.linkTextColor ?: colorScheme.primary, + linkIconColor = capsuleStyle?.linkIconColor ?: colorScheme.primary, + quoteTextColor = capsuleStyle?.quoteTextColor ?: colorScheme.onSurfaceVariant, + quoteIndicatorColor = capsuleStyle?.quoteIndicatorColor ?: colorScheme.tertiary, + bulletColor = capsuleStyle?.bulletColor ?: colorScheme.onSurfaceVariant, + preformattedTextColor = capsuleStyle?.preformattedTextColor ?: colorScheme.onSurfaceVariant, + preformattedAltColor = capsuleStyle?.preformattedAltColor ?: colorScheme.onSurfaceVariant, + preformattedBackgroundColor = capsuleStyle?.preformattedBackgroundColor + ?: colorScheme.surfaceVariant, + chromeAccentColor = capsuleStyle?.chromeAccentColor ?: colorScheme.primary, + chromeTextColor = capsuleStyle?.chromeTextColor ?: colorScheme.onSurface, + linkIconIndent = indent ) } } diff --git a/app/src/main/java/mysh/dev/gemcap/ui/content/ContentItem.kt b/app/src/main/java/mysh/dev/gemcap/ui/content/ContentItem.kt index a04d8c1..482ff0b 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/content/ContentItem.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/content/ContentItem.kt @@ -1,10 +1,12 @@ package mysh.dev.gemcap.ui.content import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import mysh.dev.gemcap.domain.GeminiContent import mysh.dev.gemcap.domain.StableByteArray -data class ContentActions( +@Stable +class ContentActions( val onLinkClick: (String) -> Unit, val onOpenImageInNewTab: (String) -> Unit, val onCopyLink: (String) -> Unit, @@ -36,7 +38,7 @@ fun ContentItem( actions.onOpenInNewTab ) - is GeminiContent.Text -> TextContent(item, searchQuery) + is GeminiContent.Text -> TextContent(item, styles, searchQuery) is GeminiContent.ListItem -> ListItemContent(item, styles, searchQuery) is GeminiContent.Quote -> QuoteContent(item, styles, searchQuery) is GeminiContent.Preformatted -> PreformattedContent(item, styles, searchQuery, highlight) diff --git a/app/src/main/java/mysh/dev/gemcap/ui/content/EmbeddedMediaContent.kt b/app/src/main/java/mysh/dev/gemcap/ui/content/EmbeddedMediaContent.kt index 2c19853..fd85be2 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/content/EmbeddedMediaContent.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/content/EmbeddedMediaContent.kt @@ -160,7 +160,7 @@ private fun CollapsedMediaCard( imageVector = icon, contentDescription = null, modifier = Modifier.size(24.dp), - tint = styles.primaryColor + tint = styles.linkIconColor ) Column( @@ -170,7 +170,7 @@ private fun CollapsedMediaCard( Text( text = displayText, style = styles.linkStyle, - color = styles.primaryColor, + color = styles.linkTextColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -279,7 +279,7 @@ private fun LoadingMediaCard( CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.dp, - color = styles.primaryColor + color = styles.linkIconColor ) Text( text = stringResource(R.string.embedded_media_loading, mediaLabel), @@ -462,9 +462,7 @@ private class AudioPlayerState( var playbackError by mutableStateOf(null) private set - fun release() = releasePlayer() - - fun releasePlayer() { + fun release() { val player = mediaPlayer ?: return runCatching { player.setOnPreparedListener(null) @@ -478,7 +476,7 @@ private class AudioPlayerState( } fun prepareAndStartPlayer() { - releasePlayer() + release() playbackError = null val player = MediaPlayer() try { diff --git a/app/src/main/java/mysh/dev/gemcap/ui/content/HeadingContent.kt b/app/src/main/java/mysh/dev/gemcap/ui/content/HeadingContent.kt index bf5334c..0ad1fa9 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/content/HeadingContent.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/content/HeadingContent.kt @@ -1,6 +1,5 @@ package mysh.dev.gemcap.ui.content -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import mysh.dev.gemcap.domain.GeminiContent @@ -12,14 +11,19 @@ fun HeadingContent( styles: CachedTextStyles, searchQuery: String ) { - // TODO: check if lagrange and similar clients support more than 3 headline styles val style = when (item.level) { 1 -> styles.headlineLarge 2 -> styles.headlineMedium else -> styles.headlineSmall } + val color = when (item.level) { + 1 -> styles.heading1Color + 2 -> styles.heading2Color + else -> styles.heading3Color + } Text( - text = highlight(item.text, searchQuery, MaterialTheme.colorScheme.primaryContainer), - style = style + text = highlight(item.text, searchQuery, styles.highlightColor), + style = style, + color = color ) } diff --git a/app/src/main/java/mysh/dev/gemcap/ui/content/LinkContent.kt b/app/src/main/java/mysh/dev/gemcap/ui/content/LinkContent.kt index 2f87ff5..306c2d0 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/content/LinkContent.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/content/LinkContent.kt @@ -4,16 +4,9 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.text.selection.DisableSelection -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Link import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -25,7 +18,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import mysh.dev.gemcap.domain.GeminiContent +import mysh.dev.gemcap.domain.LinkIconResolver import mysh.dev.gemcap.util.highlight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.ui.res.stringResource +import mysh.dev.gemcap.R @Composable fun LinkContent( @@ -37,36 +36,34 @@ fun LinkContent( onOpenInNewTab: (String) -> Unit ) { var showMenu by remember { mutableStateOf(false) } - val isHttpLink = remember(item.url) { - item.url.startsWith("http://", ignoreCase = true) || item.url.startsWith("https://", ignoreCase = true) - } - val linkIcon = if (isHttpLink) Icons.Default.Language else Icons.Default.KeyboardDoubleArrowRight - val linkIconDescription = if (isHttpLink) "HTTP/HTTPS link" else "Gemini link" + val iconGlyph = remember(item) { LinkIconResolver.iconFor(item) } + val linkIconDescription = remember(item) { LinkIconResolver.descriptionFor(item) } + val targetUrl = remember(item.url, item.resolvedUrl) { item.resolvedUrl ?: item.url } - val gestureModifier = Modifier.pointerInput(Unit) { + val gestureModifier = Modifier.pointerInput(targetUrl) { detectTapGestures( - onTap = { onLinkClick(item.url) }, + onTap = { onLinkClick(targetUrl) }, onLongPress = { showMenu = true } ) } - // TODO: padding for Icons.Default.Language is a little bit too tight, it needs to be bigger Row( modifier = gestureModifier, verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - Icon( - imageVector = linkIcon, - contentDescription = linkIconDescription, - tint = styles.primaryColor + Text( + text = iconGlyph, + style = styles.bodyLarge, + color = styles.linkIconColor ) Text( text = highlight( item.text, searchQuery, - MaterialTheme.colorScheme.primaryContainer + styles.highlightColor ), - color = styles.primaryColor, + color = styles.linkTextColor, style = styles.linkStyle ) } @@ -77,19 +74,22 @@ fun LinkContent( onDismissRequest = { showMenu = false } ) { DropdownMenuItem( - text = { Text("Open in new tab") }, + text = { Text(stringResource(R.string.link_menu_open_new_tab)) }, onClick = { - onOpenInNewTab(item.url) + onOpenInNewTab(targetUrl) showMenu = false }, leadingIcon = { - Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = linkIconDescription + ) } ) DropdownMenuItem( - text = { Text("Copy link address") }, + text = { Text(stringResource(R.string.link_menu_copy_address)) }, onClick = { - onCopyLink(item.url) + onCopyLink(targetUrl) showMenu = false }, leadingIcon = { diff --git a/app/src/main/java/mysh/dev/gemcap/ui/content/ListItemContent.kt b/app/src/main/java/mysh/dev/gemcap/ui/content/ListItemContent.kt index 845a659..22cc760 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/content/ListItemContent.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/content/ListItemContent.kt @@ -1,7 +1,6 @@ package mysh.dev.gemcap.ui.content import androidx.compose.foundation.layout.Row -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import mysh.dev.gemcap.domain.GeminiContent @@ -14,11 +13,15 @@ fun ListItemContent( searchQuery: String ) { Row { - // TODO: replace dot lol? - Text(text = "• ", style = styles.bodyMedium) Text( - text = highlight(item.text, searchQuery, MaterialTheme.colorScheme.primaryContainer), - style = styles.bodyMedium + text = "\u2022 ", // "• " + style = styles.bodyMedium, + color = styles.bulletColor + ) + Text( + text = highlight(item.text, searchQuery, styles.highlightColor), + style = styles.bodyMedium, + color = styles.bodyColor ) } } diff --git a/app/src/main/java/mysh/dev/gemcap/ui/content/PreformattedContent.kt b/app/src/main/java/mysh/dev/gemcap/ui/content/PreformattedContent.kt index 1113b82..8c3318b 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/content/PreformattedContent.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/content/PreformattedContent.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -24,9 +23,9 @@ fun PreformattedContent( highlight: Boolean ) { val highlightColor = - if (highlight) MaterialTheme.colorScheme.primaryContainer else Color.Transparent + if (highlight) styles.highlightColor else Color.Transparent Card( - colors = CardDefaults.cardColors(containerColor = styles.surfaceVariantColor), + colors = CardDefaults.cardColors(containerColor = styles.preformattedBackgroundColor), modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.padding(8.dp)) { @@ -34,7 +33,7 @@ fun PreformattedContent( Text( text = item.alt, style = styles.monoStyle, - color = styles.onSurfaceVariantColor + color = styles.preformattedAltColor ) } Text( @@ -46,7 +45,8 @@ fun PreformattedContent( fontFamily = styles.monoStyle.fontFamily ?: FontFamily.Monospace ) ), - style = styles.monoStyle + style = styles.monoStyle, + color = styles.preformattedTextColor ) } } diff --git a/app/src/main/java/mysh/dev/gemcap/ui/content/QuoteContent.kt b/app/src/main/java/mysh/dev/gemcap/ui/content/QuoteContent.kt index 0c8f869..848d818 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/content/QuoteContent.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/content/QuoteContent.kt @@ -1,7 +1,6 @@ package mysh.dev.gemcap.ui.content import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -17,9 +16,10 @@ fun QuoteContent( styles: CachedTextStyles, searchQuery: String ) { - val borderColor = styles.tertiaryColor + val borderColor = styles.quoteIndicatorColor Text( modifier = Modifier + .padding(start = styles.linkIconIndent) .drawBehind { drawLine( color = borderColor, @@ -29,7 +29,8 @@ fun QuoteContent( ) } .padding(start = 8.dp), - text = highlight(item.text, searchQuery, MaterialTheme.colorScheme.primaryContainer), - style = styles.quoteStyle + text = highlight(item.text, searchQuery, styles.highlightColor), + style = styles.quoteStyle, + color = styles.quoteTextColor ) } diff --git a/app/src/main/java/mysh/dev/gemcap/ui/content/TextContent.kt b/app/src/main/java/mysh/dev/gemcap/ui/content/TextContent.kt index 5bea69f..e04670a 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/content/TextContent.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/content/TextContent.kt @@ -1,14 +1,22 @@ package mysh.dev.gemcap.ui.content -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import mysh.dev.gemcap.domain.GeminiContent import mysh.dev.gemcap.util.highlight @Composable -fun TextContent(item: GeminiContent.Text, searchQuery: String) { +fun TextContent( + item: GeminiContent.Text, + styles: CachedTextStyles, + searchQuery: String +) { Text( - text = highlight(item.text, searchQuery, MaterialTheme.colorScheme.primaryContainer) + text = highlight(item.text, searchQuery, styles.highlightColor), + color = styles.bodyColor, + style = styles.bodyLarge, + modifier = Modifier.padding(start = styles.linkIconIndent) ) } diff --git a/app/src/main/java/mysh/dev/gemcap/ui/model/TabState.kt b/app/src/main/java/mysh/dev/gemcap/ui/model/TabState.kt index 8196e28..35c1430 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/model/TabState.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/model/TabState.kt @@ -8,9 +8,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.ImageBitmap import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import mysh.dev.gemcap.domain.CapsuleIdentity import mysh.dev.gemcap.domain.GeminiContent import mysh.dev.gemcap.domain.GeminiError +import java.util.Collections import java.util.UUID +import java.util.concurrent.ConcurrentHashMap /** * Cached page data for instant back/forward navigation. @@ -18,7 +21,8 @@ import java.util.UUID data class PageCache( val content: ImmutableList, val rawBody: String?, - val title: String + val title: String, + val capsuleIdentity: CapsuleIdentity? ) data class ScrollPosition( @@ -48,19 +52,21 @@ class TabState( var error by mutableStateOf(null) var content by mutableStateOf>(persistentListOf()) var rawBody by mutableStateOf(null) + var capsuleIdentity by mutableStateOf(null) // Screenshot preview for tab switcher var previewBitmap by mutableStateOf(null) // History management - historyIndex is observable for BackHandler to work properly - private val history = mutableListOf() + // and history is accessed from multiple coroutine contexts + private val history = Collections.synchronizedList(mutableListOf()) private var historyIndex by mutableIntStateOf(-1) // Page cache for back/forward navigation - private val pageCache = mutableMapOf() + private val pageCache = ConcurrentHashMap() // Scroll position cache for back/forward navigation - private val scrollPositions = mutableMapOf() + private val scrollPositions = ConcurrentHashMap() /** * Get cached content for a URL, if available. @@ -74,9 +80,10 @@ class TabState( pageUrl: String, pageContent: ImmutableList, pageRawBody: String?, - pageTitle: String + pageTitle: String, + pageCapsuleIdentity: CapsuleIdentity? ) { - pageCache[pageUrl] = PageCache(pageContent, pageRawBody, pageTitle) + pageCache[pageUrl] = PageCache(pageContent, pageRawBody, pageTitle, pageCapsuleIdentity) } /** @@ -86,6 +93,7 @@ class TabState( content = cached.content rawBody = cached.rawBody title = cached.title + capsuleIdentity = cached.capsuleIdentity error = null displayedUrl = pageUrl } @@ -113,12 +121,14 @@ class TabState( } fun addToHistory(newUrl: String) { - // If we are not at the end of history, clear forward history - if (historyIndex < history.size - 1) { - history.subList(historyIndex + 1, history.size).clear() + synchronized(history) { + // If we are not at the end of history, clear forward history + if (historyIndex < history.size - 1) { + history.subList(historyIndex + 1, history.size).clear() + } + history.add(newUrl) + historyIndex = history.size - 1 } - history.add(newUrl) - historyIndex = history.size - 1 url = newUrl } @@ -126,56 +136,67 @@ class TabState( fun canGoForward(): Boolean = historyIndex < history.size - 1 fun goBack(): String? { - if (canGoBack()) { - historyIndex-- - url = history[historyIndex] - return url + synchronized(history) { + if (canGoBack()) { + historyIndex-- + url = history[historyIndex] + return url + } } return null } fun goForward(): String? { - if (canGoForward()) { - historyIndex++ - url = history[historyIndex] - return url + synchronized(history) { + if (canGoForward()) { + historyIndex++ + url = history[historyIndex] + return url + } } return null } fun restoreHistory(historyUrls: List, index: Int) { - history.clear() - history.addAll(historyUrls.filter { it.isNotBlank() }) - if (history.isEmpty()) { - historyIndex = -1 - return + synchronized(history) { + history.clear() + history.addAll(historyUrls.filter { it.isNotBlank() }) + if (history.isEmpty()) { + historyIndex = -1 + return + } + historyIndex = index.coerceIn(0, history.lastIndex) + url = history[historyIndex] } - historyIndex = index.coerceIn(0, history.lastIndex) - url = history[historyIndex] } fun restoreScrollPositions(positions: Map) { scrollPositions.clear() scrollPositions.putAll(positions) } fun buildPersistedHistoryWindow(maxEntries: Int): PersistedHistoryWindow { - if (history.isEmpty()) { - return PersistedHistoryWindow(emptyList(), 0, emptyMap()) - } - val boundedMax = maxEntries.coerceAtLeast(1) - val current = historyIndex.coerceIn(0, history.lastIndex) - if (history.size <= boundedMax) { - val entries = history.toList() - val persistedScroll = entries.associateWith { key -> - scrollPositions[key] ?: ScrollPosition() + synchronized(history) { + if (history.isEmpty()) { + return PersistedHistoryWindow(emptyList(), 0, emptyMap()) } - return PersistedHistoryWindow(entries, current, persistedScroll) - } - val halfWindow = boundedMax / 2 - val start = (current - halfWindow).coerceIn(0, history.size - boundedMax) - val end = start + boundedMax - val entries = history.subList(start, end).toList() - val persistedScroll = entries.associateWith { key -> - scrollPositions[key] ?: ScrollPosition() + val boundedMax = maxEntries.coerceAtLeast(1) + val current = historyIndex.coerceIn(0, history.lastIndex) + val entries: List + val adjustedIndex: Int + if (history.size <= boundedMax) { + entries = history.toList() + adjustedIndex = current + } else { + val halfWindow = boundedMax / 2 + val start = (current - halfWindow).coerceIn(0, history.size - boundedMax) + val end = start + boundedMax + entries = history.subList(start, end).toList() + adjustedIndex = current - start + } + // Use last occurrence's scroll position for duplicate URLs + val persistedScroll = mutableMapOf() + for (entry in entries) { + persistedScroll.putIfAbsent(entry, scrollPositions[entry] ?: ScrollPosition()) + } + return PersistedHistoryWindow(entries, adjustedIndex, persistedScroll) } - return PersistedHistoryWindow(entries, current - start, persistedScroll) } -} \ No newline at end of file +} diff --git a/app/src/main/java/mysh/dev/gemcap/ui/theme/CapsuleStyleGenerator.kt b/app/src/main/java/mysh/dev/gemcap/ui/theme/CapsuleStyleGenerator.kt new file mode 100644 index 0000000..e915e75 --- /dev/null +++ b/app/src/main/java/mysh/dev/gemcap/ui/theme/CapsuleStyleGenerator.kt @@ -0,0 +1,703 @@ +package mysh.dev.gemcap.ui.theme + +import androidx.compose.ui.graphics.Color +import mysh.dev.gemcap.domain.CapsuleIdentity +import kotlin.math.abs + +data class CapsuleStyle( + val backgroundColor: Color, + val bodyColor: Color, + val heading1Color: Color, + val heading2Color: Color, + val heading3Color: Color, + val quoteTextColor: Color, + val quoteIndicatorColor: Color, + val preformattedTextColor: Color, + val preformattedAltColor: Color, + val preformattedBackgroundColor: Color, + val linkTextColor: Color, + val linkIconColor: Color, + val bulletColor: Color, + val chromeAccentColor: Color, + val chromeTextColor: Color +) + +// Color theme generation ported from Lagrange's setThemeSeed_GmDocument(). +// Lagrange source: src/gmdocument.c:1546 +// Core color utilities: src/ui/color.c, src/ui/color.h +object CapsuleStyleGenerator { + // Lagrange: src/ui/color.h:249 (minSat_HSLColor) + private const val minSatHsl = 0.013f + private const val saturationPreference = 1f + + private enum class Theme { + COLORFUL_DARK, + COLORFUL_LIGHT + } + + // Lagrange: src/ui/color.h:128-178 (tmFirst_ColorId .. max_ColorId) + private enum class ColorId { + BACKGROUND, + PARAGRAPH, + FIRST_PARAGRAPH, + QUOTE, + QUOTE_ICON, + PREFORMATTED, + HEADING1, + HEADING2, + HEADING3, + BANNER_BACKGROUND, + BANNER_TITLE, + BANNER_ICON, + BANNER_SIDE_TITLE, + INLINE_CONTENT_METADATA, + BACKGROUND_ALT_TEXT, + FRAME_ALT_TEXT, + BACKGROUND_OPEN_LINK, + LINK_FEED_ENTRY_DATE, + LINK_CUSTOM_ICON_VISITED, + BAD_LINK, + LINK_ICON, + LINK_ICON_VISITED, + LINK_TEXT, + LINK_TEXT_HOVER, + HYPERTEXT_LINK_ICON, + HYPERTEXT_LINK_ICON_VISITED, + HYPERTEXT_LINK_TEXT, + HYPERTEXT_LINK_TEXT_HOVER, + GOPHER_LINK_ICON, + GOPHER_LINK_ICON_VISITED, + GOPHER_LINK_TEXT, + GOPHER_LINK_TEXT_HOVER, + BANNER_ITEM_BACKGROUND, + BANNER_ITEM_FRAME, + BANNER_ITEM_TITLE, + BANNER_ITEM_TEXT + } + + private val allColorIds = ColorId.entries.toTypedArray() + + private data class Rgba8( + val r: Int, + val g: Int, + val b: Int, + val a: Int = 255 + ) { + fun asComposeColor(): Color { + return Color( + red = r / 255f, + green = g / 255f, + blue = b / 255f, + alpha = a / 255f + ) + } + } + + // Lagrange: src/ui/color.h:245-247 (Impl_HSLColor) + private data class HslColor( + val hue: Float, + val sat: Float, + val lum: Float, + val alpha: Float = 1f + ) + + private data class CorePalette( + val black: Rgba8, + val gray25: Rgba8, + val gray50: Rgba8, + val gray75: Rgba8, + val white: Rgba8, + val brown: Rgba8, + val orange: Rgba8, + val teal: Rgba8, + val cyan: Rgba8, + val maroon: Rgba8, + val red: Rgba8, + val darkGreen: Rgba8, + val green: Rgba8, + val indigo: Rgba8, + val blue: Rgba8 + ) + + private class ThemePalette { + private val colors = Array(allColorIds.size) { Rgba8(0, 0, 0, 255) } + + operator fun get(id: ColorId): Rgba8 = colors[id.ordinal] + + operator fun set(id: ColorId, color: Rgba8) { + colors[id.ordinal] = color + } + } + + // Lagrange: src/ui/color.c:33-53 (darkPalette_) + private val darkCorePalette = CorePalette( + black = Rgba8(0, 0, 0), + gray25 = Rgba8(40, 40, 40), + gray50 = Rgba8(80, 80, 80), + gray75 = Rgba8(160, 160, 160), + white = Rgba8(255, 255, 255), + brown = Rgba8(98, 67, 7), + orange = Rgba8(255, 170, 32), + teal = Rgba8(0, 96, 128), + cyan = Rgba8(0, 192, 255), + maroon = Rgba8(140, 32, 32), + red = Rgba8(255, 80, 80), + darkGreen = Rgba8(24, 80, 24), + green = Rgba8(48, 200, 48), + indigo = Rgba8(0, 70, 128), + blue = Rgba8(40, 132, 255) + ) + + // Lagrange: src/ui/color.c:55-75 (lightPalette_) + private val lightCorePalette = CorePalette( + black = Rgba8(0, 0, 0), + gray25 = Rgba8(75, 75, 75), + gray50 = Rgba8(150, 150, 150), + gray75 = Rgba8(235, 235, 235), + white = Rgba8(255, 255, 255), + brown = Rgba8(210, 120, 10), + orange = Rgba8(235, 215, 200), + teal = Rgba8(10, 110, 130), + cyan = Rgba8(170, 215, 220), + maroon = Rgba8(150, 60, 55), + red = Rgba8(240, 180, 170), + darkGreen = Rgba8(50, 100, 50), + green = Rgba8(128, 200, 128), + indigo = Rgba8(50, 120, 190), + blue = Rgba8(150, 211, 255) + ) + + // Lagrange: src/gmdocument.c:1793 + private val hues = floatArrayOf( + 5f, 25f, 40f, 56f, 95f, 120f, 160f, 180f, 208f, 231f, 270f, 334f + ) + + // Lagrange: src/gmdocument.c:1794-1809 (altHues) + private val altHueIndices = arrayOf( + intArrayOf(2, 3), // red + intArrayOf(8, 3), // reddish orange + intArrayOf(7, 6), // yellowish orange + intArrayOf(5, 7), // yellow + intArrayOf(8, 2), // greenish yellow + intArrayOf(2, 3), // green + intArrayOf(2, 8), // bluish green + intArrayOf(2, 5), // cyan + intArrayOf(6, 10), // sky blue + intArrayOf(3, 11), // blue + intArrayOf(8, 9), // violet + intArrayOf(7, 8) // pink + ) + + // Lagrange: src/gmdocument.c:1918-1919 + private val normSat = floatArrayOf( + 0.85f, 0.90f, 1.00f, 0.65f, 0.65f, 0.65f, + 0.90f, 0.90f, 1.00f, 0.90f, 1.00f, 0.75f + ) + + fun fromIdentity(identity: CapsuleIdentity?, isDarkMode: Boolean): CapsuleStyle? { + if (identity == null) return null + if (identity.paletteSeed == 0u) return null + + val theme = if (isDarkMode) Theme.COLORFUL_DARK else Theme.COLORFUL_LIGHT + val core = if (isDarkMode) darkCorePalette else lightCorePalette + val palette = ThemePalette() + + setDefaultLinkColors(palette, core, theme) + setDefaultNonLinkColors(palette, core, theme) + applySaturationPreference(palette, saturationPreference) + applySeededThemeColors(palette, core, theme, isDarkMode, identity.paletteSeed) + setDerivedThemeColors(palette, core, theme) + + return CapsuleStyle( + backgroundColor = palette[ColorId.BACKGROUND].asComposeColor(), + bodyColor = palette[ColorId.PARAGRAPH].asComposeColor(), + heading1Color = palette[ColorId.HEADING1].asComposeColor(), + heading2Color = palette[ColorId.HEADING2].asComposeColor(), + heading3Color = palette[ColorId.HEADING3].asComposeColor(), + quoteTextColor = palette[ColorId.QUOTE].asComposeColor(), + quoteIndicatorColor = palette[ColorId.QUOTE_ICON].asComposeColor(), + preformattedTextColor = palette[ColorId.PREFORMATTED].asComposeColor(), + preformattedAltColor = palette[ColorId.INLINE_CONTENT_METADATA].asComposeColor(), + preformattedBackgroundColor = palette[ColorId.BACKGROUND_ALT_TEXT].asComposeColor(), + linkTextColor = palette[ColorId.LINK_TEXT].asComposeColor(), + linkIconColor = palette[ColorId.LINK_ICON].asComposeColor(), + bulletColor = palette[ColorId.PARAGRAPH].asComposeColor(), + chromeAccentColor = palette[ColorId.BANNER_ICON].asComposeColor(), + chromeTextColor = palette[ColorId.BANNER_TITLE].asComposeColor() + ) + } + + private fun setDefaultLinkColors(palette: ThemePalette, core: CorePalette, theme: Theme) { + palette[ColorId.BAD_LINK] = core.red + if (isDarkTheme(theme)) { + palette[ColorId.INLINE_CONTENT_METADATA] = core.cyan + palette[ColorId.LINK_TEXT] = core.white + palette[ColorId.LINK_ICON] = core.cyan + palette[ColorId.LINK_TEXT_HOVER] = core.cyan + palette[ColorId.LINK_ICON_VISITED] = core.teal + palette[ColorId.HYPERTEXT_LINK_TEXT] = core.white + palette[ColorId.HYPERTEXT_LINK_ICON] = core.orange + palette[ColorId.HYPERTEXT_LINK_TEXT_HOVER] = core.orange + palette[ColorId.HYPERTEXT_LINK_ICON_VISITED] = core.brown + palette[ColorId.GOPHER_LINK_TEXT] = core.white + palette[ColorId.GOPHER_LINK_ICON] = core.green + palette[ColorId.GOPHER_LINK_ICON_VISITED] = core.darkGreen + palette[ColorId.GOPHER_LINK_TEXT_HOVER] = core.green + } else { + palette[ColorId.INLINE_CONTENT_METADATA] = core.brown + palette[ColorId.LINK_TEXT] = core.black + palette[ColorId.LINK_ICON] = core.teal + palette[ColorId.LINK_TEXT_HOVER] = core.teal + palette[ColorId.LINK_ICON_VISITED] = core.cyan + palette[ColorId.HYPERTEXT_LINK_TEXT] = core.black + palette[ColorId.HYPERTEXT_LINK_TEXT_HOVER] = core.brown + palette[ColorId.HYPERTEXT_LINK_ICON] = core.brown + palette[ColorId.HYPERTEXT_LINK_ICON_VISITED] = core.orange + palette[ColorId.GOPHER_LINK_TEXT] = core.black + palette[ColorId.GOPHER_LINK_TEXT_HOVER] = core.darkGreen + palette[ColorId.GOPHER_LINK_ICON] = core.darkGreen + palette[ColorId.GOPHER_LINK_ICON_VISITED] = core.green + } + } + + private fun setDefaultNonLinkColors( + palette: ThemePalette, + core: CorePalette, + theme: Theme + ) { + if (theme == Theme.COLORFUL_DARK) { + val base = HslColor(200f, 0f, 0.15f, 1f) + setHsl(palette, ColorId.BACKGROUND, base) + palette[ColorId.PARAGRAPH] = core.gray75 + setHsl(palette, ColorId.FIRST_PARAGRAPH, addSatLum(base, 0f, 0.75f)) + palette[ColorId.QUOTE] = core.cyan + palette[ColorId.PREFORMATTED] = core.cyan + palette[ColorId.HEADING1] = core.white + setHsl(palette, ColorId.HEADING2, addSatLum(base, 0.5f, 0.5f)) + setHsl(palette, ColorId.HEADING3, addSatLum(base, 1f, 0.4f)) + setHsl(palette, ColorId.BANNER_BACKGROUND, addSatLum(base, 0f, -0.05f)) + palette[ColorId.BANNER_TITLE] = core.white + palette[ColorId.BANNER_ICON] = core.orange + } else { + val base = addSatLum(hsl(core.teal), -0.3f, 0.5f) + setHsl(palette, ColorId.BACKGROUND, base) + palette[ColorId.PARAGRAPH] = core.black + palette[ColorId.FIRST_PARAGRAPH] = core.black + setHsl(palette, ColorId.QUOTE, addSatLum(base, 0f, -0.25f)) + setHsl(palette, ColorId.PREFORMATTED, addSatLum(base, 0f, -0.3f)) + setHsl(palette, ColorId.HEADING1, addSatLum(base, 1f, -0.37f)) + palette[ColorId.HEADING2] = mix(palette[ColorId.HEADING1], core.black, 0.5f) + palette[ColorId.HEADING3] = mix(palette[ColorId.BACKGROUND], core.black, 0.4f) + setHsl(palette, ColorId.BANNER_BACKGROUND, addSatLum(base, 0f, -0.1f)) + setHsl(palette, ColorId.BANNER_ICON, addSatLum(base, 0f, -0.4f)) + setHsl(palette, ColorId.BANNER_TITLE, addSatLum(base, 0f, -0.4f)) + setHsl(palette, ColorId.LINK_ICON, addSatLum(hsl(core.teal), 0f, 0f)) + palette[ColorId.LINK_ICON_VISITED] = mix(palette[ColorId.BACKGROUND], core.teal, 0.35f) + setHsl(palette, ColorId.HYPERTEXT_LINK_ICON, hsl(core.white)) + palette[ColorId.HYPERTEXT_LINK_ICON_VISITED] = mix(palette[ColorId.BACKGROUND], core.white, 0.5f) + setHsl( + palette, + ColorId.GOPHER_LINK_ICON, + addSatLum(hsl(palette[ColorId.GOPHER_LINK_ICON]), 0f, -0.25f) + ) + setHsl( + palette, + ColorId.GOPHER_LINK_TEXT_HOVER, + addSatLum(hsl(palette[ColorId.GOPHER_LINK_TEXT_HOVER]), 0f, -0.3f) + ) + } + } + + private fun applySaturationPreference(palette: ThemePalette, saturation: Float) { + for (id in allColorIds) { + if (!isLinkColor(id)) { + val color = hsl(palette[id]) + setHsl(palette, id, color.copy(sat = color.sat * saturation)) + } + } + } + + // Lagrange: src/gmdocument.c:1779-2112 (seed extraction and theme color application) + private fun applySeededThemeColors( + palette: ThemePalette, + core: CorePalette, + theme: Theme, + isDarkUi: Boolean, + themeSeed: UInt + ) { + if (themeSeed == 0u) return + + val seedHues = hues.copyOf() + if ((themeSeed and 0x00c00000u) != 0u) { + val shift = if ((themeSeed and 0x00200000u) != 0u) 10f else -10f + for (i in seedHues.indices) { + seedHues[i] += shift + } + } + + var primIndex = (themeSeed and 0xffu).toInt() % seedHues.size + if (primIndex == 11 && (themeSeed and 0x04000000u) != 0u) { + primIndex = (((primIndex.toUInt() + themeSeed) and 0x0fu).toInt()) % 12 + } + + val altIndex = intArrayOf( + if ((themeSeed and 0x4u) != 0u) 1 else 0, + if ((themeSeed and 0x40u) != 0u) 1 else 0 + ) + val altHue = seedHues[altHueIndices[primIndex][altIndex[0]]] + val altHue2 = seedHues[altHueIndices[primIndex][altIndex[1]]] + + val isBannerLighter = (themeSeed and 0x4000u) != 0u || !isDarkUi + val isDarkBgSat = (themeSeed and 0x200000u) != 0u && (primIndex < 1 || primIndex > 4) + val normLums = normLums(seedHues) + + // Lagrange: src/gmdocument.c:1860-1902 + if (theme == Theme.COLORFUL_DARK) { + val base = HslColor( + hue = seedHues[primIndex], + sat = 0.8f * ((themeSeed shr 24) and 0xffu).toFloat() / 255f + minSatHsl, + lum = 0.06f + 0.09f * (((themeSeed shr 5) and 0x7u).toFloat() / 7f), + alpha = 1f + ) + val altBase = HslColor(altHue, base.sat, base.lum, 1f) + + setHsl(palette, ColorId.BACKGROUND, base) + setHsl( + palette, + ColorId.BANNER_BACKGROUND, + addSatLum(base, 0.1f, 0.04f * (if (isBannerLighter) 1f else -1f)) + ) + setHsl( + palette, + ColorId.BANNER_TITLE, + setLum(addSatLum(base, 0.1f, 0f), 0.55f) + ) + setHsl( + palette, + ColorId.BANNER_ICON, + setLum(addSatLum(base, 0.35f, 0f), 0.65f) + ) + + val titleLum = 0.2f * (((themeSeed shr 17) and 0x7u).toFloat() / 7f) + setHsl(palette, ColorId.HEADING1, setLum(altBase, titleLum + 0.80f)) + setHsl(palette, ColorId.HEADING2, setLum(altBase, titleLum + 0.70f)) + setHsl(palette, ColorId.HEADING3, setLum(altBase, titleLum + 0.60f)) + setHsl(palette, ColorId.PARAGRAPH, addSatLum(base, 0.1f, 0.6f)) + + // Lagrange: src/gmdocument.c:1892 + if (delta(palette[ColorId.HEADING3], palette[ColorId.PARAGRAPH]) <= 80) { + setHsl( + palette, + ColorId.HEADING2, + addSatLum(hsl(palette[ColorId.HEADING2]), 0.4f, -0.12f) + ) + setHsl( + palette, + ColorId.HEADING3, + addSatLum(hsl(palette[ColorId.HEADING3]), 0.4f, -0.2f) + ) + } + + setHsl(palette, ColorId.FIRST_PARAGRAPH, addSatLum(base, 0.2f, 0.72f)) + setHsl(palette, ColorId.PREFORMATTED, HslColor(altHue2, 1f, 0.75f, 1f)) + palette[ColorId.QUOTE] = palette[ColorId.PREFORMATTED] + palette[ColorId.INLINE_CONTENT_METADATA] = palette[ColorId.HEADING3] + // Lagrange: src/gmdocument.c:1903-1944 + } else { + val normLum = normLums[primIndex] + var base = HslColor(seedHues[primIndex], 1f, normLum, 1f) + val h1 = HslColor(seedHues[primIndex], 1f, normLum - 0.37f, 1f) + base = base.copy(sat = base.sat * normSat[primIndex] * 0.8f) + + palette[ColorId.PARAGRAPH] = core.black + palette[ColorId.FIRST_PARAGRAPH] = core.black + + setHsl(palette, ColorId.BACKGROUND, base) + setHsl(palette, ColorId.QUOTE, addSatLum(base, 0f, -base.lum * 0.67f)) + setHsl(palette, ColorId.PREFORMATTED, addSatLum(base, 0f, -base.lum * 0.75f)) + setHsl(palette, ColorId.HEADING1, h1) + setHsl(palette, ColorId.HEADING2, addSatLum(h1, 0f, -0.1f)) + palette[ColorId.HEADING3] = mix(palette[ColorId.HEADING1], core.black, 0.6f) + setHsl( + palette, + ColorId.BANNER_BACKGROUND, + addSatLum(base, 0f, if (isDarkUi) -0.2f * (1f - normLum) else 0.2f * (1f - normLum)) + ) + setHsl(palette, ColorId.BANNER_ICON, addSatLum(base, 0f, if (isDarkUi) -0.6f else -0.3f)) + setHsl(palette, ColorId.BANNER_TITLE, addSatLum(base, 0f, if (isDarkUi) -0.5f else -0.25f)) + palette[ColorId.LINK_ICON_VISITED] = mix(palette[ColorId.BACKGROUND], core.teal, 0.3f) + } + + // Lagrange: src/gmdocument.c:2056 + if (isDarkTheme(theme)) { + val base = HslColor(seedHues[primIndex], 1f, normLums[primIndex], 1f) + palette[ColorId.LINK_TEXT] = mix(palette[ColorId.LINK_TEXT], rgb(base), 0.25f) + palette[ColorId.HYPERTEXT_LINK_TEXT] = palette[ColorId.LINK_TEXT] + palette[ColorId.GOPHER_LINK_TEXT] = palette[ColorId.LINK_TEXT] + } + + // Lagrange: src/gmdocument.c:2069-2112 (isDarkBgSat saturation/luminosity adjustments) + for (id in allColorIds) { + var color = hsl(palette[id]) + if (theme == Theme.COLORFUL_DARK && !isLinkColor(id)) { + if (isDarkBgSat) { + if (isBackgroundColor(id)) { + val newSat = when { + primIndex == 11 -> (4f * color.sat + 1f) / 5f + primIndex != 5 -> (color.sat + 1f) / 2f + else -> color.sat * 0.5f + } + color = color.copy(sat = newSat, lum = color.lum * 0.75f) + } else if (isTextColor(id)) { + color = color.copy(lum = (color.lum + 1f) / 2f) + } + } else { + if (isBackgroundColor(id)) { + var newSat = color.sat * 0.333f + if (primIndex == 11) newSat *= 0.5f + if (primIndex == 4 || primIndex == 5) newSat *= 0.333f + color = color.copy(sat = newSat) + } else if (id == ColorId.PARAGRAPH && (primIndex == 5 || primIndex == 4)) { + color = color.copy(sat = color.sat * 0.4f, lum = color.lum + 0.1f) + } else if (isTextColor(id)) { + color = color.copy( + sat = (color.sat + 2f) / 3f, + lum = (2f * color.lum + 1f) / 3f + ) + } + } + } + if (!isLinkColor(id)) { + color = color.copy(sat = color.sat * saturationPreference) + } + setHsl(palette, id, color) + } + } + + // Lagrange: src/gmdocument.c:1495 (setDerivedThemeColors_) + private fun setDerivedThemeColors(palette: ThemePalette, core: CorePalette, theme: Theme) { + palette[ColorId.QUOTE_ICON] = mix(palette[ColorId.QUOTE], palette[ColorId.BACKGROUND], 0.55f) + palette[ColorId.BANNER_SIDE_TITLE] = mix( + palette[ColorId.BANNER_TITLE], + palette[ColorId.BACKGROUND], + if (theme == Theme.COLORFUL_DARK) 0.55f else 0f + ) + + val bannerItemFg = if (isDarkTheme(theme)) core.white else core.black + palette[ColorId.BANNER_ITEM_BACKGROUND] = mix( + palette[ColorId.BANNER_BACKGROUND], + palette[ColorId.BANNER_TITLE], + 0.1f + ) + palette[ColorId.BANNER_ITEM_FRAME] = mix( + palette[ColorId.BANNER_BACKGROUND], + palette[ColorId.BANNER_TITLE], + 0.4f + ) + palette[ColorId.BANNER_ITEM_TEXT] = mix( + palette[ColorId.BANNER_TITLE], + bannerItemFg, + 0.5f + ) + palette[ColorId.BANNER_ITEM_TITLE] = bannerItemFg + + palette[ColorId.BACKGROUND_ALT_TEXT] = mix( + palette[ColorId.QUOTE_ICON], + palette[ColorId.BACKGROUND], + 0.85f + ) + palette[ColorId.FRAME_ALT_TEXT] = mix( + palette[ColorId.QUOTE_ICON], + palette[ColorId.BACKGROUND], + 0.4f + ) + palette[ColorId.BACKGROUND_OPEN_LINK] = mix( + palette[ColorId.LINK_TEXT], + palette[ColorId.BACKGROUND], + 0.90f + ) + palette[ColorId.LINK_FEED_ENTRY_DATE] = mix( + palette[ColorId.LINK_TEXT], + palette[ColorId.BACKGROUND], + 0.25f + ) + if (theme == Theme.COLORFUL_DARK && delta( + palette[ColorId.LINK_TEXT], + palette[ColorId.PARAGRAPH] + ) < 100 + ) { + setHsl( + palette, + ColorId.PARAGRAPH, + addSatLum(hsl(palette[ColorId.PARAGRAPH]), 0.3f, -0.025f) + ) + } + palette[ColorId.LINK_CUSTOM_ICON_VISITED] = mix( + palette[ColorId.LINK_ICON_VISITED], + palette[ColorId.LINK_ICON], + 0.2f + ) + } + + // Lagrange: src/gmdocument.c:1839-1846 (normLums_) + private fun normLums(localHues: FloatArray): FloatArray { + val result = FloatArray(localHues.size) + for (i in localHues.indices) { + result[i] = 1f - luma(HslColor(localHues[i], 0.75f, 0.5f, 1f)) / 2f + } + return result + } + + private fun setHsl(palette: ThemePalette, id: ColorId, color: HslColor) { + palette[id] = rgb(color) + } + + // Lagrange: src/ui/color.c:396 (mix_Color) + private fun mix(first: Rgba8, second: Rgba8, t: Float): Rgba8 { + val clampedT = t.coerceIn(0f, 1f) + return Rgba8( + r = (first.r * (1f - clampedT) + second.r * clampedT).toInt(), + g = (first.g * (1f - clampedT) + second.g * clampedT).toInt(), + b = (first.b * (1f - clampedT) + second.b * clampedT).toInt(), + a = (first.a * (1f - clampedT) + second.a * clampedT).toInt() + ) + } + + // Lagrange: src/ui/color.c:404 (delta_Color) + private fun delta(first: Rgba8, second: Rgba8): Int { + return abs(first.r - second.r) + abs(first.g - second.g) + abs(first.b - second.b) + } + + // Lagrange: src/ui/color.c:465 (hsl_Colorf) + private fun hsl(color: Rgba8): HslColor { + val rgb = floatArrayOf( + (color.r / 255f).coerceIn(0f, 1f), + (color.g / 255f).coerceIn(0f, 1f), + (color.b / 255f).coerceIn(0f, 1f), + (color.a / 255f).coerceIn(0f, 1f) + ) + val compMax = when { + rgb[0] >= rgb[1] && rgb[0] >= rgb[2] -> 0 + rgb[1] >= rgb[0] && rgb[1] >= rgb[2] -> 1 + else -> 2 + } + val compMin = when { + rgb[0] <= rgb[1] && rgb[0] <= rgb[2] -> 0 + rgb[1] <= rgb[0] && rgb[1] <= rgb[2] -> 1 + else -> 2 + } + val rgbMax = rgb[compMax] + val rgbMin = rgb[compMin] + val lum = (rgbMax + rgbMin) / 2f + + var hue = 0f + var sat = 0f + if (abs(rgbMax - rgbMin) > 0.00001f) { + val chr = rgbMax - rgbMin + sat = chr / (1f - abs(2f * lum - 1f)) + hue = when (compMax) { + 0 -> (rgb[1] - rgb[2]) / chr + if (rgb[1] < rgb[2]) 6f else 0f + 1 -> (rgb[2] - rgb[0]) / chr + 2f + else -> (rgb[0] - rgb[1]) / chr + 4f + } + } + return HslColor(hue * 60f, sat, lum, rgb[3]) + } + + // Lagrange: src/ui/color.c:519 (rgbf_HSLColor) + private fun rgb(color: HslColor): Rgba8 { + var hue = wrap01(color.hue / 360f) + val sat = color.sat.coerceIn(0f, 1f) + val lum = color.lum.coerceIn(0f, 1f) + + val red: Float + val green: Float + val blue: Float + + if (sat < 0.00001f) { + red = lum + green = lum + blue = lum + } else { + val q = if (lum < 0.5f) lum * (1f + sat) else lum + sat - lum * sat + val p = 2f * lum - q + red = hueToRgb(p, q, hue + 1f / 3f) + green = hueToRgb(p, q, hue) + blue = hueToRgb(p, q, hue - 1f / 3f) + } + + return Rgba8( + r = quantize8(red), + g = quantize8(green), + b = quantize8(blue), + a = quantize8(color.alpha) + ) + } + + // Lagrange: src/ui/color.c:551 (luma_HSLColor) + private fun luma(color: HslColor): Float { + return luma(rgb(color)) + } + + // Lagrange: src/ui/color.c:543 (luma_Color) + private fun luma(color: Rgba8): Float { + return 0.299f * color.r / 255f + 0.587f * color.g / 255f + 0.114f * color.b / 255f + } + + // Lagrange: src/ui/color.c:510 (hueToRgb_) + private fun hueToRgb(p: Float, q: Float, inputT: Float): Float { + var t = inputT + if (t < 0f) t += 1f + if (t > 1f) t -= 1f + if (t < 1f / 6f) return p + (q - p) * 6f * t + if (t < 1f / 2f) return q + if (t < 2f / 3f) return p + (q - p) * (2f / 3f - t) * 6f + return p + } + + private fun quantize8(value: Float): Int { + return (value.coerceIn(0f, 1f) * 255f + 0.5f).toInt() + } + + private fun wrap01(value: Float): Float { + var wrapped = value % 1f + if (wrapped < 0f) wrapped += 1f + return wrapped + } + + // Lagrange: src/ui/color.c:613 (addSatLum_HSLColor) + private fun addSatLum(color: HslColor, sat: Float, lum: Float): HslColor { + return HslColor( + hue = color.hue, + sat = (color.sat + sat).coerceIn(minSatHsl, 1f), + lum = (color.lum + lum).coerceIn(minSatHsl, 1f), + alpha = color.alpha + ) + } + + // Lagrange: src/ui/color.c:608 (setLum_HSLColor) + private fun setLum(color: HslColor, lum: Float): HslColor { + return HslColor( + hue = color.hue, + sat = color.sat, + lum = lum.coerceIn(0f, 1f), + alpha = color.alpha + ) + } + + private fun isDarkTheme(theme: Theme): Boolean { + return theme == Theme.COLORFUL_DARK + } + + private fun isLinkColor(id: ColorId): Boolean { + return id.ordinal >= ColorId.BAD_LINK.ordinal + } + + private fun isBackgroundColor(id: ColorId): Boolean { + return id == ColorId.BACKGROUND || id == ColorId.BANNER_BACKGROUND + } + + private fun isTextColor(id: ColorId): Boolean { + return !isBackgroundColor(id) + } +} diff --git a/app/src/main/java/mysh/dev/gemcap/ui/theme/Theme.kt b/app/src/main/java/mysh/dev/gemcap/ui/theme/Theme.kt index fc1f71c..d1cc654 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/theme/Theme.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/theme/Theme.kt @@ -3,6 +3,7 @@ package mysh.dev.gemcap.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.luminance import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme @@ -56,4 +57,7 @@ fun GeminiBrowserTheme( content = content ) } -} \ No newline at end of file +} + +@Composable +fun isDarkMode(): Boolean = MaterialTheme.colorScheme.background.luminance() < 0.5f \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01dac2b..c9ffd40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,4 +107,13 @@ This identity is protected with a passphrase. Passphrase Continue + + + Open in new tab + Copy link address + No Tabs Open + Saved to %1$s + Download failed: %1$s + Download available: %1$s + Link copied to clipboard From b0b86246c0e4a7abbfe3e232368eb2649fe061c2 Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 08:25:10 +0100 Subject: [PATCH 02/12] fix(certs): do not normalize X.500 string before parsing --- .../java/mysh/dev/gemcap/data/ClientCertRepository.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt b/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt index bda5c22..d56de85 100644 --- a/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt +++ b/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt @@ -276,9 +276,8 @@ class ClientCertRepository(context: Context) { * Supports both comma and semicolon separators per RFC 2253. */ private fun parseX500Dn(dn: String): Map { - val normalizedDn = dn.replace(';', ',') // Replace ; with , per RFC 2253 [page:0] val result = mutableMapOf() - for (rdn in splitDnComponents(normalizedDn)) { + for (rdn in splitDnComponents(dn)) { val eqIndex = rdn.indexOf('=') if (eqIndex > 0) { val type = rdn.substring(0, eqIndex).trim().uppercase() @@ -291,7 +290,9 @@ class ClientCertRepository(context: Context) { return result } - /** Splits an RFC 2253 DN string on unescaped commas. */ + /** + * Splits an RFC 2253 DN string on unescaped commas or semicolons. + * */ private fun splitDnComponents(dn: String): List { val parts = mutableListOf() val current = StringBuilder() @@ -308,7 +309,7 @@ class ClientCertRepository(context: Context) { inQuote = !inQuote i++ } - c == ',' && !inQuote -> { + (c == ',' || c == ';') && !inQuote -> { parts.add(current.toString()) current.clear() i++ From 973dcf005c97f6513157dba2066e9538cc670234 Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 08:30:57 +0100 Subject: [PATCH 03/12] fix(gemini parser): skip empty links Malformed `=>` lines with no URL currently generate a link entry with an empty target. --- .../mysh/dev/gemcap/domain/GeminiParser.kt | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/mysh/dev/gemcap/domain/GeminiParser.kt b/app/src/main/java/mysh/dev/gemcap/domain/GeminiParser.kt index 4e35246..7419b32 100644 --- a/app/src/main/java/mysh/dev/gemcap/domain/GeminiParser.kt +++ b/app/src/main/java/mysh/dev/gemcap/domain/GeminiParser.kt @@ -78,6 +78,10 @@ object GeminiParser { line.startsWith("=>") -> { val parts = line.removePrefix("=>").trim().split(Regex("\\s+"), 2) val url = parts.getOrNull(0)?.trim().orEmpty() + if (url.isBlank()) { + content.add(GeminiContent.Text(id = idCounter++, text = line)) + continue + } val rawLinkText = parts.getOrNull(1)?.trim().orEmpty().ifEmpty { url } val id = idCounter++ val resolvedUrl = resolveUrl(url, baseUrl) @@ -278,10 +282,12 @@ object GeminiParser { private val imageExtensions = setOf( "gif", "jpg", "jpeg", "png", "tga", "psd", "hdr", "jxl", "webp", "pic" ) - private val audioExtensions = setOf("mp3", "wav", "ogg", "opus", "mid") // also midi will NOT work + private val audioExtensions = + setOf("mp3", "wav", "ogg", "opus", "mid") // also midi will NOT work private fun extensionFlags(url: String, resolvedUrl: String?): Set { - val path = (resolvedUrl ?: url).substringBefore("?").substringBefore("#").lowercase(Locale.US) + val path = + (resolvedUrl ?: url).substringBefore("?").substringBefore("#").lowercase(Locale.US) if (path.isBlank()) return emptySet() val ext = path.substringAfterLast('.', "") if (ext.isEmpty()) return emptySet() @@ -349,11 +355,11 @@ object GeminiParser { private fun customIconAllowedForScheme(scheme: LinkScheme, isRemote: Boolean): Boolean { return (scheme == LinkScheme.GEMINI && !isRemote) || - scheme == LinkScheme.ABOUT || - scheme == LinkScheme.FILE || - scheme == LinkScheme.MAILTO || - scheme == LinkScheme.MISFIN || - scheme == LinkScheme.UNKNOWN + scheme == LinkScheme.ABOUT || + scheme == LinkScheme.FILE || + scheme == LinkScheme.MAILTO || + scheme == LinkScheme.MISFIN || + scheme == LinkScheme.UNKNOWN } private data class IconToken( @@ -396,15 +402,15 @@ object GeminiParser { return false } return codePoint in 0x1F300..0x1FAFF || - codePoint in 0x2600..0x27BF || - isRegionalIndicatorLetter(codePoint) || - codePoint == 0x2022 || - codePoint == 0x2139 || - (codePoint in 0x2190..0x21FF) || - codePoint == 0x29BF || - codePoint == 0x2A2F || - (codePoint in 0x2B00..0x2BFF) || - codePoint == 0x20BF || - (codePoint in 0x1F191..0x1F19A) + codePoint in 0x2600..0x27BF || + isRegionalIndicatorLetter(codePoint) || + codePoint == 0x2022 || + codePoint == 0x2139 || + (codePoint in 0x2190..0x21FF) || + codePoint == 0x29BF || + codePoint == 0x2A2F || + (codePoint in 0x2B00..0x2BFF) || + codePoint == 0x20BF || + (codePoint in 0x1F191..0x1F19A) } } From e04b8ed2363a2aba2e151377304c226d486b3f9a Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 08:32:47 +0100 Subject: [PATCH 04/12] feat(icon resolver): add musical note symbol for AUDIO_EXT --- app/src/main/java/mysh/dev/gemcap/domain/LinkIconResolver.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/mysh/dev/gemcap/domain/LinkIconResolver.kt b/app/src/main/java/mysh/dev/gemcap/domain/LinkIconResolver.kt index 7ede5d8..a2ed239 100644 --- a/app/src/main/java/mysh/dev/gemcap/domain/LinkIconResolver.kt +++ b/app/src/main/java/mysh/dev/gemcap/domain/LinkIconResolver.kt @@ -13,6 +13,7 @@ object LinkIconResolver { private const val GUPPY = "\uD83D\uDC1F" // 🐟 private const val SPARTAN = "\uD83D\uDCAA" // 💪 private const val FONTPACK = "\uD83D\uDD20" // 🔠 + private const val MUSICAL_NOTE = "\uD83C\uDFB5" // 🎵 fun iconFor(link: GeminiContent.Link): String { if (LinkFlag.ICON_FROM_LABEL in link.flags && !link.labelIcon.isNullOrBlank()) { @@ -33,6 +34,7 @@ object LinkIconResolver { else -> when { LinkFlag.REMOTE in link.flags -> GLOBE LinkFlag.IMAGE_EXT in link.flags -> IMAGE + LinkFlag.AUDIO_EXT in link.flags -> MUSICAL_NOTE LinkFlag.FONTPACK_EXT in link.flags -> FONTPACK else -> ARROW } From a60e3b60fa587ec3c36f02477d7380e4fb5788c4 Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 08:33:37 +0100 Subject: [PATCH 05/12] fix(tabs): restored tabs will now trigger loadPage --- app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt b/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt index e2d9fac..36ecb6c 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt @@ -281,7 +281,7 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) bookmarkManager.updateBookmarkStatus(activeTab?.url) // Lazy-load tabs that haven't been loaded yet (e.g., restored from session) val tab = activeTab ?: return - if (!tab.isLoading && tab.content.isEmpty() && tab.error == null && tab.url != HOME_URL) { + if (!tab.isLoading && tab.content.isEmpty() && tab.error == null) { loadPage(addToHistory = false) } } From bcdcdf9f4e5ff4c4c55d9beaa1dddd38ed061d32 Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 08:35:35 +0100 Subject: [PATCH 06/12] fix(downloads): display error message upon failure At least some context will be there! --- app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt b/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt index 36ecb6c..22e4620 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt @@ -360,7 +360,7 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) dialogManager.setDownloadMessage( result.fold( onSuccess = { app.getString(R.string.download_saved, it) }, - onFailure = { app.getString(R.string.download_failed, it.message) } + onFailure = { app.getString(R.string.download_failed, it.message ?: it.toString()) } ) ) } From 3e9d67a2c131d5933499143d9ceb94ff800d5fcb Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 08:37:24 +0100 Subject: [PATCH 07/12] fix(tabs): sync history state checks `canGoBack()` and `canGoForward()` currently read shared navigation state outside the lock used for updates, which can cause inconsistent back/forward functionality --- app/src/main/java/mysh/dev/gemcap/ui/model/TabState.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/mysh/dev/gemcap/ui/model/TabState.kt b/app/src/main/java/mysh/dev/gemcap/ui/model/TabState.kt index 35c1430..a32c311 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/model/TabState.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/model/TabState.kt @@ -132,8 +132,8 @@ class TabState( url = newUrl } - fun canGoBack(): Boolean = historyIndex > 0 - fun canGoForward(): Boolean = historyIndex < history.size - 1 + fun canGoBack(): Boolean = synchronized(history) { historyIndex > 0 } + fun canGoForward(): Boolean = synchronized(history) { historyIndex < history.size - 1 } fun goBack(): String? { synchronized(history) { From 0b5d74986b9fe3284fc327b85876ab609e20e92c Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 08:46:45 +0100 Subject: [PATCH 08/12] feat: add tab style composable helper Allows me to just reuse this thing in 2 places in codebase --- .../java/mysh/dev/gemcap/ui/BrowserScreen.kt | 8 ++---- .../mysh/dev/gemcap/ui/components/TabItem.kt | 12 +++------ .../dev/gemcap/ui/components/cards/TabCard.kt | 12 +++------ .../mysh/dev/gemcap/ui/theme/TabChrome.kt | 25 +++++++++++++++++++ 4 files changed, 35 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/mysh/dev/gemcap/ui/theme/TabChrome.kt diff --git a/app/src/main/java/mysh/dev/gemcap/ui/BrowserScreen.kt b/app/src/main/java/mysh/dev/gemcap/ui/BrowserScreen.kt index d31ede4..df0f2b0 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/BrowserScreen.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/BrowserScreen.kt @@ -94,8 +94,7 @@ import mysh.dev.gemcap.ui.model.HOME_URL import mysh.dev.gemcap.ui.model.SearchState import mysh.dev.gemcap.ui.model.TabState import mysh.dev.gemcap.ui.model.TabsUiState -import mysh.dev.gemcap.ui.theme.CapsuleStyleGenerator -import mysh.dev.gemcap.ui.theme.isDarkMode +import mysh.dev.gemcap.ui.theme.rememberTabChrome import mysh.dev.gemcap.util.ScreenshotUtils private const val TAG = "Recomposition" @@ -535,10 +534,7 @@ private fun GeminiContentList( ) { logRecomposition { ">>> GeminiContentList (${content.size} items)" } - val isDarkMode = isDarkMode() - val capsuleStyle = remember(tab.capsuleIdentity, isDarkMode) { - CapsuleStyleGenerator.fromIdentity(tab.capsuleIdentity, isDarkMode) - } + val capsuleStyle = rememberTabChrome(tab.capsuleIdentity).capsuleStyle val cachedStyles = rememberCachedTextStyles(capsuleStyle) val contentBackground = capsuleStyle?.backgroundColor ?: MaterialTheme.colorScheme.background diff --git a/app/src/main/java/mysh/dev/gemcap/ui/components/TabItem.kt b/app/src/main/java/mysh/dev/gemcap/ui/components/TabItem.kt index 9cf16db..eaf49c3 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/components/TabItem.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/components/TabItem.kt @@ -16,15 +16,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import mysh.dev.gemcap.ui.theme.CapsuleStyleGenerator -import mysh.dev.gemcap.ui.theme.isDarkMode +import mysh.dev.gemcap.ui.theme.rememberTabChrome import mysh.dev.gemcap.ui.model.TabState @Composable @@ -36,12 +34,10 @@ fun TabItem( onClosed: () -> Unit ) { val shape = SimpleChromeTabShape() - val isDarkMode = isDarkMode() - val capsuleStyle = remember(tab.capsuleIdentity, isDarkMode) { - CapsuleStyleGenerator.fromIdentity(tab.capsuleIdentity, isDarkMode) - } + val chrome = rememberTabChrome(tab.capsuleIdentity) + val capsuleStyle = chrome.capsuleStyle + val titleColor = chrome.titleColor val activeColor = capsuleStyle?.chromeAccentColor?.copy(alpha = 0.12f) ?: MaterialTheme.colorScheme.surface - val titleColor = capsuleStyle?.chromeTextColor ?: MaterialTheme.colorScheme.onSurface Box( modifier = Modifier diff --git a/app/src/main/java/mysh/dev/gemcap/ui/components/cards/TabCard.kt b/app/src/main/java/mysh/dev/gemcap/ui/components/cards/TabCard.kt index eec4d8b..705e31e 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/components/cards/TabCard.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/components/cards/TabCard.kt @@ -23,15 +23,13 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import mysh.dev.gemcap.ui.theme.CapsuleStyleGenerator -import mysh.dev.gemcap.ui.theme.isDarkMode +import mysh.dev.gemcap.ui.theme.rememberTabChrome import mysh.dev.gemcap.ui.model.TabState @Composable @@ -42,10 +40,9 @@ fun TabCard( onClosed: () -> Unit, aspectRatio: Float ) { - val isDarkMode = isDarkMode() - val capsuleStyle = remember(tab.capsuleIdentity, isDarkMode) { - CapsuleStyleGenerator.fromIdentity(tab.capsuleIdentity, isDarkMode) - } + val chrome = rememberTabChrome(tab.capsuleIdentity) + val capsuleStyle = chrome.capsuleStyle + val titleColor = chrome.titleColor val cardColor = if (isActive && capsuleStyle != null) { capsuleStyle.chromeAccentColor.copy(alpha = 0.15f) } else if (isActive) { @@ -53,7 +50,6 @@ fun TabCard( } else { MaterialTheme.colorScheme.surfaceVariant } - val titleColor = capsuleStyle?.chromeTextColor ?: MaterialTheme.colorScheme.onSurface Card( modifier = Modifier diff --git a/app/src/main/java/mysh/dev/gemcap/ui/theme/TabChrome.kt b/app/src/main/java/mysh/dev/gemcap/ui/theme/TabChrome.kt new file mode 100644 index 0000000..850ca65 --- /dev/null +++ b/app/src/main/java/mysh/dev/gemcap/ui/theme/TabChrome.kt @@ -0,0 +1,25 @@ +package mysh.dev.gemcap.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import mysh.dev.gemcap.domain.CapsuleIdentity + +// "Chrome" refers to the browser's own UI frame (tabs, toolbar, address bar) as opposed +// to the page content area. These are the capsule-derived colors for rendering tab UI. +// And also because I've copied style of tabs from Google Chrome. +data class TabChrome( + val capsuleStyle: CapsuleStyle?, + val titleColor: Color +) + +@Composable +fun rememberTabChrome(capsuleIdentity: CapsuleIdentity?): TabChrome { + val isDarkMode = isDarkMode() + val capsuleStyle = remember(capsuleIdentity, isDarkMode) { + CapsuleStyleGenerator.fromIdentity(capsuleIdentity, isDarkMode) + } + val titleColor = capsuleStyle?.chromeTextColor ?: MaterialTheme.colorScheme.onSurface + return TabChrome(capsuleStyle = capsuleStyle, titleColor = titleColor) +} From d1c43baf3bdb5429fb4e3c7eb13e75e002eae0b7 Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 08:49:26 +0100 Subject: [PATCH 09/12] refactor(domain): parse URI in parent function and pass it to children --- .../dev/gemcap/domain/CapsuleIdentityGenerator.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/mysh/dev/gemcap/domain/CapsuleIdentityGenerator.kt b/app/src/main/java/mysh/dev/gemcap/domain/CapsuleIdentityGenerator.kt index b5d02a5..04f1507 100644 --- a/app/src/main/java/mysh/dev/gemcap/domain/CapsuleIdentityGenerator.kt +++ b/app/src/main/java/mysh/dev/gemcap/domain/CapsuleIdentityGenerator.kt @@ -2,7 +2,6 @@ package mysh.dev.gemcap.domain import android.annotation.SuppressLint import android.net.Uri -import java.net.URI import java.util.Locale // Identity generation ported from Lagrange: @@ -29,11 +28,13 @@ object CapsuleIdentityGenerator { fun fromUrl(url: String): CapsuleIdentity { val normalizedUrl = url.trim() + @SuppressLint("UseKtx") + val parsedUri = Uri.parse(normalizedUrl) // Lagrange seed-source flow: // setThemeSeed_GmDocument(..., urlPaletteSeed_String(url), urlThemeSeed_String(url)) // See src/gmdocument.c:2348 and src/gmutil.c:324-345. - val themeSeedSource = themeSeedSource(normalizedUrl) - val paletteSeedSource = paletteSeedSource(normalizedUrl, themeSeedSource) + val themeSeedSource = themeSeedSource(parsedUri) + val paletteSeedSource = paletteSeedSource(parsedUri, themeSeedSource) val themeSeed = themeHash(themeSeedSource) val paletteSeed = themeHash(paletteSeedSource) @@ -61,9 +62,7 @@ object CapsuleIdentityGenerator { ) } - private fun themeSeedSource(url: String): String { - @SuppressLint("UseKtx") - val uri = Uri.parse(url) ?: return "" + private fun themeSeedSource(uri: Uri): String { if (uri.scheme?.equals("file", ignoreCase = true) == true) return "" val path = uri.encodedPath.orEmpty() val userMatch = userPathPattern.find(path)?.groupValues?.getOrNull(1) @@ -73,15 +72,13 @@ object CapsuleIdentityGenerator { // Partial port of src/gmutil.c:urlPaletteSeed_String() (335-345). - private fun paletteSeedSource(url: String, defaultSource: String): String { + private fun paletteSeedSource(uri: Uri, defaultSource: String): String { // Lagrange also checks a site-specific override (valueString_SiteSpec + paletteSeed key), // which is not implemented in Gemcap. // // The file-scheme check is redundant with themeSeedSource but kept for parity with // Lagrange's urlPaletteSeed_String() and to support future site-specific overrides // that might never arrive. - @SuppressLint("UseKtx") - val uri = Uri.parse(url) ?: return defaultSource if (uri.scheme.equals("file", ignoreCase = true)) { return "" } From 45aabc5000da24701c5e51d0e946fc3786091f95 Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 09:08:04 +0100 Subject: [PATCH 10/12] fix(certs): race on cached certs `cachedCertificates` is mutated and read from multiple paths without synchronization --- .../mysh/dev/gemcap/data/ClientCertRepository.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt b/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt index d56de85..4d45f10 100644 --- a/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt +++ b/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt @@ -22,6 +22,7 @@ class ClientCertRepository(context: Context) { private val prefs = context.getSharedPreferences("client_certs", Context.MODE_PRIVATE) private val identityStorage = EncryptedIdentityStorage(context) + private val cacheLock = Any() private var cachedCertificates: List? = null companion object { @@ -38,7 +39,7 @@ class ClientCertRepository(context: Context) { /** * Retrieves all stored identities. */ - fun getCertificates(): List { + fun getCertificates(): List = synchronized(cacheLock) { cachedCertificates?.let { return it } val json = prefs.getString(KEY_CERTIFICATES, null) ?: return emptyList() return try { @@ -58,7 +59,7 @@ class ClientCertRepository(context: Context) { } } - fun addCertificate(certificate: ClientCertificate) { + fun addCertificate(certificate: ClientCertificate): Unit = synchronized(cacheLock) { val certificates = getCertificates().toMutableList() certificates.removeAll { it.alias == certificate.alias } certificates.add(0, certificate) @@ -66,7 +67,7 @@ class ClientCertRepository(context: Context) { Log.d(TAG, "Added certificate: ${certificate.alias}") } - fun addUsage(alias: String, usage: IdentityUsage) { + fun addUsage(alias: String, usage: IdentityUsage): Unit = synchronized(cacheLock) { val certificates = getCertificates().map { cert -> if (cert.alias == alias) { val updatedUsages = cert.usages.filterNot { @@ -81,7 +82,7 @@ class ClientCertRepository(context: Context) { Log.d(TAG, "Added usage to $alias: $usage") } - fun removeUsage(alias: String, usage: IdentityUsage) { + fun removeUsage(alias: String, usage: IdentityUsage): Unit = synchronized(cacheLock) { val certificates = getCertificates().map { cert -> if (cert.alias == alias) { cert.copy( @@ -97,7 +98,7 @@ class ClientCertRepository(context: Context) { Log.d(TAG, "Removed usage from $alias: $usage") } - fun removeCertificate(alias: String) { + fun removeCertificate(alias: String): Unit = synchronized(cacheLock) { val deleteSucceeded = try { identityStorage.deleteIdentity(alias) } catch (e: Exception) { @@ -114,7 +115,7 @@ class ClientCertRepository(context: Context) { Log.d(TAG, "Removed certificate: $alias") } - fun setActive(alias: String, isActive: Boolean) { + fun setActive(alias: String, isActive: Boolean): Unit = synchronized(cacheLock) { val certificates = getCertificates().map { if (it.alias == alias) it.copy(isActive = isActive) else it } @@ -400,7 +401,7 @@ class ClientCertRepository(context: Context) { } } - private fun saveCertificates(certificates: List) { + private fun saveCertificates(certificates: List) = synchronized(cacheLock) { cachedCertificates = null val array = JSONArray() certificates.forEach { cert -> From b67597d6b9d7a03802b6a4df2fff160c0a9acd3b Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 09:12:57 +0100 Subject: [PATCH 11/12] fix(tabs): add check for null rawBody to not re-fetch valid pages In theory, it might try to reload perfectly valid empty gemini pages --- app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt b/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt index 22e4620..92b7cb6 100644 --- a/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt +++ b/app/src/main/java/mysh/dev/gemcap/ui/BrowserViewModel.kt @@ -281,7 +281,7 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application) bookmarkManager.updateBookmarkStatus(activeTab?.url) // Lazy-load tabs that haven't been loaded yet (e.g., restored from session) val tab = activeTab ?: return - if (!tab.isLoading && tab.content.isEmpty() && tab.error == null) { + if (!tab.isLoading && tab.content.isEmpty() && tab.error == null && tab.rawBody == null) { loadPage(addToHistory = false) } } From e808b0230f980743ad3af66a65f402bbf1845ed5 Mon Sep 17 00:00:00 2001 From: Mysh Date: Wed, 4 Mar 2026 09:14:58 +0100 Subject: [PATCH 12/12] fix(DN): parse '+' to handle multivalued RDNs --- .../dev/gemcap/data/ClientCertRepository.kt | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt b/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt index 4d45f10..fa55362 100644 --- a/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt +++ b/app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt @@ -279,18 +279,58 @@ class ClientCertRepository(context: Context) { private fun parseX500Dn(dn: String): Map { val result = mutableMapOf() for (rdn in splitDnComponents(dn)) { - val eqIndex = rdn.indexOf('=') - if (eqIndex > 0) { - val type = rdn.substring(0, eqIndex).trim().uppercase() - val value = unescapeDnValue(rdn.substring(eqIndex + 1).trim()) - if (value.isNotEmpty()) { - result.putIfAbsent(type, value) + for (subRdn in splitOnUnescapedPlus(rdn)) { + val eqIndex = subRdn.indexOf('=') + if (eqIndex > 0) { + val type = subRdn.substring(0, eqIndex).trim().uppercase() + val value = unescapeDnValue(subRdn.substring(eqIndex + 1).trim()) + if (value.isNotEmpty()) { + result.putIfAbsent(type, value) + } } } } return result } + /** + * Splits a single RDN on unescaped '+' to handle multivalued RDNs. + * Though I don't think it's possible to do something like this from + * Lagrange. + **/ + private fun splitOnUnescapedPlus(rdn: String): List { + val parts = mutableListOf() + val current = StringBuilder() + var i = 0 + var inQuote = false + while (i < rdn.length) { + val c = rdn[i] + when { + c == '\\' && i + 1 < rdn.length -> { + current.append(c).append(rdn[i + 1]) + i += 2 + } + c == '"' -> { + inQuote = !inQuote + i++ + } + c == '+' && !inQuote -> { + parts.add(current.toString()) + current.clear() + i++ + } + else -> { + current.append(c) + i++ + } + } + } + if (current.isNotEmpty()) { + parts.add(current.toString()) + } + return parts + } + /** * Splits an RFC 2253 DN string on unescaped commas or semicolons. * */