Long press support (#9558)

* Refactor input-related components into own package

* Unified input event system with common routing for keys and gestures

* Minor Linting

* Apply input system to World map right-click

* Replace UnitActionsTable Upgrade info tooltip with UnitUpgradeMenu

* Optimize ShortcutListener's full-stage scan

* Post-merge fixes
This commit is contained in:
SomeTroglodyte 2023-06-13 07:58:44 +02:00 committed by GitHub
parent d666c44697
commit 6a387fc7d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 445 additions and 224 deletions

View File

@ -0,0 +1,58 @@
package com.unciv.ui.components.input
import com.unciv.models.UncivSound
import com.unciv.ui.audio.SoundPlayer
import com.unciv.utils.Concurrency
typealias ActivationAction = () -> Unit
// The delegation inheritance is only done to reduce the signature and limit clients to *our* add functions
internal class ActivationActionMap : MutableMap<ActivationTypes, ActivationActionMap.ActivationActionList> by LinkedHashMap() {
// todo Old listener said "happens if there's a double (or more) click function but no single click" -
// means when we register a single-click but the listener *only* reports a double, the registered single-click action is invoked.
class ActivationActionList(val sound: UncivSound) : MutableList<ActivationAction> by ArrayList()
fun add(
type: ActivationTypes,
sound: UncivSound,
noEquivalence: Boolean = false,
action: ActivationAction
) {
getOrPut(type) { ActivationActionList(sound) }.add(action)
if (noEquivalence) return
for (other in ActivationTypes.equivalentValues(type)) {
getOrPut(other) { ActivationActionList(sound) }.add(action)
}
}
fun clear(type: ActivationTypes) {
if (containsKey(type)) remove(type)
}
fun clear(type: ActivationTypes, noEquivalence: Boolean) {
clear(type)
if (noEquivalence) return
for (other in ActivationTypes.equivalentValues(type)) {
clear(other)
}
}
fun clearGestures() {
for (type in ActivationTypes.gestures()) {
clear(type)
}
}
fun isNotEmpty() = any { it.value.isNotEmpty() }
fun activate(type: ActivationTypes): Boolean {
val actions = get(type) ?: return false
if (actions.isEmpty()) return false
if (actions.sound != UncivSound.Silent)
Concurrency.runOnGLThread("Sound") { SoundPlayer.play(actions.sound) }
for (action in actions)
action.invoke()
return true
}
}

View File

@ -1,116 +1,128 @@
package com.unciv.ui.components.input
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.InputListener
import com.badlogic.gdx.scenes.scene2d.Stage
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
import com.badlogic.gdx.scenes.scene2d.utils.Disableable
import com.unciv.models.UncivSound
import com.unciv.ui.audio.SoundPlayer
import com.unciv.utils.Concurrency
/** Used to stop activation events if this returns `true`. */
internal fun Actor.isActive(): Boolean = isVisible && ((this as? Disableable)?.isDisabled != true)
fun Actor.addActivationAction(action: (() -> Unit)?) {
if (action != null)
ActorAttachments.get(this).addActivationAction(action)
/** Routes events from the listener to [ActorAttachments] */
internal fun Actor.activate(type: ActivationTypes = ActivationTypes.Tap): Boolean {
if (!isActive()) return false
val attachment = ActorAttachments.getOrNull(this) ?: return false
return attachment.activate(type)
}
fun Actor.removeActivationAction(action: (() -> Unit)?) {
if (action != null)
ActorAttachments.getOrNull(this)?.removeActivationAction(action)
}
fun Actor.isActive(): Boolean = isVisible && ((this as? Disableable)?.isDisabled != true)
fun Actor.activate() {
if (isActive())
ActorAttachments.getOrNull(this)?.activate()
}
val Actor.keyShortcutsOrNull
get() = ActorAttachments.getOrNull(this)?.keyShortcuts
/** Accesses the [shortcut dispatcher][ActorKeyShortcutDispatcher] for your actor
* (creates one if the actor has none).
*
* Note that shortcuts you add with handlers are routed directly, those without are routed to [onActivation] with type [ActivationTypes.Keystroke]. */
val Actor.keyShortcuts
get() = ActorAttachments.get(this).keyShortcuts
fun Actor.onActivation(sound: UncivSound = UncivSound.Click, action: () -> Unit): Actor {
addActivationAction {
Concurrency.run("Sound") { SoundPlayer.play(sound) }
action()
}
/** Routes input events of type [type] to your handler [action].
* Will also be activated for events [equivalent][ActivationTypes.isEquivalent] to [type] unless [noEquivalence] is `true`.
* A [sound] will be played (concurrently) on activation unless you specify [UncivSound.Silent].
* @return `this` to allow chaining
*/
fun Actor.onActivation(
type: ActivationTypes,
sound: UncivSound = UncivSound.Click,
noEquivalence: Boolean = false,
action: ActivationAction
): Actor {
ActorAttachments.get(this).addActivationAction(type, sound, noEquivalence, action)
return this
}
fun Actor.onActivation(action: () -> Unit): Actor = onActivation(UncivSound.Click, action)
/** Routes clicks and [keyboard shortcuts][keyShortcuts] to your handler [action].
* A [sound] will be played (concurrently) on activation unless you specify [UncivSound.Silent].
* @return `this` to allow chaining
*/
fun Actor.onActivation(sound: UncivSound = UncivSound.Click, action: ActivationAction): Actor =
onActivation(ActivationTypes.Tap, sound, action = action)
/** Routes clicks and [keyboard shortcuts][keyShortcuts] to your handler [action].
* A [Click sound][UncivSound.Click] will be played (concurrently).
* @return `this` to allow chaining
*/
fun Actor.onActivation(action: ActivationAction): Actor =
onActivation(ActivationTypes.Tap, action = action)
enum class DispatcherVetoResult { Accept, Skip, SkipWithChildren }
typealias DispatcherVetoer = (associatedActor: Actor?, keyDispatcher: KeyShortcutDispatcher?) -> DispatcherVetoResult
/** Routes clicks to your handler [action], ignoring [keyboard shortcuts][keyShortcuts].
* A [sound] will be played (concurrently) on activation unless you specify [UncivSound.Silent].
* @return `this` to allow chaining
*/
fun Actor.onClick(sound: UncivSound = UncivSound.Click, action: ActivationAction): Actor =
onActivation(ActivationTypes.Tap, sound, noEquivalence = true, action)
/** Routes clicks to your handler [action], ignoring [keyboard shortcuts][keyShortcuts].
* A [Click sound][UncivSound.Click] will be played (concurrently).
* @return `this` to allow chaining
*/
fun Actor.onClick(action: ActivationAction): Actor =
onActivation(ActivationTypes.Tap, noEquivalence = true, action = action)
/** Routes double-clicks to your handler [action].
* A [sound] will be played (concurrently) on activation unless you specify [UncivSound.Silent].
* @return `this` to allow chaining
*/
fun Actor.onDoubleClick(sound: UncivSound = UncivSound.Click, action: ActivationAction): Actor =
onActivation(ActivationTypes.Doubletap, sound, action = action)
/** Routes right-clicks and long-presses to your handler [action].
* These are treated as equivalent so both desktop and mobile can access the same functionality with methods common to the platform.
* A [sound] will be played (concurrently) on activation unless you specify [UncivSound.Silent].
* @return `this` to allow chaining
*/
fun Actor.onRightClick(sound: UncivSound = UncivSound.Click, action: ActivationAction): Actor =
onActivation(ActivationTypes.RightClick, sound, action = action)
/** Routes long-presses (but not right-clicks) to your handler [action].
* A [sound] will be played (concurrently) on activation unless you specify [UncivSound.Silent].
* @return `this` to allow chaining
*/
@Suppress("unused") // Just in case - for now, only onRightClick is used
fun Actor.onLongPress(sound: UncivSound = UncivSound.Click, action: ActivationAction): Actor =
onActivation(ActivationTypes.Longpress, sound, noEquivalence = true, action)
/** Clears activation actions for a specific [type], and, if [noEquivalence] is `true`,
* its [equivalent][ActivationTypes.isEquivalent] types.
*/
@Suppress("unused") // Just in case - for now, it's automatic clear via clearListener
fun Actor.clearActivationActions(type: ActivationTypes, noEquivalence: Boolean = true) {
ActorAttachments.get(this).clearActivationActions(type, noEquivalence)
}
/**
* Install shortcut dispatcher for this stage. It activates all actions associated with the
* pressed key in [additionalShortcuts] (if specified) and all actors in the stage. It is
* possible to temporarily disable or veto some shortcut dispatchers by passing an appropriate
* pressed key in [additionalShortcuts] (if specified) and **all** actors in the stage - recursively.
*
* It is possible to temporarily disable or veto some shortcut dispatchers by passing an appropriate
* [dispatcherVetoerCreator] function. This function may return a [DispatcherVetoer], which
* will then be used to evaluate all shortcut sources in the stage. This two-step vetoing
* mechanism allows the callback ([dispatcherVetoerCreator]) perform expensive preparations
* only one per keypress (doing them in the returned [DispatcherVetoer] would instead be once
* mechanism allows the callback ([dispatcherVetoerCreator]) to perform expensive preparations
* only once per keypress (doing them in the returned [DispatcherVetoer] would instead be once
* per keypress/actor combination).
*
* Note - screens containing a TileGroupMap **should** supply a vetoer skipping that actor, or else
* the scanning Deque will be several thousand entries deep.
*/
fun Stage.installShortcutDispatcher(additionalShortcuts: KeyShortcutDispatcher? = null, dispatcherVetoerCreator: (() -> DispatcherVetoer?)? = null) {
addListener(object: InputListener() {
override fun keyDown(event: InputEvent?, keycode: Int): Boolean {
val key = when {
event == null ->
KeyCharAndCode.UNKNOWN
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) || Gdx.input.isKeyPressed(Input.Keys.CONTROL_RIGHT) ->
KeyCharAndCode.ctrlFromCode(event.keyCode)
else ->
KeyCharAndCode(event.keyCode)
}
if (key != KeyCharAndCode.UNKNOWN) {
var dispatcherVetoer = when { dispatcherVetoerCreator != null -> dispatcherVetoerCreator() else -> null }
if (dispatcherVetoer == null) dispatcherVetoer = { _, _ -> DispatcherVetoResult.Accept }
if (activate(key, dispatcherVetoer))
return true
// Make both Enter keys equivalent.
if ((key == KeyCharAndCode.NUMPAD_ENTER && activate(KeyCharAndCode.RETURN, dispatcherVetoer))
|| (key == KeyCharAndCode.RETURN && activate(KeyCharAndCode.NUMPAD_ENTER, dispatcherVetoer)))
return true
// Likewise always match Back to ESC.
if ((key == KeyCharAndCode.ESC && activate(KeyCharAndCode.BACK, dispatcherVetoer))
|| (key == KeyCharAndCode.BACK && activate(KeyCharAndCode.ESC, dispatcherVetoer)))
return true
}
return false
}
private fun activate(key: KeyCharAndCode, dispatcherVetoer: DispatcherVetoer): Boolean {
val shortcutResolver = KeyShortcutDispatcher.Resolver(key)
val pendingActors = ArrayDeque<Actor>(actors.toList())
if (additionalShortcuts != null && dispatcherVetoer(null, additionalShortcuts) == DispatcherVetoResult.Accept)
shortcutResolver.updateFor(additionalShortcuts)
while (pendingActors.any()) {
val actor = pendingActors.removeFirst()
val shortcuts = actor.keyShortcutsOrNull
val vetoResult = dispatcherVetoer(actor, shortcuts)
if (shortcuts != null && vetoResult == DispatcherVetoResult.Accept)
shortcutResolver.updateFor(shortcuts)
if (actor is Group && vetoResult != DispatcherVetoResult.SkipWithChildren)
pendingActors.addAll(actor.children)
}
for (action in shortcutResolver.triggeredActions)
action()
return shortcutResolver.triggeredActions.any()
}
})
fun Stage.installShortcutDispatcher(additionalShortcuts: KeyShortcutDispatcher? = null, dispatcherVetoerCreator: () -> DispatcherVetoer?) {
addListener(KeyShortcutListener(actors.asSequence(), additionalShortcuts, dispatcherVetoerCreator))
}
private class OnChangeListener(val function: (event: ChangeEvent?) -> Unit): ChangeListener() {
override fun changed(event: ChangeEvent?, actor: Actor?) {
function(event)
}
}
/** Attach a ChangeListener to [this] and route its changed event to [action] */
fun Actor.onChange(action: (event: ChangeListener.ChangeEvent?) -> Unit): Actor {
this.addListener(OnChangeListener(action))
return this
}

View File

@ -0,0 +1,22 @@
package com.unciv.ui.components.input
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.utils.ActorGestureListener
class ActivationListener : ActorGestureListener(20f, 0.25f, 1.1f, Int.MAX_VALUE.toFloat()) {
// defaults are: halfTapSquareSize = 20, tapCountInterval = 0.4f, longPressDuration = 1.1f, maxFlingDelay = Integer.MAX_VALUE
override fun tap(event: InputEvent?, x: Float, y: Float, count: Int, button: Int) {
val actor = event?.listenerActor ?: return
val type = ActivationTypes.values().firstOrNull {
it.isGesture && it.tapCount == count && it.button == button
} ?: return
actor.activate(type)
}
override fun longPress(actor: Actor?, x: Float, y: Float): Boolean {
if (actor == null) return false
return actor.activate(ActivationTypes.Longpress)
}
}

View File

@ -0,0 +1,32 @@
package com.unciv.ui.components.input
/** Formal encapsulation of the input interaction types */
enum class ActivationTypes(
internal val tapCount: Int = 0,
internal val button: Int = 0,
internal val isGesture: Boolean = true,
private val equivalentTo: ActivationTypes? = null
) {
Keystroke(isGesture = false),
Tap(1, equivalentTo = Keystroke),
Doubletap(2),
Tripletap(3), // Just to clarify it ends here
RightClick(1, 1),
DoubleRightClick(1, 1),
Longpress(equivalentTo = RightClick),
;
/** Checks whether two [ActivationTypes] are declared equivalent, e.g. [RightClick] and [Longpress]. */
internal fun isEquivalent(other: ActivationTypes) =
this == other.equivalentTo || other == this.equivalentTo
internal companion object {
fun equivalentValues(type: ActivationTypes) = values().asSequence()
.filter { it.isEquivalent(type) }
fun gestures() = values().asSequence()
.filter { it.isGesture }
}
}

View File

@ -1,8 +1,7 @@
package com.unciv.ui.components.input
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
import com.unciv.models.UncivSound
internal class ActorAttachments private constructor(actor: Actor) {
companion object {
@ -21,38 +20,55 @@ internal class ActorAttachments private constructor(actor: Actor) {
// Since 'keyShortcuts' has it anyway.
get() = keyShortcuts.actor
private lateinit var activationActions: MutableList<() -> Unit>
private var clickActivationListener: ClickListener? = null
private lateinit var activationActions: ActivationActionMap
private var activationListener: ActivationListener? = null
/**
* Keyboard dispatcher for the [actor] this is attached to.
*
* Note the routing goes [KeyShortcutListener] -> [ActorKeyShortcutDispatcher] -> [ActivationActionMap],
* meaning that shortcuts registered here _with_ explicit action parameter are independent of
* other [activationActions] and do not reach [ActivationActionMap], only those added _without_
* explicit action are routed through and get [ActivationTypes] equivalence to tap/click.
*
* This also means the former are silent while the latter do the Click sound by default.
*/
val keyShortcuts = ActorKeyShortcutDispatcher(actor)
fun activate() {
if (this::activationActions.isInitialized) {
for (action in activationActions)
action()
}
fun activate(type: ActivationTypes): Boolean {
if (!this::activationActions.isInitialized) return false
return activationActions.activate(type)
}
fun addActivationAction(action: () -> Unit) {
if (!this::activationActions.isInitialized) activationActions = mutableListOf()
activationActions.add(action)
fun addActivationAction(
type: ActivationTypes,
sound: UncivSound = UncivSound.Click,
noEquivalence: Boolean = false,
action: ActivationAction
) {
if (!this::activationActions.isInitialized)
activationActions = ActivationActionMap()
if (clickActivationListener == null) {
clickActivationListener = object: ClickListener() {
override fun clicked(event: InputEvent?, x: Float, y: Float) {
actor.activate()
}
}
actor.addListener(clickActivationListener)
else if (activationListener != null && activationListener !in actor.listeners) {
// We think our listener should be active but it isn't - Actor.clearListeners() was called.
// Decision: To keep existing code (which could have to call clearActivationActions otherwise),
// we start over clearing any registered actions using that listener.
actor.addListener(activationListener)
activationActions.clearGestures()
}
activationActions.add(type, sound, noEquivalence, action)
if (!type.isGesture || activationListener != null) return
activationListener = ActivationListener()
actor.addListener(activationListener)
}
fun removeActivationAction(action: () -> Unit) {
fun clearActivationActions(type: ActivationTypes, noEquivalence: Boolean = true) {
if (!this::activationActions.isInitialized) return
activationActions.remove(action)
if (activationActions.none() && clickActivationListener != null) {
actor.removeListener(clickActivationListener)
clickActivationListener = null
}
activationActions.clear(type, noEquivalence)
if (activationListener == null || activationActions.isNotEmpty()) return
actor.removeListener(activationListener)
activationListener = null
}
}

View File

@ -7,11 +7,12 @@ import com.badlogic.gdx.scenes.scene2d.Actor
* [activating][Actor.activate] the actor. However, other actions are possible too.
*/
class ActorKeyShortcutDispatcher internal constructor(val actor: Actor): KeyShortcutDispatcher() {
fun add(shortcut: KeyShortcut?) = add(shortcut) { actor.activate() }
fun add(binding: KeyboardBinding, priority: Int = 1) = add(binding, priority) { actor.activate() }
fun add(key: KeyCharAndCode?) = add(key) { actor.activate() }
fun add(char: Char?) = add(char) { actor.activate() }
fun add(keyCode: Int?) = add(keyCode) { actor.activate() }
val action: ActivationAction = { actor.activate(ActivationTypes.Keystroke) }
fun add(shortcut: KeyShortcut?) = add(shortcut, action)
fun add(binding: KeyboardBinding, priority: Int = 1) = add(binding, priority, action)
fun add(key: KeyCharAndCode?) = add(key, action)
fun add(char: Char?) = add(char, action)
fun add(keyCode: Int?) = add(keyCode, action)
override fun isActive(): Boolean = actor.isActive()
}

View File

@ -1,46 +0,0 @@
package com.unciv.ui.components.input
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
import com.unciv.models.UncivSound
// If there are other buttons that require special clicks then we'll have an onclick that will accept a string parameter, no worries
fun Actor.onClick(sound: UncivSound = UncivSound.Click, tapCount: Int = 1, tapInterval: Float = 0.0f, function: () -> Unit) {
onClickEvent(sound, tapCount, tapInterval) { _, _, _ -> function() }
}
/** same as [onClick], but sends the [InputEvent] and coordinates along */
fun Actor.onClickEvent(sound: UncivSound = UncivSound.Click,
tapCount: Int = 1,
tapInterval: Float = 0.0f,
function: (event: InputEvent?, x: Float, y: Float) -> Unit) {
val previousListener = this.listeners.firstOrNull { it is OnClickListener }
if (previousListener != null && previousListener is OnClickListener) {
previousListener.addClickFunction(sound, tapCount, function)
previousListener.setTapCountInterval(tapInterval)
} else {
this.addListener(OnClickListener(sound, function, tapCount, tapInterval))
}
}
fun Actor.onClick(function: () -> Unit): Actor {
onClick(UncivSound.Click, 1, 0f, function)
return this
}
fun Actor.onDoubleClick(sound: UncivSound = UncivSound.Click, tapInterval: Float = 0.25f, function: () -> Unit): Actor {
onClick(sound, 2, tapInterval, function)
return this
}
class OnChangeListener(val function: (event: ChangeEvent?) -> Unit): ChangeListener(){
override fun changed(event: ChangeEvent?, actor: Actor?) {
function(event)
}
}
fun Actor.onChange(function: (event: ChangeListener.ChangeEvent?) -> Unit): Actor {
this.addListener(OnChangeListener(function))
return this
}

View File

@ -11,32 +11,32 @@ open class KeyShortcutDispatcher {
override fun toString() = if (binding.hidden) "$key@$priority" else "$binding@$priority"
}
private data class ShortcutAction(val shortcut: KeyShortcut, val action: () -> Unit)
private data class ShortcutAction(val shortcut: KeyShortcut, val action: ActivationAction)
private val shortcuts: MutableList<ShortcutAction> = mutableListOf()
fun clear() = shortcuts.clear()
fun add(shortcut: KeyShortcut?, action: (() -> Unit)?) {
fun add(shortcut: KeyShortcut?, action: ActivationAction?) {
if (action == null || shortcut == null) return
shortcuts.removeIf { it.shortcut == shortcut }
shortcuts.add(ShortcutAction(shortcut, action))
}
fun add(binding: KeyboardBinding, priority: Int = 1, action: (() -> Unit)?) {
fun add(binding: KeyboardBinding, priority: Int = 1, action: ActivationAction?) {
add(KeyShortcut(binding, KeyCharAndCode.UNKNOWN, priority), action)
}
fun add(key: KeyCharAndCode?, action: (() -> Unit)?) {
fun add(key: KeyCharAndCode?, action: ActivationAction?) {
if (key != null)
add(KeyShortcut(KeyboardBinding.None, key), action)
}
fun add(char: Char?, action: (() -> Unit)?) {
fun add(char: Char?, action: ActivationAction?) {
if (char != null)
add(KeyCharAndCode(char), action)
}
fun add(keyCode: Int?, action: (() -> Unit)?) {
fun add(keyCode: Int?, action: ActivationAction?) {
if (keyCode != null)
add(KeyCharAndCode(keyCode), action)
}
@ -66,7 +66,7 @@ open class KeyShortcutDispatcher {
class Resolver(val key: KeyCharAndCode) {
private var priority = Int.MIN_VALUE
val triggeredActions: MutableList<() -> Unit> = mutableListOf()
val triggeredActions: MutableList<ActivationAction> = mutableListOf()
fun updateFor(dispatcher: KeyShortcutDispatcher) {
if (!dispatcher.isActive()) return

View File

@ -0,0 +1,40 @@
package com.unciv.ui.components.input
import com.badlogic.gdx.scenes.scene2d.Actor
import com.unciv.ui.components.tilegroups.TileGroupMap
import com.unciv.ui.screens.basescreen.BaseScreen
/**
* A lambda testing for a given *associatedActor* whether the shortcuts in *keyDispatcher*
* should be processed and whether the deep scan for child actors should continue.
* *associatedActor* == `null` means *keyDispatcher* is *BaseScreen.globalShortcuts*
*/
typealias DispatcherVetoer = (associatedActor: Actor?, keyDispatcher: KeyShortcutDispatcher?) -> KeyShortcutDispatcherVeto.DispatcherVetoResult
object KeyShortcutDispatcherVeto {
enum class DispatcherVetoResult { Accept, Skip, SkipWithChildren }
internal val defaultDispatcherVetoer: DispatcherVetoer = { _, _ -> DispatcherVetoResult.Accept }
/** When a Popup ([activePopup]) is active, this creates a [DispatcherVetoer] that disables all
* shortcuts on actors outside the popup and also the global shortcuts on the screen itself.
*/
fun createPopupBasedDispatcherVetoer(activePopup: Actor): DispatcherVetoer? {
return { associatedActor: Actor?, _: KeyShortcutDispatcher? ->
when {
associatedActor == null -> DispatcherVetoResult.Skip
associatedActor.isDescendantOf(activePopup) -> DispatcherVetoResult.Accept
else -> DispatcherVetoResult.SkipWithChildren
}
}
}
/** Return this from [BaseScreen.getShortcutDispatcherVetoer] for Screens containing a [TileGroupMap] */
fun createTileGroupMapDispatcherVetoer(): DispatcherVetoer {
return { associatedActor: Actor?, _: KeyShortcutDispatcher? ->
if (associatedActor is TileGroupMap<*>) DispatcherVetoResult.SkipWithChildren
else DispatcherVetoResult.Accept
}
}
}

View File

@ -0,0 +1,70 @@
package com.unciv.ui.components.input
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.InputListener
import com.unciv.ui.components.input.KeyShortcutDispatcherVeto.DispatcherVetoResult
/** @see installShortcutDispatcher */
class KeyShortcutListener(
private val actors: Sequence<Actor>,
private val additionalShortcuts: KeyShortcutDispatcher? = null,
private val dispatcherVetoerCreator: () -> DispatcherVetoer?
) : InputListener() {
override fun keyDown(event: InputEvent?, keycode: Int): Boolean {
val key = when {
event == null ->
KeyCharAndCode.UNKNOWN
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) || Gdx.input.isKeyPressed(Input.Keys.CONTROL_RIGHT) ->
KeyCharAndCode.ctrlFromCode(event.keyCode)
else ->
KeyCharAndCode(event.keyCode)
}
if (key == KeyCharAndCode.UNKNOWN) return false
val dispatcherVetoer = dispatcherVetoerCreator()
?: KeyShortcutDispatcherVeto.defaultDispatcherVetoer
if (activate(key, dispatcherVetoer))
return true
// Make both Enter keys equivalent.
if ((key == KeyCharAndCode.NUMPAD_ENTER && activate(KeyCharAndCode.RETURN, dispatcherVetoer))
|| (key == KeyCharAndCode.RETURN && activate(KeyCharAndCode.NUMPAD_ENTER, dispatcherVetoer)))
return true
// Likewise always match Back to ESC.
if ((key == KeyCharAndCode.ESC && activate(KeyCharAndCode.BACK, dispatcherVetoer))
|| (key == KeyCharAndCode.BACK && activate(KeyCharAndCode.ESC, dispatcherVetoer)))
return true
return false
}
private fun activate(key: KeyCharAndCode, dispatcherVetoer: DispatcherVetoer): Boolean {
val shortcutResolver = KeyShortcutDispatcher.Resolver(key)
val pendingActors = ArrayDeque(actors.toList())
if (additionalShortcuts != null && dispatcherVetoer(null, additionalShortcuts) == DispatcherVetoResult.Accept)
shortcutResolver.updateFor(additionalShortcuts)
while (true) {
val actor = pendingActors.removeFirstOrNull()
?: break
val shortcuts = ActorAttachments.getOrNull(actor)?.keyShortcuts
val vetoResult = dispatcherVetoer(actor, shortcuts)
if (shortcuts != null && vetoResult == DispatcherVetoResult.Accept)
shortcutResolver.updateFor(shortcuts)
if (actor is Group && vetoResult != DispatcherVetoResult.SkipWithChildren)
pendingActors.addAll(actor.children)
}
for (action in shortcutResolver.triggeredActions)
action()
return shortcutResolver.triggeredActions.isNotEmpty()
}
}

View File

@ -20,11 +20,13 @@ import com.unciv.models.TutorialTrigger
import com.unciv.models.skins.SkinStrings
import com.unciv.ui.components.Fonts
import com.unciv.ui.components.input.KeyShortcutDispatcher
import com.unciv.ui.components.input.DispatcherVetoResult
import com.unciv.ui.components.input.DispatcherVetoer
import com.unciv.ui.components.input.installShortcutDispatcher
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.extensions.isNarrowerThan4to3
import com.unciv.ui.components.input.KeyShortcutDispatcherVeto
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.Popup
import com.unciv.ui.popups.activePopup
import com.unciv.ui.popups.options.OptionsPopup
@ -37,7 +39,7 @@ abstract class BaseScreen : Screen {
/**
* Keyboard shortcuts global to the screen. While this is public and can be modified,
* you most likely should use [keyShortcuts][Actor.keyShortcuts] on appropriate [Actor] instead.
* you most likely should use [keyShortcuts] on the appropriate [Actor] instead.
*/
val globalShortcuts = KeyShortcutDispatcher()
@ -54,22 +56,20 @@ abstract class BaseScreen : Screen {
stage.setDebugParentUnderMouse(true)
}
stage.installShortcutDispatcher(globalShortcuts, this::createPopupBasedDispatcherVetoer)
@Suppress("LeakingThis")
stage.installShortcutDispatcher(globalShortcuts, this::createDispatcherVetoer)
}
private fun createPopupBasedDispatcherVetoer(): DispatcherVetoer? {
/** Hook allowing derived Screens to supply a key shortcut vetoer that can exclude parts of the
* Stage Actor hierarchy from the search. Only called if no [Popup] is active.
* @see installShortcutDispatcher
*/
open fun getShortcutDispatcherVetoer(): DispatcherVetoer? = null
private fun createDispatcherVetoer(): DispatcherVetoer? {
val activePopup = this.activePopup
if (activePopup == null)
return null
else {
// When any popup is active, disable all shortcuts on actor outside the popup
// and also the global shortcuts on the screen itself.
return { associatedActor: Actor?, _: KeyShortcutDispatcher? ->
when { associatedActor == null -> DispatcherVetoResult.Skip
associatedActor.isDescendantOf(activePopup) -> DispatcherVetoResult.Accept
else -> DispatcherVetoResult.SkipWithChildren }
}
}
?: return getShortcutDispatcherVetoer()
return KeyShortcutDispatcherVeto.createPopupBasedDispatcherVetoer(activePopup)
}
override fun show() {}

View File

@ -30,6 +30,7 @@ import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.input.onDoubleClick
import com.unciv.ui.components.extensions.packIfNeeded
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.KeyShortcutDispatcherVeto
import com.unciv.ui.components.tilegroups.CityTileGroup
import com.unciv.ui.components.tilegroups.CityTileState
import com.unciv.ui.components.tilegroups.TileGroupMap
@ -341,6 +342,9 @@ class CityScreen(
mapScrollPane.updateVisualScroll()
}
// We contain a map...
override fun getShortcutDispatcherVetoer() = KeyShortcutDispatcherVeto.createTileGroupMapDispatcherVetoer()
private fun tileWorkedIconOnClick(tileGroup: CityTileGroup, city: City) {
if (!canChangeState || city.isPuppet) return

View File

@ -30,6 +30,7 @@ import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.KeyShortcutDispatcherVeto
import com.unciv.ui.components.tilegroups.TileGroupMap
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.Popup
@ -347,4 +348,7 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize {
stopBackgroundMapGeneration()
return MainMenuScreen()
}
// We contain a map...
override fun getShortcutDispatcherVetoer() = KeyShortcutDispatcherVeto.createTileGroupMapDispatcherVetoer()
}

View File

@ -27,6 +27,7 @@ import com.unciv.ui.popups.ConfirmPopup
import com.unciv.ui.components.tilegroups.TileGroup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.KeyShortcutDispatcherVeto
import com.unciv.ui.components.input.KeyboardPanningListener
import com.unciv.ui.images.ImageWithCustomSize
import com.unciv.ui.popups.ToastPopup
@ -206,6 +207,9 @@ class MapEditorScreen(map: TileMap? = null): BaseScreen(), RecreateOnResize {
return newHolder
}
// We contain a map...
override fun getShortcutDispatcherVetoer() = KeyShortcutDispatcherVeto.createTileGroupMapDispatcherVetoer()
fun loadMap(map: TileMap, newRuleset: Ruleset? = null, selectPage: Int = 0) {
clearOverlayImages()
mapHolder.remove()

View File

@ -35,7 +35,7 @@ import com.unciv.ui.screens.worldscreen.unit.actions.UnitActionsUpgrade
* Meant to animate "in" at a given position - unlike other [Popup]s which are always stage-centered.
* No close button - use "click-behind".
* The "click-behind" semi-transparent covering of the rest of the stage is much darker than a normal
* Popup (geve the impression to take away illumination and spotlight the menu) and fades in together
* Popup (give the impression to take away illumination and spotlight the menu) and fades in together
* with the UnitUpgradeMenu itself. Closing the menu in any of the four ways will fade out everything
* inverting the fade-and-scale-in.
*
@ -43,6 +43,7 @@ import com.unciv.ui.screens.worldscreen.unit.actions.UnitActionsUpgrade
* @param position stage coortinates to show this centered over - clamped so that nothing is clipped outside the [stage]
* @param unit Who is ready to upgrade?
* @param unitAction Holds pre-calculated info like unitToUpgradeTo, cost or resource requirements. Its action is mapped to the Upgrade button.
* @param callbackAfterAnimation If true the following will be delayed until the Popup is actually closed (Stage.hasOpenPopups returns false).
* @param onButtonClicked A callback after one or several upgrades have been performed (and the menu is about to close)
*/
class UnitUpgradeMenu(
@ -50,12 +51,14 @@ class UnitUpgradeMenu(
position: Vector2,
private val unit: MapUnit,
private val unitAction: UpgradeUnitAction,
private val callbackAfterAnimation: Boolean = false,
private val onButtonClicked: () -> Unit
) : Popup(stage, Scrollability.None) {
private val container: Container<Table>
private val allUpgradableUnits: Sequence<MapUnit>
private val animationDuration = 0.33f
private val backgroundColor = (background as NinePatchDrawable).patch.color
private var afterCloseCallback: (() -> Unit)? = null
init {
innerTable.remove()
@ -136,8 +139,7 @@ class UnitUpgradeMenu(
private fun doUpgrade() {
SoundPlayer.play(unitAction.uncivSound)
unitAction.action!!()
onButtonClicked()
close()
launchCallbackAndClose()
}
private fun doAllUpgrade() {
@ -151,7 +153,12 @@ class UnitUpgradeMenu(
val otherAction = UnitActionsUpgrade.getUpgradeAction(unit)
otherAction?.action?.invoke()
}
onButtonClicked()
launchCallbackAndClose()
}
private fun launchCallbackAndClose() {
if (callbackAfterAnimation) afterCloseCallback = onButtonClicked
else onButtonClicked()
close()
}
@ -168,6 +175,7 @@ class UnitUpgradeMenu(
Actions.run {
container.remove()
super.close()
afterCloseCallback?.invoke()
}
))
}

View File

@ -95,10 +95,10 @@ class PolicyButton(viewingCiv: Civilization, canChangeState: Boolean, val policy
}
fun onClick(function: () -> Unit): PolicyButton {
(this as Actor).onClick(function = {
(this as Actor).onClick {
function()
updateState()
})
}
return this
}

View File

@ -9,11 +9,9 @@ import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Action
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.UncivGame
@ -35,17 +33,18 @@ import com.unciv.models.helpers.MiscArrowTypes
import com.unciv.models.ruleset.unique.LocalUniqueCache
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.audio.SoundPlayer
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.UnitGroup
import com.unciv.ui.components.ZoomableScrollPane
import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.colorFromRGB
import com.unciv.ui.components.extensions.darken
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.onRightClick
import com.unciv.ui.components.tilegroups.TileGroup
import com.unciv.ui.components.tilegroups.TileGroupMap
import com.unciv.ui.components.tilegroups.TileSetStrings
@ -54,8 +53,8 @@ import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.basescreen.UncivStage
import com.unciv.ui.screens.worldscreen.bottombar.BattleTableHelpers.battleAnimation
import com.unciv.utils.Log
import com.unciv.utils.Concurrency
import com.unciv.utils.Log
import com.unciv.utils.launchOnGLThread
import java.lang.Float.max
@ -131,19 +130,13 @@ class WorldMapHolder(
continue
// Right mouse click listener
tileGroup.addListener(object : ClickListener() {
init {
button = Input.Buttons.RIGHT
tileGroup.onRightClick {
val unit = worldScreen.bottomUnitTable.selectedUnit
?: return@onRightClick
Concurrency.run("WorldScreenClick") {
onTileRightClicked(unit, tileGroup.tile)
}
override fun clicked(event: InputEvent?, x: Float, y: Float) {
val unit = worldScreen.bottomUnitTable.selectedUnit
?: return
Concurrency.run("WorldScreenClick") {
onTileRightClicked(unit, tileGroup.tile)
}
}
})
}
}
actor = tileGroupMap
setSize(worldScreen.stage.width, worldScreen.stage.height)

View File

@ -33,6 +33,7 @@ import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.setFontSize
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.KeyShortcutDispatcherVeto
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.AuthPopup
import com.unciv.ui.popups.Popup
@ -284,6 +285,9 @@ class WorldScreen(
stage.addListener(KeyboardPanningListener(mapHolder, allowWASD = true))
}
// We contain a map...
override fun getShortcutDispatcherVetoer() = KeyShortcutDispatcherVeto.createTileGroupMapDispatcherVetoer()
private suspend fun loadLatestMultiplayerState(): Unit = coroutineScope {
if (game.screen != this@WorldScreen) return@coroutineScope // User already went somewhere else

View File

@ -4,22 +4,20 @@ import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.ui.Button
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.GUI
import com.unciv.UncivGame
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.models.UnitAction
import com.unciv.models.UnitActionType
import com.unciv.models.UpgradeUnitAction
import com.unciv.ui.components.UncivTooltip
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.input.KeyboardBindings
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.extensions.packIfNeeded
import com.unciv.ui.components.input.onRightClick
import com.unciv.ui.images.IconTextButton
import com.unciv.ui.objectdescriptions.BaseUnitDescriptions
import com.unciv.ui.screens.overviewscreen.UnitUpgradeMenu
import com.unciv.ui.screens.worldscreen.WorldScreen
class UnitActionsTable(val worldScreen: WorldScreen) : Table() {
@ -30,12 +28,13 @@ class UnitActionsTable(val worldScreen: WorldScreen) : Table() {
if (!worldScreen.canChangeState) return // No actions when it's not your turn or spectator!
for (unitAction in UnitActions.getUnitActions(unit)) {
val button = getUnitActionButton(unit, unitAction)
if (unitAction is UpgradeUnitAction && GUI.keyboardAvailable) {
val tipTitle = "«RED»${KeyboardBindings[unitAction.type.binding]}«»: {Upgrade}"
val tipActor = BaseUnitDescriptions.getUpgradeTooltipActor(tipTitle, unit.baseUnit, unitAction.unitToUpgradeTo)
button.addListener(UncivTooltip(button, tipActor
, offset = Vector2(0f, tipActor.packIfNeeded().height * 0.333f) // scaling fails to express size in parent coordinates
, tipAlign = Align.topLeft, targetAlign = Align.topRight))
if (unitAction is UpgradeUnitAction) {
button.onRightClick {
val pos = button.localToStageCoordinates(Vector2(button.width, button.height))
UnitUpgradeMenu(worldScreen.stage, pos, unit, unitAction, callbackAfterAnimation = true) {
worldScreen.shouldUpdate = true
}
}
}
add(button).left().padBottom(2f).row()
}
@ -54,9 +53,9 @@ class UnitActionsTable(val worldScreen: WorldScreen) : Table() {
if (unitAction.type == UnitActionType.Promote && unitAction.action != null)
actionButton.color = Color.GREEN.cpy().lerp(Color.WHITE, 0.5f)
if (unitAction !is UpgradeUnitAction) // Does its own toolTip
actionButton.addTooltip(KeyboardBindings[binding])
actionButton.addTooltip(KeyboardBindings[binding])
actionButton.pack()
if (unitAction.action == null) {
actionButton.disable()
} else {