Files
step3/scripts/ui/simulation_review.gd
hzandClaude Sonnet 5 bc9f68aaf2 Restyle the simulation break and review screens to match the results screen
Both were still on the legacy flat-panel Style system, which made
"Review Questions" on the redesigned results screen drop into a
visibly older-looking screen. Rebuilt both with the same Brand card/
badge/accent-color language: a hero card for the break screen, and
color-coded (green/red-tinted, bordered) choice cards, accent-colored
filter chips, and icon headings for the review screen. Pure re-skin -
same functions, same data flow, only what they render into changed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-01 04:21:02 -05:00

528 lines
20 KiB
GDScript

extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const EXPLANATION_ICON := preload("res://assets/icons/lucide/book-open.svg")
const LAB_ICON := preload("res://assets/icons/lucide/microscope.svg")
const REFERENCE_ICON := preload("res://assets/icons/lucide/link.svg")
const BLOCK_SIZES := [20, 19, 19, 20, 19, 19, 20, 19, 19, 20, 19, 19]
const FILTER_DEFS := [["All", "all", 0], ["Incorrect", "incorrect", 1], ["Unanswered", "unanswered", 2], ["Flagged", "flagged", 3]]
var main: Control
var simulation := {}
var questions: Array = []
var responses := {}
var filtered_indices: Array[int] = []
var review_position := 0
var active_filter := "all"
var back_button: Button
var progress_label: Label
var filter_buttons: Dictionary = {}
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
var image_close_button: Button
var return_screen := "selection"
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 12)
var toolbar := HBoxContainer.new()
toolbar.add_theme_constant_override("separation", 12)
add_child(toolbar)
back_button = Button.new()
back_button.text = "< Results"
back_button.custom_minimum_size = Vector2(0, 40)
back_button.pressed.connect(func(): main.show_screen("simulation_results", {"simulation": simulation, "return_screen": return_screen}))
toolbar.add_child(back_button)
progress_label = Label.new()
progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
progress_label.add_theme_font_override("font", Brand.font)
progress_label.add_theme_font_size_override("font_size", 15)
toolbar.add_child(progress_label)
var toolbar_spacer := Control.new()
toolbar_spacer.custom_minimum_size.x = back_button.custom_minimum_size.x
toolbar.add_child(toolbar_spacer)
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 FILTER_DEFS:
var button := Button.new()
button.text = definition[0]
button.toggle_mode = true
button.button_group = group
button.button_pressed = definition[1] == "all"
button.custom_minimum_size = Vector2(0, 38)
button.pressed.connect(_set_filter.bind(definition[1]))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
filters.add_child(button)
filter_buttons[definition[1]] = 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", 14)
scroll.add_child(content)
empty_label = Label.new()
empty_label.visible = false
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
empty_label.add_theme_font_override("font", Brand.font)
empty_label.add_theme_font_size_override("font_size", 16)
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(160, 48)
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(160, 48)
next_button.pressed.connect(_move.bind(1))
navigation.add_child(next_button)
_build_image_overlay()
UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls())
refresh_theme_controls()
func refresh_theme_controls() -> void:
var dark := UIThemeService.is_dark_mode()
_style_outline_button(back_button, Brand.ROYAL_BLUE, dark)
progress_label.add_theme_color_override("font_color", Brand.text_color(dark))
for filter_name in filter_buttons:
var accent := _filter_accent(filter_name)
_style_filter_chip(filter_buttons[filter_name], accent, dark)
_style_outline_button(previous_button, Brand.SLATE, dark)
_style_gradient_button(next_button)
if is_instance_valid(image_close_button):
_style_outline_button(image_close_button, Brand.SLATE, dark)
_render_question()
func on_show(payload: Variant = null) -> void:
if typeof(payload) == TYPE_DICTIONARY:
var wrapper: Dictionary = payload
simulation = wrapper.get("simulation", wrapper)
return_screen = str(wrapper.get("return_screen", "selection"))
questions = simulation.get("reviewQuestions", [])
responses.clear()
for response in simulation.get("responses", []):
responses[str(response.get("questionExternalId", ""))] = response
active_filter = "all"
filter_buttons["all"].button_pressed = true
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()
var dark := UIThemeService.is_dark_mode()
if filtered_indices.is_empty():
empty_label = Label.new()
empty_label.text = "No questions match this filter."
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
empty_label.add_theme_font_override("font", Brand.font)
empty_label.add_theme_font_size_override("font_size", 16)
empty_label.add_theme_color_override("font_color", Brand.muted_color(dark))
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", ""))
var outcome := "Correct" if selected == correct else "Unanswered" if selected == "" else "Incorrect"
progress_label.text = "Question %d of %d | Block %d | %s" % [review_position + 1, filtered_indices.size(), _block_for_index(question_index) + 1, outcome]
previous_button.disabled = review_position == 0
next_button.disabled = review_position + 1 >= filtered_indices.size()
var stem_card := _card(dark)
var stem_column := VBoxContainer.new()
stem_column.add_theme_constant_override("separation", 10)
stem_card.get_child(0).add_child(stem_column)
_add_text(stem_column, str(question.get("stem", "")), 15, Brand.text_color(dark), dark)
_add_media(stem_column, question.get("media", []))
_add_labs(stem_column, question.get("labs", []), dark)
content.add_child(stem_card)
_add_text(content, str(question.get("leadIn", "")), 17, Brand.text_color(dark), dark, true)
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 is_correct := choice_id == correct
var is_selected := choice_id == selected
content.add_child(_build_choice_row(dark, choice_id, str(choice.get("text", "")), str(explanations.get(choice_id, "")), is_correct, is_selected))
_add_heading(content, "Explanation", EXPLANATION_ICON, dark)
_add_text(content, str(question.get("explanation", "")), 14, Brand.text_color(dark), dark)
_add_references(content, question.get("references", []), dark)
func _build_choice_row(dark: bool, choice_id: String, choice_text: String, explanation: String, is_correct: bool, is_selected: bool) -> PanelContainer:
var accent := Brand.SUCCESS if is_correct else (Brand.MAGENTA if is_selected else Brand.SLATE)
var tinted := is_correct or is_selected
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", _choice_box(dark, accent, tinted))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_bottom", 12)
panel.add_child(margin)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 6)
margin.add_child(column)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
column.add_child(row)
var answer := Label.new()
answer.text = "%s. %s" % [choice_id, choice_text]
answer.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
answer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
answer.add_theme_font_override("font", Brand.font)
answer.add_theme_font_size_override("font_size", 14)
answer.add_theme_color_override("font_color", Brand.text_color(dark))
row.add_child(answer)
if is_correct or is_selected:
var badge := Label.new()
badge.text = "Correct answer" if is_correct else "Your answer"
badge.add_theme_font_override("font", Brand.font)
badge.add_theme_font_size_override("font_size", 11)
badge.add_theme_color_override("font_color", accent)
badge.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
badge.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(badge)
if not explanation.is_empty():
var detail := Label.new()
detail.text = explanation
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
detail.add_theme_font_override("font", Brand.font)
detail.add_theme_font_size_override("font_size", 12)
detail.add_theme_color_override("font_color", Brand.muted_color(dark))
column.add_child(detail)
return panel
func _choice_box(dark: bool, accent: Color, tinted: bool) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
if tinted:
box.bg_color = Color(accent, 0.16 if dark else 0.10)
box.border_color = accent
else:
box.bg_color = Color("11182e") if dark else Brand.LIGHT_SURFACE
box.border_color = Color("2a365a") if dark else Brand.LIGHT_BORDER
box.set_border_width_all(1)
box.set_corner_radius_all(12)
return box
func _card(dark: bool) -> PanelContainer:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_top", 14)
margin.add_theme_constant_override("margin_bottom", 14)
panel.add_child(margin)
return panel
func _add_heading(parent: Control, value: String, icon: Texture2D, dark: bool) -> void:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var icon_rect := TextureRect.new()
icon_rect.texture = icon
icon_rect.custom_minimum_size = Vector2(18, 18)
icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_rect.modulate = Brand.ROYAL_BLUE
row.add_child(icon_rect)
var label := Label.new()
label.text = value
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", 16)
label.add_theme_color_override("font_color", Brand.text_color(dark))
row.add_child(label)
parent.add_child(row)
func _add_text(parent: Control, value: String, size_value: int, color: Color, _dark: bool, bold_ish: bool = false) -> void:
if value.is_empty():
return
var label := Label.new()
label.text = value
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", size_value + 2 if bold_ish else size_value)
label.add_theme_color_override("font_color", color)
parent.add_child(label)
func _add_labs(parent: Control, labs: Array, dark: bool) -> void:
if labs.is_empty():
return
_add_heading(parent, "Laboratory studies", LAB_ICON, dark)
for raw_lab in labs:
var lab: Dictionary = raw_lab
_add_text(parent, "%s: %s" % [str(lab.get("name", lab.get("test", ""))), str(lab.get("value", ""))], 13, Brand.muted_color(dark), dark)
func _add_references(parent: Control, references: Array, dark: bool) -> void:
if references.is_empty():
return
_add_heading(parent, "References", REFERENCE_ICON, dark)
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", Brand.SKY_BLUE)
link.pressed.connect(func(target_url: String = url): OS.shell_open(target_url))
parent.add_child(link)
func _add_media(parent: Control, 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
parent.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(Brand.background_color(UIThemeService.is_dark_mode()), 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)
image_close_button = Button.new()
image_close_button.text = "X"
image_close_button.position = Vector2(16, 16)
image_close_button.custom_minimum_size = Vector2(44, 44)
image_close_button.pressed.connect(func(): image_overlay.visible = false)
image_overlay.add_child(image_close_button)
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
func _filter_accent(filter_name: String) -> Color:
match filter_name:
"incorrect": return Brand.MAGENTA
"unanswered": return Brand.SLATE
"flagged": return Brand.AMBER
_: return Brand.ROYAL_BLUE
func _style_filter_chip(button: Button, accent: Color, dark: bool) -> void:
Brand.apply_font(button)
button.add_theme_font_size_override("font_size", 13)
var normal := StyleBoxFlat.new()
normal.bg_color = Color(accent, 0.06 if dark else 0.045)
normal.border_color = Color(accent, 0.35)
normal.set_border_width_all(1)
normal.set_corner_radius_all(9)
var hover := StyleBoxFlat.new()
hover.bg_color = Color(accent, 0.18)
hover.border_color = accent
hover.set_border_width_all(1)
hover.set_corner_radius_all(9)
var pressed := StyleBoxFlat.new()
pressed.bg_color = Color(accent, 0.62)
pressed.border_color = accent
pressed.set_border_width_all(1)
pressed.set_corner_radius_all(9)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", pressed)
button.add_theme_color_override("font_color", accent if dark else accent.darkened(0.1))
button.add_theme_color_override("font_hover_color", accent)
button.add_theme_color_override("font_pressed_color", Brand.DARK_TEXT)
func _style_gradient_button(button: Button) -> void:
Brand.apply_font(button)
button.add_theme_font_size_override("font_size", 15)
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
button.add_theme_stylebox_override("hover", Brand.gradient_button_box())
button.add_theme_stylebox_override("pressed", Brand.gradient_button_box())
button.add_theme_stylebox_override("disabled", Brand.gradient_button_box())
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
button.add_theme_color_override("font_disabled_color", Color(Color.WHITE, 0.5))
func _style_outline_button(button: Button, accent: Color, dark: bool) -> void:
Brand.apply_font(button)
button.add_theme_font_size_override("font_size", 14)
var normal := StyleBoxFlat.new()
normal.bg_color = Color(0, 0, 0, 0)
normal.border_color = accent
normal.set_border_width_all(1)
normal.set_corner_radius_all(10)
var hover := StyleBoxFlat.new()
hover.bg_color = Color(accent, 0.12)
hover.border_color = accent
hover.set_border_width_all(1)
hover.set_corner_radius_all(10)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", hover)
button.add_theme_stylebox_override("disabled", normal)
button.add_theme_color_override("font_color", accent if dark else accent.darkened(0.08))
button.add_theme_color_override("font_hover_color", accent)
button.add_theme_color_override("font_disabled_color", Color(accent, 0.4))
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