diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 239a98d..5c9cd89 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -7,10 +7,12 @@ const CORRECT_ADVANCE_DELAY_SECONDS := 0.6 var main: Control 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 @@ -119,10 +121,15 @@ func _ready() -> void: scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED question_panel.add_child(scroll) + var content_margin := MarginContainer.new() + content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content_margin.add_theme_constant_override("margin_right", 16) + 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) - scroll.add_child(question_content) + content_margin.add_child(question_content) stem_label = RichTextLabel.new() stem_label.fit_content = true @@ -157,21 +164,41 @@ func _ready() -> void: percent_label.mouse_filter = Control.MOUSE_FILTER_IGNORE percent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT percent_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - percent_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - percent_label.offset_left = 0 + 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 Style.set_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 + Style.set_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 = 42 + followup_panel.offset_top = 48 followup_panel.offset_right = -16 - followup_panel.offset_bottom = -10 + 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) @@ -405,6 +432,7 @@ func _submit_or_next() -> void: if selected_choice == "": return if BlockSessionService.session_kind == "core": + _collapse_resolved_followups() var attempted_choice := selected_choice var attempted_question_id := str(BlockSessionService.get_current_question().get("id", "")) QuestionBankService.record_first_attempt(attempted_question_id, attempted_choice) @@ -679,8 +707,10 @@ func _apply_saved_choice_state() -> void: var choices: Array = question.get("choices", []) 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 = locked_wrong_choice_ids.has(id) + choice_buttons[index].disabled = 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) elif eliminated.has(id): @@ -703,13 +733,20 @@ 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 has_visible_followup := index < choice_followup_panels.size() and choice_followup_panels[index].visible - choice_buttons[index].custom_minimum_size = Vector2(0, 190) if has_visible_followup or choice_id == active_followup_choice_id else Vector2(0, 64) + 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) - choice_buttons[index].text = "%s. %s" % [choice_id, _strike_text(choice_text)] + if has_visible_followup: + var wrong_text := "%s. %s" % [choice_id, choice_text] + choice_buttons[index].text = "" + _set_choice_top_label(index, wrong_text) + else: + var wrong_text := "%s. %s" % [choice_id, _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: @@ -721,6 +758,29 @@ func _set_choice_button_text(index: int, choice: Dictionary) -> void: choice_buttons[index].text = "%s. %s" % [choice_id, 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, 110) + 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 @@ -729,6 +789,14 @@ func _set_choice_percent_label(index: int, text: String) -> void: 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", {}) @@ -779,30 +847,54 @@ func _build_followup_panel(index: int, choice_id: String) -> void: var panel := choice_followup_panels[index] var content := choice_followup_contents[index] var followup := _get_followup_for_choice(choice_id) + followup_states[choice_id] = { + "choice_id": choice_id, + "choice_text": _choice_text_for_id(choice_id), + "prompt": str(followup.get("prompt", "")), + "choices": followup.get("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 clue in the stem should make this answer less likely?")) + prompt.text = str(followup.get("prompt", "What finding in the stem makes this answer less appropriate?")) prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(prompt, 14, Style.TEXT) content.add_child(prompt) var choices: Array = followup.get("choices", []) - if choices.is_empty(): - choices = [{"id": "A", "text": "Review the stem clue"}] - followup["correct_choice_id"] = "A" - followup["teaching_point"] = "Review the stem for the clue that makes this answer less appropriate before trying again." - for option in choices: - if typeof(option) != TYPE_DICTIONARY: - continue - var option_button := Button.new() - option_button.text = "%s. %s" % [str(option.get("id", "")), 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) - 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 bool(followup.get("has_authored_choices", false)): + for option in choices: + if typeof(option) != TYPE_DICTIONARY: + continue + var option_button := Button.new() + option_button.text = "%s. %s" % [str(option.get("id", "")), 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) + 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) + continue_button.pressed.connect(func(followup_data := followup): _resolve_followup(followup_data, null, false, false)) + action_row.add_child(continue_button) panel.visible = true @@ -810,27 +902,59 @@ func _build_followup_panel(index: int, choice_id: String) -> void: func _answer_followup(option_id: String, selected_button: Button, followup: Dictionary) -> void: if not followup_required or followup_answered: return - followup_answered = true var correct_id := str(followup.get("correct_choice_id", "")) var is_correct := option_id == correct_id - Style.style_chip(selected_button, Style.LIME if is_correct else Style.RED) + _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 stem clue before choosing again.")) + teaching.text = str(followup.get("teaching_point", "Review the relevant stem finding before choosing again.")) teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_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) + + func _unlock_after_followup() -> void: + active_followup_choice_id = "" followup_required = false next_button.disabled = false if skip_button != null: @@ -838,10 +962,89 @@ func _unlock_after_followup() -> void: _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" % [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) + 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" % [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) + 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 + Style.set_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 + Style.set_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 + Style.set_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 @@ -860,18 +1063,21 @@ func _get_followup_for_choice(choice_id: String) -> Dictionary: 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 clue in the stem should make this answer less likely?")), - "choices": authored.get("choices", []), + "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 stem clue before choosing again."))) + "teaching_point": str(authored.get("teachingPoint", authored.get("teaching_point", "Review the relevant stem finding before choosing again."))) } return { - "prompt": "What clue in the stem should make this answer less likely?", - "choices": [{"id": "A", "text": "Review the stem clue"}], - "correct_choice_id": "A", - "teaching_point": "Review the stem for the clue that makes this answer less appropriate before trying again." + "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." } @@ -884,6 +1090,15 @@ func _choice_index_for_id(choice_id: String) -> int: return -1 +func _choice_text_for_id(choice_id: String) -> String: + var question := BlockSessionService.get_current_question() + var choices: Array = question.get("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