Files
Sylpheed/port/scripts/screen_view.gd
Sylpheed port agent 3d12498550 port: Godot draws an exported screen at its resting pose
P1. The project reads only `export/` -- the manifest, a screen's JSON and its
PNGs -- and draws every element at `rest`, in the export's own `paint_order`.

Three choices worth the words:

* `ExportTree` addresses screens by manifest NAME, never by path, and checks
  `format` on the manifest and on each screen before drawing. Textures are
  decoded from bytes at runtime rather than Godot-imported: `export/` is
  gitignored and regenerated wholesale, and a `.import` per sprite would be
  derived state next to derived state, invalidated on every re-export.

* One CanvasItem draws the whole screen. `paint_order` is already back-to-front,
  so honouring it is a loop; spreading it across sixteen nodes' z-indices would
  hide the one unresolved thing about that order -- the ties -- behind Godot's
  sibling rules.

* The screen renders into a SubViewport sized to the export's `design` rect.
  Capturing the window instead gave 1280x720 of screen minus a window manager's
  title bar: 1235x695. A gate that rescales that to compare against a 1280x720
  composite is measuring the compositor.

Nearest-neighbour filtering, because the export is a 1:1 copy of the disc's
texels, elements draw at up to 500 %, and it is what `ui_layout::blit` does --
so a filter difference cannot masquerade as a placement difference in the diff.

No keyframe interpolation and no focus state: both depend on constants that are
MEASURED rather than decoded (HANDOFF Q1, Q5), and a pixel-diff gate must not
have one of those inside it. P2 and P5.
2026-08-28 19:31:03 +00:00

153 lines
5.9 KiB
GDScript

# Draws one exported screen at its resting pose.
#
# P1 is static: every element is drawn at `rest`, the pose the screen holds
# once it has finished arriving (docs/FORMAT.md). Keyframe animation is P2 and
# is deliberately not here -- the keyframe time unit is measured rather than
# decoded, and this milestone must not depend on it.
#
# One CanvasItem draws the whole screen in `_draw`, rather than a node per
# element. The export's `paint_order` is already back-to-front, so honouring it
# is a loop; z-indexing sixteen nodes to reproduce the same order would be the
# same information expressed less directly, and would hide a tie behind Godot's
# own sibling rules.
class_name ScreenView
extends Node2D
## Skip `kind & 0x4` template instances that duplicate a plain element.
## docs/FORMAT.md: those are motion-trail ghosts and are not on screen at rest.
## The narrow form of the rule matters -- 174 elements on the disc carry the bit
## with no template to duplicate, and a blanket skip would erase them.
const KIND_TEMPLATE_INSTANCE := 0x4
var tree: ExportTree = null
var screen: Dictionary = {}
var textures: Dictionary = {}
var skipped: Array[String] = []
var drawn: Array[String] = []
## Which button is highlighted, by element id. P1 leaves it empty: initial focus
## was measured as unstable boot to boot (HANDOFF Q5) and picking one is an
## authored decision that belongs to P5.
var focused_id: String = ""
func load_screen(t: ExportTree, name: String) -> bool:
tree = t
screen = t.screen(name)
if screen.is_empty():
push_error(t.error)
return false
var design: Array = screen.get("design", [1280, 720])
# The export's coordinates are in this space and the viewport matches it, so
# a mismatch means the export is not what this project was built to draw.
var viewport := Vector2i(
ProjectSettings.get_setting("display/window/size/viewport_width"),
ProjectSettings.get_setting("display/window/size/viewport_height"))
if Vector2i(int(design[0]), int(design[1])) != viewport:
push_warning("screen %s is authored at %sx%s, viewport is %s" % [name, design[0], design[1], viewport])
_load_textures()
queue_redraw()
return true
func _load_textures() -> void:
textures.clear()
for element: Dictionary in screen.get("elements", []):
for key in ["sprite", "focus_sprite"]:
var rel: String = element.get(key, "")
if rel != "" and not textures.has(rel):
var tex := tree.texture(rel)
if tex == null:
push_warning(tree.error)
else:
textures[rel] = tex
# `tint_rgba` is RGBA and `fade_argb` is ARGB -- different byte orders, on
# purpose, because the disc spells them differently and a silent swap looks like
# an art bug rather than a parse bug. They multiply per channel.
static func modulate_of(pose: Dictionary) -> Color:
var tint := _rgba(pose.get("tint_rgba", "0xffffffff"))
var fade := _argb(pose.get("fade_argb", "0xffffffff"))
return Color(tint.r * fade.r, tint.g * fade.g, tint.b * fade.b, tint.a * fade.a)
static func _rgba(hex: String) -> Color:
var v := hex.hex_to_int()
return Color8((v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff)
static func _argb(hex: String) -> Color:
var v := hex.hex_to_int()
return Color8((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, (v >> 24) & 0xff)
## The drawn rectangle of an element at a pose.
##
## `pos` is the top-left at 1:1 and `pivot` is the anchor scale grows about, so
## the top-left moves by `-pivot*(s-1)` and the size is the natural size times
## `s`. At 100 % the pivot cancels, which is why it can be got wrong invisibly.
static func placement(pose: Dictionary, pivot: Vector2, natural: Vector2) -> Rect2:
var pos := _vec(pose.get("pos", [0, 0]))
var s := _vec(pose.get("scale", [100, 100])) / 100.0
return Rect2(pos - pivot * (s - Vector2.ONE), natural * s)
static func _vec(a: Array) -> Vector2:
return Vector2(float(a[0]), float(a[1]))
# An element is a ghost only when another element on the same screen carries the
# same id *without* the template bit -- the template it is a repeat of.
func _template_instance_ids() -> Dictionary:
var plain := {}
for element: Dictionary in screen.get("elements", []):
if int(String(element.get("kind_raw", "0x0")).hex_to_int()) & KIND_TEMPLATE_INSTANCE == 0:
plain[element.get("id", "")] = true
var ghosts := {}
for element: Dictionary in screen.get("elements", []):
var kind := int(String(element.get("kind_raw", "0x0")).hex_to_int())
if kind & KIND_TEMPLATE_INSTANCE != 0 and plain.has(element.get("id", "")):
ghosts[int(element.get("index", -1))] = true
return ghosts
func _draw() -> void:
if screen.is_empty():
return
var elements: Array = screen.get("elements", [])
var ghosts := _template_instance_ids()
skipped.clear()
drawn.clear()
for index: int in screen.get("paint_order", []):
var element: Dictionary = elements[index]
var id: String = element.get("id", "")
if ghosts.has(index):
skipped.append("%s (template instance)" % id)
continue
var pose: Dictionary = element.get("rest", {})
var colour := modulate_of(pose)
if colour.a <= 0.0:
skipped.append("%s (transparent at rest)" % id)
continue
var pivot := _vec(element.get("pivot", [0, 0]))
var rel: String = element.get("sprite", "")
if focused_id == id and element.get("focus_sprite", "") != "":
rel = element["focus_sprite"]
if rel != "":
var tex: Texture2D = textures.get(rel)
if tex == null:
skipped.append("%s (sprite failed to load)" % id)
continue
draw_texture_rect(tex, placement(pose, pivot, tex.get_size()), false, colour)
drawn.append(id)
elif element.get("role", "") == "primitive" and element.has("size"):
# A primitive has no texture; the quad is its declared size and its
# colour is the pose's own modulate.
draw_rect(placement(pose, pivot, _vec(element["size"])), colour, true)
drawn.append(id)
else:
# A .t32 element whose sprite the exporter could not produce. Saying
# so is the point -- a silently missing element looks like art.
skipped.append("%s (no sprite in the export)" % id)