diff --git a/README.md b/README.md index f7bb802..21ebfe1 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,22 @@ fun SomeScreen() { } ``` +### Android +Artifact: `syntakts-android` + +Syntakts uses SpannableStrings in order to display rendered text on Android + +Example: +```kotlin +val syntakts = syntakts { /* */ } + +findViewById(R.id.my_text_view).render("some input", syntakts) +``` + #### Clickable Syntakts for Compose includes a ClickableText component that is neccessary in order to handle clickable text. The `syntakts-compose-material3` includes this component as well but adds support for Material 3 theming +Syntakts for Android requires that the TextView have its movementMethod set to our ClickableMovementMethod + ## Attribution Syntakts was heavily inspired by [SimpleAST](https://github.com/discord/SimpleAST), an unfortunately abandoned library that was once used in Discords android app diff --git a/build.gradle.kts b/build.gradle.kts index 132d9f2..a042281 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,7 @@ buildscript { dependencies { classpath(libs.plugin.maven) classpath(libs.plugin.multiplatform.compose) + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0") } } diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 97ca6d2..73ae276 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -53,4 +53,8 @@ android { dependencies { implementation(libs.bundles.compose) implementation(project(":syntakts-compose-material3")) + implementation(project(":syntakts-android")) + implementation(libs.appcompat) + implementation(libs.material) + implementation(libs.constraintlayout) } \ No newline at end of file diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 5b1aa0d..aad14df 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -12,6 +12,10 @@ android:supportsRtl="true" android:theme="@style/Theme.Syntakts" tools:targetApi="31"> + + """.trimIndent() + + findViewById(R.id.test_text).render( + text = testString, + syntakts = TestSyntakts, + context = Context(this), + enableClickable = true + ) + } + +} \ No newline at end of file diff --git a/demo/src/main/java/xyz/wingio/syntakts/demo/MainActivity.kt b/demo/src/main/java/xyz/wingio/syntakts/demo/MainActivity.kt index 764d231..0b4b7a2 100644 --- a/demo/src/main/java/xyz/wingio/syntakts/demo/MainActivity.kt +++ b/demo/src/main/java/xyz/wingio/syntakts/demo/MainActivity.kt @@ -1,5 +1,6 @@ package xyz.wingio.syntakts.demo +import android.content.Intent import android.graphics.Typeface import android.os.Bundle import android.widget.Toast @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ProvideTextStyle @@ -25,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -52,31 +55,10 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + val atIntent = Intent(this, AndroidTestActivity::class.java) setContent { SyntaktsTheme { - val syntakts = rememberSyntakts { - rule("<@([0-9]+)>") { result, ctx -> - val username = ctx.userMap[result.groupValues[1]] ?: "Unknown" - appendClickable( - "@$username", - Style( - color = SyntaktsColor.YELLOW, - background = SyntaktsColor.YELLOW withOpacity 0.2f - ), - onLongClick = { - Toast.makeText(this@MainActivity, "Mention long clicked", Toast.LENGTH_SHORT).show() - }, - onClick = { - Toast.makeText(this@MainActivity, "Mention clicked", Toast.LENGTH_SHORT).show() - } - ) - } - - addMarkdownRules() - } - - var text by remember { mutableStateOf("Test") } + var text by remember { mutableStateOf("**bold** *italic* __underline__ ~~strikethrough~~") } // A surface container using the 'background' color from the theme Surface( @@ -91,7 +73,7 @@ class MainActivity : ComponentActivity() { .fillMaxSize() ) { ClickableText( - text = syntakts.rememberRendered(text, Context(MaterialTheme.colorScheme.primary)), + text = TestSyntakts.rememberRendered(text, Context(LocalContext.current)), modifier = Modifier .weight(1f) .fillMaxWidth() @@ -105,6 +87,11 @@ class MainActivity : ComponentActivity() { }, modifier = Modifier.weight(1f).fillMaxWidth() ) + Button( + onClick = { startActivity(atIntent) } + ) { + Text("Launch android test") + } } } } @@ -113,7 +100,3 @@ class MainActivity : ComponentActivity() { } -data class Context( - val primaryColor: Color, - val userMap: Map = mapOf("1234" to "Wing") -) \ No newline at end of file diff --git a/demo/src/main/java/xyz/wingio/syntakts/demo/TestSyntakts.kt b/demo/src/main/java/xyz/wingio/syntakts/demo/TestSyntakts.kt new file mode 100644 index 0000000..10945eb --- /dev/null +++ b/demo/src/main/java/xyz/wingio/syntakts/demo/TestSyntakts.kt @@ -0,0 +1,34 @@ +package xyz.wingio.syntakts.demo + +import android.content.Context as AndroidContext +import android.widget.Toast +import xyz.wingio.syntakts.markdown.addMarkdownRules +import xyz.wingio.syntakts.style.Color +import xyz.wingio.syntakts.style.Style +import xyz.wingio.syntakts.syntakts + +val TestSyntakts = syntakts { + rule("<@([0-9]+)>") { result, ctx -> + val username = ctx.userMap[result.groupValues[1]] ?: "Unknown" + appendClickable( + "@$username", + Style( + color = Color.MAGENTA, + background = Color.MAGENTA withOpacity 0.2f + ), + onLongClick = { + Toast.makeText(ctx.androidContext, "Mention long clicked", Toast.LENGTH_SHORT).show() + }, + onClick = { + Toast.makeText(ctx.androidContext, "Mention clicked", Toast.LENGTH_SHORT).show() + } + ) + } + + addMarkdownRules() +} + +data class Context( + val androidContext: AndroidContext, + val userMap: Map = mapOf("1234" to "Wing") +) \ No newline at end of file diff --git a/demo/src/main/res/layout/activity_android_test.xml b/demo/src/main/res/layout/activity_android_test.xml new file mode 100644 index 0000000..30e9e68 --- /dev/null +++ b/demo/src/main/res/layout/activity_android_test.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 044914d..8a6e92b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,9 @@ junit = "4.13.2" kotlin = "1.9.10" kotlin-binary-compatibility = "0.13.2" uuid = "0.8.1" +appcompat = "1.6.1" +material = "1.9.0" +constraintlayout = "2.1.4" [libraries] plugin-android = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -15,12 +18,16 @@ plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version. plugin-maven = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.25.3" } plugin-multiplatform-compose = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.12.0" } compose-activity = { group = "androidx.activity", name = "activity-compose", version = "1.8.0" } compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "compose-material3" } compose-stable-marker = { group = "com.github.skydoves", name = "compose-stable-marker", version.ref = "compose-stable-marker" } junit = { module = "junit:junit", version.ref = "junit" } kotlin-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version = "1.7.3" } uuid = { group = "com.benasher44", name = "uuid", version.ref = "uuid"} +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } [plugins] binary-compatibility = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "kotlin-binary-compatibility" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 87e59c3..6d64abe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,5 +19,7 @@ rootProject.name = "Syntakts" include(":demo") include(":syntakts-core") + +include(":syntakts-android") include(":syntakts-compose") include(":syntakts-compose-material3") diff --git a/syntakts-android/.gitignore b/syntakts-android/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/syntakts-android/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/syntakts-android/api/syntakts-android.api b/syntakts-android/api/syntakts-android.api new file mode 100644 index 0000000..870159b --- /dev/null +++ b/syntakts-android/api/syntakts-android.api @@ -0,0 +1,74 @@ +public final class xyz/wingio/syntakts/android/ClickableMovementMethod : android/text/method/LinkMovementMethod { + public static final field Companion Lxyz/wingio/syntakts/android/ClickableMovementMethod$Companion; + public static final field LONG_TOUCH_DURATION J + public fun ()V + public fun onTouchEvent (Landroid/widget/TextView;Landroid/text/Spannable;Landroid/view/MotionEvent;)Z +} + +public final class xyz/wingio/syntakts/android/ClickableMovementMethod$Companion { +} + +public final class xyz/wingio/syntakts/android/SyntaktsKt { + public static final fun render (Landroid/widget/TextView;Ljava/lang/CharSequence;Lxyz/wingio/syntakts/Syntakts;Ljava/lang/Object;Z)V + public static final fun render (Landroid/widget/TextView;Ljava/lang/CharSequence;Lxyz/wingio/syntakts/Syntakts;Z)V + public static final fun render (Lxyz/wingio/syntakts/Syntakts;Ljava/lang/CharSequence;Landroid/content/Context;)Ljava/lang/CharSequence; + public static final fun render (Lxyz/wingio/syntakts/Syntakts;Ljava/lang/CharSequence;Ljava/lang/Object;Landroid/content/Context;)Ljava/lang/CharSequence; + public static synthetic fun render$default (Landroid/widget/TextView;Ljava/lang/CharSequence;Lxyz/wingio/syntakts/Syntakts;Ljava/lang/Object;ZILjava/lang/Object;)V + public static synthetic fun render$default (Landroid/widget/TextView;Ljava/lang/CharSequence;Lxyz/wingio/syntakts/Syntakts;ZILjava/lang/Object;)V +} + +public final class xyz/wingio/syntakts/android/markdown/MarkdownKt { + public static final fun renderBasicMarkdown (Landroid/widget/TextView;Ljava/lang/CharSequence;)V + public static final fun renderMarkdown (Landroid/widget/TextView;Ljava/lang/CharSequence;)V +} + +public class xyz/wingio/syntakts/android/spans/ClickableSpan : android/text/style/ClickableSpan { + public fun (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public fun onClick (Landroid/view/View;)V + public final fun onLongClick (Landroid/view/View;)V + public fun updateDrawState (Landroid/text/TextPaint;)V +} + +public class xyz/wingio/syntakts/android/spans/SyntaktsStyleSpan : android/text/style/MetricAffectingSpan { + public static final field Companion Lxyz/wingio/syntakts/android/spans/SyntaktsStyleSpan$Companion; + public fun (Lxyz/wingio/syntakts/style/Style;Landroid/content/Context;)V + public final fun getContext ()Landroid/content/Context; + public final fun getStyle ()Lxyz/wingio/syntakts/style/Style; + public fun updateDrawState (Landroid/text/TextPaint;)V + public fun updateMeasureState (Landroid/text/TextPaint;)V +} + +public final class xyz/wingio/syntakts/android/spans/SyntaktsStyleSpan$Companion { + public final fun apply (Landroid/text/TextPaint;Lxyz/wingio/syntakts/style/Style;Landroid/content/Context;)V +} + +public final class xyz/wingio/syntakts/android/style/ColorKt { + public static final fun fromAndroidColorLong (Lxyz/wingio/syntakts/style/Color$Companion;J)Lxyz/wingio/syntakts/style/Color; + public static final fun toAndroidColorInt (Lxyz/wingio/syntakts/style/Color;)I + public static final fun toSyntaktsColor (J)Lxyz/wingio/syntakts/style/Color; +} + +public final class xyz/wingio/syntakts/android/style/SpannableStyledTextBuilder : xyz/wingio/syntakts/style/StyledTextBuilder { + public fun (Landroid/content/Context;)V + public fun addClickable (IILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Lxyz/wingio/syntakts/android/style/SpannableStyledTextBuilder; + public synthetic fun addClickable (IILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Lxyz/wingio/syntakts/style/StyledTextBuilder; + public fun addClickable (Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Lxyz/wingio/syntakts/style/StyledTextBuilder; + public fun addStyle (IILkotlin/jvm/functions/Function1;)Lxyz/wingio/syntakts/android/style/SpannableStyledTextBuilder; + public synthetic fun addStyle (IILkotlin/jvm/functions/Function1;)Lxyz/wingio/syntakts/style/StyledTextBuilder; + public fun addStyle (Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function1;)Lxyz/wingio/syntakts/style/StyledTextBuilder; + public fun addStyle (Lxyz/wingio/syntakts/style/Style;II)Lxyz/wingio/syntakts/android/style/SpannableStyledTextBuilder; + public synthetic fun addStyle (Lxyz/wingio/syntakts/style/Style;II)Lxyz/wingio/syntakts/style/StyledTextBuilder; + public fun addStyle (Lxyz/wingio/syntakts/style/Style;Lkotlin/ranges/IntRange;)Lxyz/wingio/syntakts/style/StyledTextBuilder; + public fun append (Ljava/lang/CharSequence;Lkotlin/jvm/functions/Function1;)Lxyz/wingio/syntakts/android/style/SpannableStyledTextBuilder; + public synthetic fun append (Ljava/lang/CharSequence;Lkotlin/jvm/functions/Function1;)Lxyz/wingio/syntakts/style/StyledTextBuilder; + public fun append (Ljava/lang/CharSequence;Lxyz/wingio/syntakts/style/Style;)Lxyz/wingio/syntakts/android/style/SpannableStyledTextBuilder; + public synthetic fun append (Ljava/lang/CharSequence;Lxyz/wingio/syntakts/style/Style;)Lxyz/wingio/syntakts/style/StyledTextBuilder; + public fun appendClickable (Ljava/lang/CharSequence;Lxyz/wingio/syntakts/style/Style;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Lxyz/wingio/syntakts/android/style/SpannableStyledTextBuilder; + public synthetic fun appendClickable (Ljava/lang/CharSequence;Lxyz/wingio/syntakts/style/Style;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Lxyz/wingio/syntakts/style/StyledTextBuilder; + public fun build ()Ljava/lang/CharSequence; + public synthetic fun build ()Ljava/lang/Object; + public fun clear ()V + public final fun getContext ()Landroid/content/Context; + public fun getLength ()I +} + diff --git a/syntakts-android/build.gradle.kts b/syntakts-android/build.gradle.kts new file mode 100644 index 0000000..f1a7c76 --- /dev/null +++ b/syntakts-android/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + kotlin("multiplatform") + id("com.android.library") + id("com.vanniktech.maven.publish.base") + alias(libs.plugins.binary.compatibility) +} + +setup( + libName = "Syntakts for Android", + moduleName = "syntakts-android", + moduleDescription = "Support for Syntakts rendering on Android" +) + +kotlin { + androidTarget { + publishLibraryVariants("release") + } + + jvmToolchain(17) + explicitApi() + + sourceSets { + val androidMain by named("androidMain") { + dependencies { + api(project(":syntakts-core")) + implementation(libs.androidx.core.ktx) + implementation(libs.kotlin.coroutines.core) + } + } + + val androidTest by named("androidUnitTest") { + dependencies { + implementation(kotlin("test")) + implementation(libs.junit) + } + } + } +} \ No newline at end of file diff --git a/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/ClickableMovementMethod.kt b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/ClickableMovementMethod.kt new file mode 100644 index 0000000..8997bac --- /dev/null +++ b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/ClickableMovementMethod.kt @@ -0,0 +1,105 @@ +package xyz.wingio.syntakts.android + +import android.text.Spannable +import android.text.method.LinkMovementMethod +import android.view.MotionEvent +import android.widget.TextView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import xyz.wingio.syntakts.android.spans.ClickableSpan + +/** + * Special [LinkMovementMethod] that can also process long clicks, only works with [ClickableSpan] + */ +public class ClickableMovementMethod : LinkMovementMethod() { + + /** + * Used to asynchronously measure time + */ + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main.immediate) + + /** + * How long the touch has lasted + */ + private var downTime: Long = 0 + + /** + * The current long press job, can only keep track of one at a time + */ + private var longPressJob: Job? = null + + override fun onTouchEvent( + widget: TextView, + buffer: Spannable, + event: MotionEvent + ): Boolean { + val action = event.action + + // We only need to wait for these events + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + var x = event.x.toInt() + var y = event.y.toInt() + + x -= widget.totalPaddingLeft + y -= widget.totalPaddingTop + + x += widget.scrollX + y += widget.scrollY + + val layout = widget.layout + val line = layout.getLineForVertical(y) + val offset = layout.getOffsetForHorizontal(line, x.toFloat()) + + val links = buffer.getSpans( + /* start = */ offset, + /* end = */ offset, + /* type = */ ClickableSpan::class.java + ) + + if (links.isNotEmpty()) { + val link = links[0] // Unlike Compose we can only do the first one + + if (action == MotionEvent.ACTION_DOWN) { + downTime = System.currentTimeMillis() + + longPressJob = coroutineScope.launch { + while (true) { + delay(1) // Only check every millisecond + val downDuration = System.currentTimeMillis() - downTime + if (downDuration >= LONG_TOUCH_DURATION) { + link.onLongClick(widget) + break // Only fire once + } + } + } + } + + if (action == MotionEvent.ACTION_UP) { + longPressJob?.cancel() + longPressJob = null + val downDuration = System.currentTimeMillis() - downTime + if (downDuration < LONG_TOUCH_DURATION) { + link.onClick(widget) + } + } + + return true + } + } + + return super.onTouchEvent(widget, buffer, event) + } + + public companion object { + + /** + * How long to wait before firing a long click event + */ + public const val LONG_TOUCH_DURATION: Long = 500 // ms + + } + +} \ No newline at end of file diff --git a/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/Syntakts.kt b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/Syntakts.kt new file mode 100644 index 0000000..6d47e74 --- /dev/null +++ b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/Syntakts.kt @@ -0,0 +1,71 @@ +package xyz.wingio.syntakts.android + +import android.content.Context +import android.widget.TextView +import xyz.wingio.syntakts.Syntakts +import xyz.wingio.syntakts.android.style.SpannableStyledTextBuilder + +/** + * Parse and render the given [text] using the defined rules into a SpannableString + * + * @param text What to parse and render + * @param context Additional information that nodes may need to render + * @param androidContext Necessary for certain text measurements + * @return SpannableString as a [CharSequence] + */ +public fun Syntakts.render(text: CharSequence, context: C, androidContext: Context): CharSequence { + val builder = SpannableStyledTextBuilder(androidContext) + val nodes = parse(text) + for (node in nodes) { + node.render(builder, context) + } + return builder.build() +} + +/** + * Parse and render the given [text] using the defined rules into a SpannableString + * + * @param text What to parse and render + * @param androidContext Necessary for certain text measurements + * @return SpannableString as a [CharSequence] + */ +public fun Syntakts.render(text: CharSequence, androidContext: Context): CharSequence = + render(text, Unit, androidContext) + +/** + * Parse and render the given [text] using the [syntakts] onto this [TextView] + * + * @param text What to parse and render + * @param syntakts An instance of [Syntakts] with the desired rules + * @param context Additional information that nodes may need to render + * @param enableClickable (optional) Whether or not to process click and long click events + */ +public fun TextView.render( + text: CharSequence, + syntakts: Syntakts, + context: C, + enableClickable: Boolean = false +) { + setText(syntakts.render(text, context, getContext())) + if (enableClickable) { + movementMethod = ClickableMovementMethod() + } +} + +/** + * Parse and render the given [text] using the [syntakts] onto this [TextView] + * + * @param text What to parse and render + * @param syntakts An instance of [Syntakts] with the desired rules + * @param enableClickable (optional) Whether or not to process click and long click events + */ +public fun TextView.render( + text: CharSequence, + syntakts: Syntakts, + enableClickable: Boolean = false +) { + setText(syntakts.render(text, context)) + if (enableClickable) { + movementMethod = ClickableMovementMethod() + } +} \ No newline at end of file diff --git a/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/markdown/Markdown.kt b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/markdown/Markdown.kt new file mode 100644 index 0000000..052eae3 --- /dev/null +++ b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/markdown/Markdown.kt @@ -0,0 +1,22 @@ +package xyz.wingio.syntakts.android.markdown + +import android.widget.TextView +import xyz.wingio.syntakts.android.render +import xyz.wingio.syntakts.markdown.BasicMarkdownSyntakts +import xyz.wingio.syntakts.markdown.MarkdownSyntakts + +/** + * Renders some Markdown to this [TextView] + * + * @param text The Markdown to be rendered, see [MarkdownSyntakts] for supported rules + * @see [MarkdownSyntakts] + */ +public fun TextView.renderMarkdown(text: CharSequence): Unit = render(text, MarkdownSyntakts) + +/** + * Renders some basic Markdown rules to this [TextView] + * + * @param text The Markdown to be rendered, see [BasicMarkdownSyntakts] for supported rules + * @see [BasicMarkdownSyntakts] + */ +public fun TextView.renderBasicMarkdown(text: CharSequence): Unit = render(text, BasicMarkdownSyntakts) \ No newline at end of file diff --git a/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/spans/ClickableSpan.kt b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/spans/ClickableSpan.kt new file mode 100644 index 0000000..43de950 --- /dev/null +++ b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/spans/ClickableSpan.kt @@ -0,0 +1,35 @@ +package xyz.wingio.syntakts.android.spans + +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.view.View + +/** + * [ClickableSpan] with the added ability to receive long clicks + * + * @param onClickListener Called when this span is clicked + * @param onLongClickListener Called when this span is long clicked + */ +public open class ClickableSpan( + private val onClickListener: (() -> Unit)?, + private val onLongClickListener: (() -> Unit)? +) : ClickableSpan() { + + override fun onClick(view: View) { + onClickListener?.invoke() + } + + /** + * Performs the long click action associated with this span + * + * @param view A reference to the view that was clicked + */ + public fun onLongClick(view: View) { + onLongClickListener?.invoke() + } + + override fun updateDrawState(ds: TextPaint) { + // NO-OP + } + +} \ No newline at end of file diff --git a/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/spans/SyntaktsStyleSpan.kt b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/spans/SyntaktsStyleSpan.kt new file mode 100644 index 0000000..28b75d5 --- /dev/null +++ b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/spans/SyntaktsStyleSpan.kt @@ -0,0 +1,137 @@ +package xyz.wingio.syntakts.android.spans + +import android.content.Context +import android.graphics.Typeface +import android.text.TextPaint +import android.text.style.MetricAffectingSpan +import androidx.core.graphics.TypefaceCompat +import xyz.wingio.syntakts.android.style.toAndroidColorInt +import xyz.wingio.syntakts.android.util.emToPx +import xyz.wingio.syntakts.android.util.spToEm +import xyz.wingio.syntakts.android.util.spToPx +import xyz.wingio.syntakts.style.FontStyle +import xyz.wingio.syntakts.style.Style +import xyz.wingio.syntakts.style.TextDecoration +import xyz.wingio.syntakts.style.TextUnit +import kotlin.math.roundToInt + +/** + * Span that applies a [Style] + * + * @param style The [Style] to apply + * @param context Necessary for certain measurements + */ +public open class SyntaktsStyleSpan( + public val style: Style, + public val context: Context +) : MetricAffectingSpan() { + + override fun updateDrawState(tp: TextPaint?) { + apply(tp, style, context) + } + + override fun updateMeasureState(textPaint: TextPaint) { + apply(textPaint, style, context) + } + + public companion object { + + /** + * Applies a given [style] to a [paint] + * + * @param paint Information for how text can be displayed + * @param style The [Style] to apply + * @param context Necessary for certain measurements + */ + public fun apply(paint: TextPaint?, style: Style, context: Context) { + if (paint == null) return + + with(style) { + color?.let { color -> + paint.setColor(color.toAndroidColorInt()) + } + + background?.let { background -> + paint.bgColor = background.toAndroidColorInt() + } + + if (fontSize !is TextUnit.Unspecified) { + paint.textSize = when (fontSize.unit) { + "sp" -> context.spToPx(fontSize.value) + "em" -> emToPx(fontSize.value, paint.textSize) + else -> paint.letterSpacing + } + } + + fontWeight?.let { fontWeight -> + paint.typeface = + TypefaceCompat.create(context, paint.typeface, fontWeight.weight, false) + } + + paint.typeface = when { + fontWeight != null && fontStyle != null -> TypefaceCompat.create( + context, + paint.typeface, + fontWeight!!.weight, + fontStyle!! == FontStyle.Italic + ) + + fontWeight != null -> TypefaceCompat.create( + context, + paint.typeface, + fontWeight!!.weight, + false + ) + + fontStyle != null -> TypefaceCompat.create( + context, + paint.typeface, + Typeface.ITALIC + ) + + else -> paint.typeface + } + + if (letterSpacing !is TextUnit.Unspecified) { + paint.letterSpacing = when (letterSpacing.unit) { + "sp" -> spToEm(letterSpacing.value, paint.textSize) + "em" -> letterSpacing.value + else -> paint.letterSpacing + } + } + + when (textDecoration) { + TextDecoration.Underline -> paint.isUnderlineText = true + TextDecoration.LineThrough -> paint.isStrikeThruText = true + else -> {} + } + + paragraphStyle?.let { paragraphStyle -> + when (paragraphStyle.lineHeight.unit) { + "sp" -> paint.applyLineHeight(context.spToPx(paragraphStyle.lineHeight.value)) + "em" -> paint.applyLineHeight(emToPx(paragraphStyle.lineHeight.value, paint.textSize)) + } + } + } + } + + } + +} + +/** + * Applies the given [lineHeight] to this paint + * + * @param lineHeight The line height for this text (in pixels) + */ +internal fun TextPaint.applyLineHeight(lineHeight: Float) { + val originHeight = fontMetricsInt.descent - fontMetricsInt.ascent + + if (originHeight <= 0) { + return + } + + val ratio: Float = lineHeight * 1.0f / originHeight + fontMetricsInt.descent = (ratio * fontMetricsInt.descent).roundToInt() + fontMetricsInt.ascent = (fontMetricsInt.descent - lineHeight).roundToInt() +} \ No newline at end of file diff --git a/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/style/Color.kt b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/style/Color.kt new file mode 100644 index 0000000..fe3e7a5 --- /dev/null +++ b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/style/Color.kt @@ -0,0 +1,41 @@ +package xyz.wingio.syntakts.android.style + +import androidx.annotation.ColorInt +import androidx.annotation.ColorLong +import xyz.wingio.syntakts.style.Color + +/** + * Convert a [Color] to an Android color int (0xAARRGGBB) + */ +@ColorInt +public fun Color.toAndroidColorInt(): Int { + return ( + (alpha shl 24) or + (red shl 16) or + (green shl 8) or + blue + ) +} + +/** + * Converts a color formatted [Long] to a [Color] + */ +public fun @receiver:ColorLong Long.toSyntaktsColor(): Color { + val alpha = shr(24) and 0xFF + val red = shr(16) and 0xFF + val green = shr(8) and 0xFF + val blue = and(0xFF) + + return Color( + red = red.toInt(), + green = green.toInt(), + blue = blue.toInt(), + alpha = alpha.toInt() + ) +} + +/** + * Creates a [Color] from a color long + */ +public fun Color.Companion.fromAndroidColorLong(@ColorLong colorLong: Long): Color = + colorLong.toSyntaktsColor() \ No newline at end of file diff --git a/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/style/SpannableStyledTextBuilder.kt b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/style/SpannableStyledTextBuilder.kt new file mode 100644 index 0000000..9107ad3 --- /dev/null +++ b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/style/SpannableStyledTextBuilder.kt @@ -0,0 +1,123 @@ +package xyz.wingio.syntakts.android.style + +import android.content.Context +import android.text.Spannable +import android.text.SpannableStringBuilder +import xyz.wingio.syntakts.android.spans.ClickableSpan +import xyz.wingio.syntakts.android.spans.SyntaktsStyleSpan +import xyz.wingio.syntakts.style.Style +import xyz.wingio.syntakts.style.StyledTextBuilder + +/** + * Instance of [StyledTextBuilder] that builds SpannableStrings + * + * @param context Used for certain measurements when applying styles + */ +public class SpannableStyledTextBuilder( + public val context: Context +) : StyledTextBuilder { + private val builder = SpannableStringBuilder() + + override val length: Int + get() = builder.length + + override fun append(text: CharSequence, style: Style?): SpannableStyledTextBuilder { + val i = length + builder.append(text) + style?.let { + builder.setSpan( + /* what = */ SyntaktsStyleSpan(style, context), + /* start = */ i, + /* end = */ length, + /* flags = */ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + it.paragraphStyle?.let { paragraphStyle -> + paragraphStyle.lineHeight + } + } + return this + } + + override fun append( + text: CharSequence, + style: Style.() -> Unit + ): SpannableStyledTextBuilder { + return append(text = text, style = Style().apply(style)) + } + + override fun appendClickable( + text: CharSequence, + style: Style?, + onLongClick: (() -> Unit)?, + onClick: () -> Unit + ): SpannableStyledTextBuilder { + val i = length + append(text, style) + builder.setSpan( + /* what = */ ClickableSpan( + onClickListener = onClick, + onLongClickListener = onLongClick + ), + /* start = */ i, + /* end = */ length, + /* flags = */ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + return this + } + + override fun addClickable( + startIndex: Int, + endIndex: Int, + onLongClick: (() -> Unit)?, + onClick: () -> Unit + ): SpannableStyledTextBuilder { + builder.setSpan( + /* what = */ ClickableSpan( + onClickListener = onClick, + onLongClickListener = onLongClick + ), + /* start = */ startIndex, + /* end = */ endIndex, + /* flags = */ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + return this + } + + override fun addStyle( + style: Style, + startIndex: Int, + endIndex: Int + ): SpannableStyledTextBuilder { + builder.setSpan( + /* what = */ SyntaktsStyleSpan(style, context), + /* start = */ startIndex, + /* end = */ endIndex, + /* flags = */ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + return this + } + + override fun addStyle( + startIndex: Int, + endIndex: Int, + style: Style.() -> Unit + ): SpannableStyledTextBuilder { + builder.setSpan( + /* what = */ SyntaktsStyleSpan(Style().apply(style), context), + /* start = */ startIndex, + /* end = */ endIndex, + /* flags = */ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + return this + } + + override fun clear() { + builder.clear() + } + + override fun build(): CharSequence { + return builder + } + +} \ No newline at end of file diff --git a/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/util/DimenUtil.kt b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/util/DimenUtil.kt new file mode 100644 index 0000000..de8cd92 --- /dev/null +++ b/syntakts-android/src/androidMain/kotlin/xyz/wingio/syntakts/android/util/DimenUtil.kt @@ -0,0 +1,37 @@ +package xyz.wingio.syntakts.android.util + +import android.content.Context +import android.util.TypedValue + +/** + * Converts sp units to em + * + * @param textSize Size of the text, in pixels + */ +internal fun spToEm( + sp: Float, + textSize: Float +) = sp / textSize + +/** + * Converts em units to px + * + * @param textSize Size of the text, in pixels + * @return Em size in pixels + */ +internal fun emToPx(em: Float, textSize: Float): Float { + return em * textSize +} + +/** + * Converts sp units to px + * + * @return sp size in pixels + */ +internal fun Context.spToPx(sp: Float): Float { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + sp, + resources.displayMetrics + ) +} \ No newline at end of file diff --git a/syntakts-core/src/commonMain/kotlin/xyz/wingio/syntakts/Syntakts.kt b/syntakts-core/src/commonMain/kotlin/xyz/wingio/syntakts/Syntakts.kt index c4f6c6f..fd73411 100644 --- a/syntakts-core/src/commonMain/kotlin/xyz/wingio/syntakts/Syntakts.kt +++ b/syntakts-core/src/commonMain/kotlin/xyz/wingio/syntakts/Syntakts.kt @@ -218,6 +218,11 @@ public class Syntakts internal constructor( return this } + /** + * Set options for debugging + * + * @param options Options for debugging + */ public fun debugOptions(options: DebugOptions): Builder { debugOptions = options return this