Files
step3/scripts/ui/simulation_review.gd
T

343 lines
12 KiB
GDScript

extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
const BLOCK_SIZES := [20, 19, 19, 20, 19, 19, 20, 19, 19, 20, 19, 19]
var main: Control
var simulation := {}
var questions: Array = []
var responses := {}
var filtered_indices: Array[int] = []
var review_position := 0
var active_filter := "all"
var progress_label: Label
var content: VBoxContainer
var previous_button: Button
var next_button: Button
var empty_label: Label
var media_requests: Array[HTTPRequest] = []
var image_overlay: Control
var image_texture: TextureRect
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 10)
var toolbar := HBoxContainer.new()
toolbar.add_theme_constant_override("separation", 8)
add_child(toolbar)
var back := Button.new()
back.text = "< Results"
Style.style_chip(back, Style.CYAN)
back.pressed.connect(func(): main.show_screen("simulation_results", simulation))
toolbar.add_child(back)
progress_label = Label.new()
progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(progress_label, 17, Style.TEXT)
toolbar.add_child(progress_label)
var filters := GridContainer.new()
filters.columns = 4
filters.add_theme_constant_override("h_separation", 8)
filters.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(filters)
var group := ButtonGroup.new()
for definition in [["All", "all"], ["Incorrect", "incorrect"], ["Unanswered", "unanswered"], ["Flagged", "flagged"]]:
var button := Button.new()
button.text = definition[0]
button.toggle_mode = true
button.button_group = group
button.button_pressed = definition[1] == "all"
Style.style_chip(button, Style.CYAN)
button.pressed.connect(_set_filter.bind(definition[1]))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
filters.add_child(button)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(scroll)
content = VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content.add_theme_constant_override("separation", 10)
scroll.add_child(content)
empty_label = Label.new()
empty_label.visible = false
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(empty_label, 19, Style.MUTED)
content.add_child(empty_label)
var navigation := HBoxContainer.new()
navigation.add_theme_constant_override("separation", 10)
add_child(navigation)
previous_button = Button.new()
previous_button.text = "< Previous"
previous_button.custom_minimum_size = Vector2(180, 48)
Style.style_chip(previous_button, Style.CYAN)
previous_button.pressed.connect(_move.bind(-1))
navigation.add_child(previous_button)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
navigation.add_child(spacer)
next_button = Button.new()
next_button.text = "Next >"
next_button.custom_minimum_size = Vector2(180, 48)
Style.style_primary_button(next_button)
next_button.pressed.connect(_move.bind(1))
navigation.add_child(next_button)
_build_image_overlay()
func on_show(payload: Variant = null) -> void:
if typeof(payload) == TYPE_DICTIONARY:
simulation = payload
questions = simulation.get("reviewQuestions", [])
responses.clear()
for response in simulation.get("responses", []):
responses[str(response.get("questionExternalId", ""))] = response
active_filter = "all"
review_position = 0
_apply_filter()
func on_hide() -> void:
_clear_media_requests()
image_overlay.visible = false
func _unhandled_key_input(event: InputEvent) -> void:
if not visible or not event.pressed or event.echo:
return
if event.keycode == KEY_LEFT:
_move(-1)
get_viewport().set_input_as_handled()
elif event.keycode == KEY_RIGHT:
_move(1)
get_viewport().set_input_as_handled()
elif event.keycode == KEY_ESCAPE and image_overlay.visible:
image_overlay.visible = false
get_viewport().set_input_as_handled()
func _set_filter(filter_name: String) -> void:
active_filter = filter_name
review_position = 0
_apply_filter()
func _apply_filter() -> void:
filtered_indices.clear()
for index in range(questions.size()):
var question: Dictionary = questions[index]
var response: Dictionary = responses.get(str(question.get("externalId", "")), {})
var selected := str(response.get("choiceId", ""))
var correct := str(question.get("correctChoiceId", ""))
if _matches_filter(active_filter, selected, correct, bool(response.get("flagged", false))):
filtered_indices.append(index)
_render_question()
func _move(direction: int) -> void:
var target := review_position + direction
if target < 0 or target >= filtered_indices.size():
return
review_position = target
_render_question()
func _render_question() -> void:
_clear_content()
_clear_media_requests()
if filtered_indices.is_empty():
empty_label = Label.new()
empty_label.text = "No questions match this filter."
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(empty_label, 19, Style.MUTED)
content.add_child(empty_label)
progress_label.text = "0 questions"
previous_button.disabled = true
next_button.disabled = true
return
var question_index := filtered_indices[review_position]
var question: Dictionary = questions[question_index]
var response: Dictionary = responses.get(str(question.get("externalId", "")), {})
var selected := str(response.get("choiceId", ""))
var correct := str(question.get("correctChoiceId", ""))
progress_label.text = "Question %d of %d | Block %d | %s" % [review_position + 1, filtered_indices.size(), _block_for_index(question_index) + 1, "Correct" if selected == correct else "Unanswered" if selected == "" else "Incorrect"]
previous_button.disabled = review_position == 0
next_button.disabled = review_position + 1 >= filtered_indices.size()
_add_text(str(question.get("stem", "")), 18, Style.TEXT)
_add_media(question.get("media", []))
_add_labs(question.get("labs", []))
_add_text(str(question.get("leadIn", "")), 21, Style.TEXT)
var explanations: Dictionary = question.get("choiceExplanations", {})
for raw_choice in question.get("choices", []):
var choice: Dictionary = raw_choice
var choice_id := str(choice.get("id", ""))
var panel := PanelContainer.new()
var border := Style.LIME if choice_id == correct else Style.RED if choice_id == selected else Style.BORDER
var background := Color("1f7a3a") if choice_id == correct else Color("8f1d2c") if choice_id == selected else Style.SURFACE
panel.add_theme_stylebox_override("panel", Style.panel_box(background, border))
content.add_child(panel)
var choice_content := VBoxContainer.new()
panel.add_child(choice_content)
var suffix := " [Correct answer]" if choice_id == correct else " [Your answer]" if choice_id == selected else ""
var answer := Label.new()
answer.text = "%s. %s%s" % [choice_id, str(choice.get("text", "")), suffix]
answer.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(answer, 17, Style.TEXT)
choice_content.add_child(answer)
var explanation := str(explanations.get(choice_id, ""))
if not explanation.is_empty():
var detail := Label.new()
detail.text = explanation
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(detail, 15, Style.TEXT)
choice_content.add_child(detail)
_add_heading("Explanation")
_add_text(str(question.get("explanation", "")), 17, Style.TEXT)
_add_references(question.get("references", []))
func _add_heading(value: String) -> void:
var label := Label.new()
label.text = value
Style.set_label(label, 20, Style.CYAN)
content.add_child(label)
func _add_text(value: String, size_value: int, color: Color) -> void:
var label := Label.new()
label.text = value
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(label, size_value, color)
content.add_child(label)
func _add_labs(labs: Array) -> void:
if labs.is_empty():
return
_add_heading("Laboratory studies")
for raw_lab in labs:
var lab: Dictionary = raw_lab
_add_text("%s: %s" % [str(lab.get("name", lab.get("test", ""))), str(lab.get("value", ""))], 15, Style.TEXT)
func _add_references(references: Array) -> void:
if references.is_empty():
return
_add_heading("References")
for raw_reference in references:
var reference: Dictionary = raw_reference
var url := str(reference.get("url", ""))
if not url.begins_with("https://"):
continue
var link := LinkButton.new()
link.text = str(reference.get("title", url))
link.tooltip_text = url
link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
link.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
link.add_theme_color_override("font_color", Style.CYAN)
link.pressed.connect(func(target_url: String = url): OS.shell_open(target_url))
content.add_child(link)
func _add_media(media: Array) -> void:
for raw_url in media:
var url := _resolve_media_url(str(raw_url))
if url.is_empty():
continue
var preview := TextureRect.new()
preview.custom_minimum_size = Vector2(0, 260)
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
content.add_child(preview)
var request := HTTPRequest.new()
request.request_completed.connect(func(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray): _on_media_loaded(result, code, body, preview))
media_requests.append(request)
add_child(request)
request.request(url)
func _on_media_loaded(result: int, code: int, body: PackedByteArray, preview: TextureRect) -> void:
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300 or not is_instance_valid(preview):
return
var image := Image.new()
var error := image.load_png_from_buffer(body)
if error != OK: error = image.load_jpg_from_buffer(body)
if error != OK: error = image.load_webp_from_buffer(body)
if error != OK: return
preview.texture = ImageTexture.create_from_image(image)
preview.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
image_texture.texture = preview.texture
image_overlay.visible = true
)
func _build_image_overlay() -> void:
image_overlay = Control.new()
image_overlay.visible = false
image_overlay.top_level = true
image_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
image_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(image_overlay)
var background := ColorRect.new()
background.color = Color(Style.BACKGROUND, 0.97)
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
image_overlay.add_child(background)
image_texture = TextureRect.new()
image_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
image_texture.offset_left = 24
image_texture.offset_top = 72
image_texture.offset_right = -24
image_texture.offset_bottom = -24
image_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
image_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
image_overlay.add_child(image_texture)
var close := Button.new()
close.text = "X"
close.position = Vector2(16, 16)
close.custom_minimum_size = Vector2(44, 44)
Style.style_chip(close, Style.CYAN)
close.pressed.connect(func(): image_overlay.visible = false)
image_overlay.add_child(close)
func _resolve_media_url(value: String) -> String:
if value.begins_with("http://") or value.begins_with("https://"):
return value
var path := value if value.begins_with("/") else "/%s" % value
var base := QuestionBankService.question_api_base_url
var scheme_end := base.find("://")
var path_start := base.find("/", scheme_end + 3)
return "%s%s" % [base if path_start < 0 else base.substr(0, path_start), path]
func _clear_content() -> void:
for child in content.get_children():
child.queue_free()
func _clear_media_requests() -> void:
for request in media_requests:
if is_instance_valid(request): request.queue_free()
media_requests.clear()
func _block_for_index(index: int) -> int:
var consumed := 0
for block in range(BLOCK_SIZES.size()):
if index < consumed + BLOCK_SIZES[block]: return block
consumed += BLOCK_SIZES[block]
return BLOCK_SIZES.size() - 1
static func _matches_filter(filter_name: String, selected: String, correct: String, flagged: bool) -> bool:
match filter_name:
"incorrect": return selected != "" and selected != correct
"unanswered": return selected == ""
"flagged": return flagged
_: return true