Files
Githug-Android/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt
Joe Tretter c0ea54c784 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 09:09:04 -05:00

1025 lines
38 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.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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.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.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.LEVELS, PaneId.VISUAL, PaneId.EXERCISE, PaneId.TERMINAL),
collapsed = emptySet(),
weights = mapOf(
PaneId.LEVELS to 1.1f,
PaneId.VISUAL to 1.3f,
PaneId.EXERCISE to 1.0f,
PaneId.TERMINAL to 1.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 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()
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 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) {
val updated = transform(paneLayout)
paneLayout = updated
scope.launch { paneLayoutStore.save(updated) }
}
fun clearCommandInput(recreateField: Boolean = false) {
commandInput = TextFieldValue(text = "", selection = TextRange.Zero)
if (recreateField) {
inputFieldVersion += 1
}
}
fun resetCurrentLevel(message: String = "Level reset.") {
repo = runtime.prepareLevel(currentLevel)
clearCommandInput()
suppressedImeEcho = null
output = listOf(message)
hintIndex = 0
historyIndex = -1
historyDraft = ""
}
fun loadLevel(index: Int) {
currentLevelIndex = index
repo = runtime.prepareLevel(levels[index])
output = listOf("Loaded level: ${levels[index].title}")
clearCommandInput()
suppressedImeEcho = null
hintIndex = 0
historyIndex = -1
historyDraft = ""
}
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
}
}
fun showCommandHelp() {
output = output + runtime.commandReferenceLines()
}
Scaffold(containerColor = AppBackground) { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding),
color = AppBackground,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(AppBackground)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
FixedHeader()
PaneWorkspace(
paneLayout = paneLayout,
onMovePane = { paneId, delta ->
updatePaneLayout { layout -> movePane(layout, paneId, delta) }
},
onTogglePane = { paneId ->
updatePaneLayout { layout ->
val collapsed = layout.collapsed.toMutableSet()
if (!collapsed.add(paneId)) collapsed.remove(paneId)
layout.copy(collapsed = collapsed)
}
},
onResize = { upper, lower, dragFraction ->
updatePaneLayout { layout -> resizePanes(layout, upper, lower, dragFraction) }
},
levelsContent = {
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
},
visualContent = { VisualPane(repo) },
exerciseContent = {
ExercisePane(
level = currentLevel,
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() },
)
},
)
}
}
}
}
}
@Composable
private fun FixedHeader() {
Card(
colors = CardDefaults.cardColors(containerColor = PanelPrimary),
shape = RoundedCornerShape(18.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Image(
painter = painterResource(id = R.drawable.githug_android_logo),
contentDescription = "GitHug Android logo",
modifier = Modifier.size(40.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("GitHug Android", style = MaterialTheme.typography.headlineSmall, color = TextPrimary)
Text("Customizable panes for Git learning on phone and tablet.", color = TextSecondary)
}
}
}
}
@Composable
private fun PaneWorkspace(
paneLayout: PaneLayout,
onMovePane: (PaneId, Int) -> Unit,
onTogglePane: (PaneId) -> Unit,
onResize: (PaneId, PaneId, Float) -> Unit,
levelsContent: @Composable () -> Unit,
visualContent: @Composable () -> Unit,
exerciseContent: @Composable () -> Unit,
terminalContent: @Composable () -> Unit,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
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 * 4f)
onResize(upper, lower, fraction)
},
)
}
}
}
}
}
@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 = 12.dp, vertical = 10.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 = 8.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(
contentColor = Accent,
disabledContentColor = TextMuted,
),
) {
Text(label, fontSize = 12.sp)
}
}
@Composable
private fun PaneDivider(
enabled: Boolean,
onDrag: (Float) -> Unit,
) {
val dragState = rememberDraggableState { delta ->
if (enabled) onDrag(delta)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp)
.draggable(
state = dragState,
orientation = Orientation.Vertical,
enabled = enabled,
),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.background(if (enabled) Accent.copy(alpha = 0.45f) else PanelTertiary),
)
}
}
@Composable
private fun LevelsPane(
levels: List<Level>,
currentLevelIndex: Int,
completedLevels: Set<String>,
onSelect: (Int) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
levels.forEachIndexed { index, level ->
Card(
colors = CardDefaults.cardColors(
containerColor = if (index == currentLevelIndex) PanelTertiary else PanelPrimary,
),
shape = RoundedCornerShape(12.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,
onHint: () -> Unit,
onReset: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
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 = onReset,
colors = ButtonDefaults.textButtonColors(contentColor = Accent),
) {
Text("Reset level")
}
}
if (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(14.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(title, color = TextPrimary, fontWeight = FontWeight.Bold)
Text(
content,
color = TextSecondary,
fontFamily = FontFamily.Monospace,
)
}
}
}
@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 focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val horizontalScroll = rememberScrollState()
val outputVerticalScroll = rememberScrollState()
val terminalMinWidth = 640.dp
LaunchedEffect(inputFieldVersion) {
if (inputFieldVersion == 0) return@LaunchedEffect
delay(75)
focusRequester.requestFocus()
keyboardController?.show()
}
fun submitCommand() {
focusManager.clearFocus(force = true)
keyboardController?.hide()
onRun()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(TerminalBackground, RoundedCornerShape(12.dp))
.padding(12.dp)
.horizontalScroll(horizontalScroll),
) {
Column(
modifier = Modifier
.fillMaxHeight()
.widthIn(min = terminalMinWidth),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Terminal", color = TextPrimary, fontWeight = FontWeight.Bold)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(PanelPrimary, RoundedCornerShape(10.dp))
.padding(12.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
.fillMaxWidth()
.background(PanelPrimary, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$",
color = Accent,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
key(inputFieldVersion) {
BasicTextField(
value = commandInput,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
singleLine = true,
textStyle = TextStyle(color = TextPrimary, fontFamily = FontFamily.Monospace),
cursorBrush = SolidColor(Accent),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { 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(34.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PanelSecondary,
contentColor = TextPrimary,
),
) {
Text(
text = label,
fontFamily = fontFamily,
fontWeight = FontWeight.Bold,
fontSize = 13.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 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
}