Files
Githug-Android/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt
Joe Tretter 4fab442939 Require native Git runtime and bundle full manpages
- Remove the app fallback path when native Git is unavailable and show a startup blocker instead
- Fix native level setup for staged file counting and cherry-pick parity
- Improve manpage loading/search and bundle full Git documentation assets
- Integrate Git cross-compilation into AndroidProjectTooling.sh and remove debug AAB support
2026-05-08 17:43:46 -05:00

227 lines
8.9 KiB
Kotlin

package solutions.tretter.githugandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
data class ManPageState(
val topic: String,
val content: String,
)
private data class ManPageMatch(
val start: Int,
val end: Int,
val lineIndex: Int,
val columnIndex: Int,
)
@Composable
fun ManPageDialog(
state: ManPageState,
onClose: () -> Unit,
) {
val horizontalScrollState = rememberScrollState()
val verticalScrollState = rememberScrollState()
var searchQuery by remember(state.content) { mutableStateOf("") }
var selectedMatch by remember(state.content) { mutableStateOf(0) }
val contentLines = remember(state.content) { state.content.lines() }
val matches = remember(searchQuery, contentLines) {
if (searchQuery.isBlank()) {
emptyList()
} else {
val result = mutableListOf<ManPageMatch>()
var absoluteLineStart = 0
contentLines.forEachIndexed { lineIndex, line ->
var searchFrom = 0
while (searchFrom <= line.length) {
val columnIndex = line.indexOf(searchQuery, searchFrom, ignoreCase = true)
if (columnIndex == -1) break
val start = absoluteLineStart + columnIndex
result += ManPageMatch(
start = start,
end = start + searchQuery.length,
lineIndex = lineIndex,
columnIndex = columnIndex,
)
searchFrom = columnIndex + searchQuery.length.coerceAtLeast(1)
}
absoluteLineStart += line.length + 1
}
result
}
}
val matchCount = matches.size
val currentMatchNumber = if (matchCount == 0) 0 else selectedMatch + 1
val highlightedContent = remember(state.content, matches, selectedMatch) {
AnnotatedString.Builder(state.content).apply {
matches.forEachIndexed { index, match ->
addStyle(
SpanStyle(
color = if (index == selectedMatch) Color.White else Color.Black,
background = if (index == selectedMatch) Color(0xFFD32F2F) else Color(0xFFFFEB3B),
),
match.start,
match.end,
)
}
}.toAnnotatedString()
}
LaunchedEffect(searchQuery, matchCount) {
selectedMatch = selectedMatch.coerceIn(0, (matchCount - 1).coerceAtLeast(0))
}
LaunchedEffect(selectedMatch, matches) {
val match = matches.getOrNull(selectedMatch) ?: return@LaunchedEffect
verticalScrollState.animateScrollTo((match.lineIndex * 18).coerceAtMost(verticalScrollState.maxValue))
horizontalScrollState.animateScrollTo((match.columnIndex * 8).coerceAtMost(horizontalScrollState.maxValue))
}
Dialog(onDismissRequest = onClose) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.86f),
color = PanelPrimary,
shape = RoundedCornerShape(8.dp),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Git Manpage",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onClose,
colors = ButtonDefaults.buttonColors(
containerColor = Accent,
contentColor = AppBackground,
),
) {
Text("Close")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextField(
value = searchQuery,
onValueChange = {
searchQuery = it
selectedMatch = 0
},
modifier = Modifier.weight(1f),
singleLine = true,
placeholder = { Text("Search") },
colors = TextFieldDefaults.colors(
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
focusedContainerColor = PanelSecondary,
unfocusedContainerColor = PanelSecondary,
focusedIndicatorColor = Accent,
unfocusedIndicatorColor = TextMuted,
cursorColor = Accent,
),
)
Button(
onClick = {
if (matchCount > 0) selectedMatch = (selectedMatch - 1 + matchCount) % matchCount
},
enabled = matchCount > 0,
colors = ButtonDefaults.buttonColors(
containerColor = PanelSecondary,
contentColor = TextPrimary,
),
) {
Text("Prev")
}
Button(
onClick = {
if (matchCount > 0) selectedMatch = (selectedMatch + 1) % matchCount
},
enabled = matchCount > 0,
colors = ButtonDefaults.buttonColors(
containerColor = PanelSecondary,
contentColor = TextPrimary,
),
) {
Text("Next")
}
}
Text(
text = "$currentMatchNumber/$matchCount",
color = TextSecondary,
fontSize = 12.sp,
)
Text(
text = "git ${state.topic}",
color = TextPrimary,
fontWeight = FontWeight.Bold,
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(TerminalBackground, RoundedCornerShape(6.dp))
.padding(10.dp)
.horizontalScroll(horizontalScrollState)
.verticalScroll(verticalScrollState),
) {
SelectionContainer {
Text(
text = highlightedContent,
modifier = Modifier.widthIn(min = 1200.dp),
color = TextPrimary,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
softWrap = false,
)
}
}
}
}
}
}