Implemented custom UI design for compose's context menus

This commit is contained in:
Abdelilah El Aissaoui 2023-04-19 22:59:18 +02:00
parent 343da198b9
commit 29c04dbad3
No known key found for this signature in database
GPG Key ID: 7587FC860F594869
9 changed files with 318 additions and 38 deletions

View File

@ -2,44 +2,60 @@
package com.jetpackduba.gitnuro package com.jetpackduba.gitnuro
import androidx.compose.foundation.background import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.LocalTextContextMenu
import androidx.compose.foundation.text.TextContextMenu
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.nativeKeyCode
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalLocalization
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.*
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.jetpackduba.gitnuro.di.DaggerAppComponent import com.jetpackduba.gitnuro.di.DaggerAppComponent
import com.jetpackduba.gitnuro.extensions.preferenceValue import com.jetpackduba.gitnuro.extensions.preferenceValue
import com.jetpackduba.gitnuro.system.systemSeparator
import com.jetpackduba.gitnuro.extensions.toWindowPlacement import com.jetpackduba.gitnuro.extensions.toWindowPlacement
import com.jetpackduba.gitnuro.git.AppGpgSigner import com.jetpackduba.gitnuro.git.AppGpgSigner
import com.jetpackduba.gitnuro.logging.printError import com.jetpackduba.gitnuro.logging.printError
import com.jetpackduba.gitnuro.managers.AppStateManager import com.jetpackduba.gitnuro.managers.AppStateManager
import com.jetpackduba.gitnuro.preferences.AppSettings import com.jetpackduba.gitnuro.preferences.AppSettings
import com.jetpackduba.gitnuro.system.systemSeparator
import com.jetpackduba.gitnuro.theme.AppTheme import com.jetpackduba.gitnuro.theme.AppTheme
import com.jetpackduba.gitnuro.theme.Theme import com.jetpackduba.gitnuro.theme.Theme
import com.jetpackduba.gitnuro.theme.isDark
import com.jetpackduba.gitnuro.theme.onBackgroundSecondary import com.jetpackduba.gitnuro.theme.onBackgroundSecondary
import com.jetpackduba.gitnuro.ui.AppTab import com.jetpackduba.gitnuro.ui.AppTab
import com.jetpackduba.gitnuro.ui.TabsManager import com.jetpackduba.gitnuro.ui.TabsManager
import com.jetpackduba.gitnuro.ui.components.RepositoriesTabPanel import com.jetpackduba.gitnuro.ui.components.RepositoriesTabPanel
import com.jetpackduba.gitnuro.ui.components.TabInformation import com.jetpackduba.gitnuro.ui.components.TabInformation
import com.jetpackduba.gitnuro.ui.components.emptyTabInformation import com.jetpackduba.gitnuro.ui.components.emptyTabInformation
import com.jetpackduba.gitnuro.ui.context_menu.AppPopupMenu
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement
import com.jetpackduba.gitnuro.ui.context_menu.Separator
import com.jetpackduba.gitnuro.ui.context_menu.TextEntry
import org.eclipse.jgit.lib.GpgSigner import org.eclipse.jgit.lib.GpgSigner
import java.awt.event.KeyEvent
import java.io.File import java.io.File
import java.nio.file.Paths import java.nio.file.Paths
import javax.inject.Inject import javax.inject.Inject
@ -70,6 +86,7 @@ class App {
appComponent.inject(this) appComponent.inject(this)
} }
@OptIn(ExperimentalFoundationApi::class)
fun start(args: Array<String>) { fun start(args: Array<String>) {
tabsManager.appComponent = this.appComponent tabsManager.appComponent = this.appComponent
val windowPlacement = appSettings.windowPlacement.toWindowPlacement val windowPlacement = appSettings.windowPlacement.toWindowPlacement
@ -117,12 +134,15 @@ class App {
state = windowState, state = windowState,
icon = painterResource(AppIcons.LOGO), icon = painterResource(AppIcons.LOGO),
) { ) {
val density = if (scale != -1f) { val compositionValues: MutableList<ProvidedValue<*>> = mutableListOf(LocalTextContextMenu provides AppPopupMenu())
arrayOf(LocalDensity provides Density(scale, 1f))
} else
emptyArray()
CompositionLocalProvider(values = density) { if (scale != -1f) {
compositionValues.add(LocalDensity provides Density(scale, 1f))
}
CompositionLocalProvider(
values = compositionValues.toTypedArray()
) {
AppTheme( AppTheme(
selectedTheme = theme, selectedTheme = theme,
customTheme = customTheme, customTheme = customTheme,
@ -228,10 +248,9 @@ private fun TabContent(currentTab: TabInformation?) {
.fillMaxSize(), .fillMaxSize(),
) { ) {
if (currentTab != null) { if (currentTab != null) {
val density = arrayOf(LocalTabScope provides currentTab) val tabScope = arrayOf(LocalTabScope provides currentTab)
CompositionLocalProvider(values = tabScope) {
CompositionLocalProvider(values = density) {
AppTab(currentTab.tabViewModel) AppTab(currentTab.tabViewModel)
} }
} }
@ -251,11 +270,3 @@ fun LoadingRepository(repoPath: String) {
} }
} }
} }
object AboutIcon : Painter() {
override val intrinsicSize = Size(256f, 256f)
override fun DrawScope.onDraw() {
drawOval(Color(0xFFFFA500))
}
}

View File

@ -14,6 +14,8 @@ object AppIcons {
const val CLOUD = "cloud.svg" const val CLOUD = "cloud.svg"
const val CODE = "code.svg" const val CODE = "code.svg"
const val COMPUTER = "computer.svg" const val COMPUTER = "computer.svg"
const val COPY = "copy.svg"
const val CUT = "cut.svg"
const val DELETE = "delete.svg" const val DELETE = "delete.svg"
const val DOWNLOAD = "download.svg" const val DOWNLOAD = "download.svg"
const val DROPDOWN = "dropdown.svg" const val DROPDOWN = "dropdown.svg"
@ -31,6 +33,7 @@ object AppIcons {
const val MESSAGE = "message.svg" const val MESSAGE = "message.svg"
const val MORE_VERT = "more_vert.svg" const val MORE_VERT = "more_vert.svg"
const val OPEN = "open.svg" const val OPEN = "open.svg"
const val PASTE = "paste.svg"
const val PERSON = "person.svg" const val PERSON = "person.svg"
const val REFRESH = "refresh.svg" const val REFRESH = "refresh.svg"
const val REMOVE = "remove.svg" const val REMOVE = "remove.svg"

View File

@ -1,6 +1,113 @@
package com.jetpackduba.gitnuro package com.jetpackduba.gitnuro
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.JPopupTextMenu
import androidx.compose.foundation.text.LocalTextContextMenu
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.awt.ComposePanel
import androidx.compose.ui.platform.LocalLocalization
import java.awt.Color
import java.awt.Component
import java.awt.Dimension
import java.awt.Graphics
import java.awt.event.KeyEvent
import java.awt.event.KeyEvent.CTRL_DOWN_MASK
import java.awt.event.KeyEvent.META_DOWN_MASK
import javax.swing.Icon
import javax.swing.JFrame
import javax.swing.JMenuItem
import javax.swing.JPopupMenu
import javax.swing.KeyStroke.getKeyStroke
import javax.swing.SwingUtilities
import org.jetbrains.skiko.hostOs
fun main(args: Array<String>) { fun main(args: Array<String>) {
val app = App() val app = App()
app.start(args) app.start(args)
} }
//fun main() = SwingUtilities.invokeLater {
// val panel = ComposePanel()
// panel.setContent {
// JPopupTextMenuProvider(panel) {
// Column {
// SelectionContainer {
// Text("Hello, Compose!")
// }
//
// var text by remember { mutableStateOf("") }
//
// TextField(text, { text = it })
// }
// }
// }
//
// val window = JFrame()
// window.contentPane.add(panel)
// window.size = Dimension(800, 600)
// window.isVisible = true
//}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun JPopupTextMenuProvider(owner: Component, content: @Composable () -> Unit) {
val localization = LocalLocalization.current
CompositionLocalProvider(
LocalTextContextMenu provides JPopupTextMenu(owner) { textManager, items ->
JPopupMenu().apply {
textManager.cut?.also {
add(
swingItem(localization.cut,KeyEvent.VK_X, it)
)
}
textManager.copy?.also {
add(
swingItem(localization.copy, KeyEvent.VK_C, it)
)
}
textManager.paste?.also {
add(
swingItem(localization.paste, KeyEvent.VK_V, it)
)
}
textManager.selectAll?.also {
add(JPopupMenu.Separator())
add(
swingItem(localization.selectAll, KeyEvent.VK_A, it)
)
}
// Here we add other items that can be defined additionaly in the other places of the application via ContextMenuDataProvider
for (item in items) {
add(
JMenuItem(item.label).apply {
addActionListener { item.onClick() }
}
)
}
}
},
content = content
)
}
private fun swingItem(
label: String,
key: Int,
onClick: () -> Unit
) = JMenuItem(label).apply {
accelerator = getKeyStroke(key, if (hostOs.isMacOS) META_DOWN_MASK else CTRL_DOWN_MASK)
addActionListener { onClick() }
}

View File

@ -20,7 +20,7 @@ fun Tooltip(text: String?, content: @Composable () -> Unit) {
tooltip = { tooltip = {
if (text != null) { if (text != null) {
val border = if (MaterialTheme.colors.isDark) { val border = if (MaterialTheme.colors.isDark) {
BorderStroke(2.dp, MaterialTheme.colors.onBackgroundSecondary.copy(alpha = 0.4f)) BorderStroke(2.dp, MaterialTheme.colors.onBackgroundSecondary.copy(alpha = 0.2f))
} else } else
null null

View File

@ -2,6 +2,8 @@ package com.jetpackduba.gitnuro.ui.context_menu
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextContextMenu
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
@ -10,27 +12,41 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.awtEventOrNull import androidx.compose.ui.awt.awtEventOrNull
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.*
import androidx.compose.ui.input.pointer.* import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalLocalization
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.rememberPopupPositionProviderAtPosition
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.extensions.awaitFirstDownEvent import com.jetpackduba.gitnuro.extensions.awaitFirstDownEvent
import com.jetpackduba.gitnuro.extensions.handMouseClickable import com.jetpackduba.gitnuro.extensions.handMouseClickable
import com.jetpackduba.gitnuro.keybindings.KeybindingOption import com.jetpackduba.gitnuro.keybindings.KeybindingOption
import com.jetpackduba.gitnuro.keybindings.matchesBinding import com.jetpackduba.gitnuro.keybindings.matchesBinding
import com.jetpackduba.gitnuro.theme.isDark
import com.jetpackduba.gitnuro.theme.onBackgroundSecondary import com.jetpackduba.gitnuro.theme.onBackgroundSecondary
import java.awt.event.KeyEvent
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import kotlin.math.abs import kotlin.math.abs
private var lastCheck: Long = 0 private var lastCheck: Long = 0
private const val MIN_TIME_BETWEEN_POPUPS_IN_MS = 20 private const val MIN_TIME_BETWEEN_POPUPS_IN_MS = 20
private const val BORDER_RADIUS = 4
@Composable @Composable
fun ContextMenu(items: () -> List<ContextMenuElement>, function: @Composable () -> Unit) { fun ContextMenu(items: () -> List<ContextMenuElement>, function: @Composable () -> Unit) {
@ -155,12 +171,14 @@ fun showPopup(x: Int, y: Int, contextMenuElements: List<ContextMenuElement>, onD
Box( Box(
modifier = Modifier modifier = Modifier
.shadow(4.dp) .shadow(8.dp)
.width(300.dp) .clip(RoundedCornerShape(BORDER_RADIUS.dp))
.background(MaterialTheme.colors.background) .background(MaterialTheme.colors.background)
.width(IntrinsicSize.Max)
.widthIn(min = 180.dp)
.run { .run {
return@run if (!MaterialTheme.colors.isLight) { if (MaterialTheme.colors.isDark) {
this.border(1.dp, MaterialTheme.colors.onBackground.copy(alpha = 0.2f)) this.border(2.dp, MaterialTheme.colors.onBackground.copy(alpha = 0.2f), shape = RoundedCornerShape(BORDER_RADIUS.dp))
} else } else
this this
} }
@ -236,12 +254,149 @@ fun TextEntry(contextTextEntry: ContextMenuElement.ContextTextEntry, onDismissRe
} }
} }
sealed interface ContextMenuElement { sealed class ContextMenuElement(
data class ContextTextEntry( label: String,
val label: String, onClick: () -> Unit = {}
) : ContextMenuItem(label, onClick) {
class ContextTextEntry(
label: String,
val icon: @Composable (() -> Painter)? = null, val icon: @Composable (() -> Painter)? = null,
val onClick: () -> Unit = {} onClick: () -> Unit = {}
) : ContextMenuElement ) : ContextMenuElement(label, onClick)
object ContextSeparator : ContextMenuElement object ContextSeparator : ContextMenuElement("", {})
}
@ExperimentalFoundationApi
class AppPopupMenu : TextContextMenu {
@Composable
override fun Area(
textManager: TextContextMenu.TextManager,
state: ContextMenuState,
content: @Composable () -> Unit
) {
val localization = LocalLocalization.current
val items = {
listOfNotNull(
textManager.copy?.let {
ContextMenuElement.ContextTextEntry(
label = localization.copy,
icon = { painterResource(AppIcons.COPY) },
onClick = it
)
},
textManager.cut?.let {
ContextMenuElement.ContextTextEntry(
label = localization.cut,
icon = { painterResource(AppIcons.CUT) },
onClick = it
)
},
textManager.paste?.let {
ContextMenuElement.ContextTextEntry(
label = localization.paste,
icon = { painterResource(AppIcons.PASTE) },
onClick = it
)
},
textManager.selectAll?.let {
ContextMenuElement.ContextTextEntry(
label = localization.selectAll,
icon = null,
onClick = it
)
},
)
}
CompositionLocalProvider(
LocalContextMenuRepresentation provides AppContextMenuRepresentation()
) {
ContextMenuArea(items, state, content = content)
}
}
}
class AppContextMenuRepresentation : ContextMenuRepresentation {
@OptIn(ExperimentalComposeUiApi::class)
@Composable
override fun Representation(state: ContextMenuState, items: () -> List<ContextMenuItem>) {
LightDefaultContextMenuRepresentation
val status = state.status
if (status is ContextMenuState.Status.Open) {
var focusManager: FocusManager? by mutableStateOf(null)
var inputModeManager: InputModeManager? by mutableStateOf(null)
Popup(
focusable = true,
onDismissRequest = { state.status = ContextMenuState.Status.Closed },
popupPositionProvider = rememberPopupPositionProviderAtPosition(
positionPx = status.rect.center
),
onKeyEvent = {
if (it.type == KeyEventType.KeyDown) {
when (it.key.nativeKeyCode) {
KeyEvent.VK_ESCAPE -> {
state.status = ContextMenuState.Status.Closed
true
}
KeyEvent.VK_DOWN -> {
inputModeManager!!.requestInputMode(InputMode.Keyboard)
focusManager!!.moveFocus(FocusDirection.Next)
true
}
KeyEvent.VK_UP -> {
inputModeManager!!.requestInputMode(InputMode.Keyboard)
focusManager!!.moveFocus(FocusDirection.Previous)
true
}
else -> false
}
} else {
false
}
},
) {
focusManager = LocalFocusManager.current
inputModeManager = LocalInputModeManager.current
val border = if (MaterialTheme.colors.isDark) {
BorderStroke(2.dp, MaterialTheme.colors.onBackgroundSecondary.copy(alpha = 0.2f))
} else
null
Column(
modifier = Modifier
.shadow(8.dp)
.clip(RoundedCornerShape(BORDER_RADIUS.dp))
.background(MaterialTheme.colors.background)
.width(IntrinsicSize.Max)
.widthIn(min = 180.dp)
.verticalScroll(rememberScrollState())
.run {
if (border != null)
border(border, RoundedCornerShape(BORDER_RADIUS.dp))
else
this
}
) {
items().forEach { item ->
when (item) {
is ContextMenuElement.ContextTextEntry -> TextEntry(
contextTextEntry = item,
onDismissRequest = { state.status = ContextMenuState.Status.Closed }
)
is ContextMenuElement.ContextSeparator -> Separator()
}
}
}
}
}
}
} }

View File

@ -515,6 +515,7 @@ fun SplitDiffLine(
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun SplitDiffLineSide( fun SplitDiffLineSide(
modifier: Modifier, modifier: Modifier,

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><circle cx="6" cy="18" fill="none" r="2"/><circle cx="12" cy="12" fill="none" r=".5"/><circle cx="6" cy="6" fill="none" r="2"/><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3z"/></svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 2h-4.18C14.4.84 13.3 0 12 0c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm7 18H5V4h2v3h10V4h2v16z"/></svg>

After

Width:  |  Height:  |  Size: 357 B