Refine follow-up review UI

This commit is contained in:
hz
2026-07-11 03:00:33 -05:00
parent ad6b3863cc
commit 9e34a5a410
+250 -35
View File
@@ -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