182 lines
6.1 KiB
GDScript
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],
|
|
})
|