Files
aetherbound-guild/runtime/tools/ui_ink_check.gd
T

182 lines
6.1 KiB
GDScript

extends RefCounted
## M59 test-only ink checker. The shipped runtime never loads this script; the
## Market visual probe preloads it so the M58-C defect class (label wider than
## its slot, label ink reaching into a sibling icon, label leaving its owning
## module) fails the capture gate instead of reaching the owner.
##
## Width rules use `Font.get_string_size` at the label's theme size, which is
## the same authority the renderer uses. The checker reports; the probe decides
## how to fail. Nothing here mutates the scanned view.
const TOLERANCE := 1.5
static func scan(root: Control) -> Array:
var violations: Array = []
for node in _descendants(root):
var label := node as Label
if label == null or not label.is_visible_in_tree():
continue
_check_text_fit(label, violations)
_check_containment(label, violations)
_check_icon_overlap(label, violations)
return violations
static func self_test(host: Node) -> Array:
var container := Control.new()
container.name = "ABGInkSelfTest"
container.size = Vector2(200.0, 60.0)
host.add_child(container)
var wide := Label.new()
wide.name = "SelfTestWideLabel"
wide.text = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
wide.add_theme_font_override("font", load("res://assets/fonts/NotoSansSC.ttf"))
wide.add_theme_font_size_override("font_size", 20)
wide.clip_text = true
wide.position = Vector2(0.0, 0.0)
wide.size = Vector2(40.0, 20.0)
container.add_child(wide)
var icon := Control.new()
icon.name = "SelfTestIcon"
icon.position = Vector2(20.0, 0.0)
icon.size = Vector2(30.0, 30.0)
container.add_child(icon)
var outside := Label.new()
outside.name = "SelfTestOutsideLabel"
outside.text = "ok"
outside.add_theme_font_override("font", load("res://assets/fonts/NotoSansSC.ttf"))
outside.add_theme_font_size_override("font_size", 20)
outside.position = Vector2(190.0, 0.0)
outside.size = Vector2(60.0, 20.0)
container.add_child(outside)
var violations := scan(container)
container.queue_free()
return violations
static func _descendants(root: Control) -> Array:
var out: Array = []
var stack: Array = [root]
while not stack.is_empty():
var current: Node = stack.pop_back()
for child in current.get_children():
out.append(child)
stack.append(child)
return out
static func _check_text_fit(label: Label, violations: Array) -> void:
if label.text.is_empty():
return
var font := label.get_theme_font("font")
if font == null:
return
var font_size := label.get_theme_font_size("font_size")
if label.autowrap_mode == TextServer.AUTOWRAP_OFF:
var wanted := font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x
if wanted > label.size.x + TOLERANCE:
violations.append({
"kind": "slot_overflow",
"node": String(label.name),
"path": String(label.get_path()),
"detail": "text=%.1f slot=%.1f font=%d" % [wanted, label.size.x, font_size],
})
else:
var wrapped := font.get_multiline_string_size(
label.text, HORIZONTAL_ALIGNMENT_LEFT, maxf(label.size.x, 1.0), font_size
)
# The box is the authority: fully wrapped text taller than the label is
# truncation, and a max-lines cap that still fits the box is not.
var allowed := label.size.y
if wrapped.y > allowed + TOLERANCE:
violations.append({
"kind": "vertical_overflow",
"node": String(label.name),
"path": String(label.get_path()),
"detail": "wrapped=%.1f allowed=%.1f font=%d" % [wrapped.y, allowed, font_size],
})
static func _check_containment(label: Label, violations: Array) -> void:
var parent := label.get_parent() as Control
if parent == null:
return
var rect := label.get_global_rect()
var owner := parent.get_global_rect()
if (
rect.position.x < owner.position.x - TOLERANCE
or rect.position.y < owner.position.y - TOLERANCE
or rect.end.x > owner.end.x + TOLERANCE
or rect.end.y > owner.end.y + TOLERANCE
):
violations.append({
"kind": "outside_owner",
"node": String(label.name),
"path": String(label.get_path()),
"detail": "label=%s owner=%s" % [rect, owner],
})
static func ink_rect(label: Label) -> Rect2:
# The rect of a centered label spans the whole slot, so comparing rects
# against a sibling icon produces false positives on accepted command
# plates. Measure the box the glyphs actually occupy instead.
var rect := label.get_global_rect()
var font := label.get_theme_font("font")
if font == null or label.text.is_empty():
return rect
var font_size := label.get_theme_font_size("font_size")
var text_size: Vector2
if label.autowrap_mode == TextServer.AUTOWRAP_OFF:
text_size = font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
else:
text_size = font.get_multiline_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x, font_size)
var x := rect.position.x
match label.horizontal_alignment:
HORIZONTAL_ALIGNMENT_CENTER:
x = rect.position.x + (rect.size.x - text_size.x) * 0.5
HORIZONTAL_ALIGNMENT_RIGHT:
x = rect.end.x - text_size.x
var y := rect.position.y
match label.vertical_alignment:
VERTICAL_ALIGNMENT_CENTER:
y = rect.position.y + (rect.size.y - text_size.y) * 0.5
VERTICAL_ALIGNMENT_BOTTOM:
y = rect.end.y - text_size.y
return Rect2(
Vector2(x, y),
Vector2(minf(text_size.x, rect.size.x), minf(text_size.y, rect.size.y))
)
static func _check_icon_overlap(label: Label, violations: Array) -> void:
var parent := label.get_parent()
if parent == null:
return
var rect := ink_rect(label)
for sibling in parent.get_children():
if sibling == label:
continue
var control := sibling as Control
if control == null or not control.is_visible_in_tree():
continue
if "Icon" not in String(control.name):
continue
# A stacked plate (icon above its caption) shares horizontal extent with
# its label by design; the defect class is a glyph drawn over the icon,
# which is when the icon's centre falls inside the label's ink box.
var icon_rect := control.get_global_rect()
if rect.has_point(icon_rect.get_center()):
violations.append({
"kind": "icon_overlap",
"node": String(label.name),
"path": String(label.get_path()),
"detail": "label_ink=%s icon=%s(%s)" % [rect, control.name, icon_rect],
})