Files
Githug-Android/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
Joe Tretter 943bf03ca0 Route interactive Git through native terminal sessions
Remove Kotlin interactive-add emulation and run patch add via the native Git runtime with PTY-backed session IO.
2026-06-27 13:07:35 -05:00

793 lines
38 KiB
Kotlin

package solutions.tretter.githugandroid
import android.os.SystemClock
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.statusBarsPadding
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.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun GitHugApp() {
MaterialTheme(colorScheme = GitHugColorScheme) {
val context = LocalContext.current
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) }
if (!runtime.isNativeGitAvailable()) {
MissingNativeGitScreen(message = runtime.unavailableMessage())
return@MaterialTheme
}
val levels = remember { sampleLevels() }
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 isPreparingLevel by remember { mutableStateOf(false) }
var levelLoadRequestId by remember { mutableStateOf(0) }
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
var manPageState by remember { mutableStateOf<ManPageState?>(null) }
var solvedCelebrationTitle by remember { mutableStateOf<String?>(null) }
var showTerminalInputHint by remember { mutableStateOf(true) }
var showExerciseDescriptionHint by remember { mutableStateOf(true) }
var showHelpOverlay by rememberSaveable { mutableStateOf(false) }
var showHelpOnStart by rememberSaveable { mutableStateOf(true) }
var helpPreferenceInitialized by rememberSaveable { mutableStateOf(false) }
var showDiagnosticDialog by rememberSaveable { mutableStateOf(false) }
var diagnosticSkinEnabled by rememberSaveable { mutableStateOf(false) }
var headerTapTimes by remember { mutableStateOf(emptyList<Long>()) }
var exerciseBounds by remember { mutableStateOf<Rect?>(null) }
var promptBounds by remember { mutableStateOf<Rect?>(null) }
val currentLevel = levels[currentLevelIndex]
val appBackground = if (diagnosticSkinEnabled) DiagnosticAppBackground else AppBackground
val imeBottom = WindowInsets.ime.getBottom(density)
val navigationBottom = WindowInsets.navigationBars.getBottom(density)
val bottomSystemPadding = with(density) {
if (imeBottom > navigationBottom) 0.dp else navigationBottom.toDp()
}
LaunchedEffect(solvedCelebrationTitle) {
if (solvedCelebrationTitle != null) {
delay(3_000)
solvedCelebrationTitle = null
}
}
LaunchedEffect(persistedPaneLayout) {
paneLayout = persistedPaneLayout
}
LaunchedEffect(currentLevelIndex) {
AppLog.d("GitHugApp", "Current level index changed to $currentLevelIndex id=${levels[currentLevelIndex].id}")
delay(100)
screenScrollState.animateScrollTo(0)
AppLog.d("GitHugApp", "Scrolled to top for level index=$currentLevelIndex id=${levels[currentLevelIndex].id}")
}
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
showHelpOnStart = restoredProgress.showHelpOnStart
if (!helpPreferenceInitialized) {
helpPreferenceInitialized = true
showHelpOverlay = restoredProgress.showHelpOnStart
}
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,
outputLineCount: Int = terminalOutputLineCount(output),
) {
updatePaneLayout(
transform = { layout ->
layout.copy(
weights = layout.weights + recommendedPaneWeights(
heights = recommendedPaneHeights(
level = currentLevel,
levelCount = levels.size,
outputLineCount = outputLineCount,
screenHeightDp = screenHeightDp,
suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
visibleHint = visibleHint,
),
)
)
},
persist = persist,
)
}
fun resetCurrentLevel(message: String = "Level reset.") {
val startedAt = System.nanoTime()
val resetLevelId = currentLevel.id
AppLog.d("GitHugApp", "Reset requested level=$resetLevelId")
val updatedCompletedLevels = completedLevels - resetLevelId
completedLevels = updatedCompletedLevels
scope.launch { persistProgress(updatedCompletedLevels, resetLevelId) }
repo = runtime.prepareLevel(currentLevel)
clearCommandInput()
suppressedImeEcho = null
output = listOf(message)
hintIndex = 0
activeExerciseDetail = null
visibleHint = null
historyIndex = -1
historyDraft = ""
editorState = null
gitMessageEditorState = null
manPageState = null
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(listOf(message)))
AppLog.d(
"GitHugApp",
"Reset finished level=$resetLevelId durationMs=${elapsedMillisSince(startedAt)} repo=${repo.diagnosticSnapshot()}",
)
}
fun loadLevel(
index: Int,
message: List<String> = listOf("Loaded level: ${levels[index].title}"),
keepSuppressedImeEcho: Boolean = false,
prepareInBackground: Boolean = false,
) {
val startedAt = System.nanoTime()
val level = levels[index]
val requestId = levelLoadRequestId + 1
levelLoadRequestId = requestId
AppLog.d("GitHugApp", "Loading level index=$index id=${level.id} title=${level.title} background=$prepareInBackground")
currentLevelIndex = index
output = if (prepareInBackground) listOf("Preparing level: ${level.title}") else message
clearCommandInput(recreateField = true)
if (!keepSuppressedImeEcho) {
suppressedImeEcho = null
}
hintIndex = 0
activeExerciseDetail = null
visibleHint = null
historyIndex = -1
historyDraft = ""
editorState = null
gitMessageEditorState = null
manPageState = null
if (prepareInBackground) {
isPreparingLevel = true
repo = RepoState()
AppLog.d(
"GitHugApp",
"Level load state updated index=$index id=${level.id} requestId=$requestId durationMs=${elapsedMillisSince(startedAt)} preparing=true",
)
scope.launch {
val prepareStartedAt = System.nanoTime()
AppLog.d("GitHugApp", "Background prepare started index=$index id=${level.id} requestId=$requestId")
val preparedRepo = withContext(Dispatchers.Default) {
runtime.prepareLevel(level)
}
val prepareMs = elapsedMillisSince(prepareStartedAt)
if (levelLoadRequestId == requestId) {
AppLog.d(
"GitHugApp",
"Background prepare applying index=$index id=${level.id} requestId=$requestId prepareMs=$prepareMs " +
"repo=${preparedRepo.diagnosticSnapshot()}",
)
repo = preparedRepo
output = message
isPreparingLevel = false
clearCommandInput(recreateField = true)
AppLog.d(
"GitHugApp",
"Background prepare applied index=$index id=${level.id} requestId=$requestId totalMs=${elapsedMillisSince(startedAt)}",
)
} else {
AppLog.d(
"GitHugApp",
"Background prepare discarded index=$index id=${level.id} requestId=$requestId activeRequestId=$levelLoadRequestId prepareMs=$prepareMs",
)
}
}
} else {
isPreparingLevel = false
val prepareStartedAt = System.nanoTime()
repo = runtime.prepareLevel(level)
AppLog.d(
"GitHugApp",
"Synchronous prepare applied index=$index id=${level.id} prepareMs=${elapsedMillisSince(prepareStartedAt)}",
)
}
paneLayout = paneLayout.copy(
weights = paneLayout.weights + recommendedPaneWeights(
heights = recommendedPaneHeights(
level = level,
levelCount = levels.size,
outputLineCount = 1,
screenHeightDp = screenHeightDp,
suggestionsVisible = false,
visibleHint = null,
),
)
)
AppLog.d(
"GitHugApp",
"Level load returned index=$index id=${level.id} requestId=$requestId durationMs=${elapsedMillisSince(startedAt)} preparing=$isPreparingLevel",
)
}
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)
val leadingCommand = beforeCursor.trimStart().substringBefore(' ')
val isCdCompletion = leadingCommand == "cd"
if (token.isBlank() && !isCdCompletion) return
val candidates = runtime.completionCandidates(currentLevel, repo, directoriesOnly = isCdCompletion)
val matches = contextualCompletionCandidates(
candidates = candidates,
commandBeforeToken = beforeCursor.substring(0, tokenStart),
token = token,
directoriesOnly = isCdCompletion,
)
if (matches.isEmpty()) return
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
if (replacement == token && matches.size > 1) {
val newOutput = output + "completion> ${matches.joinToString(" ")}"
output = newOutput
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
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 startedAt = System.nanoTime()
val levelForResult = currentLevel
val validationBlockedByEditor = editorState != null || gitMessageEditorState != null || newRepo.nativeGitSession != null
val solvedAfterCommand = if (validationBlockedByEditor) {
false
} else {
levelForResult.validator(newRepo, raw)
}
val wasAlreadyCompleted = currentLevel.id in completedLevels
AppLog.d(
"GitHugApp",
"Command='$raw' level=${levelForResult.id} solved=$solvedAfterCommand validationBlockedByEditor=$validationBlockedByEditor alreadyCompleted=$wasAlreadyCompleted " +
"completedBefore=${completedLevels.sorted()} outputLineCount=${lines.size} repo=${newRepo.diagnosticSnapshot()}",
)
val newOutput = buildList {
addAll(output)
if (echoCommand) {
add("$ $raw")
}
addAll(lines)
if (solvedAfterCommand && !wasAlreadyCompleted) {
add("")
add("👍 Level solved: ${levelForResult.title}")
add("Nice. ${completedLevels.size + 1}/${levels.size} levels complete.")
}
}
if (solvedAfterCommand && !wasAlreadyCompleted) {
val updatedCompletedLevels = completedLevels + levelForResult.id
completedLevels = updatedCompletedLevels
solvedCelebrationTitle = levelForResult.title
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, message = emptyList(), keepSuppressedImeEcho = true)
} else {
AppLog.d("GitHugApp", "All levels completed")
scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) }
repo = newRepo
val completedOutput = newOutput + listOf("🏁 All Githug levels completed.")
output = completedOutput
clearCommandInput()
suppressedImeEcho = null
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(completedOutput))
}
} else {
AppLog.d("GitHugApp", "Staying on level=${levelForResult.id}")
repo = newRepo
output = newOutput
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
}
AppLog.d(
"GitHugApp",
"Command result applied level=${levelForResult.id} solved=$solvedAfterCommand durationMs=${elapsedMillisSince(startedAt)}",
)
}
fun openEditor(invocation: VisualEditorInvocation) {
val path = invocation.path.orEmpty()
val (content, lines) = if (path.isBlank()) {
"" to emptyList()
} else {
runtime.readEditorFile(currentLevel, repo, path)
}
val newOutput = buildList {
addAll(output)
add("$ ${invocation.command}")
addAll(lines)
if (lines.isEmpty()) {
add("Opened ${invocation.editor} editor${if (path.isBlank()) "" else " for $path"}")
}
}
output = newOutput
if (lines.isEmpty()) {
editorState = TextEditorState(
editor = invocation.editor,
originalCommand = invocation.command,
path = path,
content = content,
saveAsPath = path,
)
}
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
}
fun openManPage(invocation: GitHelpInvocation) {
val content = runtime.gitManPage(currentLevel, repo, invocation.topic)
val newOutput = buildList {
addAll(output)
add("$ ${invocation.command}")
add("Opened git help viewer for ${invocation.topic}")
}
output = newOutput
manPageState = ManPageState(topic = invocation.topic, content = content)
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
}
fun openGitMessageEditor(invocation: GitEditorInvocation) {
val initialContent = runtime.gitEditorInitialContent(currentLevel, repo, invocation)
val openedMessage = when (invocation.kind) {
GitEditorCommandKind.REBASE_TODO -> "Opened Git rebase editor"
else -> "Opened Git message editor"
}
val newOutput = buildList {
addAll(output)
add("$ ${invocation.command}")
add(openedMessage)
}
output = newOutput
gitMessageEditorState = GitMessageEditorState(
invocation = invocation.copy(initialContent = initialContent),
content = initialContent,
)
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
}
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 saveGitMessageEditor() {
val state = gitMessageEditorState ?: return
val result = runtime.executeGitEditorCommandWithResult(
level = currentLevel,
currentRepo = repo,
invocation = state.invocation,
message = state.content,
)
gitMessageEditorState = result.nextEditor?.let { nextEditor ->
GitMessageEditorState(
invocation = nextEditor.invocation,
content = nextEditor.content,
)
}
applyCommandResult(state.invocation.command, result.repo, result.outputLines, echoCommand = false)
}
fun runCommand() {
val startedAt = System.nanoTime()
val submittedText = commandInput.text
val raw = submittedText.trim()
if (raw.isBlank()) return
if (isPreparingLevel) {
AppLog.d("GitHugApp", "Command blocked while preparing level=${currentLevel.id} raw='$raw'")
output = output + "Still preparing ${currentLevel.title}. Try again in a moment."
clearCommandInput(recreateField = true)
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(output))
return
}
val submittedLevelId = currentLevel.id
val isInteractiveInput = repo.nativeGitSession != null
AppLog.d(
"GitHugApp",
"Command submitted level=$submittedLevelId raw='$raw' interactive=$isInteractiveInput repo=${repo.diagnosticSnapshot()}",
)
showHelpOverlay = false
showTerminalInputHint = false
showExerciseDescriptionHint = false
suppressedImeEcho = submittedText
clearCommandInput(recreateField = true)
if (!isInteractiveInput && 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 gitEditorInvocation = parseGitEditorInvocation(raw)
if (gitEditorInvocation != null) {
openGitMessageEditor(gitEditorInvocation)
return
}
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
applyCommandResult(raw, newRepo, lines, echoCommand = !isInteractiveInput)
AppLog.d("GitHugApp", "Command handling finished level=$submittedLevelId raw='$raw' durationMs=${elapsedMillisSince(startedAt)}")
}
fun showCommandHelp() {
val newOutput = output + runtime.commandReferenceLines()
output = newOutput
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
}
fun registerHeaderTap() {
val now = SystemClock.elapsedRealtime()
val recentTaps = (headerTapTimes + now).filter { tapTime -> now - tapTime <= 15_000L }
if (recentTaps.size >= 7) {
headerTapTimes = emptyList()
showDiagnosticDialog = true
} else {
headerTapTimes = recentTaps
}
}
Scaffold(
containerColor = appBackground,
contentWindowInsets = WindowInsets(0.dp),
) { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding),
color = appBackground,
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.background(appBackground)
.statusBarsPadding()
.verticalScroll(screenScrollState)
.padding(
start = 10.dp,
top = 8.dp,
end = 10.dp,
bottom = 8.dp + bottomSystemPadding,
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FixedHeader(onTap = { registerHeaderTap() })
Box(
modifier = Modifier.fillMaxWidth(),
) {
PaneWorkspace(
modifier = Modifier.fillMaxWidth(),
paneLayout = paneLayout,
diagnosticSkinEnabled = diagnosticSkinEnabled,
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, prepareInBackground = true)
}
},
visualContent = { VisualPane(repo) },
exerciseContent = {
ExercisePane(
level = currentLevel,
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
showDescriptionHint = false,
onBoundsChanged = { exerciseBounds = it },
onToggleSuggestions = {
showExerciseDescriptionHint = false
showHelpOverlay = false
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
visibleHint = null
}
applyRecommendedPaneWeights(persist = false)
},
onHint = {
showExerciseDescriptionHint = false
showHelpOverlay = false
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 = {
showExerciseDescriptionHint = false
showHelpOverlay = false
resetCurrentLevel()
},
)
},
terminalContent = {
TerminalPane(
output = output,
inputFieldVersion = inputFieldVersion,
commandInput = commandInput,
showInputHint = false,
onInputHintDismiss = {
showTerminalInputHint = false
showHelpOverlay = false
},
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
if (it.text.isNotEmpty()) {
showTerminalInputHint = false
showExerciseDescriptionHint = false
showHelpOverlay = false
}
commandInput = it
},
onRun = { runCommand() },
onTab = { tabComplete() },
onHelp = { showCommandHelp() },
onCursorLeft = { moveCursor(-1) },
onCursorRight = { moveCursor(1) },
onHistoryUp = { historyUp() },
onHistoryDown = { historyDown() },
onPromptBoundsChanged = { promptBounds = it },
)
},
)
}
}
if (showHelpOverlay) {
HelpCalloutOverlay(
showHelpOnStart = showHelpOnStart,
onShowHelpOnStartChange = { showHelpOnStart = it },
onOk = {
showHelpOverlay = false
scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) }
},
onClose = { showHelpOverlay = false },
exerciseBounds = exerciseBounds,
promptBounds = promptBounds,
)
}
}
}
}
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() },
)
}
gitMessageEditorState?.let { state ->
GitMessageEditorDialog(
state = state,
onContentChange = { gitMessageEditorState = state.copy(content = it) },
onClose = {
output = output + "Git editor closed without saving"
gitMessageEditorState = null
},
onSave = { saveGitMessageEditor() },
)
}
manPageState?.let { state ->
ManPageDialog(
state = state,
onClose = { manPageState = null },
)
}
if (showDiagnosticDialog) {
DiagnosticSkinDialog(
diagnosticSkinEnabled = diagnosticSkinEnabled,
onDiagnosticSkinEnabledChange = { diagnosticSkinEnabled = it },
onDismiss = { showDiagnosticDialog = false },
)
}
solvedCelebrationTitle?.let { title ->
SolvedCelebrationOverlay(title = title)
}
}
}