Roblox backend: hidden TextBox proxy for text input

A single off-screen TextBox holds focus while an imgui text field is active, so the OS
delivers typing (any layout/unicode), paste and copy/cut. Its text mirrors imgui selection
(fully selected) so Ctrl+C/X copy exactly that; deletions apply as an imgui Backspace;
Ctrl is released around pasted characters. Focus is captured after the click is released,
kept on clicks inside imgui, ends on clicks outside. KeyCode text kept as fallback.
SetTextProxyDebug for logging.
This commit is contained in:
4DBug
2026-10-08 19:31:22 -05:00
parent 20c29bba7e
commit 81c5ad9e43
5 changed files with 325 additions and 9 deletions
+17 -2
View File
@@ -60,6 +60,7 @@ local InstMethods = {}
local InstMT = {}
InstMT.__index = function(t, k)
if k == "Parent" then return rawget(t, "_parent") end
local pv = rawget(t, "_props")[k]; if pv ~= nil then return pv end
local m = InstMethods[k]; if m then return m end
for _, c in ipairs(rawget(t, "_children")) do if c.Name == k then return c end end
return nil
@@ -71,13 +72,18 @@ InstMT.__newindex = function(t, k, v)
rawset(t, "_parent", v)
if v then table.insert(rawget(v, "_children"), t) end
else
rawset(t, k, v)
local props = rawget(t, "_props")
local old = props[k]
props[k] = v
local sigs = rawget(t, "_propsigs")
if sigs and sigs[k] and old ~= v then sigs[k]:Fire() end
end
end
local function newInst(class, name, parent)
local o = setmetatable({ ClassName = class, Name = name or class, _children = {} }, InstMT)
local o = setmetatable({ _children = {}, _props = { ClassName = class, Name = name or class } }, InstMT)
if class == "ScreenGui" or class == "Frame" or class == "ImageLabel" then o.AbsoluteSize = v2(SCREEN_W, SCREEN_H) end
o.Visible = true
if class == "TextBox" then rawget(o, "_props").FocusLost = Signal() end
if class == "BindableEvent" then local sig = Signal(); rawset(o, "Event", sig); rawset(o, "Fire", function(_, ...) sig:Fire(...) end) end
if parent then o.Parent = parent end
return o
@@ -95,6 +101,14 @@ function InstMethods:IsA(class)
return self.ClassName == class
end
function InstMethods:Destroy() self.Parent = nil end
local focused_box = nil
function InstMethods:GetPropertyChangedSignal(prop)
local sigs = rawget(self, "_propsigs") or {}; rawset(self, "_propsigs", sigs)
sigs[prop] = sigs[prop] or Signal(); return sigs[prop]
end
function InstMethods:CaptureFocus() focused_box = self end
function InstMethods:_loseFocus() if focused_box == self then focused_box = nil; local fl = rawget(self, "_props").FocusLost; if fl then fl:Fire(false) end end end
function InstMethods:ReleaseFocus() self:_loseFocus() end
-- Services
local mouse = v2(0, 0)
@@ -103,6 +117,7 @@ local UIS = {
InputBegan = Signal(), InputEnded = Signal(), InputChanged = Signal(), WindowFocused = Signal(), WindowFocusReleased = Signal(),
}
function UIS:GetMouseLocation() return mouse end
function UIS:GetFocusedTextBox() return focused_box end
function UIS:IsKeyDown(kc) return keys_down[kc] == true end
local RunService = { Heartbeat = Signal(), Stepped = Signal(), PreAnimation = Signal(), RenderStepped = Signal() }
+73
View File
@@ -0,0 +1,73 @@
-- Text proxy: typing/paste arrive through the hidden TextBox, and its text mirrors the InputText selection (for copy).
local G = require(game:GetService("ReplicatedStorage"):WaitForChild("ImGui"))
local ImGui, ImVec2 = G.ImGui, G.ImVec2
local UIS = game:GetService("UserInputService")
ImGui.CreateContext(); G.ImGui_ImplRoblox.Init()
local io = ImGui.GetIO()
local buf = ImGui.NewTextBuffer("", 64)
local proxy
local f = 0
game:GetService("RunService").Stepped:Connect(function()
f += 1
G.ImGui_ImplRoblox.NewFrame()
proxy = proxy or game:GetService("Players").LocalPlayer.PlayerGui.ImGui.TextProxy
if f == 3 then io:AddMousePosEvent(60, 45); io:AddMouseButtonEvent(0, true) end
if f == 4 then io:AddMouseButtonEvent(0, false) end
if f == 8 then assert(UIS:GetFocusedTextBox() == proxy, "proxy focused"); proxy.Text = "abc" end -- typed
if f == 10 then io:AddKeyEvent(G.ImGuiMod_Ctrl, true); proxy.Text = "hello world" end -- pasted with Ctrl held
if f == 11 then io:AddKeyEvent(G.ImGuiMod_Ctrl, false) end -- pasted
if f == 13 then io:AddKeyEvent(G.ImGuiMod_Ctrl, true); io:AddKeyEvent(G.ImGuiKey.A, true) end -- select all
if f == 14 then io:AddKeyEvent(G.ImGuiKey.A, false); io:AddKeyEvent(G.ImGuiMod_Ctrl, false) end
ImGui.NewFrame()
ImGui.SetNextWindowPos(ImVec2(10, 10)); ImGui.SetNextWindowSize(ImVec2(300, 100))
ImGui.Begin("T"); ImGui.InputText("##t", buf, 64); ImGui.End()
ImGui.Render(); G.ImGui_ImplRoblox.RenderDrawData(ImGui.GetDrawData())
if f == 12 then local st = ImGui.GetCurrentContext().InputTextState -- (buf itself is written on validation/deactivation)
local got = G.ImGui._ByteArrayToString(st.TextA.Data, 1, st.TextLen + 1)
assert(got == "abchello world", "text: " .. got) end
if f == 15 then proxy:_loseFocus() end
-- keys while the proxy reports focus but receives nothing (Roblox silently not delivering): must still type
if f == 20 then
io:AddMousePosEvent(60, 45); io:AddMouseButtonEvent(0, true)
end
if f == 21 then io:AddMouseButtonEvent(0, false) end
if f == 24 then
assert(UIS:GetFocusedTextBox() == proxy, "proxy focused again")
local k = { UserInputType = Enum.UserInputType.Keyboard, KeyCode = Enum.KeyCode.Z, Position = { X = 0, Y = 0, Z = 0 } }
UIS.InputBegan:Fire(k, true); UIS.InputEnded:Fire(k, true)
end -- click inside imgui (mouse is over the field): edit must survive
if f == 16 then
assert(ImGui.GetCurrentContext().ActiveId ~= 0, "edit ended by a click inside imgui")
assert(UIS:GetFocusedTextBox() == proxy, "proxy not recaptured")
assert(proxy.Text == "abchello world", "selection mirror: " .. tostring(proxy.Text))
io:AddMousePosEvent(900, 900) -- move off imgui, then the user clicks into the game
end
if f == 17 then proxy:_loseFocus() end
if f == 19 then
assert(ImGui.GetCurrentContext().ActiveId == 0, "field still active after focus lost")
assert(UIS:GetFocusedTextBox() == nil, "proxy recaptured")
assert(ImGui.TextBufferToString(buf) == "abchello world", "buf: " .. ImGui.TextBufferToString(buf))
end
-- click inside the field with a selection (Roblox drops focus on press, we recapture on release): selection must survive
if f == 28 then io:AddKeyEvent(G.ImGuiMod_Ctrl, true); io:AddKeyEvent(G.ImGuiKey.A, true) end
if f == 29 then io:AddKeyEvent(G.ImGuiKey.A, false); io:AddKeyEvent(G.ImGuiMod_Ctrl, false) end
if f == 31 then proxy:_loseFocus() end
if f == 34 then
local st = ImGui.GetCurrentContext().InputTextState
assert(st:HasSelection(), "selection lost after refocus")
assert(#proxy.Text > 5 and proxy.Text == G.ImGui._ByteArrayToString(st.TextA.Data, 1, st.TextLen + 1), "mirror after refocus: " .. proxy.Text)
print("REFOCUS OK")
proxy.Text = proxy.Text:sub(1, -2) -- Roblox Backspace without a real TextBox selection: removes 1 char
end
if f == 37 then
local st = ImGui.GetCurrentContext().InputTextState
assert(st.TextLen == 0, "backspace should delete imgui's whole selection, len " .. st.TextLen)
print("BACKSPACE OK")
end
if f == 27 then
local st = ImGui.GetCurrentContext().InputTextState
local got = G.ImGui._ByteArrayToString(st.TextA.Data, 1, st.TextLen + 1)
assert(got:find("z"), "key fallback while proxy focused: " .. got)
print("PROXY OK")
end
end)