Right after refactoring

This commit is contained in:
Joe Tretter
2026-04-23 20:24:19 -05:00
parent d38be025eb
commit 5232086590
5 changed files with 226 additions and 215 deletions

View File

@@ -1,6 +1,5 @@
package com.kawomi.githugandroid package com.kawomi.githugandroid
import android.content.Context
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
@@ -41,7 +40,6 @@ 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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -74,130 +72,8 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
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 kotlinx.coroutines.launch
import java.io.IOException
private val AppBackground = Color(0xFF000000)
private val PanelPrimary = Color(0xFF121212)
private val PanelSecondary = Color(0xFF1E1E1E)
private val PanelTertiary = Color(0xFF262626)
private val TerminalBackground = Color(0xFF050505)
private val TextPrimary = Color(0xFFFFFFFF)
private val TextSecondary = Color(0xFFE6E6E6)
private val TextMuted = Color(0xFFBDBDBD)
private val Accent = Color(0xFF00E5FF)
private val Success = Color(0xFF00FF95)
private val GitHugColorScheme = darkColorScheme(
primary = Accent,
onPrimary = Color.Black,
secondary = TextPrimary,
onSecondary = Color.Black,
background = AppBackground,
onBackground = TextPrimary,
surface = PanelPrimary,
onSurface = TextPrimary,
surfaceVariant = PanelSecondary,
onSurfaceVariant = TextSecondary,
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 enum class ExerciseDetailPanel {
HINT,
SUGGESTIONS,
}
private fun defaultPaneLayout(): PaneLayout = PaneLayout(
order = listOf(PaneId.EXERCISE, PaneId.TERMINAL, PaneId.VISUAL, PaneId.LEVELS),
collapsed = emptySet(),
weights = mapOf(
PaneId.EXERCISE to 0.95f,
PaneId.TERMINAL to 1.45f,
PaneId.VISUAL to 1.0f,
PaneId.LEVELS to 0.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() {
@@ -1174,94 +1050,3 @@ private fun TerminalKeyButton(
} }
} }
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 recommendedPaneHeights(
level: Level,
levelCount: Int,
outputLineCount: Int,
screenHeightDp: Int,
suggestionsVisible: Boolean,
visibleHint: String?,
): Map<PaneId, Int> {
val screenHeight = screenHeightDp.coerceAtLeast(560)
val levelsHeight = (56 + levelCount * 44).coerceAtLeast(160)
val descriptionLines = (level.description.length / 42).coerceAtLeast(2) + 2
val suggestionsLines = if (suggestionsVisible) level.commandSuggestions.size + 1 else 0
val hintLines = visibleHint?.let { (it.length / 42).coerceAtLeast(1) + 1 } ?: 0
val exerciseHeight = (120 + descriptionLines * 24 + suggestionsLines * 22 + hintLines * 22).coerceAtLeast(180)
val terminalHeight = (170 + outputLineCount.coerceAtLeast(1) * 20).coerceAtMost((screenHeight * 0.7f).toInt())
val visualHeight = (screenHeight * 0.24f).toInt().coerceAtLeast(170)
return mapOf(
PaneId.EXERCISE to exerciseHeight,
PaneId.TERMINAL to terminalHeight,
PaneId.VISUAL to visualHeight,
PaneId.LEVELS to levelsHeight,
)
}
private fun recommendedPaneWeights(
heights: Map<PaneId, Int>,
): Map<PaneId, Float> {
fun weightFor(height: Int): Float = (height / 180f).coerceAtLeast(0.6f)
return heights.mapValues { (_, height) -> weightFor(height) }
}
private fun recommendedWorkspaceHeight(
paneLayout: PaneLayout,
recommendedHeights: Map<PaneId, Int>,
): androidx.compose.ui.unit.Dp {
val dividerHeight = 18 * (paneLayout.order.size - 1)
val collapsedPaneHeight = 44 * paneLayout.collapsed.size
val expandedHeight = paneLayout.order
.filterNot { it in paneLayout.collapsed }
.sumOf { pane -> recommendedHeights[pane] ?: 0 }
return (expandedHeight + dividerHeight + collapsedPaneHeight).dp
}
private fun commonPrefix(values: List<String>): String {
if (values.isEmpty()) return ""
var prefix = values.first()
values.drop(1).forEach { value ->
while (!value.startsWith(prefix) && prefix.isNotEmpty()) {
prefix = prefix.dropLast(1)
}
}
return prefix
}

View File

@@ -0,0 +1,29 @@
package com.kawomi.githugandroid
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color
val AppBackground = Color(0xFF000000)
val PanelPrimary = Color(0xFF121212)
val PanelSecondary = Color(0xFF1E1E1E)
val PanelTertiary = Color(0xFF262626)
val TerminalBackground = Color(0xFF050505)
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFE6E6E6)
val TextMuted = Color(0xFFBDBDBD)
val Accent = Color(0xFF00E5FF)
val Success = Color(0xFF00FF95)
val GitHugColorScheme = darkColorScheme(
primary = Accent,
onPrimary = Color.Black,
secondary = TextPrimary,
onSecondary = Color.Black,
background = AppBackground,
onBackground = TextPrimary,
surface = PanelPrimary,
onSurface = TextPrimary,
surfaceVariant = PanelSecondary,
onSurfaceVariant = TextSecondary,
outline = TextMuted,
)

View File

@@ -0,0 +1,96 @@
package com.kawomi.githugandroid
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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)
}
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,
)
)
}
fun recommendedPaneHeights(
level: Level,
levelCount: Int,
outputLineCount: Int,
screenHeightDp: Int,
suggestionsVisible: Boolean,
visibleHint: String?,
): Map<PaneId, Int> {
val screenHeight = screenHeightDp.coerceAtLeast(560)
val levelsHeight = (56 + levelCount * 44).coerceAtLeast(160)
val descriptionLines = (level.description.length / 42).coerceAtLeast(2) + 2
val suggestionsLines = if (suggestionsVisible) level.commandSuggestions.size + 1 else 0
val hintLines = visibleHint?.let { (it.length / 42).coerceAtLeast(1) + 1 } ?: 0
val exerciseHeight = (120 + descriptionLines * 24 + suggestionsLines * 22 + hintLines * 22).coerceAtLeast(180)
val terminalHeight = (170 + outputLineCount.coerceAtLeast(1) * 20).coerceAtMost((screenHeight * 0.7f).toInt())
val visualHeight = (screenHeight * 0.24f).toInt().coerceAtLeast(170)
return mapOf(
PaneId.EXERCISE to exerciseHeight,
PaneId.TERMINAL to terminalHeight,
PaneId.VISUAL to visualHeight,
PaneId.LEVELS to levelsHeight,
)
}
fun recommendedPaneWeights(
heights: Map<PaneId, Int>,
): Map<PaneId, Float> {
fun weightFor(height: Int): Float = (height / 180f).coerceAtLeast(0.6f)
return heights.mapValues { (_, height) -> weightFor(height) }
}
fun recommendedWorkspaceHeight(
paneLayout: PaneLayout,
recommendedHeights: Map<PaneId, Int>,
): Dp {
val dividerHeight = 18 * (paneLayout.order.size - 1)
val collapsedPaneHeight = 44 * paneLayout.collapsed.size
val expandedHeight = paneLayout.order
.filterNot { it in paneLayout.collapsed }
.sumOf { pane -> recommendedHeights[pane] ?: 0 }
return (expandedHeight + dividerHeight + collapsedPaneHeight).dp
}
fun commonPrefix(values: List<String>): String {
if (values.isEmpty()) return ""
var prefix = values.first()
values.drop(1).forEach { value ->
while (!value.startsWith(prefix) && prefix.isNotEmpty()) {
prefix = prefix.dropLast(1)
}
}
return prefix
}

View File

@@ -0,0 +1,30 @@
package com.kawomi.githugandroid
enum class PaneId(val title: String) {
LEVELS("Levels"),
VISUAL("Visual"),
EXERCISE("Exercise"),
TERMINAL("Terminal"),
}
data class PaneLayout(
val order: List<PaneId>,
val collapsed: Set<PaneId>,
val weights: Map<PaneId, Float>,
)
enum class ExerciseDetailPanel {
HINT,
SUGGESTIONS,
}
fun defaultPaneLayout(): PaneLayout = PaneLayout(
order = listOf(PaneId.EXERCISE, PaneId.TERMINAL, PaneId.VISUAL, PaneId.LEVELS),
collapsed = emptySet(),
weights = mapOf(
PaneId.EXERCISE to 0.95f,
PaneId.TERMINAL to 1.45f,
PaneId.VISUAL to 1.0f,
PaneId.LEVELS to 0.8f,
),
)

View File

@@ -0,0 +1,71 @@
package com.kawomi.githugandroid
import android.content.Context
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.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
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}"
}
}
}
}