add gs
This commit is contained in:
@@ -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
|
||||
+12
@@ -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
|
||||
+12
@@ -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
|
||||
+11
@@ -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",
|
||||
]
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
local Packages = script.Parent.Parent
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
return Roact.createContext(nil)
|
||||
+359
@@ -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
|
||||
+452
@@ -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
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
local Symbol = require(script.Parent.Symbol)
|
||||
|
||||
return Symbol.named("FocusControllerInternalApi")
|
||||
+164
@@ -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
|
||||
+262
@@ -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
|
||||
+197
@@ -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,
|
||||
}
|
||||
}
|
||||
+312
@@ -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
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
local Packages = script.Parent.Parent
|
||||
local enumerate = require(Packages.enumerate)
|
||||
|
||||
return enumerate("InputBindingKind", {
|
||||
"Begin",
|
||||
"End",
|
||||
"Step",
|
||||
"MoveStep",
|
||||
})
|
||||
+30
@@ -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
|
||||
+246
@@ -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
|
||||
+210
@@ -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
|
||||
+67
@@ -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
|
||||
+298
@@ -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
|
||||
+801
@@ -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
|
||||
+51
@@ -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
|
||||
+42
@@ -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
|
||||
+32
@@ -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
|
||||
+23
@@ -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
|
||||
+75
@@ -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
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
local DEBUG = false
|
||||
|
||||
local function debugPrint(...)
|
||||
if DEBUG then
|
||||
print(...)
|
||||
end
|
||||
end
|
||||
|
||||
return debugPrint
|
||||
+24
@@ -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
|
||||
+54
@@ -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
|
||||
+98
@@ -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
|
||||
+13
@@ -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
|
||||
+30
@@ -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
|
||||
+19
@@ -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
|
||||
Reference in New Issue
Block a user