Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package expo.modules.t3composereditor

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Handler
import android.os.Looper
import java.net.URL
import java.util.concurrent.Executors

internal object T3ChipIcons {
private val images = HashMap<String, Bitmap>()
private val pending = HashMap<String, MutableSet<(String) -> Unit>>()
private val downloads = Executors.newSingleThreadExecutor()
private val main = Handler(Looper.getMainLooper())

fun cached(uri: String): Bitmap? = images[uri]

fun load(context: Context, uri: String, onLoaded: (String) -> Unit): Bitmap? =
images[uri] ?: when {
uri.isEmpty() -> null
uri.startsWith("http://") || uri.startsWith("https://") -> {
download(uri, onLoaded)
null
}
else -> decodeLocal(context, uri)?.also { images[uri] = it }
}

private fun download(uri: String, onLoaded: (String) -> Unit) {
val waiting = pending[uri]
if (waiting != null) {
waiting.add(onLoaded)
return
}
pending[uri] = mutableSetOf(onLoaded)
downloads.execute {
val bitmap = runCatching {
URL(uri).openStream().use(BitmapFactory::decodeStream)
}.getOrNull()
main.post {
val callbacks = pending.remove(uri).orEmpty()
if (bitmap != null) {
images[uri] = bitmap
callbacks.forEach { it(uri) }
}
}
}
}

private fun decodeLocal(context: Context, uri: String): Bitmap? = runCatching {
when {
uri.startsWith("/") -> BitmapFactory.decodeFile(uri)
uri.startsWith("file:") -> Uri.parse(uri).path?.let(BitmapFactory::decodeFile)
else -> context.resources.getIdentifier(uri, "drawable", context.packageName)
.takeIf { it != 0 }
?.let { BitmapFactory.decodeResource(context.resources, it) }
}
}.getOrNull()
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,20 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
private var contentInsetVertical = 0
private var tokensJson = "[]"
private var tokens: List<ComposerToken> = emptyList()
private var chipTheme = ComposerChipTheme.default()
private val redrawChipsWithIcon: (String) -> Unit = { uri ->
editor.text?.let { editable ->
editable.getSpans(0, editable.length, ComposerChipSpan::class.java)
.filter { it.iconUri == uri }
.forEach {
editable.setSpan(
it,
editable.getSpanStart(it),
editable.getSpanEnd(it),
editable.getSpanFlags(it)
)
}
}
}
private var autoCorrect = true
private var spellCheck = true
private var nativeEventCount = 0
Expand Down Expand Up @@ -241,17 +254,6 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
} else {
applySelectionTheme(parseColor(theme.optString("selection"), editor.currentTextColor))
}
chipTheme = ComposerChipTheme(
chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground),
chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder),
chipText = parseColor(theme.optString("chipText"), chipTheme.chipText),
skillBackground = parseColor(
theme.optString("skillBackground"),
chipTheme.skillBackground,
),
skillBorder = parseColor(theme.optString("skillBorder"), chipTheme.skillBorder),
skillText = parseColor(theme.optString("skillText"), chipTheme.skillText),
)
applyTokenSpans()
} catch (_: Exception) {
}
Expand Down Expand Up @@ -452,20 +454,18 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
if (token.start < 0 || token.end <= token.start || token.end > editable.length) return@forEach
val expectedSource = editable.substring(token.start, token.end)
if (expectedSource != token.source) return@forEach
T3ChipIcons.load(context, token.iconUri, redrawChipsWithIcon)
editable.setSpan(
ComposerChipSpan(
T3ContextChip(
content = T3ContextChip.Content(
label = token.label,
symbol = token.symbol,
detail = token.detail
),
fontSize = editor.textSize * 0.8f,
colors = T3ContextChip.Colors(
accent = T3ContextChip.color(token.accent, chipTheme.chipText),
foreground = chipTheme.chipText,
border = chipTheme.chipBorder
detail = token.detail,
iconUri = token.iconUri,
tintsIcon = token.tintsIcon
),
fontSize = editor.textSize,
color = T3ContextChip.color(token.accent, editor.currentTextColor),
Comment thread
flamboh marked this conversation as resolved.
maximumWidth = (
editor.width.takeIf {
it > 0
Expand Down Expand Up @@ -508,34 +508,17 @@ private data class ComposerToken(
val label: String,
val detail: String,
val accent: String,
val symbol: String,
val iconUri: String,
val tintsIcon: Boolean,
val start: Int,
val end: Int
)

private data class ComposerChipTheme(
val chipBackground: Int,
val chipBorder: Int,
val chipText: Int,
val skillBackground: Int,
val skillBorder: Int,
val skillText: Int
) {
companion object {
fun default() = ComposerChipTheme(
chipBackground = Color.rgb(238, 240, 243),
chipBorder = Color.rgb(210, 214, 220),
chipText = Color.rgb(35, 39, 45),
skillBackground = Color.rgb(233, 239, 255),
skillBorder = Color.rgb(185, 200, 245),
skillText = Color.rgb(45, 72, 155),
)
}
}

private class ComposerChipSpan(
private val chip: T3ContextChip
) : ReplacementSpan() {
val iconUri get() = chip.iconUri

override fun getSize(
paint: Paint,
text: CharSequence,
Expand Down Expand Up @@ -581,7 +564,8 @@ private fun parseTokens(value: String): List<ComposerToken> = try {
label = token.optString("label"),
detail = token.optString("detail"),
accent = token.optString("accent"),
symbol = token.optString("symbol", "doc"),
iconUri = if (token.isNull("iconUri")) "" else token.optString("iconUri"),
tintsIcon = token.optBoolean("tintsIcon"),
start = token.optInt("start"),
end = token.optInt("end"),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,34 @@ package expo.modules.t3composereditor
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.RectF
import android.graphics.Typeface
import android.text.TextPaint
import android.text.TextUtils
import kotlin.math.ceil
import kotlin.math.min

/** Shared by editable spans and inline chat images so their metrics and colors agree. */
class T3ContextChip(
content: Content,
fontSize: Float,
colors: Colors,
private val color: Int,
maximumWidth: Float,
private val density: Float
density: Float
) {
/** What the chip says: its name, the size beside it, and the glyph that leads it. */
data class Content(val label: String, val symbol: String, val detail: String = "")

data class Colors(val accent: Int, val foreground: Int, val border: Int)

private val symbol = content.symbol
data class Content(
val label: String,
val detail: String = "",
val iconUri: String = "",
val tintsIcon: Boolean = false
)

private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = fontSize
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
color = this@T3ContextChip.color
}
private val em = fontSize

Expand All @@ -37,72 +39,47 @@ class T3ContextChip(
private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = fontSize * 0.84f
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
color = this@T3ContextChip.color
}
private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}"
private val detailWidth = if (detailText.isEmpty()) 0f else detailPaint.measureText(detailText)

// The border is stroked, and a stroke straddles the path it follows, so the box has to
// reserve a stroke on every side it insets. Reserving it once left the right edge a
// stroke short, and antialiasing shaved that curve flat against the span bounds.
private val strokeWidth = density
private val inset = strokeWidth * 2f
val iconUri = content.iconUri
private val iconPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
if (content.tintsIcon) {
colorFilter =
PorterDuffColorFilter(this@T3ContextChip.color, PorterDuff.Mode.SRC_IN)
}
}
private val padding = density * 5
private val iconSlot = if (iconUri.isEmpty()) 0f else em * 1.25f
val width = ceil(
min(
maximumWidth.coerceAtLeast(em * 3),
paint.measureText(content.label) + detailWidth + em * 2.5f + inset
paint.measureText(content.label) + detailWidth + iconSlot + padding * 2
)
)
val height = ceil(em * 1.41f + inset)
val height = ceil(paint.fontMetrics.descent - paint.fontMetrics.ascent)
private val text = TextUtils.ellipsize(
content.label,
paint,
(width - em * 2.5f - inset - detailWidth).coerceAtLeast(0f),
(width - iconSlot - padding * 2 - detailWidth).coerceAtLeast(0f),
TextUtils.TruncateAt.MIDDLE
).toString()
private val fill = Color.argb(
28,
Color.red(colors.accent),
Color.green(colors.accent),
Color.blue(colors.accent)
)
private val textColor = blend(colors.accent, colors.foreground, 0.22f)
private val borderColor = blend(colors.accent, colors.border, 0.34f)

// Half a stroke keeps the border inside the box; the rest of the reserved margin is
// slack, so an antialiased edge fades out before it reaches the span bounds.
private val shape =
RectF(inset / 2f, inset / 2f, width - inset / 2f, height - inset / 2f)
private val icon = iconPath(symbol)

fun draw(canvas: Canvas, x: Float, y: Float) {
canvas.save()
canvas.translate(x, y)
paint.style = Paint.Style.FILL
paint.color = fill
canvas.drawRoundRect(shape, em / 2, em / 2, paint)
paint.style = Paint.Style.STROKE
paint.strokeWidth = strokeWidth
paint.color = borderColor
canvas.drawRoundRect(shape, em / 2, em / 2, paint)
paint.color = textColor
canvas.save()
val iconSize = em * 1.17f
canvas.translate(em / 2 + inset / 2f, (height - iconSize) / 2)
canvas.scale(iconSize / 24, iconSize / 24)
paint.strokeWidth = 1.7f
paint.strokeJoin = Paint.Join.ROUND
paint.strokeCap = Paint.Cap.ROUND
canvas.drawPath(icon, paint)
canvas.restore()
paint.style = Paint.Style.FILL
val metrics = paint.fontMetrics
val baseline = (height - metrics.descent - metrics.ascent) / 2
canvas.drawText(text, em * 2 + inset / 2f, baseline, paint)
T3ChipIcons.cached(iconUri)?.let {
val top = (height - em) / 2
canvas.drawBitmap(it, null, RectF(padding, top, padding + em, top + em), iconPaint)
}
val baseline = -paint.fontMetrics.ascent
canvas.drawText(text, padding + iconSlot, baseline, paint)
if (detailText.isNotEmpty()) {
detailPaint.color = paint.color
canvas.drawText(
detailText,
em * 2 + inset / 2f + paint.measureText(text),
padding + iconSlot + paint.measureText(text),
baseline,
detailPaint
)
Expand All @@ -123,62 +100,5 @@ class T3ContextChip(
}
Color.parseColor(androidColor)
}.getOrDefault(fallback)

private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb(
(Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(),
(Color.green(accent) * weight + Color.green(base) * (1 - weight)).toInt(),
(Color.blue(accent) * weight + Color.blue(base) * (1 - weight)).toInt(),
)

private fun iconPath(symbol: String) = Path().apply {
fun line(vararg points: Float) {
moveTo(points[0], points[1])
for (index in 2 until points.size step 2) lineTo(points[index], points[index + 1])
}
when (symbol) {
"cube" -> {
line(12f, 2f, 21f, 7f, 21f, 17f, 12f, 22f, 3f, 17f, 3f, 7f, 12f, 2f)
line(3f, 7f, 12f, 12f, 21f, 7f)
line(12f, 12f, 12f, 22f)
}
// lucide `git-pull-request`, the glyph web draws: two nodes, an elbow, and a stem.
"git-pull-request" -> {
addCircle(18f, 18f, 3f, Path.Direction.CW)
addCircle(6f, 6f, 3f, Path.Direction.CW)
moveTo(13f, 6f)
lineTo(16f, 6f)
cubicTo(17.1f, 6f, 18f, 6.9f, 18f, 8f)
lineTo(18f, 15f)
line(6f, 9f, 6f, 21f)
}
"cursorarrow.click" -> {
line(4f, 3f, 19f, 12f, 12f, 14f, 9f, 21f, 4f, 3f)
line(13f, 15f, 18f, 21f)
}
"text.bubble" -> {
line(3f, 4f, 21f, 4f, 21f, 17f, 10f, 17f, 5f, 21f, 5f, 17f, 3f, 17f, 3f, 4f)
line(7f, 8f, 17f, 8f)
line(7f, 12f, 14f, 12f)
}
"terminal", "play.rectangle", "photo" -> {
addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW)
when (symbol) {
"terminal" -> {
line(6f, 8f, 10f, 12f, 6f, 16f)
line(13f, 16f, 18f, 16f)
}
"play.rectangle" -> line(9f, 8f, 16f, 12f, 9f, 16f, 9f, 8f)
else -> {
addCircle(8f, 9f, 1.5f, Path.Direction.CW)
line(3f, 18f, 11f, 12f, 15f, 15f, 18f, 12f, 21f, 16f)
}
}
}
else -> {
line(5f, 2f, 14f, 2f, 20f, 8f, 20f, 22f, 5f, 22f, 5f, 2f)
line(14f, 2f, 14f, 8f, 20f, 8f)
}
}
}
}
}
Loading
Loading