diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c506368..f080896 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 = 16 - versionName = "0.1.15" + versionCode = 17 + versionName = "0.1.16" 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 5f92ab6..25e97e6 100644 --- a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt +++ b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt @@ -1,7 +1,26 @@ package com.kawomi.githugandroid +import android.content.Context +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +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 @@ -12,39 +31,51 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults 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.LaunchedEffect 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.runtime.key +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.SolidColor 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.TextStyle import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp 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) @@ -71,15 +102,102 @@ private val GitHugColorScheme = darkColorScheme( outline = TextMuted, ) +private enum class PaneId(val title: String) { + LEVELS("Levels"), + VISUAL("Visual"), + EXERCISE("Exercise"), + TERMINAL("Terminal"), +} + +private data class PaneLayout( + val order: List, + val collapsed: Set, + val weights: Map, +) + +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 screenScrollState = rememberScrollState() + 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 mode by remember { mutableStateOf(PlayMode.CLI_ONLY) } var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) } var commandInput by remember { mutableStateOf(TextFieldValue("")) } var inputFieldVersion by remember { mutableStateOf(0) } @@ -92,8 +210,14 @@ fun GitHugApp() { var historyDraft by remember { mutableStateOf("") } val currentLevel = levels[currentLevelIndex] - LaunchedEffect(currentLevelIndex) { - screenScrollState.scrollTo(0) + 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) { @@ -113,6 +237,17 @@ fun GitHugApp() { 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)) } @@ -194,14 +329,7 @@ fun GitHugApp() { completedLevels = completedLevels + currentLevel.id val hasNextLevel = currentLevelIndex < levels.lastIndex if (hasNextLevel) { - val nextLevelIndex = currentLevelIndex + 1 - val nextLevel = levels[nextLevelIndex] - currentLevelIndex = nextLevelIndex - repo = runtime.prepareLevel(nextLevel) - output = listOf("Loaded level: ${nextLevel.title}") - clearCommandInput() - suppressedImeEcho = null - hintIndex = 0 + loadLevel(currentLevelIndex + 1) } else { repo = newRepo output = listOf("🏁 All available MVP levels completed.") @@ -218,68 +346,168 @@ fun GitHugApp() { output = output + runtime.commandReferenceLines() } - Scaffold { padding -> + Scaffold(containerColor = AppBackground) { padding -> Surface( modifier = Modifier .fillMaxSize() - .padding(padding) + .padding(padding), + color = AppBackground, ) { Column( modifier = Modifier .fillMaxSize() .background(AppBackground) - .verticalScroll(screenScrollState) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Header(levels, currentLevelIndex, completedLevels) { index -> - currentLevelIndex = index - repo = runtime.prepareLevel(levels[index]) - output = listOf("Loaded level: ${levels[index].title}") - clearCommandInput() - suppressedImeEcho = null - hintIndex = 0 - historyIndex = -1 - historyDraft = "" - } - ModeBar(mode = mode, onModeSelected = { mode = it }) - LevelCard( - 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) + FixedHeader() + PaneWorkspace( + paneLayout = paneLayout, + onMovePane = { paneId, delta -> + updatePaneLayout { layout -> movePane(layout, paneId, delta) } }, - onReset = { resetCurrentLevel() }, - ) - if (mode == PlayMode.VISUAL) { - VisualizationPanel(repo) - } - TerminalPanel( - 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@TerminalPanel - } + onTogglePane = { paneId -> + updatePaneLayout { layout -> + val collapsed = layout.collapsed.toMutableSet() + if (!collapsed.add(paneId)) collapsed.remove(paneId) + layout.copy(collapsed = collapsed) } - suppressedImeEcho = null - commandInput = it }, - onRun = { runCommand() }, - onTab = { tabComplete() }, - onHelp = { showCommandHelp() }, - onCursorLeft = { moveCursor(-1) }, - onCursorRight = { moveCursor(1) }, - onHistoryUp = { historyUp() }, - onHistoryDown = { historyDown() }, + 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() - .heightIn(min = 260.dp) + .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) + }, ) } } @@ -288,26 +516,177 @@ fun GitHugApp() { } @Composable -private fun Header(levels: List, currentLevelIndex: Int, completedLevels: Set, onSelect: (Int) -> Unit) { - Card(colors = CardDefaults.cardColors(containerColor = PanelPrimary)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("GitHug Android", style = MaterialTheme.typography.headlineSmall, color = TextPrimary) - Text("CLI-first Git learning on Android, with optional repository visualization.", color = TextSecondary) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - levels.forEachIndexed { index, level -> - TextButton( - onClick = { onSelect(index) }, - colors = ButtonDefaults.textButtonColors( - contentColor = if (index == currentLevelIndex) Accent else TextSecondary - ) - ) { - Text( - text = buildString { - append(if (level.id in completedLevels) "✓ " else "• ") - append(level.title) - } - ) - } +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, + currentLevelIndex: Int, + completedLevels: Set, + onSelect: (Int) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .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(), + ) } } } @@ -315,76 +694,134 @@ private fun Header(levels: List, currentLevelIndex: Int, completedLevels: } @Composable -private fun ModeBar( - mode: PlayMode, - onModeSelected: (PlayMode) -> Unit, -) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - PlayMode.entries.forEach { - FilterChip( - selected = mode == it, - onClick = { onModeSelected(it) }, - label = { Text(it.label) }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = Accent, - selectedLabelColor = Color.Black, - containerColor = PanelSecondary, - labelColor = TextPrimary - ) +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 + .fillMaxSize() + .verticalScroll(rememberScrollState()), + 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 LevelCard(level: Level, onHint: () -> Unit, onReset: () -> Unit) { - Card(colors = CardDefaults.cardColors(containerColor = PanelSecondary)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.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") } +private fun ExercisePane( + level: Level, + onHint: () -> Unit, + onReset: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .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 VisualizationPanel(repo: RepoState) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - InfoCard("Workspace", repo.files.joinToString("\n") { - "${if (it.staged) "[staged]" else "[file] "} ${it.name}" - }.ifBlank { "(empty)" }, Modifier.weight(1f)) - InfoCard("Branches", buildString { - appendLine("HEAD -> ${repo.headBranch}") - repo.branches.forEach { (name, _) -> appendLine(name) } - }.trim(), Modifier.weight(1f)) - InfoCard("Commits", repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" }, Modifier.weight(1f)) - } -} - -@Composable -private fun InfoCard(title: String, content: String, modifier: Modifier = Modifier) { - Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = PanelSecondary), shape = RoundedCornerShape(16.dp)) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { +private fun InfoPaneCard( + title: String, + content: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxHeight(), + 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) + Text( + content, + color = TextSecondary, + fontFamily = FontFamily.Monospace, + modifier = Modifier.verticalScroll(rememberScrollState()), + ) } } } @Composable -private fun TerminalPanel( +private fun TerminalPane( output: List, inputFieldVersion: Int, commandInput: TextFieldValue, @@ -396,11 +833,13 @@ private fun TerminalPanel( onCursorRight: () -> Unit, onHistoryUp: () -> Unit, onHistoryDown: () -> Unit, - modifier: Modifier = Modifier, ) { 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 @@ -415,58 +854,79 @@ private fun TerminalPanel( onRun() } - Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = TerminalBackground)) { - Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Terminal", color = TextPrimary, fontWeight = FontWeight.Bold) - 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) - ) { - 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() }) + 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() }), + ) + } + } } } } @@ -482,7 +942,7 @@ private fun SpecialKeyBar( ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp) + 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) @@ -506,18 +966,54 @@ private fun TerminalKeyButton( contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), colors = ButtonDefaults.buttonColors( containerColor = PanelSecondary, - contentColor = TextPrimary - ) + contentColor = TextPrimary, + ), ) { Text( text = label, fontFamily = fontFamily, fontWeight = FontWeight.Bold, - fontSize = 13.sp + 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 { if (values.isEmpty()) return "" var prefix = values.first() @@ -527,4 +1023,4 @@ private fun commonPrefix(values: List): String { } } return prefix -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/githug_android_logo.png b/app/src/main/res/drawable/githug_android_logo.png new file mode 100644 index 0000000..1b893ac Binary files /dev/null and b/app/src/main/res/drawable/githug_android_logo.png differ diff --git a/artwork/GithugAndroidLogo.png b/artwork/GithugAndroidLogo.png new file mode 100644 index 0000000..1b893ac Binary files /dev/null and b/artwork/GithugAndroidLogo.png differ