Introduce a control panel to edit buttons instead of a fab bar

An unusual big commit, unfortunately needed because none of these changes would make sense nor work individual. A quick list of what have been done follows.
* Introduced a control panel to control buttons replacing the FAB bar
* `ConfigurableButton` and `OnScreenConfiguration` interfaces have been introduced to allow for easier proxying of actions when applying the same changes to all buttons
* Button resize logic has been stripped from the buttons in favor of the new sliders
* General cleanup and renaming of various methods to better reflect their functionality
This commit is contained in:
lynxnb 2023-03-29 17:35:51 +02:00 committed by Billy Laws
parent 49de8a8f38
commit d7e38e9556
19 changed files with 740 additions and 290 deletions

View File

@ -44,7 +44,8 @@ enum class ButtonId(val value : Long, val short : String? = null, val long : Int
LeftSR(1 shl 25, "SR", string.right_shoulder),
RightSL(1 shl 26, "SL", string.left_shoulder),
RightSR(1 shl 27, "SR", string.right_shoulder),
Menu(1 shl 28, "⌂︎", string.emu_menu_button);
Menu(1 shl 28, "⌂︎", string.emu_menu_button),
All(0x1FFFFFFF, "All");
}
/**

View File

@ -0,0 +1,43 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline.input.onscreen
import emu.skyline.input.ButtonId
/**
* This interface is used to allow proxying of [OnScreenButton]s
*/
interface ConfigurableButton {
val buttonId : ButtonId
val config : OnScreenConfiguration
/**
* Starts a button move session
* @param x The x coordinate of the initial touch
* @param y The y coordinate of the initial touch
*/
fun startMove(x : Float, y : Float)
/**
* Moves this button to the given coordinates
*/
fun move(x : Float, y : Float)
/**
* Ends the current move session
*/
fun endMove()
/**
* Resets the button to its default configuration
*/
fun resetConfig()
fun moveUp()
fun moveDown()
fun moveLeft()
fun moveRight()
}

View File

@ -22,14 +22,14 @@ import kotlin.math.roundToInt
*/
abstract class OnScreenButton(
onScreenControllerView : OnScreenControllerView,
val buttonId : ButtonId,
final override val buttonId : ButtonId,
private val defaultRelativeX : Float,
private val defaultRelativeY : Float,
private val defaultRelativeWidth : Float,
private val defaultRelativeHeight : Float,
drawableId : Int,
private val defaultEnabled : Boolean
) {
) : ConfigurableButton {
companion object {
/**
* Aspect ratio the default values were based on
@ -37,7 +37,7 @@ abstract class OnScreenButton(
const val CONFIGURED_ASPECT_RATIO = 2074f / 874f
}
val config = OnScreenConfiguration(onScreenControllerView.context, buttonId, defaultRelativeX, defaultRelativeY, defaultEnabled)
final override val config = OnScreenConfigurationImpl(onScreenControllerView.context, buttonId, defaultRelativeX, defaultRelativeY, defaultEnabled)
protected val drawable = ContextCompat.getDrawable(onScreenControllerView.context, drawableId)!!
@ -50,8 +50,8 @@ abstract class OnScreenButton(
var relativeX = config.relativeX
var relativeY = config.relativeY
private val relativeWidth get() = defaultRelativeWidth * (config.globalScale + config.scale)
private val relativeHeight get() = defaultRelativeHeight * (config.globalScale + config.scale)
private val relativeWidth get() = defaultRelativeWidth * config.scale
private val relativeHeight get() = defaultRelativeHeight * config.scale
/**
* The width of the view this button is in, populated by the view during draw
@ -158,28 +158,17 @@ abstract class OnScreenButton(
relativeY = config.relativeY
}
/**
* Starts an edit session
* @param x The x coordinate of the initial touch
* @param y The y coordinate of the initial touch
*/
open fun startEdit(x : Float, y : Float) {
fun saveConfigValues() {
config.relativeX = relativeX
config.relativeY = relativeY
}
override fun startMove(x : Float, y : Float) {
editInitialTouchPoint.set(x, y)
editInitialScale = config.scale
}
open fun edit(x : Float, y : Float) {
when (editInfo.editMode) {
EditMode.Move -> move(x, y)
EditMode.Resize -> resize(x, y)
else -> return
}
}
/**
* Moves this button to the given coordinates
*/
open fun move(x : Float, y : Float) {
override fun move(x : Float, y : Float) {
var adjustedX = x
var adjustedY = y
@ -216,34 +205,38 @@ abstract class OnScreenButton(
relativeY = (adjustedY - heightDiff) / adjustedHeight
}
/**
* Resizes this button based on the distance of the given Y coordinate from the initial Y coordinate
*/
open fun resize(x : Float, y : Float) {
// Invert the distance because the Y coordinate increases as you move down the screen
val verticalDistance = editInitialTouchPoint.y - y
config.scale = editInitialScale + verticalDistance / 200f
override fun endMove() {
saveConfigValues()
}
/**
* Ends the current edit session
*/
open fun endEdit() {
config.relativeX = relativeX
config.relativeY = relativeY
override fun moveUp() {
move(currentX, currentY - editInfo.arrowKeyMoveAmount)
saveConfigValues()
}
open fun resetRelativeValues() {
override fun moveDown() {
move(currentX, currentY + editInfo.arrowKeyMoveAmount)
saveConfigValues()
}
override fun moveLeft() {
move(currentX - editInfo.arrowKeyMoveAmount, currentY)
saveConfigValues()
}
override fun moveRight() {
move(currentX + editInfo.arrowKeyMoveAmount, currentY)
saveConfigValues()
}
override fun resetConfig() {
config.enabled = defaultEnabled
config.alpha = OnScreenConfiguration.DefaultAlpha
config.scale = OnScreenConfiguration.DefaultScale
config.relativeX = defaultRelativeX
config.relativeY = defaultRelativeY
relativeX = defaultRelativeX
relativeY = defaultRelativeY
}
open fun resetConfig() {
resetRelativeValues()
config.enabled = defaultEnabled
config.scale = OnScreenConfiguration.DefaultScale
}
}

View File

@ -10,32 +10,50 @@ import emu.skyline.input.ButtonId
import emu.skyline.utils.SwitchColors
import emu.skyline.utils.sharedPreferences
class OnScreenConfiguration(private val context : Context, private val buttonId : ButtonId, defaultRelativeX : Float, defaultRelativeY : Float, defaultEnabled : Boolean) {
interface OnScreenConfiguration {
companion object {
const val DefaultAlpha = 130
const val DefaultGlobalScale = 1.15f
const val DefaultScale = 0.0f
const val GroupDisabled = 0
const val GroupEnabled = 1
const val GroupIndeterminate = 2
const val MinAlpha = 0
const val MaxAlpha = 255
const val DefaultAlpha = 128
const val MinScale = 0.5f
const val MaxScale = 2.5f
const val DefaultScale = 1.15f
val DefaultTextColor = SwitchColors.BLACK.color
val DefaultBackgroundColor = SwitchColors.WHITE.color
}
var enabled : Boolean
/**
* The state of a group of buttons, returns an integer that can be used to set the state of a MaterialCheckBox
*/
val groupEnabled get() = if (enabled) GroupEnabled else GroupDisabled
var alpha : Int
var textColor : Int
var backgroundColor : Int
var scale : Float
var relativeX : Float
var relativeY : Float
}
class OnScreenConfigurationImpl(private val context : Context, private val buttonId : ButtonId, defaultRelativeX : Float, defaultRelativeY : Float, defaultEnabled : Boolean) : OnScreenConfiguration {
private inline fun <reified T> config(default : T, prefix : String = "${buttonId.name}_") = sharedPreferences(context, default, prefix, "controller_config")
var enabled by config(defaultEnabled)
override var enabled by config(defaultEnabled)
var alpha by config(DefaultAlpha, "")
var textColor by config(SwitchColors.BLACK.color)
var backgroundColor by config(SwitchColors.WHITE.color)
override var alpha by config(OnScreenConfiguration.DefaultAlpha)
override var textColor by config(OnScreenConfiguration.DefaultTextColor)
override var backgroundColor by config(OnScreenConfiguration.DefaultBackgroundColor)
/**
* The global scale applied to all buttons
*/
var globalScale by config(DefaultGlobalScale, "")
/**
* The scale of each button, this is added to the global scale
* Allows buttons to have their own size, while still be controlled by the global scale
*/
var scale by config(DefaultScale)
var relativeX by config(defaultRelativeX)
var relativeY by config(defaultRelativeY)
override var scale by config(OnScreenConfiguration.DefaultScale)
override var relativeX by config(defaultRelativeX)
override var relativeY by config(defaultRelativeY)
}

View File

@ -17,6 +17,7 @@ import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.View.OnTouchListener
import androidx.annotation.IntRange
import emu.skyline.input.ButtonId
import emu.skyline.input.ButtonState
import emu.skyline.input.ControllerType
@ -37,14 +38,18 @@ class OnScreenControllerView @JvmOverloads constructor(context : Context, attrs
private val controllerTypeMappings = mapOf(*ControllerType.values().map {
it to (setOf(*it.buttons) + setOf(*it.optionalButtons) to setOf(*it.sticks))
}.toTypedArray())
private const val SCALE_STEP = 0.05f
private const val ALPHA_STEP = 25
private val ALPHA_RANGE = 55..255
}
private var onButtonStateChangedListener : OnButtonStateChangedListener? = null
fun setOnButtonStateChangedListener(listener : OnButtonStateChangedListener) {
onButtonStateChangedListener = listener
}
private var onStickStateChangedListener : OnStickStateChangedListener? = null
fun setOnStickStateChangedListener(listener : OnStickStateChangedListener) {
onStickStateChangedListener = listener
}
private val joystickAnimators = mutableMapOf<JoystickButton, Animator?>()
var controllerType : ControllerType? = null
set(value) {
@ -62,8 +67,12 @@ class OnScreenControllerView @JvmOverloads constructor(context : Context, attrs
(controls.circularButtons + controls.rectangularButtons + controls.triggerButtons).forEach { it.hapticFeedback = hapticFeedback }
}
val editInfo = OnScreenEditInfo()
internal val editInfo = OnScreenEditInfo()
val isEditing get() = editInfo.isEditing
val editButton get() = editInfo.editButton
fun setOnEditButtonChangedListener(listener : OnEditButtonChangedListener?) {
editInfo.onEditButtonChangedListener = listener
}
// Populated externally by the activity, as retrieving the vibrator service inside the view crashes the layout editor
lateinit var vibrator : Vibrator
@ -222,40 +231,52 @@ class OnScreenControllerView @JvmOverloads constructor(context : Context, attrs
handled.also { if (it) invalidate() }
}
private val editingTouchHandler = OnTouchListener { _, event ->
var handled = false
/**
* Tracks whether the last pointer down event changed the active edit button
* Avoids moving the button when the user just wants to select it
*/
private var activeEditButtonChanged = false
when (event.actionMasked) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_POINTER_DOWN -> {
// Handle this event only if no other button is being edited
if (editInfo.editButton == null) {
handled = controls.allButtons.any { button ->
if (button.config.enabled && button.isTouched(event.x, event.y)) {
editInfo.editButton = button
button.startEdit(event.x, event.y)
performClick()
true
} else false
private val editingTouchHandler = OnTouchListener { _, event ->
run {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_POINTER_DOWN -> {
val touchedButton = controls.allButtons.firstOrNull { it.isTouched(event.x, event.y) } ?: return@OnTouchListener false
// Update the selection if the user touched a button other than the selected one
if (touchedButton != editInfo.editButton) {
activeEditButtonChanged = true
editInfo.editButton = touchedButton
performClick()
return@run
}
editInfo.editButton.startMove(event.x, event.y)
}
MotionEvent.ACTION_MOVE -> {
// If the user just selected another button, don't move it yet
if (activeEditButtonChanged)
return@run
editInfo.editButton.move(event.x, event.y)
invalidate()
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_CANCEL -> {
if (activeEditButtonChanged) {
activeEditButtonChanged = false
return@run
}
editInfo.editButton.endMove()
}
}
MotionEvent.ACTION_MOVE -> {
editInfo.editButton?.edit(event.x, event.y)
handled = true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_CANCEL -> {
editInfo.editButton?.endEdit()
editInfo.editButton = null
handled = true
}
}
handled.also { if (it) invalidate() }
true
}
init {
@ -264,39 +285,49 @@ class OnScreenControllerView @JvmOverloads constructor(context : Context, attrs
fun setEditMode(editMode : EditMode) {
editInfo.editMode = editMode
setOnTouchListener(if (isEditing) editingTouchHandler else playingTouchHandler )
setOnTouchListener(if (isEditing) editingTouchHandler else playingTouchHandler)
}
fun resetControls() {
controls.allButtons.forEach {
it.resetConfig()
}
controls.globalScale = OnScreenConfiguration.DefaultGlobalScale
controls.alpha = OnScreenConfiguration.DefaultAlpha
fun selectAllButtons() {
editInfo.editButton = allButtonsProxy
}
fun setButtonEnabled(enabled : Boolean) {
editInfo.editButton.config.enabled = enabled
invalidate()
}
fun increaseScale() {
controls.globalScale += SCALE_STEP
fun setButtonScale(@IntRange(from = 0, to = 100) scale : Int) {
fun toScaleRange(value : Int) : Float = (value / 100f) * (OnScreenConfiguration.MaxScale - OnScreenConfiguration.MinScale) + OnScreenConfiguration.MinScale
editInfo.editButton.config.scale = toScaleRange(scale)
invalidate()
}
fun decreaseScale() {
controls.globalScale -= SCALE_STEP
fun setButtonOpacity(@IntRange(from = 0, to = 100) opacity : Int) {
fun toAlphaRange(value : Int) : Int = ((value / 100f) * (OnScreenConfiguration.MaxAlpha - OnScreenConfiguration.MinAlpha)).toInt() + OnScreenConfiguration.MinAlpha
editInfo.editButton.config.alpha = toAlphaRange(opacity)
invalidate()
}
fun setSnapToGrid(snap : Boolean) {
editInfo.snapToGrid = snap
}
fun increaseOpacity() {
controls.alpha = (controls.alpha + ALPHA_STEP).coerceIn(ALPHA_RANGE)
fun moveButtonUp() {
editInfo.editButton.moveUp()
invalidate()
}
fun decreaseOpacity() {
controls.alpha = (controls.alpha - ALPHA_STEP).coerceIn(ALPHA_RANGE)
fun moveButtonDown() {
editInfo.editButton.moveDown()
invalidate()
}
fun moveButtonLeft() {
editInfo.editButton.moveLeft()
invalidate()
}
fun moveButtonRight() {
editInfo.editButton.moveRight()
invalidate()
}
@ -308,23 +339,6 @@ class OnScreenControllerView @JvmOverloads constructor(context : Context, attrs
return controls.globalBackgroundColor
}
fun setOnButtonStateChangedListener(listener : OnButtonStateChangedListener) {
onButtonStateChangedListener = listener
}
fun setOnStickStateChangedListener(listener : OnStickStateChangedListener) {
onStickStateChangedListener = listener
}
data class ButtonProp(val buttonId : ButtonId, val enabled : Boolean)
fun getButtonProps() = controls.allButtons.map { ButtonProp(it.buttonId, it.config.enabled) }
fun setButtonEnabled(buttonId : ButtonId, enabled : Boolean) {
controls.allButtons.first { it.buttonId == buttonId }.config.enabled = enabled
invalidate()
}
fun setTextColor(color : Int) {
for (button in controls.allButtons) {
button.config.textColor = color
@ -338,4 +352,90 @@ class OnScreenControllerView @JvmOverloads constructor(context : Context, attrs
}
invalidate()
}
fun setSnapToGrid(snap : Boolean) {
editInfo.snapToGrid = snap
editInfo.arrowKeyMoveAmount = if (snap) editInfo.gridSize else OnScreenEditInfo.ArrowKeyMoveAmount
}
fun resetButton() {
editInfo.editButton.resetConfig()
editInfo.onEditButtonChangedListener?.invoke(editInfo.editButton)
invalidate()
}
/**
* A proxy button that is used to apply changes to all buttons
*/
private val allButtonsProxy = object : ConfigurableButton {
override val buttonId : ButtonId = ButtonId.All
override val config = object : OnScreenConfiguration {
override var enabled : Boolean
get() = controls.allButtons.all { it.config.enabled }
set(value) {
controls.allButtons.forEach { it.config.enabled = value }
}
override val groupEnabled : Int
get() {
if (controls.allButtons.all { it.config.enabled })
return OnScreenConfiguration.GroupEnabled
if (controls.allButtons.all { !it.config.enabled })
return OnScreenConfiguration.GroupDisabled
return OnScreenConfiguration.GroupIndeterminate
}
override var alpha : Int
get() = controls.allButtons.sumOf { it.config.alpha } / controls.allButtons.size
set(value) {
controls.allButtons.forEach { it.config.alpha = value }
}
override var textColor : Int
get() = controls.globalTextColor
set(value) {
setTextColor(value)
}
override var backgroundColor : Int
get() = controls.globalBackgroundColor
set(value) {
setBackGroundColor(value)
}
override var scale : Float
get() = (controls.allButtons.sumOf { it.config.scale.toDouble() } / controls.allButtons.size).toFloat()
set(value) {
controls.allButtons.forEach { it.config.scale = value }
}
override var relativeX = 0f
override var relativeY = 0f
}
override fun startMove(x : Float, y : Float) {}
override fun move(x : Float, y : Float) {}
override fun endMove() {}
override fun moveUp() {
controls.allButtons.forEach { it.moveUp() }
}
override fun moveDown() {
controls.allButtons.forEach { it.moveDown() }
}
override fun moveLeft() {
controls.allButtons.forEach { it.moveLeft() }
}
override fun moveRight() {
controls.allButtons.forEach { it.moveRight() }
}
override fun resetConfig() {
controls.allButtons.forEach { it.resetConfig() }
}
}
}

View File

@ -5,109 +5,35 @@
package emu.skyline.input.onscreen
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.os.Vibrator
import android.os.VibratorManager
import android.view.*
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import dagger.hilt.android.AndroidEntryPoint
import emu.skyline.R
import emu.skyline.databinding.OnScreenEditActivityBinding
import emu.skyline.databinding.OscSliderBinding
import emu.skyline.settings.AppSettings
import emu.skyline.utils.SwitchColors
import emu.skyline.utils.SwitchColors.*
import petrov.kristiyan.colorpicker.DoubleColorPicker
import petrov.kristiyan.colorpicker.DoubleColorPicker.OnChooseDoubleColorListener
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class OnScreenEditActivity : AppCompatActivity() {
private enum class Action(@DrawableRes private val icon : Int, @DrawableRes private val activeIcon : Int = 0) {
Restore(R.drawable.ic_restore),
Toggle(R.drawable.ic_toggle_on),
Move(R.drawable.ic_move),
Resize(R.drawable.ic_resize),
Grid(R.drawable.ic_grid_off, R.drawable.ic_grid_on),
Palette(R.drawable.ic_palette),
ZoomOut(R.drawable.ic_zoom_out),
ZoomIn(R.drawable.ic_zoom_in),
OpacityMinus(R.drawable.ic_opacity_minus),
OpacityPlus(R.drawable.ic_opacity_plus),
Close(R.drawable.ic_close),
;
fun getIcon(active : Boolean) = if (activeIcon != 0 && active) activeIcon else icon
}
private val binding by lazy { OnScreenEditActivityBinding.inflate(layoutInflater) }
private var fullEditVisible = true
@Inject
lateinit var appSettings : AppSettings
private val closeAction : () -> Unit = {
if (binding.onScreenControllerView.isEditing) {
toggleFabVisibility(true)
binding.onScreenControllerView.setEditMode(EditMode.None)
} else {
fullEditVisible = !fullEditVisible
toggleFabVisibility(fullEditVisible)
fabMapping[Action.Close]!!.animate().rotation(if (fullEditVisible) 0f else 45f)
}
}
private fun toggleFabVisibility(visible : Boolean) {
fabMapping.forEach { (action, fab) ->
if (action != Action.Close) {
if (visible) fab.show()
else fab.hide()
}
}
}
private val moveAction = {
binding.onScreenControllerView.setEditMode(EditMode.Move)
toggleFabVisibility(false)
}
private val resizeAction = {
binding.onScreenControllerView.setEditMode(EditMode.Resize)
toggleFabVisibility(false)
}
private val toggleAction : () -> Unit = {
val buttonProps = binding.onScreenControllerView.getButtonProps()
val checkedButtonsArray = buttonProps.map { it.enabled }.toBooleanArray()
MaterialAlertDialogBuilder(this)
.setMultiChoiceItems(
buttonProps.map { button ->
val longText = getString(button.buttonId.long!!)
if (button.buttonId.short == longText) longText else "$longText: ${button.buttonId.short}"
}.toTypedArray(),
checkedButtonsArray
) { _, which, isChecked ->
checkedButtonsArray[which] = isChecked
}
.setPositiveButton(R.string.confirm) { _, _ ->
buttonProps.forEachIndexed { index, button ->
if (checkedButtonsArray[index] != button.enabled)
binding.onScreenControllerView.setButtonEnabled(button.buttonId, checkedButtonsArray[index])
}
}
.setNegativeButton(R.string.cancel, null)
.setOnDismissListener { fullScreen() }
.show()
}
private val paletteAction : () -> Unit = {
private fun paletteAction() {
DoubleColorPicker(this@OnScreenEditActivity).apply {
setTitle(this@OnScreenEditActivity.getString(R.string.osc_background_color))
setDefaultColorButton(binding.onScreenControllerView.getBackGroundColor())
@ -127,43 +53,25 @@ class OnScreenEditActivity : AppCompatActivity() {
}
}
private val toggleGridAction : () -> Unit = {
private fun toggleGridAction() {
val snapToGrid = !appSettings.onScreenControlSnapToGrid
appSettings.onScreenControlSnapToGrid = snapToGrid
binding.onScreenControllerView.setSnapToGrid(snapToGrid)
binding.alignmentGrid.isGone = !snapToGrid
fabMapping[Action.Grid]!!.setImageResource(Action.Grid.getIcon(!snapToGrid))
binding.gridButton.setIconResource(if (!snapToGrid) R.drawable.ic_grid_on else R.drawable.ic_grid_off)
}
private val resetAction : () -> Unit = {
private fun resetAction() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.osc_reset)
.setTitle(getString(R.string.osc_reset, binding.onScreenControllerView.editButton.buttonId.short))
.setMessage(R.string.osc_reset_confirm)
.setPositiveButton(R.string.confirm) { _, _ -> binding.onScreenControllerView.resetControls() }
.setPositiveButton(R.string.confirm) { _, _ -> binding.onScreenControllerView.resetButton() }
.setNegativeButton(R.string.cancel, null)
.setOnDismissListener { fullScreen() }
.show()
}
private data class ActionEntry(val action : Action, val callback : () -> Unit)
private val actions : List<ActionEntry> = listOf(
ActionEntry(Action.Restore, resetAction),
ActionEntry(Action.Toggle, toggleAction),
ActionEntry(Action.Move, moveAction),
ActionEntry(Action.Resize, resizeAction),
ActionEntry(Action.Grid, toggleGridAction),
ActionEntry(Action.Palette, paletteAction),
ActionEntry(Action.ZoomOut) { binding.onScreenControllerView.decreaseScale() },
ActionEntry(Action.ZoomIn) { binding.onScreenControllerView.increaseScale() },
ActionEntry(Action.OpacityMinus) { binding.onScreenControllerView.decreaseOpacity() },
ActionEntry(Action.OpacityPlus) { binding.onScreenControllerView.increaseOpacity() },
ActionEntry(Action.Close, closeAction),
)
private val fabMapping = mutableMapOf<Action, FloatingActionButton>()
override fun onCreate(savedInstanceState : Bundle?) {
super.onCreate(savedInstanceState)
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
@ -194,20 +102,40 @@ class OnScreenEditActivity : AppCompatActivity() {
binding.alignmentGrid.isGone = !snapToGrid
binding.alignmentGrid.gridSize = OnScreenEditInfo.GridSize
actions.forEach { (action, callback) ->
binding.fabParent.addView(LayoutInflater.from(this).inflate(R.layout.on_screen_edit_mini_fab, binding.fabParent, false).apply {
(this as FloatingActionButton).setImageDrawable(ContextCompat.getDrawable(context, action.getIcon(false)))
setOnClickListener { callback.invoke() }
fabMapping[action] = this
})
binding.onScreenControllerView.setOnEditButtonChangedListener { button ->
updateActiveButtonDisplayInfo(button)
}
fabMapping[Action.Grid]!!.setImageDrawable(ContextCompat.getDrawable(this, Action.Grid.getIcon(!snapToGrid)))
binding.selectAllButton.setOnClickListener { binding.onScreenControllerView.selectAllButtons() }
populateSlider(binding.scaleSlider, getString(R.string.osc_scale)) {
binding.onScreenControllerView.setButtonScale(it)
}
populateSlider(binding.opacitySlider, getString(R.string.osc_opacity)) {
binding.onScreenControllerView.setButtonOpacity(it)
}
binding.enabledCheckbox.setOnClickListener { _ ->
binding.onScreenControllerView.setButtonEnabled(binding.enabledCheckbox.isChecked)
}
binding.moveUpButton.setOnClickListener { binding.onScreenControllerView.moveButtonUp() }
binding.moveDownButton.setOnClickListener { binding.onScreenControllerView.moveButtonDown() }
binding.moveLeftButton.setOnClickListener { binding.onScreenControllerView.moveButtonLeft() }
binding.moveRightButton.setOnClickListener { binding.onScreenControllerView.moveButtonRight() }
binding.colorButton.setOnClickListener { paletteAction() }
binding.gridButton.setOnClickListener { toggleGridAction() }
binding.gridButton.setIconResource(if (!snapToGrid) R.drawable.ic_grid_on else R.drawable.ic_grid_off)
binding.resetButton.setOnClickListener { resetAction() }
binding.onScreenControllerView.setEditMode(EditMode.Move)
binding.onScreenControllerView.selectAllButtons()
}
override fun onResume() {
super.onResume()
updateActiveButtonDisplayInfo(binding.onScreenControllerView.editButton)
fullScreen()
}
@ -222,4 +150,36 @@ class OnScreenEditActivity : AppCompatActivity() {
or View.SYSTEM_UI_FLAG_FULLSCREEN)
}
}
/**
* Initializes the slider in the range [0,100], with the given label and value listener
*/
@SuppressLint("SetTextI18n")
private fun populateSlider(slider : OscSliderBinding, label : String, valueListener : ((Int) -> Unit)? = null) {
slider.title.text = label
slider.slider.apply {
valueFrom = 0f
valueTo = 100f
stepSize = 0f
// Always update the value label
addOnChangeListener { _, value, _ ->
slider.valueLabel.text = "${value.roundToInt()}%"
}
// Only call the value listener if the user is dragging the slider
addOnChangeListener { _, value, fromUser ->
if (fromUser)
valueListener?.invoke(value.roundToInt())
}
}
}
/**
* Updates the control panel UI elements to reflect the currently selected button
*/
private fun updateActiveButtonDisplayInfo(button : ConfigurableButton) {
binding.enabledCheckbox.checkedState = button.config.groupEnabled
binding.currentButton.text = getString(R.string.osc_current_button, button.buttonId.short)
binding.scaleSlider.slider.value = (button.config.scale - OnScreenConfiguration.MinScale) / (OnScreenConfiguration.MaxScale - OnScreenConfiguration.MinScale) * 100f
binding.opacitySlider.slider.value = (button.config.alpha - OnScreenConfiguration.MinAlpha) / (OnScreenConfiguration.MaxAlpha - OnScreenConfiguration.MinAlpha).toFloat() * 100f
}
}

View File

@ -10,10 +10,11 @@ import android.util.TypedValue
enum class EditMode {
None,
Move,
Resize
Move
}
typealias OnEditButtonChangedListener = ((ConfigurableButton) -> Unit)
/**
* A small class that holds information about the current edit session
* This is used to share information between the [OnScreenControllerView] and the individual [OnScreenButton]s
@ -27,7 +28,15 @@ class OnScreenEditInfo {
/**
* The button that is currently being edited
*/
var editButton : OnScreenButton? = null
private lateinit var _editButton : ConfigurableButton
var editButton : ConfigurableButton
get() = _editButton
set(value) {
_editButton = value
onEditButtonChangedListener?.invoke(value)
}
var onEditButtonChangedListener : OnEditButtonChangedListener? = null
/**
* Whether the buttons should snap to a grid when in edit mode
@ -36,6 +45,8 @@ class OnScreenEditInfo {
var gridSize : Int = GridSize
var arrowKeyMoveAmount : Int = ArrowKeyMoveAmount
val isEditing get() = editMode != EditMode.None
companion object {
@ -43,5 +54,10 @@ class OnScreenEditInfo {
* The size of the grid, calculated from the value of 8dp
*/
var GridSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, Resources.getSystem().displayMetrics).toInt()
/**
* The amount the button will be moved when using the arrow keys
*/
val ArrowKeyMoveAmount = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, Resources.getSystem().displayMetrics).toInt()
}
}

View File

@ -136,15 +136,15 @@ class JoystickButton(
fun outerToInnerRelative() = outerToInner().multiply(1f / radius)
override fun edit(x : Float, y : Float) {
super.edit(x, y)
override fun move(x : Float, y : Float) {
super.move(x, y)
innerButton.relativeX = relativeX
innerButton.relativeY = relativeY
}
override fun resetRelativeValues() {
super.resetRelativeValues()
override fun resetConfig() {
super.resetConfig()
innerButton.relativeX = relativeX
innerButton.relativeY = relativeY
@ -241,24 +241,6 @@ class Controls(onScreenControllerView : OnScreenControllerView) {
val allButtons = circularButtons + joysticks + rectangularButtons + triggerButtons
/**
* We can take any of the global scale variables from the buttons since the value is shared across all buttons
*/
var globalScale
get() = circularButtons.first().config.globalScale
set(value) {
circularButtons.first().config.globalScale = value
}
/**
* We can take any of the alpha variables from the buttons since the value is shared across all buttons
*/
var alpha
get() = circularButtons.first().config.alpha
set(value) {
circularButtons.first().config.alpha = value
}
/**
* We can take any of the global text color variables from the buttons
*/

View File

@ -1,10 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h10c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5zM17,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3z" />
android:pathData="M7,10l5,5 5,-5z" />
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6 1.41,-1.41z" />
</vector>

View File

@ -0,0 +1,11 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M15.41,16.59L10.83,12l4.58,-4.59L14,6l-6,6 6,6 1.41,-1.41z" />
</vector>

View File

@ -0,0 +1,11 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M8.59,16.59L13.17,12 8.59,7.41 10,6l6,6 -6,6 -1.41,-1.41z" />
</vector>

View File

@ -0,0 +1,11 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M7.41,15.41L12,10.83l4.59,4.58L18,14l-6,-6 -6,6z" />
</vector>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M7,10A2,2 0 0,1 9,12A2,2 0 0,1 7,14A2,2 0 0,1 5,12A2,2 0 0,1 7,10M17,7A5,5 0 0,1 22,12A5,5 0 0,1 17,17H7A5,5 0 0,1 2,12A5,5 0 0,1 7,7H17M7,9A3,3 0 0,0 4,12A3,3 0 0,0 7,15H17A3,3 0 0,0 20,12A3,3 0 0,0 17,9H7Z" />
</vector>

View File

@ -2,6 +2,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/colorSurface" />
<corners
android:topLeftRadius="28dp"
android:topRightRadius="28dp"
android:bottomLeftRadius="28dp"
android:bottomRightRadius="28dp" />
</shape>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -8,7 +9,8 @@
<emu.skyline.views.AlignmentGridView
android:id="@+id/alignment_grid"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
tools:visibility="gone" />
<emu.skyline.input.onscreen.OnScreenControllerView
android:id="@+id/on_screen_controller_view"
@ -16,15 +18,261 @@
android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/fab_parent"
android:id="@+id/control_panel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|center_horizontal"
android:background="@drawable/top_sheet_bg"
android:alpha="0.85"
android:animateLayoutChanges="true"
android:background="@drawable/rounded_background"
android:backgroundTint="?attr/colorPrimaryContainer"
android:clickable="true"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="horizontal"
android:padding="10dp"
tools:layout_height="72dp"
tools:layout_width="512dp" />
android:orientation="vertical"
android:translationY="12dp">
<!-- Tob Bar -->
<FrameLayout
android:id="@+id/top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/drag_handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:minHeight="32dp"
android:paddingBottom="0dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/close_button"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="end"
android:padding="2dp"
app:icon="@drawable/ic_close"
app:iconTint="?attr/colorOnSurfaceVariant"
tools:ignore="ContentDescription,SpeakableTextPresentCheck,TouchTargetSizeCheck" />
</FrameLayout>
<!-- Control Panel Content -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/current_button"
style="?attr/textAppearanceTitleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/osc_current_button"
android:textSize="20sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/select_all_button"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="42dp"
android:text="@string/select_all"
app:layout_constraintBottom_toBottomOf="@id/current_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/current_button"
tools:ignore="TextContrastCheck,TouchTargetSizeCheck,VisualLintBounds" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/title_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="current_button,select_all_button" />
<!-- Checkboxes -->
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/enabled_checkbox"
style="?attr/checkboxStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="-6dp"
android:layout_marginTop="4dp"
android:minHeight="0dp"
android:paddingVertical="8dp"
android:text="@string/enabled"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/current_button"
tools:ignore="TouchTargetSizeCheck" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/toggle_mode_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:paddingVertical="8dp"
android:text="@string/osc_toggle_mode"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@+id/enabled_checkbox"
app:layout_constraintTop_toBottomOf="@id/enabled_checkbox"
tools:ignore="TouchTargetSizeCheck" />
<!-- Sliders -->
<include
android:id="@+id/scale_slider"
layout="@layout/osc_slider"
android:layout_width="160dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toggle_mode_checkbox" />
<include
android:id="@+id/opacity_slider"
layout="@layout/osc_slider"
android:layout_width="160dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:layout_constraintStart_toStartOf="@id/scale_slider"
app:layout_constraintTop_toBottomOf="@id/scale_slider" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/sliders_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="end"
app:constraint_referenced_ids="scale_slider,opacity_slider,enabled_checkbox,toggle_mode_checkbox" />
<!-- Horizontal (Left and Right) Move Buttons -->
<com.google.android.material.button.MaterialButton
android:id="@+id/move_left_button"
style="@style/Widget.Material3.Button.IconButton.Outlined"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
app:icon="@drawable/ic_keyboard_arrow_left"
app:layout_constraintBottom_toBottomOf="@id/actions_barrier"
app:layout_constraintEnd_toStartOf="@id/move_right_button"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/sliders_barrier"
app:layout_constraintTop_toTopOf="@id/title_barrier"
tools:ignore="TouchTargetSizeCheck,SpeakableTextPresentCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/move_right_button"
style="@style/Widget.Material3.Button.IconButton.Outlined"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/ic_keyboard_arrow_right"
app:layout_constraintBottom_toBottomOf="@id/actions_barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@id/move_left_button"
app:layout_constraintTop_toTopOf="@id/title_barrier"
tools:ignore="TouchTargetSizeCheck,SpeakableTextPresentCheck" />
<!-- Vertical (Up and Down) Move Buttons -->
<com.google.android.material.button.MaterialButton
android:id="@+id/move_up_button"
style="@style/Widget.Material3.Button.IconButton.Outlined"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:icon="@drawable/ic_keyboard_arrow_up"
app:layout_constraintBottom_toTopOf="@id/move_down_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@id/sliders_barrier"
app:layout_constraintTop_toBottomOf="@id/title_barrier"
app:layout_constraintVertical_chainStyle="packed"
tools:ignore="TouchTargetSizeCheck,SpeakableTextPresentCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/move_down_button"
style="@style/Widget.Material3.Button.IconButton.Outlined"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/ic_keyboard_arrow_down"
app:layout_constraintBottom_toTopOf="@id/actions_barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@id/sliders_barrier"
app:layout_constraintTop_toBottomOf="@id/move_up_button"
tools:ignore="TouchTargetSizeCheck,SpeakableTextPresentCheck" />
<!-- Action Buttons -->
<com.google.android.material.button.MaterialButton
android:id="@+id/color_button"
style="@style/Widget.Material3.Button.IconButton.Filled"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:tooltipText="@string/button_color"
app:icon="@drawable/ic_palette"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/grid_button"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toEndOf="@id/sliders_barrier"
tools:ignore="TouchTargetSizeCheck,SpeakableTextPresentCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/grid_button"
style="@style/Widget.Material3.Button.IconButton.Filled"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:tooltipText="@string/toggle_grid"
app:icon="@drawable/ic_grid_on"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reset_button"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@id/color_button"
tools:ignore="TouchTargetSizeCheck,SpeakableTextPresentCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/reset_button"
style="@style/Widget.Material3.Button.IconButton.Filled"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:tooltipText="@string/reset"
app:ensureMinTouchTargetSize="false"
app:icon="@drawable/ic_restore"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@id/grid_button"
tools:ignore="ContentDescription,SpeakableTextPresentCheck,TouchTargetSizeCheck" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/actions_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="reset_button,grid_button,color_button" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</FrameLayout>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/title"
style="?attr/textAppearanceBodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Slider" />
<TextView
android:id="@+id/valueLabel"
style="?attr/textAppearanceBodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="50%" />
<com.google.android.material.slider.Slider
android:id="@+id/slider"
style="?attr/sliderStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="8dp"
app:haloRadius="18dp"
app:labelBehavior="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title"
app:thumbRadius="8dp"
app:trackColorInactive="@null"
tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck"
tools:value="0.5" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,7 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<dimen name="grid_padding">8dp</dimen>
<dimen name="onScreenItemHorizontalMargin">10dp</dimen>
<dimen name="cornerRadius">6dp</dimen>
<dimen name="cornerRadiusMedium">12dp</dimen>
<!-- Remove the default material slider vertical padding -->
<dimen name="mtrl_slider_widget_height" tools:override="true">0dp</dimen>
</resources>

View File

@ -5,6 +5,7 @@
<string name="error">An error has occurred</string>
<string name="copied_to_clipboard">Copied to clipboard</string>
<string name="emulator">Emulator</string>
<string name="enabled">Enabled</string>
<!-- Toolbar Main -->
<string name="settings">Settings</string>
<string name="share_logs">Share Logs</string>
@ -160,8 +161,15 @@
<string name="osc_edit">Edit On-Screen Controls layout</string>
<string name="osc_text_color">Text color</string>
<string name="osc_background_color">Background color</string>
<string name="osc_reset">Reset On-Screen Controls</string>
<string name="osc_reset_confirm">Are you sure you want to reset the On-Screen Controls?</string>
<string name="osc_reset">Reset Button: %1$s</string>
<string name="osc_reset_confirm">Are you sure you want to reset this button?</string>
<string name="osc_toggle_mode">Toggle Mode</string>
<string name="osc_current_button">Current Button: %1$s</string>
<string name="select_all">Select All</string>
<string name="osc_scale">Scale</string>
<string name="osc_opacity">Opacity</string>
<string name="button_color">Button color</string>
<string name="toggle_grid">Toggle grid</string>
<string name="setup_guide">Setup Guide</string>
<string name="setup_guide_description">Sequentially map every stick and button</string>
<string name="joystick">Joystick</string>