From e1cc4c496ba00c66c91a09d2e8df760b5adc246d Mon Sep 17 00:00:00 2001 From: Abdelilah El Aissaoui Date: Mon, 19 Jun 2023 09:58:33 +0200 Subject: [PATCH] Added tabs drag and drop Still requires some work to improve the animations as they flicker a bit. Fixes #82 --- .../kotlin/com/jetpackduba/gitnuro/App.kt | 3 + .../gitnuro/git/log/GetLogUseCase.kt | 17 +- .../kotlin/com/jetpackduba/gitnuro/main.kt | 2 - .../com/jetpackduba/gitnuro/ui/TabsManager.kt | 10 + .../ui/components/RepositoriesTabPanel.kt | 61 +++--- .../gitnuro/ui/components/Tooltip.kt | 3 +- .../gitnuro/ui/drag_sorting/RowDragSorting.kt | 195 ++++++++++++++++++ 7 files changed, 262 insertions(+), 29 deletions(-) create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/ui/drag_sorting/RowDragSorting.kt diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/App.kt b/src/main/kotlin/com/jetpackduba/gitnuro/App.kt index f0df2ad..93e4ccd 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/App.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/App.kt @@ -201,6 +201,9 @@ class App { onTabClosed = onCloseTab, onAddNewTab = onAddedTab, tabsHeight = 40.dp, + onMoveTab = { fromIndex, toIndex -> + tabsManager.onMoveTab(fromIndex, toIndex) + }, ) } } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/log/GetLogUseCase.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/log/GetLogUseCase.kt index 65c9a2c..295fda4 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/git/log/GetLogUseCase.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/log/GetLogUseCase.kt @@ -9,9 +9,12 @@ import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git import org.eclipse.jgit.lib.Constants import org.eclipse.jgit.lib.Ref +import org.eclipse.jgit.lib.Repository import javax.inject.Inject class GetLogUseCase @Inject constructor() { + private var graphWalkCached: GraphWalk? = null + suspend operator fun invoke(git: Git, currentBranch: Ref?, hasUncommitedChanges: Boolean, commitsLimit: Int) = withContext(Dispatchers.IO) { val commitList = GraphCommitList() @@ -20,7 +23,6 @@ class GetLogUseCase @Inject constructor() { if (currentBranch != null || repositoryState.isRebasing) { // Current branch is null when there is no log (new repo) or rebasing val logList = git.log().setMaxCount(1).call().toList() - //TODO: Perhaps cache GraphWalk when the commitsLimit is too big? This would ensure a fast traversal of the commits graph but would use more memory. Benchmark it and perhaps offer the option as a setting val walk = GraphWalk(git.repository) walk.use { @@ -47,4 +49,17 @@ class GetLogUseCase @Inject constructor() { return@withContext commitList } + + private fun cachedGraphWalk(repository: Repository): GraphWalk { + val graphWalkCached = this.graphWalkCached + + return if(graphWalkCached != null) { + graphWalkCached + } else { + val newGraphWalk = GraphWalk(repository) + this.graphWalkCached = newGraphWalk + + newGraphWalk + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/main.kt b/src/main/kotlin/com/jetpackduba/gitnuro/main.kt index fd60e82..f829778 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/main.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/main.kt @@ -2,8 +2,6 @@ package com.jetpackduba.gitnuro import com.jetpackduba.gitnuro.preferences.initPreferencesPath -private const val TAG = "main" - fun main(args: Array) { initLogging() initPreferencesPath() diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/TabsManager.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/TabsManager.kt index d9cbc6d..79962b5 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/TabsManager.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/TabsManager.kt @@ -133,4 +133,14 @@ class TabsManager @Inject constructor( appComponent = appComponent, ) } + + fun onMoveTab(fromIndex: Int, toIndex: Int) { + _tabsFlow.update { + it.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + } + } + + updatePersistedTabs() + } } \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/RepositoriesTabPanel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/RepositoriesTabPanel.kt index 8e55204..b201dc4 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/RepositoriesTabPanel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/RepositoriesTabPanel.kt @@ -1,20 +1,14 @@ package com.jetpackduba.gitnuro.ui.components -import androidx.compose.foundation.HorizontalScrollbar -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable +import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.v2.maxScrollOffset -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.runtime.* @@ -25,8 +19,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.jetpackduba.gitnuro.AppIcons -import com.jetpackduba.gitnuro.LocalTabScope +import com.jetpackduba.gitnuro.* import com.jetpackduba.gitnuro.di.AppComponent import com.jetpackduba.gitnuro.di.DaggerTabComponent import com.jetpackduba.gitnuro.di.TabComponent @@ -34,6 +27,9 @@ import com.jetpackduba.gitnuro.extensions.handMouseClickable import com.jetpackduba.gitnuro.extensions.handOnHover import com.jetpackduba.gitnuro.extensions.onMiddleMouseButtonClick import com.jetpackduba.gitnuro.managers.AppStateManager +import com.jetpackduba.gitnuro.ui.drag_sorting.DraggableItem +import com.jetpackduba.gitnuro.ui.drag_sorting.dragContainer +import com.jetpackduba.gitnuro.ui.drag_sorting.rememberDragDropState import com.jetpackduba.gitnuro.viewmodels.TabViewModel import com.jetpackduba.gitnuro.viewmodels.TabViewModelsHolder import kotlinx.coroutines.delay @@ -42,6 +38,7 @@ import javax.inject.Inject import kotlin.io.path.Path import kotlin.io.path.name +@OptIn(ExperimentalFoundationApi::class) @Composable fun RepositoriesTabPanel( tabs: List, @@ -49,6 +46,7 @@ fun RepositoriesTabPanel( tabsHeight: Dp, onTabSelected: (TabInformation) -> Unit, onTabClosed: (TabInformation) -> Unit, + onMoveTab: (Int, Int) -> Unit, onAddNewTab: () -> Unit, ) { val stateHorizontal = rememberLazyListState() @@ -89,25 +87,37 @@ fun RepositoriesTabPanel( } } + + val dragDropState = rememberDragDropState(stateHorizontal) { fromIndex, toIndex -> + onMoveTab(fromIndex, toIndex) + } + Row { LazyRow( modifier = Modifier .height(tabsHeight) - .weight(1f, false), + .weight(1f, false) + .dragContainer(dragDropState), state = stateHorizontal, ) { - items(items = tabs, key = { it.tabViewModel }) { tab -> - Tooltip(tab.path) { - Tab( - title = tab.tabName, - isSelected = currentTab == tab, - onClick = { - onTabSelected(tab) - }, - onCloseTab = { - onTabClosed(tab) - } - ) + itemsIndexed( + items = tabs, + key = { _, tab -> tab.tabViewModel } + ) { index, tab -> + DraggableItem(dragDropState, index) { _ -> + Tooltip(tab.path) { + Tab( + modifier = Modifier, + title = tab.tabName, + isSelected = currentTab == tab, + onClick = { + onTabSelected(tab) + }, + onCloseTab = { + onTabClosed(tab) + } + ) + } } } } @@ -145,6 +155,7 @@ fun RepositoriesTabPanel( @Composable fun Tab( + modifier: Modifier, title: MutableState, isSelected: Boolean, onClick: () -> Unit, @@ -159,7 +170,7 @@ fun Tab( val isHovered by hoverInteraction.collectIsHoveredAsState() Box( - modifier = Modifier + modifier = modifier .widthIn(min = 200.dp) .width(IntrinsicSize.Max) .fillMaxHeight() diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Tooltip.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Tooltip.kt index e9f05f4..2c6bd1d 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Tooltip.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Tooltip.kt @@ -15,8 +15,9 @@ import com.jetpackduba.gitnuro.theme.onBackgroundSecondary @OptIn(ExperimentalFoundationApi::class) @Composable -fun Tooltip(text: String?, content: @Composable () -> Unit) { +fun Tooltip(text: String?, modifier: Modifier = Modifier, content: @Composable () -> Unit) { TooltipArea( + modifier = modifier, tooltip = { if (text != null) { val border = if (MaterialTheme.colors.isDark) { diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/drag_sorting/RowDragSorting.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/drag_sorting/RowDragSorting.kt new file mode 100644 index 0000000..edc8186 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/drag_sorting/RowDragSorting.kt @@ -0,0 +1,195 @@ +package com.jetpackduba.gitnuro.ui.drag_sorting + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + + +@Composable +fun rememberDragDropState( + lazyListState: LazyListState, + @Suppress("PrimitiveInLambda") + onMove: (Int, Int) -> Unit +): DragDropState { + val scope = rememberCoroutineScope() + val state = remember(lazyListState) { + DragDropState( + state = lazyListState, + onMove = onMove, + scope = scope + ) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + return state +} + +class DragDropState internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + @Suppress("PrimitiveInLambda") + private val onMove: (Int, Int) -> Unit +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableStateOf(0f) + private var draggingItemInitialOffset by mutableStateOf(0) + internal val draggingItemOffset: Float + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(offset: Offset) { + state.layoutInfo.visibleItemsInfo + .firstOrNull { item -> + offset.x.toInt() in item.offset..(item.offset + item.size) + }?.also { + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset + } + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = 1f + ) + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.x + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + println("Middle offset is $middleOffset") + + val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index + } + if (targetItem != null) { + val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) { + draggingItem.index + } else if (draggingItem.index == state.firstVisibleItemIndex) { + targetItem.index + } else { + null + } + if (scrollToIndex != null) { + scope.launch { + // this is needed to neutralize automatic keeping the first item first. + state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) + onMove.invoke(draggingItem.index, targetItem.index) + } + } else { + onMove.invoke(draggingItem.index, targetItem.index) + } + draggingItemIndex = targetItem.index + } else { + val overscroll = when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return pointerInput(dragDropState) { + detectDragGestures( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() } + ) + } +} + +@ExperimentalFoundationApi +@Composable +fun LazyItemScope.DraggableItem( + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = if (dragging) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = dragDropState.draggingItemOffset + } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier.zIndex(1f) + .graphicsLayer { + translationX = dragDropState.previousItemOffset.value + } + } else { + Modifier.animateItemPlacement() + } + Column(modifier = modifier.then(draggingModifier)) { + content(dragging) + } +}