From 777d4ef7be8b7f8ab84ed8f85beca34ac8acfcd4 Mon Sep 17 00:00:00 2001 From: AdamZ Date: Thu, 27 Aug 2026 09:01:50 -0700 Subject: [PATCH] Fix edit box cursor click location --- spec/System/TestEditControl_spec.lua | 146 +++++++++++++++++++++++++++ src/Classes/EditControl.lua | 44 ++++++-- src/_SimpleGraphic.def.lua | 2 +- 3 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 spec/System/TestEditControl_spec.lua diff --git a/spec/System/TestEditControl_spec.lua b/spec/System/TestEditControl_spec.lua new file mode 100644 index 0000000000..36c9f04e61 --- /dev/null +++ b/spec/System/TestEditControl_spec.lua @@ -0,0 +1,146 @@ +describe("EditControl", function() + local originalDrawString + local originalDrawStringCursorIndex + local originalDrawStringWidth + local originalGetCursorPos + local originalIsKeyDown + + local function newMultilineControl(text, lineHeight) + return new("EditControl"):EditControl(nil, { 0, 0, 200, 100 }, text, nil, "^%C\t\n", nil, nil, lineHeight) + end + + before_each(function() + originalDrawString = _G.DrawString + originalDrawStringCursorIndex = _G.DrawStringCursorIndex + originalDrawStringWidth = _G.DrawStringWidth + originalGetCursorPos = _G.GetCursorPos + originalIsKeyDown = _G.IsKeyDown + end) + + after_each(function() + _G.DrawString = originalDrawString + _G.DrawStringCursorIndex = originalDrawStringCursorIndex + _G.DrawStringWidth = originalDrawStringWidth + _G.GetCursorPos = originalGetCursorPos + _G.IsKeyDown = originalIsKeyDown + end) + + it("maps multiline cursor Y to the displayed line", function() + local control = newMultilineControl("aa\n\tbb\n\n^1café", 14) + local calls = { } + _G.DrawStringCursorIndex = function(height, font, text, cursorX, cursorY) + table.insert(calls, { height, font, text, cursorX, cursorY }) + return #text + 1 + end + + assert.are.equal(7, control:GetCursorIndex(14, 9, 14 + 7)) + assert.are.same({ 14, "VAR", "\tbb", 9, 0 }, calls[#calls]) + assert.are.equal(8, control:GetCursorIndex(14, 9, 14 * 2 + 7)) + assert.are.same({ 14, "VAR", "", 9, 0 }, calls[#calls]) + assert.are.equal(#control.buf + 1, control:GetCursorIndex(14, 9, 1000)) + assert.are.same({ 14, "VAR", "^1café", 9, 0 }, calls[#calls]) + assert.are.equal(3, control:GetCursorIndex(14, 9, -10)) + assert.are.same({ 14, "VAR", "aa", 9, 0 }, calls[#calls]) + end) + + it("uses content coordinates for mouse-down and drag hit testing", function() + local control = newMultilineControl("aa\nbb\ncc\ndd", 14) + local calls = { } + control.IsMouseOver = function() + return true + end + control.GetMouseOverControl = function() + return nil + end + control.UpdateScrollBars = function() + end + control.ScrollCaretIntoView = function() + end + _G.DrawStringCursorIndex = function(_, _, text, cursorX, cursorY) + table.insert(calls, { text = text, cursorX = cursorX, cursorY = cursorY }) + return 2 + end + _G.IsKeyDown = function(key) + return key == "LEFTBUTTON" + end + + control.controls.scrollBarH.offset = 5 + control.controls.scrollBarV.offset = 14 + _G.GetCursorPos = function() + return 12, 23 + end + control:OnKeyDown("LEFTBUTTON") + assert.are.equal(8, control.caret) + assert.are.same({ text = "cc", cursorX = 15, cursorY = 0 }, calls[#calls]) + + control.controls.scrollBarH.offset = 3 + control.controls.scrollBarV.offset = 28 + _G.GetCursorPos = function() + return 11, 23 + end + control.hasFocus = true + control.drag = true + control:Draw({ x = 0, y = 0, width = 200, height = 100 }, true) + assert.are.equal(11, control.caret) + assert.are.same({ text = "dd", cursorX = 12, cursorY = 0 }, calls[#calls]) + end) + + it("keeps the host multiline behavior for single-line controls", function() + local control = new("EditControl"):EditControl(nil, { 0, 0, 200, 20 }, "aa\nbb") + _G.DrawStringCursorIndex = function(height, font, text, cursorX, cursorY) + assert.are.equal("aa\nbb", text) + assert.are.equal(23, cursorY) + return 4 + end + + assert.are.equal(4, control:GetCursorIndex(16, 9, 23)) + end) + + it("moves vertically by the logical line height at depth", function() + local lines = { } + for i = 1, 30 do + lines[i] = "ab" + end + local control = newMultilineControl(table.concat(lines, "\n"), 14) + local function lineStart(line) + return (line - 1) * 3 + 1 + end + _G.DrawStringWidth = function(_, _, text) + return #text + end + _G.DrawStringCursorIndex = function(_, _, text, _, cursorY) + assert.are.equal("ab", text) + assert.are.equal(0, cursorY) + return 2 + end + control.caret = lineStart(20) + 1 + + control:MoveCaretVertically(14) + assert.are.equal(lineStart(21) + 1, control.caret) + control:MoveCaretVertically(-14) + assert.are.equal(lineStart(20) + 1, control.caret) + end) + + it("draws multiline text on the logical line grid while unfocused", function() + local control = newMultilineControl("first\n\nthird", 14) + local calls = { } + _G.DrawString = function(_, top, _, height, font, text) + table.insert(calls, { top = top, height = height, font = font, text = text }) + end + control.hasFocus = false + + control:Draw({ x = 0, y = 0, width = 200, height = 100 }, true) + + local textCalls = { } + for _, call in ipairs(calls) do + if call.text == "first" or call.text == "" or call.text == "third" then + table.insert(textCalls, call) + end + end + assert.are.same({ + { top = 0, height = 14, font = "VAR", text = "first" }, + { top = 14, height = 14, font = "VAR", text = "" }, + { top = 28, height = 14, font = "VAR", text = "third" }, + }, textCalls) + end) +end) diff --git a/src/Classes/EditControl.lua b/src/Classes/EditControl.lua index 899f5b0bcf..a58a4e1cdc 100644 --- a/src/Classes/EditControl.lua +++ b/src/Classes/EditControl.lua @@ -36,6 +36,13 @@ local function newlineCount(str) end end +local function drawMultilineText(textX, textY, textHeight, font, text) + for line in (text.."\n"):gmatch("([^\n]*)\n") do + DrawString(textX, textY, "LEFT", textHeight, font, line) + textY = textY + textHeight + end +end + ---@class EditControl: ControlHost, Control, UndoHandler, TooltipHost ---@field inactiveText (fun(buf: string?): string)|string local EditClass = newClass("EditControl", "ControlHost", "Control", "UndoHandler", "TooltipHost") @@ -233,11 +240,27 @@ function EditClass:ScrollCaretIntoView() end end +function EditClass:GetCursorIndex(textHeight, cursorX, cursorY) + if not self.lineHeight then + return DrawStringCursorIndex(textHeight, self.font, self.buf, cursorX, cursorY) + end + local targetLine = m_max(m_floor(cursorY / textHeight), 0) + local lineStart, line = 1, "" + for s, textLine in (self.buf.."\n"):gmatch("()([^\n]*)\n") do + lineStart, line = s, textLine + if targetLine == 0 then + break + end + targetLine = targetLine - 1 + end + return lineStart + DrawStringCursorIndex(textHeight, self.font, line, cursorX, 0) - 1 +end + function EditClass:MoveCaretVertically(offset) local pre = self.buf:sub(1, self.caret - 1) local caretX = DrawStringWidth(self.lineHeight, self.font, lastLine(pre)) local caretY = newlineCount(pre) * self.lineHeight - self.caret = DrawStringCursorIndex(self.lineHeight, self.font, self.buf, caretX + 1, caretY + self.lineHeight/2 + offset) + self.caret = self:GetCursorIndex(self.lineHeight, caretX + 1, caretY + self.lineHeight/2 + offset) self.lastUndoState.caret = self.caret self:ScrollCaretIntoView() self.blinkStart = GetTime() @@ -296,21 +319,26 @@ function EditClass:Draw(viewPort, noTooltip) local marginB = self.controls.scrollBarH:IsShown() and 14 or 0 SetViewport(textX, textY, width - 4 - marginL - marginR, height - 4 - marginB) if not self.hasFocus then + local inactiveText if self.buf == '' and self.placeholder then SetDrawColor(self.disableCol) - DrawString(-self.controls.scrollBarH.offset, -self.controls.scrollBarV.offset, "LEFT", textHeight, self.font, self.placeholder) + inactiveText = self.placeholder else SetDrawColor(self.inactiveCol) if self.inactiveText then - local inactiveText = type(self.inactiveText) == "string" and self.inactiveText or self.inactiveText(self.buf) + inactiveText = type(self.inactiveText) == "string" and self.inactiveText or self.inactiveText(self.buf) ---@cast inactiveText string - DrawString(-self.controls.scrollBarH.offset, -self.controls.scrollBarV.offset, "LEFT", textHeight, self.font, inactiveText) elseif self.protected then - DrawString(-self.controls.scrollBarH.offset, -self.controls.scrollBarV.offset, "LEFT", textHeight, self.font, string.rep(protected_replace, #self.buf)) + inactiveText = self.buf:gsub("[^\n]", protected_replace) else - DrawString(-self.controls.scrollBarH.offset, -self.controls.scrollBarV.offset, "LEFT", textHeight, self.font, self.buf) + inactiveText = self.buf end end + if self.lineHeight then + drawMultilineText(-self.controls.scrollBarH.offset, -self.controls.scrollBarV.offset, textHeight, self.font, inactiveText) + else + DrawString(-self.controls.scrollBarH.offset, -self.controls.scrollBarV.offset, "LEFT", textHeight, self.font, inactiveText) + end SetViewport() self:DrawControls(viewPort, noTooltip and self) return @@ -320,7 +348,7 @@ function EditClass:Draw(viewPort, noTooltip) end if self.drag then local cursorX, cursorY = GetCursorPos() - self.caret = DrawStringCursorIndex(textHeight, self.font, self.buf, cursorX - textX + self.controls.scrollBarH.offset, cursorY - textY + self.controls.scrollBarV.offset) + self.caret = self:GetCursorIndex(textHeight, cursorX - textX + self.controls.scrollBarH.offset, cursorY - textY + self.controls.scrollBarV.offset) self.lastUndoState.caret = self.caret self:ScrollCaretIntoView() end @@ -502,7 +530,7 @@ function EditClass:OnKeyDown(key, doubleClick) textX = textX + DrawStringWidth(textHeight, self.font, self.prompt) + textHeight/2 end local cursorX, cursorY = GetCursorPos() - self.caret = DrawStringCursorIndex(textHeight, self.font, self.buf, cursorX - textX + self.controls.scrollBarH.offset, cursorY - textY + self.controls.scrollBarV.offset) + self.caret = self:GetCursorIndex(textHeight, cursorX - textX + self.controls.scrollBarH.offset, cursorY - textY + self.controls.scrollBarV.offset) self.sel = self.caret self.lastUndoState.caret = self.caret self:ScrollCaretIntoView() diff --git a/src/_SimpleGraphic.def.lua b/src/_SimpleGraphic.def.lua index 6e159e4134..f78eebe741 100644 --- a/src/_SimpleGraphic.def.lua +++ b/src/_SimpleGraphic.def.lua @@ -275,7 +275,7 @@ function DrawString(left, top, align, height, font, text) end ---@param height number ---@param font Font ---@param text string ----@return integer physicalWidth +---@return number logicalWidth function DrawStringWidth(height, font, text) return 1 end