2021/06/26

[Jetpack Compose]プログラミング言語のシンタックスハイライト

kotlinjetpack-compose

概要

以下のような見た目のComposableを実装しました。

code-view-min.png

  • Input
    • 言語の種類(javaとかgoとかの文字列)
    • ソースコード文字列
  • Output
    • スクロール可能(縦横どちらも可能)なビュー
    • ソースコードはシンタックスハイライトされている
    • コピーボタンが右上隅に配置されている

実装の基本方針

以下のライブラリをうまく利用させてもらいました。

CodeHighlighter.highlight

  1. こちらのライブラリはJetpack Compose用にできていないため、CodeHighlighter.highlightの戻り値をandroidx.compose.ui.text.AnnotatedStringになるように改造しました。
  2. その後、シンタックスハイライトされたAnnotatedStringを使って、rememberScrollStateをうまく利用して横にも縦にもスクロール可能なコードビューを実装しました
  3. 最後に、androidx.compose.ui.platform.LocalClipboardManagerを使って、コピーボタンを押したらソースコードがクリップボードにコピーされるボタンを配置しました

実装の詳細

CodeHighlighterの改造

以下のようにしました。

  • AnnotatedString.Builderをうまく使い、ソースコードのパース結果をハイライトしながら追加していきます。
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import io.github.kbiakov.codeview.highlight.parser.ParseResult
import io.github.kbiakov.codeview.highlight.prettify.PrettifyParser
import java.util.*

object CodeHighlighter {

    /**
     * Highlight code content.
     *
     * @param language Programming language
     * @param source Source code as single string
     * @param theme Color theme (see below)
     * @return Highlighted code in form of AnnotatedString
     */
    fun highlight(language: String, source: String, theme: ColorThemeData): AnnotatedString {
        val colors = buildColorsMap(theme)
        return with (AnnotatedString.Builder()) {
            PrettifyParser().parse(language, source).forEach { parseResult ->
                colors[parseResult]?.let {
                    pushStyle(SpanStyle(color = it))
                }
                append(source highlight parseResult)
                colors[parseResult]?.let {
                    pop()
                }
            }
            toAnnotatedString()
        }
    }

    // - Helpers

    /**
     * Parse input by extracting highlighted content.
     *
     * @param result Syntax unit
     * @return Content to highlight
     */
    private infix fun String.highlight(result: ParseResult) = safeLT {
        substring(result.offset, result.offset + result.length)
    }

    /**
     * Color accessor from built color map for selected color theme.
     *
     * @param result Syntax unit
     * @return Color for syntax unit
     */
    private operator fun HashMap<String, Color>.get(result: ParseResult) =
            this[result.styleKeys[0]] ?: this["pln"]

    /**
     * Build fast accessor (as map) for selected color theme.
     *
     * @param colorTheme Color theme
     * @return Colors map built from color theme
     */
    private fun buildColorsMap(theme: ColorThemeData): HashMap<String, Color> {
        fun color(body: SyntaxColors.() -> Int) =
                theme.syntaxColors.let { body(it).color() }
        return hashMapOf(
                "typ" to color { type },
                "kwd" to color { keyword },
                "lit" to color { literal },
                "com" to color { comment },
                "str" to color { string },
                "pun" to color { punctuation },
                "pln" to color { plain },
                "tag" to color { tag },
                "dec" to color { declaration },
                "src" to color { plain },
                "atn" to color { attrName },
                "atv" to color { attrValue },
                "nocode" to color { plain })
    }

    // - Escaping/extracting "less then" symbol

    private fun String.safeLT(op: String.() -> String) = escapeLT().op().expandLT()
    private fun String.escapeLT() = replace("<", "^")
    private fun String.expandLT() = replace("^", "&lt;")
}

/**
 * Color theme presets.
 */
enum class ColorTheme(
        val syntaxColors: SyntaxColors = SyntaxColors(),
        val numColor: Int,
        val bgContent: Int,
        val bgNum: Int,
        val noteColor: Int) {

    SOLARIZED_LIGHT(
            numColor = 0x93A1A1,
            bgContent = 0xFDF6E3,
            bgNum = 0xEEE8D5,
            noteColor = 0x657B83),

    MONOKAI(
            syntaxColors = SyntaxColors(
                    type = 0xA7E22E,
                    keyword = 0xFA2772,
                    literal = 0x66D9EE,
                    comment = 0x76715E,
                    string = 0xE6DB74,
                    punctuation = 0xC1C1C1,
                    plain = 0xF8F8F0,
                    tag = 0xF92672,
                    declaration = 0xFA2772,
                    attrName = 0xA6E22E,
                    attrValue = 0xE6DB74),
            numColor = 0x48483E,
            bgContent = 0x272822,
            bgNum = 0x272822,
            noteColor = 0xCFD0C2),

    DEFAULT(
            numColor = 0x99A8B7,
            bgContent = 0xE9EDF4,
            bgNum = 0xF2F2F6,
            noteColor = 0x4C5D6E);

    fun theme() = ColorThemeData(
            syntaxColors,
            numColor,
            bgContent,
            bgNum,
            noteColor)
}

/**
 * Custom color theme.
 */
data class ColorThemeData(
        val syntaxColors: SyntaxColors = SyntaxColors(),
        val numColor: Int,
        val bgContent: Int,
        val bgNum: Int,
        val noteColor: Int) {

    /**
     * Decompose preset color theme to data.
     * Use this form for using from Kotlin.
     */
    fun with(
            mySyntaxColors: SyntaxColors = syntaxColors,
            myNumColor: Int = numColor,
            myBgContent: Int = bgContent,
            myBgNum: Int = bgNum,
            myNoteColor: Int = noteColor
    ) = this

    /**
     * Decompose preset color theme to data.
     * Use this form for using from Java.
     */
    fun withSyntaxColors(mySyntaxColors: SyntaxColors) =
            with(mySyntaxColors = mySyntaxColors)

    fun withNumColor(myNumColor: Int) =
            with(myNumColor = myNumColor)

    fun withBgContent(myBgContent: Int) =
            with(myBgContent = myBgContent)

    fun withBgNum(myBgNum: Int) =
            with(myBgNum = myBgNum)

    fun withNoteColor(myNoteColor: Int) =
            with(myNoteColor = myNoteColor)
}

/**
 * Colors for highlighting code units.
 */
data class SyntaxColors(
        val type: Int = 0x859900,
        val keyword: Int = 0x268BD2,
        val literal: Int = 0x269186,
        val comment: Int = 0x93A1A1,
        val string: Int = 0x269186,
        val punctuation: Int = 0x586E75,
        val plain: Int = 0x586E75,
        val tag: Int = 0x859900,
        val declaration: Int = 0x268BD2,
        val attrName: Int = 0x268BD2,
        val attrValue: Int = 0x269186)

/**
 * Font presets.
 */
enum class Font {
    Consolas,
    CourierNew,
    DejaVuSansMono,
    DroidSansMonoSlashed,
    Inconsolata,
    Monaco;

    companion object {
        val Default = DroidSansMonoSlashed
    }
}

// - Helpers

/**
 * @return Converted hex int to color by adding alpha-channel
 */
fun Int.color(): Color = try {
    Color(0xff000000 + this)
} catch (e: IllegalArgumentException) {
    Color.White.copy(alpha = 0.0f)
}

CodeViewの実装

上記で改造したCodeHighlighterをうまく組み合わせて以下のようにしました。

@Composable
fun CodeView(
    language: String,
    source: String,
    modifier: Modifier = Modifier,
) {
    val horizontalScroll = rememberScrollState(0)
    val verticalScroll = rememberScrollState(0)
    val context = CodeHighlighter.highlight(
        language = language,
        source = source,
        theme = ColorTheme.MONOKAI.theme(),
    )
    Box(
        modifier = modifier.fillMaxWidth(),
    ) {
        Surface(
            color = Gray900,
            modifier = Modifier.fillMaxWidth()
        ) {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(
                        horizontal = 16.dp,
                        vertical = 12.dp,
                    )
                    .horizontalScroll(horizontalScroll)
                    .verticalScroll(verticalScroll)
            ) {
                Text(
                    text = context
                )
            }
        }
    }
}

クリップボードへコピーするボタンの追加

@Composable
fun CodeView(
    language: String,
    source: String,
    modifier: Modifier = Modifier,
) {
    val horizontalScroll = rememberScrollState(0)
    val verticalScroll = rememberScrollState(0)
    val context = CodeHighlighter.highlight(
        language = language,
        source = source,
        theme = ColorTheme.MONOKAI.theme(),
    )
    val clipboardManager = LocalClipboardManager.current  // この行を追加
    Box(
        modifier = modifier.fillMaxWidth(),
        contentAlignment = Alignment.TopEnd,  // この行を追加
    ) {
        Surface(
            color = Gray900,
            modifier = Modifier.fillMaxWidth()
        ) {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(
                        horizontal = 16.dp,
                        vertical = 12.dp,
                    )
                    .horizontalScroll(horizontalScroll)
                    .verticalScroll(verticalScroll)
            ) {
                Text(
                    text = context
                )
            }
        }

        // 以下のblockを追加
        CopyButton() {
            clipboardManager.setText(AnnotatedString(source))
        }
    }
}

// Copyボタンのコンポーネントを追加
@Composable
private fun CopyButton(upPress: () -> Unit) {
    IconButton(
        onClick = upPress,
        modifier = Modifier
            .padding(horizontal = 10.dp, vertical = 10.dp)
            .size(28.dp)
            .background(
                color = Gray900.copy(alpha = 0.32f),
                shape = CircleShape
            )
    ) {
        Icon(
            painterResource(id = R.drawable.ic_clipboard_copy_outline),  // heroiconのsvgをresに追加して利用
            tint = White,
            contentDescription = "copy",
            modifier = Modifier.size(20.dp)
        )
    }
}

Preview

以下のPreviewで確認しながら実装しました。

@Preview("code view default")
@Composable
fun CodeViewPreview() {
    CodeView(
        language = "java",
        source = """
            private val DotSize = 18.dp

            @Composable
            fun Loader() {
                val delayUnit = 300

                val infiniteTransition = rememberInfiniteTransition()
                @Composable
                fun animateScaleWithDelay(delay: Int) = infiniteTransition.animateFloat(
                    initialValue = 0f,
                    targetValue = 0f,
                    animationSpec = infiniteRepeatable(
                        animation = keyframes {
                            durationMillis = delayUnit * 4
                            0f at delay with LinearEasing
                            1f at delay + delayUnit with LinearEasing
                            0f at delay + delayUnit * 2
                        }
                    )
                )

                val scale1 by animateScaleWithDelay(0)
                val scale2 by animateScaleWithDelay(delayUnit)
                val scale3 by animateScaleWithDelay(delayUnit * 2)

                Row(
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.Center,
                ) {
                    val space = 2.dp
                    Dot(scale1)
                    Spacer(Modifier.width(space))
                    Dot(scale2)
                    Spacer(Modifier.width(space))
                    Dot(scale3)
                }
            }

            @Composable
            private fun Dot(scale: Float) = Spacer(
                modifier = Modifier
                    .size(DotSize)
                    .scale(scale = scale)
                    .background(color = MyTheme.colors.active, shape = CircleShape)
            )
        """.trimIndent()
    )
}

結構綺麗な見た目になって満足しました。

以上です。