Misc Changes
This commit is contained in:
@@ -11,13 +11,21 @@ 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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -33,10 +41,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -78,6 +88,9 @@ fun GitHugApp() {
|
||||
var solvedCelebrationTitle by remember { mutableStateOf<String?>(null) }
|
||||
var showTerminalInputHint by remember { mutableStateOf(true) }
|
||||
var showExerciseDescriptionHint by remember { mutableStateOf(true) }
|
||||
var showHelpOverlay by remember { mutableStateOf(false) }
|
||||
var showHelpOnStart by remember { mutableStateOf(true) }
|
||||
var helpPreferenceInitialized by remember { mutableStateOf(false) }
|
||||
val currentLevel = levels[currentLevelIndex]
|
||||
|
||||
LaunchedEffect(solvedCelebrationTitle) {
|
||||
@@ -119,6 +132,11 @@ fun GitHugApp() {
|
||||
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",
|
||||
@@ -271,9 +289,12 @@ fun GitHugApp() {
|
||||
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 leadingCommand = beforeCursor.trimStart().substringBefore(' ')
|
||||
val isCdCompletion = leadingCommand == "cd"
|
||||
if (token.isBlank() && !isCdCompletion) return
|
||||
|
||||
val matches = repo.files.map { it.name }.sorted().filter { it.startsWith(token) }
|
||||
val candidates = if (isCdCompletion) directoryCompletionCandidates(repo) else fileCompletionCandidates(repo)
|
||||
val matches = candidates.sorted().filter { it.startsWith(token) }
|
||||
if (matches.isEmpty()) return
|
||||
|
||||
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
|
||||
@@ -413,6 +434,7 @@ fun GitHugApp() {
|
||||
val raw = submittedText.trim()
|
||||
if (raw.isBlank()) return
|
||||
|
||||
showHelpOverlay = false
|
||||
showTerminalInputHint = false
|
||||
showExerciseDescriptionHint = false
|
||||
suppressedImeEcho = submittedText
|
||||
@@ -457,93 +479,114 @@ fun GitHugApp() {
|
||||
.padding(padding),
|
||||
color = AppBackground,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppBackground)
|
||||
.verticalScroll(screenScrollState)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FixedHeader()
|
||||
PaneWorkspace(
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
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,
|
||||
showDescriptionHint = showExerciseDescriptionHint,
|
||||
onToggleSuggestions = {
|
||||
showExerciseDescriptionHint = false
|
||||
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
|
||||
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
|
||||
visibleHint = null
|
||||
}
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
},
|
||||
onHint = {
|
||||
showExerciseDescriptionHint = 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
|
||||
resetCurrentLevel()
|
||||
},
|
||||
)
|
||||
},
|
||||
terminalContent = {
|
||||
TerminalPane(
|
||||
output = output,
|
||||
inputFieldVersion = inputFieldVersion,
|
||||
commandInput = commandInput,
|
||||
showInputHint = showTerminalInputHint,
|
||||
onInputHintDismiss = { showTerminalInputHint = 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
|
||||
.fillMaxSize()
|
||||
.background(AppBackground)
|
||||
.imePadding()
|
||||
.verticalScroll(screenScrollState)
|
||||
.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,
|
||||
showDescriptionHint = false,
|
||||
onToggleSuggestions = {
|
||||
showExerciseDescriptionHint = false
|
||||
}
|
||||
commandInput = it
|
||||
},
|
||||
onRun = { runCommand() },
|
||||
onTab = { tabComplete() },
|
||||
onHelp = { showCommandHelp() },
|
||||
onCursorLeft = { moveCursor(-1) },
|
||||
onCursorRight = { moveCursor(1) },
|
||||
onHistoryUp = { historyUp() },
|
||||
onHistoryDown = { historyDown() },
|
||||
)
|
||||
},
|
||||
)
|
||||
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() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showHelpOverlay) {
|
||||
HelpCalloutOverlay(
|
||||
showHelpOnStart = showHelpOnStart,
|
||||
onShowHelpOnStartChange = { showHelpOnStart = it },
|
||||
onOk = {
|
||||
showHelpOverlay = false
|
||||
scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) }
|
||||
},
|
||||
onClose = { showHelpOverlay = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +629,135 @@ fun GitHugApp() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileCompletionCandidates(repo: RepoState): List<String> {
|
||||
return repo.files
|
||||
.filterNot { it.deleted }
|
||||
.map { it.name }
|
||||
}
|
||||
|
||||
private fun directoryCompletionCandidates(repo: RepoState): List<String> {
|
||||
return repo.files
|
||||
.filterNot { it.deleted }
|
||||
.flatMap { file ->
|
||||
val parts = file.name.split('/').dropLast(1)
|
||||
parts.indices.map { index -> parts.take(index + 1).joinToString("/") + "/" }
|
||||
}
|
||||
.distinct()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpCalloutOverlay(
|
||||
showHelpOnStart: Boolean,
|
||||
onShowHelpOnStartChange: (Boolean) -> Unit,
|
||||
onOk: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
HelpBubble(
|
||||
text = "Read the exercise description, then solve it by entering commands below.",
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
HelpBubble(
|
||||
text = "Tap the prompt to enter a command.",
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth(),
|
||||
color = PanelPrimary.copy(alpha = 0.97f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Help",
|
||||
color = TextPrimary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
TextButton(onClick = onClose) {
|
||||
Text("Close", color = Accent)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = showHelpOnStart,
|
||||
onCheckedChange = onShowHelpOnStartChange,
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = Accent,
|
||||
uncheckedColor = TextSecondary,
|
||||
checkmarkColor = AppBackground,
|
||||
),
|
||||
)
|
||||
Text("Show help on start", color = TextPrimary)
|
||||
}
|
||||
Button(
|
||||
onClick = onOk,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Accent,
|
||||
contentColor = AppBackground,
|
||||
),
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpBubble(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bubbleColor = Color(0xFFFFF1A8)
|
||||
Column(modifier = modifier.fillMaxWidth(0.86f)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(bubbleColor, RoundedCornerShape(18.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color(0xFF161000),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.padding(start = 28.dp)
|
||||
.size(width = 26.dp, height = 13.dp),
|
||||
) {
|
||||
drawPath(
|
||||
path = Path().apply {
|
||||
moveTo(0f, 0f)
|
||||
lineTo(size.width, 0f)
|
||||
lineTo(size.width * 0.25f, size.height)
|
||||
close()
|
||||
},
|
||||
color = bubbleColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SolvedCelebrationOverlay(title: String) {
|
||||
val transition = rememberInfiniteTransition(label = "solved-celebration")
|
||||
|
||||
Reference in New Issue
Block a user