Join the RoClicks Discord
RoClicks

PropertySweep.luau

Luau, 130 lines from UI overview

Download PropertySweep.luau
--!strict
-- PropertySweep: objective UI checks for when screenshots can't be trusted (magenta playtest
-- captures, BillboardGuis never captured). Run in the client (playtest) or in edit mode on a cloned
-- ScreenGui. Returns compact lines; parse them locally (a plugin context can't POST anywhere).
--
--   local Sweep = require(path.PropertySweep)
--   print(Sweep.run(PlayerGui.MainUI, { minTap = 48, touch = true }))
--   print(Sweep.atSizes(PlayerGui.MainUI.Root, { Vector2.new(844, 390), Vector2.new(1920, 1080) }, opts))

local PropertySweep = {}

export type Opts = { minTap: number?, touch: boolean?, ignore: { string }? }

local function visible(g: GuiObject): boolean
	local p: Instance? = g
	while p and p:IsA("GuiObject") do
		if not (p :: GuiObject).Visible then return false end
		p = p.Parent
	end
	local sg = g:FindFirstAncestorWhichIsA("LayerCollector")
	return sg == nil or sg.Enabled
end

local function rect(g: GuiObject)
	return g.AbsolutePosition, g.AbsolutePosition + g.AbsoluteSize
end

local function overlaps(a: GuiObject, b: GuiObject): boolean
	local a0, a1 = rect(a)
	local b0, b1 = rect(b)
	local ix = math.min(a1.X, b1.X) - math.max(a0.X, b0.X)
	local iy = math.min(a1.Y, b1.Y) - math.max(a0.Y, b0.Y)
	return ix > 2 and iy > 2 -- 2 px tolerance for touching outlines
end

function PropertySweep.run(root: Instance, opts: Opts?): string
	local o = opts or {}
	local minTap = o.minTap or 48
	local out = {}
	local function add(kind: string, g: Instance, extra: string)
		table.insert(out, kind .. "\t" .. g:GetFullName() .. "\t" .. extra)
	end

	for _, d in root:GetDescendants() do
		if d:IsA("GuiObject") and visible(d) then
			-- text that doesn't fit (TextScaled labels report fit by design; check them by size instead)
			if (d:IsA("TextLabel") or d:IsA("TextButton")) and d.Text ~= "" then
				if not d.TextFits then
					add("TEXT_NOFIT", d, string.format("%dx%d bounds %dx%d", d.AbsoluteSize.X, d.AbsoluteSize.Y,
						d.TextBounds.X, d.TextBounds.Y))
				end
				if string.find(d.Text, "nil", 1, true) or string.find(d.Text, "?", 1, true)
					or string.find(d.Text, "_%d") then
					add("TEXT_SUSPECT", d, d.Text) -- raw ids, "?" fallbacks, "nil"
				end
			end
			-- tap targets
			if o.touch and d:IsA("GuiButton") then
				local s = d.AbsoluteSize
				if s.X < minTap or s.Y < minTap then
					add("TAP_SMALL", d, string.format("%dx%d < %d", s.X, s.Y, minTap))
				end
			end
			-- child escaping its parent's rect
			local parent = d.Parent
			if parent and parent:IsA("GuiObject") and not parent:IsA("ScrollingFrame") then
				local c0, c1 = rect(d)
				local p0, p1 = rect(parent)
				if c0.X < p0.X - 2 or c0.Y < p0.Y - 2 or c1.X > p1.X + 2 or c1.Y > p1.Y + 2 then
					if parent.ClipsDescendants then add("CLIPPED", d, "cut by parent") end
				end
			end
			-- images: report the asset, not the instance (offscreen instances read IsLoaded=false)
			if (d:IsA("ImageLabel") or d:IsA("ImageButton")) and d.Image ~= "" and not d.IsLoaded then
				add("IMG_NOTLOADED", d, d.Image)
			end
		end
	end

	-- sibling overlap between interactive controls
	for _, d in root:GetDescendants() do
		if d:IsA("GuiObject") then
			local btns = {}
			for _, c in d:GetChildren() do
				if c:IsA("GuiButton") and visible(c) then table.insert(btns, c) end
			end
			for i = 1, #btns do
				for j = i + 1, #btns do
					if overlaps(btns[i], btns[j]) then
						add("OVERLAP", btns[i], btns[j].Name)
					end
				end
			end
		end
	end

	-- billboards (never shown by Studio captures)
	for _, d in root:GetDescendants() do
		if d:IsA("BillboardGui") then
			local a = d.Adornee or d.Parent
			if not d.Enabled or not a or not a.Parent then
				add("BILLBOARD", d, "disabled or no adornee")
			end
		end
	end

	return string.format("%d issues\n%s", #out, table.concat(out, "\n"))
end

-- Sweep several screen sizes without touching the real window: resize the design root and its
-- UIScale the way the runtime controller would, sweep, restore.
function PropertySweep.atSizes(root: Frame, sizes: { Vector2 }, opts: Opts?, designW: number?, designH: number?): string
	local dw, dh = designW or 1920, designH or 1080
	local scale = root:FindFirstChildOfClass("UIScale")
	local oldSize, oldScale = root.Size, scale and scale.Scale
	local report = {}
	for _, v in sizes do
		local f = math.min(v.X / dw, v.Y / dh)
		if scale then scale.Scale = f end
		root.Size = UDim2.fromOffset(v.X / f, v.Y / f)
		task.wait() -- let AbsoluteSize/TextFits update
		table.insert(report, string.format("== %dx%d (scale %.2f)\n%s", v.X, v.Y, f, PropertySweep.run(root, opts)))
	end
	root.Size = oldSize
	if scale and oldScale then scale.Scale = oldScale end
	return table.concat(report, "\n")
end

return PropertySweep