diff --git a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt index 189e41d..dc616d8 100644 --- a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt +++ b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt @@ -1,6 +1,5 @@ package com.kawomi.githugandroid -import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation @@ -41,7 +40,6 @@ 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.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -74,130 +72,8 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp 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.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) -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, - val collapsed: Set, - val weights: Map, -) - -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 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 { - 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, -): Map { - fun weightFor(height: Int): Float = (height / 180f).coerceAtLeast(0.6f) - - return heights.mapValues { (_, height) -> weightFor(height) } -} - -private fun recommendedWorkspaceHeight( - paneLayout: PaneLayout, - recommendedHeights: Map, -): 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 { - 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 -} diff --git a/app/src/main/java/com/kawomi/githugandroid/GitHugTheme.kt b/app/src/main/java/com/kawomi/githugandroid/GitHugTheme.kt new file mode 100644 index 0000000..cba87b2 --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/GitHugTheme.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/PaneLayoutLogic.kt b/app/src/main/java/com/kawomi/githugandroid/PaneLayoutLogic.kt new file mode 100644 index 0000000..4155f38 --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/PaneLayoutLogic.kt @@ -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 { + 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, +): Map { + fun weightFor(height: Int): Float = (height / 180f).coerceAtLeast(0.6f) + + return heights.mapValues { (_, height) -> weightFor(height) } +} + +fun recommendedWorkspaceHeight( + paneLayout: PaneLayout, + recommendedHeights: Map, +): 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 { + 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 +} \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/PaneLayoutModels.kt b/app/src/main/java/com/kawomi/githugandroid/PaneLayoutModels.kt new file mode 100644 index 0000000..dcbdb79 --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/PaneLayoutModels.kt @@ -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, + val collapsed: Set, + val weights: Map, +) + +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, + ), +) \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/PaneLayoutStore.kt b/app/src/main/java/com/kawomi/githugandroid/PaneLayoutStore.kt new file mode 100644 index 0000000..3580a93 --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/PaneLayoutStore.kt @@ -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}" + } + } + } +} \ No newline at end of file