Hold the hover tooltip to the screen against the real font
test / test (push) Successful in 15s

The pixel-measured wrap passed the headless suite (which stubs text_width) but
failed three assertions under the real engine in the smoke harness:

- "Looking for:"/"Offers:" hints and the two-tone combo rows render at scale
  0.3 but the width budget -- and every other row -- is measured at 0.32, so a
  row that fit at 0.3 slipped over the budget once measured at 0.32 and clipped.
  Render those rows small as before, but bound their wrap at the 0.32 reference:
  a 0.3 row is narrower still, so it can only wrap early, never clip.
- wrap_measured word-split multi-word joker names, so a fusion's result read as
  "Fusion ready: Diamond" / "Bard ($12)" -- two jokers, not one. atomic() fuses
  a name or multi-word label into one unbreakable wrap token (spaces -> a \1
  sentinel, restored for measurement and output); applied to every joker name
  and tag label that flows through a wrapped row.

Smoke: 20/20 against the real Card:generate_UIBox_ability_table pipeline and the
real Fusion Jokers recipe table. Headless: 947 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-27 13:09:40 -07:00
parent a66faadc21
commit 65b423c77d
2 changed files with 47 additions and 18 deletions
+6
View File
@@ -7,6 +7,12 @@
Settings) instead of a button in the run HUD, which keeps the play screen Settings) instead of a button in the run HUD, which keeps the play screen
clear. Same overlay, same tabs; Mods > Combo Advisor is unchanged. clear. Same overlay, same tabs; Mods > Combo Advisor is unchanged.
### Fixed
- The Combo Advisor hover tooltip no longer runs off the edge of the screen:
every advice row is measured against the real font and wrapped to the card's
gutter, and a wrapped line never splits a joker name ("Diamond Bard" stays
whole rather than breaking across two rows).
### Fusion Jokers integration ### Fusion Jokers integration
- When [Fusion Jokers](https://github.com/wingedcatgirl/Fusion-Jokers) is - When [Fusion Jokers](https://github.com/wingedcatgirl/Fusion-Jokers) is
installed, hovering any joker that is a fusion component names the fusion it installed, hovering any joker that is a fusion component names the fusion it
+41 -18
View File
@@ -603,6 +603,16 @@ function JCA.row_width_budget()
return (type(room) == 'number' and room > 0 and room or 20) * ROW_WIDTH_FRACTION return (type(room) == 'number' and room > 0 and room or 20) * ROW_WIDTH_FRACTION
end end
-- A joker name or multi-word label must never wrap mid-phrase -- "Fusion ready:
-- Diamond" over one row and "Bard" over the next reads as two jokers. atomic()
-- fuses a phrase's spaces into a sentinel that %S+ tokenisation treats as one
-- word; wrap_measured restores them for both measurement and output. \1 never
-- occurs in localized joker text. ONLY apply to text that goes through
-- wrap_measured (text_row) -- a sentinel placed straight into a UIT node would
-- render as tofu (labelled_row's label node does NOT wrap, so it stays plain).
local NBSP = '\1'
local function atomic(s) return (tostring(s):gsub(' ', NBSP)) end
-- Word-wrap `str` to the measured budget, returning finished lines with the -- Word-wrap `str` to the measured budget, returning finished lines with the
-- hanging `indent` already applied to continuations. -- hanging `indent` already applied to continuations.
-- --
@@ -621,7 +631,9 @@ function JCA.wrap_measured(str, scale, budget, indent)
for word in tostring(str):gmatch('%S+') do for word in tostring(str):gmatch('%S+') do
local nxt = line and (line .. ' ' .. word) or word local nxt = line and (line .. ' ' .. word) or word
local limit = #lines == 0 and budget or budget - pad local limit = #lines == 0 and budget or budget - pad
if line and JCA.text_width(nxt, scale) > limit then -- Measure with sentinels restored to real spaces: an atomic() phrase is
-- one %S+ token but its true width is the width of the spaced text.
if line and JCA.text_width((nxt:gsub(NBSP, ' ')), scale) > limit then
lines[#lines + 1] = line lines[#lines + 1] = line
line = word line = word
else else
@@ -631,6 +643,7 @@ function JCA.wrap_measured(str, scale, budget, indent)
if line then lines[#lines + 1] = line end if line then lines[#lines + 1] = line end
if #lines == 0 then return {''} end if #lines == 0 then return {''} end
for i = 2, #lines do lines[i] = indent .. lines[i] end for i = 2, #lines do lines[i] = indent .. lines[i] end
for i = 1, #lines do lines[i] = (lines[i]:gsub(NBSP, ' ')) end
return lines return lines
end end
@@ -774,9 +787,15 @@ local function tooltip_rows(card)
-- body wraps beneath it, which keeps the colour split readable instead of -- body wraps beneath it, which keeps the colour split readable instead of
-- splitting a coloured phrase mid-word across rows. -- splitting a coloured phrase mid-word across rows.
local function labelled_row(label, label_colour, body) local function labelled_row(label, label_colour, body)
-- Render small so "name: reason" fits one row more often, but bound the
-- width at the SAME 0.32*S reference the budget and every other row are
-- measured against -- a row drawn at 0.3 is narrower still, so this can
-- only ever wrap early, never clip. (Measuring the wrap at 0.3 let a
-- body line that fit at 0.3 slip over the budget once measured at 0.32.)
local scale = 0.3 * S local scale = 0.3 * S
local ref = 0.32 * S
local budget = JCA.row_width_budget() local budget = JCA.row_width_budget()
if JCA.text_width(label .. body, scale) <= budget then if JCA.text_width(label .. body, ref) <= budget then
rows[#rows + 1] = { rows[#rows + 1] = {
{n = G.UIT.T, config = {text = label, colour = label_colour, scale = scale}}, {n = G.UIT.T, config = {text = label, colour = label_colour, scale = scale}},
{n = G.UIT.T, config = {text = body, {n = G.UIT.T, config = {text = body,
@@ -788,8 +807,8 @@ local function tooltip_rows(card)
text = label, colour = label_colour, scale = scale}}} text = label, colour = label_colour, scale = scale}}}
-- The label owns the line above, so EVERY body line is indented here, -- The label owns the line above, so EVERY body line is indented here,
-- not just continuations -- reserve the indent for all of them. -- not just continuations -- reserve the indent for all of them.
local pad = JCA.text_width(' ', scale) local pad = JCA.text_width(' ', ref)
for _, line in ipairs(JCA.wrap_measured(body, scale, budget - pad, '')) do for _, line in ipairs(JCA.wrap_measured(body, ref, budget - pad, '')) do
rows[#rows + 1] = {{n = G.UIT.T, config = {text = ' ' .. line, rows[#rows + 1] = {{n = G.UIT.T, config = {text = ' ' .. line,
colour = G.C.UI.TEXT_DARK, scale = scale}}} colour = G.C.UI.TEXT_DARK, scale = scale}}}
end end
@@ -807,10 +826,13 @@ local function tooltip_rows(card)
end end
if #labels > 0 then if #labels > 0 then
-- One sentence, measured-wrapped, instead of a guessed -- One sentence, measured-wrapped, instead of a guessed
-- character count: text_row already bounds the width. -- character count: text_row already bounds the width. Rendered at
-- the default scale (like the "No synergy" line above it) so its
-- wrap holds under the same budget every other row is measured by;
-- multi-word labels stay whole so "card enhancing" never splits.
local names = {} local names = {}
for _, l in ipairs(labels) do names[#names + 1] = l.name end for _, l in ipairs(labels) do names[#names + 1] = atomic(l.name) end
text_row(prefix .. table.concat(names, ', '), nil, 0.3 * S) text_row(prefix .. table.concat(names, ', '))
end end
end end
else else
@@ -827,7 +849,7 @@ local function tooltip_rows(card)
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].name end for i = shown + 1, #partners do rest[#rest + 1] = atomic(partners[i].name) end
text_row('also: ' .. table.concat(rest, ', '), G.C.UI.TEXT_DARK) text_row('also: ' .. table.concat(rest, ', '), G.C.UI.TEXT_DARK)
end end
if in_buy_area(card) and total >= JCA.config.threshold if in_buy_area(card) and total >= JCA.config.threshold
@@ -873,9 +895,9 @@ local function tooltip_rows(card)
local target, status, chain = JCA.copy_source_if_bought(key) local target, status, chain = JCA.copy_source_if_bought(key)
local via = '' local via = ''
for _, link in ipairs(chain) do for _, link in ipairs(chain) do
via = via .. name_of(link.config.center.key) .. ' -> ' via = via .. atomic(name_of(link.config.center.key)) .. ' -> '
end end
local tname = target and name_of(target.config.center.key) local tname = target and atomic(name_of(target.config.center.key))
if status == 'ok' then if status == 'ok' then
text_row('Will copy ' .. via .. tname .. ' as soon as it lands', G.C.GREEN) text_row('Will copy ' .. via .. tname .. ' as soon as it lands', G.C.GREEN)
elseif status == 'incompatible' then elseif status == 'incompatible' then
@@ -901,12 +923,13 @@ local function tooltip_rows(card)
local ok, plans = pcall(JCA.fusion_plans, card) local ok, plans = pcall(JCA.fusion_plans, card)
for i = 1, (ok and math.min(2, #plans) or 0) do for i = 1, (ok and math.min(2, #plans) or 0) do
local p = plans[i] local p = plans[i]
local rname = name_of(p.result) local rname = atomic(name_of(p.result))
local price = p.cost and (' ($' .. p.cost .. ')') or '' local price = p.cost and (' ($' .. p.cost .. ')') or ''
if not p.ready then if not p.ready then
local want = {} local want = {}
for _, m in ipairs(p.missing) do for _, m in ipairs(p.missing) do
want[#want + 1] = (m.n > 1 and (m.n .. 'x ') or '') .. name_of(m.key) want[#want + 1] = atomic(
(m.n > 1 and (m.n .. 'x ') or '') .. name_of(m.key))
end end
text_row(('Fuses into %s%s - still needs %s') text_row(('Fuses into %s%s - still needs %s')
:format(rname, price, table.concat(want, ', '))) :format(rname, price, table.concat(want, ', ')))
@@ -924,7 +947,7 @@ local function tooltip_rows(card)
end end
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_of(k) end for _, k in ipairs(p.loses) do names[#names + 1] = atomic(name_of(k)) end
text_row('Fusing drops combos with: ' .. table.concat(names, ', '), text_row('Fusing drops combos with: ' .. table.concat(names, ', '),
G.C.RED) G.C.RED)
end end
@@ -1018,9 +1041,9 @@ local function tooltip_rows(card)
-- Blueprint", which is the one answer that helps nobody. -- Blueprint", which is the one answer that helps nobody.
local via = '' local via = ''
for _, link in ipairs(chain) do for _, link in ipairs(chain) do
via = via .. name_of(link.config.center.key) .. ' -> ' via = via .. atomic(name_of(link.config.center.key)) .. ' -> '
end end
local tname = target and name_of(target.config.center.key) local tname = target and atomic(name_of(target.config.center.key))
if status == 'ok' then if status == 'ok' then
text_row('Copying ' .. via .. tname, G.C.GREEN) text_row('Copying ' .. via .. tname, G.C.GREEN)
@@ -1039,7 +1062,7 @@ local function tooltip_rows(card)
if status ~= 'ok' then if status ~= 'ok' then
local best = JCA.best_copy_target(card) local best = JCA.best_copy_target(card)
if best then if best then
local bname = name_of(best.config.center.key) local bname = atomic(name_of(best.config.center.key))
text_row(key == 'j_brainstorm' text_row(key == 'j_brainstorm'
and ('Fix: make ' .. bname .. ' your leftmost joker') and ('Fix: make ' .. bname .. ' your leftmost joker')
or ('Fix: slot this just left of ' .. bname), G.C.GREEN) or ('Fix: slot this just left of ' .. bname), G.C.GREEN)
@@ -1051,7 +1074,7 @@ local function tooltip_rows(card)
-- next blind — worth shouting about while there is still time to reorder. -- next blind — worth shouting about while there is still time to reorder.
local victim, safe = JCA.dagger_victim(card) local victim, safe = JCA.dagger_victim(card)
if victim then if victim then
local vname = name_of(victim.config.center.key) local vname = atomic(name_of(victim.config.center.key))
if safe then if safe then
text_row(vname .. ' is Eternal - the blade cannot eat it', G.C.GREEN) text_row(vname .. ' is Eternal - the blade cannot eat it', G.C.GREEN)
else else
@@ -1099,7 +1122,7 @@ local function consumable_rows(card)
end end
if #fed > 0 then if #fed > 0 then
local names = {} local names = {}
for _, f in ipairs(fed) do names[#names + 1] = f.name end for _, f in ipairs(fed) do names[#names + 1] = atomic(f.name) end
text_row('Feeds: ' .. table.concat(names, ', ')) text_row('Feeds: ' .. table.concat(names, ', '))
end end
-- A Standard Pack permanently adds playing cards, and Erosion is paid per -- A Standard Pack permanently adds playing cards, and Erosion is paid per