Files
Githug-Android/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
Joe Tretter ca20b0a358 Auto-commit after successful build: update app gameplay/UI, improve build setup
Changed files:\napp/build.gradle.kts
app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt
app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt
app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt
app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt
2026-05-02 22:09:20 -05:00

492 lines
22 KiB
Kotlin

package solutions.tretter.githugandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun GitHugApp() {
MaterialTheme(colorScheme = GitHugColorScheme) {
val context = LocalContext.current
val configuration = LocalConfiguration.current
val levels = remember { sampleLevels() }
val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) }
val paneLayoutStore = remember(context) { PaneLayoutStore(context.applicationContext) }
val gameProgressStore = remember(context) { GameProgressStore(context.applicationContext) }
val persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout())
val persistedProgress by gameProgressStore.progressFlow.collectAsState(initial = null)
val scope = rememberCoroutineScope()
val screenScrollState = rememberScrollState()
val screenHeightDp = configuration.screenHeightDp
var paneLayout by remember { mutableStateOf(defaultPaneLayout()) }
var currentLevelIndex by remember { mutableStateOf(0) }
var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) }
var commandInput by remember { mutableStateOf(TextFieldValue("")) }
var inputFieldVersion by remember { mutableStateOf(0) }
var suppressedImeEcho by remember { mutableStateOf<String?>(null) }
var output by remember { mutableStateOf(listOf(runtime.startupBanner())) }
var hintIndex by remember { mutableStateOf(0) }
var activeExerciseDetail by remember { mutableStateOf<ExerciseDetailPanel?>(null) }
var visibleHint by remember { mutableStateOf<String?>(null) }
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("") }
var hasRestoredProgress by remember { mutableStateOf(false) }
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
var manPageState by remember { mutableStateOf<ManPageState?>(null) }
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
}
}
suspend fun persistProgress(completed: Set<String>, activeLevelId: String?) {
gameProgressStore.saveProgress(completed, activeLevelId)
}
LaunchedEffect(persistedProgress) {
val restoredProgress = persistedProgress ?: return@LaunchedEffect
val restoredCompletedLevels = restoredProgress.completedLevels
completedLevels = restoredCompletedLevels
AppLog.d(
"GitHugApp",
"Observed persisted progress completed=${restoredCompletedLevels.sorted()} activeLevelId=${restoredProgress.activeLevelId} restored=$hasRestoredProgress",
)
if (!hasRestoredProgress) {
hasRestoredProgress = true
val resumeIndex = restoredProgress.activeLevelId
?.let { activeId -> levels.indexOfFirst { it.id == activeId }.takeIf { it >= 0 } }
?: levels.indexOfFirst { it.id !in restoredCompletedLevels }.takeIf { it >= 0 }
?: levels.lastIndex
AppLog.d(
"GitHugApp",
"Restoring app to levelIndex=$resumeIndex levelId=${levels[resumeIndex].id}",
)
currentLevelIndex = resumeIndex
repo = runtime.prepareLevel(levels[resumeIndex])
output = listOf(
if (restoredCompletedLevels.size == levels.size) {
"🏁 All Githug levels completed."
} else if (restoredCompletedLevels.isEmpty()) {
runtime.startupBanner()
} else {
"Resumed at level: ${levels[resumeIndex].title}"
}
)
commandInput = TextFieldValue(text = "", selection = TextRange.Zero)
suppressedImeEcho = null
hintIndex = 0
activeExerciseDetail = null
visibleHint = null
historyIndex = -1
historyDraft = ""
paneLayout = paneLayout.copy(
weights = paneLayout.weights + recommendedPaneWeights(
heights = recommendedPaneHeights(
level = levels[resumeIndex],
levelCount = levels.size,
outputLineCount = 1,
screenHeightDp = screenHeightDp,
suggestionsVisible = false,
visibleHint = null,
),
)
)
}
}
fun applyRecommendedPaneWeights(persist: Boolean = true) {
updatePaneLayout(
transform = { layout ->
layout.copy(
weights = layout.weights + recommendedPaneWeights(
heights = recommendedPaneHeights(
level = currentLevel,
levelCount = levels.size,
outputLineCount = output.size,
screenHeightDp = screenHeightDp,
suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
visibleHint = visibleHint,
),
)
)
},
persist = persist,
)
}
fun resetCurrentLevel(message: String = "Level reset.") {
repo = runtime.prepareLevel(currentLevel)
clearCommandInput()
suppressedImeEcho = null
output = listOf(message)
hintIndex = 0
activeExerciseDetail = null
visibleHint = null
historyIndex = -1
historyDraft = ""
editorState = null
manPageState = null
applyRecommendedPaneWeights(persist = false)
}
fun loadLevel(index: Int) {
AppLog.d("GitHugApp", "Loading level index=$index id=${levels[index].id} title=${levels[index].title}")
currentLevelIndex = index
repo = runtime.prepareLevel(levels[index])
output = listOf("Loaded level: ${levels[index].title}")
clearCommandInput()
suppressedImeEcho = null
hintIndex = 0
activeExerciseDetail = null
visibleHint = null
historyIndex = -1
historyDraft = ""
editorState = null
manPageState = null
paneLayout = paneLayout.copy(
weights = paneLayout.weights + recommendedPaneWeights(
heights = recommendedPaneHeights(
level = levels[index],
levelCount = levels.size,
outputLineCount = 1,
screenHeightDp = screenHeightDp,
suggestionsVisible = false,
visibleHint = null,
),
)
)
}
fun setCommandText(text: String) {
commandInput = TextFieldValue(text = text, selection = TextRange(text.length))
}
fun moveCursor(delta: Int) {
val next = (commandInput.selection.start + delta).coerceIn(0, commandInput.text.length)
commandInput = commandInput.copy(selection = TextRange(next))
}
fun historyUp() {
if (commandHistory.isEmpty()) return
if (historyIndex == -1) {
historyDraft = commandInput.text
historyIndex = commandHistory.lastIndex
} else {
historyIndex = (historyIndex - 1).coerceAtLeast(0)
}
setCommandText(commandHistory[historyIndex])
}
fun historyDown() {
if (commandHistory.isEmpty() || historyIndex == -1) return
if (historyIndex >= commandHistory.lastIndex) {
historyIndex = -1
setCommandText(historyDraft)
} else {
historyIndex += 1
setCommandText(commandHistory[historyIndex])
}
}
fun tabComplete() {
val cursor = commandInput.selection.start.coerceIn(0, commandInput.text.length)
val beforeCursor = commandInput.text.substring(0, cursor)
val tokenStart = beforeCursor.lastIndexOf(' ').let { if (it == -1) 0 else it + 1 }
val token = commandInput.text.substring(tokenStart, cursor)
if (token.isBlank()) return
val matches = repo.files.map { it.name }.sorted().filter { it.startsWith(token) }
if (matches.isEmpty()) return
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
if (replacement == token && matches.size > 1) {
output = output + "completion> ${matches.joinToString(" ")}"
return
}
val newText = commandInput.text.replaceRange(tokenStart, cursor, replacement)
val newCursor = tokenStart + replacement.length
commandInput = TextFieldValue(newText, selection = TextRange(newCursor))
}
fun applyCommandResult(raw: String, newRepo: RepoState, lines: List<String>, echoCommand: Boolean) {
val levelForResult = currentLevel
val solvedAfterCommand = levelForResult.validator(newRepo, raw)
val wasAlreadyCompleted = currentLevel.id in completedLevels
AppLog.d(
"GitHugApp",
"Command='$raw' level=${levelForResult.id} solved=$solvedAfterCommand alreadyCompleted=$wasAlreadyCompleted completedBefore=${completedLevels.sorted()}",
)
val newOutput = buildList {
addAll(output)
if (echoCommand) {
add("$ $raw")
}
addAll(lines)
if (solvedAfterCommand && !wasAlreadyCompleted) {
add("✔ Level solved: ${levelForResult.title}")
}
}
if (solvedAfterCommand && !wasAlreadyCompleted) {
val updatedCompletedLevels = completedLevels + levelForResult.id
completedLevels = updatedCompletedLevels
val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels }
if (nextLevelIndex >= 0) {
val nextLevelId = levels[nextLevelIndex].id
AppLog.d(
"GitHugApp",
"Level solved ${levelForResult.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId",
)
scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) }
loadLevel(nextLevelIndex)
} else {
AppLog.d("GitHugApp", "All levels completed")
scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) }
repo = newRepo
output = listOf("🏁 All Githug levels completed.")
clearCommandInput()
suppressedImeEcho = null
}
} else {
AppLog.d("GitHugApp", "Staying on level=${levelForResult.id}")
repo = newRepo
output = newOutput
applyRecommendedPaneWeights(persist = false)
}
}
fun openEditor(invocation: VisualEditorInvocation) {
val path = invocation.path.orEmpty()
val (content, lines) = if (path.isBlank()) {
"" to emptyList()
} else {
runtime.readEditorFile(currentLevel, repo, path)
}
output = buildList {
addAll(output)
add("$ ${invocation.command}")
addAll(lines)
if (lines.isEmpty()) {
add("Opened ${invocation.editor} editor${if (path.isBlank()) "" else " for $path"}")
}
}
if (lines.isEmpty()) {
editorState = TextEditorState(
editor = invocation.editor,
originalCommand = invocation.command,
path = path,
content = content,
saveAsPath = path,
)
}
applyRecommendedPaneWeights(persist = false)
}
fun openManPage(invocation: GitHelpInvocation) {
val content = runtime.gitManPage(currentLevel, repo, invocation.topic)
output = buildList {
addAll(output)
add("$ ${invocation.command}")
add("Opened git help viewer for ${invocation.topic}")
}
manPageState = ManPageState(topic = invocation.topic, content = content)
applyRecommendedPaneWeights(persist = false)
}
fun saveEditor() {
val state = editorState ?: return
val targetPath = state.saveAsPath.trim()
if (targetPath.isBlank()) return
val (newRepo, lines) = runtime.writeEditorFile(currentLevel, repo, targetPath, state.content)
editorState = null
applyCommandResult(state.originalCommand, newRepo, lines, echoCommand = false)
}
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 editorInvocation = parseVisualEditorInvocation(raw)
if (editorInvocation != null) {
openEditor(editorInvocation)
return
}
val helpInvocation = parseGitHelpInvocation(raw)
if (helpInvocation != null) {
openManPage(helpInvocation)
return
}
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
applyCommandResult(raw, newRepo, lines, echoCommand = true)
}
fun showCommandHelp() {
output = output + runtime.commandReferenceLines()
}
Scaffold(containerColor = AppBackground) { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding),
color = AppBackground,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(AppBackground)
.verticalScroll(screenScrollState)
.imePadding()
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FixedHeader()
PaneWorkspace(
modifier = Modifier
.fillMaxWidth(),
paneLayout = paneLayout,
onMovePane = { paneId, delta ->
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
},
onTogglePane = { paneId ->
updatePaneLayout(transform = { layout ->
val collapsed = layout.collapsed.toMutableSet()
if (!collapsed.add(paneId)) collapsed.remove(paneId)
layout.copy(collapsed = collapsed)
})
},
levelsContent = {
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
},
visualContent = { VisualPane(repo) },
exerciseContent = {
ExercisePane(
level = currentLevel,
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
onToggleSuggestions = {
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
visibleHint = null
}
applyRecommendedPaneWeights(persist = false)
},
onHint = {
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
visibleHint = hint
activeExerciseDetail = ExerciseDetailPanel.HINT
hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size)
applyRecommendedPaneWeights(persist = false)
},
onReset = { resetCurrentLevel() },
)
},
terminalContent = {
TerminalPane(
output = output,
inputFieldVersion = inputFieldVersion,
commandInput = commandInput,
onValueChange = {
val blockedEcho = suppressedImeEcho
if (blockedEcho != null && commandInput.text.isEmpty()) {
val blockedTrimmed = blockedEcho.trim()
if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) {
return@TerminalPane
}
}
suppressedImeEcho = null
commandInput = it
},
onRun = { runCommand() },
onTab = { tabComplete() },
onHelp = { showCommandHelp() },
onCursorLeft = { moveCursor(-1) },
onCursorRight = { moveCursor(1) },
onHistoryUp = { historyUp() },
onHistoryDown = { historyDown() },
)
},
)
Spacer(modifier = Modifier.height(220.dp))
}
}
}
editorState?.let { state ->
TextEditorDialog(
state = state,
onContentChange = { editorState = state.copy(content = it) },
onSaveAsPathChange = { editorState = state.copy(saveAsPath = it) },
onClose = {
output = output + "Editor closed without saving"
editorState = null
},
onSave = { saveEditor() },
)
}
manPageState?.let { state ->
ManPageDialog(
state = state,
onClose = { manPageState = null },
)
}
}
}