OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / texture_strip.gd

226 lines · gd ref:
extends VBoxContainer
## THE TEXTURE STRIP (hyper-games #94 P1 §9, first slice) — the bottom row of the
## BuildDock: search, pack filter, recent, and a horizontal run of thumbnails.
##
## A click makes a texture the player's CURRENT texture (map.set_current_tex), which
## every new brush face gets. U applies it to the selection and Y picks it from the
## aimed face; both live on the MAP, because the map owns the selection and every
## journal write — this panel never records anything (same rule as the outliner).
##
## Thumbnails are made LAZILY: a button starts blank and gets its 64 px preview
## only once it scrolls into view, a few per frame, so opening the strip does not
## decode 1196 textures. At most MAX_SHOWN buttons exist; a search narrows the rest.

const MAX_SHOWN := 240
const THUMB_PX := 64
const BUTTON_PX := 76
## Previews made per frame. Each is a texture decode + resize; four keeps a fast
## scroll smooth and fills a screenful in well under a second.
const THUMBS_PER_FRAME := 4
const RECENT_MAX := 12

var map: Node = null

var _search: LineEdit
var _pack: OptionButton
var _recent_btn: Button
var _current: Label
var _scroll: ScrollContainer
var _row: HBoxContainer
var _more: Label

var _search_text := ""
var _pack_id := ""            # "" = every pack
var _recent: PackedStringArray = []
var _shown: PackedStringArray = []
var _hidden := 0
var _buttons: Dictionary = {}  # id -> Button
var _pending: Array[String] = []  # ids whose button still has no preview


func setup(map_node: Node) -> void:
	map = map_node
	_build_ui()
	_rebuild()
	refresh_current()


func _build_ui() -> void:
	# HFlow, not HBox: on a narrow window the controls wrap to a second line instead of
	# forcing the whole bottom band wider than the gap between the side columns.
	var top := HFlowContainer.new()
	top.add_theme_constant_override("h_separation", 8)
	add_child(top)

	_search = LineEdit.new()
	_search.placeholder_text = "search textures…"
	_search.custom_minimum_size = Vector2(180, 0)
	_search.text_changed.connect(func(t: String) -> void: set_filter(t, _pack_id))
	top.add_child(_search)

	_pack = OptionButton.new()
	_pack.add_item("All packs")
	_pack.set_item_metadata(0, "")
	var packs: Array[String] = []
	for id in TexturePacks.ids():
		var p := id.get_slice("/", 0)
		if not packs.has(p):
			packs.append(p)
	for p in packs:
		_pack.add_item(p)
		_pack.set_item_metadata(_pack.item_count - 1, p)
	_pack.item_selected.connect(func(i: int) -> void: set_filter(_search_text, String(_pack.get_item_metadata(i))))
	top.add_child(_pack)

	_recent_btn = Button.new()
	_recent_btn.text = "Recent"
	_recent_btn.toggle_mode = true
	_recent_btn.toggled.connect(func(_on: bool) -> void: _rebuild())
	top.add_child(_recent_btn)

	_current = Label.new()
	_current.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	_current.clip_text = true
	top.add_child(_current)

	var hint := Label.new()
	hint.text = "click = current · U apply · Y pick"
	hint.modulate = Color(1, 1, 1, 0.6)
	top.add_child(hint)

	_scroll = ScrollContainer.new()
	_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
	_scroll.custom_minimum_size = Vector2(0, BUTTON_PX + 18)
	_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	add_child(_scroll)
	_row = HBoxContainer.new()
	_row.add_theme_constant_override("separation", 4)
	_scroll.add_child(_row)

	_more = Label.new()
	_more.modulate = Color(1, 1, 1, 0.6)
	add_child(_more)


## Narrow the strip. Either may be "" for "no constraint".
func set_filter(search: String, pack: String) -> void:
	_search_text = search
	_pack_id = pack
	if _search != null and _search.text != search:
		_search.text = search
	_rebuild()


func shown_ids() -> PackedStringArray:
	return _shown


## How many matches did not get a button (MAX_SHOWN); the strip says so.
func hidden_count() -> int:
	return _hidden


func recent() -> PackedStringArray:
	return _recent


## Make `id` the current texture — what a thumbnail click does.
func pick(id: String) -> void:
	if map != null and map.has_method("set_current_tex"):
		map.set_current_tex(id)
	_note_recent(id)


## The map calls this when the current texture changes from elsewhere (Y).
func refresh_current() -> void:
	var cur := ""
	if map != null and map.has_method("current_face_tex"):
		cur = String(map.current_face_tex().get("tex", ""))
	if _current != null:
		_current.text = "current: %s" % (cur if cur != "" else "—")
	for id: String in _buttons:
		var b: Button = _buttons[id]
		if is_instance_valid(b):
			b.button_pressed = id == cur
	_note_recent(cur)


func _note_recent(id: String) -> void:
	if id == "":
		return
	var i := _recent.find(id)
	if i == 0:
		return
	if i > 0:
		_recent.remove_at(i)
	_recent.insert(0, id)
	if _recent.size() > RECENT_MAX:
		_recent.resize(RECENT_MAX)


func _matches(id: String) -> bool:
	if _pack_id != "" and not id.begins_with(_pack_id + "/"):
		return false
	return _search_text == "" or id.to_lower().contains(_search_text.to_lower())


func _rebuild() -> void:
	var source: PackedStringArray = _recent if (_recent_btn != null and _recent_btn.button_pressed) \
		else TexturePacks.ids()
	_shown = PackedStringArray()
	_hidden = 0
	for id in source:
		if not _matches(id):
			continue
		if _shown.size() < MAX_SHOWN:
			_shown.append(id)
		else:
			_hidden += 1
	if _row == null:
		return
	for c in _row.get_children():
		c.queue_free()
	_buttons.clear()
	_pending.clear()
	var cur := ""
	if map != null and map.has_method("current_face_tex"):
		cur = String(map.current_face_tex().get("tex", ""))
	for id in _shown:
		var b := Button.new()
		b.custom_minimum_size = Vector2(BUTTON_PX, BUTTON_PX)
		b.expand_icon = true
		b.toggle_mode = true
		b.button_pressed = id == cur
		b.tooltip_text = id
		b.focus_mode = Control.FOCUS_NONE      # never eat Tab OR the UV arrows (BuildDock's rule)
		b.text = "" if TexturePacks.thumbnail_cached(id) else id.get_slice("/", 1).left(8)
		b.icon = TexturePacks.thumbnail_cached(id)
		b.pressed.connect(pick.bind(id))
		_row.add_child(b)
		_buttons[id] = b
		if b.icon == null:
			_pending.append(id)
	_more.text = ("+%d more — refine the search" % _hidden) if _hidden > 0 else ""
	_more.visible = _hidden > 0


func _process(_delta: float) -> void:
	if _pending.is_empty() or not is_visible_in_tree():
		return
	var view := _scroll.get_global_rect()
	var made := 0
	var i := 0
	while i < _pending.size() and made < THUMBS_PER_FRAME:
		var id: String = _pending[i]
		var b: Button = _buttons.get(id)
		if b == null or not is_instance_valid(b):
			_pending.remove_at(i)
			continue
		if not view.intersects(b.get_global_rect()):
			i += 1
			continue
		b.icon = TexturePacks.thumbnail(id, THUMB_PX)
		b.text = ""
		_pending.remove_at(i)
		made += 1