diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt index 7fa6add..fa15da7 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,6 +37,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString @@ -52,6 +56,8 @@ import com.ubisam.ubgitea.feature.codebrowser.content.SearchMatch import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage import com.ubisam.ubgitea.feature.codebrowser.content.extractSymbols import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines +import com.ubisam.ubgitea.feature.codebrowser.content.SymbolKind +import com.ubisam.ubgitea.feature.codebrowser.content.nameRangeOf import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath import com.ubisam.ubgitea.ui.theme.UBGiteaTheme @@ -131,8 +137,15 @@ fun CodePane( search: SearchHighlight = SearchHighlight.None, focusedLine: Int? = null, contentPadding: PaddingValues = PaddingValues(vertical = 8.dp), + // When given, the name of a function on the line that declares it is a link (TASK-060). + onFunctionTapped: ((CodeSymbol) -> Unit)? = null, ) { val colors = rememberCodeSyntaxColors() + val tapped by rememberUpdatedState(onFunctionTapped) + val linked = onFunctionTapped != null + val functionsByLine = remember(document.symbols, linked) { + if (linked) document.symbols.filter { it.kind == SymbolKind.METHOD }.associateBy(CodeSymbol::line) else emptyMap() + } val style = remember(fontSizeSp, colors.text) { TextStyle( fontFamily = FontFamily.Monospace, @@ -165,8 +178,26 @@ fun CodePane( LazyColumn(state = listState, contentPadding = contentPadding, modifier = Modifier.fillMaxSize()) { items(document.lines.size, key = { it }) { index -> val line = document.highlighted.getOrElse(index) { AnnotatedString(document.lines[index]) } - val shown = remember(line, search.byLine[index], search.current) { - withSearchHits(line, search.byLine[index], search.current, hit) + val function = functionsByLine[index] + val shown = remember(line, search.byLine[index], search.current, function) { + val marked = withSearchHits(line, search.byLine[index], search.current, hit) + val name = function?.let { nameRangeOf(it, document.lines[index]) } + if (function == null || name == null || name.last >= marked.length) { + marked + } else { + buildAnnotatedString { + append(marked) + // Underlined and otherwise left in its own color: it is still code. + addLink( + LinkAnnotation.Clickable( + tag = "function", + styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline)), + ) { tapped?.invoke(function) }, + name.first, + name.last + 1, + ) + } + } } Row( Modifier.fillMaxWidth().background(if (index == focusedLine) focusColor else Color.Transparent), diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt index f27f54a..650ac7d 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt @@ -395,6 +395,15 @@ fun CodeViewerScreen( counterpartPath = counterpartPath!!, splitFraction = splitFraction, onSplitChange = { splitFraction = it }, + // Beside each other, a function tapped in one pane is shown in the other. + onFunctionTapped = { symbol -> + otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) }?.let { match -> + scope.launch { otherState.scrollToItem(match.line) } + } + }, + onOtherFunctionTapped = { symbol -> + findSymbolNamed(document.symbols, symbol.name, symbol.kind)?.let { goToLine(it.line) } + }, ) else -> { if (layout == ViewerLayout.WIDE) { @@ -424,6 +433,7 @@ fun CodeViewerScreen( search = highlight, focusedLine = focusedLine, contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp), + onFunctionTapped = jump?.onJump, ) if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching) { val method = enclosingSymbols(document.symbols, firstVisibleLine).lastOrNull { it.kind == SymbolKind.METHOD } @@ -843,6 +853,8 @@ private fun PairPanes( counterpartPath: RepositoryPath, splitFraction: Float, onSplitChange: (Float) -> Unit, + onFunctionTapped: (CodeSymbol) -> Unit, + onOtherFunctionTapped: (CodeSymbol) -> Unit, ) { if (syncPanes && otherDocument != null) { FollowSymbols(listState, document, otherState, otherDocument) @@ -852,7 +864,10 @@ private fun PairPanes( val current: @Composable (Modifier) -> Unit = { modifier -> Column(modifier) { PaneHeader(document.name, state.path, document.lines.size, enclosingSymbols(document.symbols, listState.firstVisibleItemIndex)) - CodePane(document, listState, preferences.wordWrap, preferences.fontSizeSp, search = highlight, focusedLine = focusedLine) + CodePane( + document, listState, preferences.wordWrap, preferences.fontSizeSp, + search = highlight, focusedLine = focusedLine, onFunctionTapped = onFunctionTapped, + ) } } val other: @Composable (Modifier) -> Unit = { modifier -> @@ -861,7 +876,7 @@ private fun PairPanes( when { otherDocument != null -> { PaneHeader(name, counterpartPath, otherDocument.lines.size, enclosingSymbols(otherDocument.symbols, otherState.firstVisibleItemIndex)) - CodePane(otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp) + CodePane(otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp, onFunctionTapped = onOtherFunctionTapped) } else -> { PaneHeader(name, counterpartPath, null, emptyList()) diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt index ae7b7b7..49763d8 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt @@ -126,6 +126,21 @@ fun outlineTree(symbols: List, collapsed: Set = emptySet()): Li fun methodCountOf(symbols: List, container: CodeSymbol): Int = symbols.count { it.kind == SymbolKind.METHOD && it.line > container.line && it.line <= container.endLine } +/** + * Where the name of [symbol] is written on [line], the line it is declared on, as `[start, end)`; + * null when it cannot be found there. The name is the last occurrence before the parameter list, + * so in `Retry Retry::Make(int n)` it is `Make`, and in `A::A()` the second `A`. + */ +fun nameRangeOf(symbol: CodeSymbol, line: String): IntRange? { + val parenthesis = line.indexOf('(') + if (parenthesis <= 0) return null + val start = line.lastIndexOf(symbol.name, parenthesis - 1) + if (start < 0 || start + symbol.name.length > parenthesis) return null + // Part of a longer word (`GetValue` inside `TryGetValue`) is not the name. + if (start > 0 && (line[start - 1].isLetterOrDigit() || line[start - 1] == '_')) return null + return start until start + symbol.name.length +} + /** The symbols enclosing [line], outermost first: what a "you are here" strip shows. */ fun enclosingSymbols(symbols: List, line: Int): List = symbols.filter { it.kind != SymbolKind.FIELD && line in it.line..it.endLine }.sortedBy(CodeSymbol::depth) diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt index 02591e5..9605ed7 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt @@ -157,6 +157,28 @@ class SymbolOutlineTest { assertEquals(9, symbols.first().endLine) } + @Test + fun `the name of a function is found where it is written on its line`() { + fun named(line: String): String? { + val symbol = extractSymbols(listOf("class A", "{", line, "};"), SyntaxLanguage.CPP).firstOrNull { it.kind == SymbolKind.METHOD } + ?: extractSymbols(listOf(line, "{", "}"), SyntaxLanguage.CPP).first { it.kind == SymbolKind.METHOD } + return nameRangeOf(symbol, line)?.let { line.substring(it.first, it.last + 1) + "@" + it.first } + } + + assertEquals("OnBnClickedOk@17", named(" afx_msg void OnBnClickedOk();")) + assertEquals("Make@13", named("Retry Retry::Make(int n)")) + assertEquals("A@3", named("A::A(int a)")) + assertEquals("~A@3", named("A::~A()")) + } + + @Test + fun `a name that is only part of a longer word on the line is not the name`() { + val symbol = CodeSymbol("GetValue", "GetValue()", SymbolKind.METHOD, 0, 0, 0) + + assertEquals(null, nameRangeOf(symbol, "bool TryGetValue();")) + assertEquals(null, nameRangeOf(symbol, "no parenthesis here")) + } + @Test fun `a forward declaration is not listed`() { val symbols = extractSymbols(listOf("class Foo;"), SyntaxLanguage.CPP) diff --git a/docs/UBGitea_기술소개.pptx b/docs/UBGitea_기술소개.pptx index 005fa20..c68267b 100644 Binary files a/docs/UBGitea_기술소개.pptx and b/docs/UBGitea_기술소개.pptx differ diff --git a/docs/development-log.md b/docs/development-log.md index f9c35f6..ba39339 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -1401,3 +1401,12 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Checks and results: the required checks pass; 435 unit tests, 0 failures; lint 0 errors. - Live verification: installed on the SM-F966N; not seen on a screen by the implementer. - Next owner: the product owner, to confirm on the device. + +## 2026-09-28 (continued) — TASK-060: tapping a function in the code + +- Request: the human product owner asked for the jump of TASK-059 from the code itself. +- What changed: see `tasks/060-tap-a-function-in-the-code.md`. Names are links where a function + is declared or defined, not where it is called. +- Checks and results: the required checks pass; 437 unit tests, 0 failures; lint 0 errors. +- Live verification: installed on the SM-F966N; not seen on a screen by the implementer. +- Next owner: the product owner, to confirm on the device. diff --git a/tasks/060-tap-a-function-in-the-code.md b/tasks/060-tap-a-function-in-the-code.md new file mode 100644 index 0000000..ec679f4 --- /dev/null +++ b/tasks/060-tap-a-function-in-the-code.md @@ -0,0 +1,57 @@ +# TASK-060: Tapping a function's name in the code + +Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation +Owner: Claude +Reviewer: human product owner + +## Outcome + +TASK-059 put the jump from a header to its source on a button in the outline. The human product +owner asked for it in the code itself. + +## Scope + +- Included: + - In a file that has a counterpart, the name of a function is underlined on the line that + declares or defines it. Tapping it opens the counterpart at the function of the same name: + `.h` to `.cpp`, and `.cpp` to `.h`. + - Side by side, tapping the name in one pane brings the same function into view in the other. +- Excluded: + - A function's name where it is *called*. Going from a call to a definition needs to know which + file defines it, which means reading the whole repository; this app reads one file at a time. + - Files with no counterpart (C#, a lone `.cpp`): no names are links, as there is nowhere to go. + - Telling overloads apart: the first function of that name is the one shown. + +## Writable paths + +- `app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/` +- `app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/` +- `tasks/`, `docs/` + +## Acceptance criteria + +- [x] Only the name is the link, not the whole line; the rest of the line can still be selected. +- [x] The name is found after a qualifier (`A::F`), for a constructor (`A::A`) and a destructor. +- [x] A name that is only part of a longer word on the line is not linked. +- [x] Unit tests cover where the name is found. + +## Security and privacy + +No additional concerns. + +## Required checks + +```text +./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug +``` + +## Evidence + +- Required checks pass: 437 unit tests, 0 failures; lint 0 errors. +- Installed on the SM-F966N over wireless `adb`. +- Not seen on a screen by the implementer. In particular, how a tap on a link and a long press to + select text get along inside the selectable code has not been tried on a device. + +## Review findings + +None yet.