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() 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, ) } } } } } }