extends PanelContainer
## Vertical icon bar for group (registry room) navigation.
## Discord-like: Home button, group icons, Add button.
signal group_selected(group_id: String)
@onready var group_icons: VBoxContainer = %GroupIcons
@onready var home_btn: Button = %HomeButton
@onready var add_btn: Button = %AddButton
@onready var view_select: OptionButton = %ViewSelect
var _registries: Array = []
var _registry_meta: Dictionary = {} # reg_key -> meta dict
var _clusters: Array = [] # MI-computed clusters from graph
var _active_group: String = "" # "" = home
var _init_retries: int = 0
const MAX_INIT_RETRIES := 5
var _popup: PopupMenu = null
var _popup_reg_key: String = ""
var _settings_window: Window = null
var _avatar_picker: AvatarPicker = null
var _current_view: int = 0 # index into VIEW_MODES
## View modes: what drives the cluster icons
const VIEW_MODES: Array[Dictionary] = [
{"label": "Groups", "algo": {}},
{"label": "People", "algo": {"name": "people", "features": [{"type": "size", "weight": 1.0}], "threshold": 0.1, "minSize": 2}},
{"label": "Topics", "algo": {"name": "topics", "features": [{"type": "topics", "weight": 1.0}], "threshold": 0.3, "minSize": 2}},
{"label": "Category", "algo": {"name": "category", "features": [{"type": "category", "weight": 1.0}], "threshold": 0.1, "minSize": 2}},
{"label": "Activity", "algo": {"name": "activity", "features": [{"type": "activity", "weight": 1.0}], "threshold": 0.1, "minSize": 2}},
]
const ICON_SIZE := 48
const PILL_WIDTH := 4
const PILL_HEIGHT := 32
const COLOR_BG := Color(0.11, 0.11, 0.13)
const COLOR_PILL := Color(1.0, 1.0, 1.0)
const COLOR_PILL_DIM := Color(0.5, 0.5, 0.55)
const COLOR_HOME_BG := Color(0.3, 0.6, 0.4)
func _ready() -> void:
KeetService.discovery.registries_listed.connect(_on_registries_listed)
KeetService.discovery.registry_added.connect(func(_key, _label): KeetService.discovery.list_registries())
KeetService.discovery.registry_removed.connect(func(_key): KeetService.discovery.list_registries())
KeetService.graph_mgr.clusters_loaded.connect(_on_clusters_loaded)
KeetService.client.connected.connect(_on_connected)
home_btn.pressed.connect(_on_home_pressed)
add_btn.pressed.connect(_on_add_pressed)
# Populate view mode selector
for i: int in VIEW_MODES.size():
view_select.add_item(VIEW_MODES[i]["label"], i)
view_select.selected = 0
view_select.item_selected.connect(_on_view_mode_changed)
if KeetService.client.is_connected:
_on_connected()
func _on_connected() -> void:
KeetService.discovery.list_registries()
_recompute_clusters()
func _on_view_mode_changed(idx: int) -> void:
_current_view = idx
_clusters.clear()
# Reset to home — previous group selection doesn't apply to new view
_active_group = ""
KeetService.group_view_changed.emit("")
_rebuild_icons()
_recompute_clusters()
func _recompute_clusters() -> void:
var algo: Dictionary = VIEW_MODES[_current_view]["algo"]
if algo.is_empty():
# "Groups" mode — no clusters, just registries
_clusters.clear()
_rebuild_icons()
else:
KeetService.graph_mgr.compute(true, algo)
func _on_home_pressed() -> void:
_active_group = ""
_rebuild_icons()
group_selected.emit("")
KeetService.group_view_changed.emit("")
func _on_add_pressed() -> void:
var dialog := AcceptDialog.new()
dialog.title = "Add Group"
dialog.min_size = Vector2i(400, 220)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 6)
# Tab-like buttons: Create / Join
var mode_hbox := HBoxContainer.new()
var create_tab := Button.new()
create_tab.text = "Create New"
create_tab.toggle_mode = true
create_tab.button_pressed = true
create_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL
mode_hbox.add_child(create_tab)
var join_tab := Button.new()
join_tab.text = "Join Existing"
join_tab.toggle_mode = true
join_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL
mode_hbox.add_child(join_tab)
vbox.add_child(mode_hbox)
var sep := HSeparator.new()
vbox.add_child(sep)
# Create fields
var create_box := VBoxContainer.new()
var create_label := Label.new()
create_label.text = "Create a new group you control:"
create_label.add_theme_font_size_override("font_size", 12)
create_label.add_theme_color_override("font_color", Color(0.6, 0.65, 0.7))
create_box.add_child(create_label)
var create_name := LineEdit.new()
create_name.placeholder_text = "Group name..."
create_box.add_child(create_name)
vbox.add_child(create_box)
# Join fields (hidden by default)
var join_box := VBoxContainer.new()
join_box.visible = false
var join_label := Label.new()
join_label.text = "Paste a group invite link to join:"
join_label.add_theme_font_size_override("font_size", 12)
join_label.add_theme_color_override("font_color", Color(0.6, 0.65, 0.7))
join_box.add_child(join_label)
var invite_input := LineEdit.new()
invite_input.placeholder_text = "Invite link or token..."
join_box.add_child(invite_input)
vbox.add_child(join_box)
# Status label
var status := Label.new()
status.text = ""
status.add_theme_font_size_override("font_size", 12)
vbox.add_child(status)
# Tab switching
create_tab.pressed.connect(func():
create_tab.button_pressed = true
join_tab.button_pressed = false
create_box.visible = true
join_box.visible = false
status.text = ""
)
join_tab.pressed.connect(func():
join_tab.button_pressed = true
create_tab.button_pressed = false
create_box.visible = false
join_box.visible = true
status.text = ""
)
dialog.add_child(vbox)
dialog.confirmed.connect(func():
if create_tab.button_pressed:
var gname: String = create_name.text.strip_edges()
if gname == "":
status.text = "Enter a group name"
status.add_theme_color_override("font_color", Color(0.9, 0.4, 0.3))
return
status.text = "Creating..."
KeetService.discovery.create_registry(gname, func(result):
if result is Dictionary:
var new_id: String = result.get("roomId", "")
if new_id != "":
_active_group = new_id
group_selected.emit(new_id)
KeetService.group_view_changed.emit(new_id)
dialog.queue_free()
)
else:
var invite: String = invite_input.text.strip_edges()
if invite == "":
status.text = "Enter an invite link"
status.add_theme_color_override("font_color", Color(0.9, 0.4, 0.3))
return
status.text = "Joining..."
KeetService.discovery.join_registry(invite, func(result):
if result == null:
status.text = "Failed to join group"
status.add_theme_color_override("font_color", Color(0.9, 0.4, 0.3))
return
var joined_id: String = ""
if result is Dictionary:
joined_id = result.get("roomId", result.get("id", ""))
if joined_id != "":
_active_group = joined_id
_rebuild_icons()
group_selected.emit(joined_id)
KeetService.group_view_changed.emit(joined_id)
dialog.queue_free()
)
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()
create_name.grab_focus()
func _on_registries_listed(registries: Array) -> void:
_registries = registries
print("[groups] registries listed: %d (retry=%d)" % [registries.size(), _init_retries])
# Retry when registries empty during startup (registry rooms may not be loaded yet)
if registries.is_empty() and _init_retries < MAX_INIT_RETRIES:
_init_retries += 1
get_tree().create_timer(2.0).timeout.connect(
func(): KeetService.discovery.list_registries()
)
_rebuild_icons()
return
_init_retries = 0 # Reset on success
# Show icons immediately with letter fallbacks (don't wait for config)
_rebuild_icons()
if registries.is_empty():
return
# Fetch room config in background for avatars/descriptions
var counter := {"pending": registries.size()}
for reg: Variant in registries:
if not reg is Dictionary:
counter["pending"] -= 1
continue
var room_id: String = reg.get("roomId", "")
if room_id == "":
counter["pending"] -= 1
continue
KeetService.discovery.get_room_config(room_id, func(result, _rid = room_id):
if not is_instance_valid(self):
return
_registry_meta[_rid] = result if result is Dictionary else {}
counter["pending"] -= 1
if counter["pending"] <= 0:
_rebuild_icons()
)
func _on_clusters_loaded(clusters: Array) -> void:
_clusters = clusters
_rebuild_icons()
func _rebuild_icons() -> void:
var old := group_icons.get_children()
for child in old:
group_icons.remove_child(child)
child.queue_free()
# Update home button selected state
_style_home_button()
# Registry groups — only shown in Groups mode (view 0)
if _current_view == 0:
for reg: Variant in _registries:
if not reg is Dictionary:
continue
var room_id: String = reg.get("roomId", "")
if room_id == "":
continue
var config: Dictionary = _registry_meta.get(room_id, {})
var label: String = str(config.get("title", ""))
if label == "":
label = reg.get("title", "")
if label == "":
label = room_id.left(8)
var desc: String = str(config.get("description", ""))
var avatar_dict: Variant = config.get("avatar", null)
var avatar_b64: String = ""
if avatar_dict is Dictionary:
avatar_b64 = str(avatar_dict.get("small", ""))
var is_selected: bool = room_id == _active_group
var icon := _create_group_icon(room_id, label, desc, avatar_b64, is_selected)
group_icons.add_child(icon)
# Cluster icons — only shown when a clustering algo is selected
if _current_view > 0:
for cluster: Variant in _clusters:
if not cluster is Dictionary:
continue
var rooms: Array = cluster.get("rooms", [])
if rooms.size() < 1:
continue
var cluster_id: String = "cluster:%d" % cluster.get("id", 0)
var label: String = cluster.get("label", "")
if label == "":
label = "%d rooms" % rooms.size()
var is_selected: bool = cluster_id == _active_group
var c_icon := _create_group_icon(cluster_id, label, VIEW_MODES[_current_view]["label"], "", is_selected)
group_icons.add_child(c_icon)
func _style_home_button() -> void:
var is_home: bool = _active_group == ""
var style := StyleBoxFlat.new()
style.bg_color = COLOR_HOME_BG
# Selected: rounded rect; default: circle
var radius: int = 12 if is_home else 24
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
home_btn.add_theme_stylebox_override("normal", style)
var hover := style.duplicate()
hover.corner_radius_top_left = 12
hover.corner_radius_top_right = 12
hover.corner_radius_bottom_left = 12
hover.corner_radius_bottom_right = 12
home_btn.add_theme_stylebox_override("hover", hover)
func _create_group_icon(reg_key: String, label: String, desc: String, avatar_b64: String, is_selected: bool) -> HBoxContainer:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(60, ICON_SIZE + 4)
row.add_theme_constant_override("separation", 0)
# Pill indicator
var pill := ColorRect.new()
pill.custom_minimum_size = Vector2(PILL_WIDTH, PILL_HEIGHT)
pill.color = COLOR_PILL if is_selected else Color.TRANSPARENT
pill.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(pill)
# Spacer
var spacer := Control.new()
spacer.custom_minimum_size.x = 4
row.add_child(spacer)
# Icon button
var btn := Button.new()
btn.custom_minimum_size = Vector2(ICON_SIZE, ICON_SIZE)
btn.tooltip_text = label if desc == "" else "%s\n%s" % [label, desc]
# Shape: selected = rounded rect, default = circle
var radius: int = 12 if is_selected else 24
if avatar_b64 != "":
# Decode avatar
var img_data: PackedByteArray = Marshalls.base64_to_raw(avatar_b64)
var img := Image.new()
if img.load_png_from_buffer(img_data) == OK:
var tex := ImageTexture.create_from_image(img)
btn.icon = tex
btn.expand_icon = true
else:
# First letter with color from name hash
btn.text = label.left(1).to_upper() if label != "" else "?"
btn.add_theme_font_size_override("font_size", 18)
var style := StyleBoxFlat.new()
style.bg_color = color_from_name(label) if avatar_b64 == "" else Color(0.15, 0.15, 0.2)
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
btn.add_theme_stylebox_override("normal", style)
var hover_style := style.duplicate()
hover_style.corner_radius_top_left = 12
hover_style.corner_radius_top_right = 12
hover_style.corner_radius_bottom_left = 12
hover_style.corner_radius_bottom_right = 12
hover_style.bg_color = style.bg_color.lightened(0.15)
btn.add_theme_stylebox_override("hover", hover_style)
var pressed_style := style.duplicate()
pressed_style.corner_radius_top_left = 12
pressed_style.corner_radius_top_right = 12
pressed_style.corner_radius_bottom_left = 12
pressed_style.corner_radius_bottom_right = 12
btn.add_theme_stylebox_override("pressed", pressed_style)
btn.pressed.connect(func():
_active_group = reg_key
_rebuild_icons()
group_selected.emit(reg_key)
KeetService.group_view_changed.emit(reg_key)
)
# Right-click context menu
btn.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
_show_context_menu(reg_key, label)
)
row.add_child(btn)
# Hover pill effect
btn.mouse_entered.connect(func():
if not is_selected:
pill.color = COLOR_PILL_DIM
pill.custom_minimum_size.y = 16
)
btn.mouse_exited.connect(func():
if not is_selected:
pill.color = Color.TRANSPARENT
pill.custom_minimum_size.y = PILL_HEIGHT
)
return row
func _show_context_menu(reg_key: String, current_name: String) -> void:
if _popup != null:
_popup.queue_free()
_popup_reg_key = reg_key
_popup = PopupMenu.new()
_popup.add_item("Settings", 0)
_popup.add_item("Rename", 1)
_popup.add_separator()
_popup.add_item("Remove", 2)
_popup.id_pressed.connect(func(id: int):
match id:
0: _open_group_settings(reg_key)
1: _show_rename_dialog(reg_key, current_name)
2: _confirm_remove(reg_key, current_name)
_popup.queue_free()
_popup = null
)
get_tree().root.add_child(_popup)
_popup.popup(Rect2i(Vector2i(get_viewport().get_mouse_position()), Vector2i.ZERO))
func _open_group_settings(reg_key: String) -> void:
if _settings_window == null:
var scene: PackedScene = preload("res://scenes/group_settings.tscn")
_settings_window = scene.instantiate()
_settings_window.group_deleted.connect(_on_group_deleted)
get_tree().root.add_child(_settings_window)
_settings_window.open_for_group(reg_key)
func _on_group_deleted(reg_key: String) -> void:
if _active_group == reg_key:
_active_group = ""
group_selected.emit("")
KeetService.group_view_changed.emit("")
KeetService.discovery.list_registries()
func _show_rename_dialog(room_id: String, current_name: String) -> void:
var dialog := AcceptDialog.new()
dialog.title = "Rename Group"
dialog.min_size = Vector2i(350, 100)
var name_input := LineEdit.new()
name_input.text = current_name
name_input.placeholder_text = "Enter group name..."
dialog.add_child(name_input)
dialog.confirmed.connect(func():
var new_name: String = name_input.text.strip_edges()
if new_name != "":
KeetService.client.call_rpc("room.config.set", {
"roomId": room_id, "key": "title", "value": new_name
}, func(_result, _err):
KeetService.discovery.list_registries()
)
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()
name_input.grab_focus()
func _confirm_remove(reg_key: String, name: String) -> void:
var dialog := ConfirmationDialog.new()
dialog.title = "Remove Group"
dialog.dialog_text = "Remove '%s' from your groups?\n\nYou can rejoin later with an invite." % name
dialog.min_size = Vector2i(320, 100)
dialog.confirmed.connect(func():
KeetService.discovery.remove_registry(reg_key)
if _active_group == reg_key:
_active_group = ""
group_selected.emit("")
KeetService.group_view_changed.emit("")
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()
static func color_from_name(name: String) -> Color:
var h: int = name.hash()
var hue: float = fmod(absf(float(h)) * 0.618033988749895, 1.0)
var sat: float = 0.45 + fmod(absf(float(h >> 8)) * 0.1, 0.2)
var val: float = 0.55 + fmod(absf(float(h >> 16)) * 0.1, 0.15)
return Color.from_hsv(hue, sat, val)