From 974de67032f66e0a197650c63d5c6d01d70fab05 Mon Sep 17 00:00:00 2001 From: hz Date: Fri, 24 Jul 2026 02:27:57 -0500 Subject: [PATCH] Redesign question bank screen to match new mockup Adapts the quiz screen's header, toolbar, choice cards, and bottom bar to the designer's new layout: - Header: adds the RanaZe logo/wordmark and a question-progress bar + percentage alongside the existing "Question N of M" text. - Toolbar: consolidates 12 icon-only/chip buttons into 6 labeled items (Text size, Calculator, Clear highlight, Notes, Bookmark, More), with a new "More" popup holding the rest (labs, flag, report, flashcard, theme toggle, feedback). The underlying buttons are reparented, not duplicated, so every existing theming/visibility/state call site keeps working unmodified. - Category tag: shows the question's discipline (falling back to system) above the stem. - Choice cards: every choice now renders as a subtly-bordered rounded card even before answering, not just a flat divider row; the currently-selected pre-submit choice shows a checkmark. - Bottom bar: merges the Skip/Submit row and the timer row into one - Skip as a plain link, a centered "X of Y" progress bar, and a time slot that shows the existing countdown when a limit applies or a new count-up stopwatch ("Time elapsed") otherwise, plus a Ctrl+Enter/Cmd+Enter submit shortcut with a visible hint. --- scripts/ui/question_player.gd | 393 +++++++++++++++++++++++++++---- tests/post_answer_review_test.gd | 4 +- 2 files changed, 354 insertions(+), 43 deletions(-) diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index ea3f578..bf3db7e 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -65,7 +65,14 @@ var awaiting_next_question := false var simulation_submit_pending := false var progress_button: Button +var progress_bar: ProgressBar +var progress_percent_label: Label +var bottom_progress_label: Label +var bottom_progress_bar: ProgressBar +var header_wordmark_label: Label +var category_label: Label var timer_label: Label +var timer_caption_label: Label var stem_label: RichTextLabel var lead_label: Label var explanation_label: RichTextLabel @@ -92,6 +99,10 @@ var flashcard_prefill_dialog: Control var bookmark_button: Button var app_feedback_button: Button var theme_button: Button +var more_button: Button +var more_menu_popup: PopupPanel +var text_size_button: Button +var text_size_popup: PopupPanel var question_toolbar: HFlowContainer var header_stack: VBoxContainer var header_row: HBoxContainer @@ -107,6 +118,7 @@ var media_container: VBoxContainer var timer_bar: ProgressBar var choice_badges: Array[Label] = [] var choice_badge_textures: Array[TextureRect] = [] +var choice_selected_icons: Array[TextureRect] = [] var feedback_fog: ColorRect var labs_overlay: Control var labs_panel: PanelContainer @@ -146,6 +158,7 @@ var green_fog_time := 0.0 var correct_advance_delay := 0.0 var shake_time := 0.0 var shake_origin := Vector2.ZERO +var untimed_elapsed_start_msec := 0 func _ready() -> void: theme_service = get_node("/root/UIThemeService") @@ -213,17 +226,53 @@ func _ready() -> void: header_stack.add_theme_constant_override("separation", 8) card_content.add_child(header_stack) header_row = HBoxContainer.new() - header_row.add_theme_constant_override("separation", 10) + header_row.add_theme_constant_override("separation", 16) header_stack.add_child(header_row) + var header_brand := HBoxContainer.new() + header_brand.add_theme_constant_override("separation", 8) + header_row.add_child(header_brand) + var header_brand_mark := TextureRect.new() + header_brand_mark.texture = Brand.ranaze_mark_texture() + header_brand_mark.custom_minimum_size = Vector2(28, 28) + header_brand_mark.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + header_brand_mark.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + header_brand.add_child(header_brand_mark) + header_wordmark_label = Label.new() + header_wordmark_label.text = "RanaZe" + header_wordmark_label.add_theme_font_override("font", Brand.font) + header_wordmark_label.add_theme_font_size_override("font_size", 18) + header_brand.add_child(header_wordmark_label) + + var progress_group := VBoxContainer.new() + progress_group.add_theme_constant_override("separation", 2) + progress_group.size_flags_vertical = Control.SIZE_SHRINK_CENTER + header_row.add_child(progress_group) + progress_button = Button.new() progress_button.flat = true progress_button.alignment = HORIZONTAL_ALIGNMENT_LEFT - progress_button.custom_minimum_size = Vector2(126, 34) + progress_button.custom_minimum_size = Vector2(126, 20) progress_button.tooltip_text = "Open question navigator" _set_scaled_button_font(progress_button, 14) progress_button.pressed.connect(_toggle_question_navigator) - header_row.add_child(progress_button) + progress_group.add_child(progress_button) + + var progress_bar_row := HBoxContainer.new() + progress_bar_row.add_theme_constant_override("separation", 8) + progress_group.add_child(progress_bar_row) + + progress_bar = ProgressBar.new() + progress_bar.show_percentage = false + progress_bar.min_value = 0.0 + progress_bar.max_value = 100.0 + progress_bar.custom_minimum_size = Vector2(120, 6) + progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + progress_bar_row.add_child(progress_bar) + + progress_percent_label = Label.new() + _set_scaled_label(progress_percent_label, 13, Brand.SLATE) + progress_bar_row.add_child(progress_percent_label) var header_spacer := Control.new() header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -235,10 +284,14 @@ func _ready() -> void: timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT question_toolbar = HFlowContainer.new() - question_toolbar.add_theme_constant_override("h_separation", 8) + question_toolbar.add_theme_constant_override("h_separation", 14) question_toolbar.add_theme_constant_override("v_separation", 8) question_toolbar.alignment = FlowContainer.ALIGNMENT_END + # All the underlying buttons are constructed up front (same as before) so every + # other place in this file that mutates them by reference - theming, visibility, + # tooltips, badge state - keeps working unmodified. Only *where* each one is + # parented (directly in question_toolbar vs. reparented into a popup) changes. theme_button = Button.new() theme_button.custom_minimum_size = Vector2(44, 44) theme_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER @@ -248,7 +301,6 @@ func _ready() -> void: theme_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(theme_button, "toggle") theme_button.pressed.connect(theme_service.toggle_theme) - question_toolbar.add_child(theme_button) _refresh_theme_button() text_smaller_button = Button.new() @@ -258,7 +310,6 @@ func _ready() -> void: Style.style_chip(text_smaller_button, Style.CYAN) UISoundService.set_button_sound(text_smaller_button, "toggle") text_smaller_button.pressed.connect(func(): _adjust_question_text_scale(-TEXT_SCALE_STEP)) - question_toolbar.add_child(text_smaller_button) text_larger_button = Button.new() text_larger_button.text = "A+" @@ -267,7 +318,6 @@ func _ready() -> void: Style.style_chip(text_larger_button, Style.CYAN) UISoundService.set_button_sound(text_larger_button, "toggle") text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP)) - question_toolbar.add_child(text_larger_button) calculator_button = Button.new() calculator_button.name = "CalculatorButton" @@ -280,21 +330,18 @@ func _ready() -> void: calculator_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(calculator_button, "open") calculator_button.pressed.connect(_toggle_calculator) - question_toolbar.add_child(calculator_button) labs_button = Button.new() labs_button.tooltip_text = "Review lab results" labs_button.custom_minimum_size = Vector2(44, 44) UISoundService.set_button_sound(labs_button, "open") labs_button.pressed.connect(_show_labs_modal) - question_toolbar.add_child(labs_button) clear_highlight_button = Button.new() clear_highlight_button.tooltip_text = "Clear highlights" clear_highlight_button.custom_minimum_size = Vector2(44, 44) UISoundService.set_button_sound(clear_highlight_button, "eliminate") clear_highlight_button.pressed.connect(_clear_current_highlights) - question_toolbar.add_child(clear_highlight_button) flag_button = Button.new() flag_button.toggle_mode = true @@ -308,7 +355,6 @@ func _ready() -> void: if BlockSessionService.session_kind == "simulation": _save_simulation_current_response() ) - question_toolbar.add_child(flag_button) flashcard_button = Button.new() flashcard_button.tooltip_text = "Save as flashcard (available once answered)" @@ -321,7 +367,6 @@ func _ready() -> void: flashcard_button.disabled = true UISoundService.set_button_sound(flashcard_button, "toggle") flashcard_button.pressed.connect(_toggle_flashcard_current_question) - question_toolbar.add_child(flashcard_button) flashcard_prefill_dialog = FlashcardPrefillDialog.new() flashcard_prefill_dialog.name = "FlashcardPrefillDialog" add_child(flashcard_prefill_dialog) @@ -336,14 +381,12 @@ func _ready() -> void: bookmark_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(bookmark_button, "toggle") bookmark_button.pressed.connect(_toggle_bookmark_current_question) - question_toolbar.add_child(bookmark_button) report_button = Button.new() report_button.tooltip_text = "Report question" report_button.custom_minimum_size = Vector2(44, 44) UISoundService.set_button_sound(report_button, "open") report_button.pressed.connect(_toggle_report_current_question) - question_toolbar.add_child(report_button) notes_button = Button.new() notes_button.tooltip_text = "Open notes" @@ -355,7 +398,6 @@ func _ready() -> void: notes_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(notes_button, "open") notes_button.pressed.connect(func(): main.open_notes_dialog()) - question_toolbar.add_child(notes_button) app_feedback_button = Button.new() app_feedback_button.text = "Feedback" @@ -363,15 +405,69 @@ func _ready() -> void: app_feedback_button.custom_minimum_size = Vector2(92, 44) Style.style_chip(app_feedback_button, Style.CYAN) app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()) - question_toolbar.add_child(app_feedback_button) - question_toolbar.move_child(labs_button, 0) - question_toolbar.move_child(theme_button, question_toolbar.get_child_count() - 2) + + # Text size: single entry point opening a small popup with the existing A-/A+ + # controls untouched, instead of two always-visible buttons. + text_size_button = Button.new() + text_size_button.text = "Aa" + text_size_button.tooltip_text = "Text size" + text_size_button.custom_minimum_size = Vector2(44, 44) + text_size_button.focus_mode = Control.FOCUS_NONE + text_size_button.pressed.connect(_toggle_text_size_popup) + question_toolbar.add_child(_toolbar_labeled_button(text_size_button, "Text size")) + + question_toolbar.add_child(_toolbar_labeled_button(calculator_button, "Calculator")) + question_toolbar.add_child(_toolbar_labeled_button(clear_highlight_button, "Clear highlight")) + question_toolbar.add_child(_toolbar_labeled_button(notes_button, "Notes")) + question_toolbar.add_child(_toolbar_labeled_button(bookmark_button, "Bookmark")) + + more_button = Button.new() + more_button.text = "..." + more_button.tooltip_text = "More actions" + more_button.custom_minimum_size = Vector2(44, 44) + more_button.focus_mode = Control.FOCUS_NONE + more_button.pressed.connect(_toggle_more_menu) + question_toolbar.add_child(_toolbar_labeled_button(more_button, "More")) header_toolbar_host = HBoxContainer.new() header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END header_toolbar_host.add_child(question_toolbar) header_row.add_child(header_toolbar_host) + text_size_popup = PopupPanel.new() + text_size_popup.name = "TextSizePopup" + add_child(text_size_popup) + var text_size_margin := MarginContainer.new() + for side in ["left", "right", "top", "bottom"]: + text_size_margin.add_theme_constant_override("margin_%s" % side, 12) + text_size_popup.add_child(text_size_margin) + var text_size_row := HBoxContainer.new() + text_size_row.add_theme_constant_override("separation", 8) + text_size_margin.add_child(text_size_row) + text_size_row.add_child(text_smaller_button) + text_size_row.add_child(text_larger_button) + + more_menu_popup = PopupPanel.new() + more_menu_popup.name = "MoreMenu" + more_menu_popup.min_size = Vector2i(260, 0) + add_child(more_menu_popup) + var more_menu_margin := MarginContainer.new() + for side in ["left", "right", "top", "bottom"]: + more_menu_margin.add_theme_constant_override("margin_%s" % side, 10) + more_menu_popup.add_child(more_menu_margin) + var more_menu_content := VBoxContainer.new() + more_menu_content.add_theme_constant_override("separation", 4) + more_menu_margin.add_child(more_menu_content) + for entry in [ + [labs_button, "Labs"], + [flag_button, "Flag question"], + [report_button, "Report question"], + [flashcard_button, "Save as flashcard"], + [theme_button, "Switch theme"], + [app_feedback_button, "Feedback"], + ]: + more_menu_content.add_child(_more_menu_row(entry[0], entry[1])) + navigator_popup = PopupPanel.new() navigator_popup.name = "QuestionNavigator" navigator_popup.min_size = Vector2i(340, 180) @@ -421,6 +517,11 @@ func _ready() -> void: question_content.add_theme_constant_override("separation", 12) content_margin.add_child(question_content) + category_label = Label.new() + category_label.visible = false + _set_scaled_label(category_label, 14, Brand.SKY_BLUE) + question_content.add_child(category_label) + stem_label = RichTextLabel.new() stem_label.fit_content = true stem_label.bbcode_enabled = true @@ -521,6 +622,23 @@ func _ready() -> void: button.add_child(percent_label) choice_percent_labels.append(percent_label) + var selected_icon := TextureRect.new() + selected_icon.visible = false + selected_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + selected_icon.texture = preload("res://assets/icons/lucide/circle-check.svg") + selected_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + selected_icon.modulate = Brand.PURPLE + selected_icon.anchor_left = 1.0 + selected_icon.anchor_top = 0.0 + selected_icon.anchor_right = 1.0 + selected_icon.anchor_bottom = 0.0 + selected_icon.offset_left = -50 + selected_icon.offset_top = 18 + selected_icon.offset_right = -18 + selected_icon.offset_bottom = 50 + button.add_child(selected_icon) + choice_selected_icons.append(selected_icon) + var top_label := RichTextLabel.new() top_label.visible = false top_label.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -556,24 +674,59 @@ func _ready() -> void: _build_answer_review_ui() actions = HBoxContainer.new() - actions.add_theme_constant_override("separation", 10) + actions.add_theme_constant_override("separation", 16) + card_content.add_child(actions) + skip_button = Button.new() - skip_button.text = "Skip" - skip_button.custom_minimum_size = Vector2(120, 48) - Style.style_chip(skip_button, Style.AMBER) + skip_button.text = "Skip question" + skip_button.flat = true + skip_button.custom_minimum_size = Vector2(0, 40) UISoundService.set_button_sound(skip_button, "back") skip_button.pressed.connect(_skip_current_question) actions.add_child(skip_button) + var bottom_progress_group := VBoxContainer.new() + bottom_progress_group.add_theme_constant_override("separation", 4) + bottom_progress_group.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bottom_progress_group.alignment = BoxContainer.ALIGNMENT_CENTER + actions.add_child(bottom_progress_group) + + bottom_progress_label = Label.new() + bottom_progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _set_scaled_label(bottom_progress_label, 13, Brand.SLATE) + bottom_progress_group.add_child(bottom_progress_label) + + bottom_progress_bar = ProgressBar.new() + bottom_progress_bar.show_percentage = false + bottom_progress_bar.min_value = 0.0 + bottom_progress_bar.max_value = 100.0 + bottom_progress_bar.custom_minimum_size = Vector2(160, 6) + bottom_progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + bottom_progress_group.add_child(bottom_progress_bar) + + var time_group := VBoxContainer.new() + time_group.add_theme_constant_override("separation", 0) + actions.add_child(time_group) + + timer_caption_label = Label.new() + timer_caption_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _set_scaled_label(timer_caption_label, 12, Brand.SLATE) + time_group.add_child(timer_caption_label) + time_group.add_child(timer_label) + next_button = Button.new() next_button.text = "Submit Block" if BlockSessionService.session_kind == "simulation" and BlockSessionService.current_index + 1 == BlockSessionService.active_questions.size() else "Next" if BlockSessionService.session_kind == "simulation" else "Submit" next_button.tooltip_text = "" next_button.custom_minimum_size = Vector2(140, 48) - next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL Style.style_primary_button(next_button) next_button.pressed.connect(_submit_or_next) actions.add_child(next_button) - card_content.add_child(actions) + + var shortcut_hint_label := Label.new() + shortcut_hint_label.name = "SubmitShortcutHint" + shortcut_hint_label.text = "⌘ Enter" if OS.get_name() == "macOS" else "Ctrl+Enter" + _set_scaled_label(shortcut_hint_label, 11, Brand.SLATE) + actions.add_child(shortcut_hint_label) timer_footer = HBoxContainer.new() timer_footer.add_theme_constant_override("separation", 12) @@ -588,7 +741,6 @@ func _ready() -> void: timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53"))) timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME)) timer_footer.add_child(timer_bar) - timer_footer.add_child(timer_label) feedback_fog = ColorRect.new() feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0) @@ -677,7 +829,10 @@ func _fit_modal_panels() -> void: func _refresh_responsive_layout() -> void: if header_toolbar_host == null or header_row == null or header_stack == null: return - var compact := size.x < 1120.0 + # The toolbar is now 6 labeled items (with "More" absorbing the rest) instead of + # 12 icon-only buttons, so it's meaningfully narrower - the compact-mode breakpoint + # is tuned down to match rather than reusing the old wider-toolbar threshold. + var compact := size.x < 860.0 var target_parent: Control = header_row if compact: target_parent = header_stack @@ -685,9 +840,7 @@ func _refresh_responsive_layout() -> void: header_toolbar_host.reparent(target_parent) header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL - header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 520.0 - app_feedback_button.text = "" if size.x < 760.0 else "Feedback" - app_feedback_button.custom_minimum_size.x = 44 if size.x < 760.0 else 92 + header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 380.0 if size.x < 560.0: nav_palette.columns = 5 else: @@ -717,12 +870,12 @@ func _answer_box(dark: bool, state: String, attached: bool = false) -> StyleBoxF box.content_margin_top = 22 box.content_margin_bottom = 22 if state == "default" or state == "eliminated": - # Flat, divider-style row - most choices sit in a plain list; only the - # states below need to visually pop out as their own card. + # Every choice reads as its own subtly-bordered card, even before answering - + # only selected/correct/wrong states get a stronger accent border/background. box.bg_color = Color("0b172b") if dark else Color("ffffff") box.border_color = Color("22304d") if dark else Color("e7ebf2") - box.border_width_bottom = 1 - box.set_corner_radius_all(0) + box.set_border_width_all(1) + box.set_corner_radius_all(14) return box var background := Color("17183b") if dark else Color("eff4ff") var border := Brand.PURPLE @@ -916,10 +1069,37 @@ func _apply_question_theme() -> void: question_panel.add_theme_stylebox_override("panel", _question_content_box(dark)) if navigator_popup != null: navigator_popup.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + if text_size_popup != null: + text_size_popup.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + if more_menu_popup != null: + more_menu_popup.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + for label in more_menu_popup.find_children("MoreMenuLabel", "Label", true, false): + (label as Label).add_theme_color_override("font_color", Brand.text_color(dark)) + if question_toolbar != null: + for label in question_toolbar.find_children("ToolbarLabel", "Label", true, false): + (label as Label).add_theme_color_override("font_color", Brand.muted_color(dark)) progress_button.add_theme_color_override("font_color", Brand.text_color(dark)) progress_button.add_theme_color_override("font_hover_color", Brand.SKY_BLUE) progress_button.add_theme_color_override("font_disabled_color", Brand.text_color(dark)) + if header_wordmark_label != null: + header_wordmark_label.add_theme_color_override("font_color", Brand.text_color(dark)) + var progress_track := Style.button_box(Color("232b3d") if dark else Color("e2e8f0"), Color("303b53") if dark else Color("cbd5e1")) + var progress_fill := Style.button_box(Brand.ROYAL_BLUE, Brand.ROYAL_BLUE) + if progress_bar != null: + progress_bar.add_theme_stylebox_override("background", progress_track) + progress_bar.add_theme_stylebox_override("fill", progress_fill) + if progress_percent_label != null: + progress_percent_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + if bottom_progress_bar != null: + bottom_progress_bar.add_theme_stylebox_override("background", progress_track) + bottom_progress_bar.add_theme_stylebox_override("fill", progress_fill) + if bottom_progress_label != null: + bottom_progress_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + if category_label != null: + category_label.add_theme_color_override("font_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE) timer_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + if timer_caption_label != null: + timer_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark)) stem_label.add_theme_color_override("default_color", Brand.text_color(dark)) lead_label.add_theme_color_override("font_color", Brand.text_color(dark)) explanation_label.add_theme_color_override("default_color", Brand.text_color(dark)) @@ -935,7 +1115,7 @@ func _apply_question_theme() -> void: var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label if navigator_heading != null: navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark)) - for control in [text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]: + for control in [text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button, text_size_button, more_button]: if control == null: continue var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", ""))) @@ -952,9 +1132,17 @@ func _apply_question_theme() -> void: if calculator_panel != null: calculator_panel.refresh_theme_controls() if skip_button != null: - skip_button.add_theme_stylebox_override("normal", _toolbar_box(dark)) - skip_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) - skip_button.add_theme_color_override("font_color", Brand.text_color(dark)) + var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE + skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) + skip_button.add_theme_stylebox_override("hover", StyleBoxEmpty.new()) + skip_button.add_theme_stylebox_override("pressed", StyleBoxEmpty.new()) + skip_button.add_theme_stylebox_override("disabled", StyleBoxEmpty.new()) + skip_button.add_theme_color_override("font_color", skip_link_color) + skip_button.add_theme_color_override("font_hover_color", skip_link_color) + skip_button.add_theme_color_override("font_disabled_color", Brand.muted_color(dark)) + var shortcut_hint := find_child("SubmitShortcutHint", true, false) as Label + if shortcut_hint != null: + shortcut_hint.add_theme_color_override("font_color", Brand.muted_color(dark)) if next_button != null: next_button.add_theme_stylebox_override("normal", _primary_action_box(dark, "normal")) next_button.add_theme_stylebox_override("hover", _primary_action_box(dark, "hover")) @@ -1054,6 +1242,12 @@ func _process(delta: float) -> void: _update_timer_bar() if BlockSessionService.seconds_remaining <= 0: _finish() + if visible and not _is_session_timer_visible() and (report_overlay == null or not report_overlay.visible): + # No real time limit applies here - show a simple local count-up stopwatch + # ("Time elapsed") so the bottom-bar time slot is never blank, without + # touching BlockSessionService's own (block-scoped, not per-question) timing. + var elapsed_seconds := int(floor(float(Time.get_ticks_msec() - untimed_elapsed_start_msec) / 1000.0)) + _set_timer_display(maxi(0, elapsed_seconds)) func _input(event: InputEvent) -> void: @@ -1085,6 +1279,15 @@ func _input(event: InputEvent) -> void: if not visible or pending_correct_advance or stem_label == null: return + if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ENTER and (event.ctrl_pressed or event.meta_pressed): + var focus_owner := get_viewport().gui_get_focus_owner() + var focus_blocks_shortcut := focus_owner is TextEdit or focus_owner is LineEdit + var overlay_blocks_shortcut := (report_overlay != null and report_overlay.visible) or (labs_overlay != null and labs_overlay.visible) + if not focus_blocks_shortcut and not overlay_blocks_shortcut and next_button != null and next_button.visible and not next_button.disabled: + get_viewport().set_input_as_handled() + _submit_or_next() + return + var mouse_event := event as InputEventMouseButton if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_LEFT: return @@ -1124,7 +1327,7 @@ func _load_question() -> void: next_button.disabled = true skip_button.disabled = false skip_button.visible = BlockSessionService.session_kind not in ["review", "dynamic"] - skip_button.text = "Skip" + skip_button.text = "Skip question" report_button.visible = BlockSessionService.session_kind != "simulation" var question := BlockSessionService.get_current_question() @@ -1141,21 +1344,35 @@ func _load_question() -> void: progress_button.text = _question_progress_text() progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] else "Open question navigator" + _update_progress_indicators() + untimed_elapsed_start_msec = Time.get_ticks_msec() var show_timer := _is_session_timer_visible() - timer_label.visible = show_timer + # The time slot in the bottom bar is always visible - a countdown ("Time remaining") + # when a time limit applies, otherwise a simple count-up stopwatch ("Time elapsed") + # so the area is never blank for untimed practice. + timer_label.visible = true timer_bar.visible = show_timer timer_footer.visible = show_timer + if timer_caption_label != null: + timer_caption_label.text = "Time remaining" if show_timer else "Time elapsed" if show_timer: if BlockSessionService.session_kind == "core": _set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))) else: _set_timer_display(int(ceil(BlockSessionService.seconds_remaining))) + else: + _set_timer_display(0) _update_timer_bar() stem_plain_text = str(question.get("stem", "")) explanation_plain_text = str(question.get("explanation", "")) _render_stem() _load_question_media(question) lead_label.text = question.get("lead_in", "") + var category_text := str(question.get("discipline", "")).strip_edges() + if category_text.is_empty(): + category_text = str(question.get("system", "")).strip_edges() + category_label.text = category_text + category_label.visible = not category_text.is_empty() _refresh_labs_button() presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true) @@ -1247,9 +1464,11 @@ func _should_clear_selection_from_click(mouse_event: InputEventMouseButton) -> b return false if labs_button != null and labs_button.visible and labs_button.get_global_rect().has_point(pointer_position): return false - if flag_button != null and flag_button.get_global_rect().has_point(pointer_position): + if flag_button != null and flag_button.visible and flag_button.get_global_rect().has_point(pointer_position): return false - if report_button != null and report_button.get_global_rect().has_point(pointer_position): + if report_button != null and report_button.visible and report_button.get_global_rect().has_point(pointer_position): + return false + if more_menu_popup != null and more_menu_popup.visible and Rect2(more_menu_popup.position, more_menu_popup.size).has_point(pointer_position): return false for button in choice_buttons: if button.visible and button.get_global_rect().has_point(pointer_position): @@ -1533,7 +1752,7 @@ func _show_answer_review() -> void: if skip_button != null: skip_button.disabled = true skip_button.visible = BlockSessionService.session_kind == "bookmark" - skip_button.text = "Back to Bookmarks" if BlockSessionService.session_kind == "bookmark" else "Skip" + skip_button.text = "Back to Bookmarks" if BlockSessionService.session_kind == "bookmark" else "Skip question" skip_button.disabled = false if BlockSessionService.session_kind == "bookmark" else true if question_scroll != null: question_scroll.call_deferred("ensure_control_visible", explanation_label) @@ -1928,6 +2147,34 @@ func _question_progress_text() -> String: ] +func _update_progress_indicators() -> void: + # "core" sessions have no fixed total (endless queue), so there's no meaningful + # ratio/percentage to show - hide the bars rather than showing a misleading value. + var show_progress := BlockSessionService.session_kind != "core" + if progress_bar != null: + progress_bar.visible = show_progress + if progress_percent_label != null: + progress_percent_label.visible = show_progress + if bottom_progress_bar != null: + bottom_progress_bar.visible = show_progress + if bottom_progress_label != null: + bottom_progress_label.visible = show_progress + if not show_progress: + return + var total := BlockSessionService.active_questions.size() + var current := mini(BlockSessionService.current_index + 1, maxi(total, 1)) + var ratio := float(current) / float(maxi(total, 1)) + var percent := int(round(ratio * 100.0)) + if progress_bar != null: + progress_bar.value = ratio * 100.0 + if progress_percent_label != null: + progress_percent_label.text = "%d%%" % percent + if bottom_progress_bar != null: + bottom_progress_bar.value = ratio * 100.0 + if bottom_progress_label != null: + bottom_progress_label.text = "%d of %d" % [current, total] + + func _toggle_question_navigator() -> void: if navigator_popup == null or BlockSessionService.session_kind == "core": return @@ -1943,6 +2190,66 @@ func _toggle_question_navigator() -> void: navigator_popup.popup(Rect2i(Vector2i(popup_x, popup_y), popup_size)) +func _toolbar_labeled_button(button: Button, label_text: String) -> Control: + var wrap := VBoxContainer.new() + wrap.add_theme_constant_override("separation", 2) + wrap.alignment = BoxContainer.ALIGNMENT_CENTER + wrap.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + wrap.add_child(button) + var label := Label.new() + label.name = "ToolbarLabel" + label.text = label_text + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + _set_scaled_label(label, 11, Brand.SLATE) + wrap.add_child(label) + return wrap + + +func _more_menu_row(button: Button, label_text: String) -> Control: + # Reparents the *actual* button (not a duplicate/proxy) so its existing icon, + # tooltip, toggle state, and pressed connection keep working unmodified wherever + # it's mutated by reference elsewhere in this file. + var row := HBoxContainer.new() + row.name = "MoreMenuRow" + row.add_theme_constant_override("separation", 10) + row.add_child(button) + var label := Label.new() + label.name = "MoreMenuLabel" + label.text = label_text + _set_scaled_label(label, 14, Brand.SLATE) + row.add_child(label) + return row + + +func _toggle_text_size_popup() -> void: + if text_size_popup == null: + return + if text_size_popup.visible: + text_size_popup.hide() + return + var popup_size := Vector2i(140, 76) + var anchor := text_size_button.get_global_rect() + var viewport_size := get_viewport_rect().size + var popup_x := int(clampf(anchor.position.x, 8.0, maxf(8.0, viewport_size.x - popup_size.x - 8.0))) + var popup_y := int(minf(anchor.end.y + 6.0, viewport_size.y - popup_size.y - 8.0)) + text_size_popup.popup(Rect2i(Vector2i(popup_x, popup_y), popup_size)) + + +func _toggle_more_menu() -> void: + if more_menu_popup == null: + return + if more_menu_popup.visible: + more_menu_popup.hide() + return + var popup_size := Vector2i(260, 300) + var anchor := more_button.get_global_rect() + var viewport_size := get_viewport_rect().size + var popup_x := int(clampf(anchor.position.x - popup_size.x + anchor.size.x, 8.0, maxf(8.0, viewport_size.x - popup_size.x - 8.0))) + var popup_y := int(minf(anchor.end.y + 6.0, viewport_size.y - popup_size.y - 8.0)) + more_menu_popup.popup(Rect2i(Vector2i(popup_x, popup_y), popup_size)) + + func _navigate_to_question(target_index: int) -> void: if target_index == BlockSessionService.current_index: navigator_popup.hide() @@ -3147,6 +3454,8 @@ func _style_choice_button_for_state(index: int, choice_id: String) -> void: badge_texture.texture = BADGE_CIRCLE_FILLED_ICON if filled else BADGE_CIRCLE_OUTLINE_ICON badge_texture.modulate = accent badge.add_theme_color_override("font_color", Color.WHITE if filled else Brand.muted_color(dark)) + if index < choice_selected_icons.size(): + choice_selected_icons[index].visible = state == "selected" func _row_has_attached_panel(index: int) -> bool: diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 533c317..64a2ea2 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -357,7 +357,9 @@ func _test_player_runtime_construction() -> void: _assert(player.question_card != null, "question player should construct its branded card") _assert(player.navigator_popup != null, "question player should construct its popup navigator") _assert(player.header_row.get_node_or_null("StepBadge") == null, "question player should not display a redundant STEP 3 badge") - _assert(player.header_row.get_child(0) == player.progress_button, "question progress should be the first header item") + _assert(player.header_row.get_child(0) != player.progress_button, "the brand mark should be the first header item, ahead of question progress") + _assert(player.progress_button.get_parent().get_parent() == player.header_row, "question progress should still live in the header row") + _assert(player.progress_bar != null and player.progress_percent_label != null, "question player should construct a header progress bar and percentage label") _assert(player.clear_highlight_button.icon != null, "clear highlights should use the supplied toolbar artwork") _assert(player.report_button.icon != null, "report question should use the supplied toolbar artwork") _assert(player.labs_button.icon != null, "lab results should use the supplied toolbar artwork")