From 075d5da4d13802be64816639a19a666e3ada8572 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 27 Jul 2026 10:00:18 -0700 Subject: [PATCH] Stop the hover tooltip from clipping off the left screen edge The Combo Advisor info popup is right-anchored to the hovered card and grows leftward with no screen clamp (vanilla align_h_popup leaves lr_clamp off), so its width is set by its single widest row -- and several rows had no cap: the fusion "Fuses into X - still needs Y" line, chained copy lines, and the "name: reason" combo / "Clashes - name: blurb" rows, which run well past a single row once a long joker name meets a full 34-char blurb. On a left-of-centre shop card the box ran off the screen and clipped. Enforce one width invariant on every rendered line via a wrap_text helper (word-wrap, never splits a name, keeps continuation indents) and a seg_row for the two-segment lines. The prefixed wrap_names blocks (Looking for:, Offers:, also:, Feeds:, Fusing drops) now budget for their prefix instead of adding it on top. Size the budget to the card's actual gutter rather than a fixed number: the room width is a constant in game units whatever the window size, so the real variable is where the card sits and which side vanilla opens the popup on. row_budget measures the gutter with the game's own text metric (exact, resolution-independent) -- roomy for a far-side card, tight near the 40% flip point so it never clips, and it tracks an ultrawide/room-widening mod for free. Missing geometry falls back to the fixed budget. The one estimate, MAIN_BOX_UNITS (the card's own desc box between us and the card), is sized ~1 unit generous to bias toward wrapping early over clipping; dbg() logs the inputs for later calibration. Tests: real row-builder driven to its widest row and asserted within budget; wrap_text budget/indent/word-integrity; row_budget fallback, monotonicity and clamp. 936 pass on 5.4 and LuaJIT. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.lua | 149 ++++++++++++++++++++++++++++++++++++++++++++++--------- test.lua | 72 +++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 24 deletions(-) diff --git a/main.lua b/main.lua index ca2d740..007b24a 100644 --- a/main.lua +++ b/main.lua @@ -581,6 +581,89 @@ local function wrap_names(partners, max_width, max_lines) return lines end +-- Every advice line has to fit the shop's left gutter. The hover info popup is +-- right-anchored to the card and grows LEFTWARD with no screen clamp -- vanilla +-- align_h_popup leaves lr_clamp off (UI_definitions.lua) -- so its width is set +-- by its single widest row, and a row wider than the gutter runs off the left +-- edge and clips (the reported bug: a long "Fuses into X - still needs Y" line). +-- Keep every rendered line at or under this many characters. +local ROW_BUDGET = 36 +JCA._row_budget = ROW_BUDGET + +-- Word-wrap a sentence to <= budget chars per line, breaking only on spaces so a +-- joker name is never split. An overlong single word is left whole (better one +-- wide line than a mangled name). +local function wrap_text(str, budget) + str = tostring(str) + -- Preserve a leading indent (callers pass " " to mark continuation lines); + -- gmatch would otherwise drop it. The indent counts against the budget. + local indent = str:match('^%s+') or '' + local body_budget = budget - #indent + local lines, line = {}, nil + for word in str:gmatch('%S+') do + local nxt = line and (line .. ' ' .. word) or word + if line and #nxt > body_budget then + lines[#lines + 1] = line + line = word + else + line = nxt + end + end + lines[#lines + 1] = line or '' + lines[1] = indent .. lines[1] + return lines +end +JCA._wrap_text = wrap_text + +-- Dynamic width. The gutter a card leaves for its tooltip depends on where the +-- card sits -- vanilla opens the popup to the LEFT of a card past 40% of the +-- room and to the RIGHT of the rest (show_infotip, UI_definitions.lua) -- and on +-- the room width, which is a CONSTANT in game units whatever the window size +-- (love.resize only rescales pixels, main.lua:1304). So we size the wrap budget +-- to the real gutter: roomy for a far-side card, tight near the flip point, and +-- it tracks a room-widening/ultrawide mod for free. Anything missing (headless +-- tests) falls back to the fixed budget. +local ROW_BUDGET_MIN = 24 +local ROW_BUDGET_MAX = 60 +-- The card's OWN description popup sits between our tooltip and the card, eating +-- into the gutter. Approximate in game units; dbg() logs the inputs so it can be +-- tuned against a real hover. +local MAIN_BOX_UNITS = 4.6 + +-- Width of one average character in room units at a text scale, from the game's +-- own text-measuring formula (engine/ui.lua:143) -- exact and window-size +-- independent. nil when the font is not loaded (standalone tests). +local function unit_char_width(scale) + local f = G.LANG and G.LANG.font + local FONT = f and f.FONT + if not (FONT and FONT.getWidth and G.TILESIZE) then return nil end + local sample = 'abcdefghijklmnopqrstuvwxyz ,.-()ABCDEFGHIJKLMNOPQRSTUVWXYZ' + local ok, px = pcall(function() + return FONT:getWidth(sample) * (f.squish or 1) * scale * (f.FONTSCALE or 1) + end) + if not (ok and type(px) == 'number' and px > 0) then return nil end + return px / G.TILESIZE / #sample +end + +-- Characters that fit one tooltip row for THIS card, clamped to a sane range. +local function row_budget(card) + local room = G.ROOM and G.ROOM.T + local T = card and card.T + if not (room and T and T.x and room.w) then return ROW_BUDGET end + -- Measure at our widest row scale (0.32) so the budget is safe for every row. + local cw = unit_char_width(0.32) + if not cw or cw <= 0 then return ROW_BUDGET end + local gutter = (T.x > room.w * 0.4) and T.x + or (room.w - (T.x + (T.w or 0))) + gutter = gutter - MAIN_BOX_UNITS + -- *1.08: a hair of slack so a line of wider-than-average glyphs still fits. + local budget = math.floor(gutter / (cw * 1.08)) + dbg('row_budget x=', T.x, 'roomw=', room.w, 'cw=', cw, + 'gutter=', gutter, '->', budget) + return math.max(ROW_BUDGET_MIN, math.min(ROW_BUDGET_MAX, budget)) +end +JCA._row_budget_for = row_budget + -- Short theme nouns for the "Looking for:"/"Offers:" hints (engine roles -- mult/chips/xmult are deliberately absent — they hint nothing specific). local TAG_LABEL = { @@ -682,12 +765,36 @@ local function tooltip_rows(card) local S = JCA.config.touch_mode and 1.4 or 1 local key = card.config.center.key local partners, total = JCA.partners_for(card) + local budget = row_budget(card) local rows = {} local function text_row(str, colour) + for _, line in ipairs(wrap_text(str, budget)) do + rows[#rows + 1] = {{n = G.UIT.T, config = { + text = line, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S, + }}} + end + end + -- A coloured label followed by a body sentence ("Baron: retriggers Kings"). + -- Kept on one row when the pair fits; otherwise the label takes its own row + -- and the body wraps beneath it, indented -- so neither a long joker name + -- nor a long blurb can push the row off the screen edge. + local function seg_row(label, label_colour, body) + if #label + #body <= budget then + rows[#rows + 1] = { + {n = G.UIT.T, config = {text = label, + colour = label_colour, scale = 0.3 * S}}, + {n = G.UIT.T, config = {text = body, + colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}}, + } + return + end rows[#rows + 1] = {{n = G.UIT.T, config = { - text = str, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S, - }}} + text = label, colour = label_colour, scale = 0.3 * S}}} + for _, line in ipairs(wrap_text(body, budget - 2)) do + rows[#rows + 1] = {{n = G.UIT.T, config = { + text = ' ' .. line, colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}}} + end end if #partners == 0 then @@ -701,7 +808,7 @@ local function tooltip_rows(card) labels, prefix = tag_labels(entry.gives), 'Offers: ' end if #labels > 0 then - for i, l in ipairs(wrap_names(labels, 36, 2)) do + for i, l in ipairs(wrap_names(labels, budget - #prefix, 2)) do rows[#rows + 1] = {{n = G.UIT.T, config = { text = (i == 1 and prefix or ' ') .. l, colour = G.C.UI.TEXT_DARK, scale = 0.3 * S, @@ -719,17 +826,12 @@ local function tooltip_rows(card) for i = 1, shown do local p = partners[i] local reason = JCA.explain(key, p.key) or 'shared build theme' - rows[#rows + 1] = { - {n = G.UIT.T, config = {text = p.name .. ': ', - colour = G.C.RED, scale = 0.3 * S}}, - {n = G.UIT.T, config = {text = reason, - colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}}, - } + seg_row(p.name .. ': ', G.C.RED, reason) end if #partners > shown then local rest = {} for i = shown + 1, #partners do rest[#rest + 1] = partners[i] end - for i, l in ipairs(wrap_names(rest, 34, 2)) do + for i, l in ipairs(wrap_names(rest, budget - 6, 2)) do text_row((i == 1 and 'also: ' or ' ') .. l, G.C.UI.TEXT_DARK) end end @@ -745,13 +847,8 @@ local function tooltip_rows(card) if owned ~= card and owned.config.center.set == 'Joker' then local warn = JCA.clash_blurb[pair_key(key, owned.config.center.key)] if warn then - rows[#rows + 1] = { - {n = G.UIT.T, config = { - text = 'Clashes - ' .. name_of(owned.config.center.key) .. ': ', - colour = G.C.RED, scale = 0.3 * S}}, - {n = G.UIT.T, config = {text = warn, - colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}}, - } + seg_row('Clashes - ' .. name_of(owned.config.center.key) .. ': ', + G.C.RED, warn) end end end @@ -833,9 +930,9 @@ local function tooltip_rows(card) if #p.loses > 0 then local names = {} for _, k in ipairs(p.loses) do names[#names + 1] = {name = name_of(k)} end - for li, l in ipairs(wrap_names(names, 30, 2)) do - text_row((li == 1 and 'Fusing drops combos with: ' or ' ') .. l, - G.C.RED) + text_row('Fusing drops combos with:', G.C.RED) + for _, l in ipairs(wrap_names(names, budget - 2, 2)) do + text_row(' ' .. l, G.C.RED) end end end @@ -981,6 +1078,7 @@ local function tooltip_rows(card) rows.name = 'Combo Advisor' return rows end +JCA._tooltip_rows = tooltip_rows -- for the row-width test; not a public API -- The consumable counterpart of tooltip_rows: names the owned jokers a -- Planet/Tarot/Spectral card or its pack feeds. Quiet unless an owned joker @@ -989,14 +1087,17 @@ local function consumable_rows(card) local S = JCA.config.touch_mode and 1.4 or 1 local fed, tag = JCA.consumable_partners(card) if not tag then return nil end + local budget = row_budget(card) local rows = {} local function text_row(str, colour) - rows[#rows + 1] = {{n = G.UIT.T, config = { - text = str, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S, - }}} + for _, line in ipairs(wrap_text(str, budget)) do + rows[#rows + 1] = {{n = G.UIT.T, config = { + text = line, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S, + }}} + end end if #fed > 0 then - for i, l in ipairs(wrap_names(fed, 34, 2)) do + for i, l in ipairs(wrap_names(fed, budget - 7, 2)) do text_row((i == 1 and 'Feeds: ' or ' ') .. l) end end diff --git a/test.lua b/test.lua index 5eabbf5..5b4ecb2 100644 --- a/test.lua +++ b/test.lua @@ -1108,6 +1108,78 @@ build('Traps last page', function() G.FUNCS.jca_page_Traps{cycle_config = {current_option = 999}} end) +-------------------------------------------------------------------------------- +section('UI: advice rows fit the tooltip gutter') +-------------------------------------------------------------------------------- + +-- The hover info popup is right-anchored to the card and grows leftward with no +-- screen clamp, so a row wider than the shop's left gutter runs off-screen and +-- clips (the reported bug). Every rendered line -- summed across its coloured +-- segments -- must stay within the budget, however long the name or blurb. +local BUDGET = JCA._row_budget + +-- wrap_text: never exceeds the budget, never splits a word, loses no content. +do + local long = 'Fuses into Obelisk ($12) - still needs Wrathful Joker and Blueprint' + local lines = JCA._wrap_text(long, BUDGET) + local wide = false + for _, l in ipairs(lines) do if #l > BUDGET then wide = true end end + ok(not wide, 'wrap_text keeps every line within budget') + eq(table.concat(lines, ' '), long, 'wrap_text loses no words') + eq(#JCA._wrap_text(string.rep('x', BUDGET + 10), BUDGET), 1, + 'wrap_text leaves an unbreakable long word on its own line') + -- Continuation lines arrive pre-indented (" Baron, Mime"); the indent must + -- survive and still count against the budget. + local indented = JCA._wrap_text(' ' .. string.rep('a', BUDGET - 2), BUDGET) + eq(#indented, 1, 'an indented line at budget does not wrap') + ok(indented[1]:sub(1, 2) == ' ', 'wrap_text keeps the leading indent') + ok(#indented[1] <= BUDGET, 'indent counts against the budget') +end + +-- row_budget sizes each row to the card's actual gutter, using the game's own +-- text metric. Window size never enters it (the room is a constant in units); +-- only the card's position does. With no geometry it must fall back to the fixed +-- budget so headless callers -- and this suite -- keep a stable width. +do + eq(JCA._row_budget_for({}), BUDGET, + 'row_budget falls back to the fixed budget with no card geometry') + -- Stub the font metric: getWidth in "pixels", scaled to ~0.15 units/char. + G.TILESIZE = 0.32 / 0.15 + G.LANG = {font = {squish = 1, FONTSCALE = 1, + FONT = {getWidth = function(_, s) return #s end}}} + local roomy = JCA._row_budget_for({T = {x = 17, w = 1}}) -- far right, wide gutter + local tight = JCA._row_budget_for({T = {x = 8.1, w = 1}}) -- just past the flip + ok(roomy > tight, 'a card with more room gets a wider budget', + ('roomy=%d tight=%d'):format(roomy, tight)) + ok(tight >= 20 and roomy <= 64, 'the budget stays within a sane clamp', + ('roomy=%d tight=%d'):format(roomy, tight)) + G.TILESIZE, G.LANG = nil, nil +end + +-- The widest real row is a clash against a long-named joker carrying a full +-- 34-char blurb: "Clashes - : " is ~64 chars and must wrap. +G.shop_jokers = {} +G.jokers = {cards = {{config = {center = {key = 'j_gluttenous_joker', set = 'Joker'}}}}} +JCA.register_clash('j_widetest', 'j_gluttenous_joker', string.rep('x', BLURB_MAX)) +local hovered = { + config = {center = {key = 'j_widetest', set = 'Joker'}}, + ability = {}, area = G.shop_jokers, +} +local rows = JCA._tooltip_rows(hovered) +local widest, saw_clash = 0, false +for _, row in ipairs(rows) do + local w = 0 + for _, seg in ipairs(row) do w = w + #((seg.config or {}).text or '') end + if w > widest then widest = w end + for _, seg in ipairs(row) do + if ((seg.config or {}).text or ''):find('Clashes') then saw_clash = true end + end +end +ok(saw_clash, 'the clash line is present to be measured') +ok(widest <= BUDGET, 'every advice row fits the budget', + ('widest row is %d chars (budget %d)'):format(widest, BUDGET)) +G.jokers, G.shop_jokers = nil, nil + -------------------------------------------------------------------------------- print(('\n%d passed, %d failed'):format(pass, fail)) os.exit(fail == 0 and 0 or 1)