├── LICENSE └── TableInspector.lua /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /TableInspector.lua: -------------------------------------------------------------------------------- 1 | -- local tableInspector = TableInspector.new() 2 | -- set tableInspector.backFrame.Parent to something in the playerGui or starterGui (such as a ScreenGui) 3 | 4 | -- local tableRoot = tableInspector:addTable(name, tab) to make a new tableRoot which looks at tab 5 | -- local anotherTableRoot = tableInspector:addPath(name, {tab, 1, "t"}) to make a new tableRoot which looks at tab[1].t 6 | -- tableInspector:removeTable(tab) to remove a table or path 7 | 8 | -- LMB on the background to drag everything (if enabled) 9 | -- LMB on a table to drag just the table 10 | -- MMB on a table to delete the table 11 | -- Doubleclick LMB or RMB on a table to expand/collapse 12 | -- Shift + LMB on any value to drag out the path 13 | -- Ctrl + LMB on a table to drag out the table 14 | 15 | -- interaction settings 16 | local backgroundDragEnabled = false 17 | 18 | -- settings for customizing the look of the table inspector 19 | local textPad = Vector2.new(2, 2) -- radius 20 | local entryPad = Vector2.new(1, 1) 21 | local tableBorder = 1 22 | local linePad = 1 23 | 24 | local fontSize = 14 25 | local font = Enum.Font.Code 26 | 27 | local separatorSize = Vector2.new(10, 10) 28 | 29 | -- before padding 30 | local maxClosedSize = Vector2.new(7*14, 14*2) 31 | local maxOpenedSize = Vector2.new(7*48, 14*8) 32 | local maxOpenedTableSize = Vector2.new(1/0, 1/0) 33 | 34 | 35 | local boolColor3 = Color3.fromRGB(248, 109, 124) 36 | local numberColor3 = Color3.fromRGB(255, 198, 0) 37 | local stringColor3 = Color3.fromRGB(173, 241, 149) 38 | local functionColor3 = Color3.fromRGB(119, 255, 255) 39 | 40 | local backgroundColor3 = Color3.fromRGB(48, 48, 48) 41 | local operatorColor3 = Color3.fromRGB(204, 204, 204) 42 | 43 | local highlightColor3 = Color3.fromRGB(255, 183, 0) 44 | 45 | local doubleClickInterval = 1/4 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | local TableInspector = {} 54 | TableInspector.__index = TableInspector 55 | 56 | local TableRoot = {} 57 | TableRoot.__index = TableRoot 58 | 59 | local Element = {} 60 | Element.__index = Element 61 | 62 | local Entry = {} 63 | Entry.__index = Entry 64 | 65 | local TextService = game:GetService("TextService") 66 | 67 | 68 | local GuiService = game:GetService("GuiService") 69 | local UserInputService = game:GetService("UserInputService") 70 | local playerGui = game.Players.LocalPlayer:WaitForChild("PlayerGui") 71 | 72 | local function toVector2(vector) 73 | return Vector2.new(vector.x, vector.y) 74 | end 75 | 76 | local function getMousePosition() 77 | return UserInputService:GetMouseLocation() - GuiService:GetGuiInset() 78 | end 79 | 80 | function TableInspector.new() 81 | local self = setmetatable({}, TableInspector) 82 | 83 | self._logScale = 0 84 | 85 | self._tableRoots = {} 86 | 87 | self.backFrame = Instance.new("Frame") 88 | self.backFrame.BackgroundTransparency = 1 89 | self.backFrame.Size = UDim2.fromScale(1, 1) 90 | self.backFrame.ClipsDescendants = true 91 | 92 | self.basisFrame = Instance.new("Frame") 93 | --self.basisFrame.Size = UDim2.fromOffset(100, 100) 94 | self.basisFrame.Size = UDim2.fromOffset(100, 100) -- doesn't matter so much. 1 is as good a number as any other 95 | self.basisFrame.Transparency = 1 96 | self.basisFrame.Parent = self.backFrame 97 | 98 | self.basisScale = Instance.new("UIScale") 99 | self.basisScale.Parent = self.basisFrame 100 | 101 | self._activeElements = setmetatable({}, {__mode = "k"}) 102 | self:registerFrame(self.backFrame, self) 103 | 104 | self._connections = {} 105 | self._hoveringFrames = {} 106 | 107 | self._visible = false 108 | 109 | self._connections[1] = game:GetService("RunService").RenderStepped:Connect(function() 110 | self._visible = self:_isVisible() 111 | if not self._visible then return end 112 | 113 | local mousePosition = getMousePosition() 114 | self._hoveringFrames = playerGui:getGuiObjectsAtPosition(mousePosition.x, mousePosition.y) 115 | 116 | -- highlight effect 117 | self.highlightedValue = nil 118 | for i, frame in next, self._hoveringFrames do 119 | local object = self._activeElements[frame] 120 | if object and object.getValue then 121 | local value = object:getValue() 122 | if type(value) == "table" then 123 | self.highlightedValue = value 124 | break 125 | end 126 | end 127 | end 128 | 129 | for tableRoot in next, self._tableRoots do 130 | tableRoot:update() 131 | end 132 | end) 133 | 134 | self._connections[2] = UserInputService.InputBegan:Connect(function(inputObject, inputProcessed) 135 | if not self._visible then return end 136 | if inputProcessed then return end 137 | if UserInputService.MouseBehavior == Enum.MouseBehavior.LockCenter then 138 | return 139 | end 140 | 141 | for i, frame in next, self._hoveringFrames do 142 | local object = self._activeElements[frame] 143 | if object and object.inputBegan then 144 | local stopInput = object:inputBegan(inputObject, frame) 145 | if stopInput then return end 146 | end 147 | end 148 | end) 149 | 150 | self._connections[3] = UserInputService.InputChanged:Connect(function(inputObject, inputProcessed) 151 | if not self._visible then return end 152 | if inputProcessed then return end 153 | if UserInputService.MouseBehavior == Enum.MouseBehavior.LockCenter then 154 | return 155 | end 156 | 157 | for i, frame in next, self._hoveringFrames do 158 | local object = self._activeElements[frame] 159 | if object and object.inputChanged then 160 | local stopInput = object:inputChanged(inputObject, frame) 161 | if stopInput then return end 162 | end 163 | end 164 | end) 165 | 166 | self._connections[4] = UserInputService.InputEnded:Connect(function(inputObject, inputProcessed) 167 | --if not self._visible then return end 168 | --if inputProcessed then return end 169 | 170 | for i, frame in next, self._hoveringFrames do 171 | local object = self._activeElements[frame] 172 | if object and object.inputEnded then 173 | local stopInput = object:inputEnded(inputObject, frame) 174 | if stopInput then return end 175 | end 176 | end 177 | end) 178 | 179 | 180 | self.highlightedValue = nil 181 | 182 | return self 183 | end 184 | 185 | function TableInspector:destroy() 186 | for i, connection in next, self._connections do 187 | connection:Disconnect() 188 | end 189 | for tableRoot in next, self._tableRoots do 190 | tableRoot:destroy() 191 | end 192 | 193 | self:unregisterFrame(self.backFrame) 194 | end 195 | 196 | function TableInspector:addPath(name, path, optionalElement) 197 | local tableRoot = TableRoot.new(self, `...{name}`, path, optionalElement) 198 | tableRoot.backFrame.Parent = self.basisFrame 199 | self._tableRoots[tableRoot] = true 200 | return tableRoot 201 | end 202 | 203 | function TableInspector:addTable(name, tab, optionalElement) 204 | local tableRoot = TableRoot.new(self, name, {tab}, optionalElement) 205 | tableRoot.backFrame.Parent = self.basisFrame 206 | self._tableRoots[tableRoot] = true 207 | return tableRoot 208 | end 209 | 210 | function TableInspector:removeTable(tab) 211 | for tableRoot in next, self._tableRoots do 212 | if tableRoot._path[1] == tab then 213 | tableRoot:destroy() 214 | return 215 | end 216 | end 217 | end 218 | 219 | function TableInspector:registerFrame(frame, object) 220 | self._activeElements[frame] = object 221 | end 222 | 223 | function TableInspector:unregisterFrame(frame) 224 | self._activeElements[frame] = nil 225 | end 226 | 227 | function TableInspector:inputChanged(inputObject) 228 | if inputObject.UserInputType == Enum.UserInputType.MouseWheel then 229 | local mousePosition = getMousePosition() 230 | local basisPosition = self.basisFrame.AbsolutePosition 231 | local newLogScale = math.clamp(self._logScale + inputObject.Position.z, -4, 4) 232 | local logScaleDelta = newLogScale - self._logScale 233 | self._logScale = newLogScale 234 | 235 | local scaleFactor = 2^(logScaleDelta/2) 236 | basisPosition = scaleFactor*(basisPosition - mousePosition) + mousePosition 237 | 238 | -- doesn't work if rotated. So don't rotate it 239 | local basisRelative = (basisPosition - self.backFrame.AbsolutePosition)/self.backFrame.AbsoluteSize 240 | 241 | self.basisScale.Scale = 2^(self._logScale/2) 242 | --self.basisFrame.Position = UDim2.fromScale(basisRelative.x, basisRelative.y) 243 | self:setBasisPosition(basisPosition) 244 | 245 | return true 246 | end 247 | end 248 | 249 | function TableInspector:inputBegan(inputObject) 250 | if backgroundDragEnabled and inputObject.UserInputType == Enum.UserInputType.MouseButton1 then 251 | self:drag(Enum.UserInputType.MouseButton1) 252 | return true 253 | end 254 | end 255 | 256 | function TableInspector:getAbsoluteBounds() 257 | local boundMin = Vector2.new( 1/0, 1/0) 258 | local boundMax = Vector2.new(-1/0, -1/0) 259 | for tableRoot in next, self._tableRoots do 260 | local frameMin = tableRoot.dragFrame.AbsolutePosition 261 | local frameMax = frameMin + tableRoot.dragFrame.AbsoluteSize 262 | boundMin = boundMin:Min(frameMin) 263 | boundMax = boundMax:Max(frameMax) 264 | end 265 | 266 | return boundMin, boundMax 267 | end 268 | 269 | function TableInspector:setBasisPosition(newPosition) 270 | local parentPosition = self.basisFrame.Parent.AbsolutePosition 271 | local parentSize = self.basisFrame.Parent.AbsoluteSize 272 | local oldPosition = self.basisFrame.AbsolutePosition 273 | local boundMin, boundMax = self:getAbsoluteBounds() 274 | local localMin = boundMin - oldPosition 275 | local localMax = boundMax - oldPosition 276 | 277 | local newMin = newPosition + localMin 278 | local newMax = newPosition + localMax 279 | 280 | local portMin = parentPosition 281 | local portMax = parentPosition + parentSize 282 | if newMax.X < portMin.X then 283 | newPosition += Vector2.new(portMin.X - newMax.X, 0) 284 | elseif newMin.X > portMax.X then 285 | newPosition += Vector2.new(portMax.X - newMin.X, 0) 286 | end 287 | 288 | if newMax.Y < portMin.Y then 289 | newPosition += Vector2.new(0, portMin.Y - newMax.Y) 290 | elseif newMin.Y > portMax.Y then 291 | newPosition += Vector2.new(0, portMax.Y - newMin.Y) 292 | end 293 | 294 | local newScalePosition = (newPosition - parentPosition)/parentSize 295 | 296 | 297 | local newScalePosition = (newPosition - parentPosition)/parentSize 298 | self.basisFrame.Position = UDim2.fromScale(newScalePosition.x, newScalePosition.y) 299 | end 300 | 301 | -- drag routine 302 | function TableInspector:drag(exitInputType) 303 | local mousePosition = getMousePosition() 304 | local mouseOffsetScale = (mousePosition - self.basisFrame.AbsolutePosition)/self.basisFrame.AbsoluteSize 305 | if self._dragging then 306 | self._dragging = false 307 | self._dragConnection1:Disconnect() 308 | self._dragConnection2:Disconnect() 309 | end 310 | 311 | self._dragging = true 312 | 313 | self._dragConnection1 = game:GetService("RunService").RenderStepped:Connect(function() 314 | local newPosition = getMousePosition() - mouseOffsetScale*self.basisFrame.AbsoluteSize 315 | self:setBasisPosition(newPosition) 316 | end) 317 | 318 | self._dragConnection2 = UserInputService.InputEnded:Connect(function(inputObject) 319 | if inputObject.UserInputType ~= exitInputType then return end 320 | 321 | local newPosition = getMousePosition() - mouseOffsetScale*self.basisFrame.AbsoluteSize 322 | self:setBasisPosition(newPosition) 323 | 324 | self._dragging = false 325 | self._dragConnection1:Disconnect() 326 | self._dragConnection2:Disconnect() 327 | end) 328 | end 329 | 330 | local function isVisible(object) 331 | if not object then 332 | return false 333 | elseif object:IsA("GuiObject") then 334 | if not object.Visible then 335 | return false 336 | end 337 | elseif object:IsA("ScreenGui") then 338 | if not object.Enabled then 339 | return false 340 | end 341 | elseif object:IsA("LayerCollector") then 342 | return object.Enabled 343 | elseif object:IsA("BasePlayerGui") then 344 | return true 345 | end 346 | return isVisible(object.Parent) 347 | end 348 | 349 | function TableInspector:_isVisible() 350 | return isVisible(self.backFrame) 351 | end 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | function TableRoot.new(tableInspector, name, path, element) 377 | local self = setmetatable({}, TableRoot) 378 | 379 | self._tableInspector = tableInspector 380 | self._name = name 381 | 382 | local dataSize = TextService:GetTextSize(name, fontSize, font, Vector2.zero) 383 | -- just a basis 384 | self.backFrame = Instance.new("Frame") 385 | self.backFrame.Size = UDim2.fromOffset(100, 100) 386 | self.backFrame.Transparency = 1 387 | 388 | self.dragFrame = Instance.new("Frame") 389 | self.dragFrame.Transparency = 1 390 | --self.dragFrame.ZIndex = -1 391 | self.dragFrame.Parent = self.backFrame 392 | 393 | self.nameFrame = Instance.new("TextLabel") 394 | self.nameFrame.AnchorPoint = Vector2.new(0, 1) 395 | self.nameFrame.Size = UDim2.fromOffset(dataSize.x + 4, dataSize.y + 4) 396 | self.nameFrame.Position = UDim2.fromOffset(0, -1) 397 | self.nameFrame.TextSize = fontSize 398 | self.nameFrame.BorderSizePixel = 0 399 | self.nameFrame.TextColor3 = operatorColor3 400 | self.nameFrame.BackgroundColor3 = backgroundColor3 401 | self.nameFrame.Font = Enum.Font.Code 402 | self.nameFrame.Text = name 403 | self.nameFrame.Parent = self.backFrame 404 | 405 | self._path = path 406 | 407 | if element then 408 | local pos = element.backFrame.AbsolutePosition 409 | local basisFrame = tableInspector.basisFrame 410 | local relScale = (pos - basisFrame.AbsolutePosition)/basisFrame.AbsoluteSize 411 | self.backFrame.Position = UDim2.fromScale(relScale.x, relScale.y) 412 | end 413 | 414 | self._rootElement = element or Element.new(self._tableInspector, self, nil) 415 | self._rootElement.parent = self 416 | self._rootElement.backFrame.Position = UDim2.fromOffset(0, 0) 417 | self._rootElement.backFrame.Parent = self.backFrame 418 | 419 | self._tableInspector:registerFrame(self.dragFrame, self) 420 | self._tableInspector:registerFrame(self.nameFrame, self) 421 | 422 | self._dragging = false 423 | self._dragConnection1 = nil 424 | self._dragConnection2 = nil 425 | 426 | return self 427 | end 428 | 429 | function TableRoot:destroy() 430 | self._rootElement:destroy() 431 | self.backFrame:Destroy() 432 | self._path = nil 433 | self._tableInspector._tableRoots[self] = nil -- BAD 434 | self._tableInspector:unregisterFrame(self.dragFrame) 435 | self._tableInspector:unregisterFrame(self.nameFrame) 436 | end 437 | 438 | function TableRoot:getIndex() 439 | return self._name 440 | end 441 | 442 | function TableRoot:getPath() 443 | -- this is ridiculous lol 444 | local n = #self._path 445 | local copy = table.create(n) 446 | table.move(self._path, 1, n, 1, copy) 447 | return copy 448 | end 449 | 450 | function TableRoot:getValue() 451 | local path = self._path 452 | local cur = path[1] 453 | for i = 2, #path do 454 | local index = path[i] 455 | if type(cur) ~= "table" then 456 | return nil 457 | end 458 | cur = cur[index] 459 | end 460 | 461 | return cur 462 | end 463 | 464 | function TableRoot:update() 465 | self._rootElement:setValue(self:getValue()) 466 | local size = self._rootElement:update() 467 | self.dragFrame.Size = UDim2.fromOffset(size.x, size.y) 468 | end 469 | 470 | function TableRoot:inputBegan(inputObject) 471 | if inputObject.UserInputType == Enum.UserInputType.MouseButton1 then 472 | self:drag(Enum.UserInputType.MouseButton1) 473 | return true 474 | elseif inputObject.UserInputType == Enum.UserInputType.MouseButton3 then 475 | self:destroy() 476 | return true 477 | end 478 | end 479 | 480 | -- drag routine 481 | function TableRoot:drag(exitInputType) 482 | local mousePosition = getMousePosition() 483 | local mouseOffsetScale = (mousePosition - self.backFrame.AbsolutePosition)/self.backFrame.AbsoluteSize 484 | if self._dragging then 485 | self._dragging = false 486 | self._dragConnection1:Disconnect() 487 | self._dragConnection2:Disconnect() 488 | end 489 | 490 | self._dragging = true 491 | 492 | self._dragConnection1 = game:GetService("RunService").RenderStepped:Connect(function() 493 | local newPosition = getMousePosition() - mouseOffsetScale*self.backFrame.AbsoluteSize 494 | local newScalePosition = (newPosition - self.backFrame.Parent.AbsolutePosition)/self.backFrame.Parent.AbsoluteSize 495 | self.backFrame.Position = UDim2.fromScale(newScalePosition.x, newScalePosition.y) 496 | end) 497 | 498 | self._dragConnection2 = UserInputService.InputEnded:Connect(function(inputObject) 499 | if inputObject.UserInputType ~= exitInputType then return end 500 | 501 | local newPosition = getMousePosition() - mouseOffsetScale*self.backFrame.AbsoluteSize 502 | local newScalePosition = (newPosition - self.backFrame.Parent.AbsolutePosition)/self.backFrame.Parent.AbsoluteSize 503 | self.backFrame.Position = UDim2.fromScale(newScalePosition.x, newScalePosition.y) 504 | 505 | self._dragging = false 506 | self._dragConnection1:Disconnect() 507 | self._dragConnection2:Disconnect() 508 | end) 509 | end 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | function Element.new(tableInspector, parent, value) 532 | local self = setmetatable({}, Element) 533 | self._tableInspector = tableInspector 534 | 535 | self._value = value 536 | self._isExpanded = false 537 | self._scrollingEnabled = false 538 | 539 | self._upToDate = false 540 | 541 | self.parent = parent 542 | self.backFrame = Instance.new("Frame") 543 | self.backFrame.BackgroundColor3 = backgroundColor3 544 | self.backFrame.BorderColor3 = operatorColor3 545 | self.backFrame.BorderSizePixel = 0 546 | self.backFrame.BorderMode = Enum.BorderMode.Inset 547 | 548 | self.clipFrame = Instance.new("Frame") 549 | self.clipFrame.BackgroundTransparency = 1 550 | self.clipFrame.ClipsDescendants = true 551 | self.clipFrame.Position = UDim2.fromScale(1/2, 1/2) 552 | self.clipFrame.AnchorPoint = Vector2.new(1/2, 1/2) 553 | self.clipFrame.Parent = self.backFrame 554 | 555 | self.dataFrame = Instance.new("TextLabel") -- base object 556 | self.dataFrame.BackgroundColor3 = backgroundColor3 557 | self.dataFrame.TextXAlignment = Enum.TextXAlignment.Left 558 | self.dataFrame.TextYAlignment = Enum.TextYAlignment.Top 559 | self.dataFrame.BorderSizePixel = 0 560 | -- self.dataFrame.TextWrapped = true 561 | self.dataFrame.TextSize = fontSize 562 | self.dataFrame.Font = font 563 | self.dataFrame.Text = "" 564 | self.dataFrame.Parent = self.clipFrame 565 | 566 | self._size = Vector2.zero 567 | self._entries = {} 568 | 569 | self._tableInspector:registerFrame(self.backFrame, self) 570 | self._lastExpansionAttempt = -1/0 571 | self._lastExpansionAttemptPosition = Vector2.zero 572 | 573 | return self 574 | end 575 | 576 | function Element:destroy() 577 | self.backFrame:Destroy() 578 | self._tableInspector:unregisterFrame(self.backFrame) 579 | for index, entry in next, self._entries do 580 | entry:destroy() 581 | end 582 | table.clear(self._entries) 583 | end 584 | 585 | function Element:inputEnded(inputObject) 586 | if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then return end 587 | 588 | local controlPressed = UserInputService:IsKeyDown(Enum.KeyCode.LeftControl) or UserInputService:IsKeyDown(Enum.KeyCode.RightControl) 589 | local shiftPressed = UserInputService:IsKeyDown(Enum.KeyCode.LeftShift) or UserInputService:IsKeyDown(Enum.KeyCode.RightShift) 590 | 591 | if controlPressed and shiftPressed then 592 | elseif controlPressed then -- pulls out the literal table 593 | elseif shiftPressed then -- pulls out the pathway 594 | else 595 | local t = os.clock() 596 | self._lastExpansionAttempt = t 597 | self._lastExpansionAttemptPosition = getMousePosition() 598 | end 599 | end 600 | 601 | function Element:getPath() 602 | if not self.parent then 603 | return {self._value} 604 | end 605 | 606 | return self.parent:getPath(self) 607 | end 608 | 609 | function Element:inputBegan(inputObject) 610 | local inputType = inputObject.UserInputType 611 | local isLMB = inputType == Enum.UserInputType.MouseButton1 612 | local isRMB = inputType == Enum.UserInputType.MouseButton2 613 | if not (isLMB or isRMB) then return end 614 | 615 | local controlPressed = UserInputService:IsKeyDown(Enum.KeyCode.LeftControl) or UserInputService:IsKeyDown(Enum.KeyCode.RightControl) 616 | local shiftPressed = UserInputService:IsKeyDown(Enum.KeyCode.LeftShift) or UserInputService:IsKeyDown(Enum.KeyCode.RightShift) 617 | 618 | if isLMB and controlPressed and shiftPressed then 619 | --if typeof(self._value) ~= "Instance" then return end 620 | --game.Selection:set({self._value}) 621 | elseif isLMB and controlPressed then -- pulls out the literal table 622 | if type(self._value) ~= "table" then return end 623 | if not self.parent or not self.parent.pullOutElement then return end 624 | -- pull this off and make a new root 625 | local name = tostring(self.parent:getIndex()) 626 | 627 | self.parent:pullOutElement(self) -- just replaces itself in the parent Entry with a blank thing 628 | self.parent = nil 629 | 630 | local tableRoot = self._tableInspector:addTable(name, self._value, self) 631 | tableRoot:drag(Enum.UserInputType.MouseButton1) 632 | return true 633 | elseif isLMB and shiftPressed then -- pulls out the pathway 634 | -- if type(self._value) ~= "table" then return end 635 | if not self.parent or not self.parent.pullOutElement then return end 636 | if self.parent:getIndexElement() == self then return end 637 | -- pull this off and make a new root 638 | local name = tostring(self.parent:getIndex()) 639 | local path = self:getPath() 640 | 641 | self.parent:pullOutElement(self) -- just replaces itself in the parent Entry with a blank thing 642 | self.parent = nil 643 | 644 | local tableRoot = self._tableInspector:addPath(name, path, self) 645 | tableRoot:drag(Enum.UserInputType.MouseButton1) 646 | return true 647 | elseif isLMB then 648 | local t = os.clock() 649 | if t - self._lastExpansionAttempt < doubleClickInterval and 650 | (self._lastExpansionAttemptPosition - getMousePosition()).magnitude < 4 then 651 | self:toggleExpansion() 652 | return true 653 | end 654 | elseif isRMB then 655 | self:toggleExpansion() 656 | return true 657 | end 658 | end 659 | 660 | function Element:inputChanged(inputObject) 661 | if inputObject.UserInputType == Enum.UserInputType.MouseWheel then 662 | if not self._scrollingEnabled then return end 663 | local delta = inputObject.Position.z 664 | local lateralScroll = self._lateralScrollEnabled or UserInputService:IsKeyDown("LeftShift") or UserInputService:IsKeyDown("RightShift") 665 | local dataPosX = self.dataFrame.Position.X.Offset 666 | local dataPosY = self.dataFrame.Position.Y.Offset 667 | local dataSizeX = self.dataFrame.Size.X.Offset 668 | local dataSizeY = self.dataFrame.Size.Y.Offset 669 | local clipSizeX = self.clipFrame.Size.X.Offset 670 | local clipSizeY = self.clipFrame.Size.Y.Offset 671 | local minPosX = clipSizeX - dataSizeX 672 | local minPosY = clipSizeY - dataSizeY 673 | if lateralScroll then 674 | local nextPosX = math.clamp(dataPosX + fontSize*delta, minPosX, 0) 675 | local nextPosY = math.clamp(dataPosY, minPosY, 0) 676 | self.dataFrame.Position = UDim2.fromOffset(nextPosX, nextPosY) 677 | else 678 | local nextPosX = math.clamp(dataPosX, minPosX, 0) 679 | local nextPosY = math.clamp(dataPosY + fontSize*delta, minPosY, 0) 680 | self.dataFrame.Position = UDim2.fromOffset(nextPosX, nextPosY) 681 | end 682 | return true 683 | end 684 | end 685 | 686 | function Element:setValue(value) 687 | if self._value ~= value then 688 | self._upToDate = false 689 | self._value = value 690 | end 691 | end 692 | 693 | function Element:collapse() 694 | if self._isExpanded then 695 | self._upToDate = false 696 | self._isExpanded = false 697 | for i, entry in next, self._entries do 698 | entry.backFrame.Visible = false 699 | end 700 | end 701 | end 702 | 703 | function Element:expand() 704 | if not self._isExpanded then 705 | self._upToDate = false 706 | self._isExpanded = true 707 | for i, entry in next, self._entries do 708 | -- this will look weird 709 | entry.backFrame.Visible = true 710 | end 711 | end 712 | end 713 | 714 | function Element:toggleExpansion() 715 | if self._isExpanded then 716 | self:collapse() 717 | else 718 | self:expand() 719 | end 720 | end 721 | 722 | function Element:getValue() 723 | return self._value 724 | end 725 | 726 | function Element:getSize() 727 | return self._size 728 | end 729 | 730 | function Element:update() 731 | if self._value == self._tableInspector.highlightedValue then 732 | self.backFrame.BorderColor3 = highlightColor3--Color3.fromRGB(0, 0, 0):Lerp(highlightColor3, math.sin(2*math.pi*os.clock())^2) 733 | else 734 | self.backFrame.BorderColor3 = operatorColor3 735 | end 736 | 737 | local vType = typeof(self._value) 738 | if vType == "table" then 739 | self:setToTableForm() 740 | self:renderTable() 741 | self._upToDate = true 742 | return self._size 743 | end 744 | 745 | if self._upToDate then 746 | return self._size 747 | end 748 | 749 | if vType == "boolean" then 750 | self:setToValueForm() 751 | self:renderBoolean() 752 | elseif vType == "number" then 753 | self:setToValueForm() 754 | self:renderNumber() 755 | elseif vType == "string" then 756 | self:setToValueForm() 757 | self:renderString() 758 | elseif vType == "function" then 759 | self:setToValueForm() 760 | self:renderFunction() 761 | else 762 | self:setToValueForm() 763 | self:renderAnything() 764 | end 765 | 766 | -- the previous code should have updated self.Size 767 | self._upToDate = true 768 | return self._size 769 | end 770 | 771 | function Element:setToValueForm() 772 | if self._form == "value" then return end 773 | self._form = "value" 774 | 775 | self.backFrame.BorderSizePixel = 0 776 | for i, entry in next, self._entries do 777 | entry:destroy() 778 | end 779 | table.clear(self._entries) 780 | end 781 | 782 | function Element:setToTableForm() 783 | if self._form == "table" then return end 784 | self._form = "table" 785 | 786 | self.backFrame.BorderSizePixel = tableBorder 787 | end 788 | 789 | -- lots of caching and in-place movement made this a lot more work than I wanted it to be 790 | function Element:remapEntries() 791 | local tab = self._value 792 | local entries = self._entries 793 | 794 | -- get orphaned indices 795 | local orphanedEntries = {} 796 | local orphanedValueElements = {} 797 | 798 | for index, entry in next, entries do 799 | if tab[index] == nil then 800 | table.insert(orphanedEntries, entry) 801 | entries[index] = nil 802 | end 803 | end 804 | 805 | -- reassign indices for maximum reuse 806 | for index, value in next, tab do 807 | if not entries[index] then 808 | local entry = table.remove(orphanedEntries) 809 | if entry then 810 | entries[entry:getIndex()] = nil 811 | entries[index] = entry 812 | entry:setIndex(index) 813 | entry:collapseIndex() 814 | else 815 | entry = Entry.new(self._tableInspector, self, index) 816 | entry.backFrame.Parent = self.dataFrame 817 | if not self._isExpanded then 818 | entry.backFrame.Visible = false 819 | end 820 | entries[index] = entry 821 | end 822 | end 823 | end 824 | 825 | for i, entry in next, orphanedEntries do 826 | table.insert(orphanedValueElements, entry:getValueElement()) 827 | end 828 | 829 | local valueToEntry = {} 830 | for index, newValue in next, tab do 831 | local entry = entries[index] 832 | local oldValue = entry:getValue() 833 | if newValue == oldValue then continue end 834 | if type(oldValue) == "table" then 835 | table.insert(orphanedValueElements, entry:getValueElement()) 836 | end 837 | if type(newValue) == "table" then 838 | valueToEntry[newValue] = entry 839 | end 840 | end 841 | 842 | for i, valueElement in next, orphanedValueElements do 843 | local oldValue = valueElement:getValue() 844 | local entry = valueToEntry[oldValue] 845 | if entry then 846 | entry:swapValueElement(valueElement.parent) 847 | end 848 | end 849 | 850 | -- update all the valueElements to have the new value 851 | for index, newValue in next, tab do 852 | local entry = entries[index] 853 | entry:setValue(newValue) 854 | end 855 | 856 | for i, entry in next, orphanedEntries do 857 | entry:destroy() 858 | end 859 | end 860 | 861 | function Element:renderBoolean() 862 | self:setText(self._value and "true" or "false") 863 | self.dataFrame.TextColor3 = boolColor3 864 | end 865 | 866 | function Element:renderNumber() 867 | self:setText(tostring(self._value)) 868 | self.dataFrame.TextColor3 = numberColor3 869 | end 870 | 871 | function Element:renderString() 872 | self:setText(self._value) 873 | self.dataFrame.TextColor3 = stringColor3 874 | end 875 | 876 | local function writeArgs(count, varargs) 877 | if count == 0 then 878 | if varargs then 879 | return "..." 880 | else 881 | return "" 882 | end 883 | end 884 | 885 | local str = string.rep("_, ", count - 1) 886 | 887 | if varargs then 888 | return str .. "_, ..." 889 | else 890 | return str .. "_" 891 | end 892 | end 893 | 894 | function Element:renderFunction() 895 | if self._isExpanded then 896 | local source, name, line, count, varargs = debug.info(self._value, "snla") 897 | source = string.match(source, "[^.]*$") 898 | local args = writeArgs(count, varargs) 899 | if name == "" then 900 | self:setText(`{source}-{line}({args})`) 901 | else 902 | self:setText(`{source}.{name}({args})`) 903 | end 904 | else 905 | local name, count, varargs = debug.info(self._value, "na") 906 | local args = writeArgs(count, varargs) 907 | if name == "" then 908 | self:setText(`f({args})`) 909 | else 910 | self:setText(`{name}({args})`) 911 | end 912 | end 913 | self.dataFrame.TextColor3 = functionColor3 914 | end 915 | 916 | function Element:renderAnything() 917 | self:setText(tostring(self._value)) 918 | self.dataFrame.TextColor3 = operatorColor3 919 | end 920 | 921 | local typeOrder = { 922 | ["boolean"] = 1; 923 | ["string"] = 2; 924 | ["table"] = 3; 925 | ["function"] = 4; 926 | ["number"] = 5; 927 | } 928 | 929 | local function compare(a, b) 930 | if a == b then 931 | return 0 932 | end 933 | 934 | local typeA = type(a) 935 | local typeB = type(b) 936 | 937 | local compA 938 | local compB 939 | 940 | if typeA ~= typeB then 941 | compA, compB = typeOrder[typeA], typeOrder[typeB] 942 | elseif typeA == "string" then 943 | compA, compB = a, b 944 | elseif typeA == "number" then 945 | compA, compB = a, b 946 | elseif typeA == "boolean" then 947 | compA, compB = a and 1 or 0, b and 1 or 0 948 | else 949 | compA, compB = tostring(a), tostring(b) 950 | end 951 | 952 | -- just in case 953 | return compA == compB and 0 or compA < compB and -1 or 1 954 | end 955 | 956 | local function indexLT(a, b) 957 | return compare(a, b) < 0 958 | end 959 | 960 | local function getSortedIndices(tab) 961 | local indexList = {} 962 | for i in next, tab do 963 | table.insert(indexList, i) 964 | end 965 | table.sort(indexList, indexLT) 966 | return indexList 967 | end 968 | 969 | function Element:renderTable() 970 | if not self._isExpanded then 971 | local tab = self._value 972 | local k = 0 973 | local i = 0 974 | 975 | local prevIndex = nil 976 | local i = 1 977 | 978 | for index, value in next, tab do 979 | if index ~= i then break end 980 | prevIndex = i 981 | i += 1 982 | end 983 | 984 | i -= 1 985 | 986 | for index, value in next, tab, prevIndex do 987 | k += 1 988 | end 989 | 990 | 991 | local text = `{k}k {i}i` 992 | if self.dataFrame.Text == text then return end -- source of bugs 993 | 994 | local dataSize = TextService:GetTextSize(text, fontSize, font, Vector2.zero) 995 | local clipSize = Vector2.new( 996 | math.min(dataSize.x, maxClosedSize.x), 997 | math.min(dataSize.y, maxClosedSize.y)) 998 | local backSize = clipSize + 2*textPad -- so that it aligns in size with a normal text value 999 | 1000 | self._scrollingEnabled = false 1001 | 1002 | self.dataFrame.Text = text 1003 | self.dataFrame.TextColor3 = operatorColor3 1004 | self.dataFrame.Size = UDim2.fromOffset(dataSize.x, dataSize.y) 1005 | self.clipFrame.Size = UDim2.fromOffset(clipSize.x, clipSize.y) 1006 | self.backFrame.Size = UDim2.fromOffset(backSize.x, backSize.y) 1007 | --self.clipFrame.Position = UDim2.fromOffset(textPad.x, textPad.y) 1008 | 1009 | self._size = backSize 1010 | return 1011 | end 1012 | 1013 | self:remapEntries() 1014 | local entries = self._entries 1015 | 1016 | -- if it is expanded, but there's nothing inside 1017 | if next(entries) == nil then 1018 | local dataSize = Vector2.new(fontSize, fontSize) + 2*textPad + 2*entryPad 1019 | local clipSize = dataSize 1020 | local backSize = clipSize + 2*tableBorder*Vector2.one 1021 | self._scrollingEnabled = false 1022 | self._size = backSize 1023 | 1024 | --if self.dataFrame.Text == "" then return end -- source of bugs 1025 | 1026 | self.dataFrame.Text = "" 1027 | self.dataFrame.Size = UDim2.fromOffset(dataSize.x, dataSize.y) 1028 | self.clipFrame.Size = UDim2.fromOffset(clipSize.x, clipSize.y) 1029 | self.backFrame.Size = UDim2.fromOffset(backSize.x, backSize.y) 1030 | return 1031 | end 1032 | 1033 | local sortedIndices = getSortedIndices(entries) 1034 | 1035 | local dataSizeX = 0 1036 | local dataSizeY = 0 1037 | 1038 | for i, index in next, sortedIndices do 1039 | local entry = entries[index] 1040 | local entrySize = entry:update() 1041 | entry.backFrame.Position = UDim2.fromOffset(0, dataSizeY) 1042 | dataSizeX = math.max(dataSizeX, entrySize.x) 1043 | dataSizeY = dataSizeY + entrySize.y + linePad 1044 | end 1045 | 1046 | for index, entry in next, entries do 1047 | local entrySize = entry:getSize() 1048 | entry.backFrame.Size = UDim2.fromOffset(dataSizeX, entrySize.y) 1049 | end 1050 | 1051 | dataSizeY -= linePad 1052 | 1053 | local dataSize = Vector2.new(dataSizeX, dataSizeY) 1054 | local clipSize = Vector2.new( 1055 | math.min(dataSize.x, maxOpenedTableSize.x), 1056 | math.min(dataSize.y, maxOpenedTableSize.y)) 1057 | local backSize = clipSize + 2*tableBorder*Vector2.one 1058 | 1059 | self._scrollingEnabled = dataSize ~= clipSize 1060 | self._lateralScrollEnabled = dataSize.y == clipSize.y 1061 | 1062 | 1063 | self.dataFrame.Text = "" 1064 | self.dataFrame.Size = UDim2.fromOffset(dataSize.x, dataSize.y) 1065 | self.clipFrame.Size = UDim2.fromOffset(clipSize.x, clipSize.y) 1066 | self.backFrame.Size = UDim2.fromOffset(backSize.x, backSize.y) 1067 | 1068 | self._size = backSize 1069 | end 1070 | 1071 | function Element:setText(text) 1072 | -- this will be a source of bugs. 1073 | --if self.dataFrame.Text == text then return end 1074 | 1075 | local dataSize, clipSize, backSize 1076 | 1077 | if self._isExpanded then 1078 | dataSize = TextService:GetTextSize(text, fontSize, font, Vector2.zero) 1079 | clipSize = Vector2.new( 1080 | math.min(dataSize.x, maxOpenedSize.x), 1081 | math.min(dataSize.y, maxOpenedSize.y)) 1082 | 1083 | self._scrollingEnabled = dataSize ~= clipSize 1084 | self._lateralScrollEnabled = dataSize.y == clipSize.y 1085 | else 1086 | dataSize = TextService:GetTextSize(text, fontSize, font, Vector2.zero) 1087 | clipSize = Vector2.new( 1088 | math.min(dataSize.x, maxClosedSize.x), 1089 | math.min(dataSize.y, maxClosedSize.y)) 1090 | 1091 | self._scrollingEnabled = false 1092 | end 1093 | 1094 | backSize = clipSize + 2*textPad 1095 | 1096 | self.dataFrame.Text = text 1097 | self.dataFrame.Size = UDim2.fromOffset(dataSize.x, dataSize.y) 1098 | self.clipFrame.Size = UDim2.fromOffset(clipSize.x, clipSize.y) 1099 | self.backFrame.Size = UDim2.fromOffset(backSize.x, backSize.y) 1100 | --self.clipFrame.Position = UDim2.fromOffset(textPad.x, textPad.y) 1101 | 1102 | self._size = backSize 1103 | end 1104 | 1105 | 1106 | 1107 | 1108 | 1109 | 1110 | 1111 | 1112 | 1113 | 1114 | 1115 | 1116 | 1117 | 1118 | -- an entry is tied to an element parent, for now. 1119 | function Entry.new(tableInspector, parent, index) 1120 | local self = setmetatable({}, Entry) 1121 | self._tableInspector = tableInspector 1122 | self.parent = parent 1123 | 1124 | 1125 | self.backFrame = Instance.new("Frame") 1126 | self.backFrame.BorderSizePixel = 0 1127 | self.backFrame.BackgroundColor3 = Color3.fromRGB(96, 96, 96) 1128 | 1129 | self.separatorFrame = Instance.new("TextLabel") 1130 | self.separatorFrame.BackgroundTransparency = 1 1131 | self.separatorFrame.TextColor3 = operatorColor3 1132 | self.separatorFrame.Text = "=" 1133 | self.separatorFrame.Font = font 1134 | self.separatorFrame.TextSize = fontSize 1135 | --self.separatorFrame.BorderSizePixel = 0 1136 | --self.separatorFrame.BackgroundColor3 = operatorColor3 1137 | self.separatorFrame.Size = UDim2.fromOffset(separatorSize.x, separatorSize.y) 1138 | self.separatorFrame.Parent = self.backFrame 1139 | 1140 | --local corner = Instance.new("UICorner") 1141 | --corner.CornerRadius = UDim.new(1, 0) 1142 | --corner.Parent = self.separatorFrame 1143 | 1144 | self._indexElement = Element.new(self._tableInspector, self, index) 1145 | self._valueElement = Element.new(self._tableInspector, self, nil) 1146 | 1147 | self._indexElement.backFrame.Parent = self.backFrame 1148 | self._valueElement.backFrame.Parent = self.backFrame 1149 | --self.backFrame.Parent = parent.backFrame 1150 | 1151 | self._size = Vector2.zero 1152 | 1153 | return self 1154 | end 1155 | 1156 | function Entry:destroy() 1157 | self._indexElement:destroy() 1158 | self._valueElement:destroy() 1159 | self.backFrame:Destroy() 1160 | end 1161 | 1162 | function Entry:getPath(element) 1163 | if self._indexElement == element then 1164 | return {element:getValue()} 1165 | elseif self._valueElement == element then 1166 | local path = self.parent:getPath() 1167 | table.insert(path, self._indexElement:getValue()) 1168 | return path 1169 | else 1170 | error("Element not found in Entry") 1171 | end 1172 | end 1173 | 1174 | function Entry:setIndex(index) 1175 | self._indexElement:setValue(index) 1176 | end 1177 | 1178 | function Entry:setValue(value) 1179 | self._valueElement:setValue(value) 1180 | end 1181 | 1182 | function Entry:getIndex(index) 1183 | return self._indexElement:getValue() 1184 | end 1185 | 1186 | function Entry:getValue(value) 1187 | return self._valueElement:getValue() 1188 | end 1189 | 1190 | function Entry:getValueElement() 1191 | return self._valueElement 1192 | end 1193 | 1194 | function Entry:getIndexElement() 1195 | return self._indexElement 1196 | end 1197 | 1198 | function Entry:collapseIndex() 1199 | self._indexElement:collapse() 1200 | end 1201 | 1202 | function Entry:swapValueElement(entry) 1203 | self._valueElement, entry._valueElement = entry._valueElement, self._valueElement 1204 | self._valueElement.parent = self 1205 | entry._valueElement.parent = entry 1206 | self._valueElement.backFrame.Parent = self.backFrame 1207 | entry._valueElement.backFrame.Parent = entry.backFrame 1208 | end 1209 | 1210 | function Entry:pullOutElement(element) 1211 | if self._indexElement == element then 1212 | self._indexElement = Element.new(self._tableInspector, self, element:getValue()) 1213 | self._indexElement.backFrame.Parent = self.backFrame 1214 | elseif self._valueElement == element then 1215 | self._valueElement = Element.new(self._tableInspector, self, nil) 1216 | self._valueElement.backFrame.Parent = self.backFrame 1217 | else 1218 | error("Element not found in entry") 1219 | end 1220 | end 1221 | 1222 | function Entry:update() 1223 | local indexSize = self._indexElement:update() 1224 | local valueSize = self._valueElement:update() 1225 | 1226 | if self._prevIndexSize == indexSize and self._prevValueSize == valueSize then 1227 | -- no changes, no need to update 1228 | return self._size 1229 | end 1230 | 1231 | self.prevIndexSize = indexSize 1232 | self.prevValueSize = valueSize 1233 | 1234 | local padX = entryPad.x 1235 | local padY = entryPad.y 1236 | 1237 | local sizeX = padX + indexSize.x + padX + separatorSize.x + padX + valueSize.x + padX 1238 | local sizeY = padY + math.max(indexSize.y, separatorSize.y, valueSize.y) + padY 1239 | self._size = Vector2.new(sizeX, sizeY) 1240 | 1241 | self.separatorFrame.Position = UDim2.fromOffset(padX + indexSize.x + padX, padY + math.min(indexSize.y, valueSize.y)/2 - separatorSize.y/2) 1242 | self._indexElement.backFrame.Position = UDim2.fromOffset(padX, padY) 1243 | self._valueElement.backFrame.Position = UDim2.fromOffset(padX + indexSize.x + padX + separatorSize.x + padX, padY) 1244 | 1245 | return self._size 1246 | -- updating the entry's backFrame size and position are the responsibility of the parent Element 1247 | end 1248 | 1249 | -- gets the size, but does not update 1250 | function Entry:getSize() 1251 | return self._size 1252 | end 1253 | 1254 | return TableInspector 1255 | --------------------------------------------------------------------------------