Files
step3/scripts/ui/question_player.gd
T

441 lines
15 KiB
GDScript

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 selected_text := stem_label.get_selected_text().strip_edges()
if selected_text == "":
return
var selection_start := stem_label.get_selection_from()
var selection_end := stem_label.get_selection_to()
if selection_start == selection_end:
return
if selection_start > selection_end:
var previous_start := selection_start
selection_start = selection_end
selection_end = previous_start
_highlight_range(selection_start, selection_end)
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 _remove_overlapping_highlights(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 _remove_overlapping_highlights(highlights: Array, start: int, end: int) -> bool:
var removed := false
for index in range(highlights.size() - 1, -1, -1):
var highlight_range: Dictionary = highlights[index]
if start < int(highlight_range.get("end", 0)) and end > int(highlight_range.get("start", 0)):
highlights.remove_at(index)
removed = true
return removed
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)