diff --git a/project.godot b/project.godot index 964eba5..bab0238 100644 --- a/project.godot +++ b/project.godot @@ -18,6 +18,7 @@ config/icon="res://icon.svg" [autoload] AuthState="*res://scripts/autoload/auth_state.gd" +UIThemeService="*res://scripts/autoload/ui_theme_service.gd" UISoundService="*res://scripts/autoload/ui_sound_service.gd" QuestionBankService="*res://scripts/autoload/question_bank_service.gd" ProgressionService="*res://scripts/autoload/progression_service.gd" diff --git a/scripts/autoload/ui_theme_service.gd b/scripts/autoload/ui_theme_service.gd new file mode 100644 index 0000000..540ffcb --- /dev/null +++ b/scripts/autoload/ui_theme_service.gd @@ -0,0 +1,61 @@ +extends Node + +signal theme_changed(is_dark: bool) + +const Style := preload("res://scripts/ui/ui_style.gd") +const SETTINGS_PATH := "user://settings.cfg" +const SETTINGS_SECTION := "appearance" +const SETTINGS_KEY := "color_scheme" + +var _is_dark := true + + +func _enter_tree() -> void: + _is_dark = _load_preference() + Style.set_dark_mode(_is_dark) + + +func is_dark_mode() -> bool: + return _is_dark + + +func set_dark_mode(value: bool) -> void: + if _is_dark == value: + return + _is_dark = value + Style.set_dark_mode(_is_dark) + _save_preference() + theme_changed.emit(_is_dark) + + +func toggle_theme() -> void: + set_dark_mode(not _is_dark) + + +func _load_preference() -> bool: + var config := ConfigFile.new() + if config.load(SETTINGS_PATH) == OK: + var saved := str(config.get_value(SETTINGS_SECTION, SETTINGS_KEY, "")) + if saved == "dark" or saved == "light": + return saved == "dark" + return _system_prefers_dark() + + +func _system_prefers_dark() -> bool: + if OS.has_feature("web"): + var result: Variant = JavaScriptBridge.eval( + "window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches", + true + ) + return bool(result) + if DisplayServer.is_dark_mode_supported(): + return DisplayServer.is_dark_mode() + return true + + +func _save_preference() -> void: + var config := ConfigFile.new() + config.load(SETTINGS_PATH) + config.set_value(SETTINGS_SECTION, SETTINGS_KEY, "dark" if _is_dark else "light") + if config.save(SETTINGS_PATH) != OK: + push_warning("Unable to save color scheme preference.") diff --git a/scripts/autoload/ui_theme_service.gd.uid b/scripts/autoload/ui_theme_service.gd.uid new file mode 100644 index 0000000..79fa396 --- /dev/null +++ b/scripts/autoload/ui_theme_service.gd.uid @@ -0,0 +1 @@ +uid://dgfxwfgg7bapl diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index d3dd99a..633b7dc 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -9,11 +9,15 @@ var user_label: Label var app_menu: PanelContainer var menu_exit_button: Button var sound_toggle: CheckButton +var theme_button: Button +var theme_service var current_screen_name := "" func _ready() -> void: + theme_service = get_node("/root/UIThemeService") Style.apply_app_theme(self) _build_ui() + theme_service.theme_changed.connect(_on_theme_changed) resized.connect(_update_responsive_layout) _update_responsive_layout() show_screen("selection" if AuthState.is_logged_in else "login", "exam") @@ -92,6 +96,14 @@ func _build_ui() -> void: user_label.gui_input.connect(_on_user_label_gui_input) header.add_child(user_label) + theme_button = Button.new() + theme_button.custom_minimum_size = Vector2(44, 44) + theme_button.add_theme_font_size_override("font_size", 22) + UISoundService.set_button_sound(theme_button, "toggle") + theme_button.pressed.connect(theme_service.toggle_theme) + header.add_child(theme_button) + _refresh_theme_button() + var screens_container := MarginContainer.new() screens_container.name = "Screens" screens_container.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -176,6 +188,22 @@ func _refresh_header_stats() -> void: user_label.text = AuthState.username if AuthState.is_logged_in else "" +func _on_theme_changed(is_dark_mode: bool) -> void: + Style.refresh_tree(self, not is_dark_mode) + _refresh_theme_button() + var player = screens.get("player") + if player != null and player.has_method("refresh_theme_controls"): + player.refresh_theme_controls() + + +func _refresh_theme_button() -> void: + if theme_button == null: + return + theme_button.text = "☀" if theme_service.is_dark_mode() else "☾" + theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode" + Style.style_chip(theme_button, Style.CYAN) + + func _logout() -> void: _close_app_menu() if BlockSessionService.block_started: diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 1dfa37e..a75e3a1 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -18,6 +18,7 @@ const REPORT_CATEGORIES := [ ] var main: Control +var theme_service var choice_buttons: Array[Button] = [] var choice_percent_labels: Array[Label] = [] var choice_top_labels: Array[Label] = [] @@ -55,6 +56,7 @@ var text_larger_button: Button var labs_button: Button var flag_button: Button var report_button: Button +var theme_button: Button var question_toolbar: HBoxContainer var nav_palette: HBoxContainer var question_content: VBoxContainer @@ -86,6 +88,7 @@ var shake_time := 0.0 var shake_origin := Vector2.ZERO func _ready() -> void: + theme_service = get_node("/root/UIThemeService") set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 10) if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated): @@ -131,6 +134,14 @@ func _ready() -> void: question_toolbar.add_theme_constant_override("separation", 8) question_toolbar.alignment = BoxContainer.ALIGNMENT_END + theme_button = Button.new() + theme_button.custom_minimum_size = Vector2(44, 44) + theme_button.add_theme_font_size_override("font_size", 22) + 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() text_smaller_button.text = "A-" text_smaller_button.tooltip_text = "Decrease text size" @@ -622,12 +633,12 @@ func _submit_or_next() -> void: if current_answer.is_empty(): if selected_choice == "": return + var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", "")) 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, + question_id_for_attempt, attempted_choice, _presented_choice_ids() ) @@ -649,9 +660,8 @@ func _submit_or_next() -> void: _trigger_wrong_feedback() _apply_saved_choice_state() return - var attempted_question_id := str(BlockSessionService.get_current_question().get("id", "")) QuestionBankService.record_first_attempt( - attempted_question_id, + question_id_for_attempt, selected_choice, _presented_choice_ids() ) @@ -742,7 +752,7 @@ func _trigger_wrong_feedback() -> void: green_fog_time = 0.0 red_fog_time = FEEDBACK_FADE_SECONDS if feedback_fog != null: - feedback_fog.color = Color(1.0, 0.0, 0.0, 0.26) + feedback_fog.color = Color(1.0, 0.0, 0.0, _feedback_overlay_alpha(false)) func _trigger_correct_feedback() -> void: @@ -757,7 +767,7 @@ func _trigger_correct_feedback() -> void: for button in choice_buttons: button.disabled = true if feedback_fog != null: - feedback_fog.color = Color(0.25, 1.0, 0.25, 0.23) + feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true)) func _update_answer_feedback(delta: float) -> void: @@ -770,11 +780,11 @@ func _update_answer_feedback(delta: float) -> void: if red_fog_time > 0.0 and feedback_fog != null: red_fog_time = max(0.0, red_fog_time - delta) - var red_alpha := 0.26 * (red_fog_time / FEEDBACK_FADE_SECONDS) + var red_alpha := _feedback_overlay_alpha(false) * (red_fog_time / FEEDBACK_FADE_SECONDS) feedback_fog.color = Color(1.0, 0.0, 0.0, red_alpha) elif green_fog_time > 0.0 and feedback_fog != null: green_fog_time = max(0.0, green_fog_time - delta) - var green_alpha := 0.23 * (green_fog_time / FEEDBACK_FADE_SECONDS) + var green_alpha := _feedback_overlay_alpha(true) * (green_fog_time / FEEDBACK_FADE_SECONDS) feedback_fog.color = Color(0.25, 1.0, 0.25, green_alpha) if pending_correct_advance: @@ -787,6 +797,12 @@ func _update_answer_feedback(delta: float) -> void: _advance_or_finish() +func _feedback_overlay_alpha(is_correct: bool) -> float: + if Style.is_dark: + return 0.23 if is_correct else 0.26 + return 0.07 + + func _reset_wrong_feedback() -> void: if shake_time > 0.0: position = shake_origin @@ -853,6 +869,7 @@ func _toggle_report_current_question() -> void: func _refresh_toolbar_state() -> void: + _refresh_theme_button() _refresh_labs_button() if flag_button != null: Style.style_chip(flag_button, Style.AMBER if flagged else Style.CYAN) @@ -862,6 +879,30 @@ func _refresh_toolbar_state() -> void: report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question" +func refresh_theme_controls() -> void: + _refresh_theme_button() + if labs_button != null: + labs_button.icon = _make_labs_icon() + if flag_button != null: + flag_button.icon = _make_flag_icon() + if report_button != null: + report_button.icon = _make_report_icon() + if question_toolbar != null: + for child in question_toolbar.get_children(): + if child is Button and child.tooltip_text == "Clear highlights": + child.icon = _make_clear_highlights_icon() + _refresh_toolbar_state() + _apply_saved_choice_state() + + +func _refresh_theme_button() -> void: + if theme_button == null: + return + theme_button.text = "☀" if theme_service.is_dark_mode() else "☾" + theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode" + Style.style_chip(theme_button, Style.CYAN) + + func _refresh_player_stats() -> void: if player_stats_label == null: return @@ -895,6 +936,7 @@ func _set_scaled_button_font(button: Button, base_size: int) -> void: func _set_scaled_rich_text(label: RichTextLabel, base_size: int) -> void: label.set_meta("question_base_font_size", base_size) + label.add_theme_color_override("default_color", Style.TEXT) label.add_theme_font_size_override("normal_font_size", _scaled_font_size(base_size)) label.add_theme_font_size_override("bold_font_size", _scaled_font_size(base_size)) @@ -1493,7 +1535,7 @@ func _apply_saved_choice_state() -> void: 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) + choice_buttons[index].modulate = Color(1.0, 0.25, 0.25) if Style.is_dark else Color.WHITE elif eliminated.has(id): choice_buttons[index].modulate = Color(0.45, 0.45, 0.45) elif id == selected_choice: @@ -1754,6 +1796,8 @@ func _style_followup_result_button(button: Button, is_correct: bool) -> void: 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) + button.add_theme_color_override("font_hover_color", Style.TEXT) + button.add_theme_color_override("font_disabled_color", Style.TEXT) func _unlock_after_followup() -> void: diff --git a/scripts/ui/ui_style.gd b/scripts/ui/ui_style.gd index a6a9d32..73b142e 100644 --- a/scripts/ui/ui_style.gd +++ b/scripts/ui/ui_style.gd @@ -1,22 +1,77 @@ extends RefCounted -const BACKGROUND := Color("121827") -const SURFACE := Color("1d2638") -const SURFACE_DARK := Color("151c2b") -const SURFACE_LIGHT := Color("27334a") -const TEXT := Color("f6f8ff") -const MUTED := Color("aeb8ca") -const CYAN := Color("4de1ff") -const LIME := Color("a6ff5f") -const MAGENTA := Color("ff4fd8") -const AMBER := Color("ffc857") -const RED := Color("ff5f6d") +static var BACKGROUND := Color("121827") +static var SURFACE := Color("1d2638") +static var SURFACE_DARK := Color("151c2b") +static var SURFACE_LIGHT := Color("27334a") +static var TEXT := Color("f6f8ff") +static var MUTED := Color("aeb8ca") +static var CYAN := Color("4de1ff") +static var LIME := Color("a6ff5f") +static var MAGENTA := Color("ff4fd8") +static var AMBER := Color("ffc857") +static var RED := Color("ff5f6d") +static var BORDER := Color("33415c") +static var is_dark := true + +const DARK_PALETTE := { + "background": Color("121827"), "surface": Color("1d2638"), + "surface_dark": Color("151c2b"), "surface_light": Color("27334a"), + "text": Color("f6f8ff"), "muted": Color("aeb8ca"), + "cyan": Color("4de1ff"), "lime": Color("a6ff5f"), + "magenta": Color("ff4fd8"), "amber": Color("ffc857"), + "red": Color("ff5f6d"), "border": Color("33415c") +} +const LIGHT_PALETTE := { + "background": Color("f4f7fb"), "surface": Color("ffffff"), + "surface_dark": Color("e8edf5"), "surface_light": Color("dce5f0"), + "text": Color("172033"), "muted": Color("59677c"), + "cyan": Color("007a99"), "lime": Color("347a18"), + "magenta": Color("a32187"), "amber": Color("986400"), + "red": Color("b42335"), "border": Color("aeb9c8") +} +const CUSTOM_DARK_TO_LIGHT := { + Color("111a2a"): Color("f8fafc"), Color("172337"): Color("fffffe"), + Color("182235"): Color("fefeff"), Color("26151a"): Color("fff1f2"), + Color("263752"): Color("aeb9c8"), Color("344463"): Color("aeb9c8"), + Color("405475"): Color("96a5b8"), Color("3b4964"): Color("aeb9c8"), + Color("303c55"): Color("b9c4d2"), Color("33425d"): Color("d2dce8"), + Color("26354f"): Color("e1e8f1"), Color("20384d"): Color("d8e8ed"), + Color("18465a"): Color("d4f2f7"), Color("20647e"): Color("b9e7ef"), + Color("153b4d"): Color("c6e8ed"), Color("21344a"): Color("d8e8ed"), + Color("202637"): Color("e5e9ef"), Color("2c3445"): Color("c8d0dc"), + Color("232b3d"): Color("e1e7ef"), Color("303b53"): Color("b7c2d0"), + Color("596982"): Color("66758a"), Color("1f7a3a"): Color("dff4e5"), + Color("8f1d2c"): Color("f8dce1") +} + + +static func set_dark_mode(value: bool) -> void: + is_dark = value + var palette: Dictionary = DARK_PALETTE if value else LIGHT_PALETTE + BACKGROUND = palette.background + SURFACE = palette.surface + SURFACE_DARK = palette.surface_dark + SURFACE_LIGHT = palette.surface_light + TEXT = palette.text + MUTED = palette.muted + CYAN = palette.cyan + LIME = palette.lime + MAGENTA = palette.magenta + AMBER = palette.amber + RED = palette.red + BORDER = palette.border static func apply_app_theme(root: Control) -> void: var theme := Theme.new() theme.set_color("font_color", "Label", TEXT) theme.set_color("font_color", "Button", TEXT) - theme.set_color("font_color", "RichTextLabel", TEXT) + theme.set_color("font_hover_color", "Button", TEXT) + theme.set_color("font_pressed_color", "Button", TEXT) + theme.set_color("font_disabled_color", "Button", MUTED) + theme.set_color("default_color", "RichTextLabel", TEXT) + theme.set_color("font_selected_color", "RichTextLabel", TEXT) + theme.set_color("selection_color", "RichTextLabel", Color(CYAN, 0.28)) theme.set_font_size("font_size", "Label", 17) theme.set_font_size("font_size", "Button", 16) theme.set_stylebox("normal", "Button", button_box(SURFACE_LIGHT, Color("3b4964"))) @@ -27,10 +82,10 @@ static func apply_app_theme(root: Control) -> void: root.theme = theme -static func panel_box(color: Color = SURFACE, border: Color = Color("33415c")) -> StyleBoxFlat: +static func panel_box(color: Color = SURFACE, border: Color = BORDER) -> StyleBoxFlat: var box := StyleBoxFlat.new() - box.bg_color = color - box.border_color = border + box.bg_color = _color_for_current_theme(color) + box.border_color = _color_for_current_theme(border) box.set_border_width_all(1) box.corner_radius_top_left = 8 box.corner_radius_top_right = 8 @@ -45,8 +100,8 @@ static func panel_box(color: Color = SURFACE, border: Color = Color("33415c")) - static func button_box(color: Color, border: Color) -> StyleBoxFlat: var box := StyleBoxFlat.new() - box.bg_color = color - box.border_color = border + box.bg_color = _color_for_current_theme(color) + box.border_color = _color_for_current_theme(border) box.set_border_width_all(1) box.corner_radius_top_left = 6 box.corner_radius_top_right = 6 @@ -76,9 +131,67 @@ static func style_answer_button(button: Button) -> void: button.add_theme_stylebox_override("hover", button_box(Color("26354f"), CYAN)) button.add_theme_stylebox_override("pressed", button_box(Color("20384d"), CYAN)) button.add_theme_color_override("font_color", TEXT) + button.add_theme_color_override("font_disabled_color", MUTED) static func style_chip(button: Button, color: Color) -> void: button.add_theme_stylebox_override("normal", button_box(Color(color, 0.22), color)) button.add_theme_stylebox_override("hover", button_box(Color(color, 0.34), color)) button.add_theme_color_override("font_color", TEXT) + button.add_theme_color_override("font_disabled_color", MUTED) + + +static func refresh_tree(root: Control, was_dark: bool) -> void: + apply_app_theme(root) + _translate_control(root, DARK_PALETTE if was_dark else LIGHT_PALETTE, LIGHT_PALETTE if was_dark else DARK_PALETTE) + + +static func _translate_control(control: Control, source: Dictionary, target: Dictionary) -> void: + if control is ColorRect: + control.color = _translate_color(control.color, source, target) + for color_name in ["font_color", "font_hover_color", "font_pressed_color", "font_disabled_color", "font_selected_color", "default_color", "font_uneditable_color"]: + if control.has_theme_color_override(color_name): + control.add_theme_color_override(color_name, _translate_color(control.get_theme_color(color_name), source, target)) + for style_name in ["panel", "normal", "hover", "pressed", "disabled", "focus", "fill", "background", "read_only"]: + if control.has_theme_stylebox_override(style_name): + var box := control.get_theme_stylebox(style_name) + if box is StyleBoxFlat: + var translated := box.duplicate() as StyleBoxFlat + translated.bg_color = _translate_color(translated.bg_color, source, target) + translated.border_color = _translate_color(translated.border_color, source, target) + control.add_theme_stylebox_override(style_name, translated) + for child in control.get_children(): + if child is Control: + _translate_control(child, source, target) + + +static func _translate_color(color: Color, source: Dictionary, target: Dictionary) -> Color: + for key in source: + var candidate: Color = source[key] + if color.is_equal_approx(candidate): + var replacement: Color = target[key] + replacement.a = color.a + return replacement + for dark_color in CUSTOM_DARK_TO_LIGHT: + if color.is_equal_approx(dark_color): + var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color] + replacement.a = color.a + return replacement + for dark_color in CUSTOM_DARK_TO_LIGHT: + var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color] + if color.is_equal_approx(light_color): + var replacement: Color = dark_color + replacement.a = color.a + return replacement + return color + + +static func _color_for_current_theme(color: Color) -> Color: + if is_dark: + return color + for dark_color in CUSTOM_DARK_TO_LIGHT: + if color.is_equal_approx(dark_color): + var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color] + replacement.a = color.a + return replacement + return color diff --git a/tests/ui_theme_test.gd b/tests/ui_theme_test.gd new file mode 100644 index 0000000..8b4c7dd --- /dev/null +++ b/tests/ui_theme_test.gd @@ -0,0 +1,40 @@ +extends SceneTree + +const Style := preload("res://scripts/ui/ui_style.gd") + +var failures := 0 + + +func _initialize() -> void: + Style.set_dark_mode(true) + var root := Control.new() + var background := ColorRect.new() + background.color = Style.BACKGROUND + root.add_child(background) + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475"))) + root.add_child(panel) + + Style.set_dark_mode(false) + Style.refresh_tree(root, true) + _assert(background.color.is_equal_approx(Style.BACKGROUND), "background should switch to light mode") + _assert(root.theme.get_color("default_color", "RichTextLabel").is_equal_approx(Style.TEXT), "rich text should use the light text color") + var light_box := panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_box.bg_color.is_equal_approx(Color("fffffe")), "custom panel surface should switch to light mode") + + Style.set_dark_mode(true) + Style.refresh_tree(root, false) + _assert(background.color.is_equal_approx(Style.BACKGROUND), "background should switch back to dark mode") + _assert(root.theme.get_color("default_color", "RichTextLabel").is_equal_approx(Style.TEXT), "rich text should use the dark text color") + var dark_box := panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(dark_box.bg_color.is_equal_approx(Color("172337")), "custom panel surface should switch back to dark mode") + + root.free() + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/ui_theme_test.gd.uid b/tests/ui_theme_test.gd.uid new file mode 100644 index 0000000..cbce7f6 --- /dev/null +++ b/tests/ui_theme_test.gd.uid @@ -0,0 +1 @@ +uid://bm47u1leamc1h