The choice button above the panel already shows the letter badge, choice text, and percentage, so repeating them in the panel header was redundant now that the panel is docked inside the choice's card. Keeps the collapse toggle, now named FollowupToggle since the old FollowupSummary button it lived on is gone.
3573 lines
137 KiB
GDScript
3573 lines
137 KiB
GDScript
extends VBoxContainer
|
|
|
|
const Style := preload("res://scripts/ui/ui_style.gd")
|
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
|
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
|
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
|
|
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
|
|
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
|
|
const LABS_LIGHT_ICON := preload("res://assets/icons/medmind/labs-light.svg")
|
|
const REPORT_QUESTION_DARK_ICON := preload("res://assets/icons/medmind/report-question-dark.svg")
|
|
const REPORT_QUESTION_LIGHT_ICON := preload("res://assets/icons/medmind/report-question-light.svg")
|
|
const FLAG_DARK_ICON := preload("res://assets/icons/medmind/flag-dark.svg")
|
|
const FLAG_LIGHT_ICON := preload("res://assets/icons/medmind/flag-light.svg")
|
|
const BADGE_CIRCLE_OUTLINE_ICON := preload("res://assets/icons/medmind/badge-circle-outline.svg")
|
|
const BADGE_CIRCLE_FILLED_ICON := preload("res://assets/icons/medmind/badge-circle-filled.svg")
|
|
const FEEDBACK_FADE_SECONDS := 0.45
|
|
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
|
|
const TEXT_SCALE_MIN := 0.85
|
|
const TEXT_SCALE_MAX := 1.35
|
|
const TEXT_SCALE_STEP := 0.1
|
|
const REPORT_DETAILS_MIN := 10
|
|
const REPORT_DETAILS_MAX := 2000
|
|
const MEDIA_ZOOM_MIN := 0.25
|
|
const MEDIA_ZOOM_MAX := 4.0
|
|
const MEDIA_ZOOM_FACTOR := 1.25
|
|
const REPORT_CATEGORIES := [
|
|
{"id": "clarity", "label": "Clarity"},
|
|
{"id": "medical_accuracy", "label": "Medical accuracy"},
|
|
{"id": "answer_explanation", "label": "Answer / explanation"},
|
|
{"id": "formatting_media", "label": "Formatting / media"},
|
|
{"id": "technical_issue", "label": "Technical issue"},
|
|
{"id": "other", "label": "Other"}
|
|
]
|
|
|
|
var main: Control
|
|
var theme_service
|
|
var choice_buttons: Array[Button] = []
|
|
var choice_rows: Array[VBoxContainer] = []
|
|
var choice_percent_labels: Array[Label] = []
|
|
var choice_top_labels: Array[RichTextLabel] = []
|
|
var choice_followup_panels: Array[PanelContainer] = []
|
|
var choice_followup_contents: Array[VBoxContainer] = []
|
|
var eliminated: Array = []
|
|
var locked_wrong_choice_ids: Array = []
|
|
var followup_states := {}
|
|
var followup_attempt_order: Array = []
|
|
var active_followup_choice_id := ""
|
|
var followup_required := false
|
|
var followup_answered := false
|
|
var flagged := false
|
|
var selected_choice := ""
|
|
var current_answer := {}
|
|
var question_highlights := {}
|
|
var explanation_highlights := {}
|
|
var reported_question_ids := []
|
|
var stem_plain_text := ""
|
|
var explanation_plain_text := ""
|
|
var highlight_drag_source := ""
|
|
var timer_color_state := ""
|
|
var pending_correct_advance := false
|
|
var question_text_scale := 1.0
|
|
var presented_choices: Array = []
|
|
var awaiting_next_question := false
|
|
var simulation_submit_pending := false
|
|
|
|
var progress_button: Button
|
|
var timer_label: Label
|
|
var stem_label: RichTextLabel
|
|
var lead_label: Label
|
|
var explanation_label: RichTextLabel
|
|
var references_container: VBoxContainer
|
|
var correct_review_panel: PanelContainer
|
|
var teaching_cards: HFlowContainer
|
|
var review_followups_section: VBoxContainer
|
|
var review_followups_content: VBoxContainer
|
|
var wrong_options_section: VBoxContainer
|
|
var wrong_options_content: VBoxContainer
|
|
var review_followup_states: Array = []
|
|
var wrong_option_expanded := {}
|
|
var question_scroll: ScrollContainer
|
|
var question_elo_label: Label
|
|
var next_button: Button
|
|
var skip_button: Button
|
|
var text_smaller_button: Button
|
|
var text_larger_button: Button
|
|
var labs_button: Button
|
|
var clear_highlight_button: Button
|
|
var flag_button: Button
|
|
var report_button: Button
|
|
var notes_button: Button
|
|
var flashcard_button: Button
|
|
var flashcard_prefill_dialog: Control
|
|
var bookmark_button: Button
|
|
var app_feedback_button: Button
|
|
var theme_button: Button
|
|
var question_toolbar: HFlowContainer
|
|
var header_stack: VBoxContainer
|
|
var header_row: HBoxContainer
|
|
var header_toolbar_host: HBoxContainer
|
|
var nav_palette: GridContainer
|
|
var navigator_popup: PopupPanel
|
|
var question_card: PanelContainer
|
|
var question_panel: PanelContainer
|
|
var actions: HBoxContainer
|
|
var timer_footer: HBoxContainer
|
|
var question_content: VBoxContainer
|
|
var media_container: VBoxContainer
|
|
var timer_bar: ProgressBar
|
|
var choice_badges: Array[Label] = []
|
|
var choice_badge_textures: Array[TextureRect] = []
|
|
var feedback_fog: ColorRect
|
|
var labs_overlay: Control
|
|
var labs_panel: PanelContainer
|
|
var labs_content: VBoxContainer
|
|
var report_overlay: Control
|
|
var report_panel: PanelContainer
|
|
var report_form_content: VBoxContainer
|
|
var report_success_content: VBoxContainer
|
|
var report_category_group: ButtonGroup
|
|
var report_category_buttons: Array[CheckBox] = []
|
|
var report_details: TextEdit
|
|
var report_char_count: Label
|
|
var report_error_label: Label
|
|
var report_submit_button: Button
|
|
var report_cancel_button: Button
|
|
var report_success_label: Label
|
|
var report_is_submitting := false
|
|
var report_question_id := ""
|
|
var media_requests: Array[HTTPRequest] = []
|
|
var question_media_items: Array = []
|
|
var media_viewer_overlay: Control
|
|
var media_viewer_background: ColorRect
|
|
var media_viewer_scroll: ScrollContainer
|
|
var media_viewer_center: CenterContainer
|
|
var media_viewer_texture: TextureRect
|
|
var media_viewer_position_label: Label
|
|
var media_viewer_zoom_label: Label
|
|
var media_viewer_previous_button: Button
|
|
var media_viewer_next_button: Button
|
|
var media_viewer_index := -1
|
|
var media_viewer_zoom := 1.0
|
|
var media_viewer_dragging := false
|
|
var media_viewer_drag_position := Vector2.ZERO
|
|
var red_fog_time := 0.0
|
|
var green_fog_time := 0.0
|
|
var correct_advance_delay := 0.0
|
|
var shake_time := 0.0
|
|
var shake_origin := Vector2.ZERO
|
|
|
|
func _ready() -> void:
|
|
theme_service = get_node("/root/UIThemeService")
|
|
set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
add_theme_constant_override("separation", 10)
|
|
if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated):
|
|
QuestionBankService.choice_percentages_updated.connect(_on_choice_percentages_updated)
|
|
if not QuestionBankService.question_rating_updated.is_connected(_on_question_rating_updated):
|
|
QuestionBankService.question_rating_updated.connect(_on_question_rating_updated)
|
|
if not QuestionBankService.question_skipped.is_connected(_on_question_skipped):
|
|
QuestionBankService.question_skipped.connect(_on_question_skipped)
|
|
if not QuestionBankService.question_report_submitted.is_connected(_on_question_report_submitted):
|
|
QuestionBankService.question_report_submitted.connect(_on_question_report_submitted)
|
|
if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed):
|
|
QuestionBankService.question_report_failed.connect(_on_question_report_failed)
|
|
if not QuestionBankService.questions_changed.is_connected(_on_random_question_changed):
|
|
QuestionBankService.questions_changed.connect(_on_random_question_changed)
|
|
if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed):
|
|
QuestionBankService.questions_load_failed.connect(_on_random_question_failed)
|
|
if not ExamSimulationService.state_changed.is_connected(_on_simulation_state_changed):
|
|
ExamSimulationService.state_changed.connect(_on_simulation_state_changed)
|
|
if not ExamSimulationService.request_failed.is_connected(_on_simulation_request_failed):
|
|
ExamSimulationService.request_failed.connect(_on_simulation_request_failed)
|
|
if not BookmarkService.status_changed.is_connected(_on_bookmark_status_changed):
|
|
BookmarkService.status_changed.connect(_on_bookmark_status_changed)
|
|
if not BookmarkService.question_loaded.is_connected(_on_bookmark_question_loaded):
|
|
BookmarkService.question_loaded.connect(_on_bookmark_question_loaded)
|
|
if not BookmarkService.question_failed.is_connected(_on_bookmark_question_failed):
|
|
BookmarkService.question_failed.connect(_on_bookmark_question_failed)
|
|
if not ReviewQueueService.question_loaded.is_connected(_on_review_question_loaded):
|
|
ReviewQueueService.question_loaded.connect(_on_review_question_loaded)
|
|
if not ReviewQueueService.question_failed.is_connected(_on_review_question_failed):
|
|
ReviewQueueService.question_failed.connect(_on_review_question_failed)
|
|
if not ReviewQueueService.pass_completed.is_connected(_on_review_pass_completed):
|
|
ReviewQueueService.pass_completed.connect(_on_review_pass_completed)
|
|
|
|
question_card = PanelContainer.new()
|
|
question_card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
question_card.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
add_child(question_card)
|
|
|
|
var card_margin := MarginContainer.new()
|
|
card_margin.add_theme_constant_override("margin_left", 18)
|
|
card_margin.add_theme_constant_override("margin_right", 18)
|
|
card_margin.add_theme_constant_override("margin_top", 14)
|
|
card_margin.add_theme_constant_override("margin_bottom", 12)
|
|
question_card.add_child(card_margin)
|
|
|
|
var card_content := VBoxContainer.new()
|
|
card_content.add_theme_constant_override("separation", 10)
|
|
card_margin.add_child(card_content)
|
|
|
|
header_stack = VBoxContainer.new()
|
|
header_stack.add_theme_constant_override("separation", 8)
|
|
card_content.add_child(header_stack)
|
|
header_row = HBoxContainer.new()
|
|
header_row.add_theme_constant_override("separation", 10)
|
|
header_stack.add_child(header_row)
|
|
|
|
progress_button = Button.new()
|
|
progress_button.flat = true
|
|
progress_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
progress_button.custom_minimum_size = Vector2(126, 34)
|
|
progress_button.tooltip_text = "Open question navigator"
|
|
_set_scaled_button_font(progress_button, 14)
|
|
progress_button.pressed.connect(_toggle_question_navigator)
|
|
header_row.add_child(progress_button)
|
|
|
|
var header_spacer := Control.new()
|
|
header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
header_row.add_child(header_spacer)
|
|
|
|
timer_label = Label.new()
|
|
_set_scaled_label(timer_label, 14, Brand.SLATE)
|
|
timer_label.visible = false
|
|
timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
|
|
question_toolbar = HFlowContainer.new()
|
|
question_toolbar.add_theme_constant_override("h_separation", 8)
|
|
question_toolbar.add_theme_constant_override("v_separation", 8)
|
|
question_toolbar.alignment = FlowContainer.ALIGNMENT_END
|
|
|
|
theme_button = Button.new()
|
|
theme_button.custom_minimum_size = Vector2(44, 44)
|
|
theme_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
|
theme_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
theme_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
theme_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
theme_button.focus_mode = Control.FOCUS_NONE
|
|
UISoundService.set_button_sound(theme_button, "toggle")
|
|
theme_button.pressed.connect(theme_service.toggle_theme)
|
|
question_toolbar.add_child(theme_button)
|
|
_refresh_theme_button()
|
|
|
|
text_smaller_button = Button.new()
|
|
text_smaller_button.text = "A-"
|
|
text_smaller_button.tooltip_text = "Decrease text size"
|
|
text_smaller_button.custom_minimum_size = Vector2(44, 44)
|
|
Style.style_chip(text_smaller_button, Style.CYAN)
|
|
UISoundService.set_button_sound(text_smaller_button, "toggle")
|
|
text_smaller_button.pressed.connect(func(): _adjust_question_text_scale(-TEXT_SCALE_STEP))
|
|
question_toolbar.add_child(text_smaller_button)
|
|
|
|
text_larger_button = Button.new()
|
|
text_larger_button.text = "A+"
|
|
text_larger_button.tooltip_text = "Increase text size"
|
|
text_larger_button.custom_minimum_size = Vector2(44, 44)
|
|
Style.style_chip(text_larger_button, Style.CYAN)
|
|
UISoundService.set_button_sound(text_larger_button, "toggle")
|
|
text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP))
|
|
question_toolbar.add_child(text_larger_button)
|
|
|
|
labs_button = Button.new()
|
|
labs_button.tooltip_text = "Review lab results"
|
|
labs_button.custom_minimum_size = Vector2(44, 44)
|
|
UISoundService.set_button_sound(labs_button, "open")
|
|
labs_button.pressed.connect(_show_labs_modal)
|
|
question_toolbar.add_child(labs_button)
|
|
|
|
clear_highlight_button = Button.new()
|
|
clear_highlight_button.tooltip_text = "Clear highlights"
|
|
clear_highlight_button.custom_minimum_size = Vector2(44, 44)
|
|
UISoundService.set_button_sound(clear_highlight_button, "eliminate")
|
|
clear_highlight_button.pressed.connect(_clear_current_highlights)
|
|
question_toolbar.add_child(clear_highlight_button)
|
|
|
|
flag_button = Button.new()
|
|
flag_button.toggle_mode = true
|
|
flag_button.tooltip_text = "Flag question"
|
|
flag_button.custom_minimum_size = Vector2(44, 44)
|
|
Style.style_chip(flag_button, Style.AMBER)
|
|
UISoundService.set_button_sound(flag_button, "toggle")
|
|
flag_button.toggled.connect(func(value: bool):
|
|
flagged = value
|
|
_refresh_toolbar_state()
|
|
if BlockSessionService.session_kind == "simulation":
|
|
_save_simulation_current_response()
|
|
)
|
|
question_toolbar.add_child(flag_button)
|
|
|
|
flashcard_button = Button.new()
|
|
flashcard_button.tooltip_text = "Save as flashcard (available once answered)"
|
|
flashcard_button.icon = preload("res://assets/icons/lucide/clipboard-plus.svg")
|
|
flashcard_button.custom_minimum_size = Vector2(44, 44)
|
|
flashcard_button.add_theme_constant_override("icon_max_width", 22)
|
|
flashcard_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
flashcard_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
flashcard_button.focus_mode = Control.FOCUS_NONE
|
|
flashcard_button.disabled = true
|
|
UISoundService.set_button_sound(flashcard_button, "toggle")
|
|
flashcard_button.pressed.connect(_toggle_flashcard_current_question)
|
|
question_toolbar.add_child(flashcard_button)
|
|
flashcard_prefill_dialog = FlashcardPrefillDialog.new()
|
|
flashcard_prefill_dialog.name = "FlashcardPrefillDialog"
|
|
add_child(flashcard_prefill_dialog)
|
|
|
|
bookmark_button = Button.new()
|
|
bookmark_button.tooltip_text = "Save question"
|
|
bookmark_button.icon = preload("res://assets/icons/lucide/bookmark.svg")
|
|
bookmark_button.custom_minimum_size = Vector2(44, 44)
|
|
bookmark_button.add_theme_constant_override("icon_max_width", 22)
|
|
bookmark_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
bookmark_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
bookmark_button.focus_mode = Control.FOCUS_NONE
|
|
UISoundService.set_button_sound(bookmark_button, "toggle")
|
|
bookmark_button.pressed.connect(_toggle_bookmark_current_question)
|
|
question_toolbar.add_child(bookmark_button)
|
|
|
|
report_button = Button.new()
|
|
report_button.tooltip_text = "Report question"
|
|
report_button.custom_minimum_size = Vector2(44, 44)
|
|
UISoundService.set_button_sound(report_button, "open")
|
|
report_button.pressed.connect(_toggle_report_current_question)
|
|
question_toolbar.add_child(report_button)
|
|
|
|
notes_button = Button.new()
|
|
notes_button.tooltip_text = "Open notes"
|
|
notes_button.icon = preload("res://assets/icons/lucide/notebook-pen.svg")
|
|
notes_button.custom_minimum_size = Vector2(44, 44)
|
|
notes_button.add_theme_constant_override("icon_max_width", 22)
|
|
notes_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
notes_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
notes_button.focus_mode = Control.FOCUS_NONE
|
|
UISoundService.set_button_sound(notes_button, "open")
|
|
notes_button.pressed.connect(func(): main.open_notes_dialog())
|
|
question_toolbar.add_child(notes_button)
|
|
|
|
app_feedback_button = Button.new()
|
|
app_feedback_button.text = "Feedback"
|
|
app_feedback_button.tooltip_text = "Send general feedback or report a bug"
|
|
app_feedback_button.custom_minimum_size = Vector2(92, 44)
|
|
Style.style_chip(app_feedback_button, Style.CYAN)
|
|
app_feedback_button.pressed.connect(func(): main.open_feedback_dialog())
|
|
question_toolbar.add_child(app_feedback_button)
|
|
question_toolbar.move_child(labs_button, 0)
|
|
question_toolbar.move_child(theme_button, question_toolbar.get_child_count() - 2)
|
|
|
|
header_toolbar_host = HBoxContainer.new()
|
|
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
|
|
header_toolbar_host.add_child(question_toolbar)
|
|
header_row.add_child(header_toolbar_host)
|
|
|
|
navigator_popup = PopupPanel.new()
|
|
navigator_popup.name = "QuestionNavigator"
|
|
navigator_popup.min_size = Vector2i(340, 180)
|
|
add_child(navigator_popup)
|
|
var navigator_margin := MarginContainer.new()
|
|
navigator_margin.add_theme_constant_override("margin_left", 14)
|
|
navigator_margin.add_theme_constant_override("margin_right", 14)
|
|
navigator_margin.add_theme_constant_override("margin_top", 14)
|
|
navigator_margin.add_theme_constant_override("margin_bottom", 14)
|
|
navigator_popup.add_child(navigator_margin)
|
|
var navigator_content := VBoxContainer.new()
|
|
navigator_content.add_theme_constant_override("separation", 10)
|
|
navigator_margin.add_child(navigator_content)
|
|
var navigator_heading := Label.new()
|
|
navigator_heading.name = "NavigatorHeading"
|
|
navigator_heading.text = "Questions"
|
|
_set_scaled_label(navigator_heading, 16, Brand.DARK_TEXT)
|
|
navigator_content.add_child(navigator_heading)
|
|
var navigator_scroll := ScrollContainer.new()
|
|
navigator_scroll.custom_minimum_size = Vector2(310, 120)
|
|
navigator_content.add_child(navigator_scroll)
|
|
nav_palette = GridContainer.new()
|
|
nav_palette.columns = 8
|
|
nav_palette.add_theme_constant_override("h_separation", 6)
|
|
nav_palette.add_theme_constant_override("v_separation", 6)
|
|
navigator_scroll.add_child(nav_palette)
|
|
|
|
question_panel = PanelContainer.new()
|
|
question_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
card_content.add_child(question_panel)
|
|
|
|
question_scroll = ScrollContainer.new()
|
|
question_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
question_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
question_panel.add_child(question_scroll)
|
|
|
|
var content_margin := MarginContainer.new()
|
|
content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
content_margin.add_theme_constant_override("margin_left", 4)
|
|
content_margin.add_theme_constant_override("margin_right", 12)
|
|
question_scroll.add_child(content_margin)
|
|
|
|
question_content = VBoxContainer.new()
|
|
question_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
question_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
question_content.add_theme_constant_override("separation", 12)
|
|
content_margin.add_child(question_content)
|
|
|
|
stem_label = RichTextLabel.new()
|
|
stem_label.fit_content = true
|
|
stem_label.bbcode_enabled = true
|
|
stem_label.selection_enabled = true
|
|
stem_label.scroll_active = false
|
|
stem_label.custom_minimum_size = Vector2(0, 96)
|
|
_set_scaled_rich_text(stem_label, 18)
|
|
question_content.add_child(stem_label)
|
|
|
|
media_container = VBoxContainer.new()
|
|
media_container.visible = false
|
|
media_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
media_container.add_theme_constant_override("separation", 10)
|
|
question_content.add_child(media_container)
|
|
|
|
var lead_margin := MarginContainer.new()
|
|
lead_margin.add_theme_constant_override("margin_top", 12)
|
|
lead_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
question_content.add_child(lead_margin)
|
|
|
|
lead_label = Label.new()
|
|
lead_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(lead_label, 21, Style.TEXT)
|
|
lead_margin.add_child(lead_label)
|
|
|
|
question_elo_label = Label.new()
|
|
question_elo_label.visible = false
|
|
question_elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
_set_scaled_label(question_elo_label, 16, Style.AMBER)
|
|
question_content.add_child(question_elo_label)
|
|
|
|
var choices_container := VBoxContainer.new()
|
|
choices_container.add_theme_constant_override("separation", 10)
|
|
question_content.add_child(choices_container)
|
|
|
|
for index in range(5):
|
|
var choice_row := VBoxContainer.new()
|
|
choice_row.add_theme_constant_override("separation", 0)
|
|
choices_container.add_child(choice_row)
|
|
choice_rows.append(choice_row)
|
|
|
|
var button := Button.new()
|
|
button.custom_minimum_size = Vector2(0, 68)
|
|
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
Style.style_answer_button(button)
|
|
UISoundService.set_button_sound(button, "select")
|
|
_set_scaled_button_font(button, 17)
|
|
button.pressed.connect(func(i := index): _select_choice(i))
|
|
button.gui_input.connect(func(event: InputEvent, i := index): _choice_gui_input(event, i))
|
|
choice_buttons.append(button)
|
|
|
|
var badge_wrap := Control.new()
|
|
badge_wrap.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
badge_wrap.anchor_left = 0.0
|
|
badge_wrap.anchor_top = 0.0
|
|
badge_wrap.anchor_right = 0.0
|
|
badge_wrap.anchor_bottom = 0.0
|
|
badge_wrap.offset_left = 16
|
|
badge_wrap.offset_top = 17
|
|
badge_wrap.offset_right = 50
|
|
badge_wrap.offset_bottom = 51
|
|
button.add_child(badge_wrap)
|
|
|
|
# The badge circle is a pre-rendered SVG (tinted per-state via modulate) rather than a
|
|
# StyleBoxFlat corner-radius circle - a perfectly circular StyleBoxFlat is a known Godot
|
|
# degenerate case that can show a seam/faceting at this size even with AA tuned.
|
|
var badge_texture := TextureRect.new()
|
|
badge_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
badge_texture.texture = BADGE_CIRCLE_OUTLINE_ICON
|
|
badge_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
badge_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
badge_wrap.add_child(badge_texture)
|
|
choice_badge_textures.append(badge_texture)
|
|
|
|
var badge := Label.new()
|
|
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
badge.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
_set_scaled_label(badge, 15, Brand.muted_color(true))
|
|
badge_wrap.add_child(badge)
|
|
choice_badges.append(badge)
|
|
|
|
var percent_label := Label.new()
|
|
percent_label.visible = false
|
|
percent_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
percent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
percent_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
percent_label.anchor_left = 1.0
|
|
percent_label.anchor_top = 0.0
|
|
percent_label.anchor_right = 1.0
|
|
percent_label.anchor_bottom = 0.0
|
|
percent_label.offset_left = -82
|
|
percent_label.offset_top = 18
|
|
percent_label.offset_right = -18
|
|
percent_label.offset_bottom = 50
|
|
_set_scaled_label(percent_label, 18, Style.TEXT)
|
|
button.add_child(percent_label)
|
|
choice_percent_labels.append(percent_label)
|
|
|
|
var top_label := RichTextLabel.new()
|
|
top_label.visible = false
|
|
top_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
top_label.bbcode_enabled = true
|
|
top_label.fit_content = false
|
|
top_label.scroll_active = false
|
|
top_label.anchor_left = 0.0
|
|
top_label.anchor_top = 0.0
|
|
top_label.anchor_right = 1.0
|
|
top_label.anchor_bottom = 0.0
|
|
top_label.offset_left = 62
|
|
top_label.offset_top = 18
|
|
top_label.offset_right = -96
|
|
top_label.offset_bottom = 50
|
|
_set_scaled_rich_text(top_label, 16)
|
|
top_label.add_theme_color_override("default_color", Style.RED)
|
|
button.add_child(top_label)
|
|
choice_top_labels.append(top_label)
|
|
|
|
choice_row.add_child(button)
|
|
|
|
var followup_panel := PanelContainer.new()
|
|
followup_panel.visible = false
|
|
followup_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
choice_row.add_child(followup_panel)
|
|
choice_followup_panels.append(followup_panel)
|
|
|
|
var followup_content := VBoxContainer.new()
|
|
followup_content.add_theme_constant_override("separation", 8)
|
|
followup_panel.add_child(followup_content)
|
|
choice_followup_contents.append(followup_content)
|
|
|
|
_build_answer_review_ui()
|
|
|
|
actions = HBoxContainer.new()
|
|
actions.add_theme_constant_override("separation", 10)
|
|
skip_button = Button.new()
|
|
skip_button.text = "Skip"
|
|
skip_button.custom_minimum_size = Vector2(120, 48)
|
|
Style.style_chip(skip_button, Style.AMBER)
|
|
UISoundService.set_button_sound(skip_button, "back")
|
|
skip_button.pressed.connect(_skip_current_question)
|
|
actions.add_child(skip_button)
|
|
|
|
next_button = Button.new()
|
|
next_button.text = "Submit Block" if BlockSessionService.session_kind == "simulation" and BlockSessionService.current_index + 1 == BlockSessionService.active_questions.size() else "Next" if BlockSessionService.session_kind == "simulation" else "Submit"
|
|
next_button.tooltip_text = ""
|
|
next_button.custom_minimum_size = Vector2(140, 48)
|
|
next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
Style.style_primary_button(next_button)
|
|
next_button.pressed.connect(_submit_or_next)
|
|
actions.add_child(next_button)
|
|
card_content.add_child(actions)
|
|
|
|
timer_footer = HBoxContainer.new()
|
|
timer_footer.add_theme_constant_override("separation", 12)
|
|
card_content.add_child(timer_footer)
|
|
timer_bar = ProgressBar.new()
|
|
timer_bar.show_percentage = false
|
|
timer_bar.min_value = 0.0
|
|
timer_bar.max_value = 100.0
|
|
timer_bar.value = 100.0
|
|
timer_bar.custom_minimum_size = Vector2(0, 7)
|
|
timer_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53")))
|
|
timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME))
|
|
timer_footer.add_child(timer_bar)
|
|
timer_footer.add_child(timer_label)
|
|
|
|
feedback_fog = ColorRect.new()
|
|
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
|
|
feedback_fog.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
feedback_fog.top_level = true
|
|
feedback_fog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
add_child(feedback_fog)
|
|
|
|
_build_labs_modal()
|
|
_build_report_modal()
|
|
_build_media_viewer()
|
|
resized.connect(_fit_modal_panels)
|
|
resized.connect(_refresh_responsive_layout)
|
|
_fit_modal_panels()
|
|
_refresh_responsive_layout()
|
|
_apply_question_theme()
|
|
|
|
|
|
func _build_answer_review_ui() -> void:
|
|
correct_review_panel = PanelContainer.new()
|
|
correct_review_panel.visible = false
|
|
question_content.add_child(correct_review_panel)
|
|
var review_margin := MarginContainer.new()
|
|
review_margin.add_theme_constant_override("margin_left", 14)
|
|
review_margin.add_theme_constant_override("margin_right", 14)
|
|
review_margin.add_theme_constant_override("margin_top", 12)
|
|
review_margin.add_theme_constant_override("margin_bottom", 12)
|
|
correct_review_panel.add_child(review_margin)
|
|
var review_content := VBoxContainer.new()
|
|
review_content.add_theme_constant_override("separation", 10)
|
|
review_margin.add_child(review_content)
|
|
|
|
explanation_label = RichTextLabel.new()
|
|
explanation_label.bbcode_enabled = true
|
|
explanation_label.fit_content = true
|
|
explanation_label.selection_enabled = true
|
|
explanation_label.scroll_active = false
|
|
explanation_label.custom_minimum_size = Vector2.ZERO
|
|
_set_scaled_rich_text(explanation_label, 17)
|
|
review_content.add_child(explanation_label)
|
|
|
|
teaching_cards = HFlowContainer.new()
|
|
teaching_cards.add_theme_constant_override("h_separation", 10)
|
|
teaching_cards.add_theme_constant_override("v_separation", 10)
|
|
review_content.add_child(teaching_cards)
|
|
|
|
references_container = VBoxContainer.new()
|
|
references_container.visible = false
|
|
references_container.add_theme_constant_override("separation", 6)
|
|
review_content.add_child(references_container)
|
|
|
|
review_followups_section = VBoxContainer.new()
|
|
review_followups_section.visible = false
|
|
review_followups_section.add_theme_constant_override("separation", 8)
|
|
question_content.add_child(review_followups_section)
|
|
var followup_heading := Label.new()
|
|
followup_heading.text = "FOLLOW-UP QUESTIONS"
|
|
followup_heading.name = "ReviewFollowupHeading"
|
|
_set_scaled_label(followup_heading, 15, Brand.PURPLE)
|
|
review_followups_section.add_child(followup_heading)
|
|
var followup_subtitle := Label.new()
|
|
followup_subtitle.text = "Answer to reinforce key concepts. These responses do not affect your score."
|
|
followup_subtitle.name = "ReviewFollowupSubtitle"
|
|
followup_subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(followup_subtitle, 14, Brand.SLATE)
|
|
review_followups_section.add_child(followup_subtitle)
|
|
review_followups_content = VBoxContainer.new()
|
|
review_followups_content.add_theme_constant_override("separation", 8)
|
|
review_followups_section.add_child(review_followups_content)
|
|
|
|
wrong_options_section = VBoxContainer.new()
|
|
wrong_options_section.visible = false
|
|
wrong_options_section.add_theme_constant_override("separation", 8)
|
|
question_content.add_child(wrong_options_section)
|
|
var wrong_options_heading := Label.new()
|
|
wrong_options_heading.text = "WHY OTHER OPTIONS ARE INCORRECT"
|
|
wrong_options_heading.name = "WrongOptionsHeading"
|
|
_set_scaled_label(wrong_options_heading, 15, Brand.text_color(true))
|
|
wrong_options_section.add_child(wrong_options_heading)
|
|
wrong_options_content = VBoxContainer.new()
|
|
wrong_options_content.add_theme_constant_override("separation", 6)
|
|
wrong_options_section.add_child(wrong_options_content)
|
|
|
|
|
|
func _fit_modal_panels() -> void:
|
|
var available := get_viewport_rect().size - Vector2(32, 32)
|
|
if report_panel != null:
|
|
report_panel.custom_minimum_size = Vector2(
|
|
minf(700.0, maxf(320.0, available.x)),
|
|
minf(600.0, maxf(560.0, available.y)))
|
|
if labs_panel != null:
|
|
labs_panel.custom_minimum_size = Vector2(
|
|
minf(760.0, maxf(320.0, available.x)),
|
|
minf(430.0, maxf(360.0, available.y)))
|
|
|
|
|
|
func _refresh_responsive_layout() -> void:
|
|
if header_toolbar_host == null or header_row == null or header_stack == null:
|
|
return
|
|
var compact := size.x < 1120.0
|
|
var target_parent: Control = header_row
|
|
if compact:
|
|
target_parent = header_stack
|
|
if header_toolbar_host.get_parent() != target_parent:
|
|
header_toolbar_host.reparent(target_parent)
|
|
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
|
|
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 520.0
|
|
app_feedback_button.text = "" if size.x < 760.0 else "Feedback"
|
|
app_feedback_button.custom_minimum_size.x = 44 if size.x < 760.0 else 92
|
|
if size.x < 560.0:
|
|
nav_palette.columns = 5
|
|
else:
|
|
nav_palette.columns = 8
|
|
|
|
|
|
func _question_card_box(dark: bool) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color("081326") if dark else Color("ffffff")
|
|
box.border_color = Color("253555") if dark else Brand.LIGHT_BORDER
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(8)
|
|
return box
|
|
|
|
|
|
func _question_content_box(dark: bool) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color("081326") if dark else Color("ffffff")
|
|
box.set_corner_radius_all(6)
|
|
return box
|
|
|
|
|
|
func _answer_box(dark: bool, state: String, attached: bool = false) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.content_margin_left = 62
|
|
box.content_margin_right = 82
|
|
box.content_margin_top = 22
|
|
box.content_margin_bottom = 22
|
|
if state == "default" or state == "eliminated":
|
|
# Flat, divider-style row - most choices sit in a plain list; only the
|
|
# states below need to visually pop out as their own card.
|
|
box.bg_color = Color("0b172b") if dark else Color("ffffff")
|
|
box.border_color = Color("22304d") if dark else Color("e7ebf2")
|
|
box.border_width_bottom = 1
|
|
box.set_corner_radius_all(0)
|
|
return box
|
|
var background := Color("17183b") if dark else Color("eff4ff")
|
|
var border := Brand.PURPLE
|
|
if state == "correct":
|
|
background = Color("08251d") if dark else Color("ecfdf3")
|
|
border = Brand.SUCCESS
|
|
elif state == "wrong":
|
|
background = Color("32131d") if dark else Color("fff1f2")
|
|
border = Color("fb7185")
|
|
box.bg_color = background
|
|
box.border_color = border
|
|
box.set_border_width_all(2)
|
|
box.set_corner_radius_all(14)
|
|
if attached:
|
|
# A review/follow-up panel is docked directly beneath this choice - drop the
|
|
# shared edge so the two read as one continuous card instead of two stacked boxes.
|
|
box.border_width_bottom = 0
|
|
box.corner_radius_bottom_left = 0
|
|
box.corner_radius_bottom_right = 0
|
|
return box
|
|
|
|
|
|
func _attached_panel_box(dark: bool, accent: Color) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color("17183b") if dark else Color("eff4ff")
|
|
if accent == Brand.SUCCESS:
|
|
box.bg_color = Color("08251d") if dark else Color("ecfdf3")
|
|
elif accent == Color("fb7185"):
|
|
box.bg_color = Color("32131d") if dark else Color("fff1f2")
|
|
box.border_color = accent
|
|
box.border_width_left = 2
|
|
box.border_width_right = 2
|
|
box.border_width_bottom = 2
|
|
box.border_width_top = 0
|
|
box.corner_radius_top_left = 0
|
|
box.corner_radius_top_right = 0
|
|
box.corner_radius_bottom_left = 14
|
|
box.corner_radius_bottom_right = 14
|
|
box.content_margin_left = 12
|
|
box.content_margin_right = 12
|
|
box.content_margin_top = 10
|
|
box.content_margin_bottom = 10
|
|
return box
|
|
|
|
|
|
func _correct_review_panel_box(dark: bool) -> StyleBoxFlat:
|
|
# correct_review_panel already pads its content via an inner MarginContainer,
|
|
# so skip the shared attached-panel content margin to avoid doubling it up.
|
|
var box := _attached_panel_box(dark, Brand.SUCCESS)
|
|
box.content_margin_left = 0
|
|
box.content_margin_right = 0
|
|
box.content_margin_top = 0
|
|
box.content_margin_bottom = 0
|
|
return box
|
|
|
|
|
|
func _toolbar_box(dark: bool, active: bool = false) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color("13203a") if dark else Color("f8fafc")
|
|
box.border_color = Brand.PURPLE if active else (Color("33415d") if dark else Brand.LIGHT_BORDER)
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(7)
|
|
box.content_margin_left = 0
|
|
box.content_margin_right = 0
|
|
box.content_margin_top = 0
|
|
box.content_margin_bottom = 0
|
|
return box
|
|
|
|
|
|
func _theme_toggle_box(dark: bool, hovered: bool = false) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE
|
|
box.border_color = Brand.AMBER if hovered else (Color("33415d") if dark else Brand.LIGHT_BORDER)
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(22)
|
|
box.content_margin_left = 0
|
|
box.content_margin_right = 0
|
|
box.content_margin_top = 0
|
|
box.content_margin_bottom = 0
|
|
return box
|
|
|
|
|
|
func _style_theme_toggle_button(dark: bool) -> void:
|
|
if theme_button == null:
|
|
return
|
|
Brand.set_theme_toggle_icon(theme_button, dark)
|
|
theme_button.tooltip_text = "Switch to light mode" if dark else "Switch to dark mode"
|
|
theme_button.add_theme_stylebox_override("normal", _theme_toggle_box(dark))
|
|
theme_button.add_theme_stylebox_override("hover", _theme_toggle_box(dark, true))
|
|
theme_button.add_theme_stylebox_override("pressed", _theme_toggle_box(dark, true))
|
|
theme_button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
|
theme_button.add_theme_constant_override("h_separation", 0)
|
|
|
|
|
|
func _style_asset_toolbar_button(button: Button, texture: Texture2D, dark: bool, active: bool = false) -> void:
|
|
if button == null:
|
|
return
|
|
button.text = ""
|
|
button.icon = texture
|
|
button.expand_icon = false
|
|
button.focus_mode = Control.FOCUS_NONE
|
|
button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
|
button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
button.add_theme_constant_override("h_separation", 0)
|
|
button.add_theme_constant_override("icon_max_width", 23)
|
|
button.add_theme_stylebox_override("normal", _toolbar_box(dark, active))
|
|
button.add_theme_stylebox_override("hover", _toolbar_box(dark, true))
|
|
button.add_theme_stylebox_override("pressed", _toolbar_box(dark, true))
|
|
button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
|
for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]:
|
|
button.add_theme_color_override(color_name, Color.WHITE)
|
|
|
|
|
|
func _primary_action_box(dark: bool, state: String) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
if state == "disabled":
|
|
box.bg_color = Color("1b2539") if dark else Color("dbe3ef")
|
|
box.border_color = Color("2b3952") if dark else Color("cbd5e1")
|
|
elif state == "hover":
|
|
box.bg_color = Brand.PURPLE
|
|
box.border_color = Color("a78bfa")
|
|
elif state == "pressed":
|
|
box.bg_color = Color("6d28d9")
|
|
box.border_color = Brand.PURPLE
|
|
else:
|
|
box.bg_color = Color("4f5ff7")
|
|
box.border_color = Brand.PURPLE
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(7)
|
|
box.content_margin_left = 14
|
|
box.content_margin_right = 14
|
|
box.content_margin_top = 10
|
|
box.content_margin_bottom = 10
|
|
return box
|
|
|
|
|
|
func _teaching_card_box(dark: bool, accent: Color) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color(accent, 0.08 if dark else 0.045)
|
|
box.border_color = Color(accent, 0.42)
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(6)
|
|
box.content_margin_left = 12
|
|
box.content_margin_right = 12
|
|
box.content_margin_top = 10
|
|
box.content_margin_bottom = 10
|
|
return box
|
|
|
|
|
|
func _review_followup_box(_dark: bool, accent: Color, answered: bool) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color(accent, 0.075 if answered else 0.04)
|
|
box.border_color = Color(accent, 0.5)
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(7)
|
|
box.content_margin_left = 12
|
|
box.content_margin_right = 12
|
|
box.content_margin_top = 10
|
|
box.content_margin_bottom = 10
|
|
return box
|
|
|
|
|
|
func _wrong_option_box(dark: bool) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color("0b172b") if dark else Color("ffffff")
|
|
box.border_color = Color("2b3958") if dark else Brand.LIGHT_BORDER
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(6)
|
|
box.content_margin_left = 10
|
|
box.content_margin_right = 10
|
|
box.content_margin_top = 6
|
|
box.content_margin_bottom = 6
|
|
return box
|
|
|
|
|
|
func _style_review_followup_option(button: Button, dark: bool, option_id: String, selected_id: String, correct_id: String, answered: bool) -> void:
|
|
var state := "default"
|
|
if answered and option_id == correct_id:
|
|
state = "correct"
|
|
elif answered and option_id == selected_id:
|
|
state = "wrong"
|
|
var box := _answer_box(dark, state)
|
|
box.content_margin_left = 12
|
|
box.content_margin_right = 12
|
|
var hover_box := _answer_box(dark, "selected" if state == "default" else state)
|
|
hover_box.content_margin_left = 12
|
|
hover_box.content_margin_right = 12
|
|
button.add_theme_stylebox_override("normal", box)
|
|
button.add_theme_stylebox_override("disabled", box)
|
|
button.add_theme_stylebox_override("hover", hover_box)
|
|
button.add_theme_stylebox_override("pressed", hover_box)
|
|
button.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
button.add_theme_color_override("font_disabled_color", Brand.text_color(dark))
|
|
|
|
|
|
func _apply_question_theme() -> void:
|
|
if theme_service == null or question_card == null or question_panel == null:
|
|
return
|
|
var dark: bool = theme_service.is_dark_mode()
|
|
question_card.add_theme_stylebox_override("panel", _question_card_box(dark))
|
|
question_panel.add_theme_stylebox_override("panel", _question_content_box(dark))
|
|
if navigator_popup != null:
|
|
navigator_popup.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
|
|
progress_button.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
progress_button.add_theme_color_override("font_hover_color", Brand.SKY_BLUE)
|
|
progress_button.add_theme_color_override("font_disabled_color", Brand.text_color(dark))
|
|
timer_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
stem_label.add_theme_color_override("default_color", Brand.text_color(dark))
|
|
lead_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
explanation_label.add_theme_color_override("default_color", Brand.text_color(dark))
|
|
var review_followup_heading := find_child("ReviewFollowupHeading", true, false) as Label
|
|
if review_followup_heading != null:
|
|
review_followup_heading.add_theme_color_override("font_color", Brand.PURPLE if not dark else Color("8b7cff"))
|
|
var review_followup_subtitle := find_child("ReviewFollowupSubtitle", true, false) as Label
|
|
if review_followup_subtitle != null:
|
|
review_followup_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
var wrong_options_heading := find_child("WrongOptionsHeading", true, false) as Label
|
|
if wrong_options_heading != null:
|
|
wrong_options_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
if correct_review_panel != null:
|
|
correct_review_panel.add_theme_stylebox_override("panel", _correct_review_panel_box(dark))
|
|
question_elo_label.add_theme_color_override("font_color", Brand.AMBER)
|
|
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
|
if navigator_heading != null:
|
|
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
for control in [text_smaller_button, text_larger_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
|
if control == null:
|
|
continue
|
|
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
|
|
control.add_theme_stylebox_override("normal", _toolbar_box(dark, active))
|
|
control.add_theme_stylebox_override("hover", _toolbar_box(dark, true))
|
|
control.add_theme_stylebox_override("pressed", _toolbar_box(dark, true))
|
|
control.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
control.add_theme_color_override("icon_normal_color", Brand.SKY_BLUE)
|
|
_style_asset_toolbar_button(labs_button, LABS_DARK_ICON if dark else LABS_LIGHT_ICON, dark)
|
|
_style_asset_toolbar_button(clear_highlight_button, CLEAR_HIGHLIGHT_DARK_ICON if dark else CLEAR_HIGHLIGHT_LIGHT_ICON, dark)
|
|
_style_asset_toolbar_button(report_button, REPORT_QUESTION_DARK_ICON if dark else REPORT_QUESTION_LIGHT_ICON, dark)
|
|
_style_asset_toolbar_button(flag_button, FLAG_DARK_ICON if dark else FLAG_LIGHT_ICON, dark, flagged)
|
|
_style_theme_toggle_button(dark)
|
|
if skip_button != null:
|
|
skip_button.add_theme_stylebox_override("normal", _toolbar_box(dark))
|
|
skip_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true))
|
|
skip_button.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
if next_button != null:
|
|
next_button.add_theme_stylebox_override("normal", _primary_action_box(dark, "normal"))
|
|
next_button.add_theme_stylebox_override("hover", _primary_action_box(dark, "hover"))
|
|
next_button.add_theme_stylebox_override("pressed", _primary_action_box(dark, "pressed"))
|
|
next_button.add_theme_stylebox_override("disabled", _primary_action_box(dark, "disabled"))
|
|
next_button.add_theme_color_override("font_color", Color.WHITE)
|
|
next_button.add_theme_color_override("font_hover_color", Color.WHITE)
|
|
next_button.add_theme_color_override("font_pressed_color", Color.WHITE)
|
|
next_button.add_theme_color_override("font_disabled_color", Brand.muted_color(dark))
|
|
for index in range(choice_buttons.size()):
|
|
var choice_id := ""
|
|
if index < presented_choices.size():
|
|
choice_id = str(presented_choices[index].get("id", ""))
|
|
_style_choice_button_for_state(index, choice_id)
|
|
for attempted_choice_id in followup_attempt_order:
|
|
_render_followup_state(str(attempted_choice_id))
|
|
if correct_review_panel != null and correct_review_panel.visible:
|
|
_render_teaching_cards()
|
|
_render_review_followups()
|
|
_render_wrong_option_reviews()
|
|
|
|
|
|
func on_show(_payload: Variant = null) -> void:
|
|
_load_question()
|
|
|
|
|
|
func on_hide() -> void:
|
|
if BlockSessionService.session_kind == "simulation" and BlockSessionService.block_started:
|
|
_save_simulation_current_response()
|
|
awaiting_next_question = false
|
|
_hide_media_viewer()
|
|
_hide_report_modal(true)
|
|
_hide_labs_modal()
|
|
|
|
|
|
func _save_simulation_current_response() -> void:
|
|
if BlockSessionService.session_kind != "simulation":
|
|
return
|
|
var question := BlockSessionService.get_current_question()
|
|
if question.is_empty():
|
|
return
|
|
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
|
ExamSimulationService.save_response(
|
|
str(question.get("id", "")),
|
|
selected_choice,
|
|
eliminated,
|
|
flagged,
|
|
int(BlockSessionService.get_current_elapsed_seconds()))
|
|
|
|
|
|
func _on_simulation_state_changed() -> void:
|
|
if BlockSessionService.session_kind != "simulation":
|
|
return
|
|
var state := str(ExamSimulationService.simulation.get("state", ""))
|
|
if state == "between_blocks":
|
|
simulation_submit_pending = false
|
|
BlockSessionService.block_started = false
|
|
main.show_screen("simulation_break")
|
|
elif state == "completed":
|
|
simulation_submit_pending = false
|
|
BlockSessionService.block_started = false
|
|
main.show_screen("simulation_results", ExamSimulationService.simulation)
|
|
|
|
|
|
func _on_simulation_request_failed(message: String) -> void:
|
|
if BlockSessionService.session_kind != "simulation":
|
|
return
|
|
simulation_submit_pending = false
|
|
next_button.disabled = false
|
|
next_button.text = "Retry"
|
|
next_button.tooltip_text = message
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
_update_answer_feedback(delta)
|
|
if visible and BlockSessionService.session_kind == "simulation":
|
|
BlockSessionService.seconds_remaining = maxf(0.0, ExamSimulationService.get_block_deadline_unix() - Time.get_unix_time_from_system())
|
|
var simulation_seconds := int(ceil(BlockSessionService.seconds_remaining))
|
|
_set_timer_display(simulation_seconds)
|
|
_update_timer_bar()
|
|
if simulation_seconds <= 0 and not simulation_submit_pending:
|
|
simulation_submit_pending = true
|
|
ExamSimulationService.submit_block()
|
|
return
|
|
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
|
|
_update_timer_bar()
|
|
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible):
|
|
BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta)
|
|
var shown_seconds := int(ceil(BlockSessionService.seconds_remaining))
|
|
_set_timer_display(shown_seconds)
|
|
_update_timer_bar()
|
|
if BlockSessionService.seconds_remaining <= 0:
|
|
_finish()
|
|
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
if media_viewer_overlay != null and media_viewer_overlay.visible:
|
|
if event.is_action_pressed("ui_cancel"):
|
|
_hide_media_viewer()
|
|
elif event.is_action_pressed("ui_left"):
|
|
_show_adjacent_media(-1)
|
|
elif event.is_action_pressed("ui_right"):
|
|
_show_adjacent_media(1)
|
|
elif event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_PLUS or event.keycode == KEY_EQUAL):
|
|
_set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR)
|
|
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_MINUS:
|
|
_set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR)
|
|
else:
|
|
return
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
if report_overlay != null and report_overlay.visible and event.is_action_pressed("ui_cancel"):
|
|
_hide_report_modal()
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
if labs_overlay != null and labs_overlay.visible and event.is_action_pressed("ui_cancel"):
|
|
_hide_labs_modal()
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
if not visible or pending_correct_advance or stem_label == null:
|
|
return
|
|
|
|
var mouse_event := event as InputEventMouseButton
|
|
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_LEFT:
|
|
return
|
|
|
|
if mouse_event.pressed:
|
|
if stem_label.get_global_rect().has_point(mouse_event.global_position):
|
|
highlight_drag_source = "stem"
|
|
elif explanation_label.visible and explanation_label.get_global_rect().has_point(mouse_event.global_position):
|
|
highlight_drag_source = "explanation"
|
|
else:
|
|
highlight_drag_source = ""
|
|
elif highlight_drag_source != "":
|
|
var selection_source := highlight_drag_source
|
|
highlight_drag_source = ""
|
|
call_deferred("_commit_current_text_selection", selection_source)
|
|
elif _should_clear_selection_from_click(mouse_event):
|
|
_clear_selected_choice()
|
|
|
|
|
|
func _load_question() -> void:
|
|
_hide_report_modal(true)
|
|
_reset_wrong_feedback()
|
|
_hide_labs_modal()
|
|
eliminated.clear()
|
|
locked_wrong_choice_ids.clear()
|
|
_clear_followups()
|
|
flagged = false
|
|
selected_choice = ""
|
|
current_answer = {}
|
|
flag_button.button_pressed = false
|
|
_refresh_toolbar_state()
|
|
_clear_answer_review()
|
|
_clear_references()
|
|
question_elo_label.visible = false
|
|
question_elo_label.text = ""
|
|
next_button.text = "Submit"
|
|
next_button.disabled = true
|
|
skip_button.disabled = false
|
|
skip_button.visible = BlockSessionService.session_kind != "review"
|
|
skip_button.text = "Skip"
|
|
report_button.visible = BlockSessionService.session_kind != "simulation"
|
|
|
|
var question := BlockSessionService.get_current_question()
|
|
if question.is_empty():
|
|
if BlockSessionService.session_kind == "review":
|
|
main.show_screen("dashboard")
|
|
else:
|
|
main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
|
|
return
|
|
BookmarkService.observe_question(question)
|
|
progress_button.text = _question_progress_text()
|
|
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review"]
|
|
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review"] else "Open question navigator"
|
|
var show_timer := _is_session_timer_visible()
|
|
timer_label.visible = show_timer
|
|
timer_bar.visible = show_timer
|
|
timer_footer.visible = show_timer
|
|
if show_timer:
|
|
if BlockSessionService.session_kind == "core":
|
|
_set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())))
|
|
else:
|
|
_set_timer_display(int(ceil(BlockSessionService.seconds_remaining)))
|
|
_update_timer_bar()
|
|
stem_plain_text = str(question.get("stem", ""))
|
|
explanation_plain_text = str(question.get("explanation", ""))
|
|
_render_stem()
|
|
_load_question_media(question)
|
|
lead_label.text = question.get("lead_in", "")
|
|
_refresh_labs_button()
|
|
|
|
presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true)
|
|
var choices := presented_choices
|
|
for index in range(choice_buttons.size()):
|
|
var button := choice_buttons[index]
|
|
button.visible = index < choices.size()
|
|
if index < choice_rows.size():
|
|
choice_rows[index].visible = index < choices.size()
|
|
button.disabled = false
|
|
if index < choices.size():
|
|
var choice: Dictionary = choices[index]
|
|
_set_choice_button_text(index, choice)
|
|
button.modulate = Color.WHITE
|
|
_style_choice_button_for_state(index, str(choice.get("id", "")))
|
|
|
|
_refresh_submit_state()
|
|
|
|
var saved_answer = BlockSessionService.get_answer_for_index(BlockSessionService.current_index)
|
|
if saved_answer != null:
|
|
current_answer = saved_answer
|
|
selected_choice = str(saved_answer.get("selected_choice_id", ""))
|
|
eliminated = saved_answer.get("eliminated_choice_ids", []).duplicate()
|
|
flagged = bool(saved_answer.get("flagged", false))
|
|
flag_button.button_pressed = flagged
|
|
_refresh_toolbar_state()
|
|
if BlockSessionService.session_kind == "simulation":
|
|
_apply_saved_choice_state()
|
|
else:
|
|
_show_answer_review()
|
|
_update_question_elo_label(true)
|
|
|
|
_refresh_nav_palette()
|
|
_refresh_toolbar_state()
|
|
_apply_question_theme()
|
|
|
|
|
|
func _select_choice(index: int) -> void:
|
|
if pending_correct_advance or followup_required or _is_correct_answer_locked():
|
|
return
|
|
var choices := _get_presented_choices()
|
|
if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()):
|
|
return
|
|
if locked_wrong_choice_ids.has(str(choices[index].get("id", ""))):
|
|
return
|
|
var choice_id := str(choices[index].get("id", ""))
|
|
selected_choice = "" if selected_choice == choice_id else choice_id
|
|
if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation":
|
|
current_answer = {}
|
|
if BlockSessionService.mode == "Simulation":
|
|
_save_simulation_current_response()
|
|
else:
|
|
next_button.text = "Submit"
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _clear_selected_choice() -> void:
|
|
if selected_choice == "" or _is_correct_answer_locked():
|
|
return
|
|
selected_choice = ""
|
|
if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation":
|
|
current_answer = {}
|
|
if BlockSessionService.mode == "Simulation":
|
|
_save_simulation_current_response()
|
|
else:
|
|
next_button.text = "Submit"
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _should_clear_selection_from_click(mouse_event: InputEventMouseButton) -> bool:
|
|
if selected_choice == "" or followup_required or not current_answer.is_empty():
|
|
return false
|
|
if mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
|
|
return false
|
|
var pointer_position := mouse_event.global_position
|
|
if stem_label.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
if lead_label.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
if explanation_label.visible and explanation_label.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
if labs_overlay != null and labs_overlay.visible:
|
|
return false
|
|
if skip_button != null and skip_button.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
if next_button != null and next_button.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
if question_toolbar != null and question_toolbar.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
if labs_button != null and labs_button.visible and labs_button.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
if flag_button != null and flag_button.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
if report_button != null and report_button.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
for button in choice_buttons:
|
|
if button.visible and button.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
for panel in choice_followup_panels:
|
|
if panel.visible and panel.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
for child in media_container.get_children():
|
|
if child is Control and child.get_global_rect().has_point(pointer_position):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _skip_current_question() -> void:
|
|
if BlockSessionService.session_kind == "bookmark":
|
|
if not current_answer.is_empty():
|
|
_finish()
|
|
return
|
|
current_answer = BlockSessionService.skip_bookmark_question(eliminated, flagged)
|
|
if current_answer.is_empty():
|
|
return
|
|
selected_choice = ""
|
|
_show_answer_review()
|
|
return
|
|
if pending_correct_advance or (not current_answer.is_empty() and BlockSessionService.session_kind != "simulation"):
|
|
return
|
|
if BlockSessionService.session_kind != "core":
|
|
if BlockSessionService.session_kind == "simulation":
|
|
_save_simulation_current_response()
|
|
var target_index := _next_revisitable_index()
|
|
if target_index >= 0 and BlockSessionService.move_to(target_index):
|
|
_load_question()
|
|
elif BlockSessionService.session_kind == "simulation" and not simulation_submit_pending:
|
|
simulation_submit_pending = true
|
|
next_button.disabled = true
|
|
next_button.text = "Submitting..."
|
|
ExamSimulationService.submit_block()
|
|
else:
|
|
_finish()
|
|
return
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
if question_id == "":
|
|
return
|
|
current_answer = BlockSessionService.skip_core_question(eliminated, flagged)
|
|
if current_answer.is_empty():
|
|
return
|
|
QuestionBankService.skip_question(question_id)
|
|
selected_choice = ""
|
|
next_button.disabled = true
|
|
skip_button.disabled = true
|
|
pending_correct_advance = true
|
|
correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS
|
|
_show_explanation()
|
|
_update_question_elo_label(true)
|
|
|
|
|
|
func _next_revisitable_index() -> int:
|
|
var question_count := BlockSessionService.active_questions.size()
|
|
if question_count <= 1:
|
|
return -1
|
|
for offset in range(1, question_count):
|
|
var candidate := (BlockSessionService.current_index + offset) % question_count
|
|
var answer = BlockSessionService.get_answer_for_index(candidate)
|
|
if answer == null or str(answer.get("selected_choice_id", "")) == "":
|
|
return candidate
|
|
return (BlockSessionService.current_index + 1) % question_count
|
|
|
|
|
|
func _choice_gui_input(event: InputEvent, index: int) -> void:
|
|
if pending_correct_advance or followup_required or _is_correct_answer_locked():
|
|
return
|
|
var mouse_event := event as InputEventMouseButton
|
|
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed:
|
|
return
|
|
if not current_answer.is_empty() and BlockSessionService.session_kind != "simulation":
|
|
return
|
|
|
|
var choices := _get_presented_choices()
|
|
if index >= choices.size():
|
|
return
|
|
|
|
var choice: Dictionary = choices[index]
|
|
var choice_id := str(choice.get("id", ""))
|
|
if locked_wrong_choice_ids.has(choice_id):
|
|
return
|
|
if eliminated.has(choice_id):
|
|
eliminated.erase(choice_id)
|
|
else:
|
|
eliminated.append(choice_id)
|
|
UISoundService.play_sound("eliminate")
|
|
_set_choice_button_text(index, choice)
|
|
_apply_saved_choice_state()
|
|
if BlockSessionService.session_kind == "simulation":
|
|
_save_simulation_current_response()
|
|
|
|
|
|
func _toggle_eliminate_selected() -> void:
|
|
if pending_correct_advance or followup_required or selected_choice == "" or (not current_answer.is_empty() and BlockSessionService.session_kind != "simulation") or _is_correct_answer_locked():
|
|
return
|
|
var choices := _get_presented_choices()
|
|
for index in range(choices.size()):
|
|
if str(choices[index].get("id", "")) == selected_choice:
|
|
if eliminated.has(selected_choice):
|
|
eliminated.erase(selected_choice)
|
|
else:
|
|
eliminated.append(selected_choice)
|
|
UISoundService.play_sound("eliminate")
|
|
_set_choice_button_text(index, choices[index])
|
|
_apply_saved_choice_state()
|
|
if BlockSessionService.session_kind == "simulation":
|
|
_save_simulation_current_response()
|
|
|
|
|
|
func _submit_or_next() -> void:
|
|
if BlockSessionService.session_kind == "simulation":
|
|
_save_simulation_current_response()
|
|
if BlockSessionService.current_index + 1 < BlockSessionService.active_questions.size():
|
|
BlockSessionService.move_next()
|
|
_load_question()
|
|
return
|
|
elif not simulation_submit_pending:
|
|
simulation_submit_pending = true
|
|
next_button.disabled = true
|
|
next_button.text = "Submitting..."
|
|
ExamSimulationService.submit_block()
|
|
return
|
|
if awaiting_next_question:
|
|
return
|
|
if pending_correct_advance:
|
|
return
|
|
if current_answer.is_empty():
|
|
if selected_choice == "":
|
|
return
|
|
var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", ""))
|
|
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
|
|
_collapse_resolved_followups()
|
|
var attempted_choice := selected_choice
|
|
if BlockSessionService.session_kind == "core":
|
|
QuestionBankService.record_first_attempt(
|
|
question_id_for_attempt,
|
|
attempted_choice,
|
|
_presented_choice_ids(),
|
|
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))
|
|
)
|
|
var result: Dictionary
|
|
if BlockSessionService.session_kind == "core":
|
|
result = BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged)
|
|
elif BlockSessionService.session_kind == "bookmark":
|
|
result = BlockSessionService.submit_bookmark_attempt(selected_choice, eliminated, flagged)
|
|
else:
|
|
result = BlockSessionService.submit_review_attempt(selected_choice, eliminated, flagged)
|
|
if result.is_empty():
|
|
return
|
|
var answer: Dictionary = result.get("answer", {})
|
|
if BlockSessionService.session_kind == "review" and bool(result.get("first_submission", false)):
|
|
ReviewQueueService.record_first_submission(
|
|
BlockSessionService.get_current_question(),
|
|
attempted_choice,
|
|
_presented_choice_ids(),
|
|
int(floor(float(answer.get("elapsed_seconds", 0.0)))))
|
|
if bool(result.get("is_correct", false)):
|
|
current_answer = answer
|
|
_update_question_elo_label(true)
|
|
_show_answer_review()
|
|
_trigger_correct_feedback()
|
|
return
|
|
_lock_wrong_choice(attempted_choice)
|
|
_show_wrong_answer_followup(attempted_choice)
|
|
selected_choice = ""
|
|
current_answer = {}
|
|
next_button.text = "Submit"
|
|
next_button.disabled = true
|
|
_trigger_wrong_feedback()
|
|
_apply_saved_choice_state()
|
|
return
|
|
QuestionBankService.record_first_attempt(
|
|
question_id_for_attempt,
|
|
selected_choice,
|
|
_presented_choice_ids(),
|
|
int(floor(BlockSessionService.get_current_elapsed_seconds()))
|
|
)
|
|
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
|
_show_answer_review()
|
|
else:
|
|
if BlockSessionService.session_kind == "core":
|
|
_request_next_core_question()
|
|
elif BlockSessionService.session_kind == "bookmark":
|
|
if BookmarkService.has_next_practice():
|
|
awaiting_next_question = true
|
|
next_button.text = "Loading..."
|
|
next_button.disabled = true
|
|
BookmarkService.next_practice()
|
|
else:
|
|
_finish()
|
|
elif BlockSessionService.session_kind == "review":
|
|
awaiting_next_question = true
|
|
next_button.text = "Loading..."
|
|
next_button.disabled = true
|
|
ReviewQueueService.load_next()
|
|
else:
|
|
_advance_or_finish()
|
|
|
|
|
|
func _request_next_core_question() -> void:
|
|
awaiting_next_question = true
|
|
next_button.text = "Loading..."
|
|
next_button.disabled = true
|
|
QuestionBankService.load_next_random_question(BlockSessionService.get_core_excluded_question_ids())
|
|
|
|
|
|
func _on_random_question_changed() -> void:
|
|
if not awaiting_next_question or QuestionBankService.load_state != "ready":
|
|
return
|
|
var loaded_questions := QuestionBankService.get_all_questions()
|
|
if loaded_questions.is_empty():
|
|
_on_random_question_failed("No additional questions are available.")
|
|
return
|
|
if not BlockSessionService.append_core_question(loaded_questions[0]):
|
|
_on_random_question_failed("The next question could not be prepared.")
|
|
return
|
|
awaiting_next_question = false
|
|
_load_question()
|
|
UISoundService.play_sound("question")
|
|
|
|
|
|
func _on_random_question_failed(message: String) -> void:
|
|
if not awaiting_next_question:
|
|
return
|
|
if QuestionBankService.load_state == "retrying":
|
|
next_button.text = "Reconnecting..."
|
|
next_button.disabled = true
|
|
next_button.tooltip_text = message
|
|
return
|
|
awaiting_next_question = false
|
|
next_button.text = "Retry"
|
|
next_button.disabled = false
|
|
next_button.tooltip_text = message
|
|
|
|
|
|
func _show_answer_review() -> void:
|
|
_show_explanation()
|
|
_show_references(BlockSessionService.get_current_question().get("references", []))
|
|
_ensure_answer_review_followups()
|
|
_render_answer_review_sections()
|
|
_set_answer_review_timer_visibility()
|
|
next_button.text = "Next"
|
|
next_button.disabled = false
|
|
if BlockSessionService.session_kind == "bookmark" and not BookmarkService.has_next_practice():
|
|
next_button.text = "End of list"
|
|
next_button.disabled = true
|
|
if skip_button != null:
|
|
skip_button.disabled = true
|
|
skip_button.visible = BlockSessionService.session_kind == "bookmark"
|
|
skip_button.text = "Back to Bookmarks" if BlockSessionService.session_kind == "bookmark" else "Skip"
|
|
skip_button.disabled = false if BlockSessionService.session_kind == "bookmark" else true
|
|
if question_scroll != null:
|
|
question_scroll.call_deferred("ensure_control_visible", explanation_label)
|
|
_refresh_toolbar_state()
|
|
|
|
|
|
func _set_answer_review_timer_visibility() -> void:
|
|
var keep_core_timer := BlockSessionService.session_kind == "core"
|
|
timer_bar.visible = keep_core_timer
|
|
timer_label.visible = keep_core_timer
|
|
timer_footer.visible = keep_core_timer
|
|
if keep_core_timer:
|
|
_update_timer_bar()
|
|
|
|
|
|
func _show_explanation() -> void:
|
|
var question := BlockSessionService.get_current_question()
|
|
for button in choice_buttons:
|
|
button.disabled = true
|
|
var correct_id := str(question.get("correct_choice_id", ""))
|
|
_dock_correct_review_panel(correct_id)
|
|
correct_review_panel.visible = true
|
|
var choices := _get_presented_choices()
|
|
for index in range(choices.size()):
|
|
var choice: Dictionary = choices[index]
|
|
var id := str(choice.get("id", ""))
|
|
_set_choice_button_text(index, choice)
|
|
_style_choice_button_for_state(index, id)
|
|
|
|
explanation_plain_text = str(question.get("explanation", ""))
|
|
_render_explanation()
|
|
explanation_label.visible = true
|
|
_update_question_elo_label(true)
|
|
_refresh_nav_palette()
|
|
_update_content_vertical_alignment()
|
|
|
|
|
|
func _dock_correct_review_panel(correct_id: String) -> void:
|
|
var index := _choice_index_for_id(correct_id)
|
|
if index < 0 or index >= choice_rows.size():
|
|
return
|
|
if correct_review_panel.get_parent() != choice_rows[index]:
|
|
correct_review_panel.reparent(choice_rows[index])
|
|
|
|
|
|
func _render_answer_review_sections() -> void:
|
|
_render_teaching_cards()
|
|
_render_review_followups()
|
|
_render_wrong_option_reviews()
|
|
|
|
|
|
func _clear_answer_review() -> void:
|
|
if explanation_label != null:
|
|
explanation_label.visible = false
|
|
if correct_review_panel != null:
|
|
correct_review_panel.visible = false
|
|
_clear_container_children(teaching_cards)
|
|
_clear_container_children(review_followups_content)
|
|
_clear_container_children(wrong_options_content)
|
|
review_followup_states.clear()
|
|
wrong_option_expanded.clear()
|
|
if review_followups_section != null:
|
|
review_followups_section.visible = false
|
|
if wrong_options_section != null:
|
|
wrong_options_section.visible = false
|
|
_update_content_vertical_alignment()
|
|
|
|
|
|
func _update_content_vertical_alignment() -> void:
|
|
if question_content == null:
|
|
return
|
|
# Before an answer is submitted the stem+choices block is usually shorter than the
|
|
# available panel height; center it so it doesn't sit pinned to the top with a slab
|
|
# of empty space below. Once the answer review content appears, go back to top
|
|
# alignment so the growing explanation/follow-up sections read top-down and scroll
|
|
# naturally instead of being centered mid-panel.
|
|
var answered := correct_review_panel != null and correct_review_panel.visible
|
|
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN if answered else BoxContainer.ALIGNMENT_CENTER
|
|
|
|
|
|
func _clear_container_children(container: Control) -> void:
|
|
if container == null:
|
|
return
|
|
for child in container.get_children():
|
|
container.remove_child(child)
|
|
child.queue_free()
|
|
|
|
|
|
func _render_teaching_cards() -> void:
|
|
if teaching_cards == null:
|
|
return
|
|
_clear_container_children(teaching_cards)
|
|
var question := BlockSessionService.get_current_question()
|
|
var takeaway := str(question.get("key_takeaway", "")).strip_edges()
|
|
if takeaway == "":
|
|
var correct_id := str(question.get("correct_choice_id", ""))
|
|
var explanations: Dictionary = question.get("choice_explanations", {})
|
|
takeaway = _strip_correct_prefix(str(explanations.get(correct_id, "")).strip_edges())
|
|
var pearl := str(question.get("usmle_pearl", "")).strip_edges()
|
|
if takeaway != "":
|
|
_add_teaching_card("KEY TAKEAWAY", takeaway, Brand.SKY_BLUE)
|
|
if pearl != "":
|
|
_add_teaching_card("USMLE PEARL", pearl, Brand.PURPLE)
|
|
teaching_cards.visible = teaching_cards.get_child_count() > 0
|
|
|
|
|
|
func _add_teaching_card(title_text: String, body_text: String, accent: Color) -> void:
|
|
var dark := _followup_is_dark()
|
|
var card := PanelContainer.new()
|
|
card.custom_minimum_size = Vector2(360, 0)
|
|
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
card.add_theme_stylebox_override("panel", _teaching_card_box(dark, accent))
|
|
teaching_cards.add_child(card)
|
|
var content := VBoxContainer.new()
|
|
content.add_theme_constant_override("separation", 6)
|
|
card.add_child(content)
|
|
var heading := Label.new()
|
|
heading.text = title_text
|
|
_set_scaled_label(heading, 13, accent)
|
|
content.add_child(heading)
|
|
var body := Label.new()
|
|
body.text = body_text
|
|
body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(body, 14, Brand.text_color(dark))
|
|
content.add_child(body)
|
|
|
|
|
|
func _render_review_followups() -> void:
|
|
if review_followups_content == null:
|
|
return
|
|
_clear_container_children(review_followups_content)
|
|
var question := BlockSessionService.get_current_question()
|
|
var authored: Array = question.get("review_followups", [])
|
|
if review_followup_states.size() != authored.size():
|
|
review_followup_states.clear()
|
|
for _index in range(authored.size()):
|
|
review_followup_states.append({"selected_choice_id": "", "answered": false})
|
|
review_followups_section.visible = not authored.is_empty()
|
|
if authored.is_empty():
|
|
return
|
|
var answered_count := 0
|
|
for state in review_followup_states:
|
|
if bool(state.get("answered", false)):
|
|
answered_count += 1
|
|
var heading := find_child("ReviewFollowupHeading", true, false) as Label
|
|
if heading != null:
|
|
heading.text = "FOLLOW-UP QUESTIONS | %d of %d answered" % [answered_count, authored.size()]
|
|
for index in range(authored.size()):
|
|
if typeof(authored[index]) != TYPE_DICTIONARY:
|
|
continue
|
|
_add_review_followup_card(index, authored[index], review_followup_states[index])
|
|
|
|
|
|
func _add_review_followup_card(index: int, followup: Dictionary, state: Dictionary) -> void:
|
|
var dark := _followup_is_dark()
|
|
var answered := bool(state.get("answered", false))
|
|
var selected_id := str(state.get("selected_choice_id", ""))
|
|
var correct_id := str(followup.get("correct_choice_id", ""))
|
|
var is_correct := answered and selected_id == correct_id
|
|
var accent := Brand.SUCCESS if is_correct else Color("fb7185") if answered else Brand.PURPLE
|
|
var card := PanelContainer.new()
|
|
card.add_theme_stylebox_override("panel", _review_followup_box(dark, accent, answered))
|
|
review_followups_content.add_child(card)
|
|
var content := VBoxContainer.new()
|
|
content.add_theme_constant_override("separation", 8)
|
|
card.add_child(content)
|
|
var prompt := Label.new()
|
|
prompt.text = "%d. %s" % [index + 1, str(followup.get("prompt", ""))]
|
|
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(prompt, 15, Brand.text_color(dark))
|
|
content.add_child(prompt)
|
|
var choices: Array = followup.get("choices", [])
|
|
for option in choices:
|
|
if typeof(option) != TYPE_DICTIONARY:
|
|
continue
|
|
var option_id := str(option.get("id", ""))
|
|
var option_button := Button.new()
|
|
option_button.text = "%s. %s" % [option_id, str(option.get("text", ""))]
|
|
option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
option_button.disabled = answered
|
|
option_button.custom_minimum_size = Vector2(0, 38)
|
|
_set_scaled_button_font(option_button, 14)
|
|
_style_review_followup_option(option_button, dark, option_id, selected_id, correct_id, answered)
|
|
option_button.pressed.connect(_answer_review_followup.bind(index, option_id))
|
|
content.add_child(option_button)
|
|
if answered:
|
|
var teaching_point := Label.new()
|
|
teaching_point.text = str(followup.get("teaching_point", ""))
|
|
teaching_point.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(teaching_point, 14, accent)
|
|
content.add_child(teaching_point)
|
|
|
|
|
|
func _answer_review_followup(index: int, choice_id: String) -> void:
|
|
if index < 0 or index >= review_followup_states.size():
|
|
return
|
|
var state: Dictionary = review_followup_states[index]
|
|
if bool(state.get("answered", false)):
|
|
return
|
|
state["selected_choice_id"] = choice_id
|
|
state["answered"] = true
|
|
review_followup_states[index] = state
|
|
_render_review_followups()
|
|
|
|
|
|
func _render_wrong_option_reviews() -> void:
|
|
if wrong_options_content == null:
|
|
return
|
|
_clear_container_children(wrong_options_content)
|
|
var question := BlockSessionService.get_current_question()
|
|
var correct_id := str(question.get("correct_choice_id", ""))
|
|
var explanations: Dictionary = question.get("choice_explanations", {})
|
|
var incorrect_count := 0
|
|
for choice in _get_presented_choices():
|
|
if typeof(choice) != TYPE_DICTIONARY:
|
|
continue
|
|
var choice_id := str(choice.get("id", ""))
|
|
if choice_id == correct_id:
|
|
continue
|
|
incorrect_count += 1
|
|
_add_wrong_option_review(choice, str(explanations.get(choice_id, "")))
|
|
wrong_options_section.visible = incorrect_count > 0
|
|
|
|
|
|
func _add_wrong_option_review(choice: Dictionary, explanation: String) -> void:
|
|
var dark := _followup_is_dark()
|
|
var choice_id := str(choice.get("id", ""))
|
|
var expanded := bool(wrong_option_expanded.get(choice_id, false))
|
|
var card := PanelContainer.new()
|
|
card.add_theme_stylebox_override("panel", _wrong_option_box(dark))
|
|
wrong_options_content.add_child(card)
|
|
var content := VBoxContainer.new()
|
|
content.add_theme_constant_override("separation", 6)
|
|
card.add_child(content)
|
|
var header := Button.new()
|
|
header.flat = true
|
|
header.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
header.text = "%s. %s" % [_display_label_for_choice_id(choice_id), str(choice.get("text", ""))]
|
|
header.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg")
|
|
header.icon_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
header.expand_icon = false
|
|
header.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
header.add_theme_color_override("icon_normal_color", Brand.muted_color(dark))
|
|
_set_scaled_button_font(header, 14)
|
|
header.pressed.connect(_toggle_wrong_option_review.bind(choice_id))
|
|
content.add_child(header)
|
|
if expanded:
|
|
var detail := Label.new()
|
|
detail.text = explanation
|
|
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(detail, 14, Brand.muted_color(dark))
|
|
content.add_child(detail)
|
|
|
|
|
|
func _toggle_wrong_option_review(choice_id: String) -> void:
|
|
wrong_option_expanded[choice_id] = not bool(wrong_option_expanded.get(choice_id, false))
|
|
_render_wrong_option_reviews()
|
|
|
|
|
|
func _strip_correct_prefix(value: String) -> String:
|
|
if value.begins_with("Correct. "):
|
|
return value.substr(9)
|
|
return value
|
|
|
|
|
|
func _show_references(raw_references: Variant) -> void:
|
|
_clear_references()
|
|
if typeof(raw_references) != TYPE_ARRAY:
|
|
return
|
|
var valid_references: Array = []
|
|
for reference in raw_references:
|
|
if typeof(reference) != TYPE_DICTIONARY:
|
|
continue
|
|
var url := str(reference.get("url", "")).strip_edges()
|
|
if not _is_safe_reference_url(url):
|
|
continue
|
|
valid_references.append(reference)
|
|
if valid_references.is_empty():
|
|
return
|
|
|
|
var heading := Label.new()
|
|
heading.text = "References"
|
|
_set_scaled_label(heading, 18, Style.TEXT)
|
|
references_container.add_child(heading)
|
|
for reference in valid_references:
|
|
var title := str(reference.get("title", "Reference")).strip_edges()
|
|
var publisher := str(reference.get("publisher", "")).strip_edges()
|
|
var url := str(reference.get("url", "")).strip_edges()
|
|
var link := LinkButton.new()
|
|
link.text = title if publisher == "" else "%s - %s" % [title, publisher]
|
|
link.tooltip_text = url
|
|
link.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
link.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
|
link.add_theme_color_override("font_color", Style.CYAN)
|
|
link.add_theme_color_override("font_hover_color", Style.MAGENTA)
|
|
link.set_meta("question_base_font_size", 16)
|
|
link.add_theme_font_size_override("font_size", _scaled_font_size(16))
|
|
link.pressed.connect(_open_reference_url.bind(url))
|
|
references_container.add_child(link)
|
|
references_container.visible = true
|
|
|
|
|
|
func _clear_references() -> void:
|
|
if references_container == null:
|
|
return
|
|
for child in references_container.get_children():
|
|
references_container.remove_child(child)
|
|
child.queue_free()
|
|
references_container.visible = false
|
|
|
|
|
|
func _is_safe_reference_url(url: String) -> bool:
|
|
return url.begins_with("https://") and url.length() > 8
|
|
|
|
|
|
func _open_reference_url(url: String) -> void:
|
|
if not _is_safe_reference_url(url):
|
|
return
|
|
OS.shell_open(url)
|
|
|
|
|
|
func _advance_or_finish() -> void:
|
|
if BlockSessionService.move_next():
|
|
_load_question()
|
|
else:
|
|
_finish()
|
|
|
|
|
|
func _finish() -> void:
|
|
if BlockSessionService.session_kind == "bookmark":
|
|
BlockSessionService.finish_block()
|
|
main.show_screen("bookmarks")
|
|
return
|
|
if BlockSessionService.session_kind == "core":
|
|
BlockSessionService.finish_block()
|
|
main.show_screen("selection", "exam")
|
|
return
|
|
if BlockSessionService.session_kind == "review":
|
|
BlockSessionService.finish_block()
|
|
main.show_screen("review_queue_results", ReviewQueueService.get_summary())
|
|
return
|
|
var results := BlockSessionService.finish_block()
|
|
main.show_screen("review", results)
|
|
|
|
|
|
func _exit_run() -> void:
|
|
if BlockSessionService.session_kind == "bookmark":
|
|
BlockSessionService.finish_block()
|
|
main.show_screen("bookmarks")
|
|
return
|
|
if BlockSessionService.session_kind == "review":
|
|
BlockSessionService.finish_block()
|
|
DashboardService.refresh()
|
|
main.show_screen("dashboard")
|
|
return
|
|
var was_core_run := BlockSessionService.session_kind == "core"
|
|
BlockSessionService.resume_current_question_timer()
|
|
BlockSessionService.finish_block()
|
|
main.show_screen("selection", "exam" if was_core_run else "step3")
|
|
|
|
|
|
func _update_timer_bar() -> void:
|
|
if timer_bar == null:
|
|
return
|
|
var is_core_timer := BlockSessionService.session_kind == "core"
|
|
var ratio := BlockSessionService.get_session_timer_ratio()
|
|
timer_bar.value = ratio * 100.0
|
|
if is_core_timer and timer_label.visible:
|
|
_set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())))
|
|
var color_state := "blue"
|
|
var fill_color := Brand.ROYAL_BLUE
|
|
if ratio < 0.25:
|
|
color_state = "red"
|
|
fill_color = Style.RED
|
|
elif ratio <= 0.5:
|
|
color_state = "amber"
|
|
fill_color = Style.AMBER
|
|
if color_state != timer_color_state:
|
|
timer_color_state = color_state
|
|
timer_bar.add_theme_stylebox_override("fill", Style.button_box(fill_color, fill_color))
|
|
|
|
|
|
func _set_timer_display(total_seconds: int) -> void:
|
|
if timer_label == null:
|
|
return
|
|
var safe_seconds := maxi(0, total_seconds)
|
|
timer_label.text = "%02d:%02d" % [int(floor(float(safe_seconds) / 60.0)), safe_seconds % 60]
|
|
|
|
|
|
func _is_session_timer_visible() -> bool:
|
|
if BlockSessionService.session_kind == "core" or BlockSessionService.session_kind == "simulation":
|
|
return true
|
|
return BlockSessionService.mode == "Timed"
|
|
|
|
|
|
func _question_progress_text() -> String:
|
|
if BlockSessionService.session_kind == "core":
|
|
return "Question %d" % (BlockSessionService.core_answer_count + 1)
|
|
return "Question %d of %d" % [
|
|
mini(BlockSessionService.current_index + 1, BlockSessionService.active_questions.size()),
|
|
BlockSessionService.active_questions.size()
|
|
]
|
|
|
|
|
|
func _toggle_question_navigator() -> void:
|
|
if navigator_popup == null or BlockSessionService.session_kind == "core":
|
|
return
|
|
if navigator_popup.visible:
|
|
navigator_popup.hide()
|
|
return
|
|
_refresh_nav_palette()
|
|
var popup_size := Vector2i(340, 200)
|
|
var anchor := progress_button.get_global_rect()
|
|
var viewport_size := get_viewport_rect().size
|
|
var popup_x := int(clampf(anchor.position.x, 8.0, maxf(8.0, viewport_size.x - popup_size.x - 8.0)))
|
|
var popup_y := int(minf(anchor.end.y + 6.0, viewport_size.y - popup_size.y - 8.0))
|
|
navigator_popup.popup(Rect2i(Vector2i(popup_x, popup_y), popup_size))
|
|
|
|
|
|
func _navigate_to_question(target_index: int) -> void:
|
|
if target_index == BlockSessionService.current_index:
|
|
navigator_popup.hide()
|
|
return
|
|
if BlockSessionService.session_kind == "simulation":
|
|
_save_simulation_current_response()
|
|
if BlockSessionService.move_to(target_index):
|
|
navigator_popup.hide()
|
|
_load_question()
|
|
|
|
|
|
func _lock_wrong_choice(choice_id: String) -> void:
|
|
if choice_id == "":
|
|
return
|
|
if not locked_wrong_choice_ids.has(choice_id):
|
|
locked_wrong_choice_ids.append(choice_id)
|
|
|
|
|
|
func _trigger_wrong_feedback() -> void:
|
|
UISoundService.play_sound("error")
|
|
shake_time = 0.28
|
|
shake_origin = position
|
|
green_fog_time = 0.0
|
|
red_fog_time = FEEDBACK_FADE_SECONDS
|
|
if feedback_fog != null:
|
|
feedback_fog.color = Color(1.0, 0.0, 0.0, _feedback_overlay_alpha(false))
|
|
|
|
|
|
func _trigger_correct_feedback() -> void:
|
|
UISoundService.play_sound("correct")
|
|
red_fog_time = 0.0
|
|
green_fog_time = FEEDBACK_FADE_SECONDS
|
|
if skip_button != null:
|
|
skip_button.disabled = true
|
|
for button in choice_buttons:
|
|
button.disabled = true
|
|
if feedback_fog != null:
|
|
feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true))
|
|
|
|
|
|
func _update_answer_feedback(delta: float) -> void:
|
|
if shake_time > 0.0:
|
|
shake_time = max(0.0, shake_time - delta)
|
|
if shake_time > 0.0:
|
|
position = shake_origin + Vector2(randf_range(-9.0, 9.0), randf_range(-4.0, 4.0))
|
|
else:
|
|
position = shake_origin
|
|
|
|
if red_fog_time > 0.0 and feedback_fog != null:
|
|
red_fog_time = max(0.0, red_fog_time - delta)
|
|
var red_alpha := _feedback_overlay_alpha(false) * (red_fog_time / FEEDBACK_FADE_SECONDS)
|
|
feedback_fog.color = Color(1.0, 0.0, 0.0, red_alpha)
|
|
elif green_fog_time > 0.0 and feedback_fog != null:
|
|
green_fog_time = max(0.0, green_fog_time - delta)
|
|
var green_alpha := _feedback_overlay_alpha(true) * (green_fog_time / FEEDBACK_FADE_SECONDS)
|
|
feedback_fog.color = Color(0.25, 1.0, 0.25, green_alpha)
|
|
|
|
if pending_correct_advance:
|
|
correct_advance_delay = max(0.0, correct_advance_delay - delta)
|
|
if correct_advance_delay <= 0.0:
|
|
pending_correct_advance = false
|
|
next_button.disabled = false
|
|
if skip_button != null:
|
|
skip_button.disabled = false
|
|
_advance_or_finish()
|
|
|
|
|
|
func _feedback_overlay_alpha(is_correct: bool) -> float:
|
|
if Style.is_dark:
|
|
return 0.23 if is_correct else 0.26
|
|
return 0.07
|
|
|
|
|
|
func _reset_wrong_feedback() -> void:
|
|
if shake_time > 0.0:
|
|
position = shake_origin
|
|
shake_time = 0.0
|
|
red_fog_time = 0.0
|
|
green_fog_time = 0.0
|
|
correct_advance_delay = 0.0
|
|
pending_correct_advance = false
|
|
next_button.disabled = false
|
|
if skip_button != null:
|
|
skip_button.disabled = false
|
|
if feedback_fog != null:
|
|
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
|
|
|
|
|
|
func _commit_current_text_selection(source: String = "stem") -> void:
|
|
var target_label := stem_label if source == "stem" else explanation_label
|
|
if target_label == null:
|
|
return
|
|
var raw_selected_text := target_label.get_selected_text()
|
|
var selected_text := raw_selected_text.strip_edges()
|
|
if selected_text == "":
|
|
return
|
|
|
|
var left_trimmed_text := raw_selected_text.strip_edges(true, false)
|
|
var leading_trim_count := raw_selected_text.length() - left_trimmed_text.length()
|
|
var selection_start := target_label.get_selection_from() + leading_trim_count
|
|
_highlight_range(source, selection_start, selection_start + selected_text.length())
|
|
|
|
|
|
func _highlight_range(source: String, start: int, end: int) -> void:
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
var highlight_store := question_highlights if source == "stem" else explanation_highlights
|
|
var plain_text := stem_plain_text if source == "stem" else explanation_plain_text
|
|
var target_label := stem_label if source == "stem" else explanation_label
|
|
if not highlight_store.has(question_id):
|
|
highlight_store[question_id] = []
|
|
|
|
var highlights: Array = highlight_store[question_id]
|
|
start = clamp(start, 0, plain_text.length())
|
|
end = clamp(end, start, plain_text.length())
|
|
if start == end:
|
|
return
|
|
|
|
if _subtract_highlight_range(highlights, start, end):
|
|
_render_highlighted_text(source)
|
|
if target_label.has_method("deselect"):
|
|
target_label.deselect()
|
|
return
|
|
|
|
highlights.append({"start": start, "end": end})
|
|
_render_highlighted_text(source)
|
|
if target_label.has_method("deselect"):
|
|
target_label.deselect()
|
|
|
|
|
|
func _clear_current_highlights() -> void:
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
question_highlights[question_id] = []
|
|
explanation_highlights[question_id] = []
|
|
_render_stem()
|
|
if explanation_label.visible:
|
|
_render_explanation()
|
|
|
|
|
|
func _toggle_report_current_question() -> void:
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
if question_id == "":
|
|
return
|
|
_show_report_modal(question_id)
|
|
|
|
|
|
func _refresh_toolbar_state() -> void:
|
|
_refresh_theme_button()
|
|
_refresh_labs_button()
|
|
if report_button != null:
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
|
|
if notes_button != null:
|
|
notes_button.visible = BlockSessionService.session_kind != "simulation"
|
|
if flashcard_button != null:
|
|
flashcard_button.visible = BlockSessionService.session_kind != "simulation"
|
|
flashcard_button.disabled = current_answer.is_empty()
|
|
if bookmark_button != null:
|
|
bookmark_button.visible = BlockSessionService.session_kind != "simulation"
|
|
var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id)
|
|
var bookmark_pending := BookmarkService.is_pending(bookmark_question_id)
|
|
bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question")
|
|
_apply_question_theme()
|
|
|
|
|
|
func _toggle_bookmark_current_question() -> void:
|
|
var question := BlockSessionService.get_current_question()
|
|
if not question.is_empty():
|
|
BookmarkService.toggle_question(question)
|
|
|
|
|
|
func _toggle_flashcard_current_question() -> void:
|
|
if current_answer.is_empty():
|
|
return
|
|
var question := BlockSessionService.get_current_question()
|
|
if question.is_empty() or flashcard_prefill_dialog == null:
|
|
return
|
|
flashcard_prefill_dialog.open_for_question(question, current_answer)
|
|
|
|
|
|
func _on_bookmark_status_changed(question_id: String, value: bool, pending: bool) -> void:
|
|
if question_id != str(BlockSessionService.get_current_question().get("id", "")) or bookmark_button == null:
|
|
return
|
|
bookmark_button.tooltip_text = ("Saved; waiting to sync" if pending else "Remove bookmark") if value else ("Removal waiting to sync" if pending else "Save question")
|
|
_apply_question_theme()
|
|
|
|
|
|
func _on_bookmark_question_loaded(question: Dictionary) -> void:
|
|
if not visible or BlockSessionService.session_kind != "bookmark" or not awaiting_next_question:
|
|
return
|
|
awaiting_next_question = false
|
|
if BlockSessionService.replace_bookmark_question(question):
|
|
_load_question()
|
|
|
|
|
|
func _on_bookmark_question_failed(message: String) -> void:
|
|
if not visible or BlockSessionService.session_kind != "bookmark":
|
|
return
|
|
awaiting_next_question = false
|
|
next_button.text = "Retry"
|
|
next_button.disabled = false
|
|
next_button.tooltip_text = message
|
|
|
|
|
|
func _on_review_question_loaded(question: Dictionary) -> void:
|
|
if not visible or BlockSessionService.session_kind != "review" or not awaiting_next_question:
|
|
return
|
|
awaiting_next_question = false
|
|
if BlockSessionService.replace_review_question(question):
|
|
_load_question()
|
|
UISoundService.play_sound("question")
|
|
|
|
|
|
func _on_review_question_failed(message: String) -> void:
|
|
if not visible or BlockSessionService.session_kind != "review":
|
|
return
|
|
awaiting_next_question = false
|
|
next_button.text = "Retry"
|
|
next_button.disabled = false
|
|
next_button.tooltip_text = message
|
|
|
|
|
|
func _on_review_pass_completed(summary: Dictionary) -> void:
|
|
if not visible or BlockSessionService.session_kind != "review":
|
|
return
|
|
awaiting_next_question = false
|
|
BlockSessionService.finish_block()
|
|
main.show_screen("review_queue_results", summary)
|
|
|
|
|
|
func refresh_theme_controls() -> void:
|
|
if media_viewer_background != null:
|
|
media_viewer_background.color = Style.BACKGROUND
|
|
_refresh_toolbar_state()
|
|
_apply_saved_choice_state()
|
|
_apply_question_theme()
|
|
|
|
|
|
func _refresh_theme_button() -> void:
|
|
if theme_button == null:
|
|
return
|
|
_style_theme_toggle_button(theme_service.is_dark_mode())
|
|
|
|
|
|
func _adjust_question_text_scale(delta: float) -> void:
|
|
var next_scale: float = clamp(question_text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
|
|
if is_equal_approx(next_scale, question_text_scale):
|
|
return
|
|
question_text_scale = next_scale
|
|
_refresh_scaled_fonts(self)
|
|
_render_stem()
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _set_scaled_label(label: Label, base_size: int, color: Color = Style.TEXT) -> void:
|
|
label.set_meta("question_base_font_size", base_size)
|
|
label.add_theme_color_override("font_color", color)
|
|
label.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
|
|
|
|
|
|
func _set_scaled_button_font(button: Button, base_size: int) -> void:
|
|
button.set_meta("question_base_font_size", base_size)
|
|
button.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
|
|
|
|
|
|
func _set_scaled_rich_text(label: RichTextLabel, base_size: int) -> void:
|
|
label.set_meta("question_base_font_size", base_size)
|
|
label.add_theme_color_override("default_color", Style.TEXT)
|
|
label.add_theme_font_size_override("normal_font_size", _scaled_font_size(base_size))
|
|
label.add_theme_font_size_override("bold_font_size", _scaled_font_size(base_size))
|
|
|
|
|
|
func _refresh_scaled_fonts(node: Node) -> void:
|
|
if node.has_meta("question_base_font_size"):
|
|
var base_size := int(node.get_meta("question_base_font_size"))
|
|
if node is Label:
|
|
node.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
|
|
elif node is Button:
|
|
node.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
|
|
elif node is LinkButton:
|
|
node.add_theme_font_size_override("font_size", _scaled_font_size(base_size))
|
|
elif node is RichTextLabel:
|
|
node.add_theme_font_size_override("normal_font_size", _scaled_font_size(base_size))
|
|
node.add_theme_font_size_override("bold_font_size", _scaled_font_size(base_size))
|
|
for child in node.get_children():
|
|
_refresh_scaled_fonts(child)
|
|
|
|
|
|
func _scaled_font_size(base_size: int) -> int:
|
|
return int(round(float(base_size) * question_text_scale))
|
|
|
|
|
|
func _refresh_labs_button() -> void:
|
|
if labs_button == null:
|
|
return
|
|
labs_button.visible = _current_question_has_labs()
|
|
|
|
|
|
func _current_question_has_labs() -> bool:
|
|
var question := BlockSessionService.get_current_question()
|
|
if question.is_empty():
|
|
return false
|
|
var labs: Array = question.get("labs", [])
|
|
return not labs.is_empty()
|
|
|
|
|
|
func _load_question_media(question: Dictionary) -> void:
|
|
_clear_question_media()
|
|
var media: Array = question.get("media", [])
|
|
if media.is_empty():
|
|
return
|
|
|
|
media_container.visible = true
|
|
var question_id := str(question.get("id", ""))
|
|
for item in media:
|
|
var media_url := _resolve_media_url(str(item))
|
|
if media_url == "":
|
|
continue
|
|
var media_index := question_media_items.size()
|
|
question_media_items.append({"texture": null, "loaded": false, "failed": false})
|
|
var frame := PanelContainer.new()
|
|
frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
frame.add_theme_stylebox_override("panel", Style.panel_box(Color("111a2a"), Color("344463")))
|
|
media_container.add_child(frame)
|
|
|
|
var image_box := VBoxContainer.new()
|
|
image_box.add_theme_constant_override("separation", 8)
|
|
frame.add_child(image_box)
|
|
|
|
var status := Label.new()
|
|
status.text = "Loading image..."
|
|
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
_set_scaled_label(status, 15, Style.MUTED)
|
|
image_box.add_child(status)
|
|
|
|
var preview := Control.new()
|
|
preview.visible = false
|
|
preview.custom_minimum_size = Vector2(0, 260)
|
|
preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
|
preview.tooltip_text = "Open enlarged image"
|
|
preview.gui_input.connect(func(event: InputEvent): _media_preview_gui_input(event, media_index))
|
|
image_box.add_child(preview)
|
|
|
|
var texture_rect := TextureRect.new()
|
|
texture_rect.visible = false
|
|
texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
texture_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
preview.add_child(texture_rect)
|
|
|
|
var request := HTTPRequest.new()
|
|
request.request_completed.connect(
|
|
func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray):
|
|
_on_media_loaded(result, response_code, body, question_id, media_index, preview, texture_rect, status)
|
|
)
|
|
media_requests.append(request)
|
|
add_child(request)
|
|
var err := request.request(media_url)
|
|
if err != OK:
|
|
status.text = "Image unavailable."
|
|
|
|
|
|
func _clear_question_media() -> void:
|
|
_hide_media_viewer()
|
|
for request in media_requests:
|
|
if is_instance_valid(request):
|
|
request.queue_free()
|
|
media_requests.clear()
|
|
question_media_items.clear()
|
|
if media_container == null:
|
|
return
|
|
for child in media_container.get_children():
|
|
media_container.remove_child(child)
|
|
child.queue_free()
|
|
media_container.visible = false
|
|
|
|
|
|
func _resolve_media_url(value: String) -> String:
|
|
var media_url := value.strip_edges()
|
|
if media_url == "":
|
|
return ""
|
|
if media_url.begins_with("http://") or media_url.begins_with("https://"):
|
|
return media_url
|
|
if not media_url.begins_with("/"):
|
|
media_url = "/%s" % media_url
|
|
|
|
var base_url := QuestionBankService.question_api_base_url
|
|
var scheme_end := base_url.find("://")
|
|
if scheme_end < 0:
|
|
return media_url
|
|
var path_start := base_url.find("/", scheme_end + 3)
|
|
var origin := base_url if path_start < 0 else base_url.substr(0, path_start)
|
|
return "%s%s" % [origin, media_url]
|
|
|
|
|
|
func _on_media_loaded(
|
|
result: int,
|
|
response_code: int,
|
|
body: PackedByteArray,
|
|
question_id: String,
|
|
media_index: int,
|
|
preview: Control,
|
|
texture_rect: TextureRect,
|
|
status: Label) -> void:
|
|
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
|
|
return
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
|
status.text = "Image unavailable."
|
|
if media_index < question_media_items.size():
|
|
question_media_items[media_index]["failed"] = true
|
|
return
|
|
|
|
var image := Image.new()
|
|
var err := image.load_png_from_buffer(body)
|
|
if err != OK:
|
|
err = image.load_jpg_from_buffer(body)
|
|
if err != OK:
|
|
err = image.load_webp_from_buffer(body)
|
|
if err != OK:
|
|
status.text = "Image unavailable."
|
|
if media_index < question_media_items.size():
|
|
question_media_items[media_index]["failed"] = true
|
|
return
|
|
|
|
var texture := ImageTexture.create_from_image(image)
|
|
question_media_items[media_index]["texture"] = texture
|
|
question_media_items[media_index]["loaded"] = true
|
|
texture_rect.texture = texture
|
|
texture_rect.visible = true
|
|
preview.visible = true
|
|
status.visible = false
|
|
|
|
|
|
func _build_media_viewer() -> void:
|
|
media_viewer_overlay = Control.new()
|
|
media_viewer_overlay.visible = false
|
|
media_viewer_overlay.top_level = true
|
|
media_viewer_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
media_viewer_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
add_child(media_viewer_overlay)
|
|
|
|
media_viewer_background = ColorRect.new()
|
|
media_viewer_background.color = Style.BACKGROUND
|
|
media_viewer_background.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
media_viewer_background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
media_viewer_overlay.add_child(media_viewer_background)
|
|
|
|
var layout := VBoxContainer.new()
|
|
layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
layout.offset_left = 16
|
|
layout.offset_top = 12
|
|
layout.offset_right = -16
|
|
layout.offset_bottom = -16
|
|
layout.add_theme_constant_override("separation", 10)
|
|
media_viewer_overlay.add_child(layout)
|
|
|
|
var toolbar := HBoxContainer.new()
|
|
toolbar.add_theme_constant_override("separation", 8)
|
|
layout.add_child(toolbar)
|
|
|
|
media_viewer_position_label = Label.new()
|
|
media_viewer_position_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_set_scaled_label(media_viewer_position_label, 17, Style.TEXT)
|
|
toolbar.add_child(media_viewer_position_label)
|
|
|
|
media_viewer_previous_button = _make_media_viewer_button("<", "Previous image")
|
|
media_viewer_previous_button.pressed.connect(func(): _show_adjacent_media(-1))
|
|
toolbar.add_child(media_viewer_previous_button)
|
|
media_viewer_next_button = _make_media_viewer_button(">", "Next image")
|
|
media_viewer_next_button.pressed.connect(func(): _show_adjacent_media(1))
|
|
toolbar.add_child(media_viewer_next_button)
|
|
|
|
var zoom_out := _make_media_viewer_button("-", "Zoom out")
|
|
zoom_out.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR))
|
|
toolbar.add_child(zoom_out)
|
|
media_viewer_zoom_label = Label.new()
|
|
media_viewer_zoom_label.custom_minimum_size = Vector2(64, 44)
|
|
media_viewer_zoom_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
media_viewer_zoom_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
_set_scaled_label(media_viewer_zoom_label, 15, Style.TEXT)
|
|
toolbar.add_child(media_viewer_zoom_label)
|
|
var zoom_in := _make_media_viewer_button("+", "Zoom in")
|
|
zoom_in.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR))
|
|
toolbar.add_child(zoom_in)
|
|
var fit_button := _make_media_viewer_button("Fit", "Fit image to window", Vector2(60, 44))
|
|
fit_button.pressed.connect(_fit_media_viewer_image)
|
|
toolbar.add_child(fit_button)
|
|
var close_button := _make_media_viewer_button("X", "Close image viewer")
|
|
close_button.pressed.connect(_hide_media_viewer)
|
|
toolbar.add_child(close_button)
|
|
|
|
media_viewer_scroll = ScrollContainer.new()
|
|
media_viewer_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
media_viewer_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
media_viewer_scroll.gui_input.connect(_media_viewer_gui_input)
|
|
layout.add_child(media_viewer_scroll)
|
|
|
|
media_viewer_center = CenterContainer.new()
|
|
media_viewer_center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
media_viewer_center.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
media_viewer_scroll.add_child(media_viewer_center)
|
|
|
|
media_viewer_texture = TextureRect.new()
|
|
media_viewer_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
media_viewer_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
media_viewer_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
media_viewer_center.add_child(media_viewer_texture)
|
|
|
|
|
|
func _make_media_viewer_button(text_value: String, tooltip: String, minimum := Vector2(44, 44)) -> Button:
|
|
var button := Button.new()
|
|
button.text = text_value
|
|
button.tooltip_text = tooltip
|
|
button.custom_minimum_size = minimum
|
|
Style.style_chip(button, Style.CYAN)
|
|
UISoundService.set_button_sound(button, "toggle")
|
|
return button
|
|
|
|
|
|
func _media_preview_gui_input(event: InputEvent, media_index: int) -> void:
|
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
|
_show_media_viewer(media_index)
|
|
accept_event()
|
|
elif event is InputEventScreenTouch and event.pressed:
|
|
_show_media_viewer(media_index)
|
|
accept_event()
|
|
|
|
|
|
func _show_media_viewer(index: int) -> void:
|
|
if index < 0 or index >= question_media_items.size():
|
|
return
|
|
if not bool(question_media_items[index].get("loaded", false)):
|
|
return
|
|
var texture := question_media_items[index].get("texture") as Texture2D
|
|
if texture == null:
|
|
return
|
|
media_viewer_index = index
|
|
media_viewer_texture.texture = texture
|
|
media_viewer_overlay.visible = true
|
|
media_viewer_overlay.move_to_front()
|
|
_update_media_viewer_navigation()
|
|
call_deferred("_fit_media_viewer_image")
|
|
|
|
|
|
func _hide_media_viewer() -> void:
|
|
media_viewer_dragging = false
|
|
media_viewer_index = -1
|
|
if media_viewer_overlay != null:
|
|
media_viewer_overlay.visible = false
|
|
|
|
|
|
func _show_adjacent_media(direction: int) -> void:
|
|
var next_index := _find_loaded_media_index(media_viewer_index, direction)
|
|
if next_index >= 0:
|
|
_show_media_viewer(next_index)
|
|
|
|
|
|
func _find_loaded_media_index(from_index: int, direction: int) -> int:
|
|
var index := from_index + direction
|
|
while index >= 0 and index < question_media_items.size():
|
|
if bool(question_media_items[index].get("loaded", false)):
|
|
return index
|
|
index += direction
|
|
return -1
|
|
|
|
|
|
func _update_media_viewer_navigation() -> void:
|
|
media_viewer_position_label.text = "Image %d of %d" % [media_viewer_index + 1, question_media_items.size()]
|
|
media_viewer_previous_button.disabled = _find_loaded_media_index(media_viewer_index, -1) < 0
|
|
media_viewer_next_button.disabled = _find_loaded_media_index(media_viewer_index, 1) < 0
|
|
|
|
|
|
func _fit_media_viewer_image() -> void:
|
|
if media_viewer_texture == null or media_viewer_texture.texture == null:
|
|
return
|
|
var image_size := media_viewer_texture.texture.get_size()
|
|
var available := media_viewer_scroll.size - Vector2(24, 24)
|
|
if image_size.x <= 0.0 or image_size.y <= 0.0 or available.x <= 0.0 or available.y <= 0.0:
|
|
return
|
|
_set_media_viewer_zoom(minf(available.x / image_size.x, available.y / image_size.y))
|
|
|
|
|
|
func _set_media_viewer_zoom(value: float) -> void:
|
|
if media_viewer_texture == null or media_viewer_texture.texture == null:
|
|
return
|
|
media_viewer_zoom = clampf(value, MEDIA_ZOOM_MIN, MEDIA_ZOOM_MAX)
|
|
var scaled_size := media_viewer_texture.texture.get_size() * media_viewer_zoom
|
|
media_viewer_texture.custom_minimum_size = scaled_size
|
|
media_viewer_center.custom_minimum_size = Vector2(
|
|
maxf(media_viewer_scroll.size.x, scaled_size.x),
|
|
maxf(media_viewer_scroll.size.y, scaled_size.y))
|
|
media_viewer_zoom_label.text = "%d%%" % int(round(media_viewer_zoom * 100.0))
|
|
|
|
|
|
func _media_viewer_gui_input(event: InputEvent) -> void:
|
|
if event is InputEventMouseButton:
|
|
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
|
|
_set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR)
|
|
accept_event()
|
|
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
|
|
_set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR)
|
|
accept_event()
|
|
elif event.button_index == MOUSE_BUTTON_LEFT:
|
|
media_viewer_dragging = event.pressed
|
|
media_viewer_drag_position = event.position
|
|
accept_event()
|
|
elif event is InputEventMouseMotion and media_viewer_dragging:
|
|
var drag_delta: Vector2 = event.position - media_viewer_drag_position
|
|
media_viewer_drag_position = event.position
|
|
media_viewer_scroll.scroll_horizontal -= int(drag_delta.x)
|
|
media_viewer_scroll.scroll_vertical -= int(drag_delta.y)
|
|
accept_event()
|
|
elif event is InputEventMagnifyGesture:
|
|
_set_media_viewer_zoom(media_viewer_zoom * event.factor)
|
|
accept_event()
|
|
elif event is InputEventScreenDrag:
|
|
media_viewer_scroll.scroll_horizontal -= int(event.relative.x)
|
|
media_viewer_scroll.scroll_vertical -= int(event.relative.y)
|
|
accept_event()
|
|
|
|
|
|
func _build_report_modal() -> void:
|
|
report_overlay = Control.new()
|
|
report_overlay.visible = false
|
|
report_overlay.top_level = true
|
|
report_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
report_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
report_overlay.gui_input.connect(_report_overlay_gui_input)
|
|
add_child(report_overlay)
|
|
|
|
var dim := ColorRect.new()
|
|
dim.color = Color(0.02, 0.03, 0.05, 0.78)
|
|
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
report_overlay.add_child(dim)
|
|
|
|
var center := CenterContainer.new()
|
|
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
report_overlay.add_child(center)
|
|
|
|
report_panel = PanelContainer.new()
|
|
report_panel.custom_minimum_size = Vector2(700, 600)
|
|
report_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
report_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.RED))
|
|
center.add_child(report_panel)
|
|
|
|
var modal_body := VBoxContainer.new()
|
|
modal_body.add_theme_constant_override("separation", 14)
|
|
report_panel.add_child(modal_body)
|
|
|
|
var header := HBoxContainer.new()
|
|
header.add_theme_constant_override("separation", 10)
|
|
modal_body.add_child(header)
|
|
|
|
var title := Label.new()
|
|
title.text = "Report question"
|
|
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_set_scaled_label(title, 26, Style.TEXT)
|
|
header.add_child(title)
|
|
|
|
var close_button := Button.new()
|
|
close_button.text = "X"
|
|
close_button.tooltip_text = "Close report form"
|
|
close_button.custom_minimum_size = Vector2(42, 38)
|
|
Style.style_chip(close_button, Style.RED)
|
|
UISoundService.set_button_sound(close_button, "close")
|
|
close_button.pressed.connect(_hide_report_modal)
|
|
header.add_child(close_button)
|
|
|
|
report_form_content = VBoxContainer.new()
|
|
report_form_content.add_theme_constant_override("separation", 12)
|
|
modal_body.add_child(report_form_content)
|
|
|
|
var category_label := Label.new()
|
|
category_label.text = "What is the problem?"
|
|
_set_scaled_label(category_label, 18, Style.CYAN)
|
|
report_form_content.add_child(category_label)
|
|
|
|
var category_grid := GridContainer.new()
|
|
category_grid.columns = 2
|
|
category_grid.add_theme_constant_override("h_separation", 18)
|
|
category_grid.add_theme_constant_override("v_separation", 8)
|
|
report_form_content.add_child(category_grid)
|
|
|
|
report_category_group = ButtonGroup.new()
|
|
report_category_group.allow_unpress = false
|
|
for category in REPORT_CATEGORIES:
|
|
var category_button := CheckBox.new()
|
|
category_button.text = str(category["label"])
|
|
category_button.set_meta("report_category_id", str(category["id"]))
|
|
category_button.button_group = report_category_group
|
|
category_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
category_button.custom_minimum_size = Vector2(0, 38)
|
|
_set_scaled_button_font(category_button, 16)
|
|
category_button.toggled.connect(_on_report_category_toggled)
|
|
category_grid.add_child(category_button)
|
|
report_category_buttons.append(category_button)
|
|
|
|
var details_header := HBoxContainer.new()
|
|
report_form_content.add_child(details_header)
|
|
|
|
var details_label := Label.new()
|
|
details_label.text = "Details"
|
|
details_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_set_scaled_label(details_label, 18, Style.CYAN)
|
|
details_header.add_child(details_label)
|
|
|
|
report_char_count = Label.new()
|
|
report_char_count.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
_set_scaled_label(report_char_count, 14, Style.MUTED)
|
|
details_header.add_child(report_char_count)
|
|
|
|
report_details = TextEdit.new()
|
|
report_details.placeholder_text = "Describe what is wrong and what should be reviewed."
|
|
report_details.custom_minimum_size = Vector2(0, 190)
|
|
report_details.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
report_details.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
|
report_details.add_theme_font_size_override("font_size", 16)
|
|
report_details.add_theme_color_override("font_color", Style.TEXT)
|
|
report_details.add_theme_color_override("font_placeholder_color", Style.MUTED)
|
|
report_details.add_theme_stylebox_override("normal", Style.panel_box(Style.SURFACE_DARK, Color("3b4964")))
|
|
report_details.text_changed.connect(_on_report_details_changed)
|
|
report_form_content.add_child(report_details)
|
|
|
|
report_error_label = Label.new()
|
|
report_error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
report_error_label.custom_minimum_size = Vector2(0, 28)
|
|
_set_scaled_label(report_error_label, 15, Style.RED)
|
|
report_form_content.add_child(report_error_label)
|
|
|
|
var report_actions := HBoxContainer.new()
|
|
report_actions.add_theme_constant_override("separation", 10)
|
|
report_form_content.add_child(report_actions)
|
|
|
|
report_cancel_button = Button.new()
|
|
report_cancel_button.text = "Cancel"
|
|
report_cancel_button.custom_minimum_size = Vector2(130, 46)
|
|
Style.style_chip(report_cancel_button, Style.MUTED)
|
|
UISoundService.set_button_sound(report_cancel_button, "close")
|
|
report_cancel_button.pressed.connect(_hide_report_modal)
|
|
report_actions.add_child(report_cancel_button)
|
|
|
|
report_submit_button = Button.new()
|
|
report_submit_button.text = "Submit report"
|
|
report_submit_button.custom_minimum_size = Vector2(170, 46)
|
|
report_submit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
Style.style_primary_button(report_submit_button)
|
|
UISoundService.set_button_sound(report_submit_button, "confirmation")
|
|
report_submit_button.pressed.connect(_submit_question_report)
|
|
report_actions.add_child(report_submit_button)
|
|
|
|
report_success_content = VBoxContainer.new()
|
|
report_success_content.visible = false
|
|
report_success_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
report_success_content.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
report_success_content.add_theme_constant_override("separation", 18)
|
|
modal_body.add_child(report_success_content)
|
|
|
|
report_success_label = Label.new()
|
|
report_success_label.text = "Report submitted"
|
|
report_success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
report_success_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(report_success_label, 24, Style.LIME)
|
|
report_success_content.add_child(report_success_label)
|
|
|
|
var success_close_button := Button.new()
|
|
success_close_button.text = "Close"
|
|
success_close_button.custom_minimum_size = Vector2(180, 48)
|
|
Style.style_primary_button(success_close_button)
|
|
UISoundService.set_button_sound(success_close_button, "close")
|
|
success_close_button.pressed.connect(_hide_report_modal)
|
|
report_success_content.add_child(success_close_button)
|
|
|
|
_update_report_form_state()
|
|
|
|
|
|
func _report_overlay_gui_input(event: InputEvent) -> void:
|
|
var mouse_event := event as InputEventMouseButton
|
|
if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
|
|
return
|
|
if report_panel != null and not report_panel.get_global_rect().has_point(mouse_event.global_position):
|
|
_hide_report_modal()
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
func _show_report_modal(question_id: String) -> void:
|
|
if report_overlay == null or report_is_submitting:
|
|
return
|
|
_hide_labs_modal()
|
|
report_question_id = question_id
|
|
report_form_content.visible = true
|
|
report_success_content.visible = false
|
|
report_details.text = ""
|
|
report_error_label.text = ""
|
|
for category_button in report_category_buttons:
|
|
category_button.button_pressed = false
|
|
_set_report_submitting(false)
|
|
_update_report_form_state()
|
|
report_overlay.visible = true
|
|
report_overlay.move_to_front()
|
|
BlockSessionService.pause_current_question_timer()
|
|
|
|
|
|
func _hide_report_modal(force: bool = false) -> void:
|
|
if report_overlay == null or (report_is_submitting and not force):
|
|
return
|
|
if report_overlay.visible:
|
|
report_overlay.visible = false
|
|
BlockSessionService.resume_current_question_timer()
|
|
if not report_is_submitting:
|
|
report_question_id = ""
|
|
|
|
|
|
func _on_report_category_toggled(pressed: bool) -> void:
|
|
if pressed:
|
|
_update_report_form_state()
|
|
|
|
|
|
func _on_report_details_changed() -> void:
|
|
_update_report_form_state()
|
|
|
|
|
|
func _selected_report_category() -> String:
|
|
if report_category_group == null:
|
|
return ""
|
|
var selected := report_category_group.get_pressed_button()
|
|
if selected == null:
|
|
return ""
|
|
return str(selected.get_meta("report_category_id", ""))
|
|
|
|
|
|
func _update_report_form_state() -> void:
|
|
if report_details == null or report_submit_button == null:
|
|
return
|
|
var details_length := report_details.text.strip_edges().length()
|
|
report_char_count.text = "%d / %d" % [details_length, REPORT_DETAILS_MAX]
|
|
report_char_count.add_theme_color_override("font_color", Style.RED if details_length > REPORT_DETAILS_MAX else Style.MUTED)
|
|
if not report_is_submitting:
|
|
report_error_label.text = ""
|
|
report_submit_button.disabled = (
|
|
report_is_submitting
|
|
or _selected_report_category() == ""
|
|
or details_length < REPORT_DETAILS_MIN
|
|
or details_length > REPORT_DETAILS_MAX
|
|
)
|
|
|
|
|
|
func _set_report_submitting(value: bool) -> void:
|
|
report_is_submitting = value
|
|
if report_details != null:
|
|
report_details.editable = not value
|
|
for category_button in report_category_buttons:
|
|
category_button.disabled = value
|
|
if report_cancel_button != null:
|
|
report_cancel_button.disabled = value
|
|
if report_submit_button != null:
|
|
report_submit_button.text = "Submitting..." if value else "Submit report"
|
|
_update_report_form_state()
|
|
|
|
|
|
func _submit_question_report() -> void:
|
|
if report_is_submitting or report_question_id == "":
|
|
return
|
|
var category := _selected_report_category()
|
|
var details := report_details.text.strip_edges()
|
|
if category == "" or details.length() < REPORT_DETAILS_MIN or details.length() > REPORT_DETAILS_MAX:
|
|
_update_report_form_state()
|
|
return
|
|
report_error_label.text = ""
|
|
_set_report_submitting(true)
|
|
QuestionBankService.submit_question_report(report_question_id, category, details)
|
|
|
|
|
|
func _on_question_report_submitted(question_id: String, _report_id: String) -> void:
|
|
if question_id != report_question_id:
|
|
return
|
|
_set_report_submitting(false)
|
|
if not reported_question_ids.has(question_id):
|
|
reported_question_ids.append(question_id)
|
|
_refresh_toolbar_state()
|
|
report_form_content.visible = false
|
|
report_success_content.visible = true
|
|
report_success_label.text = "Report submitted\nThank you for helping improve this question."
|
|
UISoundService.play_sound("confirmation")
|
|
|
|
|
|
func _on_question_report_failed(question_id: String, message: String) -> void:
|
|
if question_id != report_question_id:
|
|
return
|
|
_set_report_submitting(false)
|
|
report_error_label.text = message
|
|
UISoundService.play_sound("error")
|
|
|
|
|
|
func _build_labs_modal() -> void:
|
|
labs_overlay = Control.new()
|
|
labs_overlay.visible = false
|
|
labs_overlay.top_level = true
|
|
labs_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
labs_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
labs_overlay.gui_input.connect(_labs_overlay_gui_input)
|
|
add_child(labs_overlay)
|
|
|
|
var dim := ColorRect.new()
|
|
dim.color = Color(0.02, 0.03, 0.05, 0.72)
|
|
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
labs_overlay.add_child(dim)
|
|
|
|
var center := CenterContainer.new()
|
|
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
labs_overlay.add_child(center)
|
|
|
|
labs_panel = PanelContainer.new()
|
|
labs_panel.custom_minimum_size = Vector2(760, 430)
|
|
labs_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
labs_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.LIME))
|
|
center.add_child(labs_panel)
|
|
|
|
var modal_body := VBoxContainer.new()
|
|
modal_body.add_theme_constant_override("separation", 14)
|
|
labs_panel.add_child(modal_body)
|
|
|
|
var header := HBoxContainer.new()
|
|
header.add_theme_constant_override("separation", 10)
|
|
modal_body.add_child(header)
|
|
|
|
var title := Label.new()
|
|
title.text = "Lab Results"
|
|
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_set_scaled_label(title, 26, Style.TEXT)
|
|
header.add_child(title)
|
|
|
|
var close_button := Button.new()
|
|
close_button.text = "X"
|
|
close_button.tooltip_text = "Close lab results"
|
|
close_button.custom_minimum_size = Vector2(42, 38)
|
|
Style.style_chip(close_button, Style.RED)
|
|
UISoundService.set_button_sound(close_button, "close")
|
|
close_button.pressed.connect(_hide_labs_modal)
|
|
header.add_child(close_button)
|
|
|
|
var scroll := ScrollContainer.new()
|
|
scroll.custom_minimum_size = Vector2(0, 320)
|
|
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
modal_body.add_child(scroll)
|
|
|
|
labs_content = VBoxContainer.new()
|
|
labs_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
labs_content.add_theme_constant_override("separation", 8)
|
|
scroll.add_child(labs_content)
|
|
|
|
|
|
func _labs_overlay_gui_input(event: InputEvent) -> void:
|
|
var mouse_event := event as InputEventMouseButton
|
|
if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
|
|
return
|
|
if labs_panel == null or not labs_panel.get_global_rect().has_point(mouse_event.global_position):
|
|
_hide_labs_modal()
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
func _show_labs_modal() -> void:
|
|
if labs_overlay == null or labs_content == null:
|
|
return
|
|
var question := BlockSessionService.get_current_question()
|
|
var labs: Array = question.get("labs", [])
|
|
if labs.is_empty():
|
|
return
|
|
_populate_labs_modal(labs)
|
|
labs_overlay.visible = true
|
|
labs_overlay.move_to_front()
|
|
|
|
|
|
func _hide_labs_modal() -> void:
|
|
if labs_overlay != null:
|
|
labs_overlay.visible = false
|
|
|
|
|
|
func _populate_labs_modal(labs: Array) -> void:
|
|
for child in labs_content.get_children():
|
|
labs_content.remove_child(child)
|
|
child.queue_free()
|
|
|
|
var grid := GridContainer.new()
|
|
grid.columns = 3
|
|
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
grid.add_theme_constant_override("h_separation", 16)
|
|
grid.add_theme_constant_override("v_separation", 8)
|
|
labs_content.add_child(grid)
|
|
|
|
_add_lab_table_label(grid, "Test", Style.CYAN, true)
|
|
_add_lab_table_label(grid, "Value", Style.CYAN, true)
|
|
_add_lab_table_label(grid, "Reference", Style.CYAN, true)
|
|
|
|
for lab in labs:
|
|
if typeof(lab) != TYPE_DICTIONARY:
|
|
continue
|
|
var lab_dict: Dictionary = lab
|
|
_add_lab_table_label(grid, str(lab_dict.get("name", "-")), Style.TEXT)
|
|
_add_lab_table_label(grid, _format_lab_value(lab_dict), Style.TEXT)
|
|
_add_lab_table_label(grid, str(lab_dict.get("reference", "-")), Style.MUTED)
|
|
|
|
|
|
func _add_lab_table_label(parent: GridContainer, text: String, color: Color, is_header: bool = false) -> void:
|
|
var label := Label.new()
|
|
label.text = text if text.strip_edges() != "" else "-"
|
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_set_scaled_label(label, 16 if not is_header else 17, color)
|
|
parent.add_child(label)
|
|
|
|
|
|
func _format_lab_value(lab: Dictionary) -> String:
|
|
var value := str(lab.get("value", "")).strip_edges()
|
|
var unit := str(lab.get("unit", "")).strip_edges()
|
|
if value == "" and unit == "":
|
|
return "-"
|
|
if unit == "":
|
|
return value
|
|
if value == "":
|
|
return unit
|
|
return "%s %s" % [value, unit]
|
|
|
|
|
|
func _render_stem() -> void:
|
|
_render_highlighted_text("stem")
|
|
|
|
|
|
func _render_explanation() -> void:
|
|
_render_highlighted_text("explanation")
|
|
|
|
|
|
func _render_highlighted_text(source: String) -> void:
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
var highlight_store := question_highlights if source == "stem" else explanation_highlights
|
|
var plain_text := stem_plain_text if source == "stem" else explanation_plain_text
|
|
var target_label := stem_label if source == "stem" else explanation_label
|
|
var ranges: Array = highlight_store.get(question_id, []).duplicate()
|
|
ranges.sort_custom(func(a: Dictionary, b: Dictionary): return int(a["start"]) < int(b["start"]))
|
|
|
|
var rendered := ""
|
|
var cursor := 0
|
|
for highlight_range in ranges:
|
|
var start: int = clamp(int(highlight_range.get("start", 0)), 0, plain_text.length())
|
|
var end: int = clamp(int(highlight_range.get("end", 0)), start, plain_text.length())
|
|
if start < cursor:
|
|
continue
|
|
rendered += _escape_bbcode(plain_text.substr(cursor, start - cursor))
|
|
rendered += "[bgcolor=yellow][color=black]%s[/color][/bgcolor]" % _escape_bbcode(plain_text.substr(start, end - start))
|
|
cursor = end
|
|
rendered += _escape_bbcode(plain_text.substr(cursor))
|
|
target_label.text = rendered
|
|
|
|
|
|
func _escape_bbcode(value: String) -> String:
|
|
return value.replace("[", "[lb]").replace("]", "[rb]")
|
|
|
|
|
|
func _subtract_highlight_range(highlights: Array, start: int, end: int) -> bool:
|
|
var changed := false
|
|
for index in range(highlights.size() - 1, -1, -1):
|
|
var highlight_range: Dictionary = highlights[index]
|
|
var highlight_start := int(highlight_range.get("start", 0))
|
|
var highlight_end := int(highlight_range.get("end", 0))
|
|
if start < highlight_end and end > highlight_start:
|
|
highlights.remove_at(index)
|
|
if highlight_start < start:
|
|
highlights.insert(index, {"start": highlight_start, "end": start})
|
|
index += 1
|
|
if end < highlight_end:
|
|
highlights.insert(index, {"start": end, "end": highlight_end})
|
|
changed = true
|
|
return changed
|
|
|
|
|
|
func _apply_saved_choice_state() -> void:
|
|
var choices := _get_presented_choices()
|
|
var answer_is_locked := _is_correct_answer_locked()
|
|
for index in range(choices.size()):
|
|
var id := str(choices[index].get("id", ""))
|
|
_set_choice_button_text(index, choices[index])
|
|
choice_buttons[index].disabled = answer_is_locked or locked_wrong_choice_ids.has(id)
|
|
_style_choice_button_for_state(index, id)
|
|
choice_buttons[index].modulate = Color.WHITE
|
|
|
|
if pending_correct_advance or bool(current_answer.get("is_correct", false)):
|
|
_apply_correct_answer_preview()
|
|
|
|
if followup_required:
|
|
for index in range(choices.size()):
|
|
var id := str(choices[index].get("id", ""))
|
|
choice_buttons[index].disabled = id != active_followup_choice_id
|
|
|
|
_refresh_submit_state()
|
|
|
|
|
|
func _is_correct_answer_locked() -> bool:
|
|
return bool(current_answer.get("is_correct", false))
|
|
|
|
|
|
func _refresh_submit_state() -> void:
|
|
if next_button == null:
|
|
return
|
|
if BlockSessionService.session_kind == "simulation":
|
|
next_button.disabled = false
|
|
return
|
|
if pending_correct_advance or followup_required:
|
|
next_button.disabled = true
|
|
return
|
|
if not current_answer.is_empty():
|
|
next_button.disabled = false
|
|
return
|
|
next_button.disabled = selected_choice == ""
|
|
|
|
|
|
func _set_choice_button_text(index: int, choice: Dictionary) -> void:
|
|
var choice_id := str(choice.get("id", ""))
|
|
var choice_text := str(choice.get("text", ""))
|
|
var display_label := _display_label_for_index(index)
|
|
if index < choice_badges.size():
|
|
choice_badges[index].text = display_label
|
|
_set_choice_percent_label(index, "")
|
|
_set_choice_top_label(index, "")
|
|
if locked_wrong_choice_ids.has(choice_id) or _should_show_correct_choice_percentage(choice_id):
|
|
var percentage := _get_choice_selection_percentage(choice_id)
|
|
if percentage >= 0:
|
|
_set_choice_percent_label(index, "%d%%" % percentage)
|
|
if eliminated.has(choice_id):
|
|
choice_buttons[index].text = ""
|
|
_set_choice_top_label(index, "[s]%s[/s]" % _escape_bbcode(choice_text))
|
|
else:
|
|
choice_buttons[index].text = choice_text
|
|
|
|
|
|
func _style_choice_button_for_state(index: int, choice_id: String) -> void:
|
|
if index < 0 or index >= choice_buttons.size():
|
|
return
|
|
var dark: bool = true if theme_service == null else theme_service.is_dark_mode()
|
|
var state := "default"
|
|
var correct_id := str(BlockSessionService.get_current_question().get("correct_choice_id", ""))
|
|
var reviewing := pending_correct_advance or not current_answer.is_empty()
|
|
if reviewing and choice_id == correct_id:
|
|
state = "correct"
|
|
elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id == selected_choice):
|
|
state = "wrong"
|
|
elif choice_id == selected_choice:
|
|
state = "selected"
|
|
elif eliminated.has(choice_id):
|
|
state = "eliminated"
|
|
var button := choice_buttons[index]
|
|
var attached := _row_has_attached_panel(index)
|
|
var normal_box := _answer_box(dark, state, attached)
|
|
button.add_theme_stylebox_override("normal", normal_box)
|
|
button.add_theme_stylebox_override("disabled", normal_box)
|
|
button.add_theme_stylebox_override("pressed", _answer_box(dark, "selected" if state == "default" else state, attached))
|
|
button.add_theme_stylebox_override("hover", _answer_box(dark, "selected" if state == "default" else state, attached))
|
|
var text_color := Brand.muted_color(dark) if state == "eliminated" else Brand.text_color(dark)
|
|
button.add_theme_color_override("font_color", text_color)
|
|
button.add_theme_color_override("font_disabled_color", text_color)
|
|
if index < choice_badges.size():
|
|
var badge := choice_badges[index]
|
|
var accent := Brand.SLATE
|
|
if state == "selected": accent = Brand.PURPLE
|
|
elif state == "correct": accent = Brand.SUCCESS
|
|
elif state == "wrong": accent = Color("fb7185")
|
|
var filled := state in ["selected", "correct", "wrong"]
|
|
if index < choice_badge_textures.size():
|
|
var badge_texture := choice_badge_textures[index]
|
|
badge_texture.texture = BADGE_CIRCLE_FILLED_ICON if filled else BADGE_CIRCLE_OUTLINE_ICON
|
|
badge_texture.modulate = accent
|
|
badge.add_theme_color_override("font_color", Color.WHITE if filled else Brand.muted_color(dark))
|
|
|
|
|
|
func _row_has_attached_panel(index: int) -> bool:
|
|
if index < 0 or index >= choice_rows.size():
|
|
return false
|
|
if index < choice_followup_panels.size() and choice_followup_panels[index].visible:
|
|
return true
|
|
if correct_review_panel != null and correct_review_panel.visible and correct_review_panel.get_parent() == choice_rows[index]:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _mark_review_input_handled() -> void:
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
func _set_choice_percent_label(index: int, text: String) -> void:
|
|
if index < 0 or index >= choice_percent_labels.size():
|
|
return
|
|
var label := choice_percent_labels[index]
|
|
label.text = text
|
|
label.visible = text != ""
|
|
|
|
|
|
func _set_choice_top_label(index: int, text: String) -> void:
|
|
if index < 0 or index >= choice_top_labels.size():
|
|
return
|
|
var label := choice_top_labels[index]
|
|
label.text = text
|
|
label.visible = text != ""
|
|
|
|
|
|
func _get_choice_selection_percentage(choice_id: String) -> int:
|
|
var question := BlockSessionService.get_current_question()
|
|
var percentages: Dictionary = question.get("choice_selection_percentages", {})
|
|
if percentages.is_empty():
|
|
return -1
|
|
if not percentages.has(choice_id):
|
|
return 0
|
|
return clamp(int(percentages.get(choice_id, -1)), 0, 100)
|
|
|
|
|
|
func _should_show_correct_choice_percentage(choice_id: String) -> bool:
|
|
var question := BlockSessionService.get_current_question()
|
|
return choice_id == str(question.get("correct_choice_id", "")) and (pending_correct_advance or not current_answer.is_empty())
|
|
|
|
|
|
func _apply_correct_answer_preview() -> void:
|
|
var question := BlockSessionService.get_current_question()
|
|
var correct_id := str(question.get("correct_choice_id", ""))
|
|
var choices := _get_presented_choices()
|
|
for index in range(choices.size()):
|
|
if str(choices[index].get("id", "")) == correct_id:
|
|
_style_choice_button_for_state(index, correct_id)
|
|
return
|
|
|
|
|
|
func _on_choice_percentages_updated(question_id: String, percentages: Dictionary) -> void:
|
|
if not visible:
|
|
return
|
|
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
|
|
return
|
|
BlockSessionService.update_question_choice_percentages(question_id, percentages)
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _show_wrong_answer_followup(choice_id: String) -> void:
|
|
active_followup_choice_id = choice_id
|
|
followup_required = true
|
|
followup_answered = false
|
|
var index := _choice_index_for_id(choice_id)
|
|
if index < 0:
|
|
_unlock_after_followup()
|
|
return
|
|
_build_followup_panel(index, choice_id)
|
|
if question_scroll != null:
|
|
question_scroll.call_deferred("ensure_control_visible", choice_followup_panels[index])
|
|
|
|
|
|
func _build_followup_panel(_index: int, choice_id: String) -> void:
|
|
var followup := _get_followup_for_choice(choice_id)
|
|
var followup_choices: Array = followup.get("choices", []).duplicate(true)
|
|
followup_choices.shuffle()
|
|
followup_states[choice_id] = {
|
|
"choice_id": choice_id,
|
|
"choice_text": _choice_text_for_id(choice_id),
|
|
"prompt": str(followup.get("prompt", "")),
|
|
"choices": followup_choices,
|
|
"correct_choice_id": str(followup.get("correct_choice_id", "")),
|
|
"teaching_point": str(followup.get("teaching_point", "")),
|
|
"selected_option_id": "",
|
|
"selected_option_text": "",
|
|
"was_correct": false,
|
|
"resolved": false,
|
|
"collapsed": false,
|
|
"has_authored_choices": bool(followup.get("has_authored_choices", false)),
|
|
"is_main_correct": false
|
|
}
|
|
_append_followup_attempt(choice_id)
|
|
_render_followup_state(choice_id)
|
|
|
|
|
|
func _append_followup_attempt(choice_id: String) -> void:
|
|
if not followup_attempt_order.has(choice_id):
|
|
followup_attempt_order.append(choice_id)
|
|
|
|
|
|
func _render_active_followup(index: int, state: Dictionary) -> void:
|
|
var content := choice_followup_contents[index]
|
|
_build_followup_header(content, state, true, false)
|
|
var body_content := _add_followup_body(content, false)
|
|
|
|
var prompt := Label.new()
|
|
prompt.text = str(state.get("prompt", "What finding in the stem makes this answer less appropriate?"))
|
|
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(prompt, 14, Brand.text_color(_followup_is_dark()))
|
|
body_content.add_child(prompt)
|
|
|
|
var options: Array = state.get("choices", [])
|
|
if bool(state.get("has_authored_choices", false)):
|
|
for option_index in range(options.size()):
|
|
var option = options[option_index]
|
|
if typeof(option) != TYPE_DICTIONARY:
|
|
continue
|
|
var option_button := Button.new()
|
|
option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))]
|
|
option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
option_button.custom_minimum_size = Vector2(0, 38)
|
|
Style.style_chip(option_button, Brand.PURPLE)
|
|
UISoundService.set_button_sound(option_button, "select")
|
|
_set_scaled_button_font(option_button, 14)
|
|
option_button.set_meta("option_id", str(option.get("id", "")))
|
|
option_button.pressed.connect(func(option_id := str(option.get("id", "")), selected_button := option_button, followup_data := state):
|
|
_answer_followup(option_id, selected_button, followup_data)
|
|
)
|
|
body_content.add_child(option_button)
|
|
else:
|
|
var continue_button := Button.new()
|
|
continue_button.text = "Continue"
|
|
continue_button.custom_minimum_size = Vector2(130, 38)
|
|
continue_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
|
Style.style_chip(continue_button, Brand.PURPLE)
|
|
_set_scaled_button_font(continue_button, 14)
|
|
continue_button.pressed.connect(func(followup_data := state): _resolve_followup(followup_data, null, false, false))
|
|
body_content.add_child(continue_button)
|
|
|
|
|
|
func _answer_followup(option_id: String, selected_button: Button, followup: Dictionary) -> void:
|
|
if not followup_required or followup_answered:
|
|
return
|
|
var correct_id := str(followup.get("correct_choice_id", ""))
|
|
var is_correct := option_id == correct_id
|
|
_resolve_followup(followup, selected_button, true, is_correct)
|
|
|
|
|
|
func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_selected: bool, is_correct: bool) -> void:
|
|
if not followup_required or followup_answered:
|
|
return
|
|
followup_answered = true
|
|
var resolved_choice_id := active_followup_choice_id
|
|
var selected_option_text := selected_button.text if selected_button != null else "Continued"
|
|
if followup_states.has(resolved_choice_id):
|
|
var state: Dictionary = followup_states[resolved_choice_id]
|
|
state["selected_option_id"] = str(selected_button.get_meta("option_id", "")) if selected_button != null and selected_button.has_meta("option_id") else ""
|
|
state["selected_option_text"] = selected_option_text
|
|
state["was_correct"] = is_correct
|
|
state["resolved"] = true
|
|
state["collapsed"] = false
|
|
followup_states[resolved_choice_id] = state
|
|
_render_followup_state(resolved_choice_id)
|
|
|
|
_unlock_after_followup()
|
|
|
|
|
|
func _unlock_after_followup() -> void:
|
|
active_followup_choice_id = ""
|
|
followup_required = false
|
|
next_button.disabled = false
|
|
if skip_button != null:
|
|
skip_button.disabled = false
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _collapse_resolved_followups() -> void:
|
|
for choice_id in followup_states.keys():
|
|
var state: Dictionary = followup_states[choice_id]
|
|
if bool(state.get("resolved", false)):
|
|
state["collapsed"] = true
|
|
followup_states[choice_id] = state
|
|
_render_followup_state(choice_id)
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _toggle_followup_state(choice_id: String) -> void:
|
|
if not followup_states.has(choice_id) or followup_required:
|
|
return
|
|
var state: Dictionary = followup_states[choice_id]
|
|
if not bool(state.get("resolved", false)):
|
|
return
|
|
state["collapsed"] = not bool(state.get("collapsed", false))
|
|
followup_states[choice_id] = state
|
|
_render_followup_state(choice_id)
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _render_followup_state(choice_id: String) -> void:
|
|
if not followup_states.has(choice_id):
|
|
return
|
|
var index := _choice_index_for_id(choice_id)
|
|
if index < 0 or index >= choice_followup_panels.size():
|
|
return
|
|
var state: Dictionary = followup_states[choice_id]
|
|
_clear_followup_content(index)
|
|
_style_followup_card(index, state)
|
|
if not bool(state.get("resolved", false)):
|
|
_render_active_followup(index, state)
|
|
elif bool(state.get("collapsed", false)):
|
|
_build_followup_header(choice_followup_contents[index], state, false, true)
|
|
else:
|
|
_build_resolved_followup(index, state)
|
|
choice_followup_panels[index].visible = true
|
|
_style_choice_button_for_state(index, choice_id)
|
|
|
|
|
|
func _build_resolved_followup(index: int, state: Dictionary) -> void:
|
|
var content := choice_followup_contents[index]
|
|
_build_followup_header(content, state, true, true)
|
|
var is_main_correct := bool(state.get("is_main_correct", false))
|
|
var body_content := _add_followup_body(content, is_main_correct)
|
|
|
|
var heading := Label.new()
|
|
heading.text = "Why this is appropriate:" if is_main_correct else "Why this is less appropriate:"
|
|
_set_scaled_label(heading, 14, Brand.SUCCESS if is_main_correct else Color("fb5f6d"))
|
|
body_content.add_child(heading)
|
|
|
|
var explanation := Label.new()
|
|
explanation.text = _choice_review_explanation(str(state.get("choice_id", "")), state)
|
|
explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark()))
|
|
body_content.add_child(explanation)
|
|
|
|
func _build_followup_header(content: VBoxContainer, state: Dictionary, expanded: bool, interactive: bool) -> void:
|
|
var dark := _followup_is_dark()
|
|
var is_main_correct := bool(state.get("is_main_correct", false))
|
|
var accent := Brand.SUCCESS if is_main_correct else Color("fb5f6d")
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 10)
|
|
content.add_child(row)
|
|
|
|
# The choice button above this panel already shows the letter badge, choice
|
|
# text, and percentage, so the header only needs a spacer plus the collapse
|
|
# toggle - repeating "Review: B. ..." and a status icon here was redundant.
|
|
var spacer := Control.new()
|
|
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
row.add_child(spacer)
|
|
|
|
var percentage := _get_choice_selection_percentage(str(state.get("choice_id", "")))
|
|
if percentage >= 0:
|
|
var percentage_label := Label.new()
|
|
percentage_label.text = "%d%%" % percentage
|
|
percentage_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
percentage_label.custom_minimum_size = Vector2(58, 30)
|
|
percentage_label.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
|
|
_set_scaled_label(percentage_label, 14, accent)
|
|
row.add_child(percentage_label)
|
|
|
|
if interactive:
|
|
var toggle := Button.new()
|
|
toggle.name = "FollowupToggle"
|
|
toggle.flat = true
|
|
toggle.tooltip_text = "Collapse review" if expanded else "Expand review"
|
|
toggle.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg")
|
|
toggle.custom_minimum_size = Vector2(34, 30)
|
|
toggle.add_theme_color_override("icon_normal_color", Brand.text_color(dark))
|
|
toggle.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", ""))))
|
|
row.add_child(toggle)
|
|
|
|
|
|
func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContainer:
|
|
var body := PanelContainer.new()
|
|
body.add_theme_stylebox_override("panel", _followup_body_box(_followup_is_dark(), is_correct))
|
|
content.add_child(body)
|
|
var body_content := VBoxContainer.new()
|
|
body_content.add_theme_constant_override("separation", 8)
|
|
body.add_child(body_content)
|
|
return body_content
|
|
|
|
|
|
func _style_followup_card(index: int, state: Dictionary) -> void:
|
|
if index < 0 or index >= choice_followup_panels.size():
|
|
return
|
|
var is_correct := bool(state.get("is_main_correct", false))
|
|
choice_followup_panels[index].add_theme_stylebox_override(
|
|
"panel",
|
|
_attached_panel_box(_followup_is_dark(), Brand.SUCCESS if is_correct else Color("fb7185"))
|
|
)
|
|
|
|
|
|
func _followup_body_box(dark: bool, is_correct: bool) -> StyleBoxFlat:
|
|
var accent := Brand.SUCCESS if is_correct else Color("fb5f6d")
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color(accent, 0.07 if dark else 0.045)
|
|
box.border_color = Color(accent, 0.34)
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(6)
|
|
box.content_margin_left = 14
|
|
box.content_margin_right = 14
|
|
box.content_margin_top = 12
|
|
box.content_margin_bottom = 12
|
|
return box
|
|
|
|
|
|
func _followup_is_dark() -> bool:
|
|
if theme_service == null:
|
|
return true
|
|
return theme_service.is_dark_mode()
|
|
|
|
|
|
func _choice_review_explanation(choice_id: String, state: Dictionary) -> String:
|
|
var question := BlockSessionService.get_current_question()
|
|
var explanations: Dictionary = question.get("choice_explanations", {})
|
|
var explanation := str(explanations.get(choice_id, "")).strip_edges()
|
|
if explanation.begins_with("Correct. "):
|
|
explanation = explanation.substr(9)
|
|
if explanation != "":
|
|
return explanation
|
|
var teaching_point := str(state.get("teaching_point", "")).strip_edges()
|
|
if teaching_point != "":
|
|
return teaching_point
|
|
if bool(state.get("is_main_correct", false)):
|
|
return str(question.get("explanation", "Review the correct answer explanation."))
|
|
return "Review the stem finding that makes this option less appropriate."
|
|
|
|
|
|
func _ensure_answer_review_followups() -> void:
|
|
for attempted_id in followup_states.keys():
|
|
var attempted_state: Dictionary = followup_states[attempted_id]
|
|
if bool(attempted_state.get("resolved", false)):
|
|
attempted_state["collapsed"] = true
|
|
followup_states[attempted_id] = attempted_state
|
|
for choice_id in followup_attempt_order:
|
|
_render_followup_state(str(choice_id))
|
|
|
|
|
|
func _clear_followups() -> void:
|
|
active_followup_choice_id = ""
|
|
followup_required = false
|
|
followup_answered = false
|
|
followup_states.clear()
|
|
followup_attempt_order.clear()
|
|
for index in range(choice_followup_panels.size()):
|
|
_clear_followup_content(index)
|
|
choice_followup_panels[index].visible = false
|
|
|
|
|
|
func _clear_followup_content(index: int) -> void:
|
|
if index < 0 or index >= choice_followup_contents.size():
|
|
return
|
|
for child in choice_followup_contents[index].get_children():
|
|
choice_followup_contents[index].remove_child(child)
|
|
child.queue_free()
|
|
|
|
|
|
func _get_followup_for_choice(choice_id: String) -> Dictionary:
|
|
var question := BlockSessionService.get_current_question()
|
|
var followups: Dictionary = question.get("wrong_answer_followups", {})
|
|
if followups.has(choice_id) and typeof(followups[choice_id]) == TYPE_DICTIONARY:
|
|
var authored: Dictionary = followups[choice_id]
|
|
var choices: Array = authored.get("choices", [])
|
|
return {
|
|
"prompt": str(authored.get("prompt", "What finding in the stem makes this answer less appropriate?")),
|
|
"choices": choices,
|
|
"has_authored_choices": not choices.is_empty(),
|
|
"correct_choice_id": str(authored.get("correctChoiceId", authored.get("correct_choice_id", ""))),
|
|
"teaching_point": str(authored.get("teachingPoint", authored.get("teaching_point", "Review the relevant stem finding before choosing again.")))
|
|
}
|
|
|
|
return {
|
|
"prompt": "No follow-up has been authored for this answer yet. Re-read the stem before retrying.",
|
|
"choices": [],
|
|
"has_authored_choices": false,
|
|
"correct_choice_id": "",
|
|
"teaching_point": "Review the stem for the finding that makes this answer less appropriate before trying again."
|
|
}
|
|
|
|
|
|
func _get_presented_choices() -> Array:
|
|
if not presented_choices.is_empty():
|
|
return presented_choices
|
|
return BlockSessionService.get_current_question().get("choices", [])
|
|
|
|
|
|
func _presented_choice_ids() -> Array:
|
|
var ids: Array = []
|
|
for choice in _get_presented_choices():
|
|
if typeof(choice) == TYPE_DICTIONARY:
|
|
ids.append(str(choice.get("id", "")))
|
|
return ids
|
|
|
|
|
|
func _display_label_for_index(index: int) -> String:
|
|
return String.chr(65 + index)
|
|
|
|
|
|
func _display_label_for_choice_id(choice_id: String) -> String:
|
|
var index := _choice_index_for_id(choice_id)
|
|
return _display_label_for_index(index) if index >= 0 else choice_id
|
|
|
|
|
|
func _choice_index_for_id(choice_id: String) -> int:
|
|
var choices := _get_presented_choices()
|
|
for index in range(choices.size()):
|
|
if str(choices[index].get("id", "")) == choice_id:
|
|
return index
|
|
return -1
|
|
|
|
|
|
func _choice_text_for_id(choice_id: String) -> String:
|
|
var choices := _get_presented_choices()
|
|
for choice in choices:
|
|
if typeof(choice) == TYPE_DICTIONARY and str(choice.get("id", "")) == choice_id:
|
|
return str(choice.get("text", ""))
|
|
return ""
|
|
|
|
|
|
func _on_question_rating_updated(question_id: String, question_elo: int, _rating: Dictionary) -> void:
|
|
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
|
|
return
|
|
BlockSessionService.update_question_elo(question_id, question_elo)
|
|
_update_question_elo_label(not current_answer.is_empty() or pending_correct_advance)
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _on_question_skipped(question_id: String, correct_choice_id: String, question_elo: int, _rating: Dictionary) -> void:
|
|
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
|
|
return
|
|
var question := BlockSessionService.get_current_question()
|
|
if correct_choice_id != "":
|
|
question["correct_choice_id"] = correct_choice_id
|
|
BlockSessionService.update_question_elo(question_id, question_elo)
|
|
_update_question_elo_label(true)
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _update_question_elo_label(show_label: bool) -> void:
|
|
if question_elo_label == null:
|
|
return
|
|
if not show_label:
|
|
question_elo_label.visible = false
|
|
return
|
|
var question := BlockSessionService.get_current_question()
|
|
var question_elo := int(question.get("question_elo", 1000))
|
|
question_elo_label.text = "Question ELO %d" % question_elo
|
|
question_elo_label.visible = true
|
|
|
|
|
|
func _refresh_nav_palette() -> void:
|
|
if nav_palette == null:
|
|
return
|
|
for child in nav_palette.get_children():
|
|
child.queue_free()
|
|
|
|
if BlockSessionService.session_kind in ["core", "review"]:
|
|
return
|
|
|
|
for index in range(BlockSessionService.active_questions.size()):
|
|
var button := Button.new()
|
|
button.text = str(index + 1)
|
|
button.custom_minimum_size = Vector2(34, 34)
|
|
var answer = BlockSessionService.get_answer_for_index(index)
|
|
if index == BlockSessionService.current_index:
|
|
Style.style_chip(button, Style.CYAN)
|
|
elif answer != null and bool(answer.get("flagged", false)):
|
|
Style.style_chip(button, Style.AMBER)
|
|
elif answer != null and str(answer.get("selected_choice_id", "")) != "":
|
|
Style.style_chip(button, Style.LIME)
|
|
else:
|
|
Style.style_chip(button, Color("596982"))
|
|
button.pressed.connect(_navigate_to_question.bind(index))
|
|
nav_palette.add_child(button)
|