Files
Githug-Android/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt
Joe Tretter 99b868912e Auto-commit after successful build: update app gameplay/UI, improve build setup
Changed files:\napp/build.gradle.kts
app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt
2026-04-23 12:54:06 -05:00

1200 lines
45 KiB
Kotlin

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<PaneId>,
val collapsed: Set<PaneId>,
val weights: Map<PaneId, Float>,
)
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<String?>(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<String>()) }
var commandHistory by remember { mutableStateOf(listOf<String>()) }
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<Level>,
currentLevelIndex: Int,
completedLevels: Set<String>,
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<String>,
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<PaneId, Float> {
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>): 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
}