package com.kawomi.githugandroid import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.io.IOException private val AppBackground = Color(0xFF000000) private val PanelPrimary = Color(0xFF121212) private val PanelSecondary = Color(0xFF1E1E1E) private val PanelTertiary = Color(0xFF262626) private val TerminalBackground = Color(0xFF050505) private val TextPrimary = Color(0xFFFFFFFF) private val TextSecondary = Color(0xFFE6E6E6) private val TextMuted = Color(0xFFBDBDBD) private val Accent = Color(0xFF00E5FF) private val Success = Color(0xFF00FF95) private val GitHugColorScheme = darkColorScheme( primary = Accent, onPrimary = Color.Black, secondary = TextPrimary, onSecondary = Color.Black, background = AppBackground, onBackground = TextPrimary, surface = PanelPrimary, onSurface = TextPrimary, surfaceVariant = PanelSecondary, onSurfaceVariant = TextSecondary, outline = TextMuted, ) private enum class PaneId(val title: String) { LEVELS("Levels"), VISUAL("Visual"), EXERCISE("Exercise"), TERMINAL("Terminal"), } private data class PaneLayout( val order: List, val collapsed: Set, val weights: Map, ) private fun defaultPaneLayout(): PaneLayout = PaneLayout( order = listOf(PaneId.EXERCISE, PaneId.TERMINAL, PaneId.VISUAL, PaneId.LEVELS), collapsed = emptySet(), weights = mapOf( PaneId.EXERCISE to 0.95f, PaneId.TERMINAL to 1.45f, PaneId.VISUAL to 1.0f, PaneId.LEVELS to 0.8f, ), ) private class PaneLayoutStore(private val context: Context) { private val dataStore = PreferenceDataStoreFactory.create( produceFile = { context.preferencesDataStoreFile("pane_layout_preferences") } ) private val orderKey = stringPreferencesKey("pane_order") private val collapsedKey = stringPreferencesKey("pane_collapsed") private val weightsKey = stringPreferencesKey("pane_weights") val layoutFlow = dataStore.data .catch { error -> if (error is IOException) emit(emptyPreferences()) else throw error } .map { preferences -> val defaults = defaultPaneLayout() val order = preferences[orderKey] ?.split(',') ?.mapNotNull { value -> PaneId.entries.firstOrNull { it.name == value } } ?.let { parsed -> val missing = PaneId.entries.filterNot { it in parsed } parsed + missing } ?.takeIf { it.isNotEmpty() } ?: defaults.order val collapsed = preferences[collapsedKey] ?.split(',') ?.mapNotNull { value -> PaneId.entries.firstOrNull { it.name == value } } ?.toSet() ?: emptySet() val parsedWeights = preferences[weightsKey] ?.split(';') ?.mapNotNull { item -> val parts = item.split(':', limit = 2) val paneId = PaneId.entries.firstOrNull { it.name == parts.getOrNull(0) } val weight = parts.getOrNull(1)?.toFloatOrNull() if (paneId != null && weight != null) paneId to weight.coerceAtLeast(0.6f) else null } ?.toMap() .orEmpty() PaneLayout( order = order, collapsed = collapsed, weights = PaneId.entries.associateWith { pane -> parsedWeights[pane] ?: defaults.weights.getValue(pane) }, ) } suspend fun save(layout: PaneLayout) { dataStore.edit { preferences -> preferences[orderKey] = layout.order.joinToString(",") { it.name } preferences[collapsedKey] = layout.collapsed.joinToString(",") { it.name } preferences[weightsKey] = layout.order.joinToString(";") { pane -> "${pane.name}:${layout.weights[pane] ?: 1f}" } } } } @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 persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout()) val scope = rememberCoroutineScope() val screenScrollState = rememberScrollState() val workspaceHeight = (configuration.screenHeightDp.dp * 1.05f).coerceAtLeast(560.dp) 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 suggestionsExpanded by remember { mutableStateOf(false) } var completedLevels by remember { mutableStateOf(setOf()) } var commandHistory by remember { mutableStateOf(listOf()) } var historyIndex by remember { mutableStateOf(-1) } var historyDraft by remember { mutableStateOf("") } 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 } } fun applyRecommendedPaneWeights(persist: Boolean = true) { updatePaneLayout( transform = { layout -> layout.copy( weights = layout.weights + recommendedPaneWeights( level = currentLevel, levelCount = levels.size, outputLineCount = output.size, screenHeightDp = screenHeightDp, ) ) }, persist = persist, ) } fun resetCurrentLevel(message: String = "Level reset.") { repo = runtime.prepareLevel(currentLevel) clearCommandInput() suppressedImeEcho = null output = listOf(message) hintIndex = 0 suggestionsExpanded = false 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 suggestionsExpanded = false historyIndex = -1 historyDraft = "" paneLayout = paneLayout.copy( weights = paneLayout.weights + recommendedPaneWeights( level = levels[index], levelCount = levels.size, outputLineCount = 1, screenHeightDp = screenHeightDp, ) ) } 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) 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) { completedLevels = completedLevels + currentLevel.id val hasNextLevel = currentLevelIndex < levels.lastIndex if (hasNextLevel) { loadLevel(currentLevelIndex + 1) } else { repo = newRepo output = listOf("🏁 All available MVP 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() .height(workspaceHeight), 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) }) }, onResize = { upper, lower, dragFraction -> updatePaneLayout(transform = { layout -> resizePanes(layout, upper, lower, dragFraction) }, persist = false) }, onResizeFinished = { scope.launch { paneLayoutStore.save(paneLayout) } }, levelsContent = { LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) } }, visualContent = { VisualPane(repo) }, exerciseContent = { ExercisePane( level = currentLevel, suggestionsExpanded = suggestionsExpanded, onToggleSuggestions = { suggestionsExpanded = !suggestionsExpanded }, onHint = { val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level." output = output + "hint> $hint" hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size) }, 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)) } } } } } @Composable private fun FixedHeader() { Card( colors = CardDefaults.cardColors(containerColor = PanelPrimary), shape = RoundedCornerShape(14.dp), ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Image( painter = painterResource(id = R.drawable.githug_android_logo), contentDescription = "GitHug Android logo", modifier = Modifier.size(28.dp), ) Text("GitHug Android", style = MaterialTheme.typography.titleLarge, color = TextPrimary) } } } @Composable private fun PaneWorkspace( modifier: Modifier = Modifier, paneLayout: PaneLayout, onMovePane: (PaneId, Int) -> Unit, onTogglePane: (PaneId) -> Unit, onResize: (PaneId, PaneId, Float) -> Unit, onResizeFinished: () -> Unit, levelsContent: @Composable () -> Unit, visualContent: @Composable () -> Unit, exerciseContent: @Composable () -> Unit, terminalContent: @Composable () -> Unit, ) { BoxWithConstraints(modifier = modifier) { val heightBasis = maxHeight.value.takeIf { it > 0f } ?: 1f Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(0.dp), ) { paneLayout.order.forEachIndexed { index, paneId -> val isCollapsed = paneId in paneLayout.collapsed if (isCollapsed) { CollapsedPaneCard( paneId = paneId, canMoveUp = index > 0, canMoveDown = index < paneLayout.order.lastIndex, onMoveUp = { onMovePane(paneId, -1) }, onMoveDown = { onMovePane(paneId, 1) }, onToggleCollapse = { onTogglePane(paneId) }, ) } else { PaneCard( paneId = paneId, modifier = Modifier .fillMaxWidth() .weight((paneLayout.weights[paneId] ?: 1f).coerceAtLeast(0.6f)), canMoveUp = index > 0, canMoveDown = index < paneLayout.order.lastIndex, onMoveUp = { onMovePane(paneId, -1) }, onMoveDown = { onMovePane(paneId, 1) }, onToggleCollapse = { onTogglePane(paneId) }, ) { when (paneId) { PaneId.LEVELS -> levelsContent() PaneId.VISUAL -> visualContent() PaneId.EXERCISE -> exerciseContent() PaneId.TERMINAL -> terminalContent() } } } if (index < paneLayout.order.lastIndex) { val upper = paneId val lower = paneLayout.order[index + 1] PaneDivider( enabled = upper !in paneLayout.collapsed && lower !in paneLayout.collapsed, onDrag = { deltaPx -> val fraction = deltaPx / heightBasis onResize(upper, lower, fraction) }, onDragFinished = onResizeFinished, ) } } } } } @Composable private fun PaneCard( paneId: PaneId, modifier: Modifier = Modifier, canMoveUp: Boolean, canMoveDown: Boolean, onMoveUp: () -> Unit, onMoveDown: () -> Unit, onToggleCollapse: () -> Unit, content: @Composable () -> Unit, ) { Card( modifier = modifier, colors = CardDefaults.cardColors(containerColor = PanelSecondary), shape = RoundedCornerShape(16.dp), ) { Column(modifier = Modifier.fillMaxSize()) { PaneHeader( paneId = paneId, collapsed = false, canMoveUp = canMoveUp, canMoveDown = canMoveDown, onMoveUp = onMoveUp, onMoveDown = onMoveDown, onToggleCollapse = onToggleCollapse, ) Box( modifier = Modifier .fillMaxSize() .padding(horizontal = 12.dp, vertical = 10.dp), ) { content() } } } } @Composable private fun CollapsedPaneCard( paneId: PaneId, canMoveUp: Boolean, canMoveDown: Boolean, onMoveUp: () -> Unit, onMoveDown: () -> Unit, onToggleCollapse: () -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = PanelSecondary), shape = RoundedCornerShape(16.dp), ) { PaneHeader( paneId = paneId, collapsed = true, canMoveUp = canMoveUp, canMoveDown = canMoveDown, onMoveUp = onMoveUp, onMoveDown = onMoveDown, onToggleCollapse = onToggleCollapse, ) } } @Composable private fun PaneHeader( paneId: PaneId, collapsed: Boolean, canMoveUp: Boolean, canMoveDown: Boolean, onMoveUp: () -> Unit, onMoveDown: () -> Unit, onToggleCollapse: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .background(if (collapsed) PanelTertiary else PanelPrimary) .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text(paneId.title, color = TextPrimary, fontWeight = FontWeight.Bold) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { HeaderActionButton(label = if (collapsed) "Expand" else "Collapse", onClick = onToggleCollapse) HeaderActionButton(label = "↑", enabled = canMoveUp, onClick = onMoveUp) HeaderActionButton(label = "↓", enabled = canMoveDown, onClick = onMoveDown) } } } @Composable private fun HeaderActionButton( label: String, enabled: Boolean = true, onClick: () -> Unit, ) { TextButton( onClick = onClick, enabled = enabled, contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp), colors = ButtonDefaults.textButtonColors( contentColor = Accent, disabledContentColor = TextMuted, ), ) { Text(label, fontSize = 11.sp) } } @Composable private fun PaneDivider( enabled: Boolean, onDrag: (Float) -> Unit, onDragFinished: () -> Unit, ) { val dragState = rememberDraggableState { delta -> if (enabled) onDrag(delta) } Box( modifier = Modifier .fillMaxWidth() .height(18.dp) .draggable( state = dragState, orientation = Orientation.Vertical, enabled = enabled, onDragStopped = { onDragFinished() }, ), contentAlignment = Alignment.CenterStart, ) { Box( modifier = Modifier .fillMaxWidth() .height(1.dp) .background(if (enabled) Accent.copy(alpha = 0.45f) else PanelTertiary), ) Card( colors = CardDefaults.cardColors( containerColor = if (enabled) PanelPrimary else PanelTertiary, ), shape = RoundedCornerShape(999.dp), modifier = Modifier.padding(start = 28.dp), ) { Text( text = "↕", color = if (enabled) Accent else TextMuted, modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp), fontSize = 13.sp, fontWeight = FontWeight.ExtraBold, ) } } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun RowScope.TerminalInputField( inputFieldVersion: Int, commandInput: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, focusRequester: FocusRequester, bringIntoViewRequester: BringIntoViewRequester, keyboardController: androidx.compose.ui.platform.SoftwareKeyboardController?, onSubmit: () -> Unit, ) { val scope = rememberCoroutineScope() key(inputFieldVersion) { BasicTextField( value = commandInput, onValueChange = onValueChange, modifier = Modifier .weight(1f) .focusRequester(focusRequester) .bringIntoViewRequester(bringIntoViewRequester) .onFocusChanged { state -> if (state.isFocused) { keyboardController?.show() scope.launch { delay(150) bringIntoViewRequester.bringIntoView() } } }, singleLine = true, textStyle = TextStyle( color = TextPrimary, fontFamily = FontFamily.Monospace, fontSize = 15.sp, fontWeight = FontWeight.Normal, ), cursorBrush = SolidColor(Accent), keyboardOptions = KeyboardOptions( autoCorrect = false, keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions(onDone = { onSubmit() }), decorationBox = { innerTextField -> Box( modifier = Modifier .fillMaxWidth() .background(Color.Transparent) .padding(vertical = 2.dp), contentAlignment = Alignment.CenterStart, ) { if (commandInput.text.isEmpty()) { Text( text = "Enter git command", color = TextMuted.copy(alpha = 0.7f), fontFamily = FontFamily.Monospace, fontSize = 15.sp, ) } innerTextField() } }, ) } } @Composable private fun LevelsPane( levels: List, currentLevelIndex: Int, completedLevels: Set, onSelect: (Int) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp), ) { levels.forEachIndexed { index, level -> Card( colors = CardDefaults.cardColors( containerColor = if (index == currentLevelIndex) PanelTertiary else PanelPrimary, ), shape = RoundedCornerShape(10.dp), ) { TextButton( onClick = { onSelect(index) }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.textButtonColors( contentColor = if (index == currentLevelIndex) Accent else TextSecondary, ), ) { Text( text = buildString { append(if (level.id in completedLevels) "✓ " else "• ") append(level.title) }, modifier = Modifier.fillMaxWidth(), ) } } } } } @Composable private fun VisualPane(repo: RepoState) { val configuration = LocalConfiguration.current val isWide = configuration.screenWidthDp >= 840 if (isWide) { Row( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { InfoPaneCard( title = "Workspace", content = repo.files.joinToString("\n") { "${if (it.staged) "[staged]" else "[file] "} ${it.name}" }.ifBlank { "(empty)" }, modifier = Modifier.weight(1f), ) InfoPaneCard( title = "Branches", content = buildString { appendLine("HEAD -> ${repo.headBranch}") repo.branches.forEach { (name, _) -> appendLine(name) } }.trim(), modifier = Modifier.weight(1f), ) InfoPaneCard( title = "Commits", content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" }, modifier = Modifier.weight(1f), ) } } else { Column( modifier = Modifier .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp), ) { InfoPaneCard( title = "Workspace", content = repo.files.joinToString("\n") { "${if (it.staged) "[staged]" else "[file] "} ${it.name}" }.ifBlank { "(empty)" }, ) InfoPaneCard( title = "Branches", content = buildString { appendLine("HEAD -> ${repo.headBranch}") repo.branches.forEach { (name, _) -> appendLine(name) } }.trim(), ) InfoPaneCard( title = "Commits", content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" }, ) } } } @Composable private fun ExercisePane( level: Level, suggestionsExpanded: Boolean, onToggleSuggestions: () -> Unit, onHint: () -> Unit, onReset: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary) Text(level.description, color = TextSecondary) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( onClick = onHint, colors = ButtonDefaults.buttonColors( containerColor = Accent, contentColor = Color.Black, ), ) { Text("Hint") } TextButton( onClick = onToggleSuggestions, colors = ButtonDefaults.textButtonColors(contentColor = Accent), ) { Text("Suggestions") } TextButton( onClick = onReset, colors = ButtonDefaults.textButtonColors(contentColor = Accent), ) { Text("Reset level") } } if (suggestionsExpanded && level.commandSuggestions.isNotEmpty()) { Text("Suggestions", color = TextPrimary, fontWeight = FontWeight.Bold) level.commandSuggestions.forEach { suggestion -> Text(suggestion, color = TextSecondary, fontFamily = FontFamily.Monospace) } } } } @Composable private fun InfoPaneCard( title: String, content: String, modifier: Modifier = Modifier, ) { Card( modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = PanelPrimary), shape = RoundedCornerShape(10.dp), ) { Column( modifier = Modifier .fillMaxSize() .padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text(title, color = TextPrimary, fontWeight = FontWeight.Bold) Text( content, color = TextSecondary, fontFamily = FontFamily.Monospace, ) } } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun TerminalPane( output: List, inputFieldVersion: Int, commandInput: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, onRun: () -> Unit, onTab: () -> Unit, onHelp: () -> Unit, onCursorLeft: () -> Unit, onCursorRight: () -> Unit, onHistoryUp: () -> Unit, onHistoryDown: () -> Unit, ) { val configuration = LocalConfiguration.current val focusRequester = remember { FocusRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() } val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current val horizontalScroll = rememberScrollState() val outputVerticalScroll = rememberScrollState() val terminalMinWidth = 80.dp * 7.2f val maxOutputHeight = configuration.screenHeightDp.dp * 0.7f val outputLineHeight = 20.dp val desiredOutputHeight = (output.size.coerceAtLeast(6) * outputLineHeight.value).dp.coerceAtMost(maxOutputHeight) LaunchedEffect(inputFieldVersion) { focusRequester.requestFocus() keyboardController?.show() horizontalScroll.scrollTo(0) } LaunchedEffect(output.size) { outputVerticalScroll.animateScrollTo(outputVerticalScroll.maxValue) } LaunchedEffect(output.size, commandInput.text) { if (output.isNotEmpty() && commandInput.text.isEmpty()) { horizontalScroll.scrollTo(0) } } fun submitCommand() { focusManager.clearFocus(force = true) keyboardController?.hide() onRun() } Box( modifier = Modifier .fillMaxSize() .background(TerminalBackground, RoundedCornerShape(12.dp)) .padding(8.dp) .horizontalScroll(horizontalScroll), ) { Column( modifier = Modifier .fillMaxHeight() .widthIn(min = terminalMinWidth), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Box( modifier = Modifier .heightIn(min = 120.dp, max = maxOutputHeight) .height(desiredOutputHeight) .fillMaxWidth() .background(PanelPrimary, RoundedCornerShape(10.dp)) .padding(8.dp) .verticalScroll(outputVerticalScroll), ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { output.forEach { line -> Text( text = line, color = if (line.startsWith("✔") || line.startsWith("🏁")) Success else TextSecondary, fontFamily = FontFamily.Monospace, ) } } } SpecialKeyBar( onTab = onTab, onHelp = onHelp, onCursorLeft = onCursorLeft, onCursorRight = onCursorRight, onHistoryUp = onHistoryUp, onHistoryDown = onHistoryDown, ) Row( modifier = Modifier .bringIntoViewRequester(bringIntoViewRequester) .fillMaxWidth() .widthIn(min = terminalMinWidth) .background(PanelPrimary, RoundedCornerShape(8.dp)) .padding(horizontal = 8.dp, vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = "$", color = Accent, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = 15.sp, ) TerminalInputField( inputFieldVersion = inputFieldVersion, commandInput = commandInput, onValueChange = onValueChange, focusRequester = focusRequester, bringIntoViewRequester = bringIntoViewRequester, keyboardController = keyboardController, onSubmit = { submitCommand() }, ) } } } } @Composable private fun SpecialKeyBar( onTab: () -> Unit, onHelp: () -> Unit, onCursorLeft: () -> Unit, onCursorRight: () -> Unit, onHistoryUp: () -> Unit, onHistoryDown: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { TerminalKeyButton(label = "↹", onClick = onTab, modifier = Modifier.width(56.dp), fontFamily = FontFamily.Default) TerminalKeyButton(label = "?", onClick = onHelp, modifier = Modifier.width(40.dp), fontFamily = FontFamily.Default) TerminalKeyButton(label = "←", onClick = onCursorLeft, modifier = Modifier.width(40.dp)) TerminalKeyButton(label = "→", onClick = onCursorRight, modifier = Modifier.width(40.dp)) TerminalKeyButton(label = "↑", onClick = onHistoryUp, modifier = Modifier.width(40.dp)) TerminalKeyButton(label = "↓", onClick = onHistoryDown, modifier = Modifier.width(40.dp)) } } @Composable private fun TerminalKeyButton( label: String, onClick: () -> Unit, modifier: Modifier = Modifier, fontFamily: FontFamily = FontFamily.Monospace, ) { Button( onClick = onClick, modifier = modifier.height(28.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), colors = ButtonDefaults.buttonColors( containerColor = PanelSecondary, contentColor = TextPrimary, ), ) { Text( text = label, fontFamily = fontFamily, fontWeight = FontWeight.Bold, fontSize = 11.sp, ) } } private fun movePane(layout: PaneLayout, paneId: PaneId, delta: Int): PaneLayout { val currentIndex = layout.order.indexOf(paneId) if (currentIndex == -1) return layout val targetIndex = (currentIndex + delta).coerceIn(0, layout.order.lastIndex) if (currentIndex == targetIndex) return layout val updatedOrder = layout.order.toMutableList() updatedOrder.removeAt(currentIndex) updatedOrder.add(targetIndex, paneId) return layout.copy(order = updatedOrder) } private fun resizePanes( layout: PaneLayout, upper: PaneId, lower: PaneId, dragFraction: Float, ): PaneLayout { if (upper in layout.collapsed || lower in layout.collapsed) return layout val minWeight = 0.6f val upperWeight = layout.weights[upper] ?: 1f val lowerWeight = layout.weights[lower] ?: 1f val scaledDelta = dragFraction.coerceIn(-0.75f, 0.75f) val newUpper = (upperWeight + scaledDelta).coerceAtLeast(minWeight) val newLower = (lowerWeight - scaledDelta).coerceAtLeast(minWeight) return layout.copy( weights = layout.weights + mapOf( upper to newUpper, lower to newLower, ) ) } private fun recommendedPaneWeights( level: Level, levelCount: Int, outputLineCount: Int, screenHeightDp: Int, ): Map { val screenHeight = screenHeightDp.coerceAtLeast(560) val levelsHeight = (56 + levelCount * 44).coerceAtMost((screenHeight * 0.35f).toInt()) val descriptionLines = (level.description.length / 42).coerceAtLeast(2) + 2 val suggestionsLines = level.commandSuggestions.size val exerciseHeight = (120 + descriptionLines * 24 + suggestionsLines * 22).coerceAtLeast(180) val terminalHeight = (120 + outputLineCount.coerceAtLeast(1) * 20).coerceAtMost((screenHeight * 0.7f).toInt()) val visualHeight = (screenHeight * 0.24f).toInt().coerceAtLeast(170) fun weightFor(height: Int): Float = (height.toFloat() / screenHeight.toFloat()).coerceAtLeast(0.6f) return mapOf( PaneId.EXERCISE to weightFor(exerciseHeight), PaneId.TERMINAL to weightFor(terminalHeight), PaneId.VISUAL to weightFor(visualHeight), PaneId.LEVELS to weightFor(levelsHeight), ) } private fun commonPrefix(values: List): String { if (values.isEmpty()) return "" var prefix = values.first() values.drop(1).forEach { value -> while (!value.startsWith(prefix) && prefix.isNotEmpty()) { prefix = prefix.dropLast(1) } } return prefix }