class_name AvatarPicker
extends ConfirmationDialog
## Reusable avatar upload + crop dialog.
## Opens a FileDialog, loads image, center-crops to square, scales to target sizes.
signal avatar_selected(small_b64: String, large_b64: String)
const SMALL_SIZE := 48
const LARGE_SIZE := 128
var _preview: TextureRect
var _source_image: Image = null
var _file_dialog: FileDialog
func _init() -> void:
title = "Choose Avatar"
min_size = Vector2i(300, 340)
ok_button_text = "Apply"
get_ok_button().disabled = true
func _ready() -> void:
var vbox := VBoxContainer.new()
add_child(vbox)
var label := Label.new()
label.text = "Preview"
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", 13)
vbox.add_child(label)
# Center the preview
var center := CenterContainer.new()
center.custom_minimum_size.y = 140
vbox.add_child(center)
_preview = TextureRect.new()
_preview.custom_minimum_size = Vector2(128, 128)
_preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
center.add_child(_preview)
# Placeholder
var placeholder := Image.create(LARGE_SIZE, LARGE_SIZE, false, Image.FORMAT_RGBA8)
placeholder.fill(Color(0.25, 0.25, 0.3))
_preview.texture = ImageTexture.create_from_image(placeholder)
var pick_btn := Button.new()
pick_btn.text = "Browse..."
pick_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
pick_btn.pressed.connect(_open_file_dialog)
vbox.add_child(pick_btn)
var info := Label.new()
info.text = "PNG, JPG, or WebP. Will be cropped to square."
info.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
info.add_theme_font_size_override("font_size", 11)
info.add_theme_color_override("font_color", Color(0.5, 0.55, 0.6))
vbox.add_child(info)
confirmed.connect(_on_confirmed)
canceled.connect(func(): hide())
func _open_file_dialog() -> void:
if _file_dialog != null:
_file_dialog.queue_free()
_file_dialog = FileDialog.new()
_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
_file_dialog.filters = PackedStringArray(["*.png ; PNG", "*.jpg ; JPEG", "*.jpeg ; JPEG", "*.webp ; WebP"])
_file_dialog.min_size = Vector2i(500, 400)
_file_dialog.file_selected.connect(_on_file_selected)
add_child(_file_dialog)
_file_dialog.popup_centered()
func _on_file_selected(path: String) -> void:
var img := Image.new()
var err: Error = img.load(path)
if err != OK:
push_warning("AvatarPicker: failed to load image: %s" % path)
return
_source_image = _crop_square(img)
# Show preview at large size
var preview_img: Image = _source_image.duplicate()
preview_img.resize(LARGE_SIZE, LARGE_SIZE, Image.INTERPOLATE_LANCZOS)
_preview.texture = ImageTexture.create_from_image(preview_img)
get_ok_button().disabled = false
func _on_confirmed() -> void:
if _source_image == null:
return
var small_img: Image = _source_image.duplicate()
small_img.resize(SMALL_SIZE, SMALL_SIZE, Image.INTERPOLATE_LANCZOS)
var large_img: Image = _source_image.duplicate()
large_img.resize(LARGE_SIZE, LARGE_SIZE, Image.INTERPOLATE_LANCZOS)
var small_b64: String = Marshalls.raw_to_base64(small_img.save_png_to_buffer())
var large_b64: String = Marshalls.raw_to_base64(large_img.save_png_to_buffer())
avatar_selected.emit(small_b64, large_b64)
hide()
static func _crop_square(img: Image) -> Image:
var w: int = img.get_width()
var h: int = img.get_height()
if w == h:
return img
var side: int = mini(w, h)
var x: int = (w - side) / 2
var y: int = (h - side) / 2
return img.get_region(Rect2i(x, y, side, side))