- 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
227 lines
8.9 KiB
Kotlin
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,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|