Files
step3/scripts/ui/question_player.gd
T
2026-07-30 04:26:18 -05:00

3815 lines
149 KiB
GDScript

extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
const LABS_LIGHT_ICON := preload("res://assets/icons/medmind/labs-light.svg")
const REPORT_QUESTION_DARK_ICON := preload("res://assets/icons/medmind/report-question-dark.svg")
const REPORT_QUESTION_LIGHT_ICON := preload("res://assets/icons/medmind/report-question-light.svg")
const FLAG_DARK_ICON := preload("res://assets/icons/medmind/flag-dark.svg")
const FLAG_LIGHT_ICON := preload("res://assets/icons/medmind/flag-light.svg")
const BADGE_CIRCLE_OUTLINE_ICON := preload("res://assets/icons/medmind/badge-circle-outline.svg")
const BADGE_CIRCLE_FILLED_ICON := preload("res://assets/icons/medmind/badge-circle-filled.svg")
const COIN_ICON := preload("res://assets/icons/lucide/star.svg")
const FEEDBACK_FADE_SECONDS := 0.45
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
const TEXT_SCALE_MIN := 0.85
const TEXT_SCALE_MAX := 1.35
const TEXT_SCALE_STEP := 0.1
const REPORT_DETAILS_MIN := 10
const REPORT_DETAILS_MAX := 2000
const MEDIA_ZOOM_MIN := 0.25
const MEDIA_ZOOM_MAX := 4.0
const MEDIA_ZOOM_FACTOR := 1.25
const REPORT_CATEGORIES := [
{"id": "clarity", "label": "Clarity"},
{"id": "medical_accuracy", "label": "Medical accuracy"},
{"id": "answer_explanation", "label": "Answer / explanation"},
{"id": "formatting_media", "label": "Formatting / media"},
{"id": "technical_issue", "label": "Technical issue"},
{"id": "other", "label": "Other"}
]
var main: Control
var theme_service
var choice_buttons: Array[Button] = []
var choice_rows: Array[VBoxContainer] = []
var choice_percent_labels: Array[Label] = []
var choice_top_labels: Array[RichTextLabel] = []
var choice_followup_panels: Array[PanelContainer] = []
var choice_followup_contents: Array[VBoxContainer] = []
var eliminated: Array = []
var locked_wrong_choice_ids: Array = []
var followup_states := {}
var followup_attempt_order: Array = []
var active_followup_choice_id := ""
var followup_required := false
var followup_answered := false
var flagged := false
var selected_choice := ""
var current_answer := {}
var question_highlights := {}
var explanation_highlights := {}
var reported_question_ids := []
var stem_plain_text := ""
var explanation_plain_text := ""
var highlight_drag_source := ""
var timer_color_state := ""
var pending_correct_advance := false
var question_text_scale := 1.0
var presented_choices: Array = []
var awaiting_next_question := false
var simulation_submit_pending := false
var progress_button: Button
var progress_bar: ProgressBar
var progress_percent_label: Label
var bottom_progress_label: Label
var bottom_progress_bar: ProgressBar
var header_wordmark_label: Label
var category_label: Label
var timer_label: Label
var timer_caption_label: Label
var stem_label: RichTextLabel
var lead_label: Label
var explanation_label: RichTextLabel
var references_container: VBoxContainer
var correct_review_panel: PanelContainer
var teaching_cards: HFlowContainer
var review_followups_section: VBoxContainer
var review_followups_content: VBoxContainer
var review_followup_states: Array = []
var question_scroll: ScrollContainer
var question_elo_label: Label
var next_button: Button
var skip_button: Button
var text_smaller_button: Button
var text_larger_button: Button
var labs_button: Button
var calculator_button: Button
var exit_button: Button
var clear_highlight_button: Button
var flag_button: Button
var report_button: Button
var notes_button: Button
var flashcard_button: Button
var flashcard_prefill_dialog: Control
var bookmark_button: Button
var app_feedback_button: Button
var theme_button: Button
var credits_badge: PanelContainer
var question_toolbar: HFlowContainer
var header_stack: VBoxContainer
var header_row: HBoxContainer
var header_brand: HBoxContainer
var header_toolbar_host: HBoxContainer
var nav_palette: GridContainer
var navigator_popup: PopupPanel
var question_card: PanelContainer
var question_panel: PanelContainer
var actions: HBoxContainer
var timer_footer: HBoxContainer
var question_content: VBoxContainer
var media_container: VBoxContainer
var timer_bar: ProgressBar
var choice_badges: Array[Label] = []
var choice_badge_textures: Array[TextureRect] = []
var choice_selected_icons: Array[TextureRect] = []
var feedback_fog: ColorRect
var labs_overlay: Control
var labs_panel: PanelContainer
var labs_content: VBoxContainer
var calculator_panel
var report_overlay: Control
var report_panel: PanelContainer
var report_form_content: VBoxContainer
var report_success_content: VBoxContainer
var report_category_group: ButtonGroup
var report_category_buttons: Array[CheckBox] = []
var report_details: TextEdit
var report_char_count: Label
var report_error_label: Label
var report_submit_button: Button
var report_cancel_button: Button
var report_success_label: Label
var report_is_submitting := false
var report_question_id := ""
var media_requests: Array[HTTPRequest] = []
var question_media_items: Array = []
var media_viewer_overlay: Control
var media_viewer_background: ColorRect
var media_viewer_scroll: ScrollContainer
var media_viewer_center: CenterContainer
var media_viewer_texture: TextureRect
var media_viewer_position_label: Label
var media_viewer_zoom_label: Label
var media_viewer_previous_button: Button
var media_viewer_next_button: Button
var media_viewer_index := -1
var media_viewer_zoom := 1.0
var media_viewer_dragging := false
var media_viewer_drag_position := Vector2.ZERO
var red_fog_time := 0.0
var green_fog_time := 0.0
var correct_advance_delay := 0.0
var shake_time := 0.0
var shake_origin := Vector2.ZERO
var untimed_elapsed_start_msec := 0
func _ready() -> void:
theme_service = get_node("/root/UIThemeService")
question_text_scale = _synced_question_text_scale()
if not AccountProfileService.profile_changed.is_connected(_on_account_profile_changed):
AccountProfileService.profile_changed.connect(_on_account_profile_changed)
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 10)
if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated):
QuestionBankService.choice_percentages_updated.connect(_on_choice_percentages_updated)
if not QuestionBankService.question_rating_updated.is_connected(_on_question_rating_updated):
QuestionBankService.question_rating_updated.connect(_on_question_rating_updated)
if not QuestionBankService.question_skipped.is_connected(_on_question_skipped):
QuestionBankService.question_skipped.connect(_on_question_skipped)
if not QuestionBankService.question_report_submitted.is_connected(_on_question_report_submitted):
QuestionBankService.question_report_submitted.connect(_on_question_report_submitted)
if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed):
QuestionBankService.question_report_failed.connect(_on_question_report_failed)
if not DynamicQuestionService.question_loaded.is_connected(_on_dynamic_question_loaded):
DynamicQuestionService.question_loaded.connect(_on_dynamic_question_loaded)
if not DynamicQuestionService.request_failed.is_connected(_on_dynamic_question_failed):
DynamicQuestionService.request_failed.connect(_on_dynamic_question_failed)
if not DynamicQuestionService.report_submitted.is_connected(_on_question_report_submitted):
DynamicQuestionService.report_submitted.connect(_on_question_report_submitted)
if not DynamicQuestionService.report_failed.is_connected(_on_question_report_failed):
DynamicQuestionService.report_failed.connect(_on_question_report_failed)
if not QuestionBankService.questions_changed.is_connected(_on_random_question_changed):
QuestionBankService.questions_changed.connect(_on_random_question_changed)
if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed):
QuestionBankService.questions_load_failed.connect(_on_random_question_failed)
if not ExamSimulationService.state_changed.is_connected(_on_simulation_state_changed):
ExamSimulationService.state_changed.connect(_on_simulation_state_changed)
if not ExamSimulationService.request_failed.is_connected(_on_simulation_request_failed):
ExamSimulationService.request_failed.connect(_on_simulation_request_failed)
if not BookmarkService.status_changed.is_connected(_on_bookmark_status_changed):
BookmarkService.status_changed.connect(_on_bookmark_status_changed)
if not BookmarkService.question_loaded.is_connected(_on_bookmark_question_loaded):
BookmarkService.question_loaded.connect(_on_bookmark_question_loaded)
if not BookmarkService.question_failed.is_connected(_on_bookmark_question_failed):
BookmarkService.question_failed.connect(_on_bookmark_question_failed)
if not ReviewQueueService.question_loaded.is_connected(_on_review_question_loaded):
ReviewQueueService.question_loaded.connect(_on_review_question_loaded)
if not ReviewQueueService.question_failed.is_connected(_on_review_question_failed):
ReviewQueueService.question_failed.connect(_on_review_question_failed)
if not ReviewQueueService.pass_completed.is_connected(_on_review_pass_completed):
ReviewQueueService.pass_completed.connect(_on_review_pass_completed)
question_card = PanelContainer.new()
question_card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
question_card.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(question_card)
var card_margin := MarginContainer.new()
card_margin.add_theme_constant_override("margin_left", 18)
card_margin.add_theme_constant_override("margin_right", 18)
card_margin.add_theme_constant_override("margin_top", 14)
card_margin.add_theme_constant_override("margin_bottom", 12)
question_card.add_child(card_margin)
var card_content := VBoxContainer.new()
card_content.add_theme_constant_override("separation", 10)
card_margin.add_child(card_content)
header_stack = VBoxContainer.new()
header_stack.add_theme_constant_override("separation", 8)
card_content.add_child(header_stack)
header_brand = HBoxContainer.new()
header_brand.add_theme_constant_override("separation", 8)
header_stack.add_child(header_brand)
var header_brand_mark := TextureRect.new()
header_brand_mark.texture = Brand.ranaze_mark_texture()
header_brand_mark.custom_minimum_size = Vector2(28, 28)
header_brand_mark.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
header_brand_mark.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
header_brand.add_child(header_brand_mark)
header_wordmark_label = Label.new()
header_wordmark_label.text = "RanaZe"
header_wordmark_label.add_theme_font_override("font", Brand.font)
header_wordmark_label.add_theme_font_size_override("font_size", 18)
header_brand.add_child(header_wordmark_label)
var header_brand_spacer := Control.new()
header_brand_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header_brand.add_child(header_brand_spacer)
header_row = HBoxContainer.new()
header_row.add_theme_constant_override("separation", 16)
header_stack.add_child(header_row)
var progress_group := VBoxContainer.new()
progress_group.add_theme_constant_override("separation", 2)
progress_group.size_flags_vertical = Control.SIZE_SHRINK_CENTER
header_row.add_child(progress_group)
progress_button = Button.new()
progress_button.flat = true
progress_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
progress_button.custom_minimum_size = Vector2(126, 20)
progress_button.tooltip_text = "Open question navigator"
_set_scaled_button_font(progress_button, 14)
progress_button.pressed.connect(_toggle_question_navigator)
progress_group.add_child(progress_button)
var progress_bar_row := HBoxContainer.new()
progress_bar_row.add_theme_constant_override("separation", 8)
progress_group.add_child(progress_bar_row)
progress_bar = ProgressBar.new()
progress_bar.show_percentage = false
progress_bar.min_value = 0.0
progress_bar.max_value = 100.0
progress_bar.custom_minimum_size = Vector2(120, 6)
progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
progress_bar_row.add_child(progress_bar)
progress_percent_label = Label.new()
_set_scaled_label(progress_percent_label, 13, Brand.SLATE)
progress_bar_row.add_child(progress_percent_label)
timer_label = Label.new()
_set_scaled_label(timer_label, 14, Brand.SLATE)
timer_label.visible = false
timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
question_toolbar = HFlowContainer.new()
question_toolbar.add_theme_constant_override("h_separation", 8)
question_toolbar.add_theme_constant_override("v_separation", 8)
question_toolbar.alignment = FlowContainer.ALIGNMENT_END
# All the underlying buttons are constructed up front (same as before) so every
# other place in this file that mutates them by reference - theming, visibility,
# tooltips, badge state - keeps working unmodified. Only *where* each one is
# parented (directly in question_toolbar vs. reparented into a popup) changes.
theme_button = Button.new()
theme_button.custom_minimum_size = Vector2(44, 44)
theme_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
theme_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
theme_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
theme_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
theme_button.focus_mode = Control.FOCUS_NONE
UISoundService.set_button_sound(theme_button, "toggle")
theme_button.pressed.connect(theme_service.toggle_theme)
_refresh_theme_button()
text_smaller_button = Button.new()
text_smaller_button.text = "A-"
text_smaller_button.tooltip_text = "Decrease text size"
text_smaller_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(text_smaller_button, Style.CYAN)
UISoundService.set_button_sound(text_smaller_button, "toggle")
text_smaller_button.pressed.connect(func(): _adjust_question_text_scale(-TEXT_SCALE_STEP))
text_larger_button = Button.new()
text_larger_button.text = "A+"
text_larger_button.tooltip_text = "Increase text size"
text_larger_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(text_larger_button, Style.CYAN)
UISoundService.set_button_sound(text_larger_button, "toggle")
text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP))
exit_button = Button.new()
exit_button.name = "ExitButton"
exit_button.tooltip_text = "Return to main area"
exit_button.icon = preload("res://assets/icons/lucide/house.svg")
exit_button.custom_minimum_size = Vector2(44, 44)
exit_button.add_theme_constant_override("icon_max_width", 22)
exit_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
exit_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
exit_button.focus_mode = Control.FOCUS_NONE
UISoundService.set_button_sound(exit_button, "back")
exit_button.pressed.connect(func(): main.exit_current_run())
calculator_button = Button.new()
calculator_button.name = "CalculatorButton"
calculator_button.tooltip_text = "Open calculator"
calculator_button.icon = preload("res://assets/icons/lucide/calculator.svg")
calculator_button.custom_minimum_size = Vector2(44, 44)
calculator_button.add_theme_constant_override("icon_max_width", 22)
calculator_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
calculator_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
calculator_button.focus_mode = Control.FOCUS_NONE
UISoundService.set_button_sound(calculator_button, "open")
calculator_button.pressed.connect(_toggle_calculator)
labs_button = Button.new()
labs_button.tooltip_text = "Review lab results"
labs_button.custom_minimum_size = Vector2(44, 44)
UISoundService.set_button_sound(labs_button, "open")
labs_button.pressed.connect(_show_labs_modal)
clear_highlight_button = Button.new()
clear_highlight_button.tooltip_text = "Clear highlights"
clear_highlight_button.custom_minimum_size = Vector2(44, 44)
UISoundService.set_button_sound(clear_highlight_button, "eliminate")
clear_highlight_button.pressed.connect(_clear_current_highlights)
flag_button = Button.new()
flag_button.toggle_mode = true
flag_button.tooltip_text = "Flag question"
flag_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(flag_button, Style.AMBER)
UISoundService.set_button_sound(flag_button, "toggle")
flag_button.toggled.connect(func(value: bool):
flagged = value
_refresh_toolbar_state()
if BlockSessionService.session_kind == "simulation":
_save_simulation_current_response()
)
flashcard_button = Button.new()
flashcard_button.tooltip_text = "Save as flashcard (available once answered)"
flashcard_button.icon = preload("res://assets/icons/lucide/clipboard-plus.svg")
flashcard_button.custom_minimum_size = Vector2(44, 44)
flashcard_button.add_theme_constant_override("icon_max_width", 22)
flashcard_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
flashcard_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
flashcard_button.focus_mode = Control.FOCUS_NONE
flashcard_button.disabled = true
UISoundService.set_button_sound(flashcard_button, "toggle")
flashcard_button.pressed.connect(_toggle_flashcard_current_question)
flashcard_prefill_dialog = FlashcardPrefillDialog.new()
flashcard_prefill_dialog.name = "FlashcardPrefillDialog"
add_child(flashcard_prefill_dialog)
bookmark_button = Button.new()
bookmark_button.tooltip_text = "Save question"
bookmark_button.icon = preload("res://assets/icons/lucide/bookmark.svg")
bookmark_button.custom_minimum_size = Vector2(44, 44)
bookmark_button.add_theme_constant_override("icon_max_width", 22)
bookmark_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
bookmark_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
bookmark_button.focus_mode = Control.FOCUS_NONE
UISoundService.set_button_sound(bookmark_button, "toggle")
bookmark_button.pressed.connect(_toggle_bookmark_current_question)
report_button = Button.new()
report_button.tooltip_text = "Report question"
report_button.custom_minimum_size = Vector2(44, 44)
UISoundService.set_button_sound(report_button, "open")
report_button.pressed.connect(_toggle_report_current_question)
notes_button = Button.new()
notes_button.tooltip_text = "Open notes"
notes_button.icon = preload("res://assets/icons/lucide/notebook-pen.svg")
notes_button.custom_minimum_size = Vector2(44, 44)
notes_button.add_theme_constant_override("icon_max_width", 22)
notes_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
notes_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
notes_button.focus_mode = Control.FOCUS_NONE
UISoundService.set_button_sound(notes_button, "open")
notes_button.pressed.connect(func(): main.open_notes_dialog())
app_feedback_button = Button.new()
app_feedback_button.text = "Feedback"
app_feedback_button.tooltip_text = "Send general feedback or report a bug"
app_feedback_button.custom_minimum_size = Vector2(92, 44)
Style.style_chip(app_feedback_button, Style.CYAN)
app_feedback_button.pressed.connect(func(): main.open_feedback_dialog())
credits_badge = CreditsBadge.new()
# Every action is directly visible - no "More" overflow or popups hiding
# controls behind an extra click. No text labels either (their varying widths
# broke even spacing when a button hid itself) - each button's existing
# tooltip_text carries the label as hover text instead.
question_toolbar.add_child(exit_button)
question_toolbar.add_child(text_smaller_button)
question_toolbar.add_child(text_larger_button)
question_toolbar.add_child(calculator_button)
question_toolbar.add_child(labs_button)
question_toolbar.add_child(clear_highlight_button)
question_toolbar.add_child(flag_button)
question_toolbar.add_child(flashcard_button)
question_toolbar.add_child(bookmark_button)
question_toolbar.add_child(report_button)
question_toolbar.add_child(notes_button)
question_toolbar.add_child(theme_button)
question_toolbar.add_child(app_feedback_button)
question_toolbar.add_child(credits_badge)
header_toolbar_host = HBoxContainer.new()
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
header_toolbar_host.add_child(question_toolbar)
header_brand.add_child(header_toolbar_host)
navigator_popup = PopupPanel.new()
navigator_popup.name = "QuestionNavigator"
navigator_popup.min_size = Vector2i(340, 180)
add_child(navigator_popup)
var navigator_margin := MarginContainer.new()
navigator_margin.add_theme_constant_override("margin_left", 14)
navigator_margin.add_theme_constant_override("margin_right", 14)
navigator_margin.add_theme_constant_override("margin_top", 14)
navigator_margin.add_theme_constant_override("margin_bottom", 14)
navigator_popup.add_child(navigator_margin)
var navigator_content := VBoxContainer.new()
navigator_content.add_theme_constant_override("separation", 10)
navigator_margin.add_child(navigator_content)
var navigator_heading := Label.new()
navigator_heading.name = "NavigatorHeading"
navigator_heading.text = "Questions"
_set_scaled_label(navigator_heading, 16, Brand.DARK_TEXT)
navigator_content.add_child(navigator_heading)
var navigator_scroll := ScrollContainer.new()
navigator_scroll.custom_minimum_size = Vector2(310, 120)
navigator_content.add_child(navigator_scroll)
nav_palette = GridContainer.new()
nav_palette.columns = 8
nav_palette.add_theme_constant_override("h_separation", 6)
nav_palette.add_theme_constant_override("v_separation", 6)
navigator_scroll.add_child(nav_palette)
question_panel = PanelContainer.new()
question_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
card_content.add_child(question_panel)
question_scroll = ScrollContainer.new()
question_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
question_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
question_panel.add_child(question_scroll)
var content_margin := MarginContainer.new()
content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
content_margin.add_theme_constant_override("margin_left", 4)
content_margin.add_theme_constant_override("margin_right", 12)
content_margin.add_theme_constant_override("margin_top", 20)
question_scroll.add_child(content_margin)
question_content = VBoxContainer.new()
question_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
question_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
question_content.add_theme_constant_override("separation", 12)
content_margin.add_child(question_content)
category_label = Label.new()
category_label.visible = false
_set_scaled_label(category_label, 14, Brand.SKY_BLUE)
question_content.add_child(category_label)
stem_label = RichTextLabel.new()
stem_label.fit_content = true
stem_label.bbcode_enabled = true
stem_label.selection_enabled = true
stem_label.scroll_active = false
stem_label.custom_minimum_size = Vector2(0, 96)
_set_scaled_rich_text(stem_label, 18)
question_content.add_child(stem_label)
media_container = VBoxContainer.new()
media_container.visible = false
media_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
media_container.add_theme_constant_override("separation", 10)
question_content.add_child(media_container)
var lead_margin := MarginContainer.new()
lead_margin.add_theme_constant_override("margin_top", 12)
lead_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
question_content.add_child(lead_margin)
lead_label = Label.new()
lead_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(lead_label, 21, Style.TEXT)
lead_margin.add_child(lead_label)
question_elo_label = Label.new()
question_elo_label.visible = false
question_elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_set_scaled_label(question_elo_label, 16, Style.AMBER)
question_content.add_child(question_elo_label)
var choices_container := VBoxContainer.new()
choices_container.add_theme_constant_override("separation", 10)
question_content.add_child(choices_container)
for index in range(5):
var choice_row := VBoxContainer.new()
choice_row.add_theme_constant_override("separation", 0)
choices_container.add_child(choice_row)
choice_rows.append(choice_row)
var button := Button.new()
button.custom_minimum_size = Vector2(0, 68)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
Style.style_answer_button(button)
UISoundService.set_button_sound(button, "select")
_set_scaled_button_font(button, 17)
button.pressed.connect(func(i := index): _select_choice(i))
button.gui_input.connect(func(event: InputEvent, i := index): _choice_gui_input(event, i))
choice_buttons.append(button)
var badge_wrap := Control.new()
badge_wrap.mouse_filter = Control.MOUSE_FILTER_IGNORE
badge_wrap.anchor_left = 0.0
badge_wrap.anchor_top = 0.0
badge_wrap.anchor_right = 0.0
badge_wrap.anchor_bottom = 0.0
badge_wrap.offset_left = 16
badge_wrap.offset_top = 17
badge_wrap.offset_right = 50
badge_wrap.offset_bottom = 51
button.add_child(badge_wrap)
# The badge circle is a pre-rendered SVG (tinted per-state via modulate) rather than a
# StyleBoxFlat corner-radius circle - a perfectly circular StyleBoxFlat is a known Godot
# degenerate case that can show a seam/faceting at this size even with AA tuned.
var badge_texture := TextureRect.new()
badge_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE
badge_texture.texture = BADGE_CIRCLE_OUTLINE_ICON
badge_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
badge_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
badge_wrap.add_child(badge_texture)
choice_badge_textures.append(badge_texture)
var badge := Label.new()
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
badge.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_set_scaled_label(badge, 15, Brand.muted_color(true))
badge_wrap.add_child(badge)
choice_badges.append(badge)
var percent_label := Label.new()
percent_label.visible = false
percent_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
percent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
percent_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
percent_label.anchor_left = 1.0
percent_label.anchor_top = 0.0
percent_label.anchor_right = 1.0
percent_label.anchor_bottom = 0.0
percent_label.offset_left = -82
percent_label.offset_top = 18
percent_label.offset_right = -18
percent_label.offset_bottom = 50
_set_scaled_label(percent_label, 18, Style.TEXT)
button.add_child(percent_label)
choice_percent_labels.append(percent_label)
var selected_icon := TextureRect.new()
selected_icon.visible = false
selected_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
selected_icon.texture = preload("res://assets/icons/lucide/circle-check.svg")
selected_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
selected_icon.modulate = Brand.PURPLE
selected_icon.anchor_left = 1.0
selected_icon.anchor_top = 0.0
selected_icon.anchor_right = 1.0
selected_icon.anchor_bottom = 0.0
selected_icon.offset_left = -50
selected_icon.offset_top = 18
selected_icon.offset_right = -18
selected_icon.offset_bottom = 50
button.add_child(selected_icon)
choice_selected_icons.append(selected_icon)
var top_label := RichTextLabel.new()
top_label.visible = false
top_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
top_label.bbcode_enabled = true
top_label.fit_content = false
top_label.scroll_active = false
top_label.anchor_left = 0.0
top_label.anchor_top = 0.0
top_label.anchor_right = 1.0
top_label.anchor_bottom = 0.0
top_label.offset_left = 62
top_label.offset_top = 18
top_label.offset_right = -96
top_label.offset_bottom = 50
_set_scaled_rich_text(top_label, 16)
top_label.add_theme_color_override("default_color", Style.RED)
button.add_child(top_label)
choice_top_labels.append(top_label)
choice_row.add_child(button)
var followup_panel := PanelContainer.new()
followup_panel.visible = false
followup_panel.mouse_filter = Control.MOUSE_FILTER_STOP
choice_row.add_child(followup_panel)
choice_followup_panels.append(followup_panel)
var followup_content := VBoxContainer.new()
followup_content.add_theme_constant_override("separation", 8)
followup_panel.add_child(followup_content)
choice_followup_contents.append(followup_content)
_build_answer_review_ui()
actions = HBoxContainer.new()
actions.add_theme_constant_override("separation", 16)
card_content.add_child(actions)
skip_button = Button.new()
skip_button.text = "Skip"
skip_button.flat = true
skip_button.custom_minimum_size = Vector2(0, 40)
UISoundService.set_button_sound(skip_button, "back")
skip_button.pressed.connect(_skip_current_question)
actions.add_child(skip_button)
var bottom_progress_group := VBoxContainer.new()
bottom_progress_group.add_theme_constant_override("separation", 4)
bottom_progress_group.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bottom_progress_group.alignment = BoxContainer.ALIGNMENT_CENTER
actions.add_child(bottom_progress_group)
bottom_progress_label = Label.new()
bottom_progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_set_scaled_label(bottom_progress_label, 13, Brand.SLATE)
bottom_progress_group.add_child(bottom_progress_label)
bottom_progress_bar = ProgressBar.new()
bottom_progress_bar.show_percentage = false
bottom_progress_bar.min_value = 0.0
bottom_progress_bar.max_value = 100.0
bottom_progress_bar.custom_minimum_size = Vector2(160, 6)
bottom_progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
bottom_progress_group.add_child(bottom_progress_bar)
# The timer readout still runs (BlockSessionService pacing/scoring depends on
# it, and _update_timer_bar keeps its text current), it's just no longer
# parented into the bottom bar - the caption + digits and the "Ctrl+Enter"
# hint were clutter next to the Submit button.
timer_caption_label = Label.new()
timer_caption_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_set_scaled_label(timer_caption_label, 12, Brand.SLATE)
next_button = Button.new()
next_button.text = "Submit Block" if BlockSessionService.session_kind == "simulation" and BlockSessionService.current_index + 1 == BlockSessionService.active_questions.size() else "Next" if BlockSessionService.session_kind == "simulation" else "Submit"
next_button.tooltip_text = ""
next_button.custom_minimum_size = Vector2(140, 48)
Style.style_primary_button(next_button)
next_button.pressed.connect(_submit_or_next)
actions.add_child(next_button)
timer_footer = HBoxContainer.new()
timer_footer.add_theme_constant_override("separation", 12)
card_content.add_child(timer_footer)
timer_bar = ProgressBar.new()
timer_bar.show_percentage = false
timer_bar.min_value = 0.0
timer_bar.max_value = 100.0
timer_bar.value = 100.0
timer_bar.custom_minimum_size = Vector2(0, 7)
timer_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53")))
timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME))
timer_footer.add_child(timer_bar)
feedback_fog = ColorRect.new()
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
feedback_fog.mouse_filter = Control.MOUSE_FILTER_IGNORE
feedback_fog.top_level = true
feedback_fog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(feedback_fog)
_build_labs_modal()
calculator_panel = CalculatorPanel.new()
add_child(calculator_panel)
_build_report_modal()
_build_media_viewer()
resized.connect(_fit_modal_panels)
resized.connect(_refresh_responsive_layout)
_fit_modal_panels()
_refresh_responsive_layout()
_apply_question_theme()
func _build_answer_review_ui() -> void:
correct_review_panel = PanelContainer.new()
correct_review_panel.visible = false
question_content.add_child(correct_review_panel)
var review_margin := MarginContainer.new()
review_margin.add_theme_constant_override("margin_left", 14)
review_margin.add_theme_constant_override("margin_right", 14)
review_margin.add_theme_constant_override("margin_top", 12)
review_margin.add_theme_constant_override("margin_bottom", 12)
correct_review_panel.add_child(review_margin)
var review_content := VBoxContainer.new()
review_content.add_theme_constant_override("separation", 10)
review_margin.add_child(review_content)
explanation_label = RichTextLabel.new()
explanation_label.bbcode_enabled = true
explanation_label.fit_content = true
explanation_label.selection_enabled = true
explanation_label.scroll_active = false
explanation_label.custom_minimum_size = Vector2.ZERO
_set_scaled_rich_text(explanation_label, 17)
review_content.add_child(explanation_label)
teaching_cards = HFlowContainer.new()
teaching_cards.add_theme_constant_override("h_separation", 10)
teaching_cards.add_theme_constant_override("v_separation", 10)
review_content.add_child(teaching_cards)
references_container = VBoxContainer.new()
references_container.visible = false
references_container.add_theme_constant_override("separation", 6)
review_content.add_child(references_container)
review_followups_section = VBoxContainer.new()
review_followups_section.visible = false
review_followups_section.add_theme_constant_override("separation", 8)
question_content.add_child(review_followups_section)
var followup_heading := Label.new()
followup_heading.text = "FOLLOW-UP QUESTIONS"
followup_heading.name = "ReviewFollowupHeading"
_set_scaled_label(followup_heading, 15, Brand.PURPLE)
review_followups_section.add_child(followup_heading)
var followup_subtitle := Label.new()
followup_subtitle.text = "Answer to reinforce key concepts. These responses do not affect your score."
followup_subtitle.name = "ReviewFollowupSubtitle"
followup_subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(followup_subtitle, 14, Brand.SLATE)
review_followups_section.add_child(followup_subtitle)
review_followups_content = VBoxContainer.new()
review_followups_content.add_theme_constant_override("separation", 8)
review_followups_section.add_child(review_followups_content)
func _fit_modal_panels() -> void:
var available := get_viewport_rect().size - Vector2(32, 32)
if report_panel != null:
report_panel.custom_minimum_size = Vector2(
minf(700.0, maxf(320.0, available.x)),
minf(600.0, maxf(560.0, available.y)))
if labs_panel != null:
labs_panel.custom_minimum_size = Vector2(
minf(760.0, maxf(320.0, available.x)),
minf(430.0, maxf(360.0, available.y)))
func _refresh_responsive_layout() -> void:
if header_toolbar_host == null or header_brand == null or header_stack == null:
return
var compact := size.x < 1120.0
var target_parent: Control = header_brand
if compact:
target_parent = header_stack
if header_toolbar_host.get_parent() != target_parent:
header_toolbar_host.reparent(target_parent)
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# 12 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
# rank/ELO + points badge, with 8px gaps between all 14 - anything less
# than this wraps onto a second row.
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 960.0
if size.x < 560.0:
nav_palette.columns = 5
else:
nav_palette.columns = 8
func _question_card_box(dark: bool) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("081326") if dark else Color("ffffff")
box.border_color = Color("253555") if dark else Brand.LIGHT_BORDER
box.set_border_width_all(1)
box.set_corner_radius_all(8)
return box
func _question_content_box(dark: bool) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("081326") if dark else Color("ffffff")
box.set_corner_radius_all(6)
return box
func _answer_box(dark: bool, state: String, attached: bool = false) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.content_margin_left = 62
box.content_margin_right = 82
box.content_margin_top = 22
box.content_margin_bottom = 22
if state == "default" or state == "eliminated":
# Every choice reads as its own subtly-bordered card, even before answering -
# only selected/correct/wrong states get a stronger accent border/background.
box.bg_color = Color("0b172b") if dark else Color("ffffff")
box.border_color = Color("22304d") if dark else Color("e7ebf2")
box.set_border_width_all(1)
box.set_corner_radius_all(14)
return box
var background := Color("17183b") if dark else Color("eff4ff")
var border := Brand.PURPLE
if state == "correct":
background = Color("08251d") if dark else Color("ecfdf3")
border = Brand.SUCCESS
elif state == "wrong":
background = Color("32131d") if dark else Color("fff1f2")
border = Color("fb7185")
box.bg_color = background
box.border_color = border
box.set_border_width_all(2)
box.set_corner_radius_all(14)
if attached:
# A review/follow-up panel is docked directly beneath this choice - drop the
# shared edge so the two read as one continuous card instead of two stacked boxes.
box.border_width_bottom = 0
box.corner_radius_bottom_left = 0
box.corner_radius_bottom_right = 0
return box
func _attached_panel_box(dark: bool, accent: Color) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("17183b") if dark else Color("eff4ff")
if accent == Brand.SUCCESS:
box.bg_color = Color("08251d") if dark else Color("ecfdf3")
elif accent == Color("fb7185"):
box.bg_color = Color("32131d") if dark else Color("fff1f2")
box.border_color = accent
box.border_width_left = 2
box.border_width_right = 2
box.border_width_bottom = 2
box.border_width_top = 0
box.corner_radius_top_left = 0
box.corner_radius_top_right = 0
box.corner_radius_bottom_left = 14
box.corner_radius_bottom_right = 14
box.content_margin_left = 12
box.content_margin_right = 12
box.content_margin_top = 10
box.content_margin_bottom = 10
return box
func _correct_review_panel_box(dark: bool) -> StyleBoxFlat:
# correct_review_panel already pads its content via an inner MarginContainer,
# so skip the shared attached-panel content margin to avoid doubling it up.
var box := _attached_panel_box(dark, Brand.SUCCESS)
box.content_margin_left = 0
box.content_margin_right = 0
box.content_margin_top = 0
box.content_margin_bottom = 0
return box
func _toolbar_box(dark: bool, active: bool = false) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("13203a") if dark else Color("f8fafc")
box.border_color = Brand.PURPLE if active else (Color("33415d") if dark else Brand.LIGHT_BORDER)
box.set_border_width_all(1)
box.set_corner_radius_all(7)
box.content_margin_left = 0
box.content_margin_right = 0
box.content_margin_top = 0
box.content_margin_bottom = 0
return box
func _style_theme_toggle_button(dark: bool) -> void:
if theme_button == null:
return
Brand.set_theme_toggle_icon(theme_button, dark)
theme_button.tooltip_text = "Switch to light mode" if dark else "Switch to dark mode"
# Match the same rounded-square box every other toolbar icon uses, rather than
# the distinct circular pill shape - it should read as one of the toolbar, not
# a visually separate control.
theme_button.add_theme_stylebox_override("normal", _toolbar_box(dark))
theme_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true))
theme_button.add_theme_stylebox_override("pressed", _toolbar_box(dark, true))
theme_button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
theme_button.add_theme_constant_override("h_separation", 0)
func _style_asset_toolbar_button(button: Button, texture: Texture2D, dark: bool, active: bool = false) -> void:
if button == null:
return
button.text = ""
button.icon = texture
button.expand_icon = false
button.focus_mode = Control.FOCUS_NONE
button.alignment = HORIZONTAL_ALIGNMENT_CENTER
button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
button.add_theme_constant_override("h_separation", 0)
button.add_theme_constant_override("icon_max_width", 23)
button.add_theme_stylebox_override("normal", _toolbar_box(dark, active))
button.add_theme_stylebox_override("hover", _toolbar_box(dark, true))
button.add_theme_stylebox_override("pressed", _toolbar_box(dark, true))
button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]:
button.add_theme_color_override(color_name, Color.WHITE)
func _primary_action_box(dark: bool, state: String) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
if state == "disabled":
box.bg_color = Color("1b2539") if dark else Color("dbe3ef")
box.border_color = Color("2b3952") if dark else Color("cbd5e1")
elif state == "hover":
box.bg_color = Brand.PURPLE
box.border_color = Color("a78bfa")
elif state == "pressed":
box.bg_color = Color("6d28d9")
box.border_color = Brand.PURPLE
else:
box.bg_color = Color("4f5ff7")
box.border_color = Brand.PURPLE
box.set_border_width_all(1)
box.set_corner_radius_all(7)
box.content_margin_left = 14
box.content_margin_right = 14
box.content_margin_top = 10
box.content_margin_bottom = 10
return box
func _teaching_card_box(dark: bool, accent: Color) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color(accent, 0.08 if dark else 0.045)
box.border_color = Color(accent, 0.42)
box.set_border_width_all(1)
box.set_corner_radius_all(6)
box.content_margin_left = 12
box.content_margin_right = 12
box.content_margin_top = 10
box.content_margin_bottom = 10
return box
func _review_followup_box(_dark: bool, accent: Color, answered: bool) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color(accent, 0.075 if answered else 0.04)
box.border_color = Color(accent, 0.5)
box.set_border_width_all(1)
box.set_corner_radius_all(7)
box.content_margin_left = 12
box.content_margin_right = 12
box.content_margin_top = 10
box.content_margin_bottom = 10
return box
func _style_review_followup_option(button: Button, dark: bool, option_id: String, selected_id: String, correct_id: String, answered: bool) -> void:
var state := "default"
if answered and option_id == correct_id:
state = "correct"
elif answered and option_id == selected_id:
state = "wrong"
var box := _answer_box(dark, state)
box.content_margin_left = 12
box.content_margin_right = 12
var hover_box := _answer_box(dark, "selected" if state == "default" else state)
hover_box.content_margin_left = 12
hover_box.content_margin_right = 12
button.add_theme_stylebox_override("normal", box)
button.add_theme_stylebox_override("disabled", box)
button.add_theme_stylebox_override("hover", hover_box)
button.add_theme_stylebox_override("pressed", hover_box)
button.add_theme_color_override("font_color", Brand.text_color(dark))
button.add_theme_color_override("font_disabled_color", Brand.text_color(dark))
func _apply_question_theme() -> void:
if theme_service == null or question_card == null or question_panel == null:
return
var dark: bool = theme_service.is_dark_mode()
question_card.add_theme_stylebox_override("panel", _question_card_box(dark))
question_panel.add_theme_stylebox_override("panel", _question_content_box(dark))
if navigator_popup != null:
navigator_popup.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
progress_button.add_theme_color_override("font_color", Brand.text_color(dark))
progress_button.add_theme_color_override("font_hover_color", Brand.SKY_BLUE)
progress_button.add_theme_color_override("font_disabled_color", Brand.text_color(dark))
if header_wordmark_label != null:
header_wordmark_label.add_theme_color_override("font_color", Brand.text_color(dark))
var progress_track := Style.button_box(Color("232b3d") if dark else Color("e2e8f0"), Color("303b53") if dark else Color("cbd5e1"))
var progress_fill := Style.button_box(Brand.ROYAL_BLUE, Brand.ROYAL_BLUE)
if progress_bar != null:
progress_bar.add_theme_stylebox_override("background", progress_track)
progress_bar.add_theme_stylebox_override("fill", progress_fill)
if progress_percent_label != null:
progress_percent_label.add_theme_color_override("font_color", Brand.muted_color(dark))
if bottom_progress_bar != null:
bottom_progress_bar.add_theme_stylebox_override("background", progress_track)
bottom_progress_bar.add_theme_stylebox_override("fill", progress_fill)
if bottom_progress_label != null:
bottom_progress_label.add_theme_color_override("font_color", Brand.muted_color(dark))
if category_label != null:
category_label.add_theme_color_override("font_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
timer_label.add_theme_color_override("font_color", Brand.muted_color(dark))
if timer_caption_label != null:
timer_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
stem_label.add_theme_color_override("default_color", Brand.text_color(dark))
lead_label.add_theme_color_override("font_color", Brand.text_color(dark))
explanation_label.add_theme_color_override("default_color", Brand.text_color(dark))
var review_followup_heading := find_child("ReviewFollowupHeading", true, false) as Label
if review_followup_heading != null:
review_followup_heading.add_theme_color_override("font_color", Brand.PURPLE if not dark else Color("8b7cff"))
var review_followup_subtitle := find_child("ReviewFollowupSubtitle", true, false) as Label
if review_followup_subtitle != null:
review_followup_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark))
if correct_review_panel != null:
correct_review_panel.add_theme_stylebox_override("panel", _correct_review_panel_box(dark))
question_elo_label.add_theme_color_override("font_color", Brand.AMBER)
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
if navigator_heading != null:
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
if control == null:
continue
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
control.add_theme_stylebox_override("normal", _toolbar_box(dark, active))
control.add_theme_stylebox_override("hover", _toolbar_box(dark, true))
control.add_theme_stylebox_override("pressed", _toolbar_box(dark, true))
control.add_theme_color_override("font_color", Brand.text_color(dark))
control.add_theme_color_override("icon_normal_color", Brand.SKY_BLUE)
_style_asset_toolbar_button(labs_button, LABS_DARK_ICON if dark else LABS_LIGHT_ICON, dark)
_style_asset_toolbar_button(clear_highlight_button, CLEAR_HIGHLIGHT_DARK_ICON if dark else CLEAR_HIGHLIGHT_LIGHT_ICON, dark)
_style_asset_toolbar_button(report_button, REPORT_QUESTION_DARK_ICON if dark else REPORT_QUESTION_LIGHT_ICON, dark)
_style_asset_toolbar_button(flag_button, FLAG_DARK_ICON if dark else FLAG_LIGHT_ICON, dark, flagged)
_style_theme_toggle_button(dark)
if calculator_panel != null:
calculator_panel.refresh_theme_controls()
if credits_badge != null:
credits_badge.refresh_theme_controls()
if skip_button != null:
var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
skip_button.add_theme_stylebox_override("hover", StyleBoxEmpty.new())
skip_button.add_theme_stylebox_override("pressed", StyleBoxEmpty.new())
skip_button.add_theme_stylebox_override("disabled", StyleBoxEmpty.new())
skip_button.add_theme_color_override("font_color", skip_link_color)
skip_button.add_theme_color_override("font_hover_color", skip_link_color)
skip_button.add_theme_color_override("font_disabled_color", Brand.muted_color(dark))
if next_button != null:
next_button.add_theme_stylebox_override("normal", _primary_action_box(dark, "normal"))
next_button.add_theme_stylebox_override("hover", _primary_action_box(dark, "hover"))
next_button.add_theme_stylebox_override("pressed", _primary_action_box(dark, "pressed"))
next_button.add_theme_stylebox_override("disabled", _primary_action_box(dark, "disabled"))
next_button.add_theme_color_override("font_color", Color.WHITE)
next_button.add_theme_color_override("font_hover_color", Color.WHITE)
next_button.add_theme_color_override("font_pressed_color", Color.WHITE)
next_button.add_theme_color_override("font_disabled_color", Brand.muted_color(dark))
for index in range(choice_buttons.size()):
var choice_id := ""
if index < presented_choices.size():
choice_id = str(presented_choices[index].get("id", ""))
_style_choice_button_for_state(index, choice_id)
for attempted_choice_id in followup_attempt_order:
_render_followup_state(str(attempted_choice_id))
# Guarding on current_answer/pending_correct_advance too (not just the panel's
# own visible flag) so these can never render for a question that hasn't
# actually been answered yet, even if visible was left stale by some other path.
if correct_review_panel != null and correct_review_panel.visible and (not current_answer.is_empty() or pending_correct_advance):
_render_teaching_cards()
_render_review_followups()
_render_unselected_distractor_explanations()
func on_show(_payload: Variant = null) -> void:
_load_question()
func on_hide() -> void:
if BlockSessionService.session_kind == "simulation" and BlockSessionService.block_started:
_save_simulation_current_response()
awaiting_next_question = false
_hide_media_viewer()
_hide_report_modal(true)
_hide_labs_modal()
if calculator_panel != null:
calculator_panel.close_panel()
calculator_panel.reset_calculation()
func _save_simulation_current_response() -> void:
if BlockSessionService.session_kind != "simulation":
return
var question := BlockSessionService.get_current_question()
if question.is_empty():
return
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
ExamSimulationService.save_response(
str(question.get("id", "")),
selected_choice,
eliminated,
flagged,
int(BlockSessionService.get_current_elapsed_seconds()))
func _on_simulation_state_changed() -> void:
if BlockSessionService.session_kind != "simulation":
return
var state := str(ExamSimulationService.simulation.get("state", ""))
if state == "between_blocks":
simulation_submit_pending = false
BlockSessionService.block_started = false
main.show_screen("simulation_break")
elif state == "completed":
simulation_submit_pending = false
BlockSessionService.block_started = false
main.show_screen("simulation_results", ExamSimulationService.simulation)
func _on_simulation_request_failed(message: String) -> void:
if BlockSessionService.session_kind != "simulation":
return
simulation_submit_pending = false
next_button.disabled = false
next_button.text = "Retry"
next_button.tooltip_text = message
func _process(delta: float) -> void:
_update_answer_feedback(delta)
if visible and BlockSessionService.session_kind == "simulation":
BlockSessionService.seconds_remaining = maxf(0.0, ExamSimulationService.get_block_deadline_unix() - Time.get_unix_time_from_system())
var simulation_seconds := int(ceil(BlockSessionService.seconds_remaining))
_set_timer_display(simulation_seconds)
_update_timer_bar()
if simulation_seconds <= 0 and not simulation_submit_pending:
simulation_submit_pending = true
ExamSimulationService.submit_block()
return
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
_update_timer_bar()
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible):
BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta)
var shown_seconds := int(ceil(BlockSessionService.seconds_remaining))
_set_timer_display(shown_seconds)
_update_timer_bar()
if BlockSessionService.seconds_remaining <= 0:
_finish()
if visible and not _is_session_timer_visible() and (report_overlay == null or not report_overlay.visible):
# No real time limit applies here - show a simple local count-up stopwatch
# ("Time elapsed") so the bottom-bar time slot is never blank, without
# touching BlockSessionService's own (block-scoped, not per-question) timing.
var elapsed_seconds := int(floor(float(Time.get_ticks_msec() - untimed_elapsed_start_msec) / 1000.0))
_set_timer_display(maxi(0, elapsed_seconds))
func _input(event: InputEvent) -> void:
if media_viewer_overlay != null and media_viewer_overlay.visible:
if event.is_action_pressed("ui_cancel"):
_hide_media_viewer()
elif event.is_action_pressed("ui_left"):
_show_adjacent_media(-1)
elif event.is_action_pressed("ui_right"):
_show_adjacent_media(1)
elif event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_PLUS or event.keycode == KEY_EQUAL):
_set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR)
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_MINUS:
_set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR)
else:
return
get_viewport().set_input_as_handled()
return
if report_overlay != null and report_overlay.visible and event.is_action_pressed("ui_cancel"):
_hide_report_modal()
get_viewport().set_input_as_handled()
return
if labs_overlay != null and labs_overlay.visible and event.is_action_pressed("ui_cancel"):
_hide_labs_modal()
get_viewport().set_input_as_handled()
return
if calculator_panel != null and calculator_panel.visible and event is InputEventKey:
return
if not visible or pending_correct_advance or stem_label == null:
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ENTER and (event.ctrl_pressed or event.meta_pressed):
var focus_owner := get_viewport().gui_get_focus_owner()
var focus_blocks_shortcut := focus_owner is TextEdit or focus_owner is LineEdit
var overlay_blocks_shortcut := (report_overlay != null and report_overlay.visible) or (labs_overlay != null and labs_overlay.visible)
if not focus_blocks_shortcut and not overlay_blocks_shortcut and next_button != null and next_button.visible and not next_button.disabled:
get_viewport().set_input_as_handled()
_submit_or_next()
return
var mouse_event := event as InputEventMouseButton
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_LEFT:
return
if mouse_event.pressed:
if stem_label.get_global_rect().has_point(mouse_event.global_position):
highlight_drag_source = "stem"
elif explanation_label.visible and explanation_label.get_global_rect().has_point(mouse_event.global_position):
highlight_drag_source = "explanation"
else:
highlight_drag_source = ""
elif highlight_drag_source != "":
var selection_source := highlight_drag_source
highlight_drag_source = ""
call_deferred("_commit_current_text_selection", selection_source)
elif _should_clear_selection_from_click(mouse_event):
_clear_selected_choice()
func _load_question() -> void:
_hide_report_modal(true)
_reset_wrong_feedback()
_hide_labs_modal()
eliminated.clear()
locked_wrong_choice_ids.clear()
_clear_followups()
flagged = false
selected_choice = ""
current_answer = {}
flag_button.button_pressed = false
_refresh_toolbar_state()
_clear_answer_review()
_clear_references()
question_elo_label.visible = false
question_elo_label.text = ""
next_button.text = "Submit"
next_button.disabled = true
skip_button.disabled = false
skip_button.visible = BlockSessionService.session_kind not in ["review", "dynamic"]
skip_button.text = "Skip"
report_button.visible = BlockSessionService.session_kind != "simulation"
var question := BlockSessionService.get_current_question()
if question.is_empty():
if BlockSessionService.session_kind == "review":
main.show_screen("dashboard")
elif BlockSessionService.session_kind == "dynamic":
main.show_screen("dynamic_questions")
else:
main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
return
if BlockSessionService.session_kind != "dynamic":
BookmarkService.observe_question(question)
progress_button.text = _question_progress_text()
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"]
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] else "Open question navigator"
_update_progress_indicators()
untimed_elapsed_start_msec = Time.get_ticks_msec()
var show_timer := _is_session_timer_visible()
# The time slot in the bottom bar is always visible - a countdown ("Time remaining")
# when a time limit applies, otherwise a simple count-up stopwatch ("Time elapsed")
# so the area is never blank for untimed practice.
timer_label.visible = true
timer_bar.visible = show_timer
timer_footer.visible = show_timer
if timer_caption_label != null:
# "core" sessions show a per-question stopwatch that counts UP (no fixed
# limit, just a soft pacing signal), while simulation/Timed sessions count
# DOWN to a real deadline - the caption should match which one it is.
if not show_timer or BlockSessionService.session_kind == "core":
timer_caption_label.text = "Time elapsed"
else:
timer_caption_label.text = "Time remaining"
if show_timer:
if BlockSessionService.session_kind == "core":
_set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())))
else:
_set_timer_display(int(ceil(BlockSessionService.seconds_remaining)))
else:
_set_timer_display(0)
_update_timer_bar()
stem_plain_text = str(question.get("stem", ""))
explanation_plain_text = str(question.get("explanation", ""))
_render_stem()
_load_question_media(question)
lead_label.text = question.get("lead_in", "")
var category_text := str(question.get("discipline", "")).strip_edges()
if category_text.is_empty():
category_text = str(question.get("system", "")).strip_edges()
category_label.text = category_text
category_label.visible = false
_refresh_labs_button()
presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true)
var choices := presented_choices
for index in range(choice_buttons.size()):
var button := choice_buttons[index]
button.visible = index < choices.size()
if index < choice_rows.size():
choice_rows[index].visible = index < choices.size()
button.disabled = false
if index < choices.size():
var choice: Dictionary = choices[index]
_set_choice_button_text(index, choice)
button.modulate = Color.WHITE
_style_choice_button_for_state(index, str(choice.get("id", "")))
_refresh_submit_state()
var saved_answer = BlockSessionService.get_answer_for_index(BlockSessionService.current_index)
if saved_answer != null:
current_answer = saved_answer
selected_choice = str(saved_answer.get("selected_choice_id", ""))
eliminated = saved_answer.get("eliminated_choice_ids", []).duplicate()
flagged = bool(saved_answer.get("flagged", false))
flag_button.button_pressed = flagged
_refresh_toolbar_state()
if BlockSessionService.session_kind == "simulation":
_apply_saved_choice_state()
else:
_show_answer_review()
_update_question_elo_label(true)
_refresh_nav_palette()
_refresh_toolbar_state()
_apply_question_theme()
func _select_choice(index: int) -> void:
if pending_correct_advance or followup_required or _is_correct_answer_locked():
return
var choices := _get_presented_choices()
if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()):
return
if locked_wrong_choice_ids.has(str(choices[index].get("id", ""))):
return
var choice_id := str(choices[index].get("id", ""))
selected_choice = "" if selected_choice == choice_id else choice_id
if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation":
current_answer = {}
if BlockSessionService.mode == "Simulation":
_save_simulation_current_response()
else:
next_button.text = "Submit"
_apply_saved_choice_state()
func _clear_selected_choice() -> void:
if selected_choice == "" or _is_correct_answer_locked():
return
selected_choice = ""
if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation":
current_answer = {}
if BlockSessionService.mode == "Simulation":
_save_simulation_current_response()
else:
next_button.text = "Submit"
_apply_saved_choice_state()
func _should_clear_selection_from_click(mouse_event: InputEventMouseButton) -> bool:
if selected_choice == "" or followup_required or not current_answer.is_empty():
return false
if mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
return false
var pointer_position := mouse_event.global_position
if stem_label.get_global_rect().has_point(pointer_position):
return false
if lead_label.get_global_rect().has_point(pointer_position):
return false
if explanation_label.visible and explanation_label.get_global_rect().has_point(pointer_position):
return false
if labs_overlay != null and labs_overlay.visible:
return false
if skip_button != null and skip_button.get_global_rect().has_point(pointer_position):
return false
if next_button != null and next_button.get_global_rect().has_point(pointer_position):
return false
if question_toolbar != null and question_toolbar.get_global_rect().has_point(pointer_position):
return false
if labs_button != null and labs_button.visible and labs_button.get_global_rect().has_point(pointer_position):
return false
if flag_button != null and flag_button.visible and flag_button.get_global_rect().has_point(pointer_position):
return false
if report_button != null and report_button.visible and report_button.get_global_rect().has_point(pointer_position):
return false
for button in choice_buttons:
if button.visible and button.get_global_rect().has_point(pointer_position):
return false
for panel in choice_followup_panels:
if panel.visible and panel.get_global_rect().has_point(pointer_position):
return false
for child in media_container.get_children():
if child is Control and child.get_global_rect().has_point(pointer_position):
return false
return true
func _skip_current_question() -> void:
if BlockSessionService.session_kind == "bookmark":
if not current_answer.is_empty():
_finish()
return
current_answer = BlockSessionService.skip_bookmark_question(eliminated, flagged)
if current_answer.is_empty():
return
selected_choice = ""
_show_answer_review()
return
if pending_correct_advance or (not current_answer.is_empty() and BlockSessionService.session_kind != "simulation"):
return
if BlockSessionService.session_kind != "core":
if BlockSessionService.session_kind == "simulation":
selected_choice = ""
current_answer = {}
_save_simulation_current_response()
var target_index := _next_pending_simulation_index() if BlockSessionService.session_kind == "simulation" else _next_revisitable_index()
if target_index >= 0 and BlockSessionService.move_to(target_index):
_load_question()
else:
_finish()
return
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
if question_id == "":
return
current_answer = BlockSessionService.skip_core_question(eliminated, flagged)
if current_answer.is_empty():
return
QuestionBankService.skip_question(question_id)
selected_choice = ""
next_button.disabled = true
skip_button.disabled = true
pending_correct_advance = true
correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS
_show_explanation()
_update_question_elo_label(true)
func _next_revisitable_index() -> int:
var question_count := BlockSessionService.active_questions.size()
if question_count <= 1:
return -1
for offset in range(1, question_count):
var candidate := (BlockSessionService.current_index + offset) % question_count
var answer = BlockSessionService.get_answer_for_index(candidate)
if answer == null or str(answer.get("selected_choice_id", "")) == "":
return candidate
return (BlockSessionService.current_index + 1) % question_count
func _next_pending_simulation_index() -> int:
var question_count := BlockSessionService.active_questions.size()
if question_count == 0:
return -1
for offset in range(1, question_count + 1):
var candidate := (BlockSessionService.current_index + offset) % question_count
var answer = BlockSessionService.get_answer_for_index(candidate)
if answer == null or str(answer.get("selected_choice_id", "")) == "":
return candidate
return -1
func _choice_gui_input(event: InputEvent, index: int) -> void:
if pending_correct_advance or followup_required or _is_correct_answer_locked():
return
var mouse_event := event as InputEventMouseButton
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed:
return
if not current_answer.is_empty() and BlockSessionService.session_kind != "simulation":
return
var choices := _get_presented_choices()
if index >= choices.size():
return
var choice: Dictionary = choices[index]
var choice_id := str(choice.get("id", ""))
if locked_wrong_choice_ids.has(choice_id):
return
if eliminated.has(choice_id):
eliminated.erase(choice_id)
else:
eliminated.append(choice_id)
UISoundService.play_sound("eliminate")
_set_choice_button_text(index, choice)
_apply_saved_choice_state()
if BlockSessionService.session_kind == "simulation":
_save_simulation_current_response()
func _toggle_eliminate_selected() -> void:
if pending_correct_advance or followup_required or selected_choice == "" or (not current_answer.is_empty() and BlockSessionService.session_kind != "simulation") or _is_correct_answer_locked():
return
var choices := _get_presented_choices()
for index in range(choices.size()):
if str(choices[index].get("id", "")) == selected_choice:
if eliminated.has(selected_choice):
eliminated.erase(selected_choice)
else:
eliminated.append(selected_choice)
UISoundService.play_sound("eliminate")
_set_choice_button_text(index, choices[index])
_apply_saved_choice_state()
if BlockSessionService.session_kind == "simulation":
_save_simulation_current_response()
func _submit_or_next() -> void:
if BlockSessionService.session_kind == "simulation":
if selected_choice == "":
return
_save_simulation_current_response()
var pending_index := _next_pending_simulation_index()
if pending_index >= 0:
BlockSessionService.move_to(pending_index)
_load_question()
return
if not simulation_submit_pending:
simulation_submit_pending = true
next_button.disabled = true
next_button.text = "Submitting..."
ExamSimulationService.submit_block()
return
if awaiting_next_question:
return
if pending_correct_advance:
return
if current_answer.is_empty():
if selected_choice == "":
return
var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", ""))
if BlockSessionService.session_kind == "dynamic":
var elapsed := int(floor(BlockSessionService.get_current_elapsed_seconds()))
DynamicQuestionService.submit_attempt(selected_choice, elapsed)
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, false)
_show_answer_review()
return
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
var attempted_choice := selected_choice
var met_time_limit := true
if BlockSessionService.session_kind == "core":
met_time_limit = BlockSessionService.get_current_elapsed_seconds() <= BlockSessionService.core_current_target_seconds
QuestionBankService.record_first_attempt(
question_id_for_attempt,
attempted_choice,
_presented_choice_ids(),
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())),
met_time_limit
)
var result: Dictionary
if BlockSessionService.session_kind == "core":
result = BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged)
elif BlockSessionService.session_kind == "bookmark":
result = BlockSessionService.submit_bookmark_attempt(selected_choice, eliminated, flagged)
else:
result = BlockSessionService.submit_review_attempt(selected_choice, eliminated, flagged)
if result.is_empty():
return
var answer: Dictionary = result.get("answer", {})
if BlockSessionService.session_kind == "review" and bool(result.get("first_submission", false)):
ReviewQueueService.record_first_submission(
BlockSessionService.get_current_question(),
attempted_choice,
_presented_choice_ids(),
int(floor(float(answer.get("elapsed_seconds", 0.0)))))
if bool(result.get("is_correct", false)):
current_answer = answer
_update_question_elo_label(true)
_show_answer_review()
_trigger_correct_feedback()
var earned_coin := (
BlockSessionService.session_kind == "core"
and bool(result.get("mastered", false))
and met_time_limit
)
if earned_coin:
var correct_choice_index := _choice_index_for_id(selected_choice)
if correct_choice_index >= 0 and correct_choice_index < choice_rows.size():
_play_coin_burst(choice_rows[correct_choice_index].get_global_rect())
return
_lock_wrong_choice(attempted_choice)
_show_wrong_answer_followup(attempted_choice)
selected_choice = ""
current_answer = {}
next_button.text = "Submit"
next_button.disabled = true
_trigger_wrong_feedback()
_apply_saved_choice_state()
return
QuestionBankService.record_first_attempt(
question_id_for_attempt,
selected_choice,
_presented_choice_ids(),
int(floor(BlockSessionService.get_current_elapsed_seconds()))
)
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
_show_answer_review()
else:
if BlockSessionService.session_kind == "dynamic":
awaiting_next_question = true
next_button.text = "Generating…"
next_button.disabled = true
DynamicQuestionService.load_next()
elif BlockSessionService.session_kind == "core":
_request_next_core_question()
elif BlockSessionService.session_kind == "bookmark":
if BookmarkService.has_next_practice():
awaiting_next_question = true
next_button.text = "Loading..."
next_button.disabled = true
BookmarkService.next_practice()
else:
_finish()
elif BlockSessionService.session_kind == "review":
awaiting_next_question = true
next_button.text = "Loading..."
next_button.disabled = true
ReviewQueueService.load_next()
else:
_advance_or_finish()
func _on_dynamic_question_loaded(question: Dictionary) -> void:
if not awaiting_next_question or BlockSessionService.session_kind != "dynamic":
return
if not BlockSessionService.replace_dynamic_question(question):
_on_dynamic_question_failed("The next dynamic question could not be prepared.")
return
awaiting_next_question = false
_load_question()
UISoundService.play_sound("question")
func _on_dynamic_question_failed(message: String) -> void:
if not awaiting_next_question or BlockSessionService.session_kind != "dynamic":
return
awaiting_next_question = false
next_button.text = "Retry"
next_button.disabled = false
next_button.tooltip_text = message
func _request_next_core_question() -> void:
awaiting_next_question = true
next_button.text = "Loading..."
next_button.disabled = true
QuestionBankService.load_next_random_question(BlockSessionService.get_core_excluded_question_ids())
func _on_random_question_changed() -> void:
if not awaiting_next_question or QuestionBankService.load_state != "ready":
return
var loaded_questions := QuestionBankService.get_all_questions()
if loaded_questions.is_empty():
_on_random_question_failed("No additional questions are available.")
return
if not BlockSessionService.append_core_question(loaded_questions[0]):
_on_random_question_failed("The next question could not be prepared.")
return
awaiting_next_question = false
_load_question()
UISoundService.play_sound("question")
func _on_random_question_failed(message: String) -> void:
if not awaiting_next_question:
return
if QuestionBankService.load_state == "retrying":
next_button.text = "Reconnecting..."
next_button.disabled = true
next_button.tooltip_text = message
return
awaiting_next_question = false
next_button.text = "Retry"
next_button.disabled = false
next_button.tooltip_text = message
func _show_answer_review() -> void:
if BlockSessionService.session_kind == "simulation":
_clear_answer_review()
return
_show_explanation()
_show_references(BlockSessionService.get_current_question().get("references", []))
_ensure_answer_review_followups()
_render_answer_review_sections()
_set_answer_review_timer_visibility()
next_button.text = "Next"
next_button.disabled = false
if BlockSessionService.session_kind == "bookmark" and not BookmarkService.has_next_practice():
next_button.text = "End of list"
next_button.disabled = true
if skip_button != null:
skip_button.disabled = true
skip_button.visible = BlockSessionService.session_kind == "bookmark"
skip_button.text = "Back to Bookmarks" if BlockSessionService.session_kind == "bookmark" else "Skip"
skip_button.disabled = false if BlockSessionService.session_kind == "bookmark" else true
if question_scroll != null:
question_scroll.call_deferred("ensure_control_visible", explanation_label)
_refresh_toolbar_state()
func _set_answer_review_timer_visibility() -> void:
var keep_core_timer := BlockSessionService.session_kind == "core"
timer_bar.visible = keep_core_timer
timer_label.visible = keep_core_timer
timer_footer.visible = keep_core_timer
if keep_core_timer:
_update_timer_bar()
func _show_explanation() -> void:
var question := BlockSessionService.get_current_question()
for button in choice_buttons:
button.disabled = true
var correct_id := str(question.get("correct_choice_id", ""))
_dock_correct_review_panel(correct_id)
correct_review_panel.visible = true
var choices := _get_presented_choices()
for index in range(choices.size()):
var choice: Dictionary = choices[index]
var id := str(choice.get("id", ""))
_set_choice_button_text(index, choice)
_style_choice_button_for_state(index, id)
explanation_plain_text = str(question.get("explanation", ""))
_render_explanation()
explanation_label.visible = true
category_label.visible = not category_label.text.strip_edges().is_empty()
_update_question_elo_label(true)
_refresh_nav_palette()
_update_content_vertical_alignment()
func _dock_correct_review_panel(correct_id: String) -> void:
var index := _choice_index_for_id(correct_id)
if index < 0 or index >= choice_rows.size():
return
if correct_review_panel.get_parent() != choice_rows[index]:
correct_review_panel.reparent(choice_rows[index])
func _render_answer_review_sections() -> void:
_render_teaching_cards()
_render_review_followups()
_render_unselected_distractor_explanations()
func _clear_answer_review() -> void:
if explanation_label != null:
explanation_label.visible = false
if correct_review_panel != null:
correct_review_panel.visible = false
if category_label != null:
category_label.visible = false
_clear_container_children(teaching_cards)
_clear_container_children(review_followups_content)
review_followup_states.clear()
if review_followups_section != null:
review_followups_section.visible = false
_update_content_vertical_alignment()
func _update_content_vertical_alignment() -> void:
if question_content == null:
return
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN
func _clear_container_children(container: Control) -> void:
if container == null:
return
for child in container.get_children():
container.remove_child(child)
child.queue_free()
func _render_teaching_cards() -> void:
if teaching_cards == null:
return
_clear_container_children(teaching_cards)
var question := BlockSessionService.get_current_question()
var takeaway := str(question.get("key_takeaway", "")).strip_edges()
if takeaway == "":
var correct_id := str(question.get("correct_choice_id", ""))
var explanations: Dictionary = question.get("choice_explanations", {})
takeaway = _strip_verdict_prefix(str(explanations.get(correct_id, "")).strip_edges())
var pearl := str(question.get("usmle_pearl", "")).strip_edges()
if takeaway != "":
_add_teaching_card("KEY TAKEAWAY", takeaway, Brand.SKY_BLUE)
if pearl != "":
_add_teaching_card("USMLE PEARL", pearl, Brand.PURPLE)
teaching_cards.visible = teaching_cards.get_child_count() > 0
func _add_teaching_card(title_text: String, body_text: String, accent: Color) -> void:
var dark := _followup_is_dark()
var card := PanelContainer.new()
card.custom_minimum_size = Vector2(360, 0)
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.add_theme_stylebox_override("panel", _teaching_card_box(dark, accent))
teaching_cards.add_child(card)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 6)
card.add_child(content)
var heading := Label.new()
heading.text = title_text
_set_scaled_label(heading, 13, accent)
content.add_child(heading)
var body := Label.new()
body.text = body_text
body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(body, 14, Brand.text_color(dark))
content.add_child(body)
func _render_review_followups() -> void:
if review_followups_content == null:
return
_clear_container_children(review_followups_content)
if BlockSessionService.session_kind == "simulation":
review_followups_section.visible = false
return
if not BlockSessionService.show_review_followups:
review_followups_section.visible = false
return
var question := BlockSessionService.get_current_question()
var authored: Array = question.get("review_followups", [])
if review_followup_states.size() != authored.size():
review_followup_states.clear()
for _index in range(authored.size()):
review_followup_states.append({"selected_choice_id": "", "answered": false})
review_followups_section.visible = not authored.is_empty()
if authored.is_empty():
return
var answered_count := 0
for state in review_followup_states:
if bool(state.get("answered", false)):
answered_count += 1
var heading := find_child("ReviewFollowupHeading", true, false) as Label
if heading != null:
heading.text = "FOLLOW-UP QUESTIONS | %d of %d answered" % [answered_count, authored.size()]
for index in range(authored.size()):
if typeof(authored[index]) != TYPE_DICTIONARY:
continue
_add_review_followup_card(index, authored[index], review_followup_states[index])
func _add_review_followup_card(index: int, followup: Dictionary, state: Dictionary) -> void:
var dark := _followup_is_dark()
var answered := bool(state.get("answered", false))
var selected_id := str(state.get("selected_choice_id", ""))
var correct_id := str(followup.get("correct_choice_id", ""))
var is_correct := answered and selected_id == correct_id
var accent := Brand.SUCCESS if is_correct else Color("fb7185") if answered else Brand.PURPLE
var card := PanelContainer.new()
card.add_theme_stylebox_override("panel", _review_followup_box(dark, accent, answered))
review_followups_content.add_child(card)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 8)
card.add_child(content)
var prompt := Label.new()
prompt.text = "%d. %s" % [index + 1, str(followup.get("prompt", ""))]
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(prompt, 15, Brand.text_color(dark))
content.add_child(prompt)
var choices: Array = followup.get("choices", [])
for option in choices:
if typeof(option) != TYPE_DICTIONARY:
continue
var option_id := str(option.get("id", ""))
var option_button := Button.new()
option_button.text = "%s. %s" % [option_id, str(option.get("text", ""))]
option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
option_button.disabled = answered
option_button.custom_minimum_size = Vector2(0, 38)
_set_scaled_button_font(option_button, 14)
_style_review_followup_option(option_button, dark, option_id, selected_id, correct_id, answered)
option_button.pressed.connect(_answer_review_followup.bind(index, option_id))
content.add_child(option_button)
if answered:
var teaching_point := Label.new()
teaching_point.text = str(followup.get("teaching_point", ""))
teaching_point.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(teaching_point, 14, accent)
content.add_child(teaching_point)
func _answer_review_followup(index: int, choice_id: String) -> void:
if index < 0 or index >= review_followup_states.size():
return
var state: Dictionary = review_followup_states[index]
if bool(state.get("answered", false)):
return
state["selected_choice_id"] = choice_id
state["answered"] = true
review_followup_states[index] = state
_render_review_followups()
func _render_unselected_distractor_explanations() -> void:
# Choices the user never picked (so never triggered the wrong-answer
# follow-up quiz) still need their "why incorrect" explanation shown -
# nest it directly in that choice's own card instead of a repeated
# aggregate list at the bottom. Choices that DID trigger a follow-up
# already show this same explanation inside the resolved quiz view.
# Guard against ever rendering on a question that hasn't actually been
# answered yet, regardless of what triggered this call.
if current_answer.is_empty() and not pending_correct_advance:
return
var question := BlockSessionService.get_current_question()
var correct_id := str(question.get("correct_choice_id", ""))
var choices := _get_presented_choices()
for index in range(choices.size()):
if index >= choice_followup_panels.size():
continue
var choice: Dictionary = choices[index]
if typeof(choice) != TYPE_DICTIONARY:
continue
var choice_id := str(choice.get("id", ""))
if choice_id == "" or choice_id == correct_id or followup_states.has(choice_id):
continue
_clear_followup_content(index)
var content := choice_followup_contents[index]
var explanation := Label.new()
explanation.text = _strip_verdict_prefix(_distractor_explanation_text(choice_id))
explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark()))
content.add_child(explanation)
choice_followup_panels[index].add_theme_stylebox_override(
"panel",
_attached_panel_box(_followup_is_dark(), Color("fb7185"))
)
choice_followup_panels[index].visible = true
_style_choice_button_for_state(index, choice_id)
func _distractor_explanation_text(choice_id: String) -> String:
var question := BlockSessionService.get_current_question()
var explanations: Dictionary = question.get("choice_explanations", {})
var explanation := str(explanations.get(choice_id, "")).strip_edges()
if explanation != "":
return explanation
return "Review the stem finding that makes this option less appropriate."
func _strip_verdict_prefix(value: String) -> String:
if value.begins_with("Correct. "):
return value.substr(9)
if value.begins_with("Incorrect. "):
return value.substr(11)
return value
func _show_references(raw_references: Variant) -> void:
_clear_references()
if typeof(raw_references) != TYPE_ARRAY:
return
var valid_references: Array = []
for reference in raw_references:
if typeof(reference) != TYPE_DICTIONARY:
continue
var url := str(reference.get("url", "")).strip_edges()
if not _is_safe_reference_url(url):
continue
valid_references.append(reference)
if valid_references.is_empty():
return
var heading := Label.new()
heading.text = "References"
_set_scaled_label(heading, 18, Style.TEXT)
references_container.add_child(heading)
for reference in valid_references:
var title := str(reference.get("title", "Reference")).strip_edges()
var publisher := str(reference.get("publisher", "")).strip_edges()
var url := str(reference.get("url", "")).strip_edges()
var link := LinkButton.new()
link.text = title if publisher == "" else "%s - %s" % [title, publisher]
link.tooltip_text = url
link.size_flags_horizontal = Control.SIZE_EXPAND_FILL
link.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
link.add_theme_color_override("font_color", Style.CYAN)
link.add_theme_color_override("font_hover_color", Style.MAGENTA)
link.set_meta("question_base_font_size", 16)
link.add_theme_font_size_override("font_size", _scaled_font_size(16))
link.pressed.connect(_open_reference_url.bind(url))
references_container.add_child(link)
references_container.visible = true
func _clear_references() -> void:
if references_container == null:
return
for child in references_container.get_children():
references_container.remove_child(child)
child.queue_free()
references_container.visible = false
func _is_safe_reference_url(url: String) -> bool:
return url.begins_with("https://") and url.length() > 8
func _open_reference_url(url: String) -> void:
if not _is_safe_reference_url(url):
return
OS.shell_open(url)
func _advance_or_finish() -> void:
if BlockSessionService.move_next():
_load_question()
else:
_finish()
func _finish() -> void:
if BlockSessionService.session_kind == "bookmark":
BlockSessionService.finish_block()
main.show_screen("bookmarks")
return
if BlockSessionService.session_kind == "core":
BlockSessionService.finish_block()
main.show_screen("selection", "exam")
return
if BlockSessionService.session_kind == "review":
BlockSessionService.finish_block()
main.show_screen("review_queue_results", ReviewQueueService.get_summary())
return
var results := BlockSessionService.finish_block()
main.show_screen("review", results)
func _exit_run() -> void:
if BlockSessionService.session_kind == "bookmark":
BlockSessionService.finish_block()
main.show_screen("bookmarks")
return
if BlockSessionService.session_kind in ["review", "dynamic"]:
BlockSessionService.finish_block()
DashboardService.refresh()
main.show_screen("dashboard")
return
var was_core_run := BlockSessionService.session_kind == "core"
BlockSessionService.resume_current_question_timer()
BlockSessionService.finish_block()
main.show_screen("selection", "exam" if was_core_run else "step3")
func _update_timer_bar() -> void:
if timer_bar == null:
return
var is_core_timer := BlockSessionService.session_kind == "core"
var ratio := BlockSessionService.get_session_timer_ratio()
timer_bar.value = ratio * 100.0
if is_core_timer and timer_label.visible:
_set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())))
var color_state := "blue"
var fill_color := Brand.ROYAL_BLUE
if ratio < 0.25:
color_state = "red"
fill_color = Style.RED
elif ratio <= 0.5:
color_state = "amber"
fill_color = Style.AMBER
if color_state != timer_color_state:
timer_color_state = color_state
timer_bar.add_theme_stylebox_override("fill", Style.button_box(fill_color, fill_color))
func _set_timer_display(total_seconds: int) -> void:
if timer_label == null:
return
var safe_seconds := maxi(0, total_seconds)
timer_label.text = "%02d:%02d" % [int(floor(float(safe_seconds) / 60.0)), safe_seconds % 60]
func _is_session_timer_visible() -> bool:
if BlockSessionService.session_kind == "core" or BlockSessionService.session_kind == "simulation":
return true
return BlockSessionService.mode == "Timed"
func _question_progress_text() -> String:
if BlockSessionService.session_kind == "core":
var block_size := BlockSessionService.core_block_size
return "%d of %d" % [(BlockSessionService.core_answer_count % block_size) + 1, block_size]
return "%d of %d" % [
mini(BlockSessionService.current_index + 1, BlockSessionService.active_questions.size()),
BlockSessionService.active_questions.size()
]
func _update_progress_indicators() -> void:
# "core" sessions have no fixed total (endless queue), so there's no meaningful
# ratio/percentage to show - hide the bars rather than showing a misleading value.
var show_progress := BlockSessionService.session_kind != "core"
if progress_bar != null:
progress_bar.visible = show_progress
if progress_percent_label != null:
progress_percent_label.visible = show_progress
if bottom_progress_bar != null:
bottom_progress_bar.visible = show_progress
if bottom_progress_label != null:
bottom_progress_label.visible = show_progress
if not show_progress:
return
var total := BlockSessionService.active_questions.size()
var current := mini(BlockSessionService.current_index + 1, maxi(total, 1))
var ratio := float(current) / float(maxi(total, 1))
var percent := int(round(ratio * 100.0))
if progress_bar != null:
progress_bar.value = ratio * 100.0
if progress_percent_label != null:
progress_percent_label.text = "%d%%" % percent
if bottom_progress_bar != null:
bottom_progress_bar.value = ratio * 100.0
if bottom_progress_label != null:
bottom_progress_label.text = "%d of %d" % [current, total]
func _toggle_question_navigator() -> void:
if navigator_popup == null or BlockSessionService.session_kind == "core":
return
if navigator_popup.visible:
navigator_popup.hide()
return
_refresh_nav_palette()
var popup_size := Vector2i(340, 200)
var anchor := progress_button.get_global_rect()
var viewport_size := get_viewport_rect().size
var popup_x := int(clampf(anchor.position.x, 8.0, maxf(8.0, viewport_size.x - popup_size.x - 8.0)))
var popup_y := int(minf(anchor.end.y + 6.0, viewport_size.y - popup_size.y - 8.0))
navigator_popup.popup(Rect2i(Vector2i(popup_x, popup_y), popup_size))
func _navigate_to_question(target_index: int) -> void:
if target_index == BlockSessionService.current_index:
navigator_popup.hide()
return
if BlockSessionService.session_kind == "simulation":
_save_simulation_current_response()
if BlockSessionService.move_to(target_index):
navigator_popup.hide()
_load_question()
func _lock_wrong_choice(choice_id: String) -> void:
if choice_id == "":
return
if not locked_wrong_choice_ids.has(choice_id):
locked_wrong_choice_ids.append(choice_id)
func _trigger_wrong_feedback() -> void:
UISoundService.play_sound("error")
shake_time = 0.28
shake_origin = position
green_fog_time = 0.0
red_fog_time = FEEDBACK_FADE_SECONDS
if feedback_fog != null:
feedback_fog.color = Color(1.0, 0.0, 0.0, _feedback_overlay_alpha(false))
func _trigger_correct_feedback() -> void:
UISoundService.play_sound("correct")
red_fog_time = 0.0
green_fog_time = FEEDBACK_FADE_SECONDS
if skip_button != null:
skip_button.disabled = true
for button in choice_buttons:
button.disabled = true
if feedback_fog != null:
feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true))
func _play_coin_burst(origin_rect: Rect2) -> void:
if credits_badge == null:
return
var origin := origin_rect.get_center()
var target := credits_badge.get_global_rect().get_center()
var coin_count := 6
var flight_time := 0.55
var stagger := 0.035
for index in range(coin_count):
var coin := TextureRect.new()
coin.texture = COIN_ICON
coin.modulate = Brand.AMBER
coin.custom_minimum_size = Vector2(16, 16)
coin.size = Vector2(16, 16)
coin.top_level = true
coin.mouse_filter = Control.MOUSE_FILTER_IGNORE
coin.pivot_offset = Vector2(8, 8)
var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0))
coin.global_position = origin + jitter - coin.size * 0.5
add_child(coin)
var tween := create_tween()
tween.tween_interval(index * stagger)
tween.set_parallel(true)
tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
tween.set_parallel(false)
tween.tween_callback(coin.queue_free)
var settle_time := (coin_count - 1) * stagger + flight_time
get_tree().create_timer(settle_time).timeout.connect(func():
if credits_badge != null:
credits_badge.bump())
func _update_answer_feedback(delta: float) -> void:
if shake_time > 0.0:
shake_time = max(0.0, shake_time - delta)
if shake_time > 0.0:
position = shake_origin + Vector2(randf_range(-9.0, 9.0), randf_range(-4.0, 4.0))
else:
position = shake_origin
if red_fog_time > 0.0 and feedback_fog != null:
red_fog_time = max(0.0, red_fog_time - delta)
var red_alpha := _feedback_overlay_alpha(false) * (red_fog_time / FEEDBACK_FADE_SECONDS)
feedback_fog.color = Color(1.0, 0.0, 0.0, red_alpha)
elif green_fog_time > 0.0 and feedback_fog != null:
green_fog_time = max(0.0, green_fog_time - delta)
var green_alpha := _feedback_overlay_alpha(true) * (green_fog_time / FEEDBACK_FADE_SECONDS)
feedback_fog.color = Color(0.25, 1.0, 0.25, green_alpha)
if pending_correct_advance:
correct_advance_delay = max(0.0, correct_advance_delay - delta)
if correct_advance_delay <= 0.0:
pending_correct_advance = false
next_button.disabled = false
if skip_button != null:
skip_button.disabled = false
_advance_or_finish()
func _feedback_overlay_alpha(is_correct: bool) -> float:
if Style.is_dark:
return 0.23 if is_correct else 0.26
return 0.07
func _reset_wrong_feedback() -> void:
if shake_time > 0.0:
position = shake_origin
shake_time = 0.0
red_fog_time = 0.0
green_fog_time = 0.0
correct_advance_delay = 0.0
pending_correct_advance = false
next_button.disabled = false
if skip_button != null:
skip_button.disabled = false
if feedback_fog != null:
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
func _commit_current_text_selection(source: String = "stem") -> void:
var target_label := stem_label if source == "stem" else explanation_label
if target_label == null:
return
var raw_selected_text := target_label.get_selected_text()
var selected_text := raw_selected_text.strip_edges()
if selected_text == "":
return
var left_trimmed_text := raw_selected_text.strip_edges(true, false)
var leading_trim_count := raw_selected_text.length() - left_trimmed_text.length()
var selection_start := target_label.get_selection_from() + leading_trim_count
_highlight_range(source, selection_start, selection_start + selected_text.length())
func _highlight_range(source: String, start: int, end: int) -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
var highlight_store := question_highlights if source == "stem" else explanation_highlights
var plain_text := stem_plain_text if source == "stem" else explanation_plain_text
var target_label := stem_label if source == "stem" else explanation_label
if not highlight_store.has(question_id):
highlight_store[question_id] = []
var highlights: Array = highlight_store[question_id]
start = clamp(start, 0, plain_text.length())
end = clamp(end, start, plain_text.length())
if start == end:
return
if _subtract_highlight_range(highlights, start, end):
_render_highlighted_text(source)
if target_label.has_method("deselect"):
target_label.deselect()
return
highlights.append({"start": start, "end": end})
_render_highlighted_text(source)
if target_label.has_method("deselect"):
target_label.deselect()
func _clear_current_highlights() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
question_highlights[question_id] = []
explanation_highlights[question_id] = []
_render_stem()
if explanation_label.visible:
_render_explanation()
func _toggle_report_current_question() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
if question_id == "":
return
_show_report_modal(question_id)
func _refresh_toolbar_state() -> void:
_refresh_theme_button()
_refresh_labs_button()
if report_button != null:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
if notes_button != null:
notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
if flashcard_button != null:
flashcard_button.visible = Brand.is_uat_environment() and BlockSessionService.session_kind not in ["simulation", "dynamic"]
flashcard_button.disabled = current_answer.is_empty()
if bookmark_button != null:
bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", ""))
var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id)
var bookmark_pending := BookmarkService.is_pending(bookmark_question_id)
bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question")
if calculator_button != null:
calculator_button.visible = true
_apply_question_theme()
func _toggle_calculator() -> void:
if calculator_panel != null:
calculator_panel.toggle_panel()
func _toggle_bookmark_current_question() -> void:
var question := BlockSessionService.get_current_question()
if not question.is_empty():
BookmarkService.toggle_question(question)
func _toggle_flashcard_current_question() -> void:
if current_answer.is_empty():
return
var question := BlockSessionService.get_current_question()
if question.is_empty() or flashcard_prefill_dialog == null:
return
flashcard_prefill_dialog.open_for_question(question, current_answer)
func _on_bookmark_status_changed(question_id: String, value: bool, pending: bool) -> void:
if question_id != str(BlockSessionService.get_current_question().get("id", "")) or bookmark_button == null:
return
bookmark_button.tooltip_text = ("Saved; waiting to sync" if pending else "Remove bookmark") if value else ("Removal waiting to sync" if pending else "Save question")
_apply_question_theme()
func _on_bookmark_question_loaded(question: Dictionary) -> void:
if not visible or BlockSessionService.session_kind != "bookmark" or not awaiting_next_question:
return
awaiting_next_question = false
if BlockSessionService.replace_bookmark_question(question):
_load_question()
func _on_bookmark_question_failed(message: String) -> void:
if not visible or BlockSessionService.session_kind != "bookmark":
return
awaiting_next_question = false
next_button.text = "Retry"
next_button.disabled = false
next_button.tooltip_text = message
func _on_review_question_loaded(question: Dictionary) -> void:
if not visible or BlockSessionService.session_kind != "review" or not awaiting_next_question:
return
awaiting_next_question = false
if BlockSessionService.replace_review_question(question):
_load_question()
UISoundService.play_sound("question")
func _on_review_question_failed(message: String) -> void:
if not visible or BlockSessionService.session_kind != "review":
return
awaiting_next_question = false
next_button.text = "Retry"
next_button.disabled = false
next_button.tooltip_text = message
func _on_review_pass_completed(summary: Dictionary) -> void:
if not visible or BlockSessionService.session_kind != "review":
return
awaiting_next_question = false
BlockSessionService.finish_block()
main.show_screen("review_queue_results", summary)
func refresh_theme_controls() -> void:
if media_viewer_background != null:
media_viewer_background.color = Style.BACKGROUND
_refresh_toolbar_state()
_apply_saved_choice_state()
_apply_question_theme()
func _refresh_theme_button() -> void:
if theme_button == null:
return
_style_theme_toggle_button(theme_service.is_dark_mode())
func _adjust_question_text_scale(delta: float) -> void:
var next_scale: float = clamp(question_text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
if is_equal_approx(next_scale, question_text_scale):
return
question_text_scale = next_scale
AccountProfileService.update_text_scale(next_scale)
_refresh_scaled_fonts(self)
_render_stem()
_apply_saved_choice_state()
func _synced_question_text_scale() -> float:
var saved: float = AccountProfileService.preferences.get("questionTextScale", 1.0)
return clamp(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
func _on_account_profile_changed() -> void:
var synced := _synced_question_text_scale()
if is_equal_approx(synced, question_text_scale):
return
question_text_scale = synced
_refresh_scaled_fonts(self)
_render_stem()
_apply_saved_choice_state()
func _set_scaled_label(label: Label, base_size: int, color: Color = Style.TEXT) -> void:
label.set_meta("question_base_font_size", base_size)
label.add_theme_color_override("font_color", color)
label.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
func _set_scaled_button_font(button: Button, base_size: int) -> void:
button.set_meta("question_base_font_size", base_size)
button.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
func _set_scaled_rich_text(label: RichTextLabel, base_size: int) -> void:
label.set_meta("question_base_font_size", base_size)
label.add_theme_color_override("default_color", Style.TEXT)
label.add_theme_font_size_override("normal_font_size", _scaled_font_size(base_size))
label.add_theme_font_size_override("bold_font_size", _scaled_font_size(base_size))
func _refresh_scaled_fonts(node: Node) -> void:
if node.has_meta("question_base_font_size"):
var base_size := int(node.get_meta("question_base_font_size"))
if node is Label:
node.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
elif node is Button:
node.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
elif node is LinkButton:
node.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
elif node is RichTextLabel:
node.add_theme_font_size_override("normal_font_size", _scaled_font_size(base_size))
node.add_theme_font_size_override("bold_font_size", _scaled_font_size(base_size))
for child in node.get_children():
_refresh_scaled_fonts(child)
func _scaled_font_size(base_size: int) -> int:
return int(round(float(base_size) * question_text_scale))
func _refresh_labs_button() -> void:
if labs_button == null:
return
labs_button.visible = _current_question_has_labs()
func _current_question_has_labs() -> bool:
var question := BlockSessionService.get_current_question()
if question.is_empty():
return false
var labs: Array = question.get("labs", [])
return not labs.is_empty()
func _load_question_media(question: Dictionary) -> void:
_clear_question_media()
var media: Array = question.get("media", [])
if media.is_empty():
return
media_container.visible = true
var question_id := str(question.get("id", ""))
for item in media:
var media_url := _resolve_media_url(str(item))
if media_url == "":
continue
var media_index := question_media_items.size()
question_media_items.append({"texture": null, "loaded": false, "failed": false})
var frame := PanelContainer.new()
frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
frame.add_theme_stylebox_override("panel", Style.panel_box(Color("111a2a"), Color("344463")))
media_container.add_child(frame)
var image_box := VBoxContainer.new()
image_box.add_theme_constant_override("separation", 8)
frame.add_child(image_box)
var status := Label.new()
status.text = "Loading image..."
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_set_scaled_label(status, 15, Style.MUTED)
image_box.add_child(status)
var preview := Control.new()
preview.visible = false
preview.custom_minimum_size = Vector2(0, 260)
preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
preview.tooltip_text = "Open enlarged image"
preview.gui_input.connect(func(event: InputEvent): _media_preview_gui_input(event, media_index))
image_box.add_child(preview)
var texture_rect := TextureRect.new()
texture_rect.visible = false
texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
texture_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
preview.add_child(texture_rect)
var request := HTTPRequest.new()
request.request_completed.connect(
func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray):
_on_media_loaded(result, response_code, body, question_id, media_index, preview, texture_rect, status)
)
media_requests.append(request)
add_child(request)
var err := request.request(media_url)
if err != OK:
status.text = "Image unavailable."
func _clear_question_media() -> void:
_hide_media_viewer()
for request in media_requests:
if is_instance_valid(request):
request.queue_free()
media_requests.clear()
question_media_items.clear()
if media_container == null:
return
for child in media_container.get_children():
media_container.remove_child(child)
child.queue_free()
media_container.visible = false
func _resolve_media_url(value: String) -> String:
var media_url := value.strip_edges()
if media_url == "":
return ""
if media_url.begins_with("http://") or media_url.begins_with("https://"):
return media_url
if not media_url.begins_with("/"):
media_url = "/%s" % media_url
var base_url := QuestionBankService.question_api_base_url
var scheme_end := base_url.find("://")
if scheme_end < 0:
return media_url
var path_start := base_url.find("/", scheme_end + 3)
var origin := base_url if path_start < 0 else base_url.substr(0, path_start)
return "%s%s" % [origin, media_url]
func _on_media_loaded(
result: int,
response_code: int,
body: PackedByteArray,
question_id: String,
media_index: int,
preview: Control,
texture_rect: TextureRect,
status: Label) -> void:
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
status.text = "Image unavailable."
if media_index < question_media_items.size():
question_media_items[media_index]["failed"] = true
return
var image := Image.new()
var err := image.load_png_from_buffer(body)
if err != OK:
err = image.load_jpg_from_buffer(body)
if err != OK:
err = image.load_webp_from_buffer(body)
if err != OK:
status.text = "Image unavailable."
if media_index < question_media_items.size():
question_media_items[media_index]["failed"] = true
return
var texture := ImageTexture.create_from_image(image)
question_media_items[media_index]["texture"] = texture
question_media_items[media_index]["loaded"] = true
texture_rect.texture = texture
texture_rect.visible = true
preview.visible = true
status.visible = false
func _build_media_viewer() -> void:
media_viewer_overlay = Control.new()
media_viewer_overlay.visible = false
media_viewer_overlay.top_level = true
media_viewer_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
media_viewer_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(media_viewer_overlay)
media_viewer_background = ColorRect.new()
media_viewer_background.color = Style.BACKGROUND
media_viewer_background.mouse_filter = Control.MOUSE_FILTER_STOP
media_viewer_background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
media_viewer_overlay.add_child(media_viewer_background)
var layout := VBoxContainer.new()
layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
layout.offset_left = 16
layout.offset_top = 12
layout.offset_right = -16
layout.offset_bottom = -16
layout.add_theme_constant_override("separation", 10)
media_viewer_overlay.add_child(layout)
var toolbar := HBoxContainer.new()
toolbar.add_theme_constant_override("separation", 8)
layout.add_child(toolbar)
media_viewer_position_label = Label.new()
media_viewer_position_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(media_viewer_position_label, 17, Style.TEXT)
toolbar.add_child(media_viewer_position_label)
media_viewer_previous_button = _make_media_viewer_button("<", "Previous image")
media_viewer_previous_button.pressed.connect(func(): _show_adjacent_media(-1))
toolbar.add_child(media_viewer_previous_button)
media_viewer_next_button = _make_media_viewer_button(">", "Next image")
media_viewer_next_button.pressed.connect(func(): _show_adjacent_media(1))
toolbar.add_child(media_viewer_next_button)
var zoom_out := _make_media_viewer_button("-", "Zoom out")
zoom_out.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR))
toolbar.add_child(zoom_out)
media_viewer_zoom_label = Label.new()
media_viewer_zoom_label.custom_minimum_size = Vector2(64, 44)
media_viewer_zoom_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
media_viewer_zoom_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_set_scaled_label(media_viewer_zoom_label, 15, Style.TEXT)
toolbar.add_child(media_viewer_zoom_label)
var zoom_in := _make_media_viewer_button("+", "Zoom in")
zoom_in.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR))
toolbar.add_child(zoom_in)
var fit_button := _make_media_viewer_button("Fit", "Fit image to window", Vector2(60, 44))
fit_button.pressed.connect(_fit_media_viewer_image)
toolbar.add_child(fit_button)
var close_button := _make_media_viewer_button("X", "Close image viewer")
close_button.pressed.connect(_hide_media_viewer)
toolbar.add_child(close_button)
media_viewer_scroll = ScrollContainer.new()
media_viewer_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
media_viewer_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
media_viewer_scroll.gui_input.connect(_media_viewer_gui_input)
layout.add_child(media_viewer_scroll)
media_viewer_center = CenterContainer.new()
media_viewer_center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
media_viewer_center.size_flags_vertical = Control.SIZE_EXPAND_FILL
media_viewer_scroll.add_child(media_viewer_center)
media_viewer_texture = TextureRect.new()
media_viewer_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
media_viewer_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
media_viewer_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE
media_viewer_center.add_child(media_viewer_texture)
func _make_media_viewer_button(text_value: String, tooltip: String, minimum := Vector2(44, 44)) -> Button:
var button := Button.new()
button.text = text_value
button.tooltip_text = tooltip
button.custom_minimum_size = minimum
Style.style_chip(button, Style.CYAN)
UISoundService.set_button_sound(button, "toggle")
return button
func _media_preview_gui_input(event: InputEvent, media_index: int) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_show_media_viewer(media_index)
accept_event()
elif event is InputEventScreenTouch and event.pressed:
_show_media_viewer(media_index)
accept_event()
func _show_media_viewer(index: int) -> void:
if index < 0 or index >= question_media_items.size():
return
if not bool(question_media_items[index].get("loaded", false)):
return
var texture := question_media_items[index].get("texture") as Texture2D
if texture == null:
return
media_viewer_index = index
media_viewer_texture.texture = texture
media_viewer_overlay.visible = true
media_viewer_overlay.move_to_front()
_update_media_viewer_navigation()
call_deferred("_fit_media_viewer_image")
func _hide_media_viewer() -> void:
media_viewer_dragging = false
media_viewer_index = -1
if media_viewer_overlay != null:
media_viewer_overlay.visible = false
func _show_adjacent_media(direction: int) -> void:
var next_index := _find_loaded_media_index(media_viewer_index, direction)
if next_index >= 0:
_show_media_viewer(next_index)
func _find_loaded_media_index(from_index: int, direction: int) -> int:
var index := from_index + direction
while index >= 0 and index < question_media_items.size():
if bool(question_media_items[index].get("loaded", false)):
return index
index += direction
return -1
func _update_media_viewer_navigation() -> void:
media_viewer_position_label.text = "Image %d of %d" % [media_viewer_index + 1, question_media_items.size()]
media_viewer_previous_button.disabled = _find_loaded_media_index(media_viewer_index, -1) < 0
media_viewer_next_button.disabled = _find_loaded_media_index(media_viewer_index, 1) < 0
func _fit_media_viewer_image() -> void:
if media_viewer_texture == null or media_viewer_texture.texture == null:
return
var image_size := media_viewer_texture.texture.get_size()
var available := media_viewer_scroll.size - Vector2(24, 24)
if image_size.x <= 0.0 or image_size.y <= 0.0 or available.x <= 0.0 or available.y <= 0.0:
return
_set_media_viewer_zoom(minf(available.x / image_size.x, available.y / image_size.y))
func _set_media_viewer_zoom(value: float) -> void:
if media_viewer_texture == null or media_viewer_texture.texture == null:
return
media_viewer_zoom = clampf(value, MEDIA_ZOOM_MIN, MEDIA_ZOOM_MAX)
var scaled_size := media_viewer_texture.texture.get_size() * media_viewer_zoom
media_viewer_texture.custom_minimum_size = scaled_size
media_viewer_center.custom_minimum_size = Vector2(
maxf(media_viewer_scroll.size.x, scaled_size.x),
maxf(media_viewer_scroll.size.y, scaled_size.y))
media_viewer_zoom_label.text = "%d%%" % int(round(media_viewer_zoom * 100.0))
func _media_viewer_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
_set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR)
accept_event()
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
_set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR)
accept_event()
elif event.button_index == MOUSE_BUTTON_LEFT:
media_viewer_dragging = event.pressed
media_viewer_drag_position = event.position
accept_event()
elif event is InputEventMouseMotion and media_viewer_dragging:
var drag_delta: Vector2 = event.position - media_viewer_drag_position
media_viewer_drag_position = event.position
media_viewer_scroll.scroll_horizontal -= int(drag_delta.x)
media_viewer_scroll.scroll_vertical -= int(drag_delta.y)
accept_event()
elif event is InputEventMagnifyGesture:
_set_media_viewer_zoom(media_viewer_zoom * event.factor)
accept_event()
elif event is InputEventScreenDrag:
media_viewer_scroll.scroll_horizontal -= int(event.relative.x)
media_viewer_scroll.scroll_vertical -= int(event.relative.y)
accept_event()
func _build_report_modal() -> void:
report_overlay = Control.new()
report_overlay.visible = false
report_overlay.top_level = true
report_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
report_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
report_overlay.gui_input.connect(_report_overlay_gui_input)
add_child(report_overlay)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.78)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
report_overlay.add_child(dim)
var center := CenterContainer.new()
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
report_overlay.add_child(center)
report_panel = PanelContainer.new()
report_panel.custom_minimum_size = Vector2(700, 600)
report_panel.mouse_filter = Control.MOUSE_FILTER_STOP
report_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.RED))
center.add_child(report_panel)
var modal_body := VBoxContainer.new()
modal_body.add_theme_constant_override("separation", 14)
report_panel.add_child(modal_body)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 10)
modal_body.add_child(header)
var title := Label.new()
title.text = "Report question"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(title, 26, Style.TEXT)
header.add_child(title)
var close_button := Button.new()
close_button.text = "X"
close_button.tooltip_text = "Close report form"
close_button.custom_minimum_size = Vector2(42, 38)
Style.style_chip(close_button, Style.RED)
UISoundService.set_button_sound(close_button, "close")
close_button.pressed.connect(_hide_report_modal)
header.add_child(close_button)
report_form_content = VBoxContainer.new()
report_form_content.add_theme_constant_override("separation", 12)
modal_body.add_child(report_form_content)
var report_category_label := Label.new()
report_category_label.text = "What is the problem?"
_set_scaled_label(report_category_label, 18, Style.CYAN)
report_form_content.add_child(report_category_label)
var category_grid := GridContainer.new()
category_grid.columns = 2
category_grid.add_theme_constant_override("h_separation", 18)
category_grid.add_theme_constant_override("v_separation", 8)
report_form_content.add_child(category_grid)
report_category_group = ButtonGroup.new()
report_category_group.allow_unpress = false
for category in REPORT_CATEGORIES:
var category_button := CheckBox.new()
category_button.text = str(category["label"])
category_button.set_meta("report_category_id", str(category["id"]))
category_button.button_group = report_category_group
category_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
category_button.custom_minimum_size = Vector2(0, 38)
_set_scaled_button_font(category_button, 16)
category_button.toggled.connect(_on_report_category_toggled)
category_grid.add_child(category_button)
report_category_buttons.append(category_button)
var details_header := HBoxContainer.new()
report_form_content.add_child(details_header)
var details_label := Label.new()
details_label.text = "Details"
details_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(details_label, 18, Style.CYAN)
details_header.add_child(details_label)
report_char_count = Label.new()
report_char_count.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_set_scaled_label(report_char_count, 14, Style.MUTED)
details_header.add_child(report_char_count)
report_details = TextEdit.new()
report_details.placeholder_text = "Describe what is wrong and what should be reviewed."
report_details.custom_minimum_size = Vector2(0, 190)
report_details.size_flags_vertical = Control.SIZE_EXPAND_FILL
report_details.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
report_details.add_theme_font_size_override("font_size", 16)
report_details.add_theme_color_override("font_color", Style.TEXT)
report_details.add_theme_color_override("font_placeholder_color", Style.MUTED)
report_details.add_theme_stylebox_override("normal", Style.panel_box(Style.SURFACE_DARK, Color("3b4964")))
report_details.text_changed.connect(_on_report_details_changed)
report_form_content.add_child(report_details)
report_error_label = Label.new()
report_error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
report_error_label.custom_minimum_size = Vector2(0, 28)
_set_scaled_label(report_error_label, 15, Style.RED)
report_form_content.add_child(report_error_label)
var report_actions := HBoxContainer.new()
report_actions.add_theme_constant_override("separation", 10)
report_form_content.add_child(report_actions)
report_cancel_button = Button.new()
report_cancel_button.text = "Cancel"
report_cancel_button.custom_minimum_size = Vector2(130, 46)
Style.style_chip(report_cancel_button, Style.MUTED)
UISoundService.set_button_sound(report_cancel_button, "close")
report_cancel_button.pressed.connect(_hide_report_modal)
report_actions.add_child(report_cancel_button)
report_submit_button = Button.new()
report_submit_button.text = "Submit report"
report_submit_button.custom_minimum_size = Vector2(170, 46)
report_submit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Style.style_primary_button(report_submit_button)
UISoundService.set_button_sound(report_submit_button, "confirmation")
report_submit_button.pressed.connect(_submit_question_report)
report_actions.add_child(report_submit_button)
report_success_content = VBoxContainer.new()
report_success_content.visible = false
report_success_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
report_success_content.alignment = BoxContainer.ALIGNMENT_CENTER
report_success_content.add_theme_constant_override("separation", 18)
modal_body.add_child(report_success_content)
report_success_label = Label.new()
report_success_label.text = "Report submitted"
report_success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
report_success_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(report_success_label, 24, Style.LIME)
report_success_content.add_child(report_success_label)
var success_close_button := Button.new()
success_close_button.text = "Close"
success_close_button.custom_minimum_size = Vector2(180, 48)
Style.style_primary_button(success_close_button)
UISoundService.set_button_sound(success_close_button, "close")
success_close_button.pressed.connect(_hide_report_modal)
report_success_content.add_child(success_close_button)
_update_report_form_state()
func _report_overlay_gui_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
return
if report_panel != null and not report_panel.get_global_rect().has_point(mouse_event.global_position):
_hide_report_modal()
get_viewport().set_input_as_handled()
func _show_report_modal(question_id: String) -> void:
if report_overlay == null or report_is_submitting:
return
_hide_labs_modal()
report_question_id = question_id
report_form_content.visible = true
report_success_content.visible = false
report_details.text = ""
report_error_label.text = ""
for category_button in report_category_buttons:
category_button.button_pressed = false
_set_report_submitting(false)
_update_report_form_state()
report_overlay.visible = true
report_overlay.move_to_front()
BlockSessionService.pause_current_question_timer()
func _hide_report_modal(force: bool = false) -> void:
if report_overlay == null or (report_is_submitting and not force):
return
if report_overlay.visible:
report_overlay.visible = false
BlockSessionService.resume_current_question_timer()
if not report_is_submitting:
report_question_id = ""
func _on_report_category_toggled(pressed: bool) -> void:
if pressed:
_update_report_form_state()
func _on_report_details_changed() -> void:
_update_report_form_state()
func _selected_report_category() -> String:
if report_category_group == null:
return ""
var selected := report_category_group.get_pressed_button()
if selected == null:
return ""
return str(selected.get_meta("report_category_id", ""))
func _update_report_form_state() -> void:
if report_details == null or report_submit_button == null:
return
var details_length := report_details.text.strip_edges().length()
report_char_count.text = "%d / %d" % [details_length, REPORT_DETAILS_MAX]
report_char_count.add_theme_color_override("font_color", Style.RED if details_length > REPORT_DETAILS_MAX else Style.MUTED)
if not report_is_submitting:
report_error_label.text = ""
report_submit_button.disabled = (
report_is_submitting
or _selected_report_category() == ""
or details_length < REPORT_DETAILS_MIN
or details_length > REPORT_DETAILS_MAX
)
func _set_report_submitting(value: bool) -> void:
report_is_submitting = value
if report_details != null:
report_details.editable = not value
for category_button in report_category_buttons:
category_button.disabled = value
if report_cancel_button != null:
report_cancel_button.disabled = value
if report_submit_button != null:
report_submit_button.text = "Submitting..." if value else "Submit report"
_update_report_form_state()
func _submit_question_report() -> void:
if report_is_submitting or report_question_id == "":
return
var category := _selected_report_category()
var details := report_details.text.strip_edges()
if category == "" or details.length() < REPORT_DETAILS_MIN or details.length() > REPORT_DETAILS_MAX:
_update_report_form_state()
return
report_error_label.text = ""
_set_report_submitting(true)
if BlockSessionService.session_kind == "dynamic":
DynamicQuestionService.submit_report(report_question_id, category, details)
else:
QuestionBankService.submit_question_report(report_question_id, category, details)
func _on_question_report_submitted(question_id: String, _report_id: String) -> void:
if question_id != report_question_id:
return
_set_report_submitting(false)
if not reported_question_ids.has(question_id):
reported_question_ids.append(question_id)
_refresh_toolbar_state()
report_form_content.visible = false
report_success_content.visible = true
report_success_label.text = "Report submitted\nThank you for helping improve this question."
UISoundService.play_sound("confirmation")
func _on_question_report_failed(question_id: String, message: String) -> void:
if question_id != report_question_id:
return
_set_report_submitting(false)
report_error_label.text = message
UISoundService.play_sound("error")
func _build_labs_modal() -> void:
labs_overlay = Control.new()
labs_overlay.visible = false
labs_overlay.top_level = true
labs_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
labs_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
labs_overlay.gui_input.connect(_labs_overlay_gui_input)
add_child(labs_overlay)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.72)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
labs_overlay.add_child(dim)
var center := CenterContainer.new()
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
labs_overlay.add_child(center)
labs_panel = PanelContainer.new()
labs_panel.custom_minimum_size = Vector2(760, 430)
labs_panel.mouse_filter = Control.MOUSE_FILTER_STOP
labs_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.LIME))
center.add_child(labs_panel)
var modal_body := VBoxContainer.new()
modal_body.add_theme_constant_override("separation", 14)
labs_panel.add_child(modal_body)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 10)
modal_body.add_child(header)
var title := Label.new()
title.text = "Lab Results"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(title, 26, Style.TEXT)
header.add_child(title)
var close_button := Button.new()
close_button.text = "X"
close_button.tooltip_text = "Close lab results"
close_button.custom_minimum_size = Vector2(42, 38)
Style.style_chip(close_button, Style.RED)
UISoundService.set_button_sound(close_button, "close")
close_button.pressed.connect(_hide_labs_modal)
header.add_child(close_button)
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(0, 320)
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
modal_body.add_child(scroll)
labs_content = VBoxContainer.new()
labs_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
labs_content.add_theme_constant_override("separation", 8)
scroll.add_child(labs_content)
func _labs_overlay_gui_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
return
if labs_panel == null or not labs_panel.get_global_rect().has_point(mouse_event.global_position):
_hide_labs_modal()
get_viewport().set_input_as_handled()
func _show_labs_modal() -> void:
if labs_overlay == null or labs_content == null:
return
var question := BlockSessionService.get_current_question()
var labs: Array = question.get("labs", [])
if labs.is_empty():
return
_populate_labs_modal(labs)
labs_overlay.visible = true
labs_overlay.move_to_front()
func _hide_labs_modal() -> void:
if labs_overlay != null:
labs_overlay.visible = false
func _populate_labs_modal(labs: Array) -> void:
for child in labs_content.get_children():
labs_content.remove_child(child)
child.queue_free()
var grid := GridContainer.new()
grid.columns = 3
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
grid.add_theme_constant_override("h_separation", 16)
grid.add_theme_constant_override("v_separation", 8)
labs_content.add_child(grid)
_add_lab_table_label(grid, "Test", Style.CYAN, true)
_add_lab_table_label(grid, "Value", Style.CYAN, true)
_add_lab_table_label(grid, "Reference", Style.CYAN, true)
for lab in labs:
if typeof(lab) != TYPE_DICTIONARY:
continue
var lab_dict: Dictionary = lab
_add_lab_table_label(grid, str(lab_dict.get("name", "-")), Style.TEXT)
_add_lab_table_label(grid, _format_lab_value(lab_dict), Style.TEXT)
_add_lab_table_label(grid, str(lab_dict.get("reference", "-")), Style.MUTED)
func _add_lab_table_label(parent: GridContainer, text: String, color: Color, is_header: bool = false) -> void:
var label := Label.new()
label.text = text if text.strip_edges() != "" else "-"
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(label, 16 if not is_header else 17, color)
parent.add_child(label)
func _format_lab_value(lab: Dictionary) -> String:
var value := str(lab.get("value", "")).strip_edges()
var unit := str(lab.get("unit", "")).strip_edges()
if value == "" and unit == "":
return "-"
if unit == "":
return value
if value == "":
return unit
return "%s %s" % [value, unit]
func _render_stem() -> void:
_render_highlighted_text("stem")
func _render_explanation() -> void:
_render_highlighted_text("explanation")
func _render_highlighted_text(source: String) -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
var highlight_store := question_highlights if source == "stem" else explanation_highlights
var plain_text := stem_plain_text if source == "stem" else explanation_plain_text
var target_label := stem_label if source == "stem" else explanation_label
var ranges: Array = highlight_store.get(question_id, []).duplicate()
ranges.sort_custom(func(a: Dictionary, b: Dictionary): return int(a["start"]) < int(b["start"]))
var rendered := ""
var cursor := 0
for highlight_range in ranges:
var start: int = clamp(int(highlight_range.get("start", 0)), 0, plain_text.length())
var end: int = clamp(int(highlight_range.get("end", 0)), start, plain_text.length())
if start < cursor:
continue
rendered += _escape_bbcode(plain_text.substr(cursor, start - cursor))
rendered += "[bgcolor=yellow][color=black]%s[/color][/bgcolor]" % _escape_bbcode(plain_text.substr(start, end - start))
cursor = end
rendered += _escape_bbcode(plain_text.substr(cursor))
target_label.text = rendered
func _escape_bbcode(value: String) -> String:
return value.replace("[", "[lb]").replace("]", "[rb]")
func _subtract_highlight_range(highlights: Array, start: int, end: int) -> bool:
var changed := false
for index in range(highlights.size() - 1, -1, -1):
var highlight_range: Dictionary = highlights[index]
var highlight_start := int(highlight_range.get("start", 0))
var highlight_end := int(highlight_range.get("end", 0))
if start < highlight_end and end > highlight_start:
highlights.remove_at(index)
if highlight_start < start:
highlights.insert(index, {"start": highlight_start, "end": start})
index += 1
if end < highlight_end:
highlights.insert(index, {"start": end, "end": highlight_end})
changed = true
return changed
func _apply_saved_choice_state() -> void:
var choices := _get_presented_choices()
var answer_is_locked := _is_correct_answer_locked()
for index in range(choices.size()):
var id := str(choices[index].get("id", ""))
_set_choice_button_text(index, choices[index])
choice_buttons[index].disabled = answer_is_locked or locked_wrong_choice_ids.has(id)
_style_choice_button_for_state(index, id)
choice_buttons[index].modulate = Color.WHITE
if pending_correct_advance or bool(current_answer.get("is_correct", false)):
_apply_correct_answer_preview()
if followup_required:
for index in range(choices.size()):
var id := str(choices[index].get("id", ""))
choice_buttons[index].disabled = id != active_followup_choice_id
_refresh_submit_state()
func _is_correct_answer_locked() -> bool:
return bool(current_answer.get("is_correct", false))
func _refresh_submit_state() -> void:
if next_button == null:
return
if BlockSessionService.session_kind == "simulation":
next_button.disabled = selected_choice == ""
next_button.text = "Submit Block" if selected_choice != "" and _next_pending_simulation_index() < 0 else "Next"
return
if pending_correct_advance or followup_required:
next_button.disabled = true
return
if not current_answer.is_empty():
next_button.disabled = false
return
next_button.disabled = selected_choice == ""
func _set_choice_button_text(index: int, choice: Dictionary) -> void:
var choice_id := str(choice.get("id", ""))
var choice_text := str(choice.get("text", ""))
var display_label := _display_label_for_index(index)
if index < choice_badges.size():
choice_badges[index].text = display_label
_set_choice_percent_label(index, "")
_set_choice_top_label(index, "")
var reviewing := pending_correct_advance or not current_answer.is_empty()
if locked_wrong_choice_ids.has(choice_id) or reviewing:
var percentage := _get_choice_selection_percentage(choice_id)
if percentage >= 0:
_set_choice_percent_label(index, "%d%%" % percentage)
if eliminated.has(choice_id):
choice_buttons[index].text = ""
_set_choice_top_label(index, "[s]%s[/s]" % _escape_bbcode(choice_text))
else:
choice_buttons[index].text = choice_text
func _style_choice_button_for_state(index: int, choice_id: String) -> void:
if index < 0 or index >= choice_buttons.size():
return
var dark: bool = true if theme_service == null else theme_service.is_dark_mode()
var state := "default"
var correct_id := str(BlockSessionService.get_current_question().get("correct_choice_id", ""))
var reviewing := pending_correct_advance or not current_answer.is_empty()
if reviewing and choice_id == correct_id:
state = "correct"
elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id != "" and choice_id != correct_id):
state = "wrong"
elif choice_id == selected_choice:
state = "selected"
elif eliminated.has(choice_id):
state = "eliminated"
var button := choice_buttons[index]
var attached := _row_has_attached_panel(index)
var normal_box := _answer_box(dark, state, attached)
button.add_theme_stylebox_override("normal", normal_box)
button.add_theme_stylebox_override("disabled", normal_box)
button.add_theme_stylebox_override("pressed", _answer_box(dark, "selected" if state == "default" else state, attached))
button.add_theme_stylebox_override("hover", _answer_box(dark, "selected" if state == "default" else state, attached))
var text_color := Brand.muted_color(dark) if state == "eliminated" else Brand.text_color(dark)
button.add_theme_color_override("font_color", text_color)
button.add_theme_color_override("font_disabled_color", text_color)
if index < choice_badges.size():
var badge := choice_badges[index]
var accent := Brand.SLATE
if state == "selected": accent = Brand.PURPLE
elif state == "correct": accent = Brand.SUCCESS
elif state == "wrong": accent = Color("fb7185")
var filled := state in ["selected", "correct", "wrong"]
if index < choice_badge_textures.size():
var badge_texture := choice_badge_textures[index]
badge_texture.texture = BADGE_CIRCLE_FILLED_ICON if filled else BADGE_CIRCLE_OUTLINE_ICON
badge_texture.modulate = accent
badge.add_theme_color_override("font_color", Color.WHITE if filled else Brand.muted_color(dark))
if index < choice_selected_icons.size():
choice_selected_icons[index].visible = state == "selected"
func _row_has_attached_panel(index: int) -> bool:
if index < 0 or index >= choice_rows.size():
return false
if index < choice_followup_panels.size() and choice_followup_panels[index].visible:
return true
if correct_review_panel != null and correct_review_panel.visible and correct_review_panel.get_parent() == choice_rows[index]:
return true
return false
func _mark_review_input_handled() -> void:
get_viewport().set_input_as_handled()
func _set_choice_percent_label(index: int, text: String) -> void:
if index < 0 or index >= choice_percent_labels.size():
return
var label := choice_percent_labels[index]
label.text = text
label.visible = text != ""
func _set_choice_top_label(index: int, text: String) -> void:
if index < 0 or index >= choice_top_labels.size():
return
var label := choice_top_labels[index]
label.text = text
label.visible = text != ""
func _get_choice_selection_percentage(choice_id: String) -> int:
var question := BlockSessionService.get_current_question()
var percentages: Dictionary = question.get("choice_selection_percentages", {})
if percentages.is_empty():
return -1
if not percentages.has(choice_id):
return 0
return clamp(int(percentages.get(choice_id, -1)), 0, 100)
func _apply_correct_answer_preview() -> void:
var question := BlockSessionService.get_current_question()
var correct_id := str(question.get("correct_choice_id", ""))
var choices := _get_presented_choices()
for index in range(choices.size()):
if str(choices[index].get("id", "")) == correct_id:
_style_choice_button_for_state(index, correct_id)
return
func _on_choice_percentages_updated(question_id: String, percentages: Dictionary) -> void:
if not visible:
return
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
BlockSessionService.update_question_choice_percentages(question_id, percentages)
_apply_saved_choice_state()
func _show_wrong_answer_followup(choice_id: String) -> void:
active_followup_choice_id = choice_id
followup_required = true
followup_answered = false
var index := _choice_index_for_id(choice_id)
if index < 0:
_unlock_after_followup()
return
_build_followup_panel(index, choice_id)
if question_scroll != null:
question_scroll.call_deferred("ensure_control_visible", choice_followup_panels[index])
func _build_followup_panel(_index: int, choice_id: String) -> void:
var followup := _get_followup_for_choice(choice_id)
var followup_choices: Array = followup.get("choices", []).duplicate(true)
followup_choices.shuffle()
followup_states[choice_id] = {
"choice_id": choice_id,
"choice_text": _choice_text_for_id(choice_id),
"prompt": str(followup.get("prompt", "")),
"choices": followup_choices,
"correct_choice_id": str(followup.get("correct_choice_id", "")),
"teaching_point": str(followup.get("teaching_point", "")),
"selected_option_id": "",
"selected_option_text": "",
"was_correct": false,
"resolved": false,
"has_authored_choices": bool(followup.get("has_authored_choices", false)),
"is_main_correct": false
}
_append_followup_attempt(choice_id)
_render_followup_state(choice_id)
func _append_followup_attempt(choice_id: String) -> void:
if not followup_attempt_order.has(choice_id):
followup_attempt_order.append(choice_id)
func _render_active_followup(index: int, state: Dictionary) -> void:
var content := choice_followup_contents[index]
var body_content := _add_followup_body(content, false)
var prompt := Label.new()
prompt.text = str(state.get("prompt", "What finding in the stem makes this answer less appropriate?"))
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(prompt, 14, Brand.text_color(_followup_is_dark()))
body_content.add_child(prompt)
var options: Array = state.get("choices", [])
if bool(state.get("has_authored_choices", false)):
for option_index in range(options.size()):
var option = options[option_index]
if typeof(option) != TYPE_DICTIONARY:
continue
var option_button := Button.new()
option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))]
option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
option_button.custom_minimum_size = Vector2(0, 38)
Style.style_chip(option_button, Brand.PURPLE)
UISoundService.set_button_sound(option_button, "select")
_set_scaled_button_font(option_button, 14)
option_button.set_meta("option_id", str(option.get("id", "")))
option_button.pressed.connect(func(option_id := str(option.get("id", "")), selected_button := option_button, followup_data := state):
_answer_followup(option_id, selected_button, followup_data)
)
body_content.add_child(option_button)
else:
var continue_button := Button.new()
continue_button.text = "Continue"
continue_button.custom_minimum_size = Vector2(130, 38)
continue_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.style_chip(continue_button, Brand.PURPLE)
_set_scaled_button_font(continue_button, 14)
continue_button.pressed.connect(func(followup_data := state): _resolve_followup(followup_data, null, false, false))
body_content.add_child(continue_button)
func _answer_followup(option_id: String, selected_button: Button, followup: Dictionary) -> void:
if not followup_required or followup_answered:
return
var correct_id := str(followup.get("correct_choice_id", ""))
var is_correct := option_id == correct_id
_resolve_followup(followup, selected_button, true, is_correct)
func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_selected: bool, is_correct: bool) -> void:
if not followup_required or followup_answered:
return
followup_answered = true
var resolved_choice_id := active_followup_choice_id
var selected_option_text := selected_button.text if selected_button != null else "Continued"
if followup_states.has(resolved_choice_id):
var state: Dictionary = followup_states[resolved_choice_id]
state["selected_option_id"] = str(selected_button.get_meta("option_id", "")) if selected_button != null and selected_button.has_meta("option_id") else ""
state["selected_option_text"] = selected_option_text
state["was_correct"] = is_correct
state["resolved"] = true
followup_states[resolved_choice_id] = state
_render_followup_state(resolved_choice_id)
_unlock_after_followup()
func _unlock_after_followup() -> void:
active_followup_choice_id = ""
followup_required = false
next_button.disabled = false
if skip_button != null:
skip_button.disabled = false
_apply_saved_choice_state()
func _render_followup_state(choice_id: String) -> void:
if not followup_states.has(choice_id):
return
var index := _choice_index_for_id(choice_id)
if index < 0 or index >= choice_followup_panels.size():
return
var state: Dictionary = followup_states[choice_id]
_clear_followup_content(index)
_style_followup_card(index, state)
if not bool(state.get("resolved", false)):
_render_active_followup(index, state)
else:
_build_resolved_followup(index, state)
choice_followup_panels[index].visible = true
_style_choice_button_for_state(index, choice_id)
func _build_resolved_followup(index: int, state: Dictionary) -> void:
var content := choice_followup_contents[index]
var explanation := Label.new()
explanation.text = _strip_verdict_prefix(_choice_review_explanation(str(state.get("choice_id", "")), state))
explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark()))
content.add_child(explanation)
func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContainer:
var body := PanelContainer.new()
body.add_theme_stylebox_override("panel", _followup_body_box(_followup_is_dark(), is_correct))
content.add_child(body)
var body_content := VBoxContainer.new()
body_content.add_theme_constant_override("separation", 8)
body.add_child(body_content)
return body_content
func _style_followup_card(index: int, state: Dictionary) -> void:
if index < 0 or index >= choice_followup_panels.size():
return
var is_correct := bool(state.get("is_main_correct", false))
choice_followup_panels[index].add_theme_stylebox_override(
"panel",
_attached_panel_box(_followup_is_dark(), Brand.SUCCESS if is_correct else Color("fb7185"))
)
func _followup_body_box(dark: bool, is_correct: bool) -> StyleBoxFlat:
var accent := Brand.SUCCESS if is_correct else Color("fb5f6d")
var box := StyleBoxFlat.new()
box.bg_color = Color(accent, 0.07 if dark else 0.045)
box.border_color = Color(accent, 0.34)
box.set_border_width_all(1)
box.set_corner_radius_all(6)
box.content_margin_left = 14
box.content_margin_right = 14
box.content_margin_top = 12
box.content_margin_bottom = 12
return box
func _followup_is_dark() -> bool:
if theme_service == null:
return true
return theme_service.is_dark_mode()
func _choice_review_explanation(choice_id: String, state: Dictionary) -> String:
var question := BlockSessionService.get_current_question()
var explanations: Dictionary = question.get("choice_explanations", {})
var explanation := str(explanations.get(choice_id, "")).strip_edges()
if explanation != "":
return explanation
var teaching_point := str(state.get("teaching_point", "")).strip_edges()
if teaching_point != "":
return teaching_point
if bool(state.get("is_main_correct", false)):
return str(question.get("explanation", "Review the correct answer explanation."))
return "Review the stem finding that makes this option less appropriate."
func _ensure_answer_review_followups() -> void:
for choice_id in followup_attempt_order:
_render_followup_state(str(choice_id))
func _clear_followups() -> void:
active_followup_choice_id = ""
followup_required = false
followup_answered = false
followup_states.clear()
followup_attempt_order.clear()
for index in range(choice_followup_panels.size()):
_clear_followup_content(index)
choice_followup_panels[index].visible = false
func _clear_followup_content(index: int) -> void:
if index < 0 or index >= choice_followup_contents.size():
return
for child in choice_followup_contents[index].get_children():
choice_followup_contents[index].remove_child(child)
child.queue_free()
func _get_followup_for_choice(choice_id: String) -> Dictionary:
var question := BlockSessionService.get_current_question()
var followups: Dictionary = question.get("wrong_answer_followups", {})
if followups.has(choice_id) and typeof(followups[choice_id]) == TYPE_DICTIONARY:
var authored: Dictionary = followups[choice_id]
var choices: Array = authored.get("choices", [])
return {
"prompt": str(authored.get("prompt", "What finding in the stem makes this answer less appropriate?")),
"choices": choices,
"has_authored_choices": not choices.is_empty(),
"correct_choice_id": str(authored.get("correctChoiceId", authored.get("correct_choice_id", ""))),
"teaching_point": str(authored.get("teachingPoint", authored.get("teaching_point", "Review the relevant stem finding before choosing again.")))
}
return {
"prompt": "No follow-up has been authored for this answer yet. Re-read the stem before retrying.",
"choices": [],
"has_authored_choices": false,
"correct_choice_id": "",
"teaching_point": "Review the stem for the finding that makes this answer less appropriate before trying again."
}
func _get_presented_choices() -> Array:
if not presented_choices.is_empty():
return presented_choices
return BlockSessionService.get_current_question().get("choices", [])
func _presented_choice_ids() -> Array:
var ids: Array = []
for choice in _get_presented_choices():
if typeof(choice) == TYPE_DICTIONARY:
ids.append(str(choice.get("id", "")))
return ids
func _display_label_for_index(index: int) -> String:
return String.chr(65 + index)
func _display_label_for_choice_id(choice_id: String) -> String:
var index := _choice_index_for_id(choice_id)
return _display_label_for_index(index) if index >= 0 else choice_id
func _choice_index_for_id(choice_id: String) -> int:
var choices := _get_presented_choices()
for index in range(choices.size()):
if str(choices[index].get("id", "")) == choice_id:
return index
return -1
func _choice_text_for_id(choice_id: String) -> String:
var choices := _get_presented_choices()
for choice in choices:
if typeof(choice) == TYPE_DICTIONARY and str(choice.get("id", "")) == choice_id:
return str(choice.get("text", ""))
return ""
func _on_question_rating_updated(question_id: String, question_elo: int, _rating: Dictionary) -> void:
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
BlockSessionService.update_question_elo(question_id, question_elo)
_update_question_elo_label(not current_answer.is_empty() or pending_correct_advance)
_apply_saved_choice_state()
func _on_question_skipped(question_id: String, correct_choice_id: String, question_elo: int, _rating: Dictionary) -> void:
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
var question := BlockSessionService.get_current_question()
if correct_choice_id != "":
question["correct_choice_id"] = correct_choice_id
BlockSessionService.update_question_elo(question_id, question_elo)
_update_question_elo_label(true)
_apply_saved_choice_state()
func _update_question_elo_label(show_label: bool) -> void:
if question_elo_label == null:
return
if not show_label:
question_elo_label.visible = false
return
var question := BlockSessionService.get_current_question()
var question_elo := int(question.get("question_elo", 1000))
question_elo_label.text = "Question ELO %d" % question_elo
question_elo_label.visible = true
func _refresh_nav_palette() -> void:
if nav_palette == null:
return
for child in nav_palette.get_children():
child.queue_free()
if BlockSessionService.session_kind in ["core", "review"]:
return
for index in range(BlockSessionService.active_questions.size()):
var button := Button.new()
button.text = str(index + 1)
button.custom_minimum_size = Vector2(34, 34)
var answer = BlockSessionService.get_answer_for_index(index)
if index == BlockSessionService.current_index:
Style.style_chip(button, Style.CYAN)
elif answer != null and bool(answer.get("flagged", false)):
Style.style_chip(button, Style.AMBER)
elif answer != null and str(answer.get("selected_choice_id", "")) != "":
Style.style_chip(button, Style.LIME)
else:
Style.style_chip(button, Color("596982"))
button.pressed.connect(_navigate_to_question.bind(index))
nav_palette.add_child(button)