Add a theme picker and an Original web theme (#42)
Build / test (push) Successful in 1m31s
Build / build (push) Successful in 1m13s

Settings has a Theme row listing Settings.THEMES: Classic (main_theme.tres,
still the default) and Original web (original_web_theme.tres), which looks like
MusicFrontend: orange-bordered buttons, a #333 header and popups, #424242
panels, #4e4e4e alternating rows, a yellow playing song and a light grey
player bar. Picking one sets the root window's theme at once and saves it as
theme_id in settings.cfg, and it is applied at start.

The popup backdrop and score graph follow a theme switch. The top bar has its
own TopBarBackground style. test_theme.gd checks that every theme has
everything main_theme.tres has. The screenshot tool takes a theme id.

Closes #42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-12 20:50:12 +02:00
1 parent 70e970b24c
commit 94e8dfb602
15 files changed
+2497 -586

No files matched your search

+35 -1
View File
@@ -3,6 +3,9 @@ extends Node
## ##
## Registered as the [code]Settings[/code] autoload. ## Registered as the [code]Settings[/code] autoload.
## Emitted when another theme is used, for code that reads theme colours itself.
signal theme_changed
const MAX_NUMBER_OF_PLAYERS: int = 7 const MAX_NUMBER_OF_PLAYERS: int = 7
## How many letters a search needs before it also finds names with the letters in order ## How many letters a search needs before it also finds names with the letters in order
## but apart, e.g. "lttp" for "A Link to the Past". Shorter searches only find names ## but apart, e.g. "lttp" for "A Link to the Past". Shorter searches only find names
@@ -26,8 +29,14 @@ const SAVED_SETTINGS: Dictionary[String, Array] = {
&"inspiration_list_speed", &"inspiration_list_speed",
], ],
"window": [&"fullscreen"], "window": [&"fullscreen"],
"app": [&"last_seen_version"], "app": [&"last_seen_version", &"theme_id"],
} }
## The looks the game can have, picked in Settings: an id that is saved, the name shown
## and the theme file. The first is the default. See themes/README.md.
const THEMES: Array[Dictionary] = [
{"id": "classic", "name": "Classic", "path": "res://themes/main_theme.tres"},
{"id": "original_web", "name": "Original web", "path": "res://themes/original_web_theme.tres"},
]
## When this environment variable is set, the settings files are kept in that folder ## When this environment variable is set, the settings files are kept in that folder
## instead of the user's config folder. The tests set it so they never touch the real files. ## instead of the user's config folder. The tests set it so they never touch the real files.
const CONFIG_DIR_ENV: String = "MUSICPLAYER_CONFIG_DIR" const CONFIG_DIR_ENV: String = "MUSICPLAYER_CONFIG_DIR"
@@ -85,6 +94,8 @@ var queue_ids: Dictionary[String, String] = {}
## The version the game last showed the About popup for. When it is not [member version], ## The version the game last showed the About popup for. When it is not [member version],
## this version is new and the main window opens the About popup to show what is new. ## this version is new and the main window opens the About popup to show what is new.
var last_seen_version: String = "" var last_seen_version: String = ""
## The id of the theme in use, one of [constant THEMES].
var theme_id: String = THEMES[0].id
## The folder songs are played from instead of the server, picked in the settings, or null ## The folder songs are played from instead of the server, picked in the settings, or null
## to use the server. Kept for this session only. ## to use the server. Kept for this session only.
var music_folder: MusicFolder = null var music_folder: MusicFolder = null
@@ -103,6 +114,7 @@ var version: String = ProjectSettings.get_setting("application/config/version",
func _ready() -> void: func _ready() -> void:
apply_config(load_config()) apply_config(load_config())
load_saved_settings() load_saved_settings()
_use_theme(theme_id)
if fullscreen: if fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
@@ -246,6 +258,28 @@ func characters_dir() -> String:
return "" return ""
## Switches the whole game to the theme [param id] from [constant THEMES] and saves the
## choice. Controls without a theme of their own take it from the root window.
func apply_theme(id: String) -> void:
_use_theme(id)
save_settings()
## The theme in [constant THEMES] with [param id], or the default one if there is none.
func theme_entry(id: String) -> Dictionary:
for entry: Dictionary in THEMES:
if entry.id == id:
return entry
return THEMES[0]
func _use_theme(id: String) -> void:
var entry: Dictionary = theme_entry(id)
theme_id = entry.id
get_tree().root.theme = load(entry.path)
theme_changed.emit()
func delete_children(node: Node) -> void: func delete_children(node: Node) -> void:
for n: Node in node.get_children(): for n: Node in node.get_children():
node.remove_child(n) node.remove_child(n)
+1
View File
@@ -17,6 +17,7 @@ script = ExtResource("top_bar_script")
layout_mode = 0 layout_mode = 0
offset_right = 1920.0 offset_right = 1920.0
offset_bottom = 56.0 offset_bottom = 56.0
theme_type_variation = &"TopBarBackground"
[node name="Title" type="Label" parent="." unique_id=1187907861] [node name="Title" type="Label" parent="." unique_id=1187907861]
layout_mode = 0 layout_mode = 0
+15
View File
@@ -143,6 +143,21 @@ theme_type_variation = &"HighlightMedium"
text = "No pictures found" text = "No pictures found"
text_overrun_behavior = 3 text_overrun_behavior = 3
[node name="ThemeRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ThemeRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Theme"
[node name="ThemeButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/ThemeRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(180, 0)
layout_mode = 2
focus_mode = 0
[node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/VBoxContainer" unique_id=400304684] [node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/VBoxContainer" unique_id=400304684]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
+6
View File
@@ -27,5 +27,11 @@ static func add_to(parent: Control, popups: Array[BasePopup]) -> PopupBackdrop:
return backdrop return backdrop
func _notification(what: int) -> void:
# The dimming colour comes from the theme, which can be switched in Settings
if what == NOTIFICATION_THEME_CHANGED:
color = get_theme_color(&"backdrop", &"Palette")
func _update() -> void: func _update() -> void:
visible = _popups.any(func(popup: BasePopup) -> bool: return popup.visible) visible = _popups.any(func(popup: BasePopup) -> bool: return popup.visible)
+6
View File
@@ -19,6 +19,12 @@ var _history: ScoreHistory
var _winning_score: int = 0 var _winning_score: int = 0
func _notification(what: int) -> void:
# The colours come from the theme, which can be switched in Settings
if what == NOTIFICATION_THEME_CHANGED:
queue_redraw()
## Shows [param history]. The y axis goes up to [param winning_score], or higher if ## Shows [param history]. The y axis goes up to [param winning_score], or higher if
## someone has more points. A winning score of 0 draws no winning line. ## someone has more points. A winning score of 0 draws no winning line.
func show_history(history: ScoreHistory, winning_score: int) -> void: func show_history(history: ScoreHistory, winning_score: int) -> void:
+14 -4
View File
@@ -1,9 +1,9 @@
class_name SettingsPopup class_name SettingsPopup
extends BasePopup extends BasePopup
## The application settings: which MusicServer or music folder to play from, fullscreen, ## The application settings: which MusicServer or music folder to play from, the theme,
## how many songs to download ahead, whether to prefer less played soundtracks and whether ## fullscreen, how many songs to download ahead, whether to prefer less played soundtracks
## plays count in the statistics, and a button that opens the folder the settings are ## and whether plays count in the statistics, and a button that opens the folder the
## kept in. Opened from the Settings button in the top bar. ## settings are kept in. Opened from the Settings button in the top bar.
## ##
## A music folder (see [MusicFolder]) is used instead of the server until Use server is ## A music folder (see [MusicFolder]) is used instead of the server until Use server is
## pressed or the game is closed. The settings that need the server are off meanwhile. ## pressed or the game is closed. The settings that need the server are off meanwhile.
@@ -25,6 +25,7 @@ const PADDING: Vector2 = Vector2(20, 12)
@onready var settings_panel: Panel = %SettingsPanel @onready var settings_panel: Panel = %SettingsPanel
@onready var select_server_button: OptionButton = %SelectServerButton @onready var select_server_button: OptionButton = %SelectServerButton
@onready var fullscreen_button: CheckButton = %FullscreenButton @onready var fullscreen_button: CheckButton = %FullscreenButton
@onready var theme_button: OptionButton = %ThemeButton
@onready var cache_label: Label = %CacheLabel @onready var cache_label: Label = %CacheLabel
@onready var lower_cache_button: Button = %LowerCacheButton @onready var lower_cache_button: Button = %LowerCacheButton
@onready var increase_cache_button: Button = %IncreaseCacheButton @onready var increase_cache_button: Button = %IncreaseCacheButton
@@ -56,6 +57,10 @@ var _panel_middle_y: float = 0.0
func _ready() -> void: func _ready() -> void:
for server: String in Settings.servers: for server: String in Settings.servers:
select_server_button.add_item(server) select_server_button.add_item(server)
for entry: Dictionary in Settings.THEMES:
theme_button.add_item(entry.name)
theme_button.select(Settings.THEMES.find(Settings.theme_entry(Settings.theme_id)))
theme_button.item_selected.connect(select_theme)
fullscreen_button.button_pressed = Settings.fullscreen fullscreen_button.button_pressed = Settings.fullscreen
select_server_button.select(Settings.selected_server) select_server_button.select(Settings.selected_server)
cache_label.text = str(Playlist.number_of_tracks_to_preload) cache_label.text = str(Playlist.number_of_tracks_to_preload)
@@ -83,6 +88,11 @@ func _ready() -> void:
_show_music_source() _show_music_source()
## Switches to the theme at [param index] in [constant Settings.THEMES] at once.
func select_theme(index: int) -> void:
Settings.apply_theme(Settings.THEMES[index].id)
func fullscreen() -> void: func fullscreen() -> void:
Settings.set_fullscreen(!Settings.fullscreen) Settings.set_fullscreen(!Settings.fullscreen)
File diff suppressed because it is too large. Load diff
+29
View File
@@ -15,6 +15,7 @@ const STYLES: Dictionary = {
"WindowBackground": "Panel", "WindowBackground": "Panel",
"PopupBackground": "Panel", "PopupBackground": "Panel",
"StepButton": "Button", "StepButton": "Button",
"TopBarBackground": "PanelContainer",
"PlayerList": "PanelContainer", "PlayerList": "PanelContainer",
"PlayerCharacter": "PanelContainer", "PlayerCharacter": "PanelContainer",
"PlayerCharacterHover": "PanelContainer", "PlayerCharacterHover": "PanelContainer",
@@ -171,3 +172,31 @@ func test_japanese_fallback_keeps_the_line_height_of_godots_font() -> void:
assert_float(_theme.default_font.get_height(size)) \ assert_float(_theme.default_font.get_height(size)) \
.override_failure_message("Line height at size %d" % size) \ .override_failure_message("Line height at size %d" % size) \
.is_equal(builtin.get_height(size)) .is_equal(builtin.get_height(size))
func test_every_theme_has_everything_the_main_theme_has() -> void:
var data_types: Array[Theme.DataType] = [Theme.DATA_TYPE_COLOR, Theme.DATA_TYPE_CONSTANT,
Theme.DATA_TYPE_FONT, Theme.DATA_TYPE_FONT_SIZE, Theme.DATA_TYPE_ICON,
Theme.DATA_TYPE_STYLEBOX]
for entry: Dictionary in Settings.THEMES:
var other: Theme = load(entry.path)
assert_object(other).override_failure_message(entry.path + " is missing").is_not_null()
for theme_type: String in _theme.get_type_list():
assert_str(String(other.get_type_variation_base(theme_type))) \
.override_failure_message("%s: %s" % [entry.name, theme_type]) \
.is_equal(String(_theme.get_type_variation_base(theme_type)))
for data_type: Theme.DataType in data_types:
for item: String in _theme.get_theme_item_list(data_type, theme_type):
assert_bool(other.has_theme_item(data_type, item, theme_type)) \
.override_failure_message("%s has no %s/%s" % [entry.name,
theme_type, item]).is_true()
func test_every_theme_has_its_own_name_and_id() -> void:
var ids: Array = Settings.THEMES.map(func(entry: Dictionary) -> String: return entry.id)
var names: Array = Settings.THEMES.map(func(entry: Dictionary) -> String: return entry.name)
assert_str(Settings.THEMES[0].path).is_equal(THEME_PATH)
for list: Array in [ids, names]:
for value: String in list:
assert_int(list.count(value)).override_failure_message(value).is_equal(1)
+74
View File
@@ -0,0 +1,74 @@
extends MainWindowTestSuite
## Picking a theme in Settings switches the whole game's look at once and is saved.
var _settings: SettingsPopup
func before_test() -> void:
await super()
_settings = _window.get_node("SettingsPopup")
func after_test() -> void:
Settings.apply_theme(Settings.THEMES[0].id)
await super()
func _pick(theme_name: String) -> void:
var index: int = Settings.THEMES.map(
func(entry: Dictionary) -> String: return entry.name).find(theme_name)
_settings.theme_button.select(index)
_settings.theme_button.item_selected.emit(index)
await _runner.simulate_frames(1)
func test_picker_lists_every_theme_and_shows_the_one_in_use() -> void:
var names: Array[String] = []
for index: int in _settings.theme_button.item_count:
names.append(_settings.theme_button.get_item_text(index))
assert_array(names).contains_exactly(["Classic", "Original web"])
assert_str(_settings.theme_button.get_item_text(_settings.theme_button.selected)) \
.is_equal("Classic")
func test_picking_a_theme_switches_the_look_at_once() -> void:
var button: Button = _window.get_node("TopBar/%SyncButton")
var classic_text: Color = button.get_theme_color(&"font_color")
var panel: Panel = _window.get_node("SettingsPopup/SettingsPanel")
var popup_style: StyleBox = panel.get_theme_stylebox(&"panel")
await _pick("Original web")
assert_str(Settings.theme_id).is_equal("original_web")
assert_str(_window.get_tree().root.theme.resource_path) \
.is_equal("res://themes/original_web_theme.tres")
assert_object(panel.get_theme_stylebox(&"panel")).is_not_same(popup_style)
# The web version's buttons have orange text
assert_bool(button.get_theme_color(&"font_color") != classic_text).is_true()
func test_chosen_theme_is_saved() -> void:
await _pick("Original web")
var saved: ConfigFile = ConfigFile.new()
assert_int(saved.load(Settings.saved_settings_path())).is_equal(OK)
assert_str(saved.get_value("app", "theme_id")).is_equal("original_web")
func test_backdrop_and_graph_follow_the_theme() -> void:
var backdrop: PopupBackdrop = _window.get_node("PopupBackdrop")
var classic: Color = backdrop.color
await _pick("Original web")
assert_object(backdrop.color).is_equal(backdrop.get_theme_color(&"backdrop", &"Palette"))
assert_bool(backdrop.color != classic).is_true()
func test_unknown_theme_falls_back_to_the_default() -> void:
Settings.apply_theme("no_such_theme")
assert_str(Settings.theme_id).is_equal("classic")
assert_str(_window.get_tree().root.theme.resource_path) \
.is_equal("res://themes/main_theme.tres")
@@ -0,0 +1 @@
uid://de1hpddu212xs
+1 -1
View File
@@ -6,7 +6,7 @@ const SAVED_VALUES: Dictionary = {
&"auto_repeat_song": false, &"stop_after_current": false, &"hide_next_track": false, &"auto_repeat_song": false, &"stop_after_current": false, &"hide_next_track": false,
&"hide_beginning": true, &"hide_for_seconds": 7.0, &"hide_length": true, &"hide_beginning": true, &"hide_for_seconds": 7.0, &"hide_length": true,
&"hide_ticks": true, &"winning_score": 13, &"inspiration_list_speed": 4, &"hide_ticks": true, &"winning_score": 13, &"inspiration_list_speed": 4,
&"fullscreen": true, &"last_seen_version": "1.2.3", &"fullscreen": true, &"last_seen_version": "1.2.3", &"theme_id": "original_web",
} }
var _snapshot: AutoloadSnapshot var _snapshot: AutoloadSnapshot
+29 -7
View File
@@ -1,17 +1,35 @@
# Themes # Themes
The whole look of the game comes from one theme, `main_theme.tres`. It is the project The whole look of the game comes from a theme. Scenes and scripts never set colours,
theme (Project Settings → GUI → Theme → Custom), so every scene uses it without setting font sizes or style boxes themselves; they name one of the theme's styles instead, and
anything. Scenes and scripts never set colours, font sizes or style boxes themselves; `tests/integration/test_theme.gd` checks that it stays that way.
they name one of the theme's styles instead, and `tests/integration/test_theme.gd`
checks that it stays that way. | Theme | File | |
|---|---|---|
| Classic | `main_theme.tres` | The default. Also the project theme (Project Settings → GUI → Theme → Custom), which the editor shows |
| Original web | `original_web_theme.tres` | Looks like MusicFrontend, the web version: orange-bordered buttons, `#333` header and popups, `#424242` panels, `#4e4e4e` alternating rows, a yellow playing song and a light grey player bar |
## Picking a theme
The themes are listed in `Settings.THEMES` (`data/settings.gd`): an id, the name shown
and the file. The player picks one in Settings → Theme, and it applies at once.
`Settings.apply_theme()` sets the theme of the root window, which every control without a
theme of its own uses, and saves the id as `theme_id` in `settings.cfg`. The saved theme
is applied when the game starts.
Code that reads theme colours itself redraws on `NOTIFICATION_THEME_CHANGED`, e.g. the
score graph and the popup backdrop. `Settings.theme_changed` is emitted after a switch.
To see the pictures of the screenshot tour in another theme, pass its id:
`godot --path . -s tools/take_screenshots.gd -- <dir> original_web`.
## Making a new theme ## Making a new theme
1. Copy `main_theme.tres`, e.g. to `dark_blue_theme.tres`. 1. Copy `main_theme.tres`, e.g. to `dark_blue_theme.tres`.
2. Open the copy in Godot (double-click it) and change its colours, style boxes and font 2. Open the copy in Godot (double-click it) and change its colours, style boxes and font
sizes in the theme editor. Keep every style's name. sizes in the theme editor. Keep every style's name.
3. Point Project Settings → GUI → Theme → Custom at the copy. 3. Add it to `Settings.THEMES`. `test_theme.gd` checks that it has every style, colour and
size that `main_theme.tres` has.
The **font** is the theme's default font, Godot's own Open Sans with Noto Sans JP for The **font** is the theme's default font, Godot's own Open Sans with Noto Sans JP for
Japanese text; see `fonts/README.md`. Japanese text; see `fonts/README.md`.
@@ -32,7 +50,11 @@ inspector or `theme_type_variation` in code:
|---|---|---| |---|---|---|
| `WindowBackground` | Panel | The background behind everything in the main window | | `WindowBackground` | Panel | The background behind everything in the main window |
| `PopupBackground` | Panel | The panel of every popup: dark, bordered and rounded | | `PopupBackground` | Panel | The panel of every popup: dark, bordered and rounded |
| `DarkSpacer` | Panel | The dark gaps in the player list and the add player row | | `TopBarBackground` | PanelContainer | The bar along the top of the window |
| `PlayerList` | PanelContainer | The player list, with space around its rows |
| `PlayerScore` | Label | A player's points (24) |
| `PlayerCharacter`, `PlayerCharacterHover` | PanelContainer | The frame around a player's character, and the one under the mouse |
| `StepButton` | Button | The small +1 and -1 buttons, with less padding than other buttons |
| `CharacterCard` | Panel | A character in the character select popup | | `CharacterCard` | Panel | A character in the character select popup |
| `MusicList` | PanelContainer | The list of songs in the quiz | | `MusicList` | PanelContainer | The list of songs in the quiz |
| `SongRow`, `SongRowOdd` | PanelContainer | Alternating songs in that list | | `SongRow`, `SongRowOdd` | PanelContainer | Alternating songs in that list |
+2
View File
@@ -1331,6 +1331,8 @@ TitleLarge/base_type = &"Label"
TitleLarge/font_sizes/font_size = 50 TitleLarge/font_sizes/font_size = 50
TitleMedium/base_type = &"Label" TitleMedium/base_type = &"Label"
TitleMedium/font_sizes/font_size = 35 TitleMedium/font_sizes/font_size = 35
TopBarBackground/base_type = &"PanelContainer"
TopBarBackground/styles/panel = SubResource("StyleBoxFlat_wl3gy")
TooltipLabel/colors/font_color = Color(0.875, 0.875, 0.875, 1) TooltipLabel/colors/font_color = Color(0.875, 0.875, 0.875, 1)
TooltipLabel/colors/font_outline_color = Color(0, 0, 0, 1) TooltipLabel/colors/font_outline_color = Color(0, 0, 0, 1)
TooltipLabel/colors/font_shadow_color = Color(0, 0, 0, 0) TooltipLabel/colors/font_shadow_color = Color(0, 0, 0, 0)
File diff suppressed because it is too large. Load diff
+8
View File
@@ -3,6 +3,7 @@ extends SceneTree
## ##
## Usage: ## Usage:
## MUSICPLAYER_CONFIG_DIR=$(mktemp -d) godot --path . -s tools/take_screenshots.gd -- <dir> ## MUSICPLAYER_CONFIG_DIR=$(mktemp -d) godot --path . -s tools/take_screenshots.gd -- <dir>
## [<theme id>]
## ##
## Setting [code]MUSICPLAYER_CONFIG_DIR[/code] keeps your saved quiz settings out of the ## Setting [code]MUSICPLAYER_CONFIG_DIR[/code] keeps your saved quiz settings out of the
## pictures. ## pictures.
@@ -10,6 +11,9 @@ extends SceneTree
## Writes [code]<state>.png[/code] for each state and [code]layout.json[/code] with where ## Writes [code]<state>.png[/code] for each state and [code]layout.json[/code] with where
## every node is in each state. With [code]--headless[/code] only the layout is written. ## every node is in each state. With [code]--headless[/code] only the layout is written.
## Compare two runs with [code]tools/compare_screenshots.py[/code]. ## Compare two runs with [code]tools/compare_screenshots.py[/code].
##
## The pictures use the default theme, or the theme with the given id from
## [code]Settings.THEMES[/code], e.g. [code]original_web[/code].
const SIZE: Vector2i = Vector2i(1920, 1080) const SIZE: Vector2i = Vector2i(1920, 1080)
@@ -37,6 +41,10 @@ func _take() -> void:
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport) root.add_child(viewport)
var main: Control = load("res://main/MainWindow.tscn").instantiate() var main: Control = load("res://main/MainWindow.tscn").instantiate()
if args.size() > 1:
# The game sets the root window's theme, which the viewport here is not under
var settings: Node = root.get_node("Settings")
main.theme = load(settings.theme_entry(args[1]).path)
viewport.add_child(main) viewport.add_child(main)
var layout: Dictionary = {} var layout: Dictionary = {}