-- A small, self-contained multi-line text field for the level editor sidebar. -- LÖVE ships no GUI widgets, so this rolls the minimum needed to feel like a -- normal text box: a caret, a selection, mouse click/drag positioning, and the -- usual keyboard shortcuts (arrows, word-jump, home/end, select-all, clipboard). -- -- Text is kept as an array of one-character strings (utf8-aware) with the caret -- and selection anchor expressed as caret indices (0..#chars, a position between -- characters). Everything else is derived from that. local utf8 = require("utf8") local pad = 6 local note = { active = false, chars = {}, -- array of single utf8 characters caret = 0, -- 0..#chars anchor = nil, -- selection start (caret index) or nil rect = { x = 0, y = 0, w = 0, h = 0 }, dragging = false, } -- ── helpers ───────────────────────────────────────────────────────────────── local function toChars(str) local t = {} local i, len = 1, #str while i <= len do local nexti = utf8.offset(str, 2, i) or (len + 1) t[#t + 1] = str:sub(i, nexti - 1) i = nexti end return t end local function clamp(v, lo, hi) if v < lo then return lo elseif v > hi then return hi else return v end end local function isWordChar(c) return c ~= nil and c:match("[%w_]") ~= nil end -- Line ranges as caret-index pairs: line covers caret indices s..e, and its text -- is chars[s+1 .. e]. A blank final line after a trailing newline is included. local function buildLines() local lines, start = {}, 0 for k = 1, #note.chars do if note.chars[k] == "\n" then lines[#lines + 1] = { s = start, e = k - 1 } start = k end end lines[#lines + 1] = { s = start, e = #note.chars } return lines end local function lineIndexOf(caret, lines) for i, line in ipairs(lines) do if caret >= line.s and caret <= line.e then return i end end return #lines end -- Pixel width from a line's start up to `col` characters into it. local function colWidth(line, col) local s = table.concat(note.chars, "", line.s + 1, line.s + col) return font:getWidth(s) end -- ── selection / mutation ───────────────────────────────────────────────────── local function selectionRange() if not note.anchor or note.anchor == note.caret then return nil end return math.min(note.anchor, note.caret), math.max(note.anchor, note.caret) end local function deleteRange(lo, hi) for k = hi, lo + 1, -1 do table.remove(note.chars, k) end note.caret = lo note.anchor = nil end local function deleteSelection() local lo, hi = selectionRange() if not lo then return false end deleteRange(lo, hi) return true end local function insertText(str) deleteSelection() local ins = toChars(str) for offset, c in ipairs(ins) do table.insert(note.chars, note.caret + offset, c) end note.caret = note.caret + #ins note.anchor = nil end -- Move the caret, extending or clearing the selection depending on shift. local function moveCaret(pos, keepSelection) if keepSelection then if not note.anchor then note.anchor = note.caret end else note.anchor = nil end note.caret = clamp(pos, 0, #note.chars) end local function wordLeft(i) local j = i while j > 0 and not isWordChar(note.chars[j]) do j = j - 1 end while j > 0 and isWordChar(note.chars[j]) do j = j - 1 end return j end local function wordRight(i) local n = #note.chars local j = i while j < n and not isWordChar(note.chars[j + 1]) do j = j + 1 end while j < n and isWordChar(note.chars[j + 1]) do j = j + 1 end return j end -- Map a point (sidebar-virtual coords) to the nearest caret index. local function caretFromPoint(px, py) local lines = buildLines() local lineH = font:getHeight() local row = clamp(math.floor((py - (note.rect.y + pad)) / lineH) + 1, 1, #lines) local line = lines[row] local target = px - (note.rect.x + pad) local best, bestDist = 0, math.huge for col = 0, line.e - line.s do local dist = math.abs(colWidth(line, col) - target) if dist < bestDist then best, bestDist = col, dist end end return line.s + best end -- ── public API (globals, matching the editor's module style) ───────────────── function noteEditorActive() return note.active and editorView == "room" end function noteEditorFocus() note.active = true end function noteEditorBlur() note.active = false note.dragging = false end function noteEditorText() return table.concat(note.chars) end function noteEditorSetText(str) note.chars = toChars(str or "") note.caret = #note.chars note.anchor = nil end function noteEditorSetRect(x, y, w, h) note.rect.x, note.rect.y, note.rect.w, note.rect.h = x, y, w, h end -- Click landed somewhere in the sidebar (virtual coords). Returns true if it hit -- the field (so the caller stops looking for another control). function noteEditorMousepressed(x, y) local r = note.rect if x < r.x or x > r.x + r.w or y < r.y or y > r.y + r.h then return false end note.active = true note.caret = caretFromPoint(x, y) note.anchor = note.caret note.dragging = true return true end function noteEditorMousemoved(x, y) if not note.dragging then return false end note.caret = caretFromPoint(x, y) return true end function noteEditorMousereleased() note.dragging = false if note.anchor == note.caret then note.anchor = nil end end function noteEditorTextinput(t) insertText(t) end function noteEditorKeypressed(key) local ctrl = love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") local shift = love.keyboard.isDown("lshift", "rshift") if key == "escape" then noteEditorBlur() elseif key == "return" or key == "kpenter" then insertText("\n") elseif key == "backspace" then if not deleteSelection() then local to = ctrl and wordLeft(note.caret) or note.caret - 1 if to >= 0 and note.caret > 0 then deleteRange(math.max(0, to), note.caret) end end elseif key == "delete" then if not deleteSelection() then local to = ctrl and wordRight(note.caret) or note.caret + 1 if note.caret < #note.chars then deleteRange(note.caret, math.min(#note.chars, to)) end end elseif key == "left" then moveCaret(ctrl and wordLeft(note.caret) or note.caret - 1, shift) elseif key == "right" then moveCaret(ctrl and wordRight(note.caret) or note.caret + 1, shift) elseif key == "up" or key == "down" then local lines = buildLines() local row = lineIndexOf(note.caret, lines) local col = note.caret - lines[row].s local target = clamp(row + (key == "up" and -1 or 1), 1, #lines) local line = lines[target] moveCaret(line.s + math.min(col, line.e - line.s), shift) elseif key == "home" then if ctrl then moveCaret(0, shift) else local lines = buildLines() moveCaret(lines[lineIndexOf(note.caret, lines)].s, shift) end elseif key == "end" then if ctrl then moveCaret(#note.chars, shift) else local lines = buildLines() moveCaret(lines[lineIndexOf(note.caret, lines)].e, shift) end elseif ctrl and key == "a" then note.anchor = 0 note.caret = #note.chars elseif ctrl and (key == "c" or key == "x") then local lo, hi = selectionRange() if lo then love.system.setClipboardText(table.concat(note.chars, "", lo + 1, hi)) if key == "x" then deleteRange(lo, hi) end end elseif ctrl and key == "v" then insertText(love.system.getClipboardText() or "") end end -- Rendered inside the room-view sidebar (virtual space), clipped to its rect. function noteEditorDraw() local r = note.rect local lines = buildLines() local lineH = font:getHeight() local ox, oy = r.x + pad, r.y + pad love.graphics.setColor(0, 0, 0, 0.85) love.graphics.rectangle("fill", r.x, r.y, r.w, r.h) love.graphics.setColor(gColor.white:set()) love.graphics.setLineWidth(noteEditorActive() and 2 or 1) love.graphics.rectangle("line", r.x, r.y, r.w, r.h) Layout.clip({ x = r.x, y = r.y, w = r.w, h = r.h }) -- selection highlight local lo, hi = selectionRange() if lo then love.graphics.setColor(0.25, 0.45, 0.9, 0.6) for i, line in ipairs(lines) do local segStart, segEnd = math.max(lo, line.s), math.min(hi, line.e) if segEnd > segStart then local x1 = ox + colWidth(line, segStart - line.s) local x2 = ox + colWidth(line, segEnd - line.s) love.graphics.rectangle("fill", x1, oy + (i - 1) * lineH, x2 - x1, lineH) end end end love.graphics.setColor(gColor.white:set()) for i, line in ipairs(lines) do local text = table.concat(note.chars, "", line.s + 1, line.e) love.graphics.print(text, ox, oy + (i - 1) * lineH) end -- caret (blinking) — only while focused if noteEditorActive() and (love.timer.getTime() % 1) < 0.5 then local row = lineIndexOf(note.caret, lines) local cx = ox + colWidth(lines[row], note.caret - lines[row].s) local cy = oy + (row - 1) * lineH love.graphics.setLineWidth(1) love.graphics.line(cx, cy, cx, cy + lineH) end Layout.unclip() end