Skip to content

compose代码编辑器

Published:

BasicTextField其实就可以实现,compose的基础组件可扩展性还是很强大的

定义以下类和函数:

设置类

class Settings {
    // 对于修改后编辑器要实时更新的项,使用mutableStateOf
    // 文本大小
    var fontSize by mutableStateOf(13.sp)
    // 显示行号
    var showLines by mutableStateOf(false)
    // 按tab后填入的空格数
    var tabs = 4
}

编辑器类

class Editor (
    val name: String, //名称,可以填入文件名或其他
    val content: String, //初始化的内容
    val settings: Settings = Settings() //设置
) {
    // 将内容转为TextFieldValue
    var filedValue by mutableStateOf(TextFieldValue(content))
    // 当前内容
    val text: String
    	get() = filedValue.text
    // 当前行数
    var lines: Int
    	get() = text.split('\n').size
    // 文本操作指令
    val actions = mutableMapOf<Int, ()->Unit>()
    // 快捷键,Pair<Int, Key>用来确定是否按下了ctrl/shitf/alt
    val shortKeys = mutableMapOf<Pair<Int, Key>, Int>()
    // 选中的文本范围
    val selection: TextRange
        get() = fieldValue.selection
    companion object {
        // 特殊键是否按下
        const val CTRL = 1
        const val SHIFT = 2
        const val ALT = 4
        const val CTRL_SHIFT = 3
        const val CTRL_ALT = 5
        const val SHIFT_ALT = 6
        const val CTRL_SHIFT_ALT = 7
    }
}

编辑器类要实先文本的常规操作,以及回车键、tab键按下后的操作

事件注册
	// 注册事件
    fun register(i: Int, a: ()-> Unit) {
        actions[i] = a
    }
	// 发送事件
	fun sendAction(i: Int): Boolean {
        if(actions.containsKey(i)) {
            actions[i]!!()
            return true
        }
        return false
    }
	// 注册快捷键
    fun registerShortKey(keycode: Key, action: Int, ctrl: Boolean = true, shift: Boolean = false, alt: Boolean = false) { // 000
        var tmp = 0
        if(ctrl) tmp += 1
        if(shift) tmp += 2
        if(alt) tmp += 4
        shortKeys[tmp to keycode] = action
    }
	// 同时注册事件和快捷键, keyPress可以参考companion object内常量
    fun register(i: Int, keycode: Key, keyPress: Int = 1, a: ()->Unit) {
        actions[i] = a
        shortKeys[keyPress to keycode] = i
    }
文本操作
当前行的范围
    fun currentLineRange(): TextRange {
        val st = selection.min
        val en = selection.max
        val st0 = (st - 1 downTo  0)
            .firstOrNull{ text[it] == '\n' }
            ?.let { it + 1 }
            ?: 0
        val en0 = (en until text.length)
            .firstOrNull{ text[it] == '\n' }
            ?: text.length
        return TextRange(st0, en0)
    }
当前行的文本内容
    fun currentLine(): String {
        return text.substring(currentLineRange())
    }
	// 或者
	val currentLine: String
		get() = text.substring(currentLineRange())
在指定位置前插入文本
	// str: 插入的内容, st: 位置
	fun append(str: String, st: Int = selection.min) {
        val en = selection.max
        val ns = buildString {
            append(text.substring(0, st))
            append(str)
            append(text.substring(st))
        }
        fieldValue = fieldValue.copy(text = ns, selection = TextRange(en + str.length))
    }
在行首插入文本
	// 行首直接插入
	fun appendStart(str: String) {
        val st = currentLineRange().min
        append(str, st)
    }
	// 行首插入且跳过空格
    fun appendStartTrim(str: String) {
        val st = currentLineRange().min
        val cl = currentLine()
        append(str, st + cl.length - cl.trim().length)
    }
在选择范围前后插入
    fun surround(s1: String, s2: String = s1, st: Int = selection.min, en: Int = selection.max) {
        val ns = buildString {
            append(text.substring(0, st))
            append(s1)
            append(text.substring(st,en))
            append(s2)
            append(text.substring(en))
        }
        // 插入后自动选择
        if(selection.collapsed)
            fieldValue = fieldValue.copy(text = ns, selection = TextRange(st, st+s1.length))
        else
            fieldValue = fieldValue.copy(text = ns, selection = TextRange(st, en+s1.length+s2.length))
    }
按键操作

键盘按键按下和松开都会调用一次,只要响应一次就行了,所以当松开的适合,直接返回true,告诉compose事件已经执行

这里简单的实现了

  1. 按下tab后自动填入空格
  2. 按下回车后,如果当前行前面有空格,下一行会插入相同数量的空格
  3. 删除时,若当前指针左边全是空格,删除所有的空格
    fun defaultKeyEvent(event: KeyEvent): Boolean {
        if(event.type == KeyEventType.KeyUp) return true
        if(event.type == KeyEventType.KeyDown) {
            if (!event.isCtrlPressed && !event.isShiftPressed && !event.isAltPressed) {
                val st = selection.min
                val en = selection.max
                if (event.key == Key.Tab) {
                    val ns =
                        text.substring(0, st) + " ".repeat(settings.tabs) + text.substring(en)
                    fieldValue = fieldValue.copy(text = ns, selection = TextRange(st + settings.tabs))
                    return true
                } else if (event.key == Key.Enter) {
                    val cl = currentLine()
                    val n = cl.length - cl.trimStart().length
                    val ns = text.substring(0, st) + "\n" + " ".repeat(n) + text.substring(en)
                    fieldValue = fieldValue.copy(text = ns, selection = TextRange(st + n + 1))
                    return true
                } else if(event.key == Key.Backspace) {
                    if(selection.collapsed) {
                        val cl = currentLine()
                        if (cl.trim().isEmpty()) {
                            val range = currentLineRange()
                            val ns =
                                text.substring(0, if (range.min > 0) range.min - 1 else range.min) + text.substring(
                                    range.max
                                )
                            fieldValue = fieldValue.copy(
                                text = ns,
                                selection = TextRange(if (range.min > 0) range.min - 1 else range.min)
                            )
                            return true
                        }
                    }
                }
            }
        }
        return false
    }
响应快捷键
    fun keyEvent(event: KeyEvent): Boolean {
        if(event.type == KeyEventType.KeyDown) {
            var tmp = 0
            if (event.isCtrlPressed) tmp += 1
            if (event.isShiftPressed) tmp += 2
            if (event.isAltPressed) tmp += 4
            if (shortKeys.containsKey(tmp to event.key)) {
                return sendAction(shortKeys[tmp to event.key]!!)
            }
        }
        return false
    }
注册快捷键操作示例:
editor.apply {
    register(1, Key.One) { // h1
        appendStartTrim("# ")
    }
    register(10, Key.One, Editor.CTRL_SHIFT) { // 加粗
        surround("** ", " **")
    }
}
// 按下ctrl+1 或 editor.sendAction(1) 时,行首会插入#
// 按下ctrl+shift+1 或 editor.sendAction(10) 时,当前选择的内容前后会插入**

compose组合函数

@Composable
fun EditorView(
    editor: Editor,
    modifier: Modifier = Modifier.fillMaxSize()
) {
    with(LocalDensity.current) {
        val measurer = rememberTextMeasurer()
        // 计算一个字符的高度
        val heightDp = measurer.measure("1").size.height.toDp()
        BasicTextField(
            value = editor.fieldValue,
            onValueChange = { newText ->
                editor.fieldValue = newText
            },
            modifier = modifier
                .verticalScroll(rememberScrollState())
                .onKeyEvent { event->
                    // 快捷键响应
                    editor.keyEvent(event)
                }
                .onPreviewKeyEvent { event ->
                    // tab、enter、backspace等按键响应
                    editor.defaultKeyEvent(event)
                },
            decorationBox = @Composable { innerTextField ->
                if (editor.settings.showLines) {
                    // 显示行号
                    Row {
                        val lines = editor.lines
                        Column(
                            horizontalAlignment = Alignment.End,
                            modifier = Modifier.padding(start = 4.dp, end = 4.dp)
                        ) {
                            for(i in 0 until lines) {
                                BasicText("${i+1}")
                            }
                        }
                        // 显示分割线
                        Spacer(
                            Modifier
                                .padding(start = 4.dp, end = 4.dp)
                                .width(1.dp)
                                .height(heightDp.times(lines))
                                .background(Color.Black)
                        )
                        // 文本编辑框内容
                        innerTextField()
                    }
                } else {
                    innerTextField()
                }
            }
        )
    }
}

注意事项

由于compose框架限制,TextField在失去焦点后,selection会清空,这时候需要用Box替代Button,这里是个简易实现

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UnFocusButton(
    text: String,
    modifier: Modifier = Modifier.border(1.dp, Color.Gray, RoundedCornerShape(4.dp)),
    onClick: ()->Unit
) {
    Box(modifier = modifier.onClick {
        onClick()
    }) {
        Text(text, modifier = Modifier.padding(6.dp, 3.dp))
    }
}

最后来看看效果吧

fun main() {
    application {
        Window(
            onCloseRequest = ::exitApplication,
            title = "KotlinProject",
        ) {
            App()
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun App() {
    MaterialTheme {
            Column(
        modifier = Modifier.fillMaxSize()
    ) {
        val setting by remember { mutableStateOf(Settings()) }
        val editor by remember { mutableStateOf(Editor("", Path("test.md").readText(), setting)) }
        var label1 by remember { mutableStateOf("123") }
        editor.registerMarkdownKeys()
        Row(Modifier.fillMaxWidth()) {
            UnFocusButton("显示行号") {
                setting.showLines = !setting.showLines
            }
            UnFocusButton("保存") {
                Path("test.md").writeText(editor.text)
            }
        }
        EditorView(
            editor
        )
    }
    }
}
// 注册一些markdown快捷键
fun Editor.registerMarkdownKeys() {
    register(1, Key.One) { //h1
        appendStartTrim("# ")
    }
    register(2, Key.Two) { //h2
        appendStartTrim("## ")
    }
    register(3, Key.Three) { //h3
        appendStartTrim("### ")
    }
    register(4, Key.Four) { //h4
        appendStartTrim("#### ")
    }
    register(5, Key.Five) { //h5
        appendStartTrim("##### ")
    }
    register(6, Key.Six) { //h6
        appendStartTrim("###### ")
    }
    register(10, Key.One, 3) {
        surround("* ", " *")
    }
    register(11, Key.Two, 3) {
        surround("** ", " **")
    }
    register(12, Key.Three, 3) {
        surround("*** ", " ***")
    }
    register(21, Key.L) {
        appendStartTrim("* ")
    }
}

image-20240919093923443

待实现

  1. 文本高亮,这个理论上修改TextFieldValue的annotatedString就可以实现
  2. 当某行文本长度超出自动换行时,行号显示将会出错

附件

Editor.kt
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.substring
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope

class Editor (
    val name: String,
    val content: String,
    val settings: Settings = Settings()
) {
    var fieldValue by mutableStateOf(TextFieldValue(content))
    val lines: Int
        get() = text.split('\n').size
    val lineLens: Int
        get() = lines.toString().length
    val text: String
        get() = fieldValue.text
    val actions = mutableMapOf<Int, ()->Unit>()
    fun sendAction(i: Int): Boolean {
        if(actions.containsKey(i)) {
            actions[i]!!()
            return true
        }
        return false
    }
    val shortKeys = mutableMapOf<Pair<Int, Key>, Int>()
    fun registerShortKey(keycode: Key, action: Int, ctrl: Boolean = true, shift: Boolean = false, alt: Boolean = false) { // 000
        var tmp = 0
        if(ctrl) tmp += 1
        if(shift) tmp += 2
        if(alt) tmp += 4
        shortKeys[tmp to keycode] = action
    }
    fun register(i: Int, a: ()-> Unit) {
        actions[i] = a
    }
    // 0x000 alt,shift,ctrl
    fun register(i: Int, keycode: Key, keyPress: Int = 1, a: ()->Unit) {
        actions[i] = a
        shortKeys[keyPress to keycode] = i
    }
    fun append(str: String, st: Int = selection.min) {
        val en = selection.max
        val ns = buildString {
            append(text.substring(0, st))
            append(str)
            append(text.substring(st))
        }
        fieldValue = fieldValue.copy(text = ns, selection = TextRange(en + str.length))
    }
    fun appendStart(str: String) {
        val st = currentLineRange().min
        append(str, st)
    }
    fun appendStartTrim(str: String) {
        val st = currentLineRange().min
        val cl = currentLine()
        append(str, st + cl.length - cl.trim().length)
    }
    fun surround(s1: String, s2: String = s1, st: Int = selection.min, en: Int = selection.max) {
        val ns = buildString {
            append(text.substring(0, st))
            append(s1)
            append(text.substring(st,en))
            append(s2)
            append(text.substring(en))
        }
        if(selection.collapsed)
            fieldValue = fieldValue.copy(text = ns, selection = TextRange(st, st+s1.length))
        else
            fieldValue = fieldValue.copy(text = ns, selection = TextRange(st, en+s1.length+s2.length))
    }
    val selection: TextRange
        get() = fieldValue.selection
    fun currentLineRange(): TextRange {
        val st = selection.min
        val en = selection.max
        val st0 = (st - 1 downTo  0)
            .firstOrNull{ text[it] == '\n' }
            ?.let { it + 1 }
            ?: 0
        val en0 = (en until text.length)
            .firstOrNull{ text[it] == '\n' }
            ?: text.length
        return TextRange(st0, en0)
    }
    fun currentLine(): String {
        return text.substring(currentLineRange())
    }
    fun defaultKeyEvent(event: KeyEvent): Boolean {
        if(event.type == KeyEventType.KeyUp) return true
        if(event.type == KeyEventType.KeyDown) {
            if (!event.isCtrlPressed && !event.isShiftPressed && !event.isAltPressed) {
                val st = selection.min
                val en = selection.max
                if (event.key == Key.Tab) {
                    val ns =
                        text.substring(0, st) + " ".repeat(settings.tabs) + text.substring(en)
                    fieldValue = fieldValue.copy(text = ns, selection = TextRange(st + settings.tabs))
                    return true
                } else if (event.key == Key.Enter) {
                    val cl = currentLine()
                    val n = cl.length - cl.trimStart().length
                    val ns = text.substring(0, st) + "\n" + " ".repeat(n) + text.substring(en)
                    fieldValue = fieldValue.copy(text = ns, selection = TextRange(st + n + 1))
                    return true
                } else if(event.key == Key.Backspace) {
                    if(selection.collapsed) {
                        val cl = currentLine()
                        if (cl.trim().isEmpty()) {
                            val range = currentLineRange()
                            val ns =
                                text.substring(0, if (range.min > 0) range.min - 1 else range.min) + text.substring(
                                    range.max
                                )
                            fieldValue = fieldValue.copy(
                                text = ns,
                                selection = TextRange(if (range.min > 0) range.min - 1 else range.min)
                            )
                            return true
                        }
                    }
                }
            }
        }
        return false
    }
    fun keyEvent(event: KeyEvent): Boolean {
        if(event.type == KeyEventType.KeyDown) {
            var tmp = 0
            if (event.isCtrlPressed) tmp += 1
            if (event.isShiftPressed) tmp += 2
            if (event.isAltPressed) tmp += 4
            if (shortKeys.containsKey(tmp to event.key)) {
                return sendAction(shortKeys[tmp to event.key]!!)
            }
        }
        return false
    }
    companion object {
        const val CTRL = 1
        const val SHIFT = 2
        const val ALT = 4
        const val CTRL_SHIFT = 3
        const val CTRL_ALT = 5
        const val SHIFT_ALT = 6
        const val CTRL_SHIFT_ALT = 7
    }
}
class Settings {
    var fontSize by mutableStateOf(13.sp)
    var showLines by mutableStateOf(false)
    var tabs = 4
//    var autoTab = true
}
@Composable
fun EditorView(
    editor: Editor,
//    settings: Settings = Settings(),
    modifier: Modifier = Modifier.fillMaxSize()
) {
    with(LocalDensity.current) {
        val measurer = rememberTextMeasurer()
        val heightDp = measurer.measure("1").size.height.toDp()
        BasicTextField(
            value = editor.fieldValue,
            onValueChange = { newText ->
                editor.fieldValue = newText
            },
            modifier = modifier
                .verticalScroll(rememberScrollState())
                .onKeyEvent { event->
                    editor.keyEvent(event)
                }
                .onPreviewKeyEvent { event ->
                    editor.defaultKeyEvent(event)
                },
            decorationBox = @Composable { innerTextField ->
                if (editor.settings.showLines) {
                    Row {
                        val lines = editor.lines
//                        val len = editor.lineLens
                        Column(
                            horizontalAlignment = Alignment.End,
                            modifier = Modifier.padding(start = 4.dp, end = 4.dp)
                        ) {
                            for(i in 0 until lines) {
                                BasicText("${i+1}")
                            }
                        }
                        Spacer(
                            Modifier
                                .padding(start = 4.dp, end = 4.dp)
                                .width(1.dp)
                                .height(heightDp.times(lines))
                                .background(Color.Black)
                        )
                        innerTextField()
                    }
                } else {
                    innerTextField()
                }
            }
        )
    }
}

Previous Post
kotlin voxel解析 一
Next Post
windows命令行设置网络类型