diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/App.kt b/src/main/kotlin/com/jetpackduba/gitnuro/App.kt index 1108b72..92af3dc 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/App.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/App.kt @@ -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) { 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> = 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)) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt b/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt index 5f726c6..88e26bd 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt @@ -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" diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/main.kt b/src/main/kotlin/com/jetpackduba/gitnuro/main.kt index e68124e..78beddc 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/main.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/main.kt @@ -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) { val app = App() app.start(args) -} \ No newline at end of file +} + +//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() } +} diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Tooltip.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Tooltip.kt index 674ae8d..e9f05f4 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Tooltip.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Tooltip.kt @@ -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 diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt index 7ea2dba..8a426e0 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt @@ -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, function: @Composable () -> Unit) { @@ -155,12 +171,14 @@ fun showPopup(x: Int, y: Int, contextMenuElements: List, 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) { + 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() + } + } + } + } + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/diff/Diff.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/diff/Diff.kt index 58cbdd7..94c7187 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/diff/Diff.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/diff/Diff.kt @@ -515,6 +515,7 @@ fun SplitDiffLine( } } +@OptIn(ExperimentalComposeUiApi::class) @Composable fun SplitDiffLineSide( modifier: Modifier, diff --git a/src/main/resources/copy.svg b/src/main/resources/copy.svg new file mode 100644 index 0000000..31873cb --- /dev/null +++ b/src/main/resources/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/cut.svg b/src/main/resources/cut.svg new file mode 100644 index 0000000..561870a --- /dev/null +++ b/src/main/resources/cut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/paste.svg b/src/main/resources/paste.svg new file mode 100644 index 0000000..b1134e0 --- /dev/null +++ b/src/main/resources/paste.svg @@ -0,0 +1 @@ + \ No newline at end of file