package solutions.tretter.githugandroid import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.TextRange import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @Composable fun GitHugApp() { MaterialTheme(colorScheme = GitHugColorScheme) { val context = LocalContext.current val configuration = LocalConfiguration.current val levels = remember { sampleLevels() } val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) } val paneLayoutStore = remember(context) { PaneLayoutStore(context.applicationContext) } val gameProgressStore = remember(context) { GameProgressStore(context.applicationContext) } val persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout()) val persistedCompletedLevels by gameProgressStore.completedLevelsFlow.collectAsState(initial = emptySet()) val scope = rememberCoroutineScope() val screenScrollState = rememberScrollState() val screenHeightDp = configuration.screenHeightDp var paneLayout by remember { mutableStateOf(defaultPaneLayout()) } var currentLevelIndex by remember { mutableStateOf(0) } var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) } var commandInput by remember { mutableStateOf(TextFieldValue("")) } var inputFieldVersion by remember { mutableStateOf(0) } var suppressedImeEcho by remember { mutableStateOf(null) } var output by remember { mutableStateOf(listOf(runtime.startupBanner())) } var hintIndex by remember { mutableStateOf(0) } var activeExerciseDetail by remember { mutableStateOf(null) } var visibleHint by remember { mutableStateOf(null) } var completedLevels by remember { mutableStateOf(setOf()) } var commandHistory by remember { mutableStateOf(listOf()) } var historyIndex by remember { mutableStateOf(-1) } var historyDraft by remember { mutableStateOf("") } var hasRestoredProgress by remember { mutableStateOf(false) } val currentLevel = levels[currentLevelIndex] LaunchedEffect(persistedPaneLayout) { paneLayout = persistedPaneLayout } fun updatePaneLayout(transform: (PaneLayout) -> PaneLayout, persist: Boolean = true) { val updated = transform(paneLayout) paneLayout = updated if (persist) { scope.launch { paneLayoutStore.save(updated) } } } fun clearCommandInput(recreateField: Boolean = false) { commandInput = TextFieldValue(text = "", selection = TextRange.Zero) if (recreateField) { inputFieldVersion += 1 } } LaunchedEffect(persistedCompletedLevels) { completedLevels = persistedCompletedLevels if (!hasRestoredProgress) { hasRestoredProgress = true val firstIncompleteIndex = levels.indexOfFirst { it.id !in persistedCompletedLevels } val resumeIndex = firstIncompleteIndex.takeIf { it >= 0 } ?: levels.lastIndex currentLevelIndex = resumeIndex repo = runtime.prepareLevel(levels[resumeIndex]) output = listOf( if (persistedCompletedLevels.size == levels.size) { "🏁 All Githug levels completed." } else if (persistedCompletedLevels.isEmpty()) { runtime.startupBanner() } else { "Resumed at level: ${levels[resumeIndex].title}" } ) commandInput = TextFieldValue(text = "", selection = TextRange.Zero) suppressedImeEcho = null hintIndex = 0 activeExerciseDetail = null visibleHint = null historyIndex = -1 historyDraft = "" paneLayout = paneLayout.copy( weights = paneLayout.weights + recommendedPaneWeights( heights = recommendedPaneHeights( level = levels[resumeIndex], levelCount = levels.size, outputLineCount = 1, screenHeightDp = screenHeightDp, suggestionsVisible = false, visibleHint = null, ), ) ) } } fun applyRecommendedPaneWeights(persist: Boolean = true) { updatePaneLayout( transform = { layout -> layout.copy( weights = layout.weights + recommendedPaneWeights( heights = recommendedPaneHeights( level = currentLevel, levelCount = levels.size, outputLineCount = output.size, screenHeightDp = screenHeightDp, suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS, visibleHint = visibleHint, ), ) ) }, persist = persist, ) } val recommendedHeights = recommendedPaneHeights( level = currentLevel, levelCount = levels.size, outputLineCount = output.size, screenHeightDp = screenHeightDp, suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS, visibleHint = visibleHint, ) val recommendedWeights = recommendedPaneWeights( heights = recommendedHeights, ) val workspaceHeight = recommendedWorkspaceHeight( paneLayout = paneLayout, recommendedHeights = recommendedHeights, ) fun resetCurrentLevel(message: String = "Level reset.") { repo = runtime.prepareLevel(currentLevel) clearCommandInput() suppressedImeEcho = null output = listOf(message) hintIndex = 0 activeExerciseDetail = null visibleHint = null historyIndex = -1 historyDraft = "" applyRecommendedPaneWeights(persist = false) } fun loadLevel(index: Int) { currentLevelIndex = index repo = runtime.prepareLevel(levels[index]) output = listOf("Loaded level: ${levels[index].title}") clearCommandInput() suppressedImeEcho = null hintIndex = 0 activeExerciseDetail = null visibleHint = null historyIndex = -1 historyDraft = "" paneLayout = paneLayout.copy( weights = paneLayout.weights + recommendedPaneWeights( heights = recommendedPaneHeights( level = levels[index], levelCount = levels.size, outputLineCount = 1, screenHeightDp = screenHeightDp, suggestionsVisible = false, visibleHint = null, ), ) ) } fun setCommandText(text: String) { commandInput = TextFieldValue(text = text, selection = TextRange(text.length)) } fun moveCursor(delta: Int) { val next = (commandInput.selection.start + delta).coerceIn(0, commandInput.text.length) commandInput = commandInput.copy(selection = TextRange(next)) } fun historyUp() { if (commandHistory.isEmpty()) return if (historyIndex == -1) { historyDraft = commandInput.text historyIndex = commandHistory.lastIndex } else { historyIndex = (historyIndex - 1).coerceAtLeast(0) } setCommandText(commandHistory[historyIndex]) } fun historyDown() { if (commandHistory.isEmpty() || historyIndex == -1) return if (historyIndex >= commandHistory.lastIndex) { historyIndex = -1 setCommandText(historyDraft) } else { historyIndex += 1 setCommandText(commandHistory[historyIndex]) } } fun tabComplete() { val cursor = commandInput.selection.start.coerceIn(0, commandInput.text.length) val beforeCursor = commandInput.text.substring(0, cursor) val tokenStart = beforeCursor.lastIndexOf(' ').let { if (it == -1) 0 else it + 1 } val token = commandInput.text.substring(tokenStart, cursor) if (token.isBlank()) return val matches = repo.files.map { it.name }.sorted().filter { it.startsWith(token) } if (matches.isEmpty()) return val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches) if (replacement == token && matches.size > 1) { output = output + "completion> ${matches.joinToString(" ")}" return } val newText = commandInput.text.replaceRange(tokenStart, cursor, replacement) val newCursor = tokenStart + replacement.length commandInput = TextFieldValue(newText, selection = TextRange(newCursor)) } fun runCommand() { val submittedText = commandInput.text val raw = submittedText.trim() if (raw.isBlank()) return suppressedImeEcho = submittedText clearCommandInput(recreateField = true) if (commandHistory.lastOrNull() != raw) { commandHistory = commandHistory + raw } historyIndex = -1 historyDraft = "" val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) val solvedAfterCommand = currentLevel.validator(newRepo, raw) val wasAlreadyCompleted = currentLevel.id in completedLevels val newOutput = buildList { addAll(output) add("$ $raw") addAll(lines) if (solvedAfterCommand && !wasAlreadyCompleted) { add("✔ Level solved: ${currentLevel.title}") } } if (solvedAfterCommand && !wasAlreadyCompleted) { val updatedCompletedLevels = completedLevels + currentLevel.id completedLevels = updatedCompletedLevels scope.launch { gameProgressStore.saveCompletedLevels(updatedCompletedLevels) } val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels } if (nextLevelIndex >= 0) { loadLevel(nextLevelIndex) } else { repo = newRepo output = listOf("🏁 All Githug levels completed.") clearCommandInput() suppressedImeEcho = null } } else { repo = newRepo output = newOutput applyRecommendedPaneWeights(persist = false) } } fun showCommandHelp() { output = output + runtime.commandReferenceLines() } Scaffold(containerColor = AppBackground) { padding -> Surface( modifier = Modifier .fillMaxSize() .padding(padding), color = AppBackground, ) { Column( modifier = Modifier .fillMaxWidth() .background(AppBackground) .verticalScroll(screenScrollState) .imePadding() .padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { FixedHeader() PaneWorkspace( modifier = Modifier .fillMaxWidth(), paneLayout = paneLayout, onMovePane = { paneId, delta -> updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) }) }, onTogglePane = { paneId -> updatePaneLayout(transform = { layout -> val collapsed = layout.collapsed.toMutableSet() if (!collapsed.add(paneId)) collapsed.remove(paneId) layout.copy(collapsed = collapsed) }) }, levelsContent = { LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) } }, visualContent = { VisualPane(repo) }, exerciseContent = { ExercisePane( level = currentLevel, visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null, suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS, onToggleSuggestions = { activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) { visibleHint = null } applyRecommendedPaneWeights(persist = false) }, onHint = { val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level." visibleHint = hint activeExerciseDetail = ExerciseDetailPanel.HINT hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size) applyRecommendedPaneWeights(persist = false) }, onReset = { resetCurrentLevel() }, ) }, terminalContent = { TerminalPane( output = output, inputFieldVersion = inputFieldVersion, commandInput = commandInput, onValueChange = { val blockedEcho = suppressedImeEcho if (blockedEcho != null && commandInput.text.isEmpty()) { val blockedTrimmed = blockedEcho.trim() if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) { return@TerminalPane } } suppressedImeEcho = null commandInput = it }, onRun = { runCommand() }, onTab = { tabComplete() }, onHelp = { showCommandHelp() }, onCursorLeft = { moveCursor(-1) }, onCursorRight = { moveCursor(1) }, onHistoryUp = { historyUp() }, onHistoryDown = { historyDown() }, ) }, ) Spacer(modifier = Modifier.height(220.dp)) } } } } }