diff --git a/app/build.gradle b/app/build.gradle index 6b078de7..b20f39de 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -267,6 +267,9 @@ dependencies { implementation "androidx.datastore:datastore:1.1.1" implementation "androidx.datastore:datastore-preferences:1.1.1" + // Markup + implementation "org.jetbrains:markdown:0.7.3" + // Livekit // FIXME temporarily not included, re-add when realtime media is to be implemented // implementation "io.livekit:livekit-android:$livekit_version" diff --git a/app/src/main/java/chat/revolt/components/markdown/jbm/JBMApi.kt b/app/src/main/java/chat/revolt/components/markdown/jbm/JBMApi.kt new file mode 100644 index 00000000..bdc15282 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/markdown/jbm/JBMApi.kt @@ -0,0 +1,17 @@ +package chat.revolt.components.markdown.jbm + +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import org.intellij.markdown.parser.MarkdownParser + +@RequiresOptIn(message = "This API is experimental and has many TODOs.") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +annotation class JBM + +@JBM +object JBMApi { + fun parse(src: String): ASTNode { + return MarkdownParser(GFMFlavourDescriptor()).buildMarkdownTreeFromString(src) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/components/markdown/jbm/JBMRenderer.kt b/app/src/main/java/chat/revolt/components/markdown/jbm/JBMRenderer.kt new file mode 100644 index 00000000..741223a8 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/markdown/jbm/JBMRenderer.kt @@ -0,0 +1,365 @@ +package chat.revolt.components.markdown.jbm + +import android.util.Log +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.revolt.components.markdown.Annotations +import chat.revolt.components.utils.detectTapGesturesConditionalConsume +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.MarkdownTokenTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import org.intellij.markdown.flavours.gfm.GFMTokenTypes + +data class JBMarkdownTreeState( + val sourceText: String = "", + val ignoreLineBreaks: Boolean = false, + val listDepth: Int = 0, + val fontSizeMultiplier: Float = 1f, + val linksClickable: Boolean = true +) + +val LocalJBMarkdownTreeState = + compositionLocalOf(structuralEqualityPolicy()) { JBMarkdownTreeState() } + +@Composable +@JBM +fun JBMRenderer(content: String, modifier: Modifier = Modifier) { + var tree by remember { mutableStateOf(JBMApi.parse(content)) } + + LaunchedEffect(content) { + tree = JBMApi.parse(content) + + Log.d("JBMRenderer", "Parsed tree: ${tree.children.map { it.type.name }}") + } + + CompositionLocalProvider( + LocalJBMarkdownTreeState provides JBMarkdownTreeState(content) + ) { + tree.children.map { + JBMBlock(it, modifier) + } + } +} + +private fun annotateText( + state: JBMarkdownTreeState, + node: ASTNode +): AnnotatedString { + val sourceText = state.sourceText + + return try { + buildAnnotatedString { + when (node.type) { + MarkdownTokenTypes.TEXT -> { + append(node.getTextInNode(sourceText)) + } + + MarkdownElementTypes.EMPH -> { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + for (child in node.children) { + append(annotateText(state, child)) + } + } + } + + MarkdownElementTypes.STRONG -> { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + for (child in node.children) { + append(annotateText(state, child)) + } + } + } + + GFMElementTypes.STRIKETHROUGH -> { + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + // Skip the first two children and the last two children + // because they are the tilde characters + for (child in node.children.subList(2, node.children.size - 2)) { + append(annotateText(state, child)) + } + } + } + + MarkdownTokenTypes.LIST_BULLET -> { + append(" ".repeat(state.listDepth) + " " + if (state.listDepth % 2 == 0) "•" else "◦" + " ") + } + + MarkdownTokenTypes.LIST_NUMBER -> { + withStyle(SpanStyle(fontFeatureSettings = "'tnum'")) { + append(" ".repeat(state.listDepth) + "${node.getTextInNode(sourceText)} ") + } + } + + MarkdownElementTypes.UNORDERED_LIST, + MarkdownElementTypes.ORDERED_LIST, + MarkdownElementTypes.LIST_ITEM -> { + for (child in node.children) { + append(annotateText(state, child)) + } + } + + GFMTokenTypes.CHECK_BOX -> { + if (node.getTextInNode(sourceText).trim() == "[ ]") { + appendInlineContent("checkbox", "❌") + } else { + appendInlineContent("checkbox", "✅") + } + append(" ") + } + + MarkdownElementTypes.PARAGRAPH, MarkdownElementTypes.HTML_BLOCK -> { + for (child in node.children) { + append(annotateText(state, child)) + } + } + + // re-render types + // for example, various syntactic elements like exclamation marks, brackets, etc. + // we simply append the text as is + MarkdownTokenTypes.EXCLAMATION_MARK, + MarkdownTokenTypes.LBRACKET, + MarkdownTokenTypes.RBRACKET, + MarkdownTokenTypes.LPAREN, + MarkdownTokenTypes.RPAREN, + MarkdownTokenTypes.LT, + MarkdownTokenTypes.GT, + MarkdownTokenTypes.BACKTICK, + MarkdownTokenTypes.DOUBLE_QUOTE, + MarkdownTokenTypes.SINGLE_QUOTE, + MarkdownTokenTypes.EOL, + MarkdownTokenTypes.WHITE_SPACE, + MarkdownTokenTypes.COLON, + GFMTokenTypes.TILDE -> { + append(node.getTextInNode(sourceText)) + } + + // no-op types + // for example, the special characters that are used to denote the markup are here + MarkdownTokenTypes.EMPH -> { + } + + else -> { + append("[${node.type.name}]{\n") + append(node.getTextInNode(sourceText)) + append("\n}") + } + } + } + } catch (e: Exception) { + buildAnnotatedString { + withStyle(SpanStyle(color = Color(0xFFFF0000), background = Color(0xFF000000))) { + append("[${node.type.name}] Error: ${e.message}") + } + + Log.e("JBMRenderer", "Error rendering node: ${node.type.name}", e) + } + } +} + +@Composable +private fun JBMText(node: ASTNode, modifier: Modifier) { + var layoutResult by remember { mutableStateOf(null) } + val mdState = LocalJBMarkdownTreeState.current + val annotatedText = remember(node) { annotateText(mdState, node) } + val colours = MaterialTheme.colorScheme + + val shouldConsumeTap = handler@{ offset: Int -> + Annotations.entries.filter { it.clickable }.map { it.tag }.forEach { tag -> + if (annotatedText.getStringAnnotations( + tag = tag, + start = offset, + end = offset + ).isNotEmpty() + ) { + return@handler true + } + } + + return@handler false + } + + val onClick = handler@{ offset: Int -> + if (mdState.linksClickable) { + } + } + + val onLongClick = handler@{ offset: Int -> + if (mdState.linksClickable) { + } + } + + Text( + text = annotatedText, + onTextLayout = { layoutResult = it }, + modifier = modifier.pointerInput(onClick, onLongClick) { + detectTapGesturesConditionalConsume( + onTap = { pos -> + val index = + layoutResult?.getOffsetForPosition(pos) + ?: return@detectTapGesturesConditionalConsume + onClick(index) + }, + onLongPress = { pos -> + val index = + layoutResult?.getOffsetForPosition(pos) + ?: return@detectTapGesturesConditionalConsume + onLongClick(index) + }, + shouldConsumeTap = { pos -> + val index = + layoutResult?.getOffsetForPosition(pos) + ?: return@detectTapGesturesConditionalConsume false + shouldConsumeTap(index) + } + ) + }, + inlineContent = mapOf( + "checkbox" to InlineTextContent( + placeholder = Placeholder( + width = LocalTextStyle.current.fontSize * 1.5, + height = LocalTextStyle.current.fontSize * 1.5, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ), + children = { alternateText -> + val isCheck = alternateText == "✅" + + with(LocalDensity.current) { + Canvas(modifier = Modifier.size((LocalTextStyle.current.fontSize * 1.5).toDp())) { + drawRoundRect( + color = if (isCheck) colours.primaryContainer else colours.surfaceContainer, + cornerRadius = CornerRadius(size.width * 0.1f), + topLeft = Offset(size.width * 0.1f, size.height * 0.1f), + size = size.copy( + width = size.width * 0.8f, + height = size.height * 0.8f + ) + ) + + if (isCheck) { + drawPath( + path = Path().apply { + moveTo(size.width * 0.8f, size.height * 0.3f) + lineTo(size.width * 0.4f, size.height * 0.7f) + lineTo(size.width * 0.2f, size.height * 0.5f) + }, + color = colours.onPrimaryContainer, + style = Stroke(width = size.width * 0.1f) + ) + } + } + } + } + ) + ) + ) +} + +@Composable +private fun JBMBlock(node: ASTNode, modifier: Modifier) { + when (node.type) { + MarkdownElementTypes.PARAGRAPH, + MarkdownElementTypes.HTML_BLOCK -> { + CompositionLocalProvider( + LocalTextStyle provides LocalTextStyle.current.copy( + fontSize = LocalTextStyle.current.fontSize * LocalJBMarkdownTreeState.current.fontSizeMultiplier + ) + ) { + JBMText(node, modifier) + } + } + + MarkdownElementTypes.ATX_1, + MarkdownElementTypes.ATX_2, + MarkdownElementTypes.ATX_3, + MarkdownElementTypes.ATX_4, + MarkdownElementTypes.ATX_5, + MarkdownElementTypes.ATX_6 -> { + CompositionLocalProvider( + LocalTextStyle provides LocalTextStyle.current.copy( + fontWeight = FontWeight.Bold, + fontSize = when (node.type) { + MarkdownElementTypes.ATX_1 -> 32.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier + MarkdownElementTypes.ATX_2 -> 24.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier + MarkdownElementTypes.ATX_3 -> 20.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier + MarkdownElementTypes.ATX_4 -> 16.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier + MarkdownElementTypes.ATX_5 -> 14.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier + else -> 12.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier + }, + color = when (node.type) { + MarkdownElementTypes.ATX_1 -> Color(0xFFFF0000) + MarkdownElementTypes.ATX_2 -> Color(0xFF00FF00) + MarkdownElementTypes.ATX_3 -> Color(0xFF0000FF) + MarkdownElementTypes.ATX_4 -> Color(0xFFFF00FF) + MarkdownElementTypes.ATX_5 -> Color(0xFF00FFFF) + else -> Color(0xFFFFFF00) + } + ) + ) { + if (node.startOffset != 0) { + Box(Modifier.padding(top = 8.dp)) + } + JBMText(node, modifier) + } + } + + MarkdownElementTypes.ORDERED_LIST, + MarkdownElementTypes.UNORDERED_LIST -> { + CompositionLocalProvider( + LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy( + listDepth = LocalJBMarkdownTreeState.current.listDepth + 1 + ) + ) { + JBMText(node, modifier) + } + } + + else -> { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = Color(0xFFFF7F50))) { + append("[Unknown block type ${node.type.name}]") + } + }, + modifier = modifier + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/labs/LabsHomeScreen.kt b/app/src/main/java/chat/revolt/screens/labs/LabsHomeScreen.kt index 0e7f4cb4..ce589527 100644 --- a/app/src/main/java/chat/revolt/screens/labs/LabsHomeScreen.kt +++ b/app/src/main/java/chat/revolt/screens/labs/LabsHomeScreen.kt @@ -166,6 +166,15 @@ fun LabsHomeScreen(navController: NavController) { } ) HorizontalDivider() + ListItem( + headlineContent = { + Text("JB Markdown") + }, + modifier = Modifier.clickable { + navController.navigate("sandboxes/jbm") + } + ) + HorizontalDivider() } } } diff --git a/app/src/main/java/chat/revolt/screens/labs/LabsRootScreen.kt b/app/src/main/java/chat/revolt/screens/labs/LabsRootScreen.kt index bc7d90cd..7228816b 100644 --- a/app/src/main/java/chat/revolt/screens/labs/LabsRootScreen.kt +++ b/app/src/main/java/chat/revolt/screens/labs/LabsRootScreen.kt @@ -14,6 +14,7 @@ import androidx.navigation.compose.rememberNavController import chat.revolt.api.settings.FeatureFlags import chat.revolt.screens.labs.ui.mockups.CallScreenMockup import chat.revolt.screens.labs.ui.sandbox.CryptographicAgeVerificationSandbox +import chat.revolt.screens.labs.ui.sandbox.JBMSandbox import chat.revolt.screens.labs.ui.sandbox.SettingsDslSandbox annotation class LabsFeature @@ -71,6 +72,9 @@ fun LabsRootScreen(topNav: NavController) { composable("sandboxes/settingsdsl") { SettingsDslSandbox(labsNav) } + composable("sandboxes/jbm") { + JBMSandbox(labsNav) + } } } } diff --git a/app/src/main/java/chat/revolt/screens/labs/ui/sandbox/JBMSandbox.kt b/app/src/main/java/chat/revolt/screens/labs/ui/sandbox/JBMSandbox.kt new file mode 100644 index 00000000..00b4bfa0 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/labs/ui/sandbox/JBMSandbox.kt @@ -0,0 +1,58 @@ +package chat.revolt.screens.labs.ui.sandbox + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.navigation.NavController +import chat.revolt.components.markdown.jbm.JBM +import chat.revolt.components.markdown.jbm.JBMRenderer +import chat.revolt.settings.dsl.SettingsPage + +@OptIn(JBM::class) +@Composable +fun JBMSandbox(navController: NavController) { + var mdSource by remember { mutableStateOf("") } + var submitMdSource by remember { mutableStateOf(null) } + + SettingsPage( + navController = navController, + title = { + Text( + text = "JB Markdown Sandbox", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + ) { + Subcategory( + title = { Text("Source", maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) { + TextField( + value = mdSource, + onValueChange = { mdSource = it }, + label = { Text("Markdown source") }, + modifier = Modifier.fillMaxWidth() + ) + + TextButton(onClick = { + submitMdSource = mdSource + }) { + Text("Submit") + } + } + Subcategory( + title = { Text("Output", maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) { + submitMdSource?.let { JBMRenderer(it, Modifier) } + ?: Text("Submit some Markdown and see the output.") + } + } +} \ No newline at end of file