2502 lines
91 KiB
GDScript
2502 lines
91 KiB
GDScript
extends VBoxContainer
|
|
|
|
const Style := preload("res://scripts/ui/ui_style.gd")
|
|
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_percent_labels: Array[Label] = []
|
|
var choice_top_labels: Array[Label] = []
|
|
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 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 reported_question_ids := []
|
|
var stem_plain_text := ""
|
|
var highlight_drag_started_in_stem := false
|
|
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 progress_label: Label
|
|
var timer_label: Label
|
|
var player_stats_label: Label
|
|
var stem_label: RichTextLabel
|
|
var lead_label: Label
|
|
var explanation_label: RichTextLabel
|
|
var references_container: VBoxContainer
|
|
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 flag_button: Button
|
|
var report_button: Button
|
|
var theme_button: Button
|
|
var question_toolbar: HBoxContainer
|
|
var nav_palette: HBoxContainer
|
|
var question_content: VBoxContainer
|
|
var media_container: VBoxContainer
|
|
var timer_bar: ProgressBar
|
|
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 ProfileService.profile_changed.is_connected(_refresh_player_stats):
|
|
ProfileService.profile_changed.connect(_refresh_player_stats)
|
|
if not QuestionBankService.ratings_changed.is_connected(_refresh_player_stats):
|
|
QuestionBankService.ratings_changed.connect(_refresh_player_stats)
|
|
if not AuthState.auth_changed.is_connected(_refresh_player_stats):
|
|
AuthState.auth_changed.connect(_refresh_player_stats)
|
|
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)
|
|
|
|
var top := HBoxContainer.new()
|
|
top.add_theme_constant_override("separation", 12)
|
|
progress_label = Label.new()
|
|
_set_scaled_label(progress_label, 18, Style.CYAN)
|
|
progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
top.add_child(progress_label)
|
|
timer_label = Label.new()
|
|
_set_scaled_label(timer_label, 18, Style.AMBER)
|
|
timer_label.visible = false
|
|
timer_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
top.add_child(timer_label)
|
|
|
|
var toolbar_spacer := Control.new()
|
|
toolbar_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
top.add_child(toolbar_spacer)
|
|
|
|
player_stats_label = Label.new()
|
|
player_stats_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
_set_scaled_label(player_stats_label, 15, Style.MUTED)
|
|
top.add_child(player_stats_label)
|
|
|
|
question_toolbar = HBoxContainer.new()
|
|
question_toolbar.add_theme_constant_override("separation", 8)
|
|
question_toolbar.alignment = BoxContainer.ALIGNMENT_END
|
|
|
|
theme_button = Button.new()
|
|
theme_button.custom_minimum_size = Vector2(44, 44)
|
|
theme_button.add_theme_font_size_override("font_size", 22)
|
|
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.icon = _make_labs_icon()
|
|
labs_button.custom_minimum_size = Vector2(44, 44)
|
|
Style.style_chip(labs_button, Style.LIME)
|
|
UISoundService.set_button_sound(labs_button, "open")
|
|
labs_button.pressed.connect(_show_labs_modal)
|
|
question_toolbar.add_child(labs_button)
|
|
|
|
var clear_highlight_button := Button.new()
|
|
clear_highlight_button.tooltip_text = "Clear highlights"
|
|
clear_highlight_button.icon = _make_clear_highlights_icon()
|
|
clear_highlight_button.custom_minimum_size = Vector2(44, 44)
|
|
Style.style_chip(clear_highlight_button, Style.CYAN)
|
|
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()
|
|
)
|
|
question_toolbar.add_child(flag_button)
|
|
|
|
report_button = Button.new()
|
|
report_button.tooltip_text = "Report question"
|
|
report_button.icon = _make_report_icon()
|
|
report_button.custom_minimum_size = Vector2(44, 44)
|
|
Style.style_chip(report_button, Style.CYAN)
|
|
UISoundService.set_button_sound(report_button, "open")
|
|
report_button.pressed.connect(_toggle_report_current_question)
|
|
question_toolbar.add_child(report_button)
|
|
|
|
top.add_child(question_toolbar)
|
|
add_child(top)
|
|
|
|
nav_palette = HBoxContainer.new()
|
|
nav_palette.add_theme_constant_override("separation", 6)
|
|
add_child(nav_palette)
|
|
|
|
var question_panel := PanelContainer.new()
|
|
question_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("182235"), Color("344463")))
|
|
question_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
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_right", 16)
|
|
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 button := Button.new()
|
|
button.custom_minimum_size = Vector2(0, 64)
|
|
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 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 = -92
|
|
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 := Label.new()
|
|
top_label.visible = false
|
|
top_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
top_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
top_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
|
|
top_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
top_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
top_label.offset_left = 14
|
|
top_label.offset_top = 10
|
|
top_label.offset_right = -96
|
|
top_label.offset_bottom = -252
|
|
_set_scaled_label(top_label, 16, Style.RED)
|
|
button.add_child(top_label)
|
|
choice_top_labels.append(top_label)
|
|
|
|
var followup_panel := PanelContainer.new()
|
|
followup_panel.visible = false
|
|
followup_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
followup_panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
followup_panel.offset_left = 16
|
|
followup_panel.offset_top = 48
|
|
followup_panel.offset_right = -16
|
|
followup_panel.offset_bottom = -16
|
|
followup_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("26151a"), Style.RED))
|
|
button.add_child(followup_panel)
|
|
choice_followup_panels.append(followup_panel)
|
|
|
|
var followup_content := VBoxContainer.new()
|
|
followup_content.add_theme_constant_override("separation", 6)
|
|
followup_panel.add_child(followup_content)
|
|
choice_followup_contents.append(followup_content)
|
|
|
|
question_content.add_child(button)
|
|
|
|
explanation_label = RichTextLabel.new()
|
|
explanation_label.bbcode_enabled = true
|
|
explanation_label.fit_content = true
|
|
explanation_label.scroll_active = false
|
|
explanation_label.custom_minimum_size = Vector2.ZERO
|
|
explanation_label.visible = false
|
|
_set_scaled_rich_text(explanation_label, 17)
|
|
question_content.add_child(explanation_label)
|
|
|
|
references_container = VBoxContainer.new()
|
|
references_container.visible = false
|
|
references_container.add_theme_constant_override("separation", 6)
|
|
question_content.add_child(references_container)
|
|
|
|
var 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"
|
|
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)
|
|
add_child(actions)
|
|
|
|
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, 14)
|
|
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))
|
|
add_child(timer_bar)
|
|
|
|
feedback_fog = ColorRect.new()
|
|
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
|
|
feedback_fog.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
feedback_fog.top_level = true
|
|
feedback_fog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
add_child(feedback_fog)
|
|
|
|
_build_labs_modal()
|
|
_build_report_modal()
|
|
_build_media_viewer()
|
|
resized.connect(_fit_modal_panels)
|
|
_fit_modal_panels()
|
|
|
|
|
|
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 on_show(_payload: Variant = null) -> void:
|
|
_load_question()
|
|
|
|
|
|
func on_hide() -> void:
|
|
awaiting_next_question = false
|
|
_hide_media_viewer()
|
|
_hide_report_modal(true)
|
|
_hide_labs_modal()
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
_update_answer_feedback(delta)
|
|
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))
|
|
var minutes := int(floor(float(shown_seconds) / 60.0))
|
|
timer_label.text = "Timed: %02d:%02d" % [minutes, shown_seconds % 60]
|
|
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:
|
|
highlight_drag_started_in_stem = stem_label.get_global_rect().has_point(mouse_event.global_position)
|
|
elif highlight_drag_started_in_stem:
|
|
highlight_drag_started_in_stem = false
|
|
call_deferred("_commit_current_text_selection")
|
|
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()
|
|
explanation_label.visible = false
|
|
_clear_references()
|
|
question_elo_label.visible = false
|
|
question_elo_label.text = ""
|
|
next_button.text = "Submit"
|
|
next_button.disabled = true
|
|
skip_button.disabled = false
|
|
skip_button.visible = BlockSessionService.session_kind == "core"
|
|
|
|
var question := BlockSessionService.get_current_question()
|
|
if question.is_empty():
|
|
main.show_screen("selection", "step3")
|
|
return
|
|
progress_label.visible = BlockSessionService.session_kind != "core"
|
|
progress_label.text = "" if BlockSessionService.session_kind == "core" else "Question %s" % BlockSessionService.get_progress_text()
|
|
timer_label.visible = BlockSessionService.session_kind != "core"
|
|
timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed"
|
|
timer_bar.visible = BlockSessionService.session_kind == "core"
|
|
_update_timer_bar()
|
|
stem_plain_text = str(question.get("stem", ""))
|
|
_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()
|
|
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()
|
|
_show_answer_review()
|
|
_update_question_elo_label(true)
|
|
|
|
_refresh_nav_palette()
|
|
_refresh_toolbar_state()
|
|
_refresh_player_stats()
|
|
|
|
|
|
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":
|
|
current_answer = {}
|
|
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":
|
|
current_answer = {}
|
|
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() or BlockSessionService.session_kind != "core":
|
|
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 _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():
|
|
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()
|
|
|
|
|
|
func _toggle_eliminate_selected() -> void:
|
|
if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty() 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()
|
|
|
|
|
|
func _submit_or_next() -> void:
|
|
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
|
|
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", []))
|
|
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 _show_explanation() -> void:
|
|
var question := BlockSessionService.get_current_question()
|
|
var correct_id := str(question.get("correct_choice_id", ""))
|
|
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)
|
|
if id == correct_id:
|
|
choice_buttons[index].modulate = Color(0.54, 1.0, 0.58)
|
|
elif id == selected_choice:
|
|
choice_buttons[index].modulate = Color(1.0, 0.48, 0.48)
|
|
|
|
var result_text := "Correct" if current_answer.get("is_correct", false) else "Incorrect"
|
|
explanation_label.text = "[b]%s[/b]\n%s" % [result_text, question.get("explanation", "")]
|
|
explanation_label.visible = true
|
|
_update_question_elo_label(true)
|
|
_refresh_nav_palette()
|
|
|
|
|
|
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", "step3")
|
|
return
|
|
var results := BlockSessionService.finish_block()
|
|
main.show_screen("review", results)
|
|
|
|
|
|
func _exit_run() -> void:
|
|
BlockSessionService.resume_current_question_timer()
|
|
BlockSessionService.finish_block()
|
|
main.show_screen("selection", "step3")
|
|
|
|
|
|
func _update_timer_bar() -> void:
|
|
if timer_bar == null:
|
|
return
|
|
var ratio := BlockSessionService.get_core_timer_ratio()
|
|
timer_bar.value = ratio * 100.0
|
|
var color_state := "green"
|
|
var fill_color := Style.LIME
|
|
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 _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() -> void:
|
|
if stem_label == null:
|
|
return
|
|
var raw_selected_text := stem_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 := stem_label.get_selection_from() + leading_trim_count
|
|
_highlight_range(selection_start, selection_start + selected_text.length())
|
|
|
|
|
|
func _highlight_range(start: int, end: int) -> void:
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
if not question_highlights.has(question_id):
|
|
question_highlights[question_id] = []
|
|
|
|
var highlights: Array = question_highlights[question_id]
|
|
start = clamp(start, 0, stem_plain_text.length())
|
|
end = clamp(end, start, stem_plain_text.length())
|
|
if start == end:
|
|
return
|
|
|
|
if _subtract_highlight_range(highlights, start, end):
|
|
_render_stem()
|
|
if stem_label.has_method("deselect"):
|
|
stem_label.deselect()
|
|
return
|
|
|
|
highlights.append({"start": start, "end": end})
|
|
_render_stem()
|
|
if stem_label.has_method("deselect"):
|
|
stem_label.deselect()
|
|
|
|
|
|
func _clear_current_highlights() -> void:
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
question_highlights[question_id] = []
|
|
_render_stem()
|
|
|
|
|
|
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 flag_button != null:
|
|
Style.style_chip(flag_button, Style.AMBER if flagged else Style.CYAN)
|
|
if report_button != null:
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN)
|
|
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
|
|
|
|
|
|
func refresh_theme_controls() -> void:
|
|
_refresh_theme_button()
|
|
if media_viewer_background != null:
|
|
media_viewer_background.color = Style.BACKGROUND
|
|
if labs_button != null:
|
|
labs_button.icon = _make_labs_icon()
|
|
if flag_button != null:
|
|
flag_button.icon = _make_flag_icon()
|
|
if report_button != null:
|
|
report_button.icon = _make_report_icon()
|
|
if question_toolbar != null:
|
|
for child in question_toolbar.get_children():
|
|
if child is Button and child.tooltip_text == "Clear highlights":
|
|
child.icon = _make_clear_highlights_icon()
|
|
_refresh_toolbar_state()
|
|
_apply_saved_choice_state()
|
|
|
|
|
|
func _refresh_theme_button() -> void:
|
|
if theme_button == null:
|
|
return
|
|
theme_button.text = "☀" if theme_service.is_dark_mode() else "☾"
|
|
theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode"
|
|
Style.style_chip(theme_button, Style.CYAN)
|
|
|
|
|
|
func _refresh_player_stats() -> void:
|
|
if player_stats_label == null:
|
|
return
|
|
var display_name := AuthState.username if AuthState.username != "" else AuthState.role
|
|
player_stats_label.text = "Rating: %d | %s" % [
|
|
QuestionBankService.overall_elo,
|
|
display_name
|
|
]
|
|
|
|
|
|
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 actions := HBoxContainer.new()
|
|
actions.add_theme_constant_override("separation", 10)
|
|
report_form_content.add_child(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)
|
|
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)
|
|
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:
|
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
|
var ranges: Array = question_highlights.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, stem_plain_text.length())
|
|
var end: int = clamp(int(highlight_range.get("end", 0)), start, stem_plain_text.length())
|
|
if start < cursor:
|
|
continue
|
|
rendered += _escape_bbcode(stem_plain_text.substr(cursor, start - cursor))
|
|
rendered += "[bgcolor=yellow][color=black]%s[/color][/bgcolor]" % _escape_bbcode(stem_plain_text.substr(start, end - start))
|
|
cursor = end
|
|
rendered += _escape_bbcode(stem_plain_text.substr(cursor))
|
|
stem_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", ""))
|
|
var has_followup_panel := index < choice_followup_panels.size() and choice_followup_panels[index].visible
|
|
_set_choice_button_text(index, choices[index])
|
|
choice_buttons[index].disabled = answer_is_locked or (locked_wrong_choice_ids.has(id) and not has_followup_panel)
|
|
_style_choice_button_for_state(index, id)
|
|
if locked_wrong_choice_ids.has(id):
|
|
choice_buttons[index].modulate = Color(1.0, 0.25, 0.25) if Style.is_dark else Color.WHITE
|
|
elif eliminated.has(id):
|
|
choice_buttons[index].modulate = Color(0.45, 0.45, 0.45)
|
|
elif id == selected_choice:
|
|
choice_buttons[index].modulate = Color(0.68, 0.92, 1.0)
|
|
else:
|
|
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 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)
|
|
var has_visible_followup := index < choice_followup_panels.size() and choice_followup_panels[index].visible
|
|
choice_buttons[index].custom_minimum_size = _choice_row_size(index, choice_id, has_visible_followup)
|
|
_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)
|
|
if has_visible_followup:
|
|
var wrong_text := "%s. %s" % [display_label, choice_text]
|
|
choice_buttons[index].text = ""
|
|
_set_choice_top_label(index, wrong_text)
|
|
else:
|
|
var wrong_text := "%s. %s" % [display_label, _strike_text(choice_text)]
|
|
choice_buttons[index].text = wrong_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 = "%s. %s" % [display_label, choice_text]
|
|
elif eliminated.has(choice_id):
|
|
choice_buttons[index].text = "%s. [eliminated] %s" % [display_label, _strike_text(choice_text)]
|
|
else:
|
|
choice_buttons[index].text = "%s. %s" % [display_label, choice_text]
|
|
|
|
|
|
func _style_choice_button_for_state(index: int, choice_id: String) -> void:
|
|
if choice_id == selected_choice:
|
|
choice_buttons[index].add_theme_stylebox_override("normal", Style.button_box(Color("18465a"), Style.CYAN))
|
|
choice_buttons[index].add_theme_stylebox_override("hover", Style.button_box(Color("20647e"), Style.CYAN))
|
|
choice_buttons[index].add_theme_stylebox_override("pressed", Style.button_box(Color("18465a"), Style.CYAN))
|
|
else:
|
|
Style.style_answer_button(choice_buttons[index])
|
|
|
|
|
|
func _choice_row_size(_index: int, choice_id: String, has_visible_followup: bool) -> Vector2:
|
|
if not has_visible_followup and choice_id != active_followup_choice_id:
|
|
return Vector2(0, 64)
|
|
if followup_states.has(choice_id):
|
|
var state: Dictionary = followup_states[choice_id]
|
|
if bool(state.get("collapsed", false)):
|
|
return Vector2(0, 136)
|
|
return Vector2(0, 300)
|
|
|
|
|
|
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:
|
|
choice_buttons[index].modulate = Color(0.54, 1.0, 0.58)
|
|
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)
|
|
|
|
|
|
func _build_followup_panel(index: int, choice_id: String) -> void:
|
|
_clear_followup_content(index)
|
|
var panel := choice_followup_panels[index]
|
|
var content := choice_followup_contents[index]
|
|
var followup := _get_followup_for_choice(choice_id)
|
|
var followup_choices: Array = followup.get("choices", []).duplicate(true)
|
|
followup_choices.shuffle()
|
|
followup["choices"] = followup_choices
|
|
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))
|
|
}
|
|
|
|
var prompt := Label.new()
|
|
prompt.text = str(followup.get("prompt", "What finding in the stem makes this answer less appropriate?"))
|
|
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(prompt, 14, Style.TEXT)
|
|
content.add_child(prompt)
|
|
|
|
var choices: Array = followup_choices
|
|
if bool(followup.get("has_authored_choices", false)):
|
|
for option_index in range(choices.size()):
|
|
var option = choices[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, 32)
|
|
Style.style_chip(option_button, Style.CYAN)
|
|
UISoundService.set_button_sound(option_button, "select")
|
|
_set_scaled_button_font(option_button, 15)
|
|
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 := followup):
|
|
_answer_followup(option_id, selected_button, followup_data)
|
|
)
|
|
content.add_child(option_button)
|
|
|
|
if not bool(followup.get("has_authored_choices", false)):
|
|
var action_row := HBoxContainer.new()
|
|
action_row.add_theme_constant_override("separation", 8)
|
|
content.add_child(action_row)
|
|
|
|
var continue_button := Button.new()
|
|
continue_button.text = "Continue"
|
|
continue_button.custom_minimum_size = Vector2(130, 32)
|
|
Style.style_chip(continue_button, Style.CYAN)
|
|
_set_scaled_button_font(continue_button, 15)
|
|
continue_button.pressed.connect(func(followup_data := followup): _resolve_followup(followup_data, null, false, false))
|
|
action_row.add_child(continue_button)
|
|
|
|
panel.visible = true
|
|
|
|
|
|
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"
|
|
var index := _choice_index_for_id(active_followup_choice_id)
|
|
if index >= 0:
|
|
for child in choice_followup_contents[index].get_children():
|
|
if child is Button:
|
|
child.disabled = true
|
|
elif child is HBoxContainer:
|
|
for row_child in child.get_children():
|
|
if row_child is Button:
|
|
row_child.disabled = true
|
|
|
|
if selected_button != null and style_selected:
|
|
_style_followup_result_button(selected_button, is_correct)
|
|
|
|
var teaching := Label.new()
|
|
teaching.text = str(followup.get("teaching_point", "Review the relevant stem finding before choosing again."))
|
|
teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(teaching, 14, Style.AMBER)
|
|
choice_followup_contents[index].add_child(teaching)
|
|
|
|
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
|
|
|
|
_unlock_after_followup()
|
|
|
|
|
|
func _style_followup_result_button(button: Button, is_correct: bool) -> void:
|
|
var fill := Color("1f7a3a") if is_correct else Color("8f1d2c")
|
|
var border := Style.LIME if is_correct else Style.RED
|
|
button.add_theme_stylebox_override("normal", Style.button_box(fill, border))
|
|
button.add_theme_stylebox_override("hover", Style.button_box(fill, border))
|
|
button.add_theme_stylebox_override("disabled", Style.button_box(fill, border))
|
|
button.add_theme_color_override("font_color", Style.TEXT)
|
|
button.add_theme_color_override("font_hover_color", Style.TEXT)
|
|
button.add_theme_color_override("font_disabled_color", Style.TEXT)
|
|
|
|
|
|
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):
|
|
return
|
|
var state: Dictionary = followup_states[choice_id]
|
|
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)
|
|
if bool(state.get("collapsed", false)):
|
|
_build_collapsed_followup(index, state)
|
|
else:
|
|
_build_readonly_followup(index, state)
|
|
choice_followup_panels[index].visible = true
|
|
|
|
|
|
func _build_collapsed_followup(index: int, state: Dictionary) -> void:
|
|
var content := choice_followup_contents[index]
|
|
var summary := Button.new()
|
|
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.custom_minimum_size = Vector2(0, 40)
|
|
Style.style_chip(summary, Style.AMBER)
|
|
UISoundService.set_button_sound(summary, "open")
|
|
_set_scaled_button_font(summary, 15)
|
|
summary.gui_input.connect(func(_event: InputEvent): _mark_review_input_handled())
|
|
summary.pressed.connect(func(choice_id := str(state.get("choice_id", ""))): _toggle_followup_state(choice_id))
|
|
content.add_child(summary)
|
|
|
|
|
|
func _build_readonly_followup(index: int, state: Dictionary) -> void:
|
|
var content := choice_followup_contents[index]
|
|
var summary := Button.new()
|
|
summary.text = "Hide review: %s. %s" % [_display_label_for_choice_id(str(state.get("choice_id", ""))), str(state.get("choice_text", ""))]
|
|
summary.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
summary.custom_minimum_size = Vector2(0, 36)
|
|
Style.style_chip(summary, Style.AMBER)
|
|
UISoundService.set_button_sound(summary, "close")
|
|
_set_scaled_button_font(summary, 15)
|
|
summary.gui_input.connect(func(_event: InputEvent): _mark_review_input_handled())
|
|
summary.pressed.connect(func(choice_id := str(state.get("choice_id", ""))): _toggle_followup_state(choice_id))
|
|
content.add_child(summary)
|
|
|
|
var prompt := Label.new()
|
|
prompt.text = str(state.get("prompt", ""))
|
|
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(prompt, 14, Style.TEXT)
|
|
content.add_child(prompt)
|
|
|
|
var selected := Label.new()
|
|
var selected_text := str(state.get("selected_option_text", ""))
|
|
selected.text = "Your follow-up answer: %s" % selected_text if selected_text != "" else "Follow-up reviewed."
|
|
selected.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(selected, 14, Style.LIME if bool(state.get("was_correct", false)) else Style.RED)
|
|
content.add_child(selected)
|
|
|
|
var teaching := Label.new()
|
|
teaching.text = str(state.get("teaching_point", ""))
|
|
teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_set_scaled_label(teaching, 14, Style.AMBER)
|
|
content.add_child(teaching)
|
|
|
|
|
|
func _clear_followups() -> void:
|
|
active_followup_choice_id = ""
|
|
followup_required = false
|
|
followup_answered = false
|
|
followup_states.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 _strike_text(value: String) -> String:
|
|
var result := ""
|
|
for character in value:
|
|
result += character + char(0x0336)
|
|
return result
|
|
|
|
|
|
func _make_labs_icon() -> Texture2D:
|
|
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
|
|
image.fill(Color(0, 0, 0, 0))
|
|
var glass_color := Style.TEXT
|
|
var fluid_color := Style.LIME
|
|
for x in range(8, 16):
|
|
image.set_pixel(x, 4, glass_color)
|
|
for y in range(5, 10):
|
|
image.set_pixel(10, y, glass_color)
|
|
image.set_pixel(13, y, glass_color)
|
|
for y in range(10, 20):
|
|
var left := 10 - int(floor(float(y - 10) / 3.0))
|
|
var right := 13 + int(floor(float(y - 10) / 3.0))
|
|
image.set_pixel(left, y, glass_color)
|
|
image.set_pixel(right, y, glass_color)
|
|
for x in range(7, 17):
|
|
image.set_pixel(x, 20, glass_color)
|
|
for y in range(15, 20):
|
|
for x in range(8, 16):
|
|
if x >= 10 - int(floor(float(y - 10) / 3.0)) and x <= 13 + int(floor(float(y - 10) / 3.0)):
|
|
image.set_pixel(x, y, fluid_color)
|
|
image.set_pixel(7, 7, Style.CYAN)
|
|
image.set_pixel(17, 9, Style.CYAN)
|
|
image.set_pixel(18, 6, Style.CYAN)
|
|
return ImageTexture.create_from_image(image)
|
|
|
|
|
|
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 _make_clear_highlights_icon() -> Texture2D:
|
|
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
|
|
image.fill(Color(0, 0, 0, 0))
|
|
var handle_color := Style.TEXT
|
|
var bristle_color := Style.AMBER
|
|
for offset in range(3):
|
|
_draw_icon_line(image, Vector2i(6 + offset, 16), Vector2i(16 + offset, 6), handle_color)
|
|
for x in range(13, 20):
|
|
for y in range(15, 19):
|
|
if x + y < 36:
|
|
image.set_pixel(x, y, bristle_color)
|
|
for x in range(12, 20):
|
|
image.set_pixel(x, 19, bristle_color)
|
|
return ImageTexture.create_from_image(image)
|
|
|
|
|
|
func _make_report_icon() -> Texture2D:
|
|
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
|
|
image.fill(Color(0, 0, 0, 0))
|
|
var body_color := Style.TEXT
|
|
var accent_color := Style.RED
|
|
for x in range(5, 14):
|
|
for y in range(9, 15):
|
|
image.set_pixel(x, y, body_color)
|
|
for x in range(14, 20):
|
|
image.set_pixel(x, 7, accent_color)
|
|
image.set_pixel(x, 16, accent_color)
|
|
for y in range(8, 16):
|
|
image.set_pixel(20, y, accent_color)
|
|
for y in range(10, 14):
|
|
image.set_pixel(4, y, body_color)
|
|
for y in range(15, 21):
|
|
image.set_pixel(8, y, body_color)
|
|
if y < 20:
|
|
image.set_pixel(9, y, body_color)
|
|
_draw_icon_line(image, Vector2i(2, 5), Vector2i(5, 8), accent_color)
|
|
_draw_icon_line(image, Vector2i(2, 19), Vector2i(5, 16), accent_color)
|
|
return ImageTexture.create_from_image(image)
|
|
|
|
|
|
func _draw_icon_line(image: Image, from: Vector2i, to: Vector2i, color: Color) -> void:
|
|
var dx := int(abs(to.x - from.x))
|
|
var sx := 1 if from.x < to.x else -1
|
|
var dy := -int(abs(to.y - from.y))
|
|
var sy := 1 if from.y < to.y else -1
|
|
var err: int = dx + dy
|
|
var point := from
|
|
while true:
|
|
if point.x >= 0 and point.x < image.get_width() and point.y >= 0 and point.y < image.get_height():
|
|
image.set_pixel(point.x, point.y, color)
|
|
if point == to:
|
|
break
|
|
var e2: int = 2 * err
|
|
if e2 >= dy:
|
|
err += dy
|
|
point.x += sx
|
|
if e2 <= dx:
|
|
err += dx
|
|
point.y += sy
|
|
|
|
|
|
func _refresh_nav_palette() -> void:
|
|
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(38, 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:
|
|
Style.style_chip(button, Style.LIME)
|
|
else:
|
|
Style.style_chip(button, Color("596982"))
|
|
button.pressed.connect(func(target_index: int = index):
|
|
if BlockSessionService.mode == "Timed":
|
|
BlockSessionService.move_to(target_index)
|
|
_load_question()
|
|
)
|
|
nav_palette.add_child(button)
|