From d9536289e94f735e0b698402e5a9a42c209e4e8a Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 28 Sep 2026 13:17:33 +0900 Subject: [PATCH] TASK-048: source viewer line numbers, syntax highlighting, word wrap Includes the two same-day follow-ups that fixed horizontal scrolling. See tasks/048-code-viewer-readability.md. Co-Authored-By: Claude Fable 5.1 --- .../feature/codebrowser/CodeBrowserScreen.kt | 126 +++++++++- .../codebrowser/content/SyntaxHighlighter.kt | 237 ++++++++++++++++++ .../main/res/values/strings_codebrowser.xml | 2 + docs/development-log.md | 113 +++++++++ tasks/048-code-viewer-readability.md | 137 ++++++++++ 5 files changed, 607 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt create mode 100644 tasks/048-code-viewer-readability.md diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt index 7a78e81..6c63b4a 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt @@ -75,13 +75,21 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.ubisam.ubgitea.R import com.ubisam.ubgitea.domain.model.RepositoryPath import com.ubisam.ubgitea.domain.model.SourceContent import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntryType +import com.ubisam.ubgitea.feature.codebrowser.content.CodeSyntaxColors import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification +import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage +import com.ubisam.ubgitea.feature.codebrowser.content.highlightLine +import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors +import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath import java.io.File import java.io.IOException import kotlin.math.roundToInt @@ -217,21 +225,27 @@ private fun FileContent( when (content) { is ContentClassification.Text -> { val clipboard = LocalClipboardManager.current + var wordWrap by remember(path) { mutableStateOf(false) } Column(Modifier.fillMaxSize().padding(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { OutlinedButton(onClick = { clipboard.setText(AnnotatedString(content.renderedText)) }) { Text(stringResource(R.string.codebrowser_copy)) } + OutlinedButton(onClick = { wordWrap = !wordWrap }) { + Text( + stringResource( + if (wordWrap) R.string.codebrowser_word_wrap_off else R.string.codebrowser_word_wrap_on, + ), + ) + } if (content.isTruncated) Text(stringResource(R.string.codebrowser_text_truncated)) } - SelectionContainer { - Text( - content.renderedText, - fontFamily = FontFamily.Monospace, - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()).padding(top = 12.dp), - ) - } + CodeBlock( + text = content.renderedText, + language = syntaxLanguageForPath(path), + wordWrap = wordWrap, + modifier = Modifier.fillMaxSize().padding(top = 12.dp), + ) } } is ContentClassification.Image -> { @@ -250,6 +264,102 @@ private fun FileContent( } } +// Line-numbered, syntax-highlighted source view (see SyntaxHighlighter.kt for what the highlighting +// actually is/isn't). +// +// Four things this shape specifically works around, all found live on-device -- none of them +// visible from build/lint/unit tests, which can't see a real touch gesture or a real wrapped-text +// layout: +// 1. An earlier version paired gutter+code in a LazyColumn with one horizontalScroll per row. +// Horizontal drags simply did nothing -- most likely BranchDetailScreen's HorizontalPager +// (userScrollEnabled is turned off for scrollable code, but apparently still not fully out of +// the nested-scroll picture for a LazyColumn specifically, the way it is for a plain +// Modifier.horizontalScroll chain -- see that file's own comment on this exact class of gesture +// conflict) intercepting the same-orientation drag first. Switched the outer container from +// LazyColumn to a plain Column + Modifier.verticalScroll, matching the shape of the file +// viewer's original, already-known-working implementation -- confirmed live afterward that +// horizontal drag actually moves the code again. +// 2. A version in between paired every row's gutter+code into a single Row (fix for #3 below) but +// gave each row's code Text its own independent Modifier.horizontalScroll -- reasoned at the +// time to be visually invisible since every row's text starts at column 0. Wrong in practice: +// dragging one line only scrolled that one line, leaving the rest of the file visually +// unscrolled and out of step, which reads as broken (a real product report: "선택한 곳만 좌우 +// 스크롤 되는건 아닌 것 같다"). +// 3. Tried making every row share one hoisted horizontalScrollState instead. That made horizontal +// drag do *nothing at all*, on any row, including the one being dragged -- multiple separate +// Modifier.horizontalScroll gesture-detector regions apparently don't cleanly share one +// ScrollableState the way a single shared scroll *region* does; each row still had its own +// pointer-input node competing over the same state instead of there being one region to drag. +// 4. **Final:** don't try to synchronize N separate horizontalScroll regions at all -- give the +// *whole* code column exactly one horizontalScroll region, wrapping every line's Text stacked in +// a single Column, with the gutter as a separate sibling Column that never scrolls horizontally. +// This only works correctly (gutter height staying matched to code height) as long as no line +// ever wraps to multiple visual rows, which is exactly true whenever this two-Column layout is +// even in play: it's used only when word wrap is off (softWrap = false, so every line is exactly +// one visual row no matter how long). When word wrap is on, wrapping can make a line take +// multiple visual rows, so gutter+code go back to the Row-per-line pairing from fix 2 instead +// (needed for correct number alignment, and horizontalScroll isn't used in this branch anyway -- +// wrapped lines have nothing to scroll to). +// +// Not lazy (every line is a real composable, not virtualized) -- the original single-Text version +// wasn't virtualized either, and SourceContentClassifier already bounds how many lines a file view +// ever receives, so this isn't a new performance ceiling. +@Composable +private fun CodeBlock(text: String, language: SyntaxLanguage, wordWrap: Boolean, modifier: Modifier = Modifier) { + val colors = rememberCodeSyntaxColors() + val lines = remember(text) { text.split("\n") } + val lineNumberWidth = remember(lines.size) { lines.size.toString().length } + val gutterWidth = (lineNumberWidth * 8 + 12).dp + SelectionContainer(modifier = modifier) { + if (wordWrap) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + lines.forEachIndexed { index, line -> + Row(Modifier.fillMaxWidth()) { + LineNumber(index, gutterWidth, colors) + Text( + text = highlightLine(line, language, colors), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = colors.text, + softWrap = true, + modifier = Modifier.weight(1f), + ) + } + } + } + } else { + Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + Column { + lines.indices.forEach { index -> LineNumber(index, gutterWidth, colors) } + } + Column(Modifier.horizontalScroll(rememberScrollState())) { + lines.forEach { line -> + Text( + text = highlightLine(line, language, colors), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = colors.text, + softWrap = false, + ) + } + } + } + } + } +} + +@Composable +private fun LineNumber(index: Int, width: Dp, colors: CodeSyntaxColors) { + Text( + text = (index + 1).toString(), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = colors.comment, + textAlign = TextAlign.End, + modifier = Modifier.width(width).padding(end = 8.dp), + ) +} + // currentIndex is -1 when this file isn't part of a sibling image gallery (e.g. opened directly // via a retry rather than from a directory listing) — swipe navigation is simply unavailable then, // same as any single-image folder (totalCount <= 1). diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt new file mode 100644 index 0000000..cb5a78f --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt @@ -0,0 +1,237 @@ +package com.ubisam.ubgitea.feature.codebrowser.content + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import com.ubisam.ubgitea.domain.model.RepositoryPath + +/** + * A deliberately lightweight, regex-based highlighter -- not a real per-language parser. Each line + * is tokenized independently with no cross-line state, so a `/* ... */` block comment or a + * triple-quoted string that spans multiple lines is only colored correctly on the line(s) where it + * actually starts and ends, not on every line in between. A real tokenizer for every language this + * app might show source from would be a much larger undertaking than this file viewer needs; this + * covers the common, single-line case (which is the vast majority of real code) at a fraction of the + * cost, matching this codebase's established preference for hand-rolled solutions over new + * dependencies (see the launcher icon's and the favorite-push logo's own comments on the same + * point). + */ +enum class SyntaxLanguage { + KOTLIN, JAVA, CSHARP, JAVASCRIPT, PYTHON, SHELL, YAML, XML, JSON, CSS, SQL, PLAIN +} + +fun syntaxLanguageForPath(path: RepositoryPath): SyntaxLanguage { + val extension = path.value.substringAfterLast('.', missingDelimiterValue = "").lowercase() + return when (extension) { + "kt", "kts" -> SyntaxLanguage.KOTLIN + "java" -> SyntaxLanguage.JAVA + "cs" -> SyntaxLanguage.CSHARP + "js", "jsx", "mjs", "ts", "tsx" -> SyntaxLanguage.JAVASCRIPT + "py" -> SyntaxLanguage.PYTHON + "sh", "bash", "zsh" -> SyntaxLanguage.SHELL + "yml", "yaml" -> SyntaxLanguage.YAML + "xml", "xaml", "csproj", "resx", "html", "htm", "axml" -> SyntaxLanguage.XML + "json" -> SyntaxLanguage.JSON + "css", "scss" -> SyntaxLanguage.CSS + "sql" -> SyntaxLanguage.SQL + else -> SyntaxLanguage.PLAIN + } +} + +data class CodeSyntaxColors( + val text: Color, + val keyword: Color, + val string: Color, + val comment: Color, + val number: Color, +) + +// Colors are fixed (not derived from the app's own sky-blue Material theme) and only switch +// between two hand-picked sets for light/dark -- source code highlighting has its own well-known +// conventions (keyword blue, string orange/red, comment green) independent of a given app's brand +// color, and reusing brand colors here would read as less familiar, not more polished. +@Composable +fun rememberCodeSyntaxColors(): CodeSyntaxColors { + val dark = isSystemInDarkTheme() + return remember(dark) { + if (dark) { + CodeSyntaxColors( + text = Color(0xFFD4D4D4), + keyword = Color(0xFF569CD6), + string = Color(0xFFCE9178), + comment = Color(0xFF6A9955), + number = Color(0xFFB5CEA8), + ) + } else { + CodeSyntaxColors( + text = Color(0xFF1F1F1F), + keyword = Color(0xFF0000FF), + string = Color(0xFFA31515), + comment = Color(0xFF008000), + number = Color(0xFF098658), + ) + } + } +} + +private data class LangSpec( + val keywords: Set = emptySet(), + val caseInsensitiveKeywords: Boolean = false, + val lineComment: String? = null, + val blockComment: Boolean = false, + val xmlComment: Boolean = false, +) + +private val SPECS: Map = mapOf( + SyntaxLanguage.KOTLIN to LangSpec( + keywords = setOf( + "package", "import", "class", "interface", "object", "fun", "val", "var", "if", "else", + "when", "for", "while", "do", "return", "is", "as", "in", "out", "null", "true", "false", + "this", "super", "try", "catch", "finally", "throw", "private", "public", "internal", + "protected", "companion", "data", "sealed", "enum", "open", "override", "abstract", + "final", "suspend", "inline", "reified", "vararg", "lateinit", "const", "typealias", + "by", "init", "constructor", "get", "set", "annotation", "inner", "operator", "infix", + "tailrec", "crossinline", "noinline", "expect", "actual", + ), + lineComment = "//", + blockComment = true, + ), + SyntaxLanguage.JAVA to LangSpec( + keywords = setOf( + "package", "import", "class", "interface", "extends", "implements", "public", "private", + "protected", "static", "final", "abstract", "void", "int", "long", "short", "byte", + "char", "boolean", "float", "double", "new", "return", "if", "else", "switch", "case", + "default", "for", "while", "do", "break", "continue", "try", "catch", "finally", "throw", + "throws", "this", "super", "null", "true", "false", "instanceof", "synchronized", + "volatile", "transient", "enum", "assert", + ), + lineComment = "//", + blockComment = true, + ), + SyntaxLanguage.CSHARP to LangSpec( + keywords = setOf( + "using", "namespace", "class", "interface", "struct", "enum", "public", "private", + "protected", "internal", "static", "readonly", "const", "void", "int", "long", "short", + "byte", "char", "bool", "float", "double", "decimal", "string", "object", "var", "new", + "return", "if", "else", "switch", "case", "default", "for", "foreach", "while", "do", + "break", "continue", "try", "catch", "finally", "throw", "this", "base", "null", "true", + "false", "override", "virtual", "abstract", "sealed", "partial", "async", "await", "get", + "set", "out", "ref", "in", "is", "as", "typeof", "nameof", "delegate", "event", "yield", + "lock", "unsafe", "fixed", "params", + ), + lineComment = "//", + blockComment = true, + ), + SyntaxLanguage.JAVASCRIPT to LangSpec( + keywords = setOf( + "function", "const", "let", "var", "if", "else", "for", "while", "do", "switch", "case", + "default", "break", "continue", "return", "class", "extends", "super", "this", "new", + "try", "catch", "finally", "throw", "import", "export", "from", "async", "await", + "yield", "typeof", "instanceof", "in", "of", "null", "undefined", "true", "false", + "interface", "type", "implements", "public", "private", "protected", "static", + "readonly", "enum", "namespace", "declare", + ), + lineComment = "//", + blockComment = true, + ), + SyntaxLanguage.PYTHON to LangSpec( + keywords = setOf( + "def", "class", "import", "from", "as", "if", "elif", "else", "for", "while", "break", + "continue", "return", "yield", "try", "except", "finally", "raise", "with", "lambda", + "pass", "del", "global", "nonlocal", "and", "or", "not", "in", "is", "None", "True", + "False", "self", "async", "await", "assert", + ), + lineComment = "#", + ), + SyntaxLanguage.SHELL to LangSpec( + keywords = setOf( + "if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", + "esac", "function", "return", "exit", "break", "continue", "local", "export", "readonly", + "in", "select", "time", + ), + lineComment = "#", + ), + SyntaxLanguage.YAML to LangSpec( + keywords = setOf("true", "false", "null", "yes", "no"), + lineComment = "#", + ), + SyntaxLanguage.XML to LangSpec(xmlComment = true), + SyntaxLanguage.JSON to LangSpec(keywords = setOf("true", "false", "null")), + SyntaxLanguage.CSS to LangSpec(blockComment = true), + SyntaxLanguage.SQL to LangSpec( + keywords = setOf( + "select", "from", "where", "insert", "into", "values", "update", "set", "delete", + "join", "inner", "left", "right", "outer", "on", "group", "by", "order", "having", + "limit", "as", "and", "or", "not", "null", "is", "in", "like", "between", "create", + "table", "alter", "drop", "index", "primary", "key", "foreign", "references", "default", + "distinct", "union", "all", "exists", "case", "when", "then", "else", "end", + ), + caseInsensitiveKeywords = true, + lineComment = "--", + ), + SyntaxLanguage.PLAIN to LangSpec(), +) + +private val STRING_OR_NUMBER_OR_WORD = + "\"(?:[^\"\\\\]|\\\\.)*\"" + "|" + + "'(?:[^'\\\\]|\\\\.)*'" + "|" + + "\\b\\d+(?:\\.\\d+)?[a-zA-Z]?\\b" + "|" + + "\\b[A-Za-z_][A-Za-z0-9_]*\\b" + +private val patternCache: Map = SPECS.mapValues { (_, spec) -> + val parts = mutableListOf() + if (spec.blockComment) parts += "/\\*.*?\\*/|/\\*.*$" + if (spec.xmlComment) parts += "|