extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") var main: Control var choice_buttons: Array[Button] = [] var eliminated: Array = [] var flagged := false var selected_choice := "" var current_answer := {} var question_highlights := {} var stem_plain_text := "" var highlight_mode_enabled := false var highlight_drag_started_in_stem := false var progress_label: Label var timer_label: Label var stem_label: RichTextLabel var lead_label: Label var explanation_label: RichTextLabel var next_button: Button var prev_button: Button var flag_button: Button var nav_palette: HBoxContainer var question_content: VBoxContainer func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 10) var top := HBoxContainer.new() top.add_theme_constant_override("separation", 12) progress_label = Label.new() Style.set_label(progress_label, 18, Style.CYAN) progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL top.add_child(progress_label) timer_label = Label.new() Style.set_label(timer_label, 18, Style.AMBER) top.add_child(timer_label) flag_button = Button.new() flag_button.text = "Flag" flag_button.toggle_mode = true Style.style_chip(flag_button, Style.AMBER) flag_button.toggled.connect(func(value: bool): flagged = value) top.add_child(flag_button) add_child(top) var highlight_row := HBoxContainer.new() highlight_row.add_theme_constant_override("separation", 8) var highlight_button := Button.new() highlight_button.text = "Highlight Mode" highlight_button.toggle_mode = true highlight_button.toggled.connect(func(enabled: bool): highlight_mode_enabled = enabled highlight_drag_started_in_stem = false ) Style.style_chip(highlight_button, Style.AMBER) highlight_row.add_child(highlight_button) var clear_highlight_button := Button.new() clear_highlight_button.text = "Clear Highlights" clear_highlight_button.pressed.connect(_clear_current_highlights) highlight_row.add_child(clear_highlight_button) add_child(highlight_row) 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) var scroll := ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED question_panel.add_child(scroll) question_content = VBoxContainer.new() question_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL question_content.add_theme_constant_override("separation", 12) scroll.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) question_content.add_child(stem_label) lead_label = Label.new() lead_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(lead_label, 21, Style.TEXT) question_content.add_child(lead_label) for index in range(5): var button := Button.new() button.custom_minimum_size = Vector2(0, 52) button.alignment = HORIZONTAL_ALIGNMENT_LEFT Style.style_answer_button(button) 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) 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(0, 150) explanation_label.visible = false question_content.add_child(explanation_label) var actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 10) prev_button = Button.new() prev_button.text = "Previous" prev_button.pressed.connect(func(): if BlockSessionService.move_previous(): _load_question() ) actions.add_child(prev_button) var eliminate := Button.new() eliminate.text = "Eliminate Selected" eliminate.pressed.connect(_toggle_eliminate_selected) actions.add_child(eliminate) next_button = Button.new() next_button.text = "Submit" next_button.custom_minimum_size = Vector2(140, 48) Style.style_primary_button(next_button) next_button.pressed.connect(_submit_or_next) actions.add_child(next_button) add_child(actions) func on_show(_payload: Variant = null) -> void: _load_question() func _process(delta: float) -> void: if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed": 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 not visible or not highlight_mode_enabled 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") func _load_question() -> void: eliminated.clear() flagged = false selected_choice = "" current_answer = {} flag_button.button_pressed = false explanation_label.visible = false next_button.text = "Submit" var question := BlockSessionService.get_current_question() progress_label.text = "Question %s" % BlockSessionService.get_progress_text() timer_label.text = "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed" stem_plain_text = str(question.get("stem", "")) _render_stem() lead_label.text = question.get("lead_in", "") var choices: Array = question.get("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 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 if BlockSessionService.mode == "Tutor": _show_explanation() next_button.text = "Next" else: _apply_saved_choice_state() next_button.text = "Next" prev_button.disabled = BlockSessionService.current_index == 0 _refresh_nav_palette() func _select_choice(index: int) -> void: var question := BlockSessionService.get_current_question() var choices: Array = question.get("choices", []) if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()): return selected_choice = str(choices[index].get("id", "")) if BlockSessionService.mode == "Timed": current_answer = {} next_button.text = "Submit" _apply_saved_choice_state() func _choice_gui_input(event: InputEvent, index: int) -> void: 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 question := BlockSessionService.get_current_question() var choices: Array = question.get("choices", []) if index >= choices.size(): return var choice: Dictionary = choices[index] var choice_id := str(choice.get("id", "")) if eliminated.has(choice_id): eliminated.erase(choice_id) else: eliminated.append(choice_id) _set_choice_button_text(index, choice) _apply_saved_choice_state() func _toggle_eliminate_selected() -> void: if selected_choice == "" or not current_answer.is_empty(): return var question := BlockSessionService.get_current_question() var choices: Array = question.get("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) _set_choice_button_text(index, choices[index]) _apply_saved_choice_state() func _submit_or_next() -> void: if current_answer.is_empty(): if selected_choice == "": return current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) if BlockSessionService.mode == "Tutor": _show_explanation() next_button.text = "Next" else: next_button.text = "Next" _refresh_nav_palette() else: _advance_or_finish() 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 for index in range(question.get("choices", []).size()): var choice: Dictionary = question["choices"][index] var id := str(choice.get("id", "")) 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 _refresh_nav_palette() func _advance_or_finish() -> void: if BlockSessionService.move_next(): _load_question() else: _finish() func _finish() -> void: var results := BlockSessionService.finish_block() main.show_screen("review", results) func _commit_current_text_selection() -> void: if not highlight_mode_enabled or 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 _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 question := BlockSessionService.get_current_question() var choices: Array = question.get("choices", []) for index in range(choices.size()): var id := str(choices[index].get("id", "")) _set_choice_button_text(index, choices[index]) if 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 func _set_choice_button_text(index: int, choice: Dictionary) -> void: var choice_id := str(choice.get("id", "")) var choice_text := str(choice.get("text", "")) if eliminated.has(choice_id): choice_buttons[index].text = "%s. [eliminated] %s" % [choice_id, _strike_text(choice_text)] else: choice_buttons[index].text = "%s. %s" % [choice_id, choice_text] func _strike_text(value: String) -> String: var result := "" for character in value: result += character + char(0x0336) return result func _refresh_nav_palette() -> void: for child in nav_palette.get_children(): child.queue_free() 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)