extends VBoxContainer const Brand := preload("res://scripts/ui/medmind_style.gd") var main var search_input: LineEdit var system_filter: OptionButton var discipline_filter: OptionButton var sort_filter: OptionButton var status_label: Label var retry_button: Button var list_container: VBoxContainer var load_more_button: Button var debounce_timer: Timer var list_scroll: ScrollContainer var saved_scroll_position := 0 var pending_open_question_id := "" func _ready() -> void: size_flags_horizontal = Control.SIZE_EXPAND_FILL size_flags_vertical = Control.SIZE_EXPAND_FILL add_theme_constant_override("separation", 16) _build_filters() status_label = Label.new() status_label.add_theme_font_override("font", Brand.font) status_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) add_child(status_label) retry_button = Button.new() retry_button.text = "Retry" retry_button.visible = false retry_button.custom_minimum_size = Vector2(100, 42) retry_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN retry_button.pressed.connect(_retry) add_child(retry_button) list_scroll = ScrollContainer.new() list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL add_child(list_scroll) list_container = VBoxContainer.new() list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL list_container.add_theme_constant_override("separation", 10) list_scroll.add_child(list_container) load_more_button = Button.new() load_more_button.text = "Load more" load_more_button.custom_minimum_size = Vector2(0, 44) load_more_button.pressed.connect(func(): BookmarkService.load_bookmarks(false)) list_container.add_child(load_more_button) debounce_timer = Timer.new() debounce_timer.one_shot = true debounce_timer.wait_time = 0.35 debounce_timer.timeout.connect(_reload) add_child(debounce_timer) BookmarkService.list_changed.connect(_render) BookmarkService.list_failed.connect(_on_list_failed) BookmarkService.question_loaded.connect(_on_question_loaded) BookmarkService.question_failed.connect(_on_question_load_failed) UIThemeService.theme_changed.connect(func(_dark: bool): _render()) func _build_filters() -> void: var row := HFlowContainer.new() row.add_theme_constant_override("separation", 10) add_child(row) search_input = LineEdit.new() search_input.placeholder_text = "Search saved questions" search_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL search_input.custom_minimum_size = Vector2(260, 44) search_input.text_changed.connect(func(_value: String): debounce_timer.start()) row.add_child(search_input) system_filter = _add_filter(row, "All systems") discipline_filter = _add_filter(row, "All disciplines") sort_filter = _add_filter(row, "Newest", ["Oldest"]) func _add_filter(parent: Container, first: String, extras: Array = []) -> OptionButton: var control := OptionButton.new() control.add_item(first) for text in extras: control.add_item(str(text)) control.custom_minimum_size = Vector2(150, 44) control.item_selected.connect(func(_index: int): _reload()) parent.add_child(control) return control func on_show(_payload: Variant = null) -> void: _populate_categories() if not BookmarkService.remote_list_loaded: BookmarkService.load_bookmarks(true, _filters()) _render() func on_hide() -> void: if list_scroll != null: saved_scroll_position = int(list_scroll.scroll_vertical) func _populate_categories() -> void: _populate_filter(system_filter, "All systems", BookmarkService.available_systems) _populate_filter(discipline_filter, "All disciplines", BookmarkService.available_disciplines) func _populate_filter(control: OptionButton, first: String, values: Array) -> void: var selected_text := control.get_item_text(control.selected) if control.item_count > 0 else first control.clear() control.add_item(first) for value in values: if str(value) != "" and str(value) != "All Systems": control.add_item(str(value)) for index in range(control.item_count): if control.get_item_text(index) == selected_text: control.select(index) break func _filters() -> Dictionary: return { "query": search_input.text, "system": "" if system_filter.selected == 0 else system_filter.get_item_text(system_filter.selected), "discipline": "" if discipline_filter.selected == 0 else discipline_filter.get_item_text(discipline_filter.selected), "sort": "oldest" if sort_filter.selected == 1 else "newest" } func _reload() -> void: BookmarkService.load_bookmarks(true, _filters()) _render() func _render() -> void: if list_container == null: return if not visible: return _populate_categories() for child in list_container.get_children(): if child != load_more_button: child.queue_free() status_label.text = "Loading bookmarks..." if BookmarkService.list_loading else BookmarkService.list_error retry_button.visible = BookmarkService.list_error != "" if not BookmarkService.list_loading and BookmarkService.items.is_empty() and BookmarkService.list_error == "": status_label.text = "No saved questions match these filters." var insert_index := 0 for item in BookmarkService.items: var card := _bookmark_card(item) list_container.add_child(card) list_container.move_child(card, insert_index) insert_index += 1 load_more_button.visible = BookmarkService.has_more load_more_button.disabled = BookmarkService.list_loading if list_scroll != null: list_scroll.set_deferred("scroll_vertical", saved_scroll_position) func _bookmark_card(item: Dictionary) -> Control: var dark := UIThemeService.is_dark_mode() var card := PanelContainer.new() card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.TEAL)) var row := HFlowContainer.new() row.add_theme_constant_override("separation", 14) card.add_child(row) var content := VBoxContainer.new() content.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(content) var stem := Label.new() stem.text = str(item.get("stem", "")) stem.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART stem.add_theme_font_override("font", Brand.font) stem.add_theme_font_size_override("font_size", 16) content.add_child(stem) var metadata := Label.new() metadata.text = "%s | %s | Difficulty %d%s" % [ str(item.get("system", "")), str(item.get("discipline", "")), int(item.get("difficulty", 1)), " | Image" if bool(item.get("has_media", false)) else ""] metadata.add_theme_font_override("font", Brand.font) metadata.add_theme_font_size_override("font_size", 13) metadata.add_theme_color_override("font_color", Brand.muted_color(dark)) content.add_child(metadata) var open := Button.new() open.text = "Practice" open.custom_minimum_size = Vector2(105, 42) open.pressed.connect(_open_question.bind(str(item.get("question_id", "")))) row.add_child(open) var remove := Button.new() remove.icon = preload("res://assets/icons/lucide/bookmark.svg") remove.tooltip_text = "Remove bookmark" remove.custom_minimum_size = Vector2(44, 42) remove.pressed.connect(BookmarkService.set_bookmarked.bind(str(item.get("question_id", "")), false)) row.add_child(remove) return card func _open_question(question_id: String) -> void: pending_open_question_id = question_id status_label.text = "Loading question..." retry_button.visible = false BookmarkService.begin_practice(question_id) func _on_question_loaded(question: Dictionary) -> void: if not visible: return pending_open_question_id = "" if BlockSessionService.start_bookmark_question(question): main.show_screen("player") func _on_list_failed(message: String) -> void: status_label.text = message retry_button.visible = true func _on_question_load_failed(message: String) -> void: if not visible: return status_label.text = message retry_button.visible = true func _retry() -> void: retry_button.visible = false if pending_open_question_id != "": status_label.text = "Loading question..." BookmarkService.begin_practice(pending_open_question_id) else: _reload()