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) <noreply@anthropic.com>
This commit is contained in:
@@ -581,6 +581,89 @@ local function wrap_names(partners, max_width, max_lines)
|
|||||||
return lines
|
return lines
|
||||||
end
|
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
|
-- Short theme nouns for the "Looking for:"/"Offers:" hints (engine roles
|
||||||
-- mult/chips/xmult are deliberately absent — they hint nothing specific).
|
-- mult/chips/xmult are deliberately absent — they hint nothing specific).
|
||||||
local TAG_LABEL = {
|
local TAG_LABEL = {
|
||||||
@@ -682,13 +765,37 @@ local function tooltip_rows(card)
|
|||||||
local S = JCA.config.touch_mode and 1.4 or 1
|
local S = JCA.config.touch_mode and 1.4 or 1
|
||||||
local key = card.config.center.key
|
local key = card.config.center.key
|
||||||
local partners, total = JCA.partners_for(card)
|
local partners, total = JCA.partners_for(card)
|
||||||
|
local budget = row_budget(card)
|
||||||
|
|
||||||
local rows = {}
|
local rows = {}
|
||||||
local function text_row(str, colour)
|
local function text_row(str, colour)
|
||||||
|
for _, line in ipairs(wrap_text(str, budget)) do
|
||||||
rows[#rows + 1] = {{n = G.UIT.T, config = {
|
rows[#rows + 1] = {{n = G.UIT.T, config = {
|
||||||
text = str, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S,
|
text = line, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S,
|
||||||
}}}
|
}}}
|
||||||
end
|
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 = 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
|
if #partners == 0 then
|
||||||
text_row(in_buy_area(card) and 'No synergy with your jokers.'
|
text_row(in_buy_area(card) and 'No synergy with your jokers.'
|
||||||
@@ -701,7 +808,7 @@ local function tooltip_rows(card)
|
|||||||
labels, prefix = tag_labels(entry.gives), 'Offers: '
|
labels, prefix = tag_labels(entry.gives), 'Offers: '
|
||||||
end
|
end
|
||||||
if #labels > 0 then
|
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 = {
|
rows[#rows + 1] = {{n = G.UIT.T, config = {
|
||||||
text = (i == 1 and prefix or ' ') .. l,
|
text = (i == 1 and prefix or ' ') .. l,
|
||||||
colour = G.C.UI.TEXT_DARK, scale = 0.3 * S,
|
colour = G.C.UI.TEXT_DARK, scale = 0.3 * S,
|
||||||
@@ -719,17 +826,12 @@ local function tooltip_rows(card)
|
|||||||
for i = 1, shown do
|
for i = 1, shown do
|
||||||
local p = partners[i]
|
local p = partners[i]
|
||||||
local reason = JCA.explain(key, p.key) or 'shared build theme'
|
local reason = JCA.explain(key, p.key) or 'shared build theme'
|
||||||
rows[#rows + 1] = {
|
seg_row(p.name .. ': ', G.C.RED, reason)
|
||||||
{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}},
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
if #partners > shown then
|
if #partners > shown then
|
||||||
local rest = {}
|
local rest = {}
|
||||||
for i = shown + 1, #partners do rest[#rest + 1] = partners[i] end
|
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)
|
text_row((i == 1 and 'also: ' or ' ') .. l, G.C.UI.TEXT_DARK)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -745,13 +847,8 @@ local function tooltip_rows(card)
|
|||||||
if owned ~= card and owned.config.center.set == 'Joker' then
|
if owned ~= card and owned.config.center.set == 'Joker' then
|
||||||
local warn = JCA.clash_blurb[pair_key(key, owned.config.center.key)]
|
local warn = JCA.clash_blurb[pair_key(key, owned.config.center.key)]
|
||||||
if warn then
|
if warn then
|
||||||
rows[#rows + 1] = {
|
seg_row('Clashes - ' .. name_of(owned.config.center.key) .. ': ',
|
||||||
{n = G.UIT.T, config = {
|
G.C.RED, warn)
|
||||||
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}},
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -833,9 +930,9 @@ local function tooltip_rows(card)
|
|||||||
if #p.loses > 0 then
|
if #p.loses > 0 then
|
||||||
local names = {}
|
local names = {}
|
||||||
for _, k in ipairs(p.loses) do names[#names + 1] = {name = name_of(k)} end
|
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('Fusing drops combos with:', G.C.RED)
|
||||||
text_row((li == 1 and 'Fusing drops combos with: ' or ' ') .. l,
|
for _, l in ipairs(wrap_names(names, budget - 2, 2)) do
|
||||||
G.C.RED)
|
text_row(' ' .. l, G.C.RED)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -981,6 +1078,7 @@ local function tooltip_rows(card)
|
|||||||
rows.name = 'Combo Advisor'
|
rows.name = 'Combo Advisor'
|
||||||
return rows
|
return rows
|
||||||
end
|
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
|
-- The consumable counterpart of tooltip_rows: names the owned jokers a
|
||||||
-- Planet/Tarot/Spectral card or its pack feeds. Quiet unless an owned joker
|
-- 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 S = JCA.config.touch_mode and 1.4 or 1
|
||||||
local fed, tag = JCA.consumable_partners(card)
|
local fed, tag = JCA.consumable_partners(card)
|
||||||
if not tag then return nil end
|
if not tag then return nil end
|
||||||
|
local budget = row_budget(card)
|
||||||
local rows = {}
|
local rows = {}
|
||||||
local function text_row(str, colour)
|
local function text_row(str, colour)
|
||||||
|
for _, line in ipairs(wrap_text(str, budget)) do
|
||||||
rows[#rows + 1] = {{n = G.UIT.T, config = {
|
rows[#rows + 1] = {{n = G.UIT.T, config = {
|
||||||
text = str, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S,
|
text = line, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S,
|
||||||
}}}
|
}}}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
if #fed > 0 then
|
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)
|
text_row((i == 1 and 'Feeds: ' or ' ') .. l)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1108,6 +1108,78 @@ build('Traps last page', function()
|
|||||||
G.FUNCS.jca_page_Traps{cycle_config = {current_option = 999}}
|
G.FUNCS.jca_page_Traps{cycle_config = {current_option = 999}}
|
||||||
end)
|
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 - <name>: <blurb>" 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))
|
print(('\n%d passed, %d failed'):format(pass, fail))
|
||||||
os.exit(fail == 0 and 0 or 1)
|
os.exit(fail == 0 and 0 or 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user