diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9c77de1..19464e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.kawomi.githugandroid" minSdk = 26 targetSdk = 34 - versionCode = 26 - versionName = "0.1.25" + versionCode = 27 + versionName = "0.1.26" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt index c6c05ff..174cf64 100644 --- a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt +++ b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt @@ -19,6 +19,7 @@ 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 @@ -205,6 +206,7 @@ fun GitHugApp() { 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) } @@ -214,6 +216,7 @@ fun GitHugApp() { var suppressedImeEcho by remember { mutableStateOf(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()) } var commandHistory by remember { mutableStateOf(listOf()) } var historyIndex by remember { mutableStateOf(-1) } @@ -239,14 +242,32 @@ fun GitHugApp() { } } + 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) { @@ -256,8 +277,17 @@ fun GitHugApp() { 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) { @@ -351,6 +381,7 @@ fun GitHugApp() { } else { repo = newRepo output = newOutput + applyRecommendedPaneWeights(persist = false) } } @@ -403,6 +434,8 @@ fun GitHugApp() { 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" @@ -766,9 +799,7 @@ private fun LevelsPane( onSelect: (Int) -> Unit, ) { Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp), ) { levels.forEachIndexed { index, level -> @@ -859,13 +890,13 @@ private fun VisualPane(repo: RepoState) { @Composable private fun ExercisePane( level: Level, + suggestionsExpanded: Boolean, + onToggleSuggestions: () -> Unit, onHint: () -> Unit, onReset: () -> Unit, ) { Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary) @@ -880,6 +911,12 @@ private fun ExercisePane( ) { Text("Hint") } + TextButton( + onClick = onToggleSuggestions, + colors = ButtonDefaults.textButtonColors(contentColor = Accent), + ) { + Text("Suggestions") + } TextButton( onClick = onReset, colors = ButtonDefaults.textButtonColors(contentColor = Accent), @@ -887,7 +924,7 @@ private fun ExercisePane( Text("Reset level") } } - if (level.commandSuggestions.isNotEmpty()) { + if (suggestionsExpanded && level.commandSuggestions.isNotEmpty()) { Text("Suggestions", color = TextPrimary, fontWeight = FontWeight.Bold) level.commandSuggestions.forEach { suggestion -> Text(suggestion, color = TextSecondary, fontFamily = FontFamily.Monospace) @@ -938,6 +975,7 @@ private fun TerminalPane( onHistoryUp: () -> Unit, onHistoryDown: () -> Unit, ) { + val configuration = LocalConfiguration.current val focusRequester = remember { FocusRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() } val focusManager = LocalFocusManager.current @@ -945,16 +983,26 @@ private fun TerminalPane( 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() @@ -976,7 +1024,8 @@ private fun TerminalPane( ) { Box( modifier = Modifier - .weight(1f) + .heightIn(min = 120.dp, max = maxOutputHeight) + .height(desiredOutputHeight) .fillMaxWidth() .background(PanelPrimary, RoundedCornerShape(10.dp)) .padding(8.dp) @@ -1114,6 +1163,30 @@ private fun resizePanes( ) } +private fun recommendedPaneWeights( + level: Level, + levelCount: Int, + outputLineCount: Int, + screenHeightDp: Int, +): Map { + 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 { if (values.isEmpty()) return "" var prefix = values.first()