Auto-commit after successful build: update app gameplay/UI, improve build setup
Changed files:\napp/build.gradle.kts app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt app/src/main/res/drawable/githug_android_logo.png artwork/GithugAndroidLogo.png
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.kawomi.githugandroid"
|
applicationId = "com.kawomi.githugandroid"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 16
|
versionCode = 17
|
||||||
versionName = "0.1.15"
|
versionName = "0.1.16"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
package com.kawomi.githugandroid
|
package com.kawomi.githugandroid
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
@@ -12,39 +31,51 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.TextRange
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
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.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 AppBackground = Color(0xFF000000)
|
||||||
private val PanelPrimary = Color(0xFF121212)
|
private val PanelPrimary = Color(0xFF121212)
|
||||||
@@ -71,15 +102,102 @@ private val GitHugColorScheme = darkColorScheme(
|
|||||||
outline = TextMuted,
|
outline = TextMuted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private enum class PaneId(val title: String) {
|
||||||
|
LEVELS("Levels"),
|
||||||
|
VISUAL("Visual"),
|
||||||
|
EXERCISE("Exercise"),
|
||||||
|
TERMINAL("Terminal"),
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PaneLayout(
|
||||||
|
val order: List<PaneId>,
|
||||||
|
val collapsed: Set<PaneId>,
|
||||||
|
val weights: Map<PaneId, Float>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
@Composable
|
||||||
fun GitHugApp() {
|
fun GitHugApp() {
|
||||||
MaterialTheme(colorScheme = GitHugColorScheme) {
|
MaterialTheme(colorScheme = GitHugColorScheme) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val levels = remember { sampleLevels() }
|
val levels = remember { sampleLevels() }
|
||||||
val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) }
|
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 currentLevelIndex by remember { mutableStateOf(0) }
|
||||||
var mode by remember { mutableStateOf(PlayMode.CLI_ONLY) }
|
|
||||||
var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) }
|
var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) }
|
||||||
var commandInput by remember { mutableStateOf(TextFieldValue("")) }
|
var commandInput by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
var inputFieldVersion by remember { mutableStateOf(0) }
|
var inputFieldVersion by remember { mutableStateOf(0) }
|
||||||
@@ -92,8 +210,14 @@ fun GitHugApp() {
|
|||||||
var historyDraft by remember { mutableStateOf("") }
|
var historyDraft by remember { mutableStateOf("") }
|
||||||
val currentLevel = levels[currentLevelIndex]
|
val currentLevel = levels[currentLevelIndex]
|
||||||
|
|
||||||
LaunchedEffect(currentLevelIndex) {
|
LaunchedEffect(persistedPaneLayout) {
|
||||||
screenScrollState.scrollTo(0)
|
paneLayout = persistedPaneLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePaneLayout(transform: (PaneLayout) -> PaneLayout) {
|
||||||
|
val updated = transform(paneLayout)
|
||||||
|
paneLayout = updated
|
||||||
|
scope.launch { paneLayoutStore.save(updated) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearCommandInput(recreateField: Boolean = false) {
|
fun clearCommandInput(recreateField: Boolean = false) {
|
||||||
@@ -113,6 +237,17 @@ fun GitHugApp() {
|
|||||||
historyDraft = ""
|
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) {
|
fun setCommandText(text: String) {
|
||||||
commandInput = TextFieldValue(text = text, selection = TextRange(text.length))
|
commandInput = TextFieldValue(text = text, selection = TextRange(text.length))
|
||||||
}
|
}
|
||||||
@@ -194,14 +329,7 @@ fun GitHugApp() {
|
|||||||
completedLevels = completedLevels + currentLevel.id
|
completedLevels = completedLevels + currentLevel.id
|
||||||
val hasNextLevel = currentLevelIndex < levels.lastIndex
|
val hasNextLevel = currentLevelIndex < levels.lastIndex
|
||||||
if (hasNextLevel) {
|
if (hasNextLevel) {
|
||||||
val nextLevelIndex = currentLevelIndex + 1
|
loadLevel(currentLevelIndex + 1)
|
||||||
val nextLevel = levels[nextLevelIndex]
|
|
||||||
currentLevelIndex = nextLevelIndex
|
|
||||||
repo = runtime.prepareLevel(nextLevel)
|
|
||||||
output = listOf("Loaded level: ${nextLevel.title}")
|
|
||||||
clearCommandInput()
|
|
||||||
suppressedImeEcho = null
|
|
||||||
hintIndex = 0
|
|
||||||
} else {
|
} else {
|
||||||
repo = newRepo
|
repo = newRepo
|
||||||
output = listOf("🏁 All available MVP levels completed.")
|
output = listOf("🏁 All available MVP levels completed.")
|
||||||
@@ -218,32 +346,42 @@ fun GitHugApp() {
|
|||||||
output = output + runtime.commandReferenceLines()
|
output = output + runtime.commandReferenceLines()
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold { padding ->
|
Scaffold(containerColor = AppBackground) { padding ->
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding),
|
||||||
|
color = AppBackground,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(AppBackground)
|
.background(AppBackground)
|
||||||
.verticalScroll(screenScrollState)
|
.padding(12.dp),
|
||||||
.padding(16.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
) {
|
||||||
Header(levels, currentLevelIndex, completedLevels) { index ->
|
FixedHeader()
|
||||||
currentLevelIndex = index
|
PaneWorkspace(
|
||||||
repo = runtime.prepareLevel(levels[index])
|
paneLayout = paneLayout,
|
||||||
output = listOf("Loaded level: ${levels[index].title}")
|
onMovePane = { paneId, delta ->
|
||||||
clearCommandInput()
|
updatePaneLayout { layout -> movePane(layout, paneId, delta) }
|
||||||
suppressedImeEcho = null
|
},
|
||||||
hintIndex = 0
|
onTogglePane = { paneId ->
|
||||||
historyIndex = -1
|
updatePaneLayout { layout ->
|
||||||
historyDraft = ""
|
val collapsed = layout.collapsed.toMutableSet()
|
||||||
|
if (!collapsed.add(paneId)) collapsed.remove(paneId)
|
||||||
|
layout.copy(collapsed = collapsed)
|
||||||
}
|
}
|
||||||
ModeBar(mode = mode, onModeSelected = { mode = it })
|
},
|
||||||
LevelCard(
|
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,
|
level = currentLevel,
|
||||||
onHint = {
|
onHint = {
|
||||||
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
|
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
|
||||||
@@ -252,10 +390,9 @@ fun GitHugApp() {
|
|||||||
},
|
},
|
||||||
onReset = { resetCurrentLevel() },
|
onReset = { resetCurrentLevel() },
|
||||||
)
|
)
|
||||||
if (mode == PlayMode.VISUAL) {
|
},
|
||||||
VisualizationPanel(repo)
|
terminalContent = {
|
||||||
}
|
TerminalPane(
|
||||||
TerminalPanel(
|
|
||||||
output = output,
|
output = output,
|
||||||
inputFieldVersion = inputFieldVersion,
|
inputFieldVersion = inputFieldVersion,
|
||||||
commandInput = commandInput,
|
commandInput = commandInput,
|
||||||
@@ -264,7 +401,7 @@ fun GitHugApp() {
|
|||||||
if (blockedEcho != null && commandInput.text.isEmpty()) {
|
if (blockedEcho != null && commandInput.text.isEmpty()) {
|
||||||
val blockedTrimmed = blockedEcho.trim()
|
val blockedTrimmed = blockedEcho.trim()
|
||||||
if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) {
|
if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) {
|
||||||
return@TerminalPanel
|
return@TerminalPane
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
suppressedImeEcho = null
|
suppressedImeEcho = null
|
||||||
@@ -277,9 +414,8 @@ fun GitHugApp() {
|
|||||||
onCursorRight = { moveCursor(1) },
|
onCursorRight = { moveCursor(1) },
|
||||||
onHistoryUp = { historyUp() },
|
onHistoryUp = { historyUp() },
|
||||||
onHistoryDown = { historyDown() },
|
onHistoryDown = { historyDown() },
|
||||||
modifier = Modifier
|
)
|
||||||
.fillMaxWidth()
|
},
|
||||||
.heightIn(min = 260.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,58 +424,346 @@ fun GitHugApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Header(levels: List<Level>, currentLevelIndex: Int, completedLevels: Set<String>, onSelect: (Int) -> Unit) {
|
private fun FixedHeader() {
|
||||||
Card(colors = CardDefaults.cardColors(containerColor = PanelPrimary)) {
|
Card(
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
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("GitHug Android", style = MaterialTheme.typography.headlineSmall, color = TextPrimary)
|
||||||
Text("CLI-first Git learning on Android, with optional repository visualization.", color = TextSecondary)
|
Text("Customizable panes for Git learning on phone and tablet.", color = TextSecondary)
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
.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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
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<Level>,
|
||||||
|
currentLevelIndex: Int,
|
||||||
|
completedLevels: Set<String>,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
levels.forEachIndexed { index, level ->
|
levels.forEachIndexed { index, level ->
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (index == currentLevelIndex) PanelTertiary else PanelPrimary,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { onSelect(index) },
|
onClick = { onSelect(index) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = ButtonDefaults.textButtonColors(
|
colors = ButtonDefaults.textButtonColors(
|
||||||
contentColor = if (index == currentLevelIndex) Accent else TextSecondary
|
contentColor = if (index == currentLevelIndex) Accent else TextSecondary,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = buildString {
|
text = buildString {
|
||||||
append(if (level.id in completedLevels) "✓ " else "• ")
|
append(if (level.id in completedLevels) "✓ " else "• ")
|
||||||
append(level.title)
|
append(level.title)
|
||||||
}
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ModeBar(
|
private fun VisualPane(repo: RepoState) {
|
||||||
mode: PlayMode,
|
val configuration = LocalConfiguration.current
|
||||||
onModeSelected: (PlayMode) -> Unit,
|
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 ExercisePane(
|
||||||
|
level: Level,
|
||||||
|
onHint: () -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(
|
||||||
PlayMode.entries.forEach {
|
modifier = Modifier
|
||||||
FilterChip(
|
.fillMaxSize()
|
||||||
selected = mode == it,
|
.verticalScroll(rememberScrollState()),
|
||||||
onClick = { onModeSelected(it) },
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
label = { Text(it.label) },
|
) {
|
||||||
colors = FilterChipDefaults.filterChipColors(
|
|
||||||
selectedContainerColor = Accent,
|
|
||||||
selectedLabelColor = Color.Black,
|
|
||||||
containerColor = PanelSecondary,
|
|
||||||
labelColor = TextPrimary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary)
|
||||||
Text(level.description, color = TextSecondary)
|
Text(level.description, color = TextSecondary)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -347,44 +771,57 @@ private fun LevelCard(level: Level, onHint: () -> Unit, onReset: () -> Unit) {
|
|||||||
onClick = onHint,
|
onClick = onHint,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Accent,
|
containerColor = Accent,
|
||||||
contentColor = Color.Black
|
contentColor = Color.Black,
|
||||||
)
|
),
|
||||||
) { Text("Hint") }
|
) {
|
||||||
|
Text("Hint")
|
||||||
|
}
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onReset,
|
onClick = onReset,
|
||||||
colors = ButtonDefaults.textButtonColors(contentColor = Accent)
|
colors = ButtonDefaults.textButtonColors(contentColor = Accent),
|
||||||
) { Text("Reset level") }
|
) {
|
||||||
|
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
|
@Composable
|
||||||
private fun VisualizationPanel(repo: RepoState) {
|
private fun InfoPaneCard(
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
title: String,
|
||||||
InfoCard("Workspace", repo.files.joinToString("\n") {
|
content: String,
|
||||||
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
|
modifier: Modifier = Modifier,
|
||||||
}.ifBlank { "(empty)" }, Modifier.weight(1f))
|
) {
|
||||||
InfoCard("Branches", buildString {
|
Card(
|
||||||
appendLine("HEAD -> ${repo.headBranch}")
|
modifier = modifier.fillMaxHeight(),
|
||||||
repo.branches.forEach { (name, _) -> appendLine(name) }
|
colors = CardDefaults.cardColors(containerColor = PanelPrimary),
|
||||||
}.trim(), Modifier.weight(1f))
|
shape = RoundedCornerShape(14.dp),
|
||||||
InfoCard("Commits", repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" }, Modifier.weight(1f))
|
) {
|
||||||
}
|
Column(
|
||||||
}
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
@Composable
|
.padding(12.dp),
|
||||||
private fun InfoCard(title: String, content: String, modifier: Modifier = Modifier) {
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = PanelSecondary), shape = RoundedCornerShape(16.dp)) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text(title, color = TextPrimary, fontWeight = FontWeight.Bold)
|
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
|
@Composable
|
||||||
private fun TerminalPanel(
|
private fun TerminalPane(
|
||||||
output: List<String>,
|
output: List<String>,
|
||||||
inputFieldVersion: Int,
|
inputFieldVersion: Int,
|
||||||
commandInput: TextFieldValue,
|
commandInput: TextFieldValue,
|
||||||
@@ -396,11 +833,13 @@ private fun TerminalPanel(
|
|||||||
onCursorRight: () -> Unit,
|
onCursorRight: () -> Unit,
|
||||||
onHistoryUp: () -> Unit,
|
onHistoryUp: () -> Unit,
|
||||||
onHistoryDown: () -> Unit,
|
onHistoryDown: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val horizontalScroll = rememberScrollState()
|
||||||
|
val outputVerticalScroll = rememberScrollState()
|
||||||
|
val terminalMinWidth = 640.dp
|
||||||
|
|
||||||
LaunchedEffect(inputFieldVersion) {
|
LaunchedEffect(inputFieldVersion) {
|
||||||
if (inputFieldVersion == 0) return@LaunchedEffect
|
if (inputFieldVersion == 0) return@LaunchedEffect
|
||||||
@@ -415,17 +854,38 @@ private fun TerminalPanel(
|
|||||||
onRun()
|
onRun()
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = TerminalBackground)) {
|
Box(
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
modifier = Modifier
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
.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)
|
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 ->
|
output.forEach { line ->
|
||||||
Text(
|
Text(
|
||||||
text = line,
|
text = line,
|
||||||
color = if (line.startsWith("✔") || line.startsWith("🏁")) Success else TextSecondary,
|
color = if (line.startsWith("✔") || line.startsWith("🏁")) Success else TextSecondary,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
SpecialKeyBar(
|
SpecialKeyBar(
|
||||||
onTab = onTab,
|
onTab = onTab,
|
||||||
onHelp = onHelp,
|
onHelp = onHelp,
|
||||||
@@ -439,13 +899,14 @@ private fun TerminalPanel(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(PanelPrimary, RoundedCornerShape(8.dp))
|
.background(PanelPrimary, RoundedCornerShape(8.dp))
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "$",
|
text = "$",
|
||||||
color = Accent,
|
color = Accent,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
key(inputFieldVersion) {
|
key(inputFieldVersion) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
@@ -462,13 +923,12 @@ private fun TerminalPanel(
|
|||||||
keyboardType = KeyboardType.Ascii,
|
keyboardType = KeyboardType.Ascii,
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(onDone = { submitCommand() })
|
keyboardActions = KeyboardActions(onDone = { submitCommand() }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -482,7 +942,7 @@ private fun SpecialKeyBar(
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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 = onTab, modifier = Modifier.width(56.dp), fontFamily = FontFamily.Default)
|
||||||
TerminalKeyButton(label = "?", onClick = onHelp, modifier = Modifier.width(40.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),
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = PanelSecondary,
|
containerColor = PanelSecondary,
|
||||||
contentColor = TextPrimary
|
contentColor = TextPrimary,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
fontFamily = fontFamily,
|
fontFamily = fontFamily,
|
||||||
fontWeight = FontWeight.Bold,
|
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>): String {
|
private fun commonPrefix(values: List<String>): String {
|
||||||
if (values.isEmpty()) return ""
|
if (values.isEmpty()) return ""
|
||||||
var prefix = values.first()
|
var prefix = values.first()
|
||||||
|
|||||||
BIN
app/src/main/res/drawable/githug_android_logo.png
Normal file
BIN
app/src/main/res/drawable/githug_android_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
BIN
artwork/GithugAndroidLogo.png
Normal file
BIN
artwork/GithugAndroidLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
Reference in New Issue
Block a user