Tabbed pager architecture update (#6460)

* Change TabbedPager mechanism to communicate page activation

* Change TabbedPager mechanism for fixed content

* OptionsPopup better use of TabbedPager

* TabbedPager arrow keys

* After-merge patch

Co-authored-by: Yair Morgenstern <yairm210@hotmail.com>
This commit is contained in:
SomeTroglodyte 2022-04-04 17:06:58 +02:00 committed by GitHub
parent 5876047bda
commit 8385f814a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 144 additions and 108 deletions

View File

@ -61,9 +61,7 @@ class DiplomacyOverviewTab (
update()
}
override fun getFixedContent(): WidgetGroup {
return fixedContent
}
override fun getFixedContent() = fixedContent
// Refresh content and determine landscape/portrait layout
private fun update() {

View File

@ -52,10 +52,8 @@ class EmpireOverviewScreen(
keyPressDispatcher = keyPressDispatcher,
capacity = EmpireOverviewCategories.values().size)
tabbedPager.addPage(Constants.close) {
_, _ -> game.setWorldScreen()
}
tabbedPager.getPageButton(0).setColor(0.75f, 0.1f, 0.1f, 1f)
tabbedPager.bindArrowKeys()
tabbedPager.addClosePage { game.setWorldScreen() }
for (category in EmpireOverviewCategories.values()) {
val tabState = category.stateTester(viewingPlayer)
@ -70,17 +68,8 @@ class EmpireOverviewScreen(
icon, iconSize,
disabled = tabState != EmpireOverviewTabState.Normal,
shortcutKey = category.shortcutKey,
scrollAlign = category.scrollAlign,
fixedContent = pageObject.getFixedContent(),
onDeactivation = { _, _, scrollY -> pageObject.deactivated(scrollY) }
) {
index, name ->
val scrollY = pageObject.activated()
if (scrollY != null) tabbedPager.setPageScrollY(index, scrollY)
if (name == "Stats")
game.settings.addCompletedTutorialTask("See your stats breakdown")
game.settings.lastOverviewPage = name
}
scrollAlign = category.scrollAlign
)
if (category.name == page)
tabbedPager.selectPage(index)
}
@ -99,6 +88,6 @@ class EmpireOverviewScreen(
fun resizePage(tab: EmpireOverviewTab) {
val category = (pageObjects.entries.find { it.value == tab } ?: return).key
tabbedPager.replacePage(category.name, tab, tab.getFixedContent())
tabbedPager.replacePage(category.name, tab)
}
}

View File

@ -2,10 +2,10 @@ package com.unciv.ui.overviewscreen
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup
import com.badlogic.gdx.utils.Align
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.TabbedPager
import com.unciv.ui.utils.packIfNeeded
import com.unciv.ui.utils.toLabel
@ -13,17 +13,18 @@ abstract class EmpireOverviewTab (
val viewingPlayer: CivilizationInfo,
val overviewScreen: EmpireOverviewScreen,
persistedData: EmpireOverviewTabPersistableData? = null
) : Table(BaseScreen.skin) {
) : Table(BaseScreen.skin), TabbedPager.IPageExtensions {
open class EmpireOverviewTabPersistableData {
open fun isEmpty() = true
}
open val persistableData = persistedData ?: EmpireOverviewTabPersistableData()
/** Override if your Tab needs to do stuff on activation. @return non-null to scroll the Tab vertically within the TabbedPager. */
open fun activated(): Float? = null
/** Override if your Tab needs to do housekeeping when it loses focus. [scrollY] is the Tab's current vertical scroll position. */
open fun deactivated(scrollY: Float) {}
/** Override to supply content not participating in scrolling */
open fun getFixedContent(): WidgetGroup? = null
override fun activated(index: Int, caption: String, pager: TabbedPager) {
val settings = overviewScreen.game.settings
if (caption == "Stats")
settings.addCompletedTutorialTask("See your stats breakdown")
settings.lastOverviewPage = caption
}
val gameInfo = viewingPlayer.gameInfo

View File

@ -37,8 +37,7 @@ class ReligionOverviewTab(
private val statsTable = Table()
private val beliefsTable = Table()
override fun getFixedContent(): WidgetGroup? {
return Table().apply {
override fun getFixedContent() = Table().apply {
defaults().pad(5f)
align(Align.top)
@ -49,7 +48,6 @@ class ReligionOverviewTab(
add(religionButtonLabel)
addSeparator()
}
}
init {
defaults().pad(5f)

View File

@ -3,7 +3,6 @@ package com.unciv.ui.overviewscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.civilization.CivilizationInfo

View File

@ -4,7 +4,6 @@ import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.logic.civilization.CivilizationInfo
@ -30,9 +29,13 @@ class UnitOverviewTab(
}
override val persistableData = (persistedData as? UnitTabPersistableData) ?: UnitTabPersistableData()
override fun activated() = persistableData.scrollY
override fun deactivated(scrollY: Float) {
persistableData.scrollY = scrollY
override fun activated(index: Int, caption: String, pager: TabbedPager) {
if (persistableData.scrollY != null)
pager.setPageScrollY(index, persistableData.scrollY!!)
super.activated(index, caption, pager)
}
override fun deactivated(index: Int, caption: String, pager: TabbedPager) {
persistableData.scrollY = pager.getPageScrollY(index)
}
private val supplyTableWidth = (overviewScreen.stage.width * 0.25f).coerceAtLeast(240f)
@ -40,9 +43,7 @@ class UnitOverviewTab(
private val unitHeaderTable = Table()
private val fixedContent = Table()
override fun getFixedContent(): WidgetGroup {
return fixedContent
}
override fun getFixedContent() = fixedContent
init {
fixedContent.add(getUnitSupplyTable()).align(Align.top).padBottom(10f).row()

View File

@ -81,9 +81,7 @@ class WonderOverviewTab(
private val wonders: Array<WonderInfo> = collectInfo()
private val fixedContent = Table()
override fun getFixedContent(): WidgetGroup {
return fixedContent
}
override fun getFixedContent() = fixedContent
init {
fixedContent.apply {

View File

@ -1,5 +1,6 @@
package com.unciv.ui.utils
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.*
import com.badlogic.gdx.scenes.scene2d.ui.*
@ -9,11 +10,8 @@ import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip
/*
Unimplemented ideas:
Use fixedContent for OptionsPopup mod check tab
*/
//TODO If keys are assigned, the widget is in a popup not filling stage width, and a button is
// partially visible on the right end, the key tooltip will show outside the parent.
/**
* Implements a 'Tabs' widget where different pages can be switched by selecting a header button.
@ -72,10 +70,27 @@ class TabbedPager(
private val fixedContentScroll = LinkedScrollPane(horizontalOnly = true)
private val fixedContentScrollCell: Cell<ScrollPane>
private val contentScroll = LinkedScrollPane(horizontalOnly = false, linkTo = fixedContentScroll)
private var savedScrollListener: EventListener? = null
private val deferredSecretPages = ArrayDeque<PageState>(0)
private var askPasswordLock = false
//endregion
//region Public Interfaces
/** Pages added via [addPage] can optionally implement this to get notified when they are
* [activated] or [deactivated], or to provide [fixed content][getFixedContent] */
interface IPageExtensions {
/** Called by [TabbedPager] after a page is shown, whether by user click or programmatically. */
fun activated(index: Int, caption: String, pager: TabbedPager)
/** Called by [TabbedPager] before a page is hidden, whether by user click or programmatically. */
fun deactivated(index: Int, caption: String, pager: TabbedPager) {}
/** @return Optional second content [Actor], will be placed outside the tab's main [ScrollPane] between header and `content`. Scrolls horizontally only. */
fun getFixedContent(): Actor? = null
}
//endregion
//region Private Classes
@ -84,8 +99,6 @@ class TabbedPager(
var content: Actor,
var fixedContent: Actor?,
var disabled: Boolean,
val onActivation: ((Int, String) -> Unit)?,
val onDeactivation: ((Int, String, Float) -> Unit)?,
icon: Actor?,
iconSize: Float,
val shortcutKey: KeyCharAndCode,
@ -247,6 +260,12 @@ class TabbedPager(
}
}
private class EmptyClosePage(private val action: ()->Unit) : Actor(), IPageExtensions {
override fun activated(index: Int, caption: String, pager: TabbedPager) {
action()
}
}
//endregion
//region Initialization
@ -312,7 +331,7 @@ class TabbedPager(
if (activePage != -1) {
val page = pages[activePage]
page.onDeactivation?.invoke(activePage, page.caption, contentScroll.scrollY)
(page.content as? IPageExtensions)?.deactivated(activePage, page.caption, this)
page.button.color = Color.WHITE
fixedContentScroll.actor = null
page.scrollX = contentScroll.scrollX
@ -359,7 +378,8 @@ class TabbedPager(
else
// when coming from a tap/click, can we at least ensure no part of it is outside the visible area
headerScroll.run { scrollX = scrollX.coerceIn((page.buttonX + page.buttonW - scrollWidth)..page.buttonX) }
page.onActivation?.invoke(index, page.caption)
(page.content as? IPageExtensions)?.activated(index, page.caption, this)
}
return true
}
@ -393,6 +413,12 @@ class TabbedPager(
/** Access a page's header button e.g. for unusual formatting */
fun getPageButton(index: Int) = pages[index].button
/** Query the vertical scroll position af a page's contents */
fun getPageScrollY(index: Int): Float {
if (index == activePage) return contentScroll.scrollY
if (index !in 0 until pages.size) return 0f
return pages[index].scrollY
}
/** Change the vertical scroll position af a page's contents */
fun setPageScrollY(index: Int, scrollY: Float, animation: Boolean = false) {
if (index !in 0 until pages.size) return
@ -403,6 +429,32 @@ class TabbedPager(
if (!animation) contentScroll.updateVisualScroll()
}
/** Disable/Enable built-in ScrollPane for content pages, including focus stealing prevention */
fun setScrollDisabled(disabled: Boolean) {
if (disabled == contentScroll.isScrollingDisabledY) return
contentScroll.setScrollingDisabled(disabled, disabled)
if (disabled) {
savedScrollListener = contentScroll.captureListeners.first()
contentScroll.captureListeners.clear()
} else {
if (savedScrollListener != null)
contentScroll.addCaptureListener(savedScrollListener)
}
}
/** Bind arrow keys to navigate pages left/right.
* Needs [keyPressDispatcher] to be set on instantiation.
* Caller is responsible for cleanup if necessary. */
fun bindArrowKeys() {
if (keyPressDispatcher == null) return
fun cyclePage(direction: Int) {
if (activePage == -1) return
selectPage((activePage + direction).coerceIn(0 until pages.size))
}
keyPressDispatcher[KeyCharAndCode(Input.Keys.LEFT)] = { cyclePage(-1) }
keyPressDispatcher[KeyCharAndCode(Input.Keys.RIGHT)] = { cyclePage(1) }
}
/** Remove a page by its index.
* @return `true` if page successfully removed */
fun removePage(index: Int): Boolean {
@ -425,18 +477,7 @@ class TabbedPager(
if (isActive) selectPage(-1)
pages[index].let {
it.content = content
measureContent(it)
}
if (isActive) selectPage(index)
}
/** Replace a page's [content] and [fixedContent] by its [index]. */
fun replacePage(index: Int, content: Actor, fixedContent: Actor?) {
if (index !in 0 until pages.size) return
val isActive = index == activePage
if (isActive) selectPage(-1)
pages[index].let {
it.content = content
it.fixedContent = fixedContent
it.fixedContent = (content as? IPageExtensions)?.getFixedContent()
measureContent(it)
}
if (isActive) selectPage(index)
@ -444,22 +485,17 @@ class TabbedPager(
/** Replace a page's [content] by its [caption]. */
fun replacePage(caption: String, content: Actor) = replacePage(getPageIndex(caption), content)
/** Replace a page's [content] and [fixedContent] by its [caption]. */
fun replacePage(caption: String, content: Actor, fixedContent: Actor?) = replacePage(getPageIndex(caption), content, fixedContent)
/** Add a page!
* @param caption Text to be shown on the header button (automatically translated), can later be used to reference the page in other calls.
* @param content [Actor] to show in the lower area when this page is selected.
* @param content [Actor] to show in the lower area when this page is selected. Can optionally implement [IPageExtensions] to be notified of activation or deactivation.
* @param icon Actor, typically an [Image], to show before the caption on the header button.
* @param iconSize Size for [icon] - if not zero, the icon is wrapped to allow a [setSize] even on [Image] which ignores size.
* @param insertBefore -1 to add at the end, or index of existing page to insert this before it.
* @param secret Marks page as 'secret'. A password is asked once per [TabbedPager] and if it does not match the has passed in the constructor the page and all subsequent secret pages are dropped.
* @param disabled Initial disabled state. Disabled pages cannot be selected even with [selectPage], their button is dimmed.
* @param shortcutKey Optional keyboard key to associate - goes to the [KeyPressDispatcher] passed in the constructor.
* @param syncScroll If on, the ScrollPanes for [content] and [fixedContent] will synchronize horizontally.
* @param fixedContent Optional second content [Actor], will be placed outside the tab's main [ScrollPane] between header and [content]. Scrolls horizontally only.
* @param onDeactivation _Optional_ callback called when this page is hidden. Lambda arguments are page index and caption, and scrollY of the tab's [ScrollPane].
* @param onActivation _Optional_ callback called when this page is shown (per actual change to this page, not per header click). Lambda arguments are page index and caption.
* @param syncScroll If on, the ScrollPanes for [content] and [fixed content][IPageExtensions.getFixedContent] will synchronize horizontally.
* @return The new page's index or -1 if it could not be immediately added (secret).
*/
fun addPage(
@ -472,19 +508,14 @@ class TabbedPager(
disabled: Boolean = false,
shortcutKey: KeyCharAndCode = KeyCharAndCode.UNKNOWN,
scrollAlign: Int = Align.top,
syncScroll: Boolean = true,
fixedContent: Actor? = null,
onDeactivation: ((Int, String, Float) -> Unit)? = null,
onActivation: ((Int, String) -> Unit)? = null
syncScroll: Boolean = true
): Int {
// Build page descriptor and header button
val page = PageState(
caption = caption,
content = content ?: Group(),
fixedContent = fixedContent,
fixedContent = (content as? IPageExtensions)?.getFixedContent(),
disabled = disabled,
onActivation = onActivation,
onDeactivation = onDeactivation,
icon = icon,
iconSize = iconSize,
shortcutKey = shortcutKey,
@ -514,6 +545,18 @@ class TabbedPager(
return addAndShowPage(page, insertBefore)
}
/**
* Add a "Close" button tho the Tab headers, with empty content which will invoke [action] when clicked
*/
fun addClosePage(
insertBefore: Int = -1,
color: Color = Color(0.75f, 0.1f, 0.1f, 1f),
action: ()->Unit
) {
val index = addPage(Constants.close, EmptyClosePage(action), insertBefore = insertBefore)
pages[index].button.color = color
}
/**
* Activate any [secret][addPage] pages by asking for the password.
*

View File

@ -16,6 +16,7 @@ import com.unciv.UncivGame
import com.unciv.logic.MapSaver
import com.unciv.logic.civilization.PlayerType
import com.unciv.models.UncivSound
import com.unciv.models.metadata.BaseRuleset
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.Ruleset.RulesetError
import com.unciv.models.ruleset.Ruleset.RulesetErrorSeverity
@ -59,7 +60,6 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) {
private var modCheckBaseSelect: TranslatedSelectBox? = null
private val modCheckResultTable = Table()
private val selectBoxMinWidth: Float
private val previousMaxWorldZoom = settings.maxWorldZoomOut
//endregion
@ -81,7 +81,7 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) {
tabMaxHeight = (if (isPortrait()) 0.7f else 0.8f) * stage.height
}
tabs = TabbedPager(tabMinWidth, tabMaxWidth, 0f, tabMaxHeight,
headerFontSize = 21, backgroundColor = Color.CLEAR, capacity = 8)
headerFontSize = 21, backgroundColor = Color.CLEAR, keyPressDispatcher = this.keyPressDispatcher, capacity = 8)
add(tabs).pad(0f).grow().row()
tabs.addPage("About", getAboutTab(), ImageGetter.getExternalImage("Icon.png"), 24f)
@ -91,14 +91,17 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) {
tabs.addPage("Sound", getSoundTab(), ImageGetter.getImage("OtherIcons/Speaker"), 24f)
tabs.addPage("Multiplayer", getMultiplayerTab(), ImageGetter.getImage("OtherIcons/Multiplayer"), 24f)
tabs.addPage("Advanced", getAdvancedTab(), ImageGetter.getImage("OtherIcons/Settings"), 24f)
if (RulesetCache.size > 1) {
tabs.addPage("Locate mod errors", getModCheckTab(), ImageGetter.getImage("OtherIcons/Mods"), 24f) { _, _ ->
if (RulesetCache.size > BaseRuleset.values().size) {
val content = ModCheckTab(this) {
if (modCheckFirstRun) runModChecker()
else runModChecker(modCheckBaseSelect!!.selected.value)
}
tabs.addPage("Locate mod errors", content, ImageGetter.getImage("OtherIcons/Mods"), 24f)
}
if (Gdx.input.isKeyPressed(Input.Keys.SHIFT_RIGHT) && (Gdx.input.isKeyPressed(Input.Keys.CONTROL_RIGHT) || Gdx.input.isKeyPressed(Input.Keys.ALT_RIGHT))) {
tabs.addPage("Debug", getDebugTab(), ImageGetter.getImage("OtherIcons/SecretOptions"), 24f, secret = true)
}
tabs.bindArrowKeys() // If we're sharing WorldScreen's dispatcher that's OK since it does revertToCheckPoint on update
addCloseButton {
previousScreen.game.musicController.onChange(null)
@ -274,27 +277,22 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) {
settings.save()
connectionToServerButton.isEnabled = multiplayerServerTextField.text != Constants.dropboxMultiplayerServer
}
serverIpTable.add(multiplayerServerTextField).width(screen.stage.width / 2)
add(serverIpTable).row()
serverIpTable.add(multiplayerServerTextField).minWidth(screen.stage.width / 2).growX()
add(serverIpTable).fillX().row()
add("Reset to Dropbox".toTextButton().onClick {
multiplayerServerTextField.text = Constants.dropboxMultiplayerServer
}).row()
add(connectionToServerButton.onClick {
val popup = Popup(screen).apply {
val popup = Popup(screen).apply {
addGoodSizedLabel("Awaiting response...").row()
}
popup.open(true)
successfullyConnectedToServer { success: Boolean, result: String ->
if (success) {
popup.addGoodSizedLabel("Success!").row()
popup.addCloseButton()
} else {
popup.addGoodSizedLabel("Failed!").row()
popup.addCloseButton()
}
successfullyConnectedToServer { success: Boolean, _: String ->
popup.addGoodSizedLabel(if (success) "Success!" else "Failed!").row()
popup.addCloseButton()
}
}).row()
}
@ -373,27 +371,38 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) {
addSetUserId()
}
private fun getModCheckTab() = Table(BaseScreen.skin).apply {
defaults().pad(10f).align(Align.top)
val reloadModsButton = "Reload mods".toTextButton().onClick {
runModChecker(modCheckBaseSelect!!.selected.value)
}
add(reloadModsButton).row()
private class ModCheckTab(
options: OptionsPopup,
private val runAction: ()->Unit
) : Table(), TabbedPager.IPageExtensions {
private val fixedContent = Table()
val labeledBaseSelect = Table(BaseScreen.skin).apply {
add("Check extension mods based on:".toLabel()).padRight(10f)
val baseMods = listOf(modCheckWithoutBase) + RulesetCache.getSortedBaseRulesets()
modCheckBaseSelect = TranslatedSelectBox(baseMods, modCheckWithoutBase, BaseScreen.skin).apply {
selectedIndex = 0
onChange {
runModChecker(modCheckBaseSelect!!.selected.value)
init {
defaults().pad(10f).align(Align.top)
fixedContent.defaults().pad(10f).align(Align.top)
val reloadModsButton = "Reload mods".toTextButton().onClick(runAction)
fixedContent.add(reloadModsButton).row()
val labeledBaseSelect = Table().apply {
add("Check extension mods based on:".toLabel()).padRight(10f)
val baseMods = listOf(modCheckWithoutBase) + RulesetCache.getSortedBaseRulesets()
options.modCheckBaseSelect = TranslatedSelectBox(baseMods, modCheckWithoutBase, BaseScreen.skin).apply {
selectedIndex = 0
onChange { runAction() }
}
add(options.modCheckBaseSelect)
}
add(modCheckBaseSelect)
}
add(labeledBaseSelect).row()
fixedContent.add(labeledBaseSelect).row()
add(modCheckResultTable)
add(options.modCheckResultTable)
}
override fun getFixedContent() = fixedContent
override fun activated(index: Int, caption: String, pager: TabbedPager) {
runAction()
}
}
private fun runModChecker(base: String = modCheckWithoutBase) {