Added tabs drag and drop

Still requires some work to improve the animations as they flicker a bit.

Fixes #82
This commit is contained in:
Abdelilah El Aissaoui 2023-06-19 09:58:33 +02:00
parent fcf4732bf1
commit e1cc4c496b
No known key found for this signature in database
GPG Key ID: 7587FC860F594869
7 changed files with 262 additions and 29 deletions

View File

@ -201,6 +201,9 @@ class App {
onTabClosed = onCloseTab,
onAddNewTab = onAddedTab,
tabsHeight = 40.dp,
onMoveTab = { fromIndex, toIndex ->
tabsManager.onMoveTab(fromIndex, toIndex)
},
)
}
}

View File

@ -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
}
}
}

View File

@ -2,8 +2,6 @@ package com.jetpackduba.gitnuro
import com.jetpackduba.gitnuro.preferences.initPreferencesPath
private const val TAG = "main"
fun main(args: Array<String>) {
initLogging()
initPreferencesPath()

View File

@ -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()
}
}

View File

@ -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<TabInformation>,
@ -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<String>,
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()

View File

@ -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) {

View File

@ -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<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
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<Int?>(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)
}
}