diff --git a/android/app/src/main/java/com/jossephus/chuchu/data/repository/SettingsRepository.kt b/android/app/src/main/java/com/jossephus/chuchu/data/repository/SettingsRepository.kt index 1c8f4d1b..493f25bf 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/data/repository/SettingsRepository.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/data/repository/SettingsRepository.kt @@ -37,6 +37,9 @@ class SettingsRepository(context: Context) { private val _accessoryBarSingleRow = MutableStateFlow(prefs.getBoolean(KEY_ACCESSORY_BAR_SINGLE_ROW, false)) val accessoryBarSingleRow: StateFlow = _accessoryBarSingleRow.asStateFlow() + private val _termuxStyleAccessoryBar = MutableStateFlow(prefs.getBoolean(KEY_TERMUX_STYLE_ACCESSORY_BAR, false)) + val termuxStyleAccessoryBar: StateFlow = _termuxStyleAccessoryBar.asStateFlow() + private val _appLockEnabled = MutableStateFlow(prefs.getBoolean(KEY_APP_LOCK_ENABLED, false)) val appLockEnabled: StateFlow = _appLockEnabled.asStateFlow() @@ -93,6 +96,11 @@ class SettingsRepository(context: Context) { _accessoryBarSingleRow.value = enabled } + fun setTermuxStyleAccessoryBar(enabled: Boolean) { + prefs.edit().putBoolean(KEY_TERMUX_STYLE_ACCESSORY_BAR, enabled).apply() + _termuxStyleAccessoryBar.value = enabled + } + fun setTerminalTabMode(mode: TerminalTabMode) { prefs.edit().putString(KEY_TAB_MODE, mode.name).apply() _terminalTabMode.value = mode @@ -144,13 +152,38 @@ class SettingsRepository(context: Context) { private fun loadAccessoryLayoutIds(): List { val stored = prefs.getString(KEY_ACCESSORY_LAYOUT, null) - ?: return TerminalAccessoryLayoutStore.defaultLayoutIds() + if (stored == null) { + markScreenActionsBackfilled() + return TerminalAccessoryLayoutStore.defaultLayoutIds() + } if (stored.isBlank()) { + markScreenActionsBackfilled() return emptyList() } - return TerminalAccessoryLayoutStore.normalizeIds( + val ids = TerminalAccessoryLayoutStore.normalizeIds( stored.split(',').map(String::trim).filter(String::isNotEmpty), ) + return backfillScreenActions(ids) + } + + /** + * Runs the screen-action backfill once, then records that it happened so a user who later + * removes those keys on purpose does not get them back on the next launch. + */ + private fun backfillScreenActions(ids: List): List { + if (prefs.getBoolean(KEY_ACCESSORY_SCREEN_ACTIONS_BACKFILLED, false)) { + return ids + } + val migrated = TerminalAccessoryLayoutStore.backfillScreenActions(ids) + prefs.edit() + .putString(KEY_ACCESSORY_LAYOUT, migrated.joinToString(separator = ",")) + .putBoolean(KEY_ACCESSORY_SCREEN_ACTIONS_BACKFILLED, true) + .apply() + return migrated + } + + private fun markScreenActionsBackfilled() { + prefs.edit().putBoolean(KEY_ACCESSORY_SCREEN_ACTIONS_BACKFILLED, true).apply() } private fun loadTerminalCustomKeyGroups(): List { @@ -164,6 +197,9 @@ class SettingsRepository(context: Context) { private const val KEY_ACCESSORY_LAYOUT = "terminal_accessory_layout" private const val KEY_TERMINAL_CUSTOM_ACTIONS = "terminal_custom_actions" private const val KEY_ACCESSORY_BAR_SINGLE_ROW = "terminal_accessory_bar_single_row" + private const val KEY_TERMUX_STYLE_ACCESSORY_BAR = "terminal_termux_style_accessory_bar" + private const val KEY_ACCESSORY_SCREEN_ACTIONS_BACKFILLED = + "terminal_accessory_screen_actions_backfilled" private const val KEY_TAB_MODE = "terminal_tab_mode" private const val KEY_APP_LOCK_ENABLED = "app_lock_enabled" private const val KEY_REQUIRE_AUTH_ON_CONNECT = "require_auth_on_connect" diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/ApplicationNavController.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/ApplicationNavController.kt index 45096d71..5b91a752 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/ApplicationNavController.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/ApplicationNavController.kt @@ -122,6 +122,7 @@ fun ApplicationNavController() { val requireAuthOnConnect by settingsRepo.requireAuthOnConnect.collectAsStateWithLifecycle() val accessoryLayoutIds by settingsRepo.accessoryLayoutIds.collectAsStateWithLifecycle() val accessoryBarSingleRow by settingsRepo.accessoryBarSingleRow.collectAsStateWithLifecycle() + val termuxStyleAccessoryBar by settingsRepo.termuxStyleAccessoryBar.collectAsStateWithLifecycle() val customKeyGroups by settingsRepo.terminalCustomKeyGroups.collectAsStateWithLifecycle() val tabMode by settingsRepo.terminalTabMode.collectAsStateWithLifecycle() val localShellEnabled by settingsRepo.localShellEnabled.collectAsStateWithLifecycle() @@ -136,6 +137,7 @@ fun ApplicationNavController() { localShellEnabled = localShellEnabled, currentAccessoryLayoutIds = accessoryLayoutIds, accessoryBarSingleRow = accessoryBarSingleRow, + termuxStyleAccessoryBar = termuxStyleAccessoryBar, currentTerminalCustomKeyGroups = customKeyGroups, currentTabMode = tabMode, onTabModeChanged = settingsRepo::setTerminalTabMode, @@ -150,6 +152,7 @@ fun ApplicationNavController() { onLocalShellEnabledChanged = settingsRepo::setLocalShellEnabled, onAccessoryLayoutChanged = settingsRepo::setAccessoryLayoutIds, onAccessoryBarSingleRowChanged = settingsRepo::setAccessoryBarSingleRow, + onTermuxStyleAccessoryBarChanged = settingsRepo::setTermuxStyleAccessoryBar, currentTerminalFontSize = terminalFontSize, onTerminalFontSizeChanged = settingsRepo::setTerminalFontSize, onTerminalCustomActionsChanged = settingsRepo::setTerminalCustomKeyGroups, diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/SettingsScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/SettingsScreen.kt index c330c1cd..d40f6256 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/SettingsScreen.kt @@ -48,6 +48,7 @@ fun SettingsScreen( localShellEnabled: Boolean, currentAccessoryLayoutIds: List, accessoryBarSingleRow: Boolean, + termuxStyleAccessoryBar: Boolean, currentTerminalCustomKeyGroups: List, currentTabMode: TerminalTabMode = TerminalTabMode.Classic, onTabModeChanged: (TerminalTabMode) -> Unit = {}, @@ -62,6 +63,7 @@ fun SettingsScreen( onLocalShellEnabledChanged: (Boolean) -> Unit, onAccessoryLayoutChanged: (List) -> Unit, onAccessoryBarSingleRowChanged: (Boolean) -> Unit, + onTermuxStyleAccessoryBarChanged: (Boolean) -> Unit, currentTerminalFontSize: Float = 14f, onTerminalFontSizeChanged: (Float) -> Unit = {}, onTerminalCustomActionsChanged: (List) -> Unit, @@ -169,6 +171,8 @@ fun SettingsScreen( onEditAccessoryLayout = { showAccessoryEditor = true }, accessoryBarSingleRow = accessoryBarSingleRow, onAccessoryBarSingleRowChanged = onAccessoryBarSingleRowChanged, + termuxStyleAccessoryBar = termuxStyleAccessoryBar, + onTermuxStyleAccessoryBarChanged = onTermuxStyleAccessoryBarChanged, currentTerminalFontSize = currentTerminalFontSize, onTerminalFontSizeChanged = onTerminalFontSizeChanged, currentTerminalCustomKeyGroups = currentTerminalCustomKeyGroups, diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/TerminalAccessorySettings.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/TerminalAccessorySettings.kt index 19de7c07..616de9c5 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/TerminalAccessorySettings.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/TerminalAccessorySettings.kt @@ -78,6 +78,8 @@ internal fun TerminalSettings( onEditAccessoryLayout: () -> Unit, accessoryBarSingleRow: Boolean, onAccessoryBarSingleRowChanged: (Boolean) -> Unit, + termuxStyleAccessoryBar: Boolean, + onTermuxStyleAccessoryBarChanged: (Boolean) -> Unit, currentTerminalFontSize: Float = 14f, onTerminalFontSizeChanged: (Float) -> Unit = {}, currentTerminalCustomKeyGroups: List, @@ -331,6 +333,28 @@ internal fun TerminalSettings( ) } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + ChuText("termux-style accessory bar", style = typography.label) + ChuText( + "equal-width keys, no borders, tighter spacing", + style = typography.bodySmall, + color = colors.textMuted, + ) + } + ChuSwitch( + checked = termuxStyleAccessoryBar, + onCheckedChange = onTermuxStyleAccessoryBarChanged, + ) + } + if (selectedEntries.isEmpty()) { ChuText( "choose the accessory keys you want in the terminal bar.", @@ -348,6 +372,7 @@ internal fun TerminalSettings( modifierState = ModifierState(), onAction = {}, useSingleRow = accessoryBarSingleRow, + termuxStyle = termuxStyleAccessoryBar, modifier = Modifier.fillMaxWidth(), ) } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt index d9a1b131..aa5f95e9 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt @@ -62,11 +62,9 @@ fun CommandPalette( accessoryEntries: List, accessoryModifierState: ModifierState, onAccessoryAction: (AccessoryAction) -> Unit, - onChuchuKey: () -> Unit, chuchuKeyActive: Boolean, - onOpenFiles: () -> Unit, - onOpenSettings: () -> Unit, useSingleRowAccessoryBar: Boolean, + termuxStyleAccessoryBar: Boolean = false, onSelectTab: (String) -> Unit, onCloseTab: (String) -> Unit, onAddTab: () -> Unit, @@ -254,11 +252,9 @@ fun CommandPalette( entries = accessoryEntries, modifierState = accessoryModifierState, onAction = onAccessoryAction, - onSettings = onOpenSettings, - onChuchuKey = onChuchuKey, chuchuKeyActive = chuchuKeyActive, - onOpenFiles = onOpenFiles, useSingleRow = useSingleRowAccessoryBar, + termuxStyle = termuxStyleAccessoryBar, modifier = Modifier.align(Alignment.BottomCenter) .windowInsetsPadding(WindowInsets.safeDrawing) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index b20e7090..246c732f 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -289,6 +289,7 @@ fun TerminalScreen( val tabMode by settingsRepo.terminalTabMode.collectAsStateWithLifecycle() val currentAccessoryLayoutIds by settingsRepo.accessoryLayoutIds.collectAsStateWithLifecycle() val useSingleRowAccessoryBar by settingsRepo.accessoryBarSingleRow.collectAsStateWithLifecycle() + val useTermuxStyleAccessoryBar by settingsRepo.termuxStyleAccessoryBar.collectAsStateWithLifecycle() val currentTerminalCustomKeyGroups by settingsRepo.terminalCustomKeyGroups.collectAsStateWithLifecycle() val settingsFontSize by settingsRepo.terminalFontSize.collectAsStateWithLifecycle() @@ -826,6 +827,26 @@ fun TerminalScreen( fun dispatchAccessoryAction(action: AccessoryAction) { + when (action) { + AccessoryAction.Settings -> { + onOpenSettings() + return + } + AccessoryAction.ChuchuKey -> { + chuchuKeys.togglePrefix() + requestInputFocus() + return + } + AccessoryAction.OpenFiles -> { + if (filesSupported) { + vm.selectConnectionTab(ConnectionTab.Files) + } else { + showLocalShellFilesUnsupported() + } + return + } + else -> Unit + } if ( action is AccessoryAction.SendText && chuchuKeys.handleText(action.text) ) { @@ -1502,7 +1523,10 @@ fun TerminalScreen( } } - Spacer(modifier = Modifier.height(6.dp)) + // The termux-style bar sits flush against the terminal, so it drops the gap. + if (!(selectedTab == ConnectionTab.Terminal && useTermuxStyleAccessoryBar)) { + Spacer(modifier = Modifier.height(6.dp)) + } if (selectedTab == ConnectionTab.Terminal) { if (activeHostCount > 1 && currentHostName != null) { Row( @@ -1560,21 +1584,14 @@ fun TerminalScreen( entries = accessoryLayout, modifierState = modifierState, onAction = ::dispatchAccessoryAction, - onSettings = onOpenSettings, - onChuchuKey = { - chuchuKeys.togglePrefix() - requestInputFocus() - }, chuchuKeyActive = chuchuKeys.isPrefixActive, - onOpenFiles = { - if (filesSupported) { - vm.selectConnectionTab(ConnectionTab.Files) - } else { - showLocalShellFilesUnsupported() - } - }, useSingleRow = useSingleRowAccessoryBar, - modifier = Modifier.padding(bottom = 2.dp), + termuxStyle = useTermuxStyleAccessoryBar, + modifier = if (useTermuxStyleAccessoryBar) { + Modifier + } else { + Modifier.padding(bottom = 2.dp) + }, ) } } @@ -1585,7 +1602,27 @@ fun TerminalScreen( UploadProgressDialog(progress = uploadProgress) } if (showTabSheet) { - val paletteAccessoryAction: (AccessoryAction) -> Unit = { action -> + val paletteAccessoryAction: (AccessoryAction) -> Unit = fun(action: AccessoryAction) { + when (action) { + AccessoryAction.Settings -> { + onOpenSettings() + return + } + AccessoryAction.ChuchuKey -> { + chuchuKeys.togglePrefix() + return + } + AccessoryAction.OpenFiles -> { + if (filesSupported) { + vm.selectConnectionTab(ConnectionTab.Files) + showTabSheet = false + } else { + showLocalShellFilesUnsupported() + } + return + } + else -> Unit + } if ( !(action is AccessoryAction.SendText && chuchuKeys.handleText(action.text)) @@ -1655,18 +1692,9 @@ fun TerminalScreen( accessoryEntries = accessoryLayout, accessoryModifierState = modifierState, onAccessoryAction = paletteAccessoryAction, - onChuchuKey = { chuchuKeys.togglePrefix() }, chuchuKeyActive = chuchuKeys.isPrefixActive, - onOpenFiles = { - if (filesSupported) { - vm.selectConnectionTab(ConnectionTab.Files) - showTabSheet = false - } else { - showLocalShellFilesUnsupported() - } - }, - onOpenSettings = onOpenSettings, useSingleRowAccessoryBar = useSingleRowAccessoryBar, + termuxStyleAccessoryBar = useTermuxStyleAccessoryBar, onSelectTab = { vm.selectTab(it) showTabSheet = false diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/KeyboardAccessoryBar.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/KeyboardAccessoryBar.kt index 818dcafe..07a49cce 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/KeyboardAccessoryBar.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/KeyboardAccessoryBar.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.gestures.waitForUpOrCancellation 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.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -29,8 +31,10 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -38,6 +42,9 @@ import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -46,59 +53,105 @@ import com.jossephus.chuchu.ui.components.ChuButtonSurface import com.jossephus.chuchu.ui.components.ChuButtonVariant import com.jossephus.chuchu.ui.components.ChuText import com.jossephus.chuchu.ui.theme.ChuColors -import com.jossephus.chuchu.ui.theme.ChuSymbolsFontFamily import com.jossephus.chuchu.ui.theme.ChuTypography import kotlinx.coroutines.withTimeoutOrNull private const val INITIAL_REPEAT_DELAY_MS = 400L private const val REPEAT_INTERVAL_MS = 50L +/** Keys visible at once before the termux-style single-row bar starts scrolling. */ +private const val TERMUX_SINGLE_ROW_KEYS_PER_SCREEN = 8 + +private const val MIN_LABEL_FONT_SIZE_SP = 8f +private const val LABEL_FONT_SIZE_STEP_SP = 0.5f + +private val ButtonHeight = 30.dp +private val DefaultButtonPadding = PaddingValues(start = 10.dp, end = 10.dp, top = 3.dp, bottom = 3.dp) +private val TermuxButtonPadding = PaddingValues(horizontal = 0.dp, vertical = 2.dp) + +/** + * The accessory key bar that sits above the keyboard. + * + * The default style draws bordered, content-sized keys that wrap across up to two rows. The termux + * style instead divides the bar into equal, borderless columns so keys line up on a grid, the way + * Termux's extra-keys row does. + */ @OptIn(ExperimentalLayoutApi::class) @Composable fun KeyboardAccessoryBar( entries: List, modifierState: ModifierState, onAction: (AccessoryAction) -> Unit, - onSettings: (() -> Unit)? = null, - onChuchuKey: (() -> Unit)? = null, chuchuKeyActive: Boolean = false, - onOpenFiles: (() -> Unit)? = null, useSingleRow: Boolean = false, + termuxStyle: Boolean = false, horizontalPadding: Dp = 8.dp, verticalPadding: Dp = 6.dp, modifier: Modifier = Modifier, ) { - val buttonHeight = 30.dp - val buttonPadding = PaddingValues(start = 10.dp, end = 10.dp, top = 3.dp, bottom = 3.dp) + val buttonPadding = if (termuxStyle) TermuxButtonPadding else DefaultButtonPadding + val keySpacing = if (termuxStyle) 0.dp else 6.dp + val barHorizontalPadding = if (termuxStyle) 0.dp else horizontalPadding + val barVerticalPadding = if (termuxStyle) 2.dp else verticalPadding + var expandedGroupId by remember { mutableStateOf(null) } - val onChildAction: (AccessoryAction) -> Unit = { action -> - onAction(action) + val toggleGroup: (String) -> Unit = { id -> + expandedGroupId = if (expandedGroupId == id) null else id } Column(modifier = modifier) { - expandedGroupId?.let { groupId -> - val expandedGroup = - entries.firstOrNull { it is ResolvedAccessoryEntry.Group && it.group.id == groupId } - as? ResolvedAccessoryEntry.Group - if (expandedGroup != null) { - GroupPopover( - group = expandedGroup.group, - modifierState = modifierState, - onAction = onChildAction, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, - horizontalPadding = horizontalPadding, - ) - } + val expandedGroup = expandedGroupId?.let { groupId -> + entries.filterIsInstance() + .firstOrNull { it.group.id == groupId } + } + if (expandedGroup != null) { + GroupPopover( + group = expandedGroup.group, + modifierState = modifierState, + onAction = onAction, + chuchuKeyActive = chuchuKeyActive, + horizontalPadding = barHorizontalPadding, + ) } - if (useSingleRow) { - Row( + when { + useSingleRow && termuxStyle -> BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = barVerticalPadding), + ) { + // Size keys off the bar width instead of their content so they stay on the grid, + // then let the row scroll once they overflow. + val keyWidth = maxWidth / TERMUX_SINGLE_ROW_KEYS_PER_SCREEN + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(keySpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + entries.forEach { entry -> + AccessoryEntry( + entry = entry, + expanded = expandedGroupId == entry.id, + modifierState = modifierState, + onAction = onAction, + onToggleGroup = toggleGroup, + chuchuKeyActive = chuchuKeyActive, + termuxStyle = true, + buttonPadding = buttonPadding, + modifier = Modifier.width(keyWidth), + ) + } + } + } + + useSingleRow -> Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding) + .padding(horizontal = barHorizontalPadding, vertical = barVerticalPadding) .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(keySpacing), verticalAlignment = Alignment.CenterVertically, ) { entries.forEach { entry -> @@ -107,62 +160,67 @@ fun KeyboardAccessoryBar( expanded = expandedGroupId == entry.id, modifierState = modifierState, onAction = onAction, - onToggleGroup = { id -> - expandedGroupId = if (expandedGroupId == id) null else id - }, - buttonHeight = buttonHeight, + onToggleGroup = toggleGroup, + chuchuKeyActive = chuchuKeyActive, + termuxStyle = false, buttonPadding = buttonPadding, ) } - FilesButton( - onChuchuKey = onChuchuKey, - chuchuKeyActive = chuchuKeyActive, - onOpenFiles = onOpenFiles, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, - ) - SettingsButton( - onSettings = onSettings, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, - ) } - return - } - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - maxLines = 2, - ) { - entries.forEach { entry -> - AccessoryEntry( - entry = entry, - expanded = expandedGroupId == entry.id, - modifierState = modifierState, - onAction = onAction, - onToggleGroup = { id -> - expandedGroupId = if (expandedGroupId == id) null else id - }, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, - ) + termuxStyle -> { + val (topRow, bottomRow) = TerminalAccessoryLayoutStore.splitIntoTwoRows(entries) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = barVerticalPadding), + verticalArrangement = Arrangement.spacedBy(keySpacing), + ) { + listOf(topRow, bottomRow).filter { it.isNotEmpty() }.forEach { rowEntries -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(keySpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + rowEntries.forEach { entry -> + AccessoryEntry( + entry = entry, + expanded = expandedGroupId == entry.id, + modifierState = modifierState, + onAction = onAction, + onToggleGroup = toggleGroup, + chuchuKeyActive = chuchuKeyActive, + termuxStyle = true, + buttonPadding = buttonPadding, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + + else -> FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = barHorizontalPadding, vertical = barVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(keySpacing), + verticalArrangement = Arrangement.spacedBy(keySpacing), + maxLines = 2, + ) { + entries.forEach { entry -> + AccessoryEntry( + entry = entry, + expanded = expandedGroupId == entry.id, + modifierState = modifierState, + onAction = onAction, + onToggleGroup = toggleGroup, + chuchuKeyActive = chuchuKeyActive, + termuxStyle = false, + buttonPadding = buttonPadding, + ) + } } - FilesButton( - onChuchuKey = onChuchuKey, - chuchuKeyActive = chuchuKeyActive, - onOpenFiles = onOpenFiles, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, - ) - SettingsButton( - onSettings = onSettings, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, - ) } } } @@ -172,8 +230,7 @@ private fun GroupPopover( group: AccessoryKeyGroup, modifierState: ModifierState, onAction: (AccessoryAction) -> Unit, - buttonHeight: Dp, - buttonPadding: PaddingValues, + chuchuKeyActive: Boolean, horizontalPadding: Dp, ) { val colors = ChuColors.current @@ -193,12 +250,15 @@ private fun GroupPopover( verticalAlignment = Alignment.CenterVertically, ) { group.children.forEach { child -> + // The popover scrolls freely, so its keys keep the default look even when the bar + // underneath is termux-styled. AccessoryButton( item = child, modifierState = modifierState, onAction = onAction, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, + chuchuKeyActive = chuchuKeyActive, + termuxStyle = false, + buttonPadding = DefaultButtonPadding, ) } } @@ -212,26 +272,30 @@ private fun AccessoryEntry( modifierState: ModifierState, onAction: (AccessoryAction) -> Unit, onToggleGroup: (String) -> Unit, - buttonHeight: Dp, + chuchuKeyActive: Boolean, + termuxStyle: Boolean, buttonPadding: PaddingValues, + modifier: Modifier = Modifier, ) { when (entry) { - is ResolvedAccessoryEntry.Single -> - AccessoryButton( - item = entry.item, - modifierState = modifierState, - onAction = onAction, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, - ) - is ResolvedAccessoryEntry.Group -> - GroupButton( - group = entry.group, - expanded = expanded, - onToggle = { onToggleGroup(entry.group.id) }, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, - ) + is ResolvedAccessoryEntry.Single -> AccessoryButton( + item = entry.item, + modifierState = modifierState, + onAction = onAction, + chuchuKeyActive = chuchuKeyActive, + termuxStyle = termuxStyle, + buttonPadding = buttonPadding, + modifier = modifier, + ) + + is ResolvedAccessoryEntry.Group -> GroupButton( + group = entry.group, + expanded = expanded, + onToggle = { onToggleGroup(entry.group.id) }, + termuxStyle = termuxStyle, + buttonPadding = buttonPadding, + modifier = modifier, + ) } } @@ -240,21 +304,23 @@ private fun GroupButton( group: AccessoryKeyGroup, expanded: Boolean, onToggle: () -> Unit, - buttonHeight: Dp, + termuxStyle: Boolean, buttonPadding: PaddingValues, + modifier: Modifier = Modifier, ) { val colors = ChuColors.current val typography = ChuTypography.current ChuButton( onClick = onToggle, - variant = if (expanded) ChuButtonVariant.Filled else ChuButtonVariant.Outlined, - modifier = Modifier.height(buttonHeight), + variant = keyVariant(active = expanded, termuxStyle = termuxStyle), + modifier = modifier.height(ButtonHeight), contentPadding = buttonPadding, ) { - ChuText( - group.label, + AccessoryLabel( + text = group.label, style = typography.label, color = if (expanded) colors.onAccent else colors.textPrimary, + termuxStyle = termuxStyle, ) } } @@ -264,101 +330,71 @@ private fun AccessoryButton( item: AccessoryKeyItem, modifierState: ModifierState, onAction: (AccessoryAction) -> Unit, - buttonHeight: Dp, + chuchuKeyActive: Boolean, + termuxStyle: Boolean, buttonPadding: PaddingValues, + modifier: Modifier = Modifier, ) { + val colors = ChuColors.current val typography = ChuTypography.current - val toggleModifier = (item.action as? AccessoryAction.ToggleModifier)?.modifier - if (toggleModifier != null) { - ToggleButton( - label = item.label, - enabled = modifierState.isEnabled(toggleModifier), - onClick = { onAction(item.action) }, - modifier = Modifier.height(buttonHeight), - contentPadding = buttonPadding, + val labelStyle = item.labelFontFamily?.let { family -> + TextStyle( + fontFamily = family, + fontWeight = typography.label.fontWeight, + fontStyle = typography.label.fontStyle, + fontSize = 16.sp, + lineHeight = 16.sp, ) - } else { - val specialKey = (item.action as? AccessoryAction.SendSpecialKey)?.key - if (specialKey != null && specialKey.isRepeatable) { - RepeatableAccessoryButton( - item = item, - onAction = onAction, - buttonHeight = buttonHeight, - buttonPadding = buttonPadding, - ) - } else { + } ?: typography.label + + val toggleModifier = (item.action as? AccessoryAction.ToggleModifier)?.modifier + val specialKey = (item.action as? AccessoryAction.SendSpecialKey)?.key + + when { + toggleModifier != null -> { + val active = modifierState.isEnabled(toggleModifier) ChuButton( onClick = { onAction(item.action) }, - variant = ChuButtonVariant.Outlined, - modifier = Modifier.height(buttonHeight), + variant = keyVariant(active = active, termuxStyle = termuxStyle), + modifier = modifier.height(ButtonHeight), contentPadding = buttonPadding, ) { - ChuText(item.label, style = typography.label) + // A leading dot marks a sticky modifier. Termux-style keys are too narrow for the + // extra glyph, so there the fill alone carries the state. + AccessoryLabel( + text = if (active && !termuxStyle) "• ${item.label}" else item.label, + style = typography.label, + color = if (active) colors.onAccent else colors.textSecondary, + termuxStyle = termuxStyle, + ) } } - } -} -@Composable -private fun FilesButton( - onChuchuKey: (() -> Unit)?, - chuchuKeyActive: Boolean, - onOpenFiles: (() -> Unit)?, - buttonHeight: Dp, - buttonPadding: PaddingValues, -) { - val colors = ChuColors.current - val typography = ChuTypography.current - if (onChuchuKey != null) { - ChuButton( - onClick = onChuchuKey, - variant = if (chuchuKeyActive) ChuButtonVariant.Filled else ChuButtonVariant.Outlined, - modifier = Modifier.height(buttonHeight), - contentPadding = buttonPadding, - ) { - ChuText( - "⌘", - style = typography.label, - color = if (chuchuKeyActive) colors.onAccent else colors.textPrimary, - ) - } - } - if (onOpenFiles == null) return - ChuButton( - onClick = onOpenFiles, - variant = ChuButtonVariant.Outlined, - modifier = Modifier.height(buttonHeight), - contentPadding = buttonPadding, - ) { - ChuText( - text = "", - style = TextStyle( - fontFamily = ChuSymbolsFontFamily, - fontWeight = typography.label.fontWeight, - fontStyle = typography.label.fontStyle, - fontSize = 16.sp, - lineHeight = 16.sp, - ), - color = colors.textPrimary, + specialKey?.isRepeatable == true -> RepeatableAccessoryButton( + item = item, + onAction = onAction, + termuxStyle = termuxStyle, + buttonPadding = buttonPadding, + labelStyle = labelStyle, + modifier = modifier, ) - } -} -@Composable -private fun SettingsButton( - onSettings: (() -> Unit)?, - buttonHeight: Dp, - buttonPadding: PaddingValues, -) { - val typography = ChuTypography.current - if (onSettings == null) return - ChuButton( - onClick = onSettings, - variant = ChuButtonVariant.Outlined, - modifier = Modifier.height(buttonHeight), - contentPadding = buttonPadding, - ) { - ChuText("⚙", style = typography.label) + else -> { + val active = item.action is AccessoryAction.ChuchuKey && chuchuKeyActive + ChuButton( + onClick = { onAction(item.action) }, + variant = keyVariant(active = active, termuxStyle = termuxStyle), + modifier = modifier.height(ButtonHeight), + contentPadding = buttonPadding, + ) { + AccessoryLabel( + text = item.label, + style = labelStyle, + color = if (active) colors.onAccent else colors.textPrimary, + termuxStyle = termuxStyle, + ) + } + } } } @@ -366,17 +402,19 @@ private fun SettingsButton( private fun RepeatableAccessoryButton( item: AccessoryKeyItem, onAction: (AccessoryAction) -> Unit, - buttonHeight: Dp, + termuxStyle: Boolean, buttonPadding: PaddingValues, + labelStyle: TextStyle, + modifier: Modifier = Modifier, ) { - val typography = ChuTypography.current + val colors = ChuColors.current val haptics = LocalHapticFeedback.current var pressed by remember { mutableStateOf(false) } val currentOnAction by rememberUpdatedState(onAction) Box( - modifier = Modifier - .height(buttonHeight) + modifier = modifier + .height(ButtonHeight) .pointerInput(item.action) { awaitEachGesture { awaitFirstDown().consume() @@ -413,37 +451,73 @@ private fun RepeatableAccessoryButton( }, ) { ChuButtonSurface( - modifier = Modifier.height(buttonHeight), + modifier = if (termuxStyle) Modifier.matchParentSize() else Modifier.height(ButtonHeight), pressed = pressed, - variant = ChuButtonVariant.Outlined, + variant = keyVariant(active = false, termuxStyle = termuxStyle), contentPadding = buttonPadding, ) { - ChuText(item.label, style = typography.label) + AccessoryLabel( + text = item.label, + style = labelStyle, + color = colors.textPrimary, + termuxStyle = termuxStyle, + ) } } } +private fun keyVariant(active: Boolean, termuxStyle: Boolean): ChuButtonVariant = when { + active -> ChuButtonVariant.Filled + termuxStyle -> ChuButtonVariant.Ghost + else -> ChuButtonVariant.Outlined +} + +/** + * Draws a key label, shrinking the font until it fits when the key is termux-styled. Those keys are + * sized off the bar width rather than their text, so a label like "Enter" would otherwise wrap onto + * a second line in a narrow column. + */ @Composable -private fun ToggleButton( - label: String, - enabled: Boolean, - onClick: () -> Unit, - modifier: Modifier, - contentPadding: PaddingValues, +private fun AccessoryLabel( + text: String, + style: TextStyle, + color: Color, + termuxStyle: Boolean, ) { - val colors = ChuColors.current - val typography = ChuTypography.current - val activeLabel = if (enabled) "• $label" else label - ChuButton( - onClick = onClick, - modifier = modifier, - contentPadding = contentPadding, - variant = if (enabled) ChuButtonVariant.Filled else ChuButtonVariant.Outlined, - ) { + if (!termuxStyle) { + ChuText(text, style = style, color = color) + return + } + + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + + BoxWithConstraints(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + val maxWidthPx = with(density) { maxWidth.toPx() } + val fittedStyle = remember(text, style, maxWidthPx) { + var candidate = style + while (candidate.fontSize.value > MIN_LABEL_FONT_SIZE_SP + LABEL_FONT_SIZE_STEP_SP) { + val measured = textMeasurer.measure( + text = text, + style = candidate, + maxLines = 1, + overflow = TextOverflow.Clip, + constraints = Constraints(maxWidth = maxWidthPx.toInt()), + ) + if (measured.lineCount <= 1 && measured.size.width <= maxWidthPx) break + candidate = candidate.copy( + fontSize = (candidate.fontSize.value - LABEL_FONT_SIZE_STEP_SP).sp, + ) + } + candidate + } + ChuText( - activeLabel, - style = typography.label, - color = if (enabled) colors.onAccent else colors.textSecondary, + text = text, + style = fittedStyle, + color = color, + maxLines = 1, + overflow = TextOverflow.Clip, ) } } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalAccessory.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalAccessory.kt index 41860cfd..b097adb5 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalAccessory.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalAccessory.kt @@ -1,5 +1,8 @@ package com.jossephus.chuchu.ui.terminal +import androidx.compose.ui.text.font.FontFamily +import com.jossephus.chuchu.ui.theme.ChuSymbolsFontFamily + enum class TerminalModifier { Ctrl, Alt, @@ -103,12 +106,22 @@ sealed interface AccessoryAction { data class SendText(val text: String) : AccessoryAction data object Paste : AccessoryAction + + /** Opens the settings screen. Handled by the host screen, not the dispatcher. */ + data object Settings : AccessoryAction + + /** Toggles the chuchu prefix. Handled by the host screen, not the dispatcher. */ + data object ChuchuKey : AccessoryAction + + /** Switches to the file browser tab. Handled by the host screen, not the dispatcher. */ + data object OpenFiles : AccessoryAction } data class AccessoryKeyItem( val id: String, val label: String, val action: AccessoryAction, + val labelFontFamily: FontFamily? = null, ) data class AccessoryKeyGroup( @@ -164,6 +177,13 @@ object TerminalAccessoryDispatcher { modifierState = modifierState, shouldPaste = true, ) + + // Screen-level actions are intercepted by the host before reaching the dispatcher. + AccessoryAction.Settings, + AccessoryAction.ChuchuKey, + AccessoryAction.OpenFiles -> AccessoryDispatchResult( + modifierState = modifierState, + ) } } @@ -202,9 +222,26 @@ object TerminalAccessoryLayoutStore { AccessoryKeyItem("f10", TerminalSpecialKey.F10.label, AccessoryAction.SendSpecialKey(TerminalSpecialKey.F10)), AccessoryKeyItem("f11", TerminalSpecialKey.F11.label, AccessoryAction.SendSpecialKey(TerminalSpecialKey.F11)), AccessoryKeyItem("f12", TerminalSpecialKey.F12.label, AccessoryAction.SendSpecialKey(TerminalSpecialKey.F12)), - AccessoryKeyItem("paste", "Paste", AccessoryAction.Paste), + AccessoryKeyItem("paste", "⎘", AccessoryAction.Paste), // U+2398 + AccessoryKeyItem("chuchu_key", "⌘", AccessoryAction.ChuchuKey), + AccessoryKeyItem("open_files", "", AccessoryAction.OpenFiles, labelFontFamily = ChuSymbolsFontFamily), + AccessoryKeyItem("settings", "⚙", AccessoryAction.Settings), ) + /** + * Keys that used to be fixed trailing buttons on the bar, before they became ordinary catalog + * entries the user can reorder or remove. + */ + private val screenActionIds: List = listOf("chuchu_key", "open_files", "settings") + + /** + * Appends any missing screen-action key to a saved layout. Layouts stored before those keys + * entered the catalog do not list them, and without this they would disappear from the bar on + * upgrade — taking the only route to settings with them. Callers must apply this exactly once. + */ + fun backfillScreenActions(ids: List): List = + ids + screenActionIds.filterNot { it in ids } + private val compositeGroups: List = listOf( AccessoryKeyGroup( id = "digits", @@ -235,10 +272,23 @@ object TerminalAccessoryLayoutStore { "right", "enter", "space", + "chuchu_key", + "open_files", + "settings", ) fun defaultEntries(): List = resolveSelectedLayout(defaultLayoutIds) + /** + * Splits entries into two rows as evenly as possible. The first row takes the extra entry when + * the count is odd, so the second row is never the longer one. Only the termux-style bar uses + * this; the default bar lets [FlowRow] wrap. + */ + fun splitIntoTwoRows(items: List): Pair, List> { + val splitIndex = (items.size + 1) / 2 + return items.take(splitIndex) to items.drop(splitIndex) + } + fun defaultLayoutIds(): List = defaultLayoutIds fun catalog(): List = catalogItems diff --git a/android/app/src/test/java/com/jossephus/chuchu/ui/terminal/AccessoryLogicTest.kt b/android/app/src/test/java/com/jossephus/chuchu/ui/terminal/AccessoryLogicTest.kt index b03e6733..fe18ded2 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/ui/terminal/AccessoryLogicTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/ui/terminal/AccessoryLogicTest.kt @@ -311,4 +311,87 @@ class AccessoryLogicTest { val item = TerminalAccessoryLayoutStore.catalog().first { it.id == "paste" } assertTrue(item.action is AccessoryAction.Paste) } + + // ── Screen actions ──────────────────────────────────────────────────── + + @Test + fun `screen actions are catalog keys so they can be reordered or removed`() { + val ids = TerminalAccessoryLayoutStore.catalog().map { it.id } + assertTrue("catalog must contain chuchu_key", "chuchu_key" in ids) + assertTrue("catalog must contain open_files", "open_files" in ids) + assertTrue("catalog must contain settings", "settings" in ids) + } + + @Test + fun `default layout offers the screen actions`() { + val ids = TerminalAccessoryLayoutStore.defaultLayoutIds() + assertTrue("chuchu_key" in ids) + assertTrue("open_files" in ids) + assertTrue("settings" in ids) + } + + @Test + fun `open_files renders with the symbol font`() { + val item = TerminalAccessoryLayoutStore.catalog().first { it.id == "open_files" } + assertTrue(item.action is AccessoryAction.OpenFiles) + assertNotNull("open_files label needs the symbol font to render", item.labelFontFamily) + } + + @Test + fun `dispatching a screen action leaves terminal state untouched`() { + // The host screen intercepts these; reaching the dispatcher must be a no-op, and in + // particular must not clear sticky modifiers. + val sticky = ModifierState(ctrl = true) + listOf( + AccessoryAction.Settings, + AccessoryAction.ChuchuKey, + AccessoryAction.OpenFiles, + ).forEach { action -> + val result = TerminalAccessoryDispatcher.dispatch(action, sticky) + assertEquals(sticky, result.modifierState) + assertNull(result.text) + assertNull(result.specialKey) + assertFalse(result.shouldPaste) + assertFalse(result.suppressImeInput) + } + } + + // ── Layout migration ────────────────────────────────────────────────── + + @Test + fun `backfill appends screen actions missing from a saved layout`() { + val saved = listOf("escape", "tab", "ctrl") + val migrated = TerminalAccessoryLayoutStore.backfillScreenActions(saved) + assertEquals(listOf("escape", "tab", "ctrl", "chuchu_key", "open_files", "settings"), migrated) + } + + @Test + fun `backfill keeps screen actions the layout already places`() { + val saved = listOf("settings", "escape", "chuchu_key", "open_files") + val migrated = TerminalAccessoryLayoutStore.backfillScreenActions(saved) + assertEquals("an already-migrated layout must be left alone", saved, migrated) + } + + // ── Termux-style two-row split ──────────────────────────────────────── + + @Test + fun `two-row split never leaves the bottom row longer than the top`() { + val (top, bottom) = TerminalAccessoryLayoutStore.splitIntoTwoRows(listOf(1, 2, 3, 4, 5)) + assertEquals(listOf(1, 2, 3), top) + assertEquals(listOf(4, 5), bottom) + } + + @Test + fun `two-row split halves an even layout`() { + val (top, bottom) = TerminalAccessoryLayoutStore.splitIntoTwoRows(listOf(1, 2, 3, 4)) + assertEquals(listOf(1, 2), top) + assertEquals(listOf(3, 4), bottom) + } + + @Test + fun `two-row split handles an empty layout`() { + val (top, bottom) = TerminalAccessoryLayoutStore.splitIntoTwoRows(emptyList()) + assertTrue(top.isEmpty()) + assertTrue(bottom.isEmpty()) + } }