3388 lines
128 KiB
GDScript
3388 lines
128 KiB
GDScript
extends VBoxContainer
|
|
|
|
const Style := preload("res://scripts/ui/ui_style.gd")
|
|
const Brand := preload("res://scripts/ui/medmind_style.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 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 correct_answer_heading: Label
|
|
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 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 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)
|
|
|
|
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.icon = _make_flag_icon()
|
|
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)
|
|
|
|
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.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.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)
|
|
|
|
for index in range(5):
|
|
var choice_row := VBoxContainer.new()
|
|
choice_row.add_theme_constant_override("separation", 6)
|
|
question_content.add_child(choice_row)
|
|
choice_rows.append(choice_row)
|
|
|
|
var button := Button.new()
|
|
button.custom_minimum_size = Vector2(0, 54)
|
|
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 := Label.new()
|
|
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
badge.anchor_left = 0.0
|
|
badge.anchor_top = 0.0
|
|
badge.anchor_right = 0.0
|
|
badge.anchor_bottom = 0.0
|
|
badge.offset_left = 12
|
|
badge.offset_top = 12
|
|
badge.offset_right = 42
|
|
badge.offset_bottom = 42
|
|
_set_scaled_label(badge, 14, Brand.muted_color(true))
|
|
button.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 = 8
|
|
percent_label.offset_right = -18
|
|
percent_label.offset_bottom = 40
|
|
_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 = 54
|
|
top_label.offset_top = 10
|
|
top_label.offset_right = -96
|
|
top_label.offset_bottom = 42
|
|
_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)
|
|
|
|
correct_answer_heading = Label.new()
|
|
_set_scaled_label(correct_answer_heading, 17, Brand.SUCCESS)
|
|
review_content.add_child(correct_answer_heading)
|
|
|
|
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) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
var background := Color("0b172b") if dark else Color("ffffff")
|
|
var border := Color("2b3958") if dark else Brand.LIGHT_BORDER
|
|
if state == "selected":
|
|
background = Color("17183b") if dark else Color("f7f3ff")
|
|
border = Brand.PURPLE
|
|
elif 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")
|
|
elif state == "eliminated":
|
|
background = Color("0a1323") if dark else Color("f8fafc")
|
|
border = Color("26344f") if dark else Color("e2e8f0")
|
|
box.bg_color = background
|
|
box.border_color = border
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(7)
|
|
box.content_margin_left = 54
|
|
box.content_margin_right = 82
|
|
box.content_margin_top = 12
|
|
box.content_margin_bottom = 12
|
|
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) -> 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))
|
|
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 _correct_review_box(dark: bool) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color("08251d") if dark else Color("f0fdf4")
|
|
box.border_color = Color(Brand.SUCCESS, 0.62)
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(7)
|
|
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_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, flag_button, notes_button, app_feedback_button]:
|
|
if control == null:
|
|
continue
|
|
var active: bool = control == flag_button and flagged
|
|
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_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 = true
|
|
report_button.visible = BlockSessionService.session_kind != "simulation"
|
|
|
|
var question := BlockSessionService.get_current_question()
|
|
if question.is_empty():
|
|
main.show_screen("selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
|
|
return
|
|
progress_button.text = _question_progress_text()
|
|
progress_button.disabled = BlockSessionService.session_kind == "core"
|
|
progress_button.tooltip_text = "" if BlockSessionService.session_kind == "core" 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 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 == "core":
|
|
_collapse_resolved_followups()
|
|
var attempted_choice := selected_choice
|
|
QuestionBankService.record_first_attempt(
|
|
question_id_for_attempt,
|
|
attempted_choice,
|
|
_presented_choice_ids()
|
|
)
|
|
var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged)
|
|
if result.is_empty():
|
|
return
|
|
var answer: Dictionary = result.get("answer", {})
|
|
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()
|
|
)
|
|
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
|
_show_answer_review()
|
|
else:
|
|
if BlockSessionService.session_kind == "core":
|
|
_request_next_core_question()
|
|
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 skip_button != null:
|
|
skip_button.disabled = true
|
|
skip_button.visible = false
|
|
if question_scroll != null:
|
|
question_scroll.call_deferred("ensure_control_visible", explanation_label)
|
|
|
|
|
|
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 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
|
|
correct_review_panel.visible = true
|
|
var correct_id := str(question.get("correct_choice_id", ""))
|
|
correct_answer_heading.text = "Correct Answer: %s" % _display_label_for_choice_id(correct_id)
|
|
_update_question_elo_label(true)
|
|
_refresh_nav_palette()
|
|
|
|
|
|
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
|
|
|
|
|
|
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 == "core":
|
|
BlockSessionService.finish_block()
|
|
main.show_screen("selection", "exam")
|
|
return
|
|
var results := BlockSessionService.finish_block()
|
|
main.show_screen("review", results)
|
|
|
|
|
|
func _exit_run() -> void:
|
|
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"
|
|
_apply_question_theme()
|
|
|
|
|
|
func refresh_theme_controls() -> void:
|
|
if media_viewer_background != null:
|
|
media_viewer_background.color = Style.BACKGROUND
|
|
if flag_button != null:
|
|
flag_button.icon = _make_flag_icon()
|
|
_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
|
|
choice_buttons[index].custom_minimum_size = Vector2(0, 54)
|
|
_set_choice_percent_label(index, "")
|
|
_set_choice_top_label(index, "")
|
|
if locked_wrong_choice_ids.has(choice_id):
|
|
var percentage := _get_choice_selection_percentage(choice_id)
|
|
if percentage >= 0:
|
|
_set_choice_percent_label(index, "%d%%" % percentage)
|
|
choice_buttons[index].text = choice_text
|
|
elif _should_show_correct_choice_percentage(choice_id):
|
|
var correct_percentage := _get_choice_selection_percentage(choice_id)
|
|
if correct_percentage >= 0:
|
|
_set_choice_percent_label(index, "%d%%" % correct_percentage)
|
|
choice_buttons[index].text = choice_text
|
|
elif 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 normal_box := _answer_box(dark, state)
|
|
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))
|
|
button.add_theme_stylebox_override("hover", _answer_box(dark, "selected" if state == "default" else state))
|
|
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")
|
|
badge.add_theme_stylebox_override("normal", Brand.icon_circle_box(dark, accent, 15, state in ["selected", "correct", "wrong"]))
|
|
badge.add_theme_color_override("font_color", Color.WHITE if state in ["selected", "correct", "wrong"] else Brand.muted_color(dark))
|
|
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
if not is_main_correct and bool(state.get("has_authored_choices", false)):
|
|
var similar_button := Button.new()
|
|
similar_button.text = "Ask similar question"
|
|
similar_button.custom_minimum_size = Vector2(172, 38)
|
|
similar_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
|
Style.style_chip(similar_button, Color("fb5f6d"))
|
|
_set_scaled_button_font(similar_button, 14)
|
|
similar_button.pressed.connect(_restart_followup.bind(str(state.get("choice_id", ""))))
|
|
body_content.add_child(similar_button)
|
|
|
|
|
|
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)
|
|
|
|
var status := Label.new()
|
|
status.text = "✓" if is_main_correct else "x"
|
|
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
status.custom_minimum_size = Vector2(30, 30)
|
|
status.add_theme_stylebox_override("normal", Brand.icon_circle_box(dark, accent, 15, true))
|
|
_set_scaled_label(status, 15, Color.WHITE)
|
|
row.add_child(status)
|
|
|
|
var summary := Button.new()
|
|
summary.name = "FollowupSummary"
|
|
summary.flat = true
|
|
summary.text = "Review: %s. %s" % [_display_label_for_choice_id(str(state.get("choice_id", ""))), str(state.get("choice_text", ""))]
|
|
summary.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
summary.disabled = not interactive
|
|
_set_scaled_button_font(summary, 15)
|
|
summary.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
summary.add_theme_color_override("font_hover_color", accent)
|
|
summary.add_theme_color_override("font_disabled_color", Brand.text_color(dark))
|
|
if interactive:
|
|
summary.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", ""))))
|
|
row.add_child(summary)
|
|
|
|
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.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
|
|
choice_followup_panels[index].add_theme_stylebox_override(
|
|
"panel",
|
|
_followup_card_box(_followup_is_dark(), bool(state.get("is_main_correct", false)))
|
|
)
|
|
|
|
|
|
func _followup_card_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("0b172b") if dark else Color("ffffff")
|
|
box.border_color = Color(accent, 0.72)
|
|
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 _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 _restart_followup(choice_id: String) -> void:
|
|
if followup_required or not followup_states.has(choice_id):
|
|
return
|
|
var state: Dictionary = followup_states[choice_id]
|
|
if not bool(state.get("has_authored_choices", false)):
|
|
return
|
|
var options: Array = state.get("choices", []).duplicate(true)
|
|
options.shuffle()
|
|
state["choices"] = options
|
|
state["selected_option_id"] = ""
|
|
state["selected_option_text"] = ""
|
|
state["was_correct"] = false
|
|
state["resolved"] = false
|
|
state["collapsed"] = false
|
|
followup_states[choice_id] = state
|
|
active_followup_choice_id = choice_id
|
|
followup_required = true
|
|
followup_answered = false
|
|
next_button.disabled = true
|
|
if skip_button != null:
|
|
skip_button.disabled = true
|
|
_render_followup_state(choice_id)
|
|
var index := _choice_index_for_id(choice_id)
|
|
if index >= 0 and question_scroll != null:
|
|
question_scroll.call_deferred("ensure_control_visible", choice_followup_panels[index])
|
|
|
|
|
|
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 _make_flag_icon() -> Texture2D:
|
|
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
|
|
image.fill(Color(0, 0, 0, 0))
|
|
for y in range(4, 20):
|
|
image.set_pixel(6, y, Style.TEXT)
|
|
image.set_pixel(7, y, Style.TEXT)
|
|
for x in range(8, 18):
|
|
for y in range(4, 11):
|
|
if x < 16 or y < 9:
|
|
image.set_pixel(x, y, Style.AMBER)
|
|
for y in range(11, 14):
|
|
for x in range(8, 14 - (y - 11)):
|
|
image.set_pixel(x, y, Style.AMBER)
|
|
return ImageTexture.create_from_image(image)
|
|
|
|
|
|
func _refresh_nav_palette() -> void:
|
|
if nav_palette == null:
|
|
return
|
|
for child in nav_palette.get_children():
|
|
child.queue_free()
|
|
|
|
if BlockSessionService.session_kind == "core":
|
|
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)
|