This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,12 @@
--[[
Package link auto-generated by Rotriever
]]
local PackageIndex = script.Parent.Parent
local package = PackageIndex["roblox_cryo"]["cryo"]
if package.ClassName == "ModuleScript" then
return require(package)
end
return package
@@ -0,0 +1,12 @@
--[[
Package link auto-generated by Rotriever
]]
local PackageIndex = script.Parent.Parent
local package = PackageIndex["roblox_roact"]["roact"]
if package.ClassName == "ModuleScript" then
return require(package)
end
return package
@@ -0,0 +1,12 @@
--[[
Package link auto-generated by Rotriever
]]
local PackageIndex = script.Parent.Parent
local package = PackageIndex["roblox_enumerate"]["enumerate"]
if package.ClassName == "ModuleScript" then
return require(package)
end
return package
@@ -0,0 +1,11 @@
# Generated by Rotriever. Format subject to change in future releases.
name = "roblox/roact-gamepad"
version = "0.4.4"
commit = "e5eb28c3a4981f971378372570ce9cd4ee023137"
source = "url+https://github.com/roblox/roact-gamepad"
dependencies = [
"Cryo roblox/cryo 1.0.0 url+https://github.com/roblox/cryo",
"Roact roblox/roact 1.3.0 url+https://github.com/roblox/roact",
"enumerate roblox/enumerate 1.0.0 url+https://github.com/roblox/enumerate",
"t roblox/t 1.2.5 url+https://github.com/roblox/t",
]
@@ -0,0 +1,4 @@
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
return Roact.createContext(nil)
@@ -0,0 +1,359 @@
--!nonstrict
-- Manages groups of selectable elements, reacting to selection changes for
-- individual items and triggering events for group selection changes
local Packages = script.Parent.Parent
local Cryo = require(Packages.Cryo)
local Input = require(script.Parent.Input)
local createSignal = require(script.Parent.createSignal)
local debugPrint = require(script.Parent.debugPrint)
local InternalApi = require(script.Parent.FocusControllerInternalApi)
local FocusControllerInternal = {}
FocusControllerInternal.__index = FocusControllerInternal
function FocusControllerInternal.new()
local self = setmetatable({
selectionChangedSignal = createSignal(),
boundInputsChangedSignal = createSignal(),
focusNodeTree = {},
allNodes = {},
rootRef = nil,
engineInterface = nil,
captureFocusOnInitialize = false,
inputDisconnectors = {},
boundInputs = {},
focusedLeaf = nil,
}, FocusControllerInternal)
return self
end
function FocusControllerInternal:moveFocusTo(ref)
if self.engineInterface == nil then
error("FocusController is not connected to a component hierarchy!", 2)
end
debugPrint("[FOCUS] Move focus to", ref)
local node = self.allNodes[ref]
if node ~= nil and not self:isNodeFocused(node) then
node:focus()
end
end
function FocusControllerInternal:moveFocusToNeighbor(neighborProp)
if self.engineInterface == nil then
error("FocusController is not connected to a component hierarchy!", 2)
end
if self.focusedLeaf ~= nil then
debugPrint("[FOCUS] Move focus to", neighborProp, "from", self.focusedLeaf.ref)
local refValue = self.focusedLeaf.ref:getValue()
if refValue ~= nil and refValue[neighborProp] ~= nil then
self:setSelection(refValue[neighborProp])
end
end
end
function FocusControllerInternal:getSelection()
return self.engineInterface.getSelection()
end
function FocusControllerInternal:setSelection(ref)
self.engineInterface.setSelection(ref)
end
function FocusControllerInternal:registerNode(parentNode, refKey, node)
if parentNode ~= nil then
debugPrint("[TREE ] Registering child node", refKey)
local parentEntry = self.focusNodeTree[parentNode] or {}
parentEntry[refKey] = node
self.focusNodeTree[parentNode] = parentEntry
else
debugPrint("[TREE ] Registering root node", refKey)
self.rootRef = refKey
end
self.allNodes[refKey] = node
end
function FocusControllerInternal:deregisterNode(parentNode, refKey)
debugPrint("[TREE ] Deregistering child node", refKey)
if parentNode ~= nil then
self.focusNodeTree[parentNode][refKey] = nil
end
self.allNodes[refKey] = nil
end
function FocusControllerInternal:descendantRemovedRefocus()
-- If focusedLeaf is nil, then we've lost focus altogether, which likely
-- means that focus belongs to a different focusable tree. Since that's out
-- of our control, we can stop here.
if self.focusedLeaf == nil then
return
end
-- If the currently focused leaf has a nil ref, then its associated host
-- component has unmounted and we need to refocus.
if self.focusedLeaf.ref:getValue() == nil then
debugPrint("[FOCUS] Focused node was removed; refocusing from nearest existing ancestor")
-- Climb up the focusedLeaf's ancestry until we find a node that still
-- exists; if we do find one, focus it
local ancestorNode = self.focusedLeaf.parent
while ancestorNode ~= nil and self.allNodes[ancestorNode.ref] == nil do
ancestorNode = ancestorNode.Parent
end
if ancestorNode ~= nil then
ancestorNode:focus()
end
end
end
function FocusControllerInternal:descendantAddedRefocus()
-- If focusedLeaf is nil, then we've lost focus altogether, which likely
-- means that focus belongs to a different focusable tree. Since that's out
-- of our control, we can stop here.
if self.focusedLeaf == nil then
return
end
-- If the current focusedLeaf has children, then descendants must have been
-- added to it, and we should re-run its focus logic.
if not Cryo.isEmpty(self:getChildren(self.focusedLeaf)) then
-- A new descendant was introduced, which means that we need to refocus
-- the current leaf
debugPrint("[FOCUS] Currently-focused node is no longer a leaf; refocusing", self.focusedLeaf.ref)
self.focusedLeaf:focus()
end
end
function FocusControllerInternal:getChildren(parentNode)
return self.focusNodeTree[parentNode] or {}
end
function FocusControllerInternal:isNodeFocused(node)
if self.focusedLeaf == nil then
return false
end
if self.focusedLeaf == node then
return true
end
-- Find out if one of the focused leaf's parents is equal to the provided
-- node, in which case, it remains in focus
local parentNode = self.focusedLeaf.parent
while parentNode ~= nil do
if parentNode == node then
return true
end
parentNode = parentNode.parent
end
return false
end
-- Prints a human-readable version of the node tree.
function FocusControllerInternal:debugPrintTree()
local function recursePrintTree(node, indent)
-- Print the current node
debugPrint(indent, tostring(node.ref))
-- Recurse through children
local children = self:getChildren(node)
for _, childNode in pairs(children) do
recursePrintTree(childNode, indent .. " ")
end
end
debugPrint("Printing Focus Node Tree:")
local rootNode = self.allNodes[self.rootRef]
recursePrintTree(rootNode, "")
end
function FocusControllerInternal:updateInputBindings()
local newBindings = {}
local focusChainNode = self.focusedLeaf
while focusChainNode ~= nil do
for _, binding in pairs(focusChainNode.inputBindings) do
local key = Input.getUniqueKey(binding)
local existing = newBindings[key]
if existing == nil then
debugPrint("[INPUT] Bind input", key)
newBindings[key] = binding
end
end
focusChainNode = focusChainNode.parent
end
-- It's pretty straightforward to simply disconnect and reconnect all event
-- connections whenever this function is called; we wouldn't typically be
-- able to rely on binding identity equality anyways
for _, disconnector in pairs(self.inputDisconnectors) do
disconnector()
end
self.inputDisconnectors = {}
self.boundInputs = {}
for key, binding in pairs(newBindings) do
self.inputDisconnectors[key] = Input.connectToEvent(binding, self.engineInterface)
if binding.keyCode then
self.boundInputs[binding.keyCode] = binding.meta or {}
end
end
end
function FocusControllerInternal:initialize(engineInterface)
-- If the engineInterface is already set, then this FocusController was
-- probably also assigned to another tree
if self.engineInterface ~= nil then
error("FocusController cannot be initialized more than once; make sure you are not passing it to multiple components")
end
self.engineInterface = engineInterface
-- Create a connection to the GuiService property relevant to the navigation
-- tree we want to connect
self.guiServiceConnection = engineInterface.subscribeToSelectionChanged(function()
-- This FocusController is not attached to an Instance hierarchy yet, so
-- we shouldn't try to manage selection
if self.rootRef == nil then
return
end
-- Track whether or not the previous focus was inside this hierarchy
local wasPreviouslyFocused = self.focusedLeaf ~= nil
-- Nil out our focusedLeaf (we'll recalculate it if necessary) and get
-- the current selection
self.focusedLeaf = nil
local selectedInstance = engineInterface.getSelection()
local rootRefValue = self.rootRef:getValue()
-- If selection is occurring within this FocusControllerInternal's
-- hierarchy, we need to recompute the currently focused leaf
if selectedInstance ~= nil then
if rootRefValue == selectedInstance or selectedInstance:IsDescendantOf(rootRefValue) then
debugPrint(
"[EVENT] Selection changed to",
selectedInstance,
"in focus hierarchy beginning at",
rootRefValue
)
-- Find the currently-focused node within our hierarchy and set
-- self.focusedLeaf accordingly.
for ref, node in pairs(self.allNodes) do
if selectedInstance == ref:getValue() then
self.focusedLeaf = node
break
end
end
end
end
-- We should fire our selectionChanged signal in the event that any of
-- the following occur:
-- 1. Selection moved within the hierarchy
-- 2. Selection moved from outside the hierarchy to an element inside it
-- 3. Selection moved from inside the hierarchy to an element outside it
if self.focusedLeaf ~= nil or wasPreviouslyFocused then
self.selectionChangedSignal:fire()
-- Update input connections here
self:updateInputBindings()
self.boundInputsChangedSignal:fire(self.boundInputs)
end
end)
if self.captureFocusOnInitialize then
self:captureFocus()
end
end
function FocusControllerInternal:captureFocus()
if self.engineInterface == nil then
self.captureFocusOnInitialize = true
else
self.allNodes[self.rootRef]:focus()
end
end
function FocusControllerInternal:releaseFocus()
if self.engineInterface ~= nil then
self.engineInterface.setSelection(nil)
end
end
function FocusControllerInternal:teardown()
if self.guiServiceConnection ~= nil then
self.guiServiceConnection:Disconnect()
end
-- Disconnect all bound inputs. These can be left dangling when a whole tree
-- is unmounted at once
for _, disconnect in pairs(self.inputDisconnectors) do
disconnect()
end
-- Make sure this controller is restored to its uninitialized state
self.rootRef = nil
self.engineInterface = nil
self.captureFocusOnInitialize = false
self.focusedLeaf = nil
end
function FocusControllerInternal:subscribeToSelectionChange(callback)
debugPrint("[TREE ] New subscription to selection change event")
return self.selectionChangedSignal:subscribe(callback)
end
-- Creates an object with a public API for managing focus. This object can be
-- used in components to direct focus as necessary
function FocusControllerInternal.createPublicApiWrapper()
local focusControllerInternal = FocusControllerInternal.new()
return {
[InternalApi] = focusControllerInternal,
moveFocusTo = function(...)
focusControllerInternal:moveFocusTo(...)
end,
moveFocusLeft = function()
focusControllerInternal:moveFocusToNeighbor("NextSelectionLeft")
end,
moveFocusRight = function()
focusControllerInternal:moveFocusToNeighbor("NextSelectionRight")
end,
moveFocusUp = function()
focusControllerInternal:moveFocusToNeighbor("NextSelectionUp")
end,
moveFocusDown = function()
focusControllerInternal:moveFocusToNeighbor("NextSelectionDown")
end,
captureFocus = function()
focusControllerInternal:captureFocus()
end,
releaseFocus = function()
focusControllerInternal:releaseFocus()
end,
getBoundInputs = function()
return focusControllerInternal.boundInputs
end,
subscribeToBoundInputsChanged = function(callback)
return focusControllerInternal.boundInputsChangedSignal:subscribe(callback)
end,
}
end
return FocusControllerInternal
@@ -0,0 +1,452 @@
return function()
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
local FocusNode = require(script.Parent.FocusNode)
local FocusController = require(script.Parent.FocusController)
local InternalApi = require(script.Parent.FocusControllerInternalApi)
local Input = require(script.Parent.Input)
local MockEngine = require(script.Parent.Test.MockEngine)
local createSpy = require(script.Parent.Test.createSpy)
local function createRootNode(ref)
local node = FocusNode.new({
focusController = FocusController.createPublicApiWrapper(),
[Roact.Ref] = ref,
})
node:attachToTree(nil, function() end)
return node
end
local function addChildNode(parentNode)
local instance = Instance.new("Frame")
instance.Parent = parentNode.ref:getValue()
local childRef, _ = Roact.createBinding(instance)
local childNode = FocusNode.new({
parentFocusNode = parentNode,
[Roact.Ref] = childRef,
})
childNode:attachToTree(parentNode, function() end)
return childNode, childRef
end
describe("event management", function()
it("should not fire subscribed signals before it's initialized", function()
local ref, _ = Roact.createBinding(Instance.new("Frame"))
local focusNode = createRootNode(ref)
local focusController = focusNode.focusController[InternalApi]
local selectionChangeSpy = createSpy()
focusController:subscribeToSelectionChange(selectionChangeSpy.value)
local mockEngine, _ = MockEngine.new()
mockEngine:simulateSelectionChanged(Instance.new("Frame"))
expect(selectionChangeSpy.callCount).to.equal(0)
end)
it("should fire subscribed signals when selection changes occur once initialized", function()
local ref, _ = Roact.createBinding(Instance.new("Frame"))
local focusNode = createRootNode(ref)
local focusController = focusNode.focusController[InternalApi]
local selectionChangeSpy = createSpy()
focusController:subscribeToSelectionChange(selectionChangeSpy.value)
local mockEngine, engineInterface = MockEngine.new()
expect(selectionChangeSpy.callCount).to.equal(0)
focusController:initialize(engineInterface)
mockEngine:simulateSelectionChanged(ref:getValue())
expect(selectionChangeSpy.callCount).to.equal(1)
end)
end)
describe("focus logic", function()
it("should consider selected objects to be in focus", function()
local ref, _ = Roact.createBinding(Instance.new("Frame"))
local focusNode = createRootNode(ref)
local _, engineInterface = MockEngine.new()
local focusController = focusNode.focusController[InternalApi]
focusController:initialize(engineInterface)
expect(focusController:isNodeFocused(focusNode)).to.equal(false)
focusNode:focus()
expect(focusController:isNodeFocused(focusNode)).to.equal(true)
end)
it("should consider parent nodes of selected objects to be in focus", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local childNode, _ = addChildNode(parentNode)
local _, engineInterface = MockEngine.new()
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
expect(focusController:isNodeFocused(parentNode)).to.equal(false)
expect(focusController:isNodeFocused(childNode)).to.equal(false)
childNode:focus()
expect(focusController:isNodeFocused(parentNode)).to.equal(true)
expect(focusController:isNodeFocused(childNode)).to.equal(true)
end)
it("should change its notion of focus when selection is changed", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local childNodeA, _ = addChildNode(parentNode)
local childNodeB, childRefB = addChildNode(parentNode)
local _, engineInterface = MockEngine.new()
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
childNodeA:focus()
expect(focusController:isNodeFocused(childNodeA)).to.equal(true)
expect(focusController:isNodeFocused(childNodeB)).to.equal(false)
engineInterface.setSelection(childRefB:getValue())
expect(focusController:isNodeFocused(childNodeA)).to.equal(false)
expect(focusController:isNodeFocused(childNodeB)).to.equal(true)
end)
it("should change its notion of focus when selection changes via the engine", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local childNodeA, _ = addChildNode(parentNode)
local childNodeB, childRefB = addChildNode(parentNode)
local mockEngine, engineInterface = MockEngine.new()
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
childNodeA:focus()
expect(focusController:isNodeFocused(childNodeA)).to.equal(true)
expect(focusController:isNodeFocused(childNodeB)).to.equal(false)
mockEngine:simulateSelectionChanged(childRefB:getValue())
expect(focusController:isNodeFocused(childNodeA)).to.equal(false)
expect(focusController:isNodeFocused(childNodeB)).to.equal(true)
end)
it("should only track selection changes inside its hierarchy", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local childNodeA, _ = addChildNode(parentNode)
local childNodeB, _ = addChildNode(parentNode)
local mockEngine, engineInterface = MockEngine.new()
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
local selectionChangedSpy = createSpy()
focusController:subscribeToSelectionChange(selectionChangedSpy.value)
childNodeA:focus()
expect(focusController:isNodeFocused(childNodeA)).to.equal(true)
expect(selectionChangedSpy.callCount).to.equal(1)
-- Moving selection within the hierarchy should trigger fire events
childNodeB:focus()
expect(selectionChangedSpy.callCount).to.equal(2)
-- When selection changes from _inside_ this hierarchy to nil,
-- notify accordingly
mockEngine:simulateSelectionChanged(nil)
expect(selectionChangedSpy.callCount).to.equal(3)
-- Moving back into the hierarchy should once again trigger events
childNodeA:focus()
expect(selectionChangedSpy.callCount).to.equal(4)
-- When selection changes from inside the hierarchy to outside the
-- hierarchy, events should fire
local unconnectedFrame = Instance.new("Frame")
mockEngine:simulateSelectionChanged(unconnectedFrame)
expect(selectionChangedSpy.callCount).to.equal(5)
-- When selection changes between elements outside the hierarchy,
-- no events should be triggered
local unconnectedFrame2 = Instance.new("Frame")
mockEngine:simulateSelectionChanged(unconnectedFrame2)
expect(selectionChangedSpy.callCount).to.equal(5)
-- When selection changes from an element outside the hierarchy to
-- nil, no events should be triggered
mockEngine:simulateSelectionChanged(nil)
expect(selectionChangedSpy.callCount).to.equal(5)
end)
end)
describe("Tree-level focus management", function()
it("should not automatically capture focus", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local focusControllerInternal = parentNode.focusController[InternalApi]
expect(focusControllerInternal:isNodeFocused(parentNode)).to.equal(false)
end)
it("should focus the top-level node when captureFocus is called", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local _, engineInterface = MockEngine.new()
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
parentNode.focusController.captureFocus()
local focusControllerInternal = parentNode.focusController[InternalApi]
expect(focusControllerInternal:isNodeFocused(parentNode)).to.equal(true)
end)
it("should focus the top-level node when captureFocus is called, even if initialized afterwards", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local _, engineInterface = MockEngine.new()
-- Capture focus first, then initialize the node afterwards. This
-- simulates scenarios in which a component wants to captureFocus on
-- didMount, but is not yet parented to the DataModel
parentNode.focusController.captureFocus()
local focusControllerInternal = parentNode.focusController[InternalApi]
focusControllerInternal:initialize(engineInterface)
expect(focusControllerInternal:isNodeFocused(parentNode)).to.equal(true)
end)
it("should set selection to nil when focus is released", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local _, engineInterface = MockEngine.new()
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
parentNode.focusController.captureFocus()
expect(engineInterface.getSelection()).to.equal(rootRef:getValue())
parentNode.focusController.releaseFocus()
expect(engineInterface.getSelection()).to.equal(nil)
end)
end)
describe("Input binding", function()
it("should only call bound inputs when the element is in focus", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local childNodeA, _ = addChildNode(parentNode)
local callbackSpyA = createSpy()
childNodeA.inputBindings = {
action = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonX, callbackSpyA.value),
}
local childNodeB, _ = addChildNode(parentNode)
local callbackSpyB = createSpy()
childNodeB.inputBindings = {
action = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonX, callbackSpyB.value),
}
local mockEngine, engineInterface = MockEngine.new()
local focusControllerInternal = parentNode.focusController[InternalApi]
focusControllerInternal:initialize(engineInterface)
expect(callbackSpyA.callCount).to.equal(0)
expect(callbackSpyB.callCount).to.equal(0)
childNodeA:focus()
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
expect(callbackSpyA.callCount).to.equal(1)
expect(callbackSpyB.callCount).to.equal(0)
childNodeB:focus()
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
expect(callbackSpyA.callCount).to.equal(1)
expect(callbackSpyB.callCount).to.equal(1)
end)
it("should allow input bindings to override parent input bindings", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local callbackSpyParent = createSpy()
parentNode.inputBindings = {
action = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonX, callbackSpyParent.value),
}
-- ChildA does not override the parent's binding
local childNodeA, _ = addChildNode(parentNode)
-- ChildB has a binding to the same button, which overrides the parent
local childNodeB, _ = addChildNode(parentNode)
local callbackSpyChild = createSpy()
childNodeB.inputBindings = {
action = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonX, callbackSpyChild.value),
}
local mockEngine, engineInterface = MockEngine.new()
local focusControllerInternal = parentNode.focusController[InternalApi]
focusControllerInternal:initialize(engineInterface)
expect(callbackSpyParent.callCount).to.equal(0)
expect(callbackSpyChild.callCount).to.equal(0)
-- When A is focused, we should use the parent's input binding
childNodeA:focus()
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
expect(callbackSpyParent.callCount).to.equal(1)
expect(callbackSpyChild.callCount).to.equal(0)
-- When B is focused, we should use child B's input binding
childNodeB:focus()
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
expect(callbackSpyParent.callCount).to.equal(1)
expect(callbackSpyChild.callCount).to.equal(1)
end)
it("should override onStep bindings the same as begin and end", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local callbackSpyParent = createSpy()
parentNode.inputBindings = {
action = Input.PublicInterface.onStep(Enum.KeyCode.ButtonX, callbackSpyParent.value),
}
-- ChildA does not override the parent's binding
local childNodeA, _ = addChildNode(parentNode)
-- ChildB has a binding to the same button, which overrides the parent
local childNodeB, _ = addChildNode(parentNode)
local callbackSpyChild = createSpy()
childNodeB.inputBindings = {
action = Input.PublicInterface.onStep(Enum.KeyCode.ButtonX, callbackSpyChild.value),
}
local mockEngine, engineInterface = MockEngine.new()
local focusControllerInternal = parentNode.focusController[InternalApi]
focusControllerInternal:initialize(engineInterface)
expect(callbackSpyParent.callCount).to.equal(0)
expect(callbackSpyChild.callCount).to.equal(0)
-- When A is focused, we should use the parent's input binding
childNodeA:focus()
mockEngine:renderStep(0.03)
expect(callbackSpyParent.callCount).to.equal(1)
expect(callbackSpyChild.callCount).to.equal(0)
-- When B is focused, both it and the parent's binding run
childNodeB:focus()
mockEngine:renderStep(0.03)
expect(callbackSpyParent.callCount).to.equal(1)
expect(callbackSpyChild.callCount).to.equal(1)
end)
it("should override onMoveStep bindings wholesale, since they don't differ by keycode", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local callbackSpyParent = createSpy()
parentNode.inputBindings = {
action = Input.PublicInterface.onMoveStep(callbackSpyParent.value),
}
-- ChildA does not override the parent's binding
local childNodeA, _ = addChildNode(parentNode)
-- ChildB has a binding to the same button, which overrides the parent
local childNodeB, _ = addChildNode(parentNode)
local callbackSpyChild = createSpy()
childNodeB.inputBindings = {
action = Input.PublicInterface.onMoveStep(callbackSpyChild.value),
}
local mockEngine, engineInterface = MockEngine.new()
local focusControllerInternal = parentNode.focusController[InternalApi]
focusControllerInternal:initialize(engineInterface)
expect(callbackSpyParent.callCount).to.equal(0)
expect(callbackSpyChild.callCount).to.equal(0)
-- When A is focused, we should use the parent's input binding
childNodeA:focus()
mockEngine:renderStep(0.03)
expect(callbackSpyParent.callCount).to.equal(1)
expect(callbackSpyChild.callCount).to.equal(0)
-- When B is focused, its binding is run instead
childNodeB:focus()
mockEngine:renderStep(0.03)
expect(callbackSpyParent.callCount).to.equal(1)
expect(callbackSpyChild.callCount).to.equal(1)
end)
it("should update input bindings when focus changes", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local parentNode = createRootNode(rootRef)
local boundInputsChangedSpy = createSpy()
local disconnect = parentNode.focusController.subscribeToBoundInputsChanged(boundInputsChangedSpy.value)
local childNodeA, _ = addChildNode(parentNode)
childNodeA.inputBindings = {
action = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonX, function() end, {
key = "actionX"
}),
}
local childNodeB, _ = addChildNode(parentNode)
childNodeB.inputBindings = {
action1 = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonY, function() end, {
key = "actionY"
}),
action2 = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonA, function() end),
}
local _, engineInterface = MockEngine.new()
local focusControllerInternal = parentNode.focusController[InternalApi]
focusControllerInternal:initialize(engineInterface)
expect(boundInputsChangedSpy.callCount).to.equal(0)
childNodeA:focus()
expect(boundInputsChangedSpy.callCount).to.equal(1)
local boundInputs = parentNode.focusController.getBoundInputs()
expect(boundInputs[Enum.KeyCode.ButtonY]).to.equal(nil)
expect(boundInputs[Enum.KeyCode.ButtonA]).to.equal(nil)
expect(boundInputs[Enum.KeyCode.ButtonX].key).to.equal("actionX")
childNodeB:focus()
expect(boundInputsChangedSpy.callCount).to.equal(2)
boundInputs = parentNode.focusController.getBoundInputs()
expect(boundInputs[Enum.KeyCode.ButtonY].key).to.equal("actionY")
expect(boundInputs[Enum.KeyCode.ButtonX]).to.equal(nil)
-- expect a binding without a meta table field to return an empty table rather than nil
expect(#boundInputs[Enum.KeyCode.ButtonA]).to.equal(0)
disconnect()
childNodeA:focus()
expect(boundInputsChangedSpy.callCount).to.equal(2)
end)
end)
end
@@ -0,0 +1,3 @@
local Symbol = require(script.Parent.Symbol)
return Symbol.named("FocusControllerInternalApi")
@@ -0,0 +1,164 @@
--!nonstrict
-- Manages groups of selectable elements, reacting to selection changes for
-- individual items and triggering events for group selection changes
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
local Cryo = require(Packages.Cryo)
local InternalApi = require(script.Parent.FocusControllerInternalApi)
local FocusNode = {}
FocusNode.__index = FocusNode
function FocusNode.new(navProps)
local focusController
if navProps.parentFocusNode ~= nil then
focusController = navProps.parentFocusNode.focusController
elseif navProps.focusController ~= nil then
focusController = navProps.focusController
else
-- FIXME: do we ever even hit this?
error("Cannot create node without focus manager")
end
local self = setmetatable({
focusController = focusController,
ref = navProps[Roact.Ref],
lastFocused = nil,
}, FocusNode)
self:updateNavProps(navProps)
return self
end
function FocusNode:__getFocusControllerInternal()
return self.focusController[InternalApi]
end
function FocusNode:__findDefaultChildNode()
local lowestLayoutOrder = math.huge
local lowestLayoutOrderChild = nil
local focusController = self:__getFocusControllerInternal()
local children = focusController:getChildren(self)
local groupHostObject = self.ref:getValue()
-- Iterate through all children of this node, looking for a LayoutOrder
-- associated with each node. Picking the lowest LayoutOrder is a good
-- approximation of selecting the "first" child of a given container if the
-- case that we don't have a default or a previous value to restore
for ref, child in pairs(children) do
local hostObject = ref:getValue()
-- For each child node, determine whether it or any of its ancestors (up
-- to the group) has the LayoutOrder property defined
while hostObject ~= groupHostObject and hostObject ~= nil do
if hostObject:isA("GuiObject") then
local layoutOrder = hostObject.LayoutOrder
-- LayoutOrder == 0 is the default value; in most cases, this
-- implies that it wasn't explicitly set, so we should ignore it
if layoutOrder ~= 0 then
if layoutOrder < lowestLayoutOrder then
lowestLayoutOrder = layoutOrder
lowestLayoutOrderChild = child
end
-- Once we've found a layout order to associate with the
-- Focusable, we break out and move on to the next child
break
end
end
hostObject = hostObject.Parent
end
end
if lowestLayoutOrderChild ~= nil then
return lowestLayoutOrderChild
else
-- If no valid target was returned, we return any valid member
local _, arbitraryChild = next(children)
return arbitraryChild
end
end
function FocusNode:updateNavProps(navProps)
local restorePreviousChildFocus = false
if navProps.restorePreviousChildFocus ~= nil then
restorePreviousChildFocus = navProps.restorePreviousChildFocus
end
self.defaultChildRef = navProps.defaultChild
self.restorePreviousChildFocus = restorePreviousChildFocus
self.inputBindings = navProps.inputBindings or {}
local focusController = self:__getFocusControllerInternal()
if focusController:isNodeFocused(self) then
focusController:updateInputBindings()
end
end
function FocusNode:focus()
local focusController = self:__getFocusControllerInternal()
local children = focusController:getChildren(self)
if Cryo.isEmpty(children) then
focusController:setSelection(self.ref:getValue())
else
if self.restorePreviousChildFocus and self.lastFocused ~= nil then
focusController:moveFocusTo(self.lastFocused)
elseif self.defaultChildRef ~= nil then
focusController:moveFocusTo(self.defaultChildRef)
else
local defaultChild = self:__findDefaultChildNode()
if defaultChild ~= nil then
defaultChild:focus()
end
end
end
end
function FocusNode:attachToTree(parent, onFocusChanged)
local focusController = self:__getFocusControllerInternal()
focusController:registerNode(parent, self.ref, self)
self.parent = parent
self.disconnectSelectionListener = focusController:subscribeToSelectionChange(function()
-- Perform focus management operations set up by the FocusNode's owner
local focused = focusController:isNodeFocused(self)
onFocusChanged(focused)
if self.parent ~= nil and focused then
self.parent.lastFocused = self.ref
end
-- Keep track of the last focused ref so that we can provide it to
-- self.props.selectionRule whenever we regain focus
local children = focusController:getChildren(self)
if not Cryo.isEmpty(children) and focused then
-- For the special-case scenario in which the ref for our group
-- gained selection, we follow any established rules to find the
-- correct member of the group to bounce selection to, managed in
-- the `focus` callback on focusController
if focusController:getSelection() == self.ref:getValue() then
self:focus()
end
end
end)
end
function FocusNode:detachFromTree()
local focusController = self:__getFocusControllerInternal()
focusController:deregisterNode(self.parent, self.ref)
if self.disconnectSelectionListener ~= nil then
self.disconnectSelectionListener()
self.disconnectSelectionListener = nil
end
end
return FocusNode
@@ -0,0 +1,262 @@
return function()
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
local FocusNode = require(script.Parent.FocusNode)
local FocusController = require(script.Parent.FocusController)
local InternalApi = require(script.Parent.FocusControllerInternalApi)
local MockEngine = require(script.Parent.Test.MockEngine)
local function createRootNode(ref)
local node = FocusNode.new({
focusController = FocusController.createPublicApiWrapper(),
[Roact.Ref] = ref,
})
node:attachToTree(nil, function() end)
return node
end
local function addChildNode(parentNode)
local instance = Instance.new("Frame")
instance.Parent = parentNode.ref:getValue()
local childRef, _ = Roact.createBinding(instance)
local childNode = FocusNode.new({
parentFocusNode = parentNode,
[Roact.Ref] = childRef,
})
childNode:attachToTree(parentNode, function() end)
return childNode, childRef
end
describe("basic selection behavior", function()
it("should set selection to a ref when the ref's node is focused", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local _, engineInterface = MockEngine.new()
local focusNode = createRootNode(rootRef)
local focusController = focusNode.focusController[InternalApi]
focusController:initialize(engineInterface)
focusNode:focus()
expect(engineInterface.getSelection()).to.equal(rootRef:getValue())
end)
it("should redirect selection to a child when a non-leaf node is focused", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local _, engineInterface = MockEngine.new()
local parentNode = createRootNode(rootRef)
local _, childRef = addChildNode(parentNode)
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
parentNode:focus()
expect(engineInterface.getSelection()).to.equal(childRef:getValue())
end)
it("should redirect selection to a child when a non-leaf node gains selection", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local mockEngine, engineInterface = MockEngine.new()
local parentNode = createRootNode(rootRef)
local _, childRef = addChildNode(parentNode)
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
mockEngine:simulateSelectionChanged(rootRef:getValue())
expect(engineInterface.getSelection()).to.equal(childRef:getValue())
end)
end)
describe("child auto-selection behavior", function()
it("should select the provided default when present", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local _, engineInterface = MockEngine.new()
local parentNode = createRootNode(rootRef)
local childNodeA, _ = addChildNode(parentNode)
local childNodeB, childRefB = addChildNode(parentNode)
parentNode.defaultChildRef = childRefB
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
parentNode:focus()
expect(focusController:isNodeFocused(childNodeB)).to.equal(true)
childNodeA:focus()
parentNode:focus()
expect(focusController:isNodeFocused(childNodeB)).to.equal(true)
end)
it("should restore previous selection when the option is enabled", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local _, engineInterface = MockEngine.new()
local parentNode = createRootNode(rootRef)
local childNodeA, _ = addChildNode(parentNode)
local childNodeB, _ = addChildNode(parentNode)
parentNode.restorePreviousChildFocus = true
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
childNodeA:focus()
parentNode:focus()
expect(focusController:isNodeFocused(childNodeA)).to.equal(true)
childNodeB:focus()
parentNode:focus()
expect(focusController:isNodeFocused(childNodeB)).to.equal(true)
end)
it("should defer to the default child ref when no child was previously focused", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local _, engineInterface = MockEngine.new()
local parentNode = createRootNode(rootRef)
local childNodeA, _ = addChildNode(parentNode)
local childNodeB, _ = addChildNode(parentNode)
local childNodeC, childRefC = addChildNode(parentNode)
parentNode.defaultChildRef = childRefC
parentNode.restorePreviousChildFocus = true
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
parentNode:focus()
expect(focusController:isNodeFocused(childNodeC)).to.equal(true)
childNodeA:focus()
childNodeB:focus()
parentNode:focus()
expect(focusController:isNodeFocused(childNodeB)).to.equal(true)
end)
end)
describe("initial selection logic when nothing is specified", function()
local function addChildNested(parentNode, parentInstance)
local instance = Instance.new("Frame")
instance.Parent = parentInstance
local childRef, _ = Roact.createBinding(instance)
local childNode = FocusNode.new({
parentFocusNode = parentNode,
[Roact.Ref] = childRef,
})
childNode:attachToTree(parentNode, function() end)
return childNode, childRef
end
it("should choose the child with the lowest LayoutOrder", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local _, engineInterface = MockEngine.new()
local parentNode = createRootNode(rootRef)
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
local childNode1, childRef1 = addChildNode(parentNode)
childRef1:getValue().LayoutOrder = 1
local _, childRef2 = addChildNode(parentNode)
childRef2:getValue().LayoutOrder = 2
local _, childRef3 = addChildNode(parentNode)
childRef3:getValue().LayoutOrder = 3
parentNode:focus()
expect(focusController:isNodeFocused(childNode1)).to.equal(true)
end)
it("should choose the lowest layout order even of nested elements", function()
local function insertAncestor(instance)
local newParent = Instance.new("Frame")
local grandparent = instance.Parent
instance.Parent = newParent
newParent.Parent = grandparent
end
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local _, engineInterface = MockEngine.new()
local parentNode = createRootNode(rootRef)
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
local childNode1, childRef1 = addChildNode(parentNode)
insertAncestor(childRef1:getValue())
childRef1:getValue().Parent.LayoutOrder = 1
local _, childRef2 = addChildNode(parentNode)
insertAncestor(childRef2:getValue())
childRef2:getValue().Parent.LayoutOrder = 2
local _, childRef3 = addChildNode(parentNode)
insertAncestor(childRef3:getValue())
childRef3:getValue().Parent.LayoutOrder = 3
parentNode:focus()
expect(focusController:isNodeFocused(childNode1)).to.equal(true)
end)
it("should only use the LayoutOrder closest to each child", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local _, engineInterface = MockEngine.new()
local parentNode = createRootNode(rootRef)
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
-- Insert an intermediate frame with LayoutOrder = 1; we want to
-- make sure not to traverse back up to this and choose the wrong
-- child
local intermediateFrame = Instance.new("Frame")
intermediateFrame.Parent = rootRef:getValue()
intermediateFrame.LayoutOrder = 1
local childNode1, childRef1 = addChildNested(parentNode, intermediateFrame)
childRef1:getValue().LayoutOrder = 2
local _, childRef2 = addChildNested(parentNode, intermediateFrame)
childRef2:getValue().LayoutOrder = 3
local _, childRef3 = addChildNested(parentNode, intermediateFrame)
childRef3:getValue().LayoutOrder = 4
parentNode:focus()
expect(focusController:isNodeFocused(childNode1)).to.equal(true)
end)
it("should safely skip past non-GuiObjects", function()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local _, engineInterface = MockEngine.new()
local parentNode = createRootNode(rootRef)
local focusController = parentNode.focusController[InternalApi]
focusController:initialize(engineInterface)
-- Insert an intermediate frame with LayoutOrder = 1; we want to
-- make sure not to traverse back up to this and choose the wrong
-- child
local intermediateFolder = Instance.new("Folder")
intermediateFolder.Parent = rootRef:getValue()
local childNode1, childRef1 = addChildNested(parentNode, intermediateFolder)
childRef1:getValue().LayoutOrder = 1
local _, childRef2 = addChildNested(parentNode, intermediateFolder)
childRef2:getValue().LayoutOrder = 2
-- None specified; this one will have to climb to the tree
local _, _ = addChildNested(parentNode, intermediateFolder)
expect(function()
parentNode:focus()
end).never.to.throw()
expect(focusController:isNodeFocused(childNode1)).to.equal(true)
end)
end)
end
@@ -0,0 +1,197 @@
local debugPrint = require(script.Parent.debugPrint)
local InputBindingKind = require(script.Parent.InputBindingKind)
local INPUT_TYPES = {
[Enum.UserInputType.Keyboard] = true,
[Enum.UserInputType.Gamepad1] = true,
[Enum.UserInputType.Gamepad2] = true,
[Enum.UserInputType.Gamepad3] = true,
[Enum.UserInputType.Gamepad4] = true,
[Enum.UserInputType.Gamepad5] = true,
[Enum.UserInputType.Gamepad6] = true,
[Enum.UserInputType.Gamepad7] = true,
[Enum.UserInputType.Gamepad8] = true,
}
--[[
In order to reduce the friction of tracking this globally, we track the
gamepad connection state of each "engine interface" individually. In
production contexts, this table will have one key-value pair. When running
tests, each test will provide it's own mock engine interface; this table
will keep them separate and prevent tests from interfering with one another
]]
local engineGamepadState = {}
local function initializeEngineGamepadState()
return {
gamepadConnectedConnection = nil,
gamepadDisconnectedConnection = nil,
onStepConnections = 0,
primaryGamepadState = {},
}
end
local function getEngineState(engineInterface)
if engineGamepadState[engineInterface] == nil then
engineGamepadState[engineInterface] = initializeEngineGamepadState()
end
return engineGamepadState[engineInterface]
end
local function updatePrimaryGamepad(engineInterface)
local engineState = getEngineState(engineInterface)
local primaryGamepad = Enum.UserInputType.Gamepad1
for _, gamepadNum in ipairs(engineInterface.getNavigationGamepads()) do
if engineInterface.getGamepadConnected(gamepadNum) then
primaryGamepad = gamepadNum
break
end
end
-- States returned by getGamepadState are mutable and updated by the engine,
-- so this table only needs to be setup once per gamepad update
local states = engineInterface.getGamepadState(primaryGamepad)
engineState.primaryGamepadState = {}
for _, state in ipairs(states) do
engineState.primaryGamepadState[state.KeyCode] = state
end
end
local function getInputEvent(action, matchInput)
return function(inputObject)
if matchInput(inputObject) then
debugPrint("[EVENT] Process input: ",
inputObject.KeyCode,
"-",
inputObject.UserInputState
)
action(inputObject)
end
end
end
local function wrapWithGamepadStateListener(engineInterface, connection)
local engineState = getEngineState(engineInterface)
if engineState.onStepConnections == 0 then
updatePrimaryGamepad(engineInterface)
engineState.gamepadConnectedConnection = engineInterface.subscribeToGamepadConnected(function()
updatePrimaryGamepad(engineInterface)
end)
engineState.gamepadDisconnectedConnection = engineInterface.subscribeToGamepadDisconnected(function()
updatePrimaryGamepad(engineInterface)
end)
end
engineState.onStepConnections = engineState.onStepConnections + 1
return function()
connection:Disconnect()
engineState.onStepConnections = engineState.onStepConnections - 1
if engineState.onStepConnections == 0 then
engineState.gamepadConnectedConnection:Disconnect()
engineState.gamepadConnectedConnection = nil
engineState.gamepadDisconnectedConnection:Disconnect()
engineState.gamepadDisconnectedConnection = nil
end
end
end
-- Returns a function that can be called to disconnect from the event
local function connectToEvent(binding, engineInterface)
if binding.kind == InputBindingKind.Begin then
local function matchInput(inputObject)
return INPUT_TYPES[inputObject.UserInputType]
and inputObject.UserInputState == Enum.UserInputState.Begin
and inputObject.KeyCode == binding.keyCode
end
local connection = engineInterface.subscribeToInputBegan(getInputEvent(binding.action, matchInput))
return function()
connection:Disconnect()
end
elseif binding.kind == InputBindingKind.End then
local function matchInput(inputObject)
return INPUT_TYPES[inputObject.UserInputType]
and inputObject.UserInputState == Enum.UserInputState.End
and inputObject.KeyCode == binding.keyCode
end
local connection = engineInterface.subscribeToInputEnded(getInputEvent(binding.action, matchInput))
return function()
connection:Disconnect()
end
elseif binding.kind == InputBindingKind.Step then
local engineState = getEngineState(engineInterface)
local connection = engineInterface.subscribeToRenderStepped(function(step)
debugPrint("[EVENT] Render step triggered onStep callback")
binding.action(engineState.primaryGamepadState[binding.keyCode], step)
end)
return wrapWithGamepadStateListener(engineInterface, connection)
elseif binding.kind == InputBindingKind.MoveStep then
local engineState = getEngineState(engineInterface)
local connection = engineInterface.subscribeToRenderStepped(function(step)
debugPrint("[EVENT] Render step triggered onMoveStep callback")
local moveState = {
[Enum.KeyCode.Thumbstick1] = engineState.primaryGamepadState[Enum.KeyCode.Thumbstick1],
[Enum.KeyCode.DPadUp] = engineState.primaryGamepadState[Enum.KeyCode.DPadUp],
[Enum.KeyCode.DPadDown] = engineState.primaryGamepadState[Enum.KeyCode.DPadDown],
[Enum.KeyCode.DPadLeft] = engineState.primaryGamepadState[Enum.KeyCode.DPadLeft],
[Enum.KeyCode.DPadRight] = engineState.primaryGamepadState[Enum.KeyCode.DPadRight],
}
binding.action(moveState, step)
end)
return wrapWithGamepadStateListener(engineInterface, connection)
end
end
local function makeInputBinding(kind)
return function(keyCode, action, meta)
assert(typeof(keyCode) == "EnumItem" and keyCode.EnumType == Enum.KeyCode,
"Invalid argument #1: expected a member of Enum.KeyCode")
assert(typeof(action) == "function", "Invalid argument #2: expected a function")
return {
kind = kind,
keyCode = keyCode,
action = action,
meta = meta,
}
end
end
local function onMoveStepInputBinding(action)
return {
kind = InputBindingKind.MoveStep,
action = action,
}
end
local function getUniqueKey(binding)
if binding.keyCode then
return tostring(binding.kind) .. "-" .. tostring(binding.keyCode)
else
return tostring(binding.kind)
end
end
return {
getUniqueKey = getUniqueKey,
connectToEvent = connectToEvent,
PublicInterface = {
onBegin = makeInputBinding(InputBindingKind.Begin),
onEnd = makeInputBinding(InputBindingKind.End),
onStep = makeInputBinding(InputBindingKind.Step),
onMoveStep = onMoveStepInputBinding,
}
}
@@ -0,0 +1,312 @@
return function()
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
local Input = require(script.Parent.Input)
local createSpy = require(script.Parent.Test.createSpy)
local MockEngine = require(script.Parent.Test.MockEngine)
describe("onBegin and onEnd", function()
it("should run respond to relevant events when onBegin is connected", function()
local mockEngine, engineInterface = MockEngine.new()
local onBeginSpy = createSpy()
local onBegin = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonA, onBeginSpy.value)
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.Begin,
})
expect(onBeginSpy.callCount).to.equal(0)
local disconnector = Input.connectToEvent(onBegin, engineInterface)
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.Begin,
})
expect(onBeginSpy.callCount).to.equal(1)
local captured = onBeginSpy:captureValues("inputObject")
expect(captured.inputObject.KeyCode).to.equal(Enum.KeyCode.ButtonA)
expect(captured.inputObject.UserInputState).to.equal(Enum.UserInputState.Begin)
-- When state is `End`, the callback shouldn't be called
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.End,
})
expect(onBeginSpy.callCount).to.equal(1)
-- After disconnecting, the callback shouldn't be called
disconnector()
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.Begin,
})
expect(onBeginSpy.callCount).to.equal(1)
end)
it("should run respond to relevant events when onEnd is connected", function()
local mockEngine, engineInterface = MockEngine.new()
local onEndSpy = createSpy()
local onEnd = Input.PublicInterface.onEnd(Enum.KeyCode.ButtonA, onEndSpy.value)
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.End,
})
expect(onEndSpy.callCount).to.equal(0)
local disconnector = Input.connectToEvent(onEnd, engineInterface)
-- When state is `Begin`, the callback shouldn't be called
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.Begin,
})
expect(onEndSpy.callCount).to.equal(0)
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.End,
})
expect(onEndSpy.callCount).to.equal(1)
local captured = onEndSpy:captureValues("inputObject")
expect(captured.inputObject.KeyCode).to.equal(Enum.KeyCode.ButtonA)
expect(captured.inputObject.UserInputState).to.equal(Enum.UserInputState.End)
-- After disconnecting, the callback shouldn't be called
disconnector()
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.End,
})
expect(onEndSpy.callCount).to.equal(1)
end)
it("should ignore events with unrelated KeyCodes", function()
local mockEngine, engineInterface = MockEngine.new()
local onBeginSpy = createSpy()
local onBegin = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonA, onBeginSpy.value)
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.End,
})
expect(onBeginSpy.callCount).to.equal(0)
local disconnector = Input.connectToEvent(onBegin, engineInterface)
-- When the KeyCode does not match, the callback won't be fired
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonB,
UserInputState = Enum.UserInputState.Begin,
})
expect(onBeginSpy.callCount).to.equal(0)
disconnector()
end)
it("should keep a separate notion of gamepad state for each 'engine interface', for testing purposes", function()
local mockEngine1, engineInterface1 = MockEngine.new()
local mockEngine2, engineInterface2 = MockEngine.new()
local onStepSpy1 = createSpy()
local onStep1 = Input.PublicInterface.onStep(Enum.KeyCode.Thumbstick1, onStepSpy1.value)
local disconnector1 = Input.connectToEvent(onStep1, engineInterface1)
local onStepSpy2 = createSpy()
local onStep2 = Input.PublicInterface.onStep(Enum.KeyCode.Thumbstick1, onStepSpy2.value)
local disconnector2 = Input.connectToEvent(onStep2, engineInterface2)
mockEngine1:simulateInput({
KeyCode = Enum.KeyCode.Thumbstick1,
UserInputState = Enum.UserInputState.Begin,
Delta = Vector3.new(1, 1, 0),
})
mockEngine1:renderStep(0.03)
expect(onStepSpy1.callCount).to.equal(1)
expect(onStepSpy2.callCount).to.equal(0)
local capturedValues1 = onStepSpy1:captureValues("inputObject", "_")
local thumbstickValue1 = capturedValues1.inputObject
expect(thumbstickValue1.Delta).to.equal(Vector3.new(1, 1, 0))
mockEngine2:simulateInput({
KeyCode = Enum.KeyCode.Thumbstick1,
UserInputState = Enum.UserInputState.Begin,
Delta = Vector3.new(-1, -1, 0),
})
mockEngine2:renderStep(0.03)
expect(onStepSpy1.callCount).to.equal(1)
expect(onStepSpy2.callCount).to.equal(1)
local capturedValues2 = onStepSpy2:captureValues("inputObject", "_")
local thumbstickValue2 = capturedValues2.inputObject
-- Verify that the first one didn't change
expect(thumbstickValue1.Delta).to.equal(Vector3.new(1, 1, 0))
expect(thumbstickValue2.Delta).to.equal(Vector3.new(-1, -1, 0))
disconnector1()
disconnector2()
end)
end)
describe("onStep", function()
it("should fire once per render step when connected", function()
local mockEngine, engineInterface = MockEngine.new()
local onStepSpy = createSpy()
local onStep = Input.PublicInterface.onStep(Enum.KeyCode.ButtonA, onStepSpy.value)
expect(onStepSpy.callCount).to.equal(0)
local disconnector = Input.connectToEvent(onStep, engineInterface)
mockEngine:renderStep(0.1)
expect(onStepSpy.callCount).to.equal(1)
local captured = onStepSpy:captureValues("_inputObjects", "deltaTime")
expect(captured.deltaTime).to.equal(0.1)
disconnector()
end)
it("should accept a keyCode and provide a relevant inputObject to the callback", function()
local mockEngine, engineInterface = MockEngine.new()
local onStepSpy = createSpy()
local onStep = Input.PublicInterface.onStep(Enum.KeyCode.Thumbstick1, onStepSpy.value)
expect(onStepSpy.callCount).to.equal(0)
local disconnector = Input.connectToEvent(onStep, engineInterface)
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.Thumbstick1,
UserInputState = Enum.UserInputState.Change,
Position = Vector3.new(0, 0, 0),
Delta = Vector3.new(0, 0, 0),
})
mockEngine:renderStep(0.1)
expect(onStepSpy.callCount).to.equal(1)
local captured = onStepSpy:captureValues("inputObject", "_deltaTime")
expect(captured.inputObject.KeyCode).to.equal(Enum.KeyCode.Thumbstick1)
expect(captured.inputObject.UserInputState).to.equal(Enum.UserInputState.Change)
expect(captured.inputObject.Position).to.equal(Vector3.new(0, 0, 0))
expect(captured.inputObject.Delta).to.equal(Vector3.new(0, 0, 0))
disconnector()
end)
it("should provide an up-to-date inputObject to the callback", function()
local mockEngine, engineInterface = MockEngine.new()
local onStepSpy = createSpy()
local onStep = Input.PublicInterface.onStep(Enum.KeyCode.Thumbstick1, onStepSpy.value)
expect(onStepSpy.callCount).to.equal(0)
local disconnector = Input.connectToEvent(onStep, engineInterface)
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.Thumbstick1,
UserInputState = Enum.UserInputState.Change,
Position = Vector3.new(0, 0, 0),
Delta = Vector3.new(0, 0, 0),
})
mockEngine:renderStep(0.1)
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.Thumbstick1,
UserInputState = Enum.UserInputState.End,
Position = Vector3.new(1, 0, 0),
Delta = Vector3.new(1, 0, 0),
})
mockEngine:renderStep(0.1)
expect(onStepSpy.callCount).to.equal(2)
local captured = onStepSpy:captureValues("inputObject", "_deltaTime")
expect(captured.inputObject.KeyCode).to.equal(Enum.KeyCode.Thumbstick1)
expect(captured.inputObject.UserInputState).to.equal(Enum.UserInputState.End)
expect(captured.inputObject.Position).to.equal(Vector3.new(1, 0, 0))
expect(captured.inputObject.Delta).to.equal(Vector3.new(1, 0, 0))
disconnector()
end)
end)
describe("onMoveStep", function()
local function simulateSeveralInputs(mockEngine, state, keyCodes)
for _, keyCode in ipairs(keyCodes) do
mockEngine:simulateInput({
KeyCode = keyCode,
UserInputState = state,
})
end
end
it("should reflect the state of each input at each render step", function()
local mockEngine, engineInterface = MockEngine.new()
local onMoveStepSpy = createSpy()
local onStep = Input.PublicInterface.onMoveStep(onMoveStepSpy.value)
expect(onMoveStepSpy.callCount).to.equal(0)
local disconnector = Input.connectToEvent(onStep, engineInterface)
simulateSeveralInputs(mockEngine, Enum.UserInputState.Begin, {
Enum.KeyCode.Thumbstick1,
Enum.KeyCode.DPadUp,
Enum.KeyCode.DPadDown,
Enum.KeyCode.DPadLeft,
Enum.KeyCode.DPadRight,
})
mockEngine:renderStep(0.1)
expect(onMoveStepSpy.callCount).to.equal(1)
local captured = onMoveStepSpy:captureValues("inputObjects", "_deltaTime")
local captured = {
Thumbstick1 = captured.inputObjects[Enum.KeyCode.Thumbstick1],
DPadUp = captured.inputObjects[Enum.KeyCode.DPadUp],
DPadDown = captured.inputObjects[Enum.KeyCode.DPadDown],
DPadLeft = captured.inputObjects[Enum.KeyCode.DPadLeft],
DPadRight = captured.inputObjects[Enum.KeyCode.DPadRight],
}
expect(captured.Thumbstick1.UserInputState).to.equal(Enum.UserInputState.Begin)
expect(captured.DPadUp.UserInputState).to.equal(Enum.UserInputState.Begin)
expect(captured.DPadDown.UserInputState).to.equal(Enum.UserInputState.Begin)
expect(captured.DPadLeft.UserInputState).to.equal(Enum.UserInputState.Begin)
expect(captured.DPadRight.UserInputState).to.equal(Enum.UserInputState.Begin)
simulateSeveralInputs(mockEngine, Enum.UserInputState.Change, {
Enum.KeyCode.Thumbstick1,
Enum.KeyCode.DPadUp,
Enum.KeyCode.DPadDown,
Enum.KeyCode.DPadLeft,
Enum.KeyCode.DPadRight,
})
mockEngine:renderStep(0.1)
-- Shouldn't need to recapture, since the inputObjects themselves
-- are being mutated directly by the engine
expect(captured.Thumbstick1.UserInputState).to.equal(Enum.UserInputState.Change)
expect(captured.DPadUp.UserInputState).to.equal(Enum.UserInputState.Change)
expect(captured.DPadDown.UserInputState).to.equal(Enum.UserInputState.Change)
expect(captured.DPadLeft.UserInputState).to.equal(Enum.UserInputState.Change)
expect(captured.DPadRight.UserInputState).to.equal(Enum.UserInputState.Change)
disconnector()
end)
end)
end
@@ -0,0 +1,9 @@
local Packages = script.Parent.Parent
local enumerate = require(Packages.enumerate)
return enumerate("InputBindingKind", {
"Begin",
"End",
"Step",
"MoveStep",
})
@@ -0,0 +1,30 @@
--[[
A 'Symbol' is an opaque marker type.
Symbols have the type 'userdata', but when printed to the console, the name
of the symbol is shown.
]]
local Symbol = {}
--[[
Creates a Symbol with the given name.
When printed or coerced to a string, the symbol will turn into the string
given as its name.
]]
function Symbol.named(name)
assert(type(name) == "string", "Symbols must be created using a string name!")
local self = newproxy(true)
local wrappedName = ("Symbol(%s)"):format(name)
getmetatable(self).__tostring = function()
return wrappedName
end
return self
end
return Symbol
@@ -0,0 +1,246 @@
local Packages = script.Parent.Parent.Parent
local Cryo = require(Packages.Cryo)
local createSignal = require(script.Parent.Parent.createSignal)
local MockEngine = {}
MockEngine.__index = MockEngine
local VALID_INPUT_TYPES = {
[Enum.UserInputType.Gamepad1] = true,
[Enum.UserInputType.Gamepad2] = true,
[Enum.UserInputType.Gamepad3] = true,
[Enum.UserInputType.Gamepad4] = true,
[Enum.UserInputType.Gamepad5] = true,
[Enum.UserInputType.Gamepad6] = true,
[Enum.UserInputType.Gamepad7] = true,
[Enum.UserInputType.Gamepad8] = true,
[Enum.UserInputType.Keyboard] = true,
}
local GAMEPAD_STATES = {
Enum.KeyCode.Thumbstick2,
Enum.KeyCode.DPadDown,
Enum.KeyCode.DPadUp,
Enum.KeyCode.ButtonL3,
Enum.KeyCode.ButtonL2,
Enum.KeyCode.DPadRight,
Enum.KeyCode.ButtonR1,
Enum.KeyCode.ButtonSelect,
Enum.KeyCode.ButtonStart,
Enum.KeyCode.ButtonY,
Enum.KeyCode.DPadLeft,
Enum.KeyCode.ButtonR2,
Enum.KeyCode.ButtonR3,
Enum.KeyCode.ButtonX,
Enum.KeyCode.Thumbstick1,
Enum.KeyCode.ButtonB,
Enum.KeyCode.ButtonA,
Enum.KeyCode.ButtonL1,
}
local DIRECTIONAL_INPUTS = {
[Enum.KeyCode.DPadUp] = "NextSelectionUp",
[Enum.KeyCode.Up] = "NextSelectionUp",
[Enum.KeyCode.DPadDown] = "NextSelectionDown",
[Enum.KeyCode.Down] = "NextSelectionDown",
[Enum.KeyCode.DPadLeft] = "NextSelectionLeft",
[Enum.KeyCode.Left] = "NextSelectionLeft",
[Enum.KeyCode.DPadRight] = "NextSelectionRight",
[Enum.KeyCode.Right] = "NextSelectionRight",
}
local function defaultInputObject(keyCode)
return {
KeyCode = keyCode,
Delta = Vector3.new(),
Position = Vector3.new(),
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.End,
}
end
local function wrapDisconnector(disconnect)
return {
Disconnect = disconnect
}
end
function MockEngine.new()
local self = setmetatable({
-- Mock engine state
__mockSelected = nil,
__connectedGamepads = {},
__gamepadStates = {},
-- Signals that represent engine events firing
__selectionChangedSignal = createSignal(),
__inputSignal = createSignal(),
__renderStepped = createSignal(),
__gamepadConnected = createSignal(),
__gamepadDisconnected = createSignal(),
__interface = nil,
}, MockEngine)
local mockInterface = {
getSelection = function()
return self.__mockSelected
end,
setSelection = function(selectionTarget)
self.__mockSelected = selectionTarget
-- Since this isn't driven by the engine, manually fire the signal
self.__selectionChangedSignal:fire()
end,
getGamepadConnected = function(id)
return self.__connectedGamepads[id] or false
end,
getGamepadState = function(id)
if self.__gamepadStates[id] == nil then
self:__initializeGamepadState(id)
end
-- To mimic the real engine behavior, this returns a table that can
-- continue to be mutated by the mock engine
return self.__gamepadStates[id]
end,
getNavigationGamepads = function(id)
return Cryo.Dictionary.keys(self.__connectedGamepads)
end,
subscribeToSelectionChanged = function(callback)
local disconnector = self.__selectionChangedSignal:subscribe(callback)
return wrapDisconnector(disconnector)
end,
subscribeToInputBegan = function(callback)
local disconnector = self.__inputSignal:subscribe(callback)
return wrapDisconnector(disconnector)
end,
subscribeToInputChanged = function(callback)
local disconnector = self.__inputSignal:subscribe(callback)
return wrapDisconnector(disconnector)
end,
subscribeToInputEnded = function(callback)
local disconnector = self.__inputSignal:subscribe(callback)
return wrapDisconnector(disconnector)
end,
subscribeToRenderStepped = function(callback)
local disconnector = self.__renderStepped:subscribe(callback)
return wrapDisconnector(disconnector)
end,
subscribeToGamepadConnected = function(callback)
local disconnector = self.__gamepadConnected:subscribe(callback)
return wrapDisconnector(disconnector)
end,
subscribeToGamepadDisconnected = function(callback)
local disconnector = self.__gamepadDisconnected:subscribe(callback)
return wrapDisconnector(disconnector)
end,
}
self.__interface = mockInterface
return self, mockInterface
end
function MockEngine:__initializeGamepadState(id)
self.__gamepadStates[id] = Cryo.List.map(GAMEPAD_STATES, function(keyCode)
return defaultInputObject(keyCode)
end)
end
function MockEngine:simulateSelectionChanged(selectionTarget)
self.__mockSelected = selectionTarget
self.__selectionChangedSignal:fire()
end
function MockEngine:simulateInput(inputObject)
-- Simplify the input object so that we don't always need to provide all the
-- values
local inputObject = Cryo.Dictionary.join({
Delta = Vector3.new(),
Position = Vector3.new(),
UserInputType = Enum.UserInputType.Gamepad1,
}, inputObject)
assert(typeof(inputObject.KeyCode) == "EnumItem" and inputObject.KeyCode.EnumType == Enum.KeyCode,
"Invalid inputObject.KeyCode: expected a member of Enum.KeyCode")
assert(VALID_INPUT_TYPES[inputObject.UserInputType], "Invalid inputObject.UserInputType")
-- Simulate navigational actions by jumping to relevant neighbors
local keyCode = inputObject.KeyCode
local neighborProperty = DIRECTIONAL_INPUTS[keyCode]
-- To mimic engine behavior, this happens *before* the input signal is
-- processed
if neighborProperty ~= nil and self.__interface.getSelection() ~= nil then
self.__interface.setSelection(self.__interface.getSelection()[neighborProperty])
end
-- For gamepad inputs, update the gamepad state InputObject
if inputObject.UserInputType ~= Enum.UserInputType.Keyboard then
local gamepadId = inputObject.UserInputType
-- As a shorthand, if the originating gamepad for this simulated input
-- isn't connected yet, we first simulate connecting that gamepad
if not self.__connectedGamepads[gamepadId] then
self:connectGamepad(gamepadId)
end
local gamepadState = self.__gamepadStates[gamepadId]
local index = Cryo.List.findWhere(gamepadState, function(state)
return state.KeyCode == keyCode
end)
if index == nil then
error(("Invalid InputObject: KeyCode %s is not possible on %s"):format(
tostring(keyCode),
tostring(gamepadId)
), 2)
end
for key, value in pairs(inputObject) do
gamepadState[index][key] = value
end
end
-- Pass on input by firing the input signal with an approximation of a
-- InputObject
self.__inputSignal:fire(inputObject)
end
function MockEngine:renderStep(deltaTime)
if deltaTime == nil then
deltaTime = 1 / 30
end
self.__renderStepped:fire(deltaTime)
end
function MockEngine:connectGamepad(id)
assert(typeof(id) == "EnumItem" and id.EnumType == Enum.UserInputType,
"Invalid argument #1: expected a member of Enum.UserInputType")
self.__connectedGamepads[id] = true
self:__initializeGamepadState(id)
self.__gamepadConnected:fire(id)
end
function MockEngine:disconnectGamepad(id)
assert(typeof(id) == "EnumItem" and id.EnumType == Enum.UserInputType,
"Invalid argument #1: expected a member of Enum.UserInputType")
self.__connectedGamepads[id] = nil
self.__gamepadDisconnected:fire(id)
end
return MockEngine
@@ -0,0 +1,210 @@
return function()
local Packages = script.Parent.Parent.Parent
local Cryo = require(Packages.Cryo)
local MockEngine = require(script.Parent.MockEngine)
local createSpy = require(script.Parent.createSpy)
describe("selection logic simulation", function()
it("should fire a change signal when simulating selection change", function()
local mockEngine, engineInterface = MockEngine.new()
local selectionChangedSpy = createSpy()
engineInterface.subscribeToSelectionChanged(selectionChangedSpy.value)
local selectionTarget = Instance.new("Frame")
mockEngine:simulateSelectionChanged(selectionTarget)
expect(selectionChangedSpy.callCount).to.equal(1)
expect(engineInterface.getSelection()).to.equal(selectionTarget)
end)
it("should automatically simulate selection change when simulating gamepad directional inputs", function()
local mockEngine, engineInterface = MockEngine.new()
-- Create two UI elements that are vertical neighbors
local upper = Instance.new("Frame")
local lower = Instance.new("Frame")
upper.NextSelectionDown = lower
lower.NextSelectionUp = upper
-- Set starting selection to the upper element of the two
engineInterface.setSelection(upper)
expect(engineInterface.getSelection()).to.equal(upper)
local selectionChangedSpy = createSpy()
local inputBeganSpy = createSpy()
engineInterface.subscribeToSelectionChanged(selectionChangedSpy.value)
engineInterface.subscribeToInputBegan(inputBeganSpy.value)
-- Simulate a downward input, and confirm that the expected
-- selection change occurs and selection is now on the lower element
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.DPadDown
})
expect(inputBeganSpy.callCount).to.equal(1)
expect(selectionChangedSpy.callCount).to.equal(1)
expect(engineInterface.getSelection()).to.equal(lower)
-- Simulate a downward input, and confirm that the expected
-- selection change occurs and selection returns to the upper
-- element
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.DPadUp
})
expect(inputBeganSpy.callCount).to.equal(2)
expect(selectionChangedSpy.callCount).to.equal(2)
expect(engineInterface.getSelection()).to.equal(upper)
end)
it("should automatically simulate selection change when simulating keyboard directional inputs", function()
local mockEngine, engineInterface = MockEngine.new()
-- Create two UI elements that are vertical neighbors
local upper = Instance.new("Frame")
local lower = Instance.new("Frame")
upper.NextSelectionDown = lower
lower.NextSelectionUp = upper
-- Set starting selection to the upper element of the two
engineInterface.setSelection(upper)
expect(engineInterface.getSelection()).to.equal(upper)
local selectionChangedSpy = createSpy()
local inputBeganSpy = createSpy()
engineInterface.subscribeToSelectionChanged(selectionChangedSpy.value)
engineInterface.subscribeToInputBegan(inputBeganSpy.value)
-- Simulate a downward input, and confirm that the expected
-- selection change occurs and selection is now on the lower element
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.DPadDown,
UserInputState = Enum.UserInputState.Begin,
})
expect(inputBeganSpy.callCount).to.equal(1)
expect(selectionChangedSpy.callCount).to.equal(1)
expect(engineInterface.getSelection()).to.equal(lower)
-- Simulate a downward input, and confirm that the expected
-- selection change occurs and selection returns to the upper
-- element
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.DPadUp,
UserInputState = Enum.UserInputState.Begin,
})
expect(inputBeganSpy.callCount).to.equal(2)
expect(selectionChangedSpy.callCount).to.equal(2)
expect(engineInterface.getSelection()).to.equal(upper)
end)
end)
describe("gamepad state simulation", function()
it("should fire events when gamepads are connected and disconnected", function()
local mockEngine, engineInterface = MockEngine.new()
local gamepadConnectedSpy = createSpy()
local gamepadDisconnectedSpy = createSpy()
engineInterface.subscribeToGamepadConnected(gamepadConnectedSpy.value)
engineInterface.subscribeToGamepadDisconnected(gamepadDisconnectedSpy.value)
expect(gamepadConnectedSpy.callCount).to.equal(0)
expect(gamepadDisconnectedSpy.callCount).to.equal(0)
local gamepad1 = Enum.UserInputType.Gamepad1
mockEngine:connectGamepad(gamepad1)
expect(gamepadConnectedSpy.callCount).to.equal(1)
gamepadConnectedSpy:assertCalledWith(gamepad1)
expect(gamepadDisconnectedSpy.callCount).to.equal(0)
mockEngine:disconnectGamepad(gamepad1)
expect(gamepadConnectedSpy.callCount).to.equal(1)
expect(gamepadDisconnectedSpy.callCount).to.equal(1)
gamepadDisconnectedSpy:assertCalledWith(gamepad1)
end)
it("should report gamepad connection status", function()
local mockEngine, engineInterface = MockEngine.new()
expect(engineInterface.getGamepadConnected(gamepad1)).to.equal(false)
local gamepad1 = Enum.UserInputType.Gamepad1
mockEngine:connectGamepad(gamepad1)
expect(engineInterface.getGamepadConnected(gamepad1)).to.equal(true)
mockEngine:disconnectGamepad(gamepad1)
expect(engineInterface.getGamepadConnected(gamepad1)).to.equal(false)
end)
it("should automatically 'connect' a gamepad when a matching input is simulated", function()
local mockEngine, engineInterface = MockEngine.new()
expect(engineInterface.getGamepadConnected(Enum.UserInputType.Gamepad1)).to.equal(false)
mockEngine:simulateInput({
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.Begin,
UserInputType = Enum.UserInputType.Gamepad1,
})
expect(engineInterface.getGamepadConnected(Enum.UserInputType.Gamepad1)).to.equal(true)
end)
it("should give access to current gamepad input state", function()
local mockEngine, engineInterface = MockEngine.new()
local gamepad1 = Enum.UserInputType.Gamepad1
mockEngine:connectGamepad(gamepad1)
-- Check that a valid state is returned even when no inputs for the
-- given keyCode have been issued. There should be an entry for each
-- possible KeyCode (Thumbstick 1 and 2, L1/L2/L3, R1/R2/R3,
-- A/B/X/Y, Select/Start, DPad up/down/left/right), 18 total
local gamepadState = engineInterface.getGamepadState(gamepad1)
expect(#gamepadState).to.equal(18)
local buttonAIndex = Cryo.List.findWhere(gamepadState, function(inputObject)
return inputObject.KeyCode == Enum.KeyCode.ButtonA
end)
expect(buttonAIndex).to.be.ok()
local buttonAInputObject = gamepadState[buttonAIndex]
-- A quick consistency check with expected default values
expect(buttonAInputObject.UserInputType).to.equal(gamepad1)
expect(buttonAInputObject.UserInputState).to.equal(Enum.UserInputState.End)
mockEngine:simulateInput({
UserInputType = gamepad1,
KeyCode = Enum.KeyCode.ButtonA,
UserInputState = Enum.UserInputState.Begin,
})
-- The engine will directly mutate the InputObjects it returns from
-- `GetGamepadState`, so we simulate the same behavior here. This
-- means we can check that the input object we already saved to
-- buttonAInputObject to mutate in response to inputs
expect(buttonAInputObject.UserInputState).to.equal(Enum.UserInputState.Begin)
end)
end)
describe("render step simulation", function()
it("should fire subscribed events when render steps are simulated", function()
local mockEngine, engineInterface = MockEngine.new()
local renderSteppedSpy = createSpy()
engineInterface.subscribeToRenderStepped(renderSteppedSpy.value)
expect(renderSteppedSpy.callCount).to.equal(0)
mockEngine:renderStep(0.03)
expect(renderSteppedSpy.callCount).to.equal(1)
renderSteppedSpy:assertCalledWith(0.03)
end)
end)
end
@@ -0,0 +1,67 @@
--[[
A utility used to create a function spy that can be used to robustly test
that functions are invoked the correct number of times and with the correct
number of arguments.
This should only be used in tests.
]]
local function createSpy(inner)
local self = {
callCount = 0,
values = {},
valuesLength = 0,
}
self.value = function(...)
self.callCount = self.callCount + 1
self.values = {...}
self.valuesLength = select("#", ...)
if inner ~= nil then
return inner(...)
end
return
end
self.assertCalledWith = function(_, ...)
local len = select("#", ...)
if self.valuesLength ~= len then
error(("Expected %d arguments, but was called with %d arguments"):format(
self.valuesLength,
len
), 2)
end
for i = 1, len do
local expected = select(i, ...)
assert(self.values[i] == expected, "value differs; got " .. tostring(self.values[i]) .. ", expected " .. tostring(expected))
end
end
self.captureValues = function(_, ...)
local len = select("#", ...)
local result = {}
assert(self.valuesLength == len, "length of expected values differs from stored values")
for i = 1, len do
local key = select(i, ...)
result[key] = self.values[i]
end
return result
end
setmetatable(self, {
__index = function(_, key)
error(("%q is not a valid member of spy"):format(key))
end,
})
return self
end
return createSpy
@@ -0,0 +1,298 @@
--!nonstrict
-- Manages groups of selectable elements, reacting to selection changes for
-- individual items and triggering events for group selection changes
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
local Cryo = require(Packages.Cryo)
local t = require(Packages.t)
local FocusContext = require(script.Parent.FocusContext)
local forwardRef = require(script.Parent.forwardRef)
local FocusNode = require(script.Parent.FocusNode)
local getEngineInterface = require(script.Parent.getEngineInterface)
local InternalApi = require(script.Parent.FocusControllerInternalApi)
local nonHostProps = {
parentFocusNode = Cryo.None,
parentNeighbors = Cryo.None,
focusController = Cryo.None,
onFocusGained = Cryo.None,
onFocusLost = Cryo.None,
onFocusChanged = Cryo.None,
inputBindings = Cryo.None,
restorePreviousChildFocus = Cryo.None,
defaultChild = Cryo.None,
}
local function checkFocusManager(props)
if props.focusController ~= nil and props.parentFocusNode ~= nil then
return false, "Cannot attach a new focusController beneath an existing one"
end
return true
end
local focusableValidateProps = t.intersection(t.interface({
parentFocusNode = t.optional(t.table),
focusController = t.optional(t.table),
[Roact.Ref] = t.table,
restorePreviousChildFocus = t.boolean,
inputBindings = t.table,
defaultChild = t.optional(t.table),
onFocusGained = t.optional(t.callback),
onFocusLost = t.optional(t.callback),
onFocusChanged = t.optional(t.callback),
}), checkFocusManager)
local focusableDefaultProps = {
restorePreviousChildFocus = false,
inputBindings = {},
}
--[[
Identifies an instance as a focusable element or a group of focusable
elements. Injects a navigation context object that propagates its own
navigational props to any children that are also selectable.
]]
local function asFocusable(innerComponent)
local componentName = ("Focusable(%s)"):format(tostring(innerComponent))
-- Selection container component; groups together children and reacts to changes
-- in GuiService.SelectedObject
local Focusable = Roact.Component:extend(componentName)
Focusable.validateProps = focusableValidateProps
Focusable.defaultProps = focusableDefaultProps
function Focusable:init()
self.focused = false
local parentNeighbors = self.props.parentNeighbors or {}
self.navContext = {
focusNode = FocusNode.new(self.props),
neighbors = {
NextSelectionLeft = self.props.NextSelectionLeft or parentNeighbors.NextSelectionLeft,
NextSelectionRight = self.props.NextSelectionRight or parentNeighbors.NextSelectionRight,
NextSelectionUp = self.props.NextSelectionUp or parentNeighbors.NextSelectionUp,
NextSelectionDown = self.props.NextSelectionDown or parentNeighbors.NextSelectionDown,
}
}
self.updateFocusedState = function(newFocusedState)
if not self.focused and newFocusedState then
self:gainFocus()
elseif self.focused and not newFocusedState then
self:loseFocus()
end
end
if self:isRoot() then
local isRooted = false
-- If this Focusable needs to behave as a root, it is responsible for
-- initializing the FocusManager. Once it becomes a descendant of
-- `game`, we initialize the FocusManager which determines which sort of
-- PlayerGui this focus tree is contained under
self.ancestryChanged = function(instance)
if not isRooted and instance:IsDescendantOf(game) then
isRooted = true
self:getFocusControllerInternal():initialize(getEngineInterface(instance))
end
end
-- This function is called separately, since we don't want to falsely
-- trigger an existing callback in props when we call
-- `ancestryChanged` in didMount
self.ancestryChangedListener = function(instance)
self.ancestryChanged(instance)
local existingCallback = self.props[Roact.Event.AncestryChanged]
if existingCallback ~= nil then
existingCallback(instance)
end
end
self.refreshFocusOnDescendantAdded = function(descendant)
self:getFocusControllerInternal():descendantAddedRefocus()
local existingCallback = self.props[Roact.Event.DescendantAdded]
if existingCallback ~= nil then
existingCallback(descendant)
end
end
self.refreshFocusOnDescendantRemoved = function(descendant)
self:getFocusControllerInternal():descendantRemovedRefocus()
local existingCallback = self.props[Roact.Event.DescendantRemoving]
if existingCallback ~= nil then
existingCallback(descendant)
end
end
end
end
function Focusable:willUpdate(nextProps)
-- Here, we need to carefully update the navigation context according to
-- the incoming props. There are three different categories of prop
-- changes we have to deal with.
-- 1. Apply the changes from the navigation props themselves. These only
-- affect navigation behavior for this node's ref and do not need to
-- cascade to other parts of the tree
self.navContext.focusNode:updateNavProps(nextProps)
-- 2. If neighbors changed, we need to cascade this change through
-- context, so we make sure the value that we pass to context has a
-- _new_ identity
if
nextProps.NextSelectionLeft ~= self.navContext.neighbors.NextSelectionLeft
or nextProps.NextSelectionRight ~= self.navContext.neighbors.NextSelectionRight
or nextProps.NextSelectionDown ~= self.navContext.neighbors.NextSelectionDown
or nextProps.NextSelectionUp ~= self.navContext.neighbors.NextSelectionUp
or nextProps.parentNeighbors ~= self.props.parentNeighbors
then
local parentNeighbors = nextProps.parentNeighbors or {}
self.navContext = {
focusNode = self.navContext.focusNode,
neighbors = {
NextSelectionLeft = nextProps.NextSelectionLeft or parentNeighbors.NextSelectionLeft,
NextSelectionRight = nextProps.NextSelectionRight or parentNeighbors.NextSelectionRight,
NextSelectionUp = nextProps.NextSelectionUp or parentNeighbors.NextSelectionUp,
NextSelectionDown = nextProps.NextSelectionDown or parentNeighbors.NextSelectionDown,
}
}
end
-- 3. Finally, if the ref changed, then for now we simply get angry and
-- throw an error; we'll likely have to manage this another way
-- anyways!
if self.navContext.focusNode.ref ~= nextProps[Roact.Ref] then
error("Cannot change the ref passed to a Focusable component", 0)
end
end
function Focusable:gainFocus()
self.focused = true
if self.props.onFocusGained ~= nil then
self.props.onFocusGained()
end
if self.props.onFocusChanged ~= nil then
self.props.onFocusChanged(true)
end
end
function Focusable:loseFocus()
self.focused = false
if self.props.onFocusLost ~= nil then
self.props.onFocusLost()
end
if self.props.onFocusChanged ~= nil then
self.props.onFocusChanged(false)
end
end
-- Determines whether or not this Focusable is supposed to be the root of a
-- focusable tree, determined by whether or not it has parent or focus props
-- provided
function Focusable:isRoot()
return self.props.focusController ~= nil and self.props.parentFocusNode == nil
end
function Focusable:getFocusControllerInternal()
return self.navContext.focusNode.focusController[InternalApi]
end
function Focusable:render()
local ref = self.props[Roact.Ref]
local childDefaultNavProps = {
NextSelectionLeft = ref,
NextSelectionRight = ref,
NextSelectionDown = ref,
NextSelectionUp = ref,
[Roact.Ref] = ref,
}
local innerProps
if self:isRoot() then
local rootNavProps = {
[Roact.Event.AncestryChanged] = self.ancestryChangedListener,
[Roact.Event.DescendantAdded] = self.refreshFocusOnDescendantAdded,
[Roact.Event.DescendantRemoving] = self.refreshFocusOnDescendantRemoved,
}
innerProps = Cryo.Dictionary.join(
childDefaultNavProps,
self.props,
rootNavProps,
nonHostProps
)
else
innerProps = Cryo.Dictionary.join(
childDefaultNavProps,
self.props.parentNeighbors or {},
self.props,
nonHostProps
)
end
-- We pass the inner component as a single child (instead of part of a
-- table of children) because it causes Roact to reuse the key provided
-- to _this_ component when naming the resulting object. This means that
-- Focusable avoids disrupting the naming of the Instance hierarchy
return Roact.createElement(FocusContext.Provider, {
value = self.navContext,
}, Roact.createElement(innerComponent, innerProps))
end
function Focusable:didMount()
self.navContext.focusNode:attachToTree(self.props.parentFocusNode, self.updateFocusedState)
if self:isRoot() then
-- Ancestry change may not trigger if the UI elements we're mounting
-- to were previously mounted to the DataModel already
self.ancestryChanged(self.props[Roact.Ref]:getValue())
end
end
function Focusable:willUnmount()
self.navContext.focusNode:detachFromTree()
if self:isRoot() then
self:getFocusControllerInternal():teardown()
end
end
return forwardRef(function(props, ref)
return Roact.createElement(FocusContext.Consumer, {
render = function(navContext)
if navContext == nil and props.focusController == nil then
-- If this component can't be the root, and there's no
-- parent, behave like the underlying component and ignore
-- all focus logic
local hostPropsOnly = Cryo.Dictionary.join(props, nonHostProps)
return Roact.createElement(innerComponent, hostPropsOnly)
end
local propsWithNav = Cryo.Dictionary.join(props, {
parentFocusNode = navContext and navContext.focusNode or nil,
parentNeighbors = navContext and navContext.neighbors or nil,
[Roact.Ref] = ref,
})
return Roact.createElement(Focusable, propsWithNav)
end,
})
end)
end
return asFocusable
@@ -0,0 +1,801 @@
return function()
local Players = game:GetService("Players")
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
local asFocusable = require(script.Parent.asFocusable)
local createRefCache = require(script.Parent.createRefCache)
local FocusContext = require(script.Parent.FocusContext)
local FocusNode = require(script.Parent.FocusNode)
local Input = require(script.Parent.Input)
local FocusController = require(script.Parent.FocusController)
local InternalApi = require(script.Parent.FocusControllerInternalApi)
local MockEngine = require(script.Parent.Test.MockEngine)
local createSpy = require(script.Parent.Test.createSpy)
local function createRootNode(ref)
local node = FocusNode.new({
focusController = FocusController.createPublicApiWrapper(),
[Roact.Ref] = ref,
})
node:attachToTree(nil, function() end)
return node
end
local function createTestContainer()
local rootRef, _ = Roact.createBinding(Instance.new("Frame"))
local focusNode = createRootNode(rootRef)
local mockEngine, engineInterface = MockEngine.new()
focusNode.focusController[InternalApi]:initialize(engineInterface)
return {
rootRef = rootRef,
rootFocusNode = focusNode,
focusController = focusNode.focusController[InternalApi],
mockEngine = mockEngine,
engineInterface = engineInterface,
getNode = function(ref)
return focusNode.focusController[InternalApi].allNodes[ref]
end,
FocusProvider = function(props)
return Roact.createElement(FocusContext.Provider, {
value = {
focusNode = focusNode,
}
}, props[Roact.Children])
end
}
end
describe("Focusable component basics", function()
it("adds a new node to the focus tree when it mounts", function()
local testContainer = createTestContainer()
local FocusableFrame = asFocusable("Frame")
local injectedRef = Roact.createRef()
local tree = Roact.mount(Roact.createElement(testContainer.FocusProvider, {}, {
FocusChild = Roact.createElement(FocusableFrame, {
[Roact.Ref] = injectedRef,
}),
}))
local focusController = testContainer.focusController
expect(injectedRef:getValue()).to.be.ok()
expect(focusController.allNodes[injectedRef]).to.be.ok()
local children = focusController:getChildren(testContainer.rootFocusNode)
expect(children[injectedRef]).to.be.ok()
Roact.unmount(tree)
end)
it("removes nodes from the focus tree when the component unmounts", function()
local testContainer = createTestContainer()
local FocusableFrame = asFocusable("Frame")
local injectedRef = Roact.createRef()
local tree = Roact.mount(Roact.createElement(testContainer.FocusProvider, {}, {
FocusChild = Roact.createElement(FocusableFrame, {
[Roact.Ref] = injectedRef,
}),
}))
-- Update the tree with the child focusable frame absent, which will
-- unmount it from the tree
Roact.update(tree, Roact.createElement(testContainer.FocusProvider))
local focusController = testContainer.focusController
expect(injectedRef:getValue()).to.equal(nil)
expect(focusController.allNodes[injectedRef]).to.equal(nil)
local children = focusController:getChildren(testContainer.rootFocusNode)
expect(children[injectedRef]).to.equal(nil)
Roact.unmount(tree)
end)
it("triggers callbacks when focus changes", function()
local testContainer = createTestContainer()
local FocusableFrame = asFocusable("Frame")
local focusGainedSpy = createSpy()
local focusLostSpy = createSpy()
local focusChangedSpy = createSpy()
local childRefA = Roact.createRef()
local childRefB = Roact.createRef()
local tree = Roact.mount(Roact.createElement(testContainer.FocusProvider, {}, {
FocusChildA = Roact.createElement(FocusableFrame, {
[Roact.Ref] = childRefA,
onFocusGained = focusGainedSpy.value,
onFocusLost = focusLostSpy.value,
onFocusChanged = focusChangedSpy.value,
}),
FocusChildB = Roact.createElement(FocusableFrame, {
[Roact.Ref] = childRefB,
})
}), testContainer.rootRef:getValue())
testContainer.focusController:moveFocusTo(childRefA)
expect(focusGainedSpy.callCount).to.equal(1)
expect(focusChangedSpy.callCount).to.equal(1)
focusChangedSpy:assertCalledWith(true)
testContainer.focusController:moveFocusTo(childRefB)
expect(focusLostSpy.callCount).to.equal(1)
expect(focusChangedSpy.callCount).to.equal(2)
focusChangedSpy:assertCalledWith(false)
Roact.unmount(tree)
end)
it("triggers callbacks when focus is released", function()
local testContainer = createTestContainer()
local FocusableFrame = asFocusable("Frame")
local focusLostSpy = createSpy()
local focusChangedSpy = createSpy()
local childRefA = Roact.createRef()
local tree = Roact.mount(Roact.createElement(testContainer.FocusProvider, {}, {
FocusChildA = Roact.createElement(FocusableFrame, {
[Roact.Ref] = childRefA,
onFocusLost = focusLostSpy.value,
onFocusChanged = focusChangedSpy.value,
}),
}), testContainer.rootRef:getValue())
testContainer.focusController:moveFocusTo(childRefA)
expect(focusChangedSpy.callCount).to.equal(1)
focusChangedSpy:assertCalledWith(true)
testContainer.focusController:releaseFocus()
expect(focusLostSpy.callCount).to.equal(1)
expect(focusChangedSpy.callCount).to.equal(2)
focusChangedSpy:assertCalledWith(false)
Roact.unmount(tree)
end)
end)
describe("Root vs non-root Focusable", function()
it("should ignore focusable logic if no parent or controller is provided", function()
local FocusableFrame = asFocusable("Frame")
local focusGainedSpy = createSpy()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
onFocusGained = focusGainedSpy.value,
}))
expect(focusGainedSpy.callCount).to.equal(0)
Roact.unmount(tree)
end)
it("should initialize and teardown focusController when it's the root", function()
local FocusableFrame = asFocusable("Frame")
-- This test is testing the automatic initialization of the internal
-- focusController based on the instance tree it's attached to. To
-- do this, we depend on the using a PlayerGui instance to avoid
-- simulate the real-world use case instead of the mock engine
expect(Players.LocalPlayer.PlayerGui).to.be.ok()
local focusController = FocusController.createPublicApiWrapper()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
}), Players.LocalPlayer.PlayerGui)
expect(focusController[InternalApi].engineInterface).never.to.equal(nil)
Roact.unmount(tree)
expect(focusController[InternalApi].engineInterface).to.equal(nil)
end)
it("should inherit parent neighbors through multiple layers", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local function getNode(ref)
return focusController[InternalApi].allNodes[ref]
end
local refs = createRefCache()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
[Roact.Ref] = refs.root,
}, {
TopSelectionTarget = Roact.createElement(FocusableFrame, {
NextSelectionDown = refs.bottomFocusable,
[Roact.Ref] = refs.topFocusable,
}),
BottomSelectionTarget = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.bottomFocusable,
NextSelectionUp = refs.topFocusable,
}, {
IntermediateChild = Roact.createElement(FocusableFrame, {}, {
-- This focusable child should be able to inherit
-- neighbors from its grandparent
LeafChild = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.bottomLeaf,
})
}),
}),
}), nil)
local focusControllerInternal = focusController[InternalApi]
local mockEngine, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
-- Initialize gamepad focus to the top element
local topNode = getNode(refs.topFocusable)
topNode:focus()
expect(focusControllerInternal:isNodeFocused(topNode)).to.equal(true)
-- Move focus down; this should work without neighbor propagation
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.DPadDown,
})
local bottomLeafNode = getNode(refs.bottomLeaf)
expect(focusControllerInternal:isNodeFocused(bottomLeafNode)).to.equal(true)
-- Move focus up; this only works if grandparents' neighbors get
-- passed down correctly
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.DPadUp,
})
expect(focusControllerInternal:isNodeFocused(topNode)).to.equal(true)
Roact.unmount(tree)
end)
end)
-- These tests rely on the fact that a FocusController passed to a Focusable
-- component will _not_ be automatically initialized if it's not mounted
-- under a PlayerGui. We leverage this technicality to initialize it
-- ourselves with the mock engine interface.
describe("Refresh focus logic", function()
it("should redirect focus to the parent when a focused child is detached", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local function getNode(ref)
return focusController[InternalApi].allNodes[ref]
end
local refs = createRefCache()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
[Roact.Ref] = refs.root,
}, {
FocusChild = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.child,
}),
}), nil)
local focusControllerInternal = focusController[InternalApi]
local _, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
local childNode = getNode(refs.child)
childNode:focus()
expect(focusControllerInternal:isNodeFocused(childNode)).to.equal(true)
tree = Roact.update(tree, Roact.createElement(FocusableFrame, {
focusController = focusController,
[Roact.Ref] = refs.root,
}))
local rootNode = getNode(refs.root)
expect(focusControllerInternal:isNodeFocused(rootNode)).to.equal(true)
Roact.unmount(tree)
end)
it("should trigger parent focus logic when a focused child is detached", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local function getNode(ref)
return focusController[InternalApi].allNodes[ref]
end
local refs = createRefCache()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
FocusChildA = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.childA,
}),
FocusChildB = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.childB,
}),
}), nil)
local focusControllerInternal = focusController[InternalApi]
local _, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
local childNodeA = getNode(refs.childA)
childNodeA:focus()
expect(focusControllerInternal:isNodeFocused(childNodeA)).to.equal(true)
tree = Roact.update(tree, Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
FocusChildB = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.childB,
}),
}))
local childNodeB = getNode(refs.childB)
expect(focusControllerInternal:isNodeFocused(childNodeB)).to.equal(true)
Roact.unmount(tree)
end)
it("should trigger parent focus logic when a node has children added to it", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local function getNode(ref)
return focusController[InternalApi].allNodes[ref]
end
local refs = createRefCache()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
[Roact.Ref] = refs.root,
}), nil)
local focusControllerInternal = focusController[InternalApi]
local _, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
focusController.captureFocus()
local rootNode = getNode(refs.root)
expect(focusControllerInternal:isNodeFocused(rootNode)).to.equal(true)
tree = Roact.update(tree, Roact.createElement(FocusableFrame, {
focusController = focusController,
[Roact.Ref] = refs.root,
}, {
FocusChild = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.child,
}),
}))
local childNode = getNode(refs.child)
expect(focusControllerInternal:isNodeFocused(childNode)).to.equal(true)
Roact.unmount(tree)
end)
it("should not refocus when adding children to a parent that already has at least one child", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local function getNode(ref)
return focusController[InternalApi].allNodes[ref]
end
local childRefA = Roact.createRef()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
FocusChildA = Roact.createElement(FocusableFrame, {
[Roact.Ref] = childRefA,
}),
}), nil)
local focusControllerInternal = focusController[InternalApi]
local _, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
local childNodeA = getNode(childRefA)
childNodeA:focus()
expect(focusControllerInternal:isNodeFocused(childNodeA)).to.equal(true)
tree = Roact.update(tree, Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
FocusChildA = Roact.createElement(FocusableFrame, {
[Roact.Ref] = childRefA,
}),
FocusChildB = Roact.createElement(FocusableFrame),
}))
-- Focus should not have moved as a result of the above change
expect(focusControllerInternal:isNodeFocused(childNodeA)).to.equal(true)
Roact.unmount(tree)
end)
it("should clean up input event subscriptions when the Focusable they're bound to is detached", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local beginCallbackSpy, moveStepCallbackSpy = createSpy(), createSpy()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
FocusChildA = Roact.createElement(FocusableFrame, {
inputBindings = {
Input.PublicInterface.onBegin(Enum.KeyCode.ButtonX, beginCallbackSpy.value),
Input.PublicInterface.onMoveStep(moveStepCallbackSpy.value),
}
}),
}), nil)
local focusControllerInternal = focusController[InternalApi]
local mockEngine, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
focusController.captureFocus()
expect(beginCallbackSpy.callCount).to.equal(0)
expect(moveStepCallbackSpy.callCount).to.equal(0)
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
mockEngine:renderStep()
expect(beginCallbackSpy.callCount).to.equal(1)
expect(moveStepCallbackSpy.callCount).to.equal(1)
-- Remove the child from the tree, which will also nil its parents
-- and trigger any auto-refocusing logic
local tree = Roact.update(tree, Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
-- Child was removed
}), nil)
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
mockEngine:renderStep()
expect(beginCallbackSpy.callCount).to.equal(1)
expect(moveStepCallbackSpy.callCount).to.equal(1)
Roact.unmount(tree)
end)
it("should clean up input event subscriptions when the whole tree is cleaned up", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local beginCallbackSpy, moveStepCallbackSpy = createSpy(), createSpy()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
inputBindings = {
Input.PublicInterface.onBegin(Enum.KeyCode.ButtonX, beginCallbackSpy.value),
Input.PublicInterface.onMoveStep(moveStepCallbackSpy.value),
}
}), nil)
local focusControllerInternal = focusController[InternalApi]
local mockEngine, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
focusController.captureFocus()
expect(beginCallbackSpy.callCount).to.equal(0)
expect(moveStepCallbackSpy.callCount).to.equal(0)
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
mockEngine:renderStep()
expect(beginCallbackSpy.callCount).to.equal(1)
expect(moveStepCallbackSpy.callCount).to.equal(1)
-- Remove the child from the tree, which will also nil its parents
-- and trigger any auto-refocusing logic
Roact.unmount(tree)
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
mockEngine:renderStep()
expect(beginCallbackSpy.callCount).to.equal(1)
expect(moveStepCallbackSpy.callCount).to.equal(1)
end)
end)
describe("Component behavior", function()
it("should not replace any provided event handlers", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local ancestryChangedSpy = createSpy()
local descendantAddedSpy = createSpy()
local descendantRemovedSpy = createSpy()
local rootRef = Roact.createRef()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
[Roact.Ref] = rootRef,
[Roact.Event.AncestryChanged] = ancestryChangedSpy.value,
[Roact.Event.DescendantAdded] = descendantAddedSpy.value,
[Roact.Event.DescendantRemoving] = descendantRemovedSpy.value,
}), nil)
expect(ancestryChangedSpy.callCount).to.equal(0)
expect(descendantAddedSpy.callCount).to.equal(0)
expect(descendantRemovedSpy.callCount).to.equal(0)
local newParentFrame = Instance.new("Frame")
rootRef:getValue().Parent = newParentFrame
expect(ancestryChangedSpy.callCount).to.equal(1)
local newChildFrame = Instance.new("Frame")
newChildFrame.Parent = rootRef:getValue()
expect(descendantAddedSpy.callCount).to.equal(1)
newChildFrame.Parent = nil
expect(descendantRemovedSpy.callCount).to.equal(1)
Roact.unmount(tree)
end)
it("should update navigation logic correctly when props update", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local xBindingSpy = createSpy()
local yBindingSpy = createSpy()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
inputBindings = {
onXButton = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonX, xBindingSpy.value),
}
}), nil)
local focusControllerInternal = focusController[InternalApi]
local mockEngine, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
focusController.captureFocus()
expect(xBindingSpy.callCount).to.equal(0)
expect(yBindingSpy.callCount).to.equal(0)
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
expect(xBindingSpy.callCount).to.equal(1)
expect(yBindingSpy.callCount).to.equal(0)
tree = Roact.update(tree, Roact.createElement(FocusableFrame, {
focusController = focusController,
inputBindings = {
onYButton = Input.PublicInterface.onBegin(Enum.KeyCode.ButtonY, yBindingSpy.value),
}
}))
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonY,
})
expect(xBindingSpy.callCount).to.equal(1)
expect(yBindingSpy.callCount).to.equal(1)
-- Pressing X again does not trigger the old callback
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.ButtonX,
})
expect(xBindingSpy.callCount).to.equal(1)
Roact.unmount(tree)
end)
it("should propagate updates to inherited parent neighbor relationships", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local function getNode(ref)
return focusController[InternalApi].allNodes[ref]
end
local parentRefA = Roact.createRef()
local parentRefB = Roact.createRef()
local childRefA = Roact.createRef()
local childRefB = Roact.createRef()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
ParentA = Roact.createElement(FocusableFrame, {
NextSelectionDown = parentRefB,
[Roact.Ref] = parentRefA,
}, {
ChildA = Roact.createElement(FocusableFrame, {
[Roact.Ref] = childRefA,
})
}),
ParentB = Roact.createElement(FocusableFrame, {
-- No neighbors set
[Roact.Ref] = parentRefB,
}, {
ChildB = Roact.createElement(FocusableFrame, {
[Roact.Ref] = childRefB,
})
}),
}), nil)
local focusControllerInternal = focusController[InternalApi]
local mockEngine, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
local childNodeA = getNode(childRefA)
local childNodeB = getNode(childRefB)
childNodeA:focus()
expect(focusControllerInternal:isNodeFocused(childNodeA)).to.equal(true)
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.DPadDown,
})
expect(focusControllerInternal:isNodeFocused(childNodeB)).to.equal(true)
-- Try moving back up; no neighbors are set, and none are inherited
-- from the parents, so this shouldn't cause the selection to move
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.DPadUp,
})
expect(focusControllerInternal:isNodeFocused(childNodeB)).to.equal(true)
-- Update the tree to introduce an upward neighbor
tree = Roact.update(tree, Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
ParentA = Roact.createElement(FocusableFrame, {
NextSelectionDown = parentRefB,
[Roact.Ref] = parentRefA,
}, {
ChildA = Roact.createElement(FocusableFrame, {
[Roact.Ref] = childRefA,
})
}),
ParentB = Roact.createElement(FocusableFrame, {
NextSelectionUp = parentRefA,
[Roact.Ref] = parentRefB,
}, {
ChildB = Roact.createElement(FocusableFrame, {
[Roact.Ref] = childRefB,
})
}),
}))
-- Make sure we're still on B like before
expect(focusControllerInternal:isNodeFocused(childNodeB)).to.equal(true)
-- This time, moving back up should work as expected
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.DPadUp,
})
expect(focusControllerInternal:isNodeFocused(childNodeA)).to.equal(true)
Roact.unmount(tree)
end)
it("should propagate updates to inherited grandparent neighbor relationships", function()
local FocusableFrame = asFocusable("Frame")
local focusController = FocusController.createPublicApiWrapper()
local function getNode(ref)
return focusController[InternalApi].allNodes[ref]
end
local refs = createRefCache()
local tree = Roact.mount(Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
TopSelectionTarget = Roact.createElement(FocusableFrame, {
NextSelectionDown = refs.bottomFocusable,
[Roact.Ref] = refs.topFocusable,
}),
BottomSelectionTarget = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.bottomFocusable,
}, {
IntermediateChild = Roact.createElement(FocusableFrame, {}, {
LeafChild = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.bottomLeaf,
})
}),
}),
}), nil)
local focusControllerInternal = focusController[InternalApi]
local mockEngine, engineInterface = MockEngine.new()
focusControllerInternal:initialize(engineInterface)
local bottomLeafNode = getNode(refs.bottomLeaf)
bottomLeafNode:focus()
expect(focusControllerInternal:isNodeFocused(bottomLeafNode)).to.equal(true)
-- This upward input will not work on this tree, since there's no
-- upward neighbor defined just yet
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.DPadUp,
})
expect(focusControllerInternal:isNodeFocused(bottomLeafNode)).to.equal(true)
-- Update the tree to introduce an upward neighbor
tree = Roact.update(tree, Roact.createElement(FocusableFrame, {
focusController = focusController,
}, {
TopSelectionTarget = Roact.createElement(FocusableFrame, {
NextSelectionDown = refs.bottomFocusable,
[Roact.Ref] = refs.topFocusable,
}),
BottomSelectionTarget = Roact.createElement(FocusableFrame, {
NextSelectionUp = refs.topFocusable,
[Roact.Ref] = refs.bottomFocusable,
}, {
IntermediateChild = Roact.createElement(FocusableFrame, {}, {
-- This focusable child should be able to inherit
-- neighbors from its grandparent
LeafChild = Roact.createElement(FocusableFrame, {
[Roact.Ref] = refs.bottomLeaf,
})
}),
}),
}))
-- Make sure we're still on B like before
expect(focusControllerInternal:isNodeFocused(bottomLeafNode)).to.equal(true)
-- This time, moving up should work as expected
mockEngine:simulateInput({
UserInputType = Enum.UserInputType.Gamepad1,
UserInputState = Enum.UserInputState.Begin,
KeyCode = Enum.KeyCode.DPadUp,
})
local topNode = getNode(refs.topFocusable)
expect(focusControllerInternal:isNodeFocused(topNode)).to.equal(true)
Roact.unmount(tree)
end)
end)
end
@@ -0,0 +1,51 @@
local asFocusable = require(script.Parent.asFocusable)
local function checkHostProperties(component)
local instance = Instance.new(component)
assert(instance:IsA("GuiObject"))
end
local function isValidFocusable(component)
local componentType = typeof(component)
if componentType == "string" then
local hasHostProps, _ = pcall(checkHostProperties, component)
return hasHostProps
elseif componentType == "function" or componentType == "table" then
-- Not much else we can do here right now
return true
end
-- All other types are invalid components anyways
return false
end
-- Returns a table that dynamically instantiates Focusable components whenever a
-- new key is accessed. This means that any valid component can be Focusable
local function createFocusableCache()
local focusableComponentCache = {}
setmetatable(focusableComponentCache, {
__index = function(_, key)
if not isValidFocusable(key) then
error("Component " .. tostring(key) .. " (" .. typeof(key) .. ") is not a valid focusable component", 2)
end
local newComponent = asFocusable(key)
focusableComponentCache[key] = newComponent
return newComponent
end,
__tostring = function(self)
local result = "{"
for key, componentClass in pairs(self) do
result = ("%s\n\t%s -> %s"):format(result, tostring(key), tostring(componentClass))
end
return result .. "\n}"
end
})
return focusableComponentCache
end
return createFocusableCache
@@ -0,0 +1,42 @@
return function()
local createFocusableCache = require(script.Parent.createFocusableCache)
it("should always return Roact components", function()
local focusableCache = createFocusableCache()
local keys = { "TextLabel", "Frame", "ImageLabel" }
for _, key in ipairs(keys) do
local focusableComponent = focusableCache[key]
expect(focusableComponent).never.to.equal(nil)
-- We don't really have a good way to verify types right now
expect(typeof(focusableComponent.render)).to.equal("function")
end
end)
it("should return the same object for the same key", function()
local focusableCache = createFocusableCache()
local TextLabel = focusableCache.TextLabel
local Frame = focusableCache.Frame
expect(TextLabel).to.equal(focusableCache.TextLabel)
expect(Frame).to.equal(focusableCache.Frame)
end)
it("should verify (to the best of its ability) that the provided component is a viable focusable component", function()
local focusableCache = createFocusableCache()
expect(function()
return focusableCache[1]
end).to.throw()
expect(function()
return focusableCache.Folder
end).to.throw()
local function myFunctionComponent(props)
return nil
end
expect(function()
return focusableCache[myFunctionComponent]
end).never.to.throw()
end)
end
@@ -0,0 +1,32 @@
--[[
This is a handy trick to allow us to reference refs before we've actually
rendered anything, and without duplicating rendering logic!
]]
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
-- Returns a table that dynamically instantiates refs whenever a new key is
-- accessed; helpful for building dynamic lists of elements
local function createRefCache()
local refCache = {}
setmetatable(refCache, {
__index = function(_, key)
local newRef = Roact.createRef()
refCache[key] = newRef
return newRef
end,
__tostring = function(self)
local result = "{"
for key, ref in pairs(self) do
result = ("%s\n\t%s -> %s"):format(result, tostring(key), tostring(ref))
end
return result .. "\n}"
end
})
return refCache
end
return createRefCache
@@ -0,0 +1,23 @@
return function()
local createRefCache = require(script.Parent.createRefCache)
it("should always return valid ref objects", function()
local refCache = createRefCache()
local keys = { "test", "whatever", "some key" }
for _, key in ipairs(keys) do
local ref = refCache[key]
expect(ref).never.to.equal(nil)
expect(typeof(ref.getValue)).to.equal("function")
end
end)
it("should return the same object for the same key", function()
local refCache = createRefCache()
local firstRef = refCache.firstRef
local secondRef = refCache.secondRef
expect(firstRef).to.equal(refCache.firstRef)
expect(secondRef).to.equal(refCache.secondRef)
end)
end
@@ -0,0 +1,75 @@
--[[
This is a simple signal implementation that has a dead-simple API.
local signal = createSignal()
local disconnect = signal:subscribe(function(foo)
print("Cool foo:", foo)
end)
signal:fire("something")
disconnect()
]]
local function addToMap(map, addKey, addValue)
local new = {}
for key, value in pairs(map) do
new[key] = value
end
new[addKey] = addValue
return new
end
local function removeFromMap(map, removeKey)
local new = {}
for key, value in pairs(map) do
if key ~= removeKey then
new[key] = value
end
end
return new
end
local function createSignal()
local connections = {}
local function subscribe(self, callback)
assert(typeof(callback) == "function", "Can only subscribe to signals with a function.")
local connection = {
callback = callback,
}
connections = addToMap(connections, callback, connection)
local function disconnect()
assert(not connection.disconnected, "Listeners can only be disconnected once.")
connection.disconnected = true
connections = removeFromMap(connections, callback)
end
return disconnect
end
local function fire(self, ...)
for callback, connection in pairs(connections) do
if not connection.disconnected then
callback(...)
end
end
end
return {
subscribe = subscribe,
fire = fire,
}
end
return createSignal
@@ -0,0 +1,9 @@
local DEBUG = false
local function debugPrint(...)
if DEBUG then
print(...)
end
end
return debugPrint
@@ -0,0 +1,24 @@
-- TODO: Consider contributing this to Roact itself
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
--[[
Passed a provided ref to given render callback. Can be used to treat class
components as host components and assign the passed-in ref to the underlying
host component
]]
local function forwardRef(render)
local ForwardRefComponent = Roact.Component:extend("ForwardRefContainer")
function ForwardRefComponent:init()
self.defaultRef = Roact.createRef()
end
function ForwardRefComponent:render()
return render(self.props, self.props[Roact.Ref] or self.defaultRef)
end
return ForwardRefComponent
end
return forwardRef
@@ -0,0 +1,54 @@
return function()
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
local forwardRef = require(script.Parent.forwardRef)
local createSpy = require(script.Parent.Test.createSpy)
it("should provide a valid ref to the given function when none is passed in", function()
local internalComponentSpy = createSpy(function(props, ref)
return nil
end)
local Component = forwardRef(internalComponentSpy.value)
expect(internalComponentSpy.callCount).to.equal(0)
local tree = Roact.mount(Roact.createElement(Component, {
text = "Hello",
}), nil)
expect(internalComponentSpy.callCount).to.equal(1)
local args = internalComponentSpy:captureValues("props", "ref")
expect(args.props.text).to.equal("Hello")
expect(args.ref).to.be.ok()
expect(typeof(args.ref.getValue)).to.equal("function")
Roact.unmount(tree)
end)
it("should forward a provided ref if present", function()
local internalComponentSpy = createSpy(function(props, ref)
return nil
end)
local Component = forwardRef(internalComponentSpy.value)
expect(internalComponentSpy.callCount).to.equal(0)
local providedRef = Roact.createRef()
local tree = Roact.mount(Roact.createElement(Component, {
text = "Hello",
[Roact.Ref] = providedRef,
}), nil)
expect(internalComponentSpy.callCount).to.equal(1)
local args = internalComponentSpy:captureValues("props", "ref")
expect(args.props.text).to.equal("Hello")
expect(args.ref).to.equal(providedRef)
Roact.unmount(tree)
end)
end
@@ -0,0 +1,98 @@
--!strict
local GuiService = game:GetService("GuiService")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local CoreGui = game:GetService("CoreGui")
--[[
The CoreInterface will be used for any focus trees mounted under the
`CoreGui` service in the DataModel.
]]
local CoreInterface = {}
function CoreInterface.getGamepadConnected(gamepadNum)
return UserInputService:GetGamepadConnected(gamepadNum)
end
function CoreInterface.getGamepadState(gamepadNum)
return UserInputService:GetGamepadState(gamepadNum)
end
function CoreInterface.getNavigationGamepads()
return UserInputService:GetNavigationGamepads()
end
function CoreInterface.getSelection()
return GuiService.SelectedCoreObject
end
function CoreInterface.setSelection(selectionTarget)
GuiService.SelectedCoreObject = selectionTarget
end
function CoreInterface.subscribeToSelectionChanged(callback)
return GuiService:GetPropertyChangedSignal("SelectedCoreObject"):Connect(callback)
end
function CoreInterface.subscribeToRenderStepped(callback)
return RunService.RenderStepped:Connect(callback)
end
function CoreInterface.subscribeToGamepadConnected(callback)
return UserInputService.GamepadConnected:Connect(callback)
end
function CoreInterface.subscribeToGamepadDisconnected(callback)
return UserInputService.GamepadDisconnected:Connect(callback)
end
function CoreInterface.subscribeToInputBegan(callback)
return UserInputService.InputBegan:Connect(callback)
end
function CoreInterface.subscribeToInputEnded(callback)
return UserInputService.InputEnded:Connect(callback)
end
--[[
The PlayerGuiInterface will be used for focus trees mounted anywhere under a
`PlayerGui` instance
]]
local PlayerGuiInterface = {}
function PlayerGuiInterface.getSelection()
return GuiService.SelectedObject
end
function PlayerGuiInterface.setSelection(selectionTarget)
GuiService.SelectedObject = selectionTarget
end
function PlayerGuiInterface.subscribeToSelectionChanged(callback)
return GuiService:GetPropertyChangedSignal("SelectedObject"):Connect(callback)
end
-- These functions aren't distinct from their core counterparts, but are still
-- very useful to mock. For the PlayerGuiInterface, we simply reuse the same
-- function as the CoreInterface
PlayerGuiInterface.getGamepadConnected = CoreInterface.getGamepadConnected
PlayerGuiInterface.getGamepadState = CoreInterface.getGamepadState
PlayerGuiInterface.getNavigationGamepads = CoreInterface.getNavigationGamepads
PlayerGuiInterface.subscribeToRenderStepped = CoreInterface.subscribeToRenderStepped
PlayerGuiInterface.subscribeToGamepadConnected = CoreInterface.subscribeToGamepadConnected
PlayerGuiInterface.subscribeToGamepadDisconnected = CoreInterface.subscribeToGamepadDisconnected
PlayerGuiInterface.subscribeToInputBegan = CoreInterface.subscribeToInputBegan
PlayerGuiInterface.subscribeToInputEnded = CoreInterface.subscribeToInputEnded
return function(instance)
if instance:IsDescendantOf(CoreGui) then
return CoreInterface
else
local playerGui = instance:FindFirstAncestorWhichIsA("PlayerGui")
if playerGui == nil then
error("Gamepad navigation not supported. Must be a child of CoreGui or a PlayerGui")
end
return PlayerGuiInterface
end
end
@@ -0,0 +1,13 @@
local createFocusableCache = require(script.createFocusableCache)
local api = {
createRefCache = require(script.createRefCache),
Focusable = createFocusableCache(),
Input = require(script.Input).PublicInterface,
withFocusController = require(script.withFocusController),
createFocusController = require(script.FocusController).createPublicApiWrapper,
}
return api
@@ -0,0 +1,30 @@
--!strict
-- Manages groups of selectable elements, reacting to selection changes for
-- individual items and triggering events for group selection changes
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
local FocusContext = require(script.Parent.FocusContext)
local function FocusControllerConsumer(props)
return Roact.createElement(FocusContext.Consumer, {
render = function(navContext)
local focusController
if navContext then
focusController = navContext.focusNode.focusController
else
focusController = nil
end
return props.render(focusController)
end,
})
end
local function withFocusController(render)
return Roact.createElement(FocusControllerConsumer, {
render = render
})
end
return withFocusController
@@ -0,0 +1,19 @@
return function()
local Packages = script.Parent.Parent
local Roact = require(Packages.Roact)
local asFocusable = require(script.Parent.asFocusable)
local withFocusController = require(script.Parent.withFocusController)
describe("withFocusController", function()
it("should give a nil focusController if no focusRoot exists in the tree", function()
local FocusableFrame = asFocusable("Frame")
local tree = Roact.mount(withFocusController(function(focusController)
expect(focusController).to.equal(nil)
return Roact.createElement(FocusableFrame)
end))
Roact.unmount(tree)
end)
end)
end
@@ -0,0 +1,12 @@
--[[
Package link auto-generated by Rotriever
]]
local PackageIndex = script.Parent.Parent
local package = PackageIndex["roblox_t"]["t"]
if package.ClassName == "ModuleScript" then
return require(package)
end
return package