Files
step3/scripts/ui/question_player.gd
T
hz 4db5b9d374 Drop the redundant status icon and "Review: B..." label from follow-up panels
The choice button above the panel already shows the letter badge,
choice text, and percentage, so repeating them in the panel header
was redundant now that the panel is docked inside the choice's card.
Keeps the collapse toggle, now named FollowupToggle since the old
FollowupSummary button it lived on is gone.
2026-07-23 01:47:11 -05:00

3573 lines
137 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 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 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 timer_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 wrong_options_section: VBoxContainer
var wrong_options_content: VBoxContainer
var review_followup_states: Array = []
var wrong_option_expanded := {}
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 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 question_toolbar: HFlowContainer
var header_stack: VBoxContainer
var header_row: 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 feedback_fog: ColorRect
var labs_overlay: Control
var labs_panel: PanelContainer
var labs_content: VBoxContainer
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
func _ready() -> void:
theme_service = get_node("/root/UIThemeService")
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 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_row = HBoxContainer.new()
header_row.add_theme_constant_override("separation", 10)
header_stack.add_child(header_row)
progress_button = Button.new()
progress_button.flat = true
progress_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
progress_button.custom_minimum_size = Vector2(126, 34)
progress_button.tooltip_text = "Open question navigator"
_set_scaled_button_font(progress_button, 14)
progress_button.pressed.connect(_toggle_question_navigator)
header_row.add_child(progress_button)
var header_spacer := Control.new()
header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header_row.add_child(header_spacer)
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
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)
question_toolbar.add_child(theme_button)
_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))
question_toolbar.add_child(text_smaller_button)
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))
question_toolbar.add_child(text_larger_button)
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)
question_toolbar.add_child(labs_button)
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)
question_toolbar.add_child(clear_highlight_button)
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()
)
question_toolbar.add_child(flag_button)
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)
question_toolbar.add_child(flashcard_button)
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)
question_toolbar.add_child(bookmark_button)
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)
question_toolbar.add_child(report_button)
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())
question_toolbar.add_child(notes_button)
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())
question_toolbar.add_child(app_feedback_button)
question_toolbar.move_child(labs_button, 0)
question_toolbar.move_child(theme_button, question_toolbar.get_child_count() - 2)
header_toolbar_host = HBoxContainer.new()
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
header_toolbar_host.add_child(question_toolbar)
header_row.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)
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)
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 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", 10)
skip_button = Button.new()
skip_button.text = "Skip"
skip_button.custom_minimum_size = Vector2(120, 48)
Style.style_chip(skip_button, Style.AMBER)
UISoundService.set_button_sound(skip_button, "back")
skip_button.pressed.connect(_skip_current_question)
actions.add_child(skip_button)
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)
next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Style.style_primary_button(next_button)
next_button.pressed.connect(_submit_or_next)
actions.add_child(next_button)
card_content.add_child(actions)
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)
timer_footer.add_child(timer_label)
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()
_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)
wrong_options_section = VBoxContainer.new()
wrong_options_section.visible = false
wrong_options_section.add_theme_constant_override("separation", 8)
question_content.add_child(wrong_options_section)
var wrong_options_heading := Label.new()
wrong_options_heading.text = "WHY OTHER OPTIONS ARE INCORRECT"
wrong_options_heading.name = "WrongOptionsHeading"
_set_scaled_label(wrong_options_heading, 15, Brand.text_color(true))
wrong_options_section.add_child(wrong_options_heading)
wrong_options_content = VBoxContainer.new()
wrong_options_content.add_theme_constant_override("separation", 6)
wrong_options_section.add_child(wrong_options_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_row == null or header_stack == null:
return
var compact := size.x < 1120.0
var target_parent: Control = header_row
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
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 520.0
app_feedback_button.text = "" if size.x < 760.0 else "Feedback"
app_feedback_button.custom_minimum_size.x = 44 if size.x < 760.0 else 92
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":
# Flat, divider-style row - most choices sit in a plain list; only the
# states below need to visually pop out as their own card.
box.bg_color = Color("0b172b") if dark else Color("ffffff")
box.border_color = Color("22304d") if dark else Color("e7ebf2")
box.border_width_bottom = 1
box.set_corner_radius_all(0)
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 _theme_toggle_box(dark: bool, hovered: bool = false) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE
box.border_color = Brand.AMBER if hovered else (Color("33415d") if dark else Brand.LIGHT_BORDER)
box.set_border_width_all(1)
box.set_corner_radius_all(22)
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"
theme_button.add_theme_stylebox_override("normal", _theme_toggle_box(dark))
theme_button.add_theme_stylebox_override("hover", _theme_toggle_box(dark, true))
theme_button.add_theme_stylebox_override("pressed", _theme_toggle_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 _wrong_option_box(dark: bool) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("0b172b") if dark else Color("ffffff")
box.border_color = Color("2b3958") if dark else Brand.LIGHT_BORDER
box.set_border_width_all(1)
box.set_corner_radius_all(6)
box.content_margin_left = 10
box.content_margin_right = 10
box.content_margin_top = 6
box.content_margin_bottom = 6
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))
timer_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))
var wrong_options_heading := find_child("WrongOptionsHeading", true, false) as Label
if wrong_options_heading != null:
wrong_options_heading.add_theme_color_override("font_color", Brand.text_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 [text_smaller_button, text_larger_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 skip_button != null:
skip_button.add_theme_stylebox_override("normal", _toolbar_box(dark))
skip_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true))
skip_button.add_theme_color_override("font_color", Brand.text_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))
if correct_review_panel != null and correct_review_panel.visible:
_render_teaching_cards()
_render_review_followups()
_render_wrong_option_reviews()
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()
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()
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 not visible or pending_correct_advance or stem_label == null:
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 != "review"
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")
else:
main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
return
BookmarkService.observe_question(question)
progress_button.text = _question_progress_text()
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review"]
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review"] else "Open question navigator"
var show_timer := _is_session_timer_visible()
timer_label.visible = show_timer
timer_bar.visible = show_timer
timer_footer.visible = show_timer
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)))
_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", "")
_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.get_global_rect().has_point(pointer_position):
return false
if report_button != null 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":
_save_simulation_current_response()
var target_index := _next_revisitable_index()
if target_index >= 0 and BlockSessionService.move_to(target_index):
_load_question()
elif BlockSessionService.session_kind == "simulation" and not simulation_submit_pending:
simulation_submit_pending = true
next_button.disabled = true
next_button.text = "Submitting..."
ExamSimulationService.submit_block()
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 _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":
_save_simulation_current_response()
if BlockSessionService.current_index + 1 < BlockSessionService.active_questions.size():
BlockSessionService.move_next()
_load_question()
return
elif 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 in ["core", "bookmark", "review"]:
_collapse_resolved_followups()
var attempted_choice := selected_choice
if BlockSessionService.session_kind == "core":
QuestionBankService.record_first_attempt(
question_id_for_attempt,
attempted_choice,
_presented_choice_ids(),
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))
)
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()
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 == "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 _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:
_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
_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_wrong_option_reviews()
func _clear_answer_review() -> void:
if explanation_label != null:
explanation_label.visible = false
if correct_review_panel != null:
correct_review_panel.visible = false
_clear_container_children(teaching_cards)
_clear_container_children(review_followups_content)
_clear_container_children(wrong_options_content)
review_followup_states.clear()
wrong_option_expanded.clear()
if review_followups_section != null:
review_followups_section.visible = false
if wrong_options_section != null:
wrong_options_section.visible = false
_update_content_vertical_alignment()
func _update_content_vertical_alignment() -> void:
if question_content == null:
return
# Before an answer is submitted the stem+choices block is usually shorter than the
# available panel height; center it so it doesn't sit pinned to the top with a slab
# of empty space below. Once the answer review content appears, go back to top
# alignment so the growing explanation/follow-up sections read top-down and scroll
# naturally instead of being centered mid-panel.
var answered := correct_review_panel != null and correct_review_panel.visible
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN if answered else BoxContainer.ALIGNMENT_CENTER
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_correct_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)
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_wrong_option_reviews() -> void:
if wrong_options_content == null:
return
_clear_container_children(wrong_options_content)
var question := BlockSessionService.get_current_question()
var correct_id := str(question.get("correct_choice_id", ""))
var explanations: Dictionary = question.get("choice_explanations", {})
var incorrect_count := 0
for choice in _get_presented_choices():
if typeof(choice) != TYPE_DICTIONARY:
continue
var choice_id := str(choice.get("id", ""))
if choice_id == correct_id:
continue
incorrect_count += 1
_add_wrong_option_review(choice, str(explanations.get(choice_id, "")))
wrong_options_section.visible = incorrect_count > 0
func _add_wrong_option_review(choice: Dictionary, explanation: String) -> void:
var dark := _followup_is_dark()
var choice_id := str(choice.get("id", ""))
var expanded := bool(wrong_option_expanded.get(choice_id, false))
var card := PanelContainer.new()
card.add_theme_stylebox_override("panel", _wrong_option_box(dark))
wrong_options_content.add_child(card)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 6)
card.add_child(content)
var header := Button.new()
header.flat = true
header.alignment = HORIZONTAL_ALIGNMENT_LEFT
header.text = "%s. %s" % [_display_label_for_choice_id(choice_id), str(choice.get("text", ""))]
header.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg")
header.icon_alignment = HORIZONTAL_ALIGNMENT_RIGHT
header.expand_icon = false
header.add_theme_color_override("font_color", Brand.text_color(dark))
header.add_theme_color_override("icon_normal_color", Brand.muted_color(dark))
_set_scaled_button_font(header, 14)
header.pressed.connect(_toggle_wrong_option_review.bind(choice_id))
content.add_child(header)
if expanded:
var detail := Label.new()
detail.text = explanation
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(detail, 14, Brand.muted_color(dark))
content.add_child(detail)
func _toggle_wrong_option_review(choice_id: String) -> void:
wrong_option_expanded[choice_id] = not bool(wrong_option_expanded.get(choice_id, false))
_render_wrong_option_reviews()
func _strip_correct_prefix(value: String) -> String:
if value.begins_with("Correct. "):
return value.substr(9)
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 == "review":
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":
return "Question %d" % (BlockSessionService.core_answer_count + 1)
return "Question %d of %d" % [
mini(BlockSessionService.current_index + 1, BlockSessionService.active_questions.size()),
BlockSessionService.active_questions.size()
]
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 _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 != "simulation"
if flashcard_button != null:
flashcard_button.visible = BlockSessionService.session_kind != "simulation"
flashcard_button.disabled = current_answer.is_empty()
if bookmark_button != null:
bookmark_button.visible = BlockSessionService.session_kind != "simulation"
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")
_apply_question_theme()
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
_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 category_label := Label.new()
category_label.text = "What is the problem?"
_set_scaled_label(category_label, 18, Style.CYAN)
report_form_content.add_child(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)
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 = false
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, "")
if locked_wrong_choice_ids.has(choice_id) or _should_show_correct_choice_percentage(choice_id):
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 == selected_choice):
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))
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 _should_show_correct_choice_percentage(choice_id: String) -> bool:
var question := BlockSessionService.get_current_question()
return choice_id == str(question.get("correct_choice_id", "")) and (pending_correct_advance or not current_answer.is_empty())
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,
"collapsed": 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]
_build_followup_header(content, state, true, false)
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
state["collapsed"] = false
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 _collapse_resolved_followups() -> void:
for choice_id in followup_states.keys():
var state: Dictionary = followup_states[choice_id]
if bool(state.get("resolved", false)):
state["collapsed"] = true
followup_states[choice_id] = state
_render_followup_state(choice_id)
_apply_saved_choice_state()
func _toggle_followup_state(choice_id: String) -> void:
if not followup_states.has(choice_id) or followup_required:
return
var state: Dictionary = followup_states[choice_id]
if not bool(state.get("resolved", false)):
return
state["collapsed"] = not bool(state.get("collapsed", false))
followup_states[choice_id] = state
_render_followup_state(choice_id)
_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)
elif bool(state.get("collapsed", false)):
_build_followup_header(choice_followup_contents[index], state, false, true)
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]
_build_followup_header(content, state, true, true)
var is_main_correct := bool(state.get("is_main_correct", false))
var body_content := _add_followup_body(content, is_main_correct)
var heading := Label.new()
heading.text = "Why this is appropriate:" if is_main_correct else "Why this is less appropriate:"
_set_scaled_label(heading, 14, Brand.SUCCESS if is_main_correct else Color("fb5f6d"))
body_content.add_child(heading)
var explanation := Label.new()
explanation.text = _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()))
body_content.add_child(explanation)
func _build_followup_header(content: VBoxContainer, state: Dictionary, expanded: bool, interactive: bool) -> void:
var dark := _followup_is_dark()
var is_main_correct := bool(state.get("is_main_correct", false))
var accent := Brand.SUCCESS if is_main_correct else Color("fb5f6d")
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
content.add_child(row)
# The choice button above this panel already shows the letter badge, choice
# text, and percentage, so the header only needs a spacer plus the collapse
# toggle - repeating "Review: B. ..." and a status icon here was redundant.
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(spacer)
var percentage := _get_choice_selection_percentage(str(state.get("choice_id", "")))
if percentage >= 0:
var percentage_label := Label.new()
percentage_label.text = "%d%%" % percentage
percentage_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
percentage_label.custom_minimum_size = Vector2(58, 30)
percentage_label.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
_set_scaled_label(percentage_label, 14, accent)
row.add_child(percentage_label)
if interactive:
var toggle := Button.new()
toggle.name = "FollowupToggle"
toggle.flat = true
toggle.tooltip_text = "Collapse review" if expanded else "Expand review"
toggle.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg")
toggle.custom_minimum_size = Vector2(34, 30)
toggle.add_theme_color_override("icon_normal_color", Brand.text_color(dark))
toggle.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", ""))))
row.add_child(toggle)
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.begins_with("Correct. "):
explanation = explanation.substr(9)
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 attempted_id in followup_states.keys():
var attempted_state: Dictionary = followup_states[attempted_id]
if bool(attempted_state.get("resolved", false)):
attempted_state["collapsed"] = true
followup_states[attempted_id] = attempted_state
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)