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