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
import androidx.compose.foundation.background
import androidx.compose.foundation.*
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.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusDirection
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.LocalFocusManager
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalLocalization
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import androidx.compose.ui.window.*
import com.jetpackduba.gitnuro.di.DaggerAppComponent
import com.jetpackduba.gitnuro.extensions.preferenceValue
import com.jetpackduba.gitnuro.system.systemSeparator
import com.jetpackduba.gitnuro.extensions.toWindowPlacement
import com.jetpackduba.gitnuro.git.AppGpgSigner
import com.jetpackduba.gitnuro.logging.printError
import com.jetpackduba.gitnuro.managers.AppStateManager
import com.jetpackduba.gitnuro.preferences.AppSettings
import com.jetpackduba.gitnuro.system.systemSeparator
import com.jetpackduba.gitnuro.theme.AppTheme
import com.jetpackduba.gitnuro.theme.Theme
import com.jetpackduba.gitnuro.theme.isDark
import com.jetpackduba.gitnuro.theme.onBackgroundSecondary
import com.jetpackduba.gitnuro.ui.AppTab
import com.jetpackduba.gitnuro.ui.TabsManager
import com.jetpackduba.gitnuro.ui.components.RepositoriesTabPanel
import com.jetpackduba.gitnuro.ui.components.TabInformation
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 java.awt.event.KeyEvent
import java.io.File
import java.nio.file.Paths
import javax.inject.Inject
@ -70,6 +86,7 @@ class App {
appComponent.inject(this)
}
@OptIn(ExperimentalFoundationApi::class)
fun start(args: Array<String>) {
tabsManager.appComponent = this.appComponent
val windowPlacement = appSettings.windowPlacement.toWindowPlacement
@ -117,12 +134,15 @@ class App {
state = windowState,
icon = painterResource(AppIcons.LOGO),
) {
val density = if (scale != -1f) {
arrayOf(LocalDensity provides Density(scale, 1f))
} else
emptyArray()
val compositionValues: MutableList<ProvidedValue<*>> = mutableListOf(LocalTextContextMenu provides AppPopupMenu())
CompositionLocalProvider(values = density) {
if (scale != -1f) {
compositionValues.add(LocalDensity provides Density(scale, 1f))
}
CompositionLocalProvider(
values = compositionValues.toTypedArray()
) {
AppTheme(
selectedTheme = theme,
customTheme = customTheme,
@ -228,10 +248,9 @@ private fun TabContent(currentTab: TabInformation?) {
.fillMaxSize(),
) {
if (currentTab != null) {
val density = arrayOf(LocalTabScope provides currentTab)
val tabScope = arrayOf(LocalTabScope provides currentTab)
CompositionLocalProvider(values = density) {
CompositionLocalProvider(values = tabScope) {
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 CODE = "code.svg"
const val COMPUTER = "computer.svg"
const val COPY = "copy.svg"
const val CUT = "cut.svg"
const val DELETE = "delete.svg"
const val DOWNLOAD = "download.svg"
const val DROPDOWN = "dropdown.svg"
@ -31,6 +33,7 @@ object AppIcons {
const val MESSAGE = "message.svg"
const val MORE_VERT = "more_vert.svg"
const val OPEN = "open.svg"
const val PASTE = "paste.svg"
const val PERSON = "person.svg"
const val REFRESH = "refresh.svg"
const val REMOVE = "remove.svg"

View File

@ -1,6 +1,113 @@
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>) {
val app = App()
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 = {
if (text != null) {
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
null

View File

@ -2,6 +2,8 @@ package com.jetpackduba.gitnuro.ui.context_menu
import androidx.compose.foundation.*
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.MaterialTheme
import androidx.compose.material.Text
@ -10,27 +12,41 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.awtEventOrNull
import androidx.compose.ui.draw.clip
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.geometry.Offset
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.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.window.Popup
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.handMouseClickable
import com.jetpackduba.gitnuro.keybindings.KeybindingOption
import com.jetpackduba.gitnuro.keybindings.matchesBinding
import com.jetpackduba.gitnuro.theme.isDark
import com.jetpackduba.gitnuro.theme.onBackgroundSecondary
import java.awt.event.KeyEvent
import java.awt.event.MouseEvent
import kotlin.math.abs
private var lastCheck: Long = 0
private const val MIN_TIME_BETWEEN_POPUPS_IN_MS = 20
private const val BORDER_RADIUS = 4
@Composable
fun ContextMenu(items: () -> List<ContextMenuElement>, function: @Composable () -> Unit) {
@ -155,12 +171,14 @@ fun showPopup(x: Int, y: Int, contextMenuElements: List<ContextMenuElement>, onD
Box(
modifier = Modifier
.shadow(4.dp)
.width(300.dp)
.shadow(8.dp)
.clip(RoundedCornerShape(BORDER_RADIUS.dp))
.background(MaterialTheme.colors.background)
.width(IntrinsicSize.Max)
.widthIn(min = 180.dp)
.run {
return@run if (!MaterialTheme.colors.isLight) {
this.border(1.dp, MaterialTheme.colors.onBackground.copy(alpha = 0.2f))
if (MaterialTheme.colors.isDark) {
this.border(2.dp, MaterialTheme.colors.onBackground.copy(alpha = 0.2f), shape = RoundedCornerShape(BORDER_RADIUS.dp))
} else
this
}
@ -236,12 +254,149 @@ fun TextEntry(contextTextEntry: ContextMenuElement.ContextTextEntry, onDismissRe
}
}
sealed interface ContextMenuElement {
data class ContextTextEntry(
val label: String,
sealed class ContextMenuElement(
label: String,
onClick: () -> Unit = {}
) : ContextMenuItem(label, onClick) {
class ContextTextEntry(
label: String,
val icon: @Composable (() -> Painter)? = null,
val onClick: () -> Unit = {}
) : ContextMenuElement
onClick: () -> Unit = {}
) : 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
fun SplitDiffLineSide(
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