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,
|
onTabClosed = onCloseTab,
|
||||||
onAddNewTab = onAddedTab,
|
onAddNewTab = onAddedTab,
|
||||||
tabsHeight = 40.dp,
|
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.api.Git
|
||||||
import org.eclipse.jgit.lib.Constants
|
import org.eclipse.jgit.lib.Constants
|
||||||
import org.eclipse.jgit.lib.Ref
|
import org.eclipse.jgit.lib.Ref
|
||||||
|
import org.eclipse.jgit.lib.Repository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class GetLogUseCase @Inject constructor() {
|
class GetLogUseCase @Inject constructor() {
|
||||||
|
private var graphWalkCached: GraphWalk? = null
|
||||||
|
|
||||||
suspend operator fun invoke(git: Git, currentBranch: Ref?, hasUncommitedChanges: Boolean, commitsLimit: Int) =
|
suspend operator fun invoke(git: Git, currentBranch: Ref?, hasUncommitedChanges: Boolean, commitsLimit: Int) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val commitList = GraphCommitList()
|
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
|
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()
|
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)
|
val walk = GraphWalk(git.repository)
|
||||||
|
|
||||||
walk.use {
|
walk.use {
|
||||||
@ -47,4 +49,17 @@ class GetLogUseCase @Inject constructor() {
|
|||||||
|
|
||||||
return@withContext commitList
|
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
|
import com.jetpackduba.gitnuro.preferences.initPreferencesPath
|
||||||
|
|
||||||
private const val TAG = "main"
|
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
initLogging()
|
initLogging()
|
||||||
initPreferencesPath()
|
initPreferencesPath()
|
||||||
|
@ -133,4 +133,14 @@ class TabsManager @Inject constructor(
|
|||||||
appComponent = appComponent,
|
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
|
package com.jetpackduba.gitnuro.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.HorizontalScrollbar
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
|
||||||
import androidx.compose.foundation.v2.maxScrollOffset
|
import androidx.compose.foundation.v2.maxScrollOffset
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.IconButton
|
|
||||||
import androidx.compose.material.MaterialTheme
|
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.runtime.*
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.jetpackduba.gitnuro.AppIcons
|
import com.jetpackduba.gitnuro.*
|
||||||
import com.jetpackduba.gitnuro.LocalTabScope
|
|
||||||
import com.jetpackduba.gitnuro.di.AppComponent
|
import com.jetpackduba.gitnuro.di.AppComponent
|
||||||
import com.jetpackduba.gitnuro.di.DaggerTabComponent
|
import com.jetpackduba.gitnuro.di.DaggerTabComponent
|
||||||
import com.jetpackduba.gitnuro.di.TabComponent
|
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.handOnHover
|
||||||
import com.jetpackduba.gitnuro.extensions.onMiddleMouseButtonClick
|
import com.jetpackduba.gitnuro.extensions.onMiddleMouseButtonClick
|
||||||
import com.jetpackduba.gitnuro.managers.AppStateManager
|
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.TabViewModel
|
||||||
import com.jetpackduba.gitnuro.viewmodels.TabViewModelsHolder
|
import com.jetpackduba.gitnuro.viewmodels.TabViewModelsHolder
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@ -42,6 +38,7 @@ import javax.inject.Inject
|
|||||||
import kotlin.io.path.Path
|
import kotlin.io.path.Path
|
||||||
import kotlin.io.path.name
|
import kotlin.io.path.name
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RepositoriesTabPanel(
|
fun RepositoriesTabPanel(
|
||||||
tabs: List<TabInformation>,
|
tabs: List<TabInformation>,
|
||||||
@ -49,6 +46,7 @@ fun RepositoriesTabPanel(
|
|||||||
tabsHeight: Dp,
|
tabsHeight: Dp,
|
||||||
onTabSelected: (TabInformation) -> Unit,
|
onTabSelected: (TabInformation) -> Unit,
|
||||||
onTabClosed: (TabInformation) -> Unit,
|
onTabClosed: (TabInformation) -> Unit,
|
||||||
|
onMoveTab: (Int, Int) -> Unit,
|
||||||
onAddNewTab: () -> Unit,
|
onAddNewTab: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val stateHorizontal = rememberLazyListState()
|
val stateHorizontal = rememberLazyListState()
|
||||||
@ -89,25 +87,37 @@ fun RepositoriesTabPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val dragDropState = rememberDragDropState(stateHorizontal) { fromIndex, toIndex ->
|
||||||
|
onMoveTab(fromIndex, toIndex)
|
||||||
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(tabsHeight)
|
.height(tabsHeight)
|
||||||
.weight(1f, false),
|
.weight(1f, false)
|
||||||
|
.dragContainer(dragDropState),
|
||||||
state = stateHorizontal,
|
state = stateHorizontal,
|
||||||
) {
|
) {
|
||||||
items(items = tabs, key = { it.tabViewModel }) { tab ->
|
itemsIndexed(
|
||||||
Tooltip(tab.path) {
|
items = tabs,
|
||||||
Tab(
|
key = { _, tab -> tab.tabViewModel }
|
||||||
title = tab.tabName,
|
) { index, tab ->
|
||||||
isSelected = currentTab == tab,
|
DraggableItem(dragDropState, index) { _ ->
|
||||||
onClick = {
|
Tooltip(tab.path) {
|
||||||
onTabSelected(tab)
|
Tab(
|
||||||
},
|
modifier = Modifier,
|
||||||
onCloseTab = {
|
title = tab.tabName,
|
||||||
onTabClosed(tab)
|
isSelected = currentTab == tab,
|
||||||
}
|
onClick = {
|
||||||
)
|
onTabSelected(tab)
|
||||||
|
},
|
||||||
|
onCloseTab = {
|
||||||
|
onTabClosed(tab)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -145,6 +155,7 @@ fun RepositoriesTabPanel(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Tab(
|
fun Tab(
|
||||||
|
modifier: Modifier,
|
||||||
title: MutableState<String>,
|
title: MutableState<String>,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
@ -159,7 +170,7 @@ fun Tab(
|
|||||||
val isHovered by hoverInteraction.collectIsHoveredAsState()
|
val isHovered by hoverInteraction.collectIsHoveredAsState()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.widthIn(min = 200.dp)
|
.widthIn(min = 200.dp)
|
||||||
.width(IntrinsicSize.Max)
|
.width(IntrinsicSize.Max)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
@ -15,8 +15,9 @@ import com.jetpackduba.gitnuro.theme.onBackgroundSecondary
|
|||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun Tooltip(text: String?, content: @Composable () -> Unit) {
|
fun Tooltip(text: String?, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
TooltipArea(
|
TooltipArea(
|
||||||
|
modifier = modifier,
|
||||||
tooltip = {
|
tooltip = {
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
val border = if (MaterialTheme.colors.isDark) {
|
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