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:
parent
fcf4732bf1
commit
e1cc4c496b
@ -201,6 +201,9 @@ class App {
|
||||
onTabClosed = onCloseTab,
|
||||
onAddNewTab = onAddedTab,
|
||||
tabsHeight = 40.dp,
|
||||
onMoveTab = { fromIndex, toIndex ->
|
||||
tabsManager.onMoveTab(fromIndex, toIndex)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user