Improve question review and image handling

This commit is contained in:
hz
2026-07-14 16:02:14 -05:00
parent 7529ed5863
commit 8aaf7d61e9
8 changed files with 391 additions and 17 deletions
+265 -16
View File
@@ -8,6 +8,9 @@ const TEXT_SCALE_MAX := 1.35
const TEXT_SCALE_STEP := 0.1
const REPORT_DETAILS_MIN := 10
const REPORT_DETAILS_MAX := 2000
const MEDIA_ZOOM_MIN := 0.25
const MEDIA_ZOOM_MAX := 4.0
const MEDIA_ZOOM_FACTOR := 1.25
const REPORT_CATEGORIES := [
{"id": "clarity", "label": "Clarity"},
{"id": "medical_accuracy", "label": "Medical accuracy"},
@@ -84,6 +87,20 @@ var report_success_label: Label
var report_is_submitting := false
var report_question_id := ""
var media_requests: Array[HTTPRequest] = []
var question_media_items: Array = []
var media_viewer_overlay: Control
var media_viewer_background: ColorRect
var media_viewer_scroll: ScrollContainer
var media_viewer_center: CenterContainer
var media_viewer_texture: TextureRect
var media_viewer_position_label: Label
var media_viewer_zoom_label: Label
var media_viewer_previous_button: Button
var media_viewer_next_button: Button
var media_viewer_index := -1
var media_viewer_zoom := 1.0
var media_viewer_dragging := false
var media_viewer_drag_position := Vector2.ZERO
var red_fog_time := 0.0
var green_fog_time := 0.0
var correct_advance_delay := 0.0
@@ -331,7 +348,7 @@ func _ready() -> void:
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.custom_minimum_size = Vector2.ZERO
explanation_label.visible = false
_set_scaled_rich_text(explanation_label, 17)
question_content.add_child(explanation_label)
@@ -381,6 +398,7 @@ func _ready() -> void:
_build_labs_modal()
_build_report_modal()
_build_media_viewer()
resized.connect(_fit_modal_panels)
_fit_modal_panels()
@@ -403,6 +421,7 @@ func on_show(_payload: Variant = null) -> void:
func on_hide() -> void:
awaiting_next_question = false
_hide_media_viewer()
_hide_report_modal(true)
_hide_labs_modal()
@@ -421,6 +440,21 @@ func _process(delta: float) -> void:
func _input(event: InputEvent) -> void:
if media_viewer_overlay != null and media_viewer_overlay.visible:
if event.is_action_pressed("ui_cancel"):
_hide_media_viewer()
elif event.is_action_pressed("ui_left"):
_show_adjacent_media(-1)
elif event.is_action_pressed("ui_right"):
_show_adjacent_media(1)
elif event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_PLUS or event.keycode == KEY_EQUAL):
_set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR)
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_MINUS:
_set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR)
else:
return
get_viewport().set_input_as_handled()
return
if report_overlay != null and report_overlay.visible and event.is_action_pressed("ui_cancel"):
_hide_report_modal()
get_viewport().set_input_as_handled()
@@ -513,7 +547,7 @@ func _load_question() -> void:
func _select_choice(index: int) -> void:
if pending_correct_advance or followup_required:
if pending_correct_advance or followup_required or _is_correct_answer_locked():
return
var choices := _get_presented_choices()
if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()):
@@ -529,7 +563,7 @@ func _select_choice(index: int) -> void:
func _clear_selected_choice() -> void:
if selected_choice == "":
if selected_choice == "" or _is_correct_answer_locked():
return
selected_choice = ""
if BlockSessionService.mode == "Timed":
@@ -596,7 +630,7 @@ func _skip_current_question() -> void:
func _choice_gui_input(event: InputEvent, index: int) -> void:
if pending_correct_advance or followup_required:
if pending_correct_advance or followup_required or _is_correct_answer_locked():
return
var mouse_event := event as InputEventMouseButton
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed:
@@ -622,7 +656,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void:
func _toggle_eliminate_selected() -> void:
if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty():
if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty() or _is_correct_answer_locked():
return
var choices := _get_presented_choices()
for index in range(choices.size()):
@@ -726,10 +760,7 @@ func _show_answer_review() -> void:
skip_button.disabled = true
skip_button.visible = false
if question_scroll != null:
var review_target: Control = explanation_label
if references_container.visible:
review_target = references_container
question_scroll.call_deferred("ensure_control_visible", review_target)
question_scroll.call_deferred("ensure_control_visible", explanation_label)
func _show_explanation() -> void:
@@ -990,6 +1021,8 @@ func _refresh_toolbar_state() -> void:
func refresh_theme_controls() -> void:
_refresh_theme_button()
if media_viewer_background != null:
media_viewer_background.color = Style.BACKGROUND
if labs_button != null:
labs_button.icon = _make_labs_icon()
if flag_button != null:
@@ -1096,6 +1129,8 @@ func _load_question_media(question: Dictionary) -> void:
var media_url := _resolve_media_url(str(item))
if media_url == "":
continue
var media_index := question_media_items.size()
question_media_items.append({"texture": null, "loaded": false, "failed": false})
var frame := PanelContainer.new()
frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
frame.add_theme_stylebox_override("panel", Style.panel_box(Color("111a2a"), Color("344463")))
@@ -1111,18 +1146,27 @@ func _load_question_media(question: Dictionary) -> void:
_set_scaled_label(status, 15, Style.MUTED)
image_box.add_child(status)
var preview := Control.new()
preview.visible = false
preview.custom_minimum_size = Vector2(0, 260)
preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
preview.tooltip_text = "Open enlarged image"
preview.gui_input.connect(func(event: InputEvent): _media_preview_gui_input(event, media_index))
image_box.add_child(preview)
var texture_rect := TextureRect.new()
texture_rect.visible = false
texture_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH
texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
texture_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
texture_rect.custom_minimum_size = Vector2(0, 260)
texture_rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL
image_box.add_child(texture_rect)
preview.add_child(texture_rect)
var request := HTTPRequest.new()
request.request_completed.connect(
func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray):
_on_media_loaded(result, response_code, body, question_id, texture_rect, status)
_on_media_loaded(result, response_code, body, question_id, media_index, preview, texture_rect, status)
)
media_requests.append(request)
add_child(request)
@@ -1132,10 +1176,12 @@ func _load_question_media(question: Dictionary) -> void:
func _clear_question_media() -> void:
_hide_media_viewer()
for request in media_requests:
if is_instance_valid(request):
request.queue_free()
media_requests.clear()
question_media_items.clear()
if media_container == null:
return
for child in media_container.get_children():
@@ -1167,12 +1213,16 @@ func _on_media_loaded(
response_code: int,
body: PackedByteArray,
question_id: String,
media_index: int,
preview: Control,
texture_rect: TextureRect,
status: Label) -> void:
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
status.text = "Image unavailable."
if media_index < question_media_items.size():
question_media_items[media_index]["failed"] = true
return
var image := Image.new()
@@ -1183,13 +1233,207 @@ func _on_media_loaded(
err = image.load_webp_from_buffer(body)
if err != OK:
status.text = "Image unavailable."
if media_index < question_media_items.size():
question_media_items[media_index]["failed"] = true
return
texture_rect.texture = ImageTexture.create_from_image(image)
var texture := ImageTexture.create_from_image(image)
question_media_items[media_index]["texture"] = texture
question_media_items[media_index]["loaded"] = true
texture_rect.texture = texture
texture_rect.visible = true
preview.visible = true
status.visible = false
func _build_media_viewer() -> void:
media_viewer_overlay = Control.new()
media_viewer_overlay.visible = false
media_viewer_overlay.top_level = true
media_viewer_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
media_viewer_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(media_viewer_overlay)
media_viewer_background = ColorRect.new()
media_viewer_background.color = Style.BACKGROUND
media_viewer_background.mouse_filter = Control.MOUSE_FILTER_STOP
media_viewer_background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
media_viewer_overlay.add_child(media_viewer_background)
var layout := VBoxContainer.new()
layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
layout.offset_left = 16
layout.offset_top = 12
layout.offset_right = -16
layout.offset_bottom = -16
layout.add_theme_constant_override("separation", 10)
media_viewer_overlay.add_child(layout)
var toolbar := HBoxContainer.new()
toolbar.add_theme_constant_override("separation", 8)
layout.add_child(toolbar)
media_viewer_position_label = Label.new()
media_viewer_position_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(media_viewer_position_label, 17, Style.TEXT)
toolbar.add_child(media_viewer_position_label)
media_viewer_previous_button = _make_media_viewer_button("<", "Previous image")
media_viewer_previous_button.pressed.connect(func(): _show_adjacent_media(-1))
toolbar.add_child(media_viewer_previous_button)
media_viewer_next_button = _make_media_viewer_button(">", "Next image")
media_viewer_next_button.pressed.connect(func(): _show_adjacent_media(1))
toolbar.add_child(media_viewer_next_button)
var zoom_out := _make_media_viewer_button("-", "Zoom out")
zoom_out.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR))
toolbar.add_child(zoom_out)
media_viewer_zoom_label = Label.new()
media_viewer_zoom_label.custom_minimum_size = Vector2(64, 44)
media_viewer_zoom_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
media_viewer_zoom_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_set_scaled_label(media_viewer_zoom_label, 15, Style.TEXT)
toolbar.add_child(media_viewer_zoom_label)
var zoom_in := _make_media_viewer_button("+", "Zoom in")
zoom_in.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR))
toolbar.add_child(zoom_in)
var fit_button := _make_media_viewer_button("Fit", "Fit image to window", Vector2(60, 44))
fit_button.pressed.connect(_fit_media_viewer_image)
toolbar.add_child(fit_button)
var close_button := _make_media_viewer_button("X", "Close image viewer")
close_button.pressed.connect(_hide_media_viewer)
toolbar.add_child(close_button)
media_viewer_scroll = ScrollContainer.new()
media_viewer_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
media_viewer_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
media_viewer_scroll.gui_input.connect(_media_viewer_gui_input)
layout.add_child(media_viewer_scroll)
media_viewer_center = CenterContainer.new()
media_viewer_center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
media_viewer_center.size_flags_vertical = Control.SIZE_EXPAND_FILL
media_viewer_scroll.add_child(media_viewer_center)
media_viewer_texture = TextureRect.new()
media_viewer_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
media_viewer_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
media_viewer_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE
media_viewer_center.add_child(media_viewer_texture)
func _make_media_viewer_button(text_value: String, tooltip: String, minimum := Vector2(44, 44)) -> Button:
var button := Button.new()
button.text = text_value
button.tooltip_text = tooltip
button.custom_minimum_size = minimum
Style.style_chip(button, Style.CYAN)
UISoundService.set_button_sound(button, "toggle")
return button
func _media_preview_gui_input(event: InputEvent, media_index: int) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_show_media_viewer(media_index)
accept_event()
elif event is InputEventScreenTouch and event.pressed:
_show_media_viewer(media_index)
accept_event()
func _show_media_viewer(index: int) -> void:
if index < 0 or index >= question_media_items.size():
return
if not bool(question_media_items[index].get("loaded", false)):
return
var texture := question_media_items[index].get("texture") as Texture2D
if texture == null:
return
media_viewer_index = index
media_viewer_texture.texture = texture
media_viewer_overlay.visible = true
media_viewer_overlay.move_to_front()
_update_media_viewer_navigation()
call_deferred("_fit_media_viewer_image")
func _hide_media_viewer() -> void:
media_viewer_dragging = false
media_viewer_index = -1
if media_viewer_overlay != null:
media_viewer_overlay.visible = false
func _show_adjacent_media(direction: int) -> void:
var next_index := _find_loaded_media_index(media_viewer_index, direction)
if next_index >= 0:
_show_media_viewer(next_index)
func _find_loaded_media_index(from_index: int, direction: int) -> int:
var index := from_index + direction
while index >= 0 and index < question_media_items.size():
if bool(question_media_items[index].get("loaded", false)):
return index
index += direction
return -1
func _update_media_viewer_navigation() -> void:
media_viewer_position_label.text = "Image %d of %d" % [media_viewer_index + 1, question_media_items.size()]
media_viewer_previous_button.disabled = _find_loaded_media_index(media_viewer_index, -1) < 0
media_viewer_next_button.disabled = _find_loaded_media_index(media_viewer_index, 1) < 0
func _fit_media_viewer_image() -> void:
if media_viewer_texture == null or media_viewer_texture.texture == null:
return
var image_size := media_viewer_texture.texture.get_size()
var available := media_viewer_scroll.size - Vector2(24, 24)
if image_size.x <= 0.0 or image_size.y <= 0.0 or available.x <= 0.0 or available.y <= 0.0:
return
_set_media_viewer_zoom(minf(available.x / image_size.x, available.y / image_size.y))
func _set_media_viewer_zoom(value: float) -> void:
if media_viewer_texture == null or media_viewer_texture.texture == null:
return
media_viewer_zoom = clampf(value, MEDIA_ZOOM_MIN, MEDIA_ZOOM_MAX)
var scaled_size := media_viewer_texture.texture.get_size() * media_viewer_zoom
media_viewer_texture.custom_minimum_size = scaled_size
media_viewer_center.custom_minimum_size = Vector2(
maxf(media_viewer_scroll.size.x, scaled_size.x),
maxf(media_viewer_scroll.size.y, scaled_size.y))
media_viewer_zoom_label.text = "%d%%" % int(round(media_viewer_zoom * 100.0))
func _media_viewer_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
_set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR)
accept_event()
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
_set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR)
accept_event()
elif event.button_index == MOUSE_BUTTON_LEFT:
media_viewer_dragging = event.pressed
media_viewer_drag_position = event.position
accept_event()
elif event is InputEventMouseMotion and media_viewer_dragging:
var drag_delta: Vector2 = event.position - media_viewer_drag_position
media_viewer_drag_position = event.position
media_viewer_scroll.scroll_horizontal -= int(drag_delta.x)
media_viewer_scroll.scroll_vertical -= int(drag_delta.y)
accept_event()
elif event is InputEventMagnifyGesture:
_set_media_viewer_zoom(media_viewer_zoom * event.factor)
accept_event()
elif event is InputEventScreenDrag:
media_viewer_scroll.scroll_horizontal -= int(event.relative.x)
media_viewer_scroll.scroll_vertical -= int(event.relative.y)
accept_event()
func _build_report_modal() -> void:
report_overlay = Control.new()
report_overlay.visible = false
@@ -1639,11 +1883,12 @@ func _subtract_highlight_range(highlights: Array, start: int, end: int) -> bool:
func _apply_saved_choice_state() -> void:
var choices := _get_presented_choices()
var answer_is_locked := _is_correct_answer_locked()
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) and not has_followup_panel
choice_buttons[index].disabled = answer_is_locked or (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) if Style.is_dark else Color.WHITE
@@ -1665,6 +1910,10 @@ func _apply_saved_choice_state() -> void:
_refresh_submit_state()
func _is_correct_answer_locked() -> bool:
return bool(current_answer.get("is_correct", false))
func _refresh_submit_state() -> void:
if next_button == null:
return
+17
View File
@@ -11,6 +11,7 @@ var question_bank_button: Button
var bank_status_label: Label
var bank_error_label: Label
var retry_button: Button
var image_question_test_link: LinkButton
var current_stage := "exam"
var pending_question_bank_launch := false
@@ -152,6 +153,15 @@ func _build_mode_view() -> void:
retry_button.pressed.connect(_retry_question_bank)
mode_view.add_child(retry_button)
image_question_test_link = LinkButton.new()
image_question_test_link.text = "Temporary: test a question with an image"
image_question_test_link.tooltip_text = "Load a random published question that includes media"
image_question_test_link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
image_question_test_link.add_theme_color_override("font_color", Style.CYAN)
image_question_test_link.add_theme_color_override("font_hover_color", Style.MAGENTA)
image_question_test_link.pressed.connect(_select_image_question_for_testing)
mode_view.add_child(image_question_test_link)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
mode_view.add_child(spacer)
@@ -196,6 +206,12 @@ func _retry_question_bank() -> void:
_refresh_bank_state()
func _select_image_question_for_testing() -> void:
pending_question_bank_launch = true
QuestionBankService.load_image_question_for_testing()
_refresh_bank_state()
func _on_questions_changed() -> void:
_refresh_bank_state()
if pending_question_bank_launch and visible and current_stage == "step3" and QuestionBankService.load_state == "ready":
@@ -213,6 +229,7 @@ func _refresh_bank_state() -> void:
return
var state := QuestionBankService.load_state
question_bank_button.disabled = pending_question_bank_launch
image_question_test_link.disabled = pending_question_bank_launch
retry_button.visible = state == "failed" or state == "retrying"
retry_button.disabled = state == "retrying"
bank_error_label.text = QuestionBankService.last_error if state == "failed" or state == "retrying" else ""